mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 23:25:28 +00:00
1922 lines
67 KiB
TypeScript
Executable file
1922 lines
67 KiB
TypeScript
Executable file
#!/usr/bin/env bun
|
|
/**
|
|
* craft-cli — Terminal client for Qwen Code server.
|
|
*
|
|
* Connects over WebSocket (ws:// or wss://) to a running Qwen Code server
|
|
* and provides commands for listing resources, managing sessions, sending
|
|
* messages with real-time streaming, and validating server health.
|
|
*/
|
|
|
|
import { resolve } from 'path'
|
|
import { CliRpcClient } from './client.ts'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Arg parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CliArgs {
|
|
url: string
|
|
token: string
|
|
workspace?: string
|
|
timeout: number
|
|
json: boolean
|
|
tlsCa?: string
|
|
sendTimeout: number
|
|
command: string
|
|
rest: string[]
|
|
// run-specific flags
|
|
sources: string[]
|
|
mode: string
|
|
outputFormat: string
|
|
noCleanup: boolean
|
|
noSpinner: boolean
|
|
verbose: boolean
|
|
serverEntry?: string
|
|
workspaceDir?: string
|
|
// LLM configuration
|
|
provider: string
|
|
model: string
|
|
apiKey: string
|
|
baseUrl: string
|
|
}
|
|
|
|
export function parseArgs(argv: string[]): CliArgs {
|
|
const args = argv.slice(2) // skip bun + script path
|
|
let url = ''
|
|
let token = ''
|
|
let workspace: string | undefined
|
|
let timeout = 10_000
|
|
let json = false
|
|
let tlsCa: string | undefined
|
|
let sendTimeout = 300_000 // 5 min
|
|
const rest: string[] = []
|
|
let command = ''
|
|
const sources: string[] = []
|
|
let mode = ''
|
|
let outputFormat = 'text'
|
|
let noCleanup = false
|
|
let noSpinner = false
|
|
let verbose = false
|
|
let serverEntry: string | undefined
|
|
let workspaceDir: string | undefined
|
|
let provider = ''
|
|
let model = ''
|
|
let apiKey = ''
|
|
let baseUrl = ''
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i]
|
|
switch (arg) {
|
|
case '--url':
|
|
url = args[++i] ?? ''
|
|
break
|
|
case '--token':
|
|
token = args[++i] ?? ''
|
|
break
|
|
case '--workspace':
|
|
workspace = args[++i]
|
|
break
|
|
case '--timeout':
|
|
timeout = parseInt(args[++i] ?? '10000', 10)
|
|
break
|
|
case '--json':
|
|
json = true
|
|
break
|
|
case '--tls-ca':
|
|
tlsCa = args[++i]
|
|
break
|
|
case '--send-timeout':
|
|
sendTimeout = parseInt(args[++i] ?? '300000', 10)
|
|
break
|
|
case '--source':
|
|
sources.push(args[++i] ?? '')
|
|
break
|
|
case '--mode':
|
|
mode = args[++i] ?? ''
|
|
break
|
|
case '--output-format':
|
|
outputFormat = args[++i] ?? 'text'
|
|
break
|
|
case '--no-cleanup':
|
|
noCleanup = true
|
|
break
|
|
case '--disable-spinner':
|
|
case '--no-spinner':
|
|
noSpinner = true
|
|
break
|
|
case '--verbose':
|
|
case '-v':
|
|
verbose = true
|
|
break
|
|
case '--server-entry':
|
|
serverEntry = args[++i]
|
|
break
|
|
case '--workspace-dir':
|
|
workspaceDir = args[++i]
|
|
break
|
|
case '--provider':
|
|
provider = args[++i] ?? ''
|
|
break
|
|
case '--model':
|
|
model = args[++i] ?? ''
|
|
break
|
|
case '--api-key':
|
|
apiKey = args[++i] ?? ''
|
|
break
|
|
case '--base-url':
|
|
baseUrl = args[++i] ?? ''
|
|
break
|
|
case '--help':
|
|
case '-h':
|
|
command = 'help'
|
|
break
|
|
case '--version':
|
|
command = 'version'
|
|
break
|
|
case '--validate-server':
|
|
command = 'validate'
|
|
break
|
|
default:
|
|
if (!command && !arg.startsWith('-')) {
|
|
command = arg
|
|
} else {
|
|
rest.push(arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Env var fallbacks
|
|
if (!url) url = process.env.CRAFT_SERVER_URL ?? ''
|
|
if (!token) token = process.env.CRAFT_SERVER_TOKEN ?? ''
|
|
if (!tlsCa) tlsCa = process.env.CRAFT_TLS_CA
|
|
if (!provider) provider = process.env.LLM_PROVIDER ?? 'qwen'
|
|
if (!model) model = process.env.LLM_MODEL ?? ''
|
|
if (!apiKey) apiKey = process.env.LLM_API_KEY ?? ''
|
|
if (!baseUrl) baseUrl = process.env.LLM_BASE_URL ?? ''
|
|
|
|
return { url, token, workspace, timeout, json, tlsCa, sendTimeout, command, rest, sources, mode, outputFormat, noCleanup, noSpinner, verbose, serverEntry, workspaceDir, provider, model, apiKey, baseUrl }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto workspace resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function resolveWorkspace(
|
|
client: CliRpcClient,
|
|
explicit?: string,
|
|
): Promise<string | undefined> {
|
|
if (explicit) {
|
|
// Bind client to the workspace so push events reach us
|
|
await client.invoke('window:switchWorkspace', explicit).catch(() => {})
|
|
return explicit
|
|
}
|
|
try {
|
|
const workspaces = (await client.invoke('workspaces:get')) as any[]
|
|
if (workspaces?.length > 0) {
|
|
const id = workspaces[0].id
|
|
await client.invoke('window:switchWorkspace', id).catch(() => {})
|
|
return id
|
|
}
|
|
} catch {
|
|
// Fall through — workspace may not be needed
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function out(data: unknown, jsonMode: boolean): void {
|
|
if (jsonMode) {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n')
|
|
} else if (typeof data === 'string') {
|
|
process.stdout.write(data + '\n')
|
|
} else {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n')
|
|
}
|
|
}
|
|
|
|
function err(msg: string): void {
|
|
process.stderr.write(`Error: ${msg}\n`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ANSI colors (disabled when NO_COLOR is set or stdout is not a TTY)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false
|
|
const c = {
|
|
dim: (s: string) => _useColor ? `\x1b[2m${s}\x1b[22m` : s,
|
|
green: (s: string) => _useColor ? `\x1b[32m${s}\x1b[39m` : s,
|
|
red: (s: string) => _useColor ? `\x1b[31m${s}\x1b[39m` : s,
|
|
cyan: (s: string) => _useColor ? `\x1b[36m${s}\x1b[39m` : s,
|
|
bold: (s: string) => _useColor ? `\x1b[1m${s}\x1b[22m` : s,
|
|
yellow: (s: string) => _useColor ? `\x1b[33m${s}\x1b[39m` : s,
|
|
blue: (s: string) => _useColor ? `\x1b[34m${s}\x1b[39m` : s,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spinner (TTY only — skipped when piped or NO_COLOR)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
|
|
function createSpinner(text: string): { stop(): void } {
|
|
let i = 0
|
|
let stopped = false
|
|
// Render first frame immediately — setInterval alone misses fast steps
|
|
process.stdout.write(`${text} ${c.dim(_spinnerFrames[i++ % _spinnerFrames.length])}`)
|
|
const timer = setInterval(() => {
|
|
process.stdout.write(`\r\x1b[2K${text} ${c.dim(_spinnerFrames[i++ % _spinnerFrames.length])}`)
|
|
}, 80)
|
|
return {
|
|
stop() {
|
|
if (stopped) return
|
|
stopped = true
|
|
clearInterval(timer)
|
|
process.stdout.write('\r\x1b[2K')
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cmdPing(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const start = performance.now()
|
|
const clientId = await client.connect()
|
|
const latency = Math.round(performance.now() - start)
|
|
out(
|
|
args.json
|
|
? { clientId, latencyMs: latency }
|
|
: `Connected: clientId=${clientId} latency=${latency}ms`,
|
|
args.json,
|
|
)
|
|
}
|
|
|
|
async function cmdHealth(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const result = await client.invoke('credentials:healthCheck')
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdVersions(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const result = await client.invoke('system:versions')
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdWorkspaces(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const result = (await client.invoke('workspaces:get')) as any[]
|
|
if (args.json) {
|
|
out(result, true)
|
|
} else {
|
|
if (!result?.length) {
|
|
out('No workspaces found', false)
|
|
return
|
|
}
|
|
for (const ws of result) {
|
|
out(`${ws.id} ${ws.name ?? '(unnamed)'} ${ws.path ?? ''}`, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cmdSessions(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const workspaceId = await resolveWorkspace(client, args.workspace)
|
|
if (!workspaceId) {
|
|
err('No workspace available. Use --workspace <id>')
|
|
process.exit(1)
|
|
}
|
|
const result = (await client.invoke('sessions:get', workspaceId)) as any[]
|
|
if (args.json) {
|
|
out(result, true)
|
|
} else {
|
|
if (!result?.length) {
|
|
out('No sessions found', false)
|
|
return
|
|
}
|
|
for (const s of result) {
|
|
const name = s.name ?? '(unnamed)'
|
|
const preview = s.preview ? ` ${s.preview.slice(0, 60)}` : ''
|
|
const status = s.isProcessing ? ' [processing]' : ''
|
|
out(`${s.id} ${name}${preview}${status}`, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cmdConnections(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const result = await client.invoke('LLM_Connection:list')
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdSources(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const workspaceId = await resolveWorkspace(client, args.workspace)
|
|
if (!workspaceId) {
|
|
err('No workspace available. Use --workspace <id>')
|
|
process.exit(1)
|
|
}
|
|
const result = await client.invoke('sources:get', workspaceId)
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdSessionCreate(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
await client.connect()
|
|
const workspaceId = await resolveWorkspace(client, args.workspace)
|
|
if (!workspaceId) {
|
|
err('No workspace available. Use --workspace <id>')
|
|
process.exit(1)
|
|
}
|
|
|
|
// Parse sub-args: --name <n>
|
|
let name: string | undefined
|
|
for (let i = 0; i < args.rest.length; i++) {
|
|
if (args.rest[i] === '--name') name = args.rest[++i]
|
|
}
|
|
|
|
const opts: Record<string, unknown> = {}
|
|
if (name) opts.name = name
|
|
if (args.mode) opts.permissionMode = args.mode
|
|
|
|
const result = await client.invoke('sessions:create', workspaceId, opts)
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdSessionMessages(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const sessionId = args.rest[0]
|
|
if (!sessionId) {
|
|
err('Usage: session messages <session-id>')
|
|
process.exit(1)
|
|
}
|
|
await client.connect()
|
|
const result = await client.invoke('sessions:getMessages', sessionId)
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdSessionDelete(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const sessionId = args.rest[0]
|
|
if (!sessionId) {
|
|
err('Usage: session delete <session-id>')
|
|
process.exit(1)
|
|
}
|
|
await client.connect()
|
|
await client.invoke('sessions:delete', sessionId)
|
|
out(args.json ? { deleted: sessionId } : `Deleted session: ${sessionId}`, args.json)
|
|
}
|
|
|
|
/**
|
|
* Read prompt text from positional args + stdin.
|
|
* If there are positional words, they become the base message.
|
|
* Reads stdin when: --stdin flag is present, or no message and stdin is piped (not a TTY).
|
|
*/
|
|
async function readPrompt(words: string[], restArgs?: string[]): Promise<string> {
|
|
let message = words.join(' ')
|
|
|
|
const wantsStdin = restArgs?.includes('--stdin')
|
|
const isTTY = typeof process.stdin.isTTY === 'boolean' ? process.stdin.isTTY : false
|
|
if (wantsStdin || (!message && !isTTY)) {
|
|
const chunks: string[] = []
|
|
const reader = Bun.stdin.stream().getReader()
|
|
const decoder = new TextDecoder()
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
chunks.push(decoder.decode(value, { stream: true }))
|
|
}
|
|
const stdinText = chunks.join('')
|
|
message = message ? `${message}\n${stdinText}` : stdinText
|
|
}
|
|
|
|
return message
|
|
}
|
|
|
|
/**
|
|
* Subscribe to session events, send the message, stream output, wait for completion.
|
|
* Returns the exit code (0 = success, 1 = error, 130 = interrupted).
|
|
*/
|
|
async function sendAndStream(
|
|
client: CliRpcClient,
|
|
sessionId: string,
|
|
message: string,
|
|
args: CliArgs,
|
|
): Promise<number> {
|
|
let exitCode = 0
|
|
let finished = false
|
|
const streamJson = args.outputFormat === 'stream-json'
|
|
|
|
const unsub = client.on('session:event', (event: unknown) => {
|
|
const ev = event as { type: string; sessionId: string; [key: string]: unknown }
|
|
if (ev.sessionId !== sessionId) return
|
|
|
|
if (streamJson) {
|
|
process.stdout.write(JSON.stringify(ev) + '\n')
|
|
}
|
|
|
|
switch (ev.type) {
|
|
case 'text_delta':
|
|
if (!streamJson) process.stdout.write(ev.delta as string)
|
|
break
|
|
case 'tool_start':
|
|
if (!streamJson) process.stdout.write(`\n[tool: ${ev.toolName}${ev.toolIntent ? ` — ${ev.toolIntent}` : ''}]\n`)
|
|
break
|
|
case 'tool_result': {
|
|
if (!streamJson) {
|
|
const result = String(ev.result ?? '')
|
|
if (result.length > 200) {
|
|
process.stdout.write(`${result.slice(0, 200)}...\n`)
|
|
} else if (result) {
|
|
process.stdout.write(`${result}\n`)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'error':
|
|
if (!streamJson) err(String(ev.error))
|
|
exitCode = 1
|
|
finished = true
|
|
break
|
|
case 'complete':
|
|
if (!streamJson) process.stdout.write('\n')
|
|
finished = true
|
|
break
|
|
case 'interrupted':
|
|
if (!streamJson) process.stdout.write('\n[interrupted]\n')
|
|
exitCode = 130
|
|
finished = true
|
|
break
|
|
}
|
|
})
|
|
|
|
await client.invoke('sessions:sendMessage', sessionId, message)
|
|
|
|
const deadline = Date.now() + args.sendTimeout
|
|
while (!finished && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
|
|
unsub()
|
|
|
|
if (!finished) {
|
|
err('Send timeout — no completion event received')
|
|
exitCode = 1
|
|
}
|
|
|
|
return exitCode
|
|
}
|
|
|
|
async function cmdSend(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const sessionId = args.rest[0]
|
|
if (!sessionId) {
|
|
err('Usage: send <session-id> <message>')
|
|
process.exit(1)
|
|
}
|
|
|
|
const message = await readPrompt(args.rest.slice(1), args.rest)
|
|
if (!message.trim()) {
|
|
err('No message provided')
|
|
process.exit(1)
|
|
}
|
|
|
|
await client.connect()
|
|
const exitCode = await sendAndStream(client, sessionId, message, args)
|
|
client.destroy()
|
|
process.exit(exitCode)
|
|
}
|
|
|
|
interface LocalServer {
|
|
client: CliRpcClient
|
|
stop: () => Promise<void>
|
|
}
|
|
|
|
async function spawnLocalServer(args: CliArgs, opts?: { quiet?: boolean }): Promise<LocalServer> {
|
|
const { spawnServer } = await import('./server-spawner.ts')
|
|
process.stderr.write('Starting server...\n')
|
|
const server = await spawnServer({
|
|
serverEntry: args.serverEntry,
|
|
startupTimeout: args.timeout > 30_000 ? args.timeout : 30_000,
|
|
quiet: opts?.quiet,
|
|
})
|
|
process.stderr.write(`Server ready: ${server.url}\n`)
|
|
const client = new CliRpcClient(server.url, {
|
|
token: server.token,
|
|
requestTimeout: args.timeout,
|
|
})
|
|
return { client, stop: server.stop }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM connection helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getProviderDisplayName(provider: string): string {
|
|
return provider === 'qwen' ? 'Qwen Code' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
|
}
|
|
|
|
export function resolveApiKey(_provider: string, explicit: string): string {
|
|
return explicit
|
|
}
|
|
|
|
export function shouldSetupLlmConnection(existingConnectionCount: number, _args: Pick<CliArgs, 'provider' | 'baseUrl'>): boolean {
|
|
return existingConnectionCount === 0
|
|
}
|
|
|
|
async function setupLlmConnection(
|
|
client: CliRpcClient,
|
|
_args: CliArgs,
|
|
): Promise<{ connectionSlug: string }> {
|
|
const provider = 'qwen'
|
|
const connectionSlug = 'qwen-code'
|
|
|
|
await client.invoke('LLM_Connection:save', {
|
|
slug: connectionSlug,
|
|
name: getProviderDisplayName(provider),
|
|
providerType: 'qwen',
|
|
authType: 'none',
|
|
createdAt: Date.now(),
|
|
})
|
|
const setupResult = await client.invoke('settings:setupLlmConnection', { slug: connectionSlug }) as { success: boolean; error?: string }
|
|
if (!setupResult?.success) {
|
|
throw new Error(`LLM connection setup failed: ${setupResult?.error ?? 'unknown error'}`)
|
|
}
|
|
await client.invoke('LLM_Connection:setDefault', connectionSlug)
|
|
process.stderr.write('LLM connection configured: Qwen Code\n')
|
|
|
|
return { connectionSlug }
|
|
}
|
|
|
|
async function cmdRun(args: CliArgs): Promise<void> {
|
|
// Prompt = all positional args (no session ID needed, unlike send)
|
|
const message = await readPrompt(args.rest, args.rest)
|
|
if (!message.trim()) {
|
|
err('No prompt provided. Usage: run <message>')
|
|
process.exit(1)
|
|
}
|
|
|
|
const server = await spawnLocalServer(args)
|
|
|
|
let client: CliRpcClient | undefined = server.client
|
|
let sessionId: string | undefined
|
|
|
|
const cleanup = async () => {
|
|
if (sessionId && client?.isConnected && !args.noCleanup) {
|
|
await client.invoke('sessions:delete', sessionId).catch(() => {})
|
|
}
|
|
client?.destroy()
|
|
await server.stop()
|
|
}
|
|
|
|
// Signal handling — cancel + clean up on SIGINT/SIGTERM
|
|
const onSignal = async () => {
|
|
if (sessionId && client?.isConnected) {
|
|
await client.invoke('sessions:cancel', sessionId).catch(() => {})
|
|
}
|
|
await cleanup()
|
|
process.exit(130)
|
|
}
|
|
process.on('SIGINT', onSignal)
|
|
process.on('SIGTERM', onSignal)
|
|
|
|
try {
|
|
await client.connect()
|
|
|
|
// Bootstrap workspace from directory if specified
|
|
let bootstrappedWorkspaceId: string | undefined
|
|
if (args.workspaceDir) {
|
|
const absPath = resolve(args.workspaceDir)
|
|
const ws = (await client.invoke('workspaces:create', absPath, 'ci-workspace')) as { id: string }
|
|
bootstrappedWorkspaceId = ws.id
|
|
process.stderr.write(`Workspace registered: ${absPath}\n`)
|
|
}
|
|
|
|
// Auto-setup the Qwen Code connection when no connection exists yet.
|
|
const connections = (await client.invoke('LLM_Connection:list')) as any[]
|
|
let connectionSlug: string | undefined
|
|
if (shouldSetupLlmConnection(connections?.length ?? 0, args)) {
|
|
const result = await setupLlmConnection(client, args)
|
|
connectionSlug = result.connectionSlug
|
|
}
|
|
|
|
const workspaceId = bootstrappedWorkspaceId
|
|
?? await resolveWorkspace(client, args.workspace)
|
|
if (bootstrappedWorkspaceId) {
|
|
await client.invoke('window:switchWorkspace', bootstrappedWorkspaceId).catch(() => {})
|
|
}
|
|
if (!workspaceId) {
|
|
err('No workspace found on server')
|
|
process.exit(1)
|
|
}
|
|
|
|
const session = (await client.invoke('sessions:create', workspaceId, {
|
|
permissionMode: args.mode || 'allow-all',
|
|
enabledSourceSlugs: args.sources.length > 0 ? args.sources : undefined,
|
|
slugHint: message,
|
|
})) as { id: string }
|
|
sessionId = session.id
|
|
|
|
if (args.model) {
|
|
await client.invoke('session:setModel', sessionId, workspaceId, args.model, connectionSlug)
|
|
}
|
|
|
|
const exitCode = await sendAndStream(client, sessionId, message, args)
|
|
await cleanup()
|
|
process.exit(exitCode)
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
err(msg)
|
|
await cleanup()
|
|
process.exit(1)
|
|
} finally {
|
|
process.off('SIGINT', onSignal)
|
|
process.off('SIGTERM', onSignal)
|
|
}
|
|
}
|
|
|
|
async function cmdValidate(args: CliArgs): Promise<void> {
|
|
let server: LocalServer | undefined
|
|
let client: CliRpcClient
|
|
|
|
// Use a generous timeout for validation steps — source creation and MCP
|
|
// server startup can be slow on Windows.
|
|
const validateArgs = { ...args, timeout: Math.max(args.timeout, 30_000) }
|
|
|
|
if (args.url) {
|
|
client = new CliRpcClient(args.url, {
|
|
token: args.token || undefined,
|
|
requestTimeout: validateArgs.timeout,
|
|
connectTimeout: validateArgs.timeout,
|
|
})
|
|
} else {
|
|
server = await spawnLocalServer(validateArgs, { quiet: !args.verbose })
|
|
client = server.client
|
|
}
|
|
|
|
try {
|
|
const exitCode = await runValidation(client, args.json, args.noSpinner, args.workspaceDir, {
|
|
baseUrl: args.baseUrl,
|
|
apiKey: args.apiKey,
|
|
provider: args.provider,
|
|
})
|
|
client.destroy()
|
|
if (server) await server.stop()
|
|
process.exit(exitCode)
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
err(msg)
|
|
client.destroy()
|
|
if (server) await server.stop()
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
async function cmdCancel(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const sessionId = args.rest[0]
|
|
if (!sessionId) {
|
|
err('Usage: cancel <session-id>')
|
|
process.exit(1)
|
|
}
|
|
await client.connect()
|
|
await client.invoke('sessions:cancel', sessionId)
|
|
out(args.json ? { cancelled: sessionId } : `Cancelled: ${sessionId}`, args.json)
|
|
}
|
|
|
|
async function cmdInvoke(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const channel = args.rest[0]
|
|
if (!channel) {
|
|
err('Usage: invoke <channel> [json-args...]')
|
|
process.exit(1)
|
|
}
|
|
await client.connect()
|
|
|
|
// Parse remaining args as JSON
|
|
const invokeArgs: unknown[] = []
|
|
for (let i = 1; i < args.rest.length; i++) {
|
|
try {
|
|
invokeArgs.push(JSON.parse(args.rest[i]))
|
|
} catch {
|
|
invokeArgs.push(args.rest[i])
|
|
}
|
|
}
|
|
|
|
const result = await client.invoke(channel, ...invokeArgs)
|
|
out(result, args.json)
|
|
}
|
|
|
|
async function cmdListen(client: CliRpcClient, args: CliArgs): Promise<void> {
|
|
const channel = args.rest[0]
|
|
if (!channel) {
|
|
err('Usage: listen <channel>')
|
|
process.exit(1)
|
|
}
|
|
await client.connect()
|
|
|
|
client.on(channel, (...eventArgs: unknown[]) => {
|
|
out({ channel, args: eventArgs, timestamp: new Date().toISOString() }, true)
|
|
})
|
|
|
|
process.stdout.write(`Listening on ${channel} (Ctrl+C to stop)\n`)
|
|
|
|
// Keep alive
|
|
await new Promise(() => {
|
|
// Never resolves — Ctrl+C exits
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validate server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ValidateStep {
|
|
name: string
|
|
fn: (client: CliRpcClient, ctx: ValidateContext) => Promise<string>
|
|
}
|
|
|
|
export interface ValidateContext {
|
|
/** Pre-existing workspace directory (from --workspace-dir) */
|
|
workspaceDir?: string
|
|
/** Custom endpoint URL (from --base-url) */
|
|
baseUrl?: string
|
|
/** API key override (from --api-key) */
|
|
apiKey?: string
|
|
/** Provider hint (from --provider, default 'qwen') */
|
|
provider?: string
|
|
workspaceId?: string
|
|
workspaceRootPath?: string
|
|
createdWorkspace?: boolean
|
|
createdSessionId?: string
|
|
createdSourceSlug?: string
|
|
createdSkillSlug?: string
|
|
createdAutomation?: boolean
|
|
automationTestSessionId?: string
|
|
/** Session created by automation that should be blocked by failing condition (if bug occurs) */
|
|
automationBlockedSessionId?: string
|
|
automationName?: string
|
|
automationBlockedName?: string
|
|
createdLabelId?: string
|
|
/** Backup of existing automations.json before overwrite (undefined = didn't exist) */
|
|
automationsJsonBackup?: string | null
|
|
/** Backup of existing automations-history.jsonl before overwrite (undefined = didn't exist) */
|
|
automationsHistoryBackup?: string | null
|
|
branchedSessionId?: string
|
|
/** Label ID for e2e-test label created for session tool validation */
|
|
e2eTestLabelId?: string
|
|
onEvent?: (ev: { type: string; [key: string]: unknown }) => void
|
|
}
|
|
|
|
/** Minimal shapes for RPC responses used in validation steps. */
|
|
interface ValidateStatus {
|
|
id?: string
|
|
label?: string
|
|
}
|
|
|
|
interface ValidateSession {
|
|
id: string
|
|
name?: string
|
|
labels?: string[]
|
|
}
|
|
|
|
interface ValidateLabel {
|
|
id?: string
|
|
name?: string
|
|
}
|
|
|
|
interface ValidateMessageBlock {
|
|
type: string
|
|
text?: string
|
|
}
|
|
|
|
interface ValidateMessage {
|
|
role: string
|
|
content: string | ValidateMessageBlock[]
|
|
}
|
|
|
|
interface ValidateMessagesResponse {
|
|
messages?: ValidateMessage[]
|
|
conversation?: ValidateMessage[]
|
|
}
|
|
|
|
/**
|
|
* Send a message and wait for streaming events.
|
|
* Returns a summary of received event types.
|
|
* If expectTool is true, validates that tool_start + tool_result events arrived.
|
|
*/
|
|
async function waitForSendEvents(
|
|
client: CliRpcClient,
|
|
sessionId: string,
|
|
message: string,
|
|
timeoutMs: number,
|
|
expectTool: boolean,
|
|
sendOptions?: Record<string, unknown>,
|
|
onEvent?: (ev: { type: string; [key: string]: unknown }) => void,
|
|
expectToolName?: string,
|
|
): Promise<string> {
|
|
const seen = new Set<string>()
|
|
let textChunks = 0
|
|
let toolName = ''
|
|
let finished = false
|
|
|
|
const unsub = client.on('session:event', (event: unknown) => {
|
|
const ev = event as { type: string; sessionId: string; [key: string]: unknown }
|
|
if (ev.sessionId !== sessionId) return
|
|
|
|
seen.add(ev.type)
|
|
if (ev.type === 'text_delta') textChunks++
|
|
if (ev.type === 'tool_start') toolName = String(ev.toolName ?? '')
|
|
if (ev.type === 'complete' || ev.type === 'error' || ev.type === 'interrupted') {
|
|
finished = true
|
|
}
|
|
onEvent?.(ev)
|
|
})
|
|
|
|
try {
|
|
await client.invoke('sessions:sendMessage', sessionId, message,
|
|
undefined, undefined, sendOptions)
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
while (!finished && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
|
|
if (!finished) throw new Error('Timed out waiting for completion')
|
|
|
|
// Only treat as failure if error was the terminal event (no complete followed)
|
|
if (seen.has('error') && !seen.has('complete')) throw new Error('Session returned an error event')
|
|
|
|
if (expectTool) {
|
|
if (!seen.has('tool_start')) throw new Error('No tool_start event received')
|
|
if (!seen.has('tool_result')) throw new Error('No tool_result event received')
|
|
if (expectToolName && !toolName.includes(expectToolName)) {
|
|
throw new Error(`Expected tool containing "${expectToolName}", got "${toolName}"`)
|
|
}
|
|
return `tool=${toolName}, ${textChunks} text deltas, events: ${[...seen].join(', ')}`
|
|
}
|
|
|
|
if (!seen.has('text_delta')) throw new Error('No text_delta events received')
|
|
return `${textChunks} text deltas, events: ${[...seen].join(', ')}`
|
|
} finally {
|
|
unsub()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up automation test artifacts (config files, session, label).
|
|
* Shared between the automation:cleanup test step and runValidation error recovery.
|
|
*/
|
|
async function cleanupAutomationArtifacts(
|
|
client: CliRpcClient,
|
|
ctx: ValidateContext,
|
|
): Promise<string[]> {
|
|
const cleaned: string[] = []
|
|
|
|
// Restore or remove automation config files
|
|
if (ctx.workspaceRootPath && ctx.createdAutomation) {
|
|
try {
|
|
const { writeFile, unlink } = await import('fs/promises')
|
|
const configPath = `${ctx.workspaceRootPath}/automations.json`
|
|
const historyPath = `${ctx.workspaceRootPath}/automations-history.jsonl`
|
|
if (ctx.automationsJsonBackup != null) {
|
|
await writeFile(configPath, ctx.automationsJsonBackup).catch(() => {})
|
|
cleaned.push('automations.json (restored)')
|
|
} else {
|
|
await unlink(configPath).catch(() => {})
|
|
cleaned.push('automations.json (removed)')
|
|
}
|
|
if (ctx.automationsHistoryBackup != null) {
|
|
await writeFile(historyPath, ctx.automationsHistoryBackup).catch(() => {})
|
|
} else {
|
|
await unlink(historyPath).catch(() => {})
|
|
}
|
|
ctx.createdAutomation = false
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
// Delete automation-triggered sessions
|
|
for (const key of ['automationTestSessionId', 'automationBlockedSessionId'] as const) {
|
|
const id = ctx[key]
|
|
if (!id || !client.isConnected) continue
|
|
try {
|
|
await client.invoke('sessions:delete', id)
|
|
cleaned.push(`session ${id}`)
|
|
ctx[key] = undefined
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
// Delete test label
|
|
if (ctx.workspaceId && ctx.createdLabelId && client.isConnected) {
|
|
try {
|
|
await client.invoke('labels:delete', ctx.workspaceId, ctx.createdLabelId)
|
|
cleaned.push(`label ${ctx.createdLabelId}`)
|
|
ctx.createdLabelId = undefined
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
return cleaned
|
|
}
|
|
|
|
export function getValidateSteps(): ValidateStep[] {
|
|
return [
|
|
{
|
|
name: 'Connect + handshake',
|
|
fn: async (client) => {
|
|
const start = performance.now()
|
|
const clientId = await client.connect()
|
|
const ms = Math.round(performance.now() - start)
|
|
return `clientId: ${clientId}, ${ms}ms`
|
|
},
|
|
},
|
|
{
|
|
name: 'credentials:healthCheck',
|
|
fn: async (client) => {
|
|
const r = (await client.invoke('credentials:healthCheck')) as any
|
|
return JSON.stringify(r)
|
|
},
|
|
},
|
|
{
|
|
name: 'system:versions',
|
|
fn: async (client) => {
|
|
const r = (await client.invoke('system:versions')) as any
|
|
return r?.node ? `node=${r.node}` : JSON.stringify(r)
|
|
},
|
|
},
|
|
{
|
|
name: 'system:homeDir',
|
|
fn: async (client) => {
|
|
const r = await client.invoke('system:homeDir')
|
|
return String(r)
|
|
},
|
|
},
|
|
{
|
|
name: 'workspaces:get',
|
|
fn: async (client, ctx) => {
|
|
// Register workspace from --workspace-dir if provided
|
|
if (ctx.workspaceDir) {
|
|
const { resolve } = await import('path')
|
|
const absPath = resolve(ctx.workspaceDir)
|
|
const ws = (await client.invoke('workspaces:create', absPath, 'ci-workspace')) as { id: string }
|
|
ctx.workspaceId = ws.id
|
|
ctx.workspaceRootPath = absPath
|
|
await client.invoke('window:switchWorkspace', ws.id)
|
|
return `registered: ${absPath}`
|
|
}
|
|
const r = (await client.invoke('workspaces:get')) as any[]
|
|
if (r?.length > 0) {
|
|
ctx.workspaceId = r[0].id
|
|
ctx.workspaceRootPath = r[0].rootPath ?? r[0].path
|
|
// Bind this client to the workspace so push events (e.g. session:event)
|
|
// routed { to: 'workspace' } reach us.
|
|
await client.invoke('window:switchWorkspace', r[0].id)
|
|
return `${r.length} workspaces`
|
|
}
|
|
// Auto-bootstrap a temp workspace for CI environments
|
|
const { mkdtemp } = await import('fs/promises')
|
|
const { tmpdir } = await import('os')
|
|
const tmpDir = await mkdtemp(`${tmpdir()}/craft-validate-`)
|
|
const ws = (await client.invoke('workspaces:create', tmpDir, 'validate-workspace')) as { id: string }
|
|
ctx.workspaceId = ws.id
|
|
ctx.workspaceRootPath = tmpDir
|
|
ctx.createdWorkspace = true
|
|
await client.invoke('window:switchWorkspace', ws.id)
|
|
return `0 found → created temp workspace`
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:get',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const r = (await client.invoke('sessions:get', ctx.workspaceId)) as any[]
|
|
return `${r?.length ?? 0} sessions`
|
|
},
|
|
},
|
|
{
|
|
name: 'LLM_Connection:list',
|
|
fn: async (client, ctx) => {
|
|
const r = (await client.invoke('LLM_Connection:list')) as any[]
|
|
const provider = ctx.provider || 'qwen'
|
|
if (!shouldSetupLlmConnection(r?.length ?? 0, { provider, baseUrl: ctx.baseUrl ?? '' })) {
|
|
return `${r.length} connections`
|
|
}
|
|
const slug = 'qwen-code'
|
|
await client.invoke('LLM_Connection:save', {
|
|
slug,
|
|
name: 'Qwen Code',
|
|
providerType: 'qwen',
|
|
authType: 'none',
|
|
createdAt: Date.now(),
|
|
})
|
|
const result = await client.invoke('settings:setupLlmConnection', { slug }) as { success: boolean; error?: string }
|
|
if (!result?.success) return `setup failed: ${result?.error ?? 'unknown'}`
|
|
await client.invoke('LLM_Connection:setDefault', slug)
|
|
return '0 found -> created Qwen Code connection'
|
|
},
|
|
},
|
|
{
|
|
name: 'sources:get',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const r = (await client.invoke('sources:get', ctx.workspaceId)) as any[]
|
|
return `${r?.length ?? 0} sources`
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:create',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const name = `__cli-validate-${Date.now()}`
|
|
const r = (await client.invoke('sessions:create', ctx.workspaceId, {
|
|
name,
|
|
permissionMode: 'allow-all',
|
|
})) as any
|
|
ctx.createdSessionId = r?.id
|
|
return ctx.createdSessionId ?? 'created'
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:getMessages',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
await client.invoke('sessions:getMessages', ctx.createdSessionId)
|
|
return 'session readable'
|
|
},
|
|
},
|
|
{
|
|
name: 'send message + stream',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
'Reply with exactly: VALIDATION_OK', 60_000, false, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
{
|
|
name: 'send message + tool use',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
'Use the Bash tool to run: echo TOOL_VALIDATION_OK', 90_000, true, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
// ----- Session tool validation (guards against #511 regression) -----
|
|
{
|
|
name: 'labels:create (e2e-test)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const r = (await client.invoke('labels:create', ctx.workspaceId, {
|
|
name: 'e2e-test',
|
|
color: 'gray',
|
|
})) as any
|
|
ctx.e2eTestLabelId = r?.id
|
|
return `label created: ${r?.id}`
|
|
},
|
|
},
|
|
{
|
|
name: 'session-tools:set_session_labels',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
if (!ctx.e2eTestLabelId) return 'skipped (no e2e-test label)'
|
|
const result = await waitForSendEvents(client, ctx.createdSessionId,
|
|
'Use the set_session_labels tool to set labels: ["e2e-test"] on the current session. Do NOT use any other tool.',
|
|
90_000, true, undefined, ctx.onEvent, 'set_session_labels')
|
|
// Verify labels were actually applied
|
|
const sessions = (await client.invoke('sessions:get', ctx.workspaceId)) as any[]
|
|
const session = sessions?.find((s: any) => s.id === ctx.createdSessionId)
|
|
const labels = session?.labels ?? session?.labelIds ?? []
|
|
if (!labels.length) throw new Error('Labels not applied to session')
|
|
return `${result} — labels verified: ${JSON.stringify(labels)}`
|
|
},
|
|
},
|
|
{
|
|
name: 'session-tools:get_session_info',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
'Use the get_session_info tool to get info about the current session. Do NOT use any other tool.',
|
|
90_000, true, undefined, ctx.onEvent, 'get_session_info')
|
|
},
|
|
},
|
|
{
|
|
name: 'session-tools:list_sessions',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
'Use the list_sessions tool to list all sessions. Do NOT use any other tool.',
|
|
90_000, true, undefined, ctx.onEvent, 'list_sessions')
|
|
},
|
|
},
|
|
// ----- Session branching -----
|
|
{
|
|
name: 'sessions:branch',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.workspaceId) return 'skipped (no session)'
|
|
const r = (await client.invoke('sessions:getMessages', ctx.createdSessionId)) as ValidateMessagesResponse
|
|
const messages = r?.messages ?? r?.conversation ?? []
|
|
const firstAssistant = messages.find((m) => m.role === 'assistant') as any
|
|
if (!firstAssistant?.id) throw new Error('No assistant message found to branch from')
|
|
const branch = (await client.invoke('sessions:create', ctx.workspaceId, {
|
|
name: `__cli-validate-branch-${Date.now()}`,
|
|
permissionMode: 'allow-all',
|
|
branchFromSessionId: ctx.createdSessionId,
|
|
branchFromMessageId: firstAssistant.id,
|
|
})) as any
|
|
ctx.branchedSessionId = branch?.id
|
|
return `branched at message ${firstAssistant.id} → session ${branch?.id}`
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:branch verify',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.branchedSessionId) return 'skipped (no branch)'
|
|
const r = (await client.invoke('sessions:getMessages', ctx.branchedSessionId)) as ValidateMessagesResponse
|
|
const messages = r?.messages ?? r?.conversation ?? []
|
|
const hasAssistant = messages.some((m) => m.role === 'assistant')
|
|
if (!hasAssistant) throw new Error('Branch missing assistant message')
|
|
const origR = (await client.invoke('sessions:getMessages', ctx.createdSessionId!)) as ValidateMessagesResponse
|
|
const origMessages = origR?.messages ?? origR?.conversation ?? []
|
|
if (messages.length >= origMessages.length) {
|
|
throw new Error(`Branch has ${messages.length} messages, expected fewer than original (${origMessages.length})`)
|
|
}
|
|
return `branch has ${messages.length} messages (original has ${origMessages.length})`
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:branch send',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.branchedSessionId) return 'skipped (no branch)'
|
|
return await waitForSendEvents(client, ctx.branchedSessionId,
|
|
'Reply with exactly: BRANCH_OK', 60_000, false, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
// ----- Source lifecycle -----
|
|
{
|
|
name: 'sources:create',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const r = (await client.invoke('sources:create', ctx.workspaceId, {
|
|
name: 'Cat Facts',
|
|
provider: 'catfact',
|
|
type: 'api',
|
|
api: { baseUrl: 'https://catfact.ninja', authType: 'none' },
|
|
icon: '🐱',
|
|
})) as any
|
|
ctx.createdSourceSlug = r?.slug
|
|
return ctx.createdSourceSlug ? `slug=${ctx.createdSourceSlug}` : JSON.stringify(r)
|
|
},
|
|
},
|
|
{
|
|
name: 'send + source mention',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.createdSourceSlug) return 'skipped (no session or source)'
|
|
// Enable the source on the session
|
|
await client.invoke('sessions:command', ctx.createdSessionId, {
|
|
type: 'setSources',
|
|
sourceSlugs: [ctx.createdSourceSlug],
|
|
})
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
`[source:${ctx.createdSourceSlug}] Get me a cat fact`, 90_000, false, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
// ----- MCP source validation (pre-committed in .github/agents/sources/) -----
|
|
{
|
|
name: 'mcp:craft-public (auth:none)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
// Enable the pre-committed craft-public MCP source on the session
|
|
const enableSlugs = [ctx.createdSourceSlug, 'craft-public'].filter(Boolean) as string[]
|
|
await client.invoke('sessions:command', ctx.createdSessionId, {
|
|
type: 'setSources',
|
|
sourceSlugs: enableSlugs,
|
|
})
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
`[source:craft-public] List the documents under the "CraftAgents E2E Test" folder inside the "CraftAgents" folder. Just list their names.`,
|
|
180_000, false, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
{
|
|
name: 'mcp:stitch-mcp (header-auth)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
const apiKey = process.env.STITCH_API_KEY
|
|
if (!apiKey) return 'skipped (no STITCH_API_KEY)'
|
|
// Inject credential into store (multi-header JSON format, same as API headerNames)
|
|
await client.invoke('sources:saveCredentials', ctx.workspaceId, 'stitch-mcp', JSON.stringify({ 'X-Goog-Api-Key': apiKey }))
|
|
// Enable stitch-mcp + existing sources on session
|
|
const enableSlugs = [ctx.createdSourceSlug, 'craft-public', 'stitch-mcp'].filter(Boolean) as string[]
|
|
await client.invoke('sessions:command', ctx.createdSessionId, {
|
|
type: 'setSources',
|
|
sourceSlugs: enableSlugs,
|
|
})
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
`Use the source_test tool to test the stitch-mcp source. Report the result.`,
|
|
90_000, false, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
// ----- Skill lifecycle -----
|
|
{
|
|
name: 'send + skill create',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.workspaceRootPath) return 'skipped (no session or workspace)'
|
|
ctx.createdSkillSlug = '__cli-validate-skill'
|
|
const sourceSlug = ctx.createdSourceSlug ?? 'cat-facts'
|
|
const skillDir = `${ctx.workspaceRootPath}/skills/${ctx.createdSkillSlug}`
|
|
// Use bash to create the skill file deterministically
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
`Use the Bash tool to run this exact command:
|
|
mkdir -p "${skillDir}" && cat > "${skillDir}/SKILL.md" << 'SKILLEOF'
|
|
---
|
|
name: "CLI Validate Skill"
|
|
description: "Validation skill created by craft-cli"
|
|
requiredSources:
|
|
- "${sourceSlug}"
|
|
---
|
|
|
|
This skill does two things:
|
|
1. Check the current water temperature of Lake Balaton (search the web or estimate based on the season)
|
|
2. Use the Cat Facts source to get a random cat fact
|
|
|
|
Always perform both steps when this skill is invoked.
|
|
SKILLEOF`, 90_000, true, undefined, ctx.onEvent)
|
|
},
|
|
},
|
|
{
|
|
name: 'skills:get (verify)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId || !ctx.createdSkillSlug) return 'skipped (no skill)'
|
|
const r = (await client.invoke('skills:get', ctx.workspaceId)) as any[]
|
|
const found = r?.find((s: any) => s.slug === ctx.createdSkillSlug)
|
|
if (!found) throw new Error(`Skill '${ctx.createdSkillSlug}' not found in skills list`)
|
|
return `found: ${found.name ?? found.slug}`
|
|
},
|
|
},
|
|
{
|
|
name: 'send + skill mention',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.createdSkillSlug) return 'skipped (no session or skill)'
|
|
return await waitForSendEvents(client, ctx.createdSessionId,
|
|
`[skill:${ctx.createdSkillSlug}] Run the skill`, 120_000, false,
|
|
{ skillSlugs: [ctx.createdSkillSlug] }, ctx.onEvent)
|
|
},
|
|
},
|
|
{
|
|
name: 'skills:delete',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId || !ctx.createdSkillSlug) return 'skipped (no skill)'
|
|
await client.invoke('skills:delete', ctx.workspaceId, ctx.createdSkillSlug)
|
|
return `deleted skill: ${ctx.createdSkillSlug}`
|
|
},
|
|
},
|
|
// ----- Automation lifecycle -----
|
|
{
|
|
name: 'automation:create',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.workspaceRootPath) return 'skipped (no session or workspace)'
|
|
const configPath = `${ctx.workspaceRootPath}/automations.json`
|
|
const historyPath = `${ctx.workspaceRootPath}/automations-history.jsonl`
|
|
const { readFile, writeFile } = await import('fs/promises')
|
|
|
|
// Always backup + overwrite with deterministic validation config,
|
|
// then restore during cleanup.
|
|
const existingConfig = await readFile(configPath, 'utf-8').catch(() => null)
|
|
ctx.automationsJsonBackup = existingConfig
|
|
ctx.automationsHistoryBackup = await readFile(historyPath, 'utf-8').catch(() => null)
|
|
|
|
const templatePath = `${process.cwd()}/.github/agents/automations.json`
|
|
const templateConfig = await readFile(templatePath, 'utf-8').catch(() => null)
|
|
if (!templateConfig) {
|
|
throw new Error(`Missing automation template at ${templatePath}`)
|
|
}
|
|
|
|
const parsed = JSON.parse(templateConfig) as {
|
|
automations?: { SessionStatusChange?: Array<{ name?: string }> }
|
|
}
|
|
const entries = parsed?.automations?.SessionStatusChange
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
throw new Error('Automation template missing automations.SessionStatusChange entries')
|
|
}
|
|
|
|
const blocked = entries.find((e) => e.name === 'CLI Validate Condition Blocked')
|
|
const pass = entries.find((e) => e.name === 'CLI Validate Condition Pass')
|
|
if (!blocked?.name || !pass?.name) {
|
|
throw new Error('Automation template must define both "CLI Validate Condition Blocked" and "CLI Validate Condition Pass"')
|
|
}
|
|
|
|
ctx.automationBlockedName = blocked.name
|
|
ctx.automationName = pass.name
|
|
|
|
await writeFile(configPath, templateConfig)
|
|
ctx.createdAutomation = true
|
|
// ConfigWatcher auto-detects automations.json changes (debounced)
|
|
await new Promise((r) => setTimeout(r, 2000))
|
|
return `wrote config from template (blocked=${ctx.automationBlockedName}, pass=${ctx.automationName})`
|
|
},
|
|
},
|
|
{
|
|
name: 'automation:trigger (status change)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId || !ctx.workspaceId) return 'skipped (no session or workspace)'
|
|
// Get available statuses to find one containing "in-progress"
|
|
const statuses = (await client.invoke('statuses:list', ctx.workspaceId)) as ValidateStatus[]
|
|
const inProgress = statuses?.find((s) =>
|
|
(s.id ?? '').toLowerCase().includes('in-progress') ||
|
|
(s.label ?? '').toLowerCase().includes('in progress')
|
|
)
|
|
const statusValue = inProgress?.id ?? 'in-progress'
|
|
|
|
// Change session status to trigger the automations
|
|
await client.invoke('sessions:command', ctx.createdSessionId, {
|
|
type: 'setSessionStatus',
|
|
state: statusValue,
|
|
})
|
|
|
|
// Poll for expected automation behavior:
|
|
// - pass automation MUST create a session
|
|
// - blocked automation MUST NOT create a session
|
|
let delay = 1000
|
|
const deadline = Date.now() + 60_000
|
|
while (Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, delay))
|
|
delay = Math.min(delay * 1.5, 10_000)
|
|
const sessions = (await client.invoke('sessions:get', ctx.workspaceId)) as ValidateSession[]
|
|
|
|
const blockedSession = sessions?.find((s) =>
|
|
s.name === ctx.automationBlockedName && s.id !== ctx.createdSessionId
|
|
)
|
|
if (blockedSession) {
|
|
ctx.automationBlockedSessionId = blockedSession.id
|
|
throw new Error(`Blocked automation unexpectedly triggered (session=${blockedSession.id})`)
|
|
}
|
|
|
|
const passSession = sessions?.find((s) =>
|
|
s.name === ctx.automationName && s.id !== ctx.createdSessionId
|
|
)
|
|
if (passSession) {
|
|
ctx.automationTestSessionId = passSession.id
|
|
|
|
// Guard against delayed blocked-automation session creation.
|
|
await new Promise((r) => setTimeout(r, 2000))
|
|
const sessionsAfter = (await client.invoke('sessions:get', ctx.workspaceId)) as ValidateSession[]
|
|
const blockedAfter = sessionsAfter?.find((s) =>
|
|
s.name === ctx.automationBlockedName && s.id !== ctx.createdSessionId
|
|
)
|
|
if (blockedAfter) {
|
|
ctx.automationBlockedSessionId = blockedAfter.id
|
|
throw new Error(`Blocked automation unexpectedly triggered after delay (session=${blockedAfter.id})`)
|
|
}
|
|
|
|
return `pass triggered → session ${passSession.id}; blocked automation did not trigger (status=${statusValue})`
|
|
}
|
|
}
|
|
throw new Error('Passing automation-created session not found within 60s')
|
|
},
|
|
},
|
|
{
|
|
name: 'automation:verify session',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.automationTestSessionId) return 'skipped (no automation session)'
|
|
// Wait for the automation session to complete
|
|
let delay = 1000
|
|
const deadline = Date.now() + 90_000
|
|
while (Date.now() < deadline) {
|
|
const session = (await client.invoke('sessions:getMessages', ctx.automationTestSessionId)) as ValidateMessagesResponse
|
|
const messages = session?.messages ?? session?.conversation ?? []
|
|
const hasAssistant = messages.some((m) => m.role === 'assistant')
|
|
if (hasAssistant) {
|
|
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant')
|
|
const text = typeof lastAssistant?.content === 'string'
|
|
? lastAssistant.content
|
|
: Array.isArray(lastAssistant?.content)
|
|
? lastAssistant.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join(' ')
|
|
: ''
|
|
return `session has assistant response (${text.slice(0, 80).trim()})`
|
|
}
|
|
await new Promise((r) => setTimeout(r, delay))
|
|
delay = Math.min(delay * 1.5, 10_000)
|
|
}
|
|
throw new Error('Automation session did not complete within 90s')
|
|
},
|
|
},
|
|
{
|
|
name: 'automation:verify labels',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.automationTestSessionId || !ctx.workspaceId) return 'skipped (no automation session)'
|
|
// Verify label was auto-created
|
|
const labels = (await client.invoke('labels:list', ctx.workspaceId)) as ValidateLabel[]
|
|
const found = labels?.find((l) => (l.id ?? l.name ?? '') === 'cli-validate-label')
|
|
if (!found) throw new Error('Label cli-validate-label was not auto-created')
|
|
ctx.createdLabelId = found.id ?? 'cli-validate-label'
|
|
|
|
// Verify the automation session has the label
|
|
const sessions = (await client.invoke('sessions:get', ctx.workspaceId)) as ValidateSession[]
|
|
const automationSession = sessions?.find((s) => s.id === ctx.automationTestSessionId)
|
|
const sessionLabels: string[] = automationSession?.labels ?? []
|
|
const hasLabel = sessionLabels.some((l: string) => l.includes('cli-validate-label'))
|
|
if (!hasLabel) throw new Error(`Automation session missing label (has: ${sessionLabels.join(', ')})`)
|
|
return `label created and assigned: ${ctx.createdLabelId}`
|
|
},
|
|
},
|
|
{
|
|
name: 'automations:getLastExecuted',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const history = (await client.invoke('automations:getLastExecuted', ctx.workspaceId)) as Record<string, number>
|
|
const entries = Object.entries(history)
|
|
if (entries.length === 0) throw new Error('No automation execution history found')
|
|
// Verify at least one automation ran recently (within last 2 minutes)
|
|
const recentThreshold = Date.now() - 120_000
|
|
const recent = entries.find(([, ts]) => ts > recentThreshold)
|
|
if (!recent) throw new Error(`No recent automation execution (latest: ${Math.max(...entries.map(([, ts]) => ts))})`)
|
|
return `${entries.length} automation(s), latest ran ${Math.round((Date.now() - recent[1]) / 1000)}s ago`
|
|
},
|
|
},
|
|
// ----- Webhook validation -----
|
|
{
|
|
name: 'webhook:test (RPC)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId) return 'skipped (no workspace)'
|
|
const r = (await client.invoke('automations:test', {
|
|
workspaceId: ctx.workspaceId,
|
|
actions: [{
|
|
type: 'webhook',
|
|
url: 'http://127.0.0.1:19999/validate-test',
|
|
method: 'GET',
|
|
}],
|
|
})) as any
|
|
const result = r?.actions?.[0]
|
|
if (result?.success) throw new Error('Expected webhook to fail (nothing listening)')
|
|
if (!result?.error && result?.statusCode !== 0) throw new Error('Expected error or statusCode 0 in result')
|
|
return `correctly failed: ${(result.error ?? `statusCode=${result.statusCode}`).slice(0, 80)}`
|
|
},
|
|
},
|
|
{
|
|
name: 'webhook:verify failure',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceRootPath) return 'skipped (no workspace root)'
|
|
const { readFile } = await import('fs/promises')
|
|
const historyPath = `${ctx.workspaceRootPath}/automations-history.jsonl`
|
|
|
|
const start = Date.now()
|
|
const deadline = start + 15_000
|
|
let delay = 200
|
|
|
|
let lastLineCount = 0
|
|
let lastWebhookCount = 0
|
|
let lastSummary = 'no entries'
|
|
|
|
while (Date.now() < deadline) {
|
|
const content = await readFile(historyPath, 'utf-8').catch(() => '')
|
|
const lines = content.trim().split('\n').filter(Boolean)
|
|
lastLineCount = lines.length
|
|
|
|
const entries = lines
|
|
.map((l) => {
|
|
try {
|
|
return JSON.parse(l)
|
|
} catch {
|
|
return null
|
|
}
|
|
})
|
|
.filter(Boolean) as Array<Record<string, unknown>>
|
|
|
|
const webhookEntries = entries.filter((e) => !!e.webhook)
|
|
lastWebhookCount = webhookEntries.length
|
|
|
|
if (webhookEntries.length > 0) {
|
|
const recentThreshold = Date.now() - 120_000
|
|
const recentFailed = webhookEntries.find((e: any) =>
|
|
!e.ok && e.ts > recentThreshold && e.webhook?.method === 'POST'
|
|
) as any
|
|
if (recentFailed) {
|
|
return `webhook failure recorded: method=${recentFailed.webhook.method}, url=${recentFailed.webhook.url?.slice(0, 50)}`
|
|
}
|
|
|
|
const latest = webhookEntries[webhookEntries.length - 1] as any
|
|
lastSummary = `latest: ok=${String(latest?.ok)} method=${String(latest?.webhook?.method ?? 'n/a')} ts=${String(latest?.ts ?? 'n/a')}`
|
|
}
|
|
|
|
await new Promise((r) => setTimeout(r, delay))
|
|
delay = Math.min(Math.round(delay * 1.8), 1500)
|
|
}
|
|
|
|
const waitedMs = Date.now() - start
|
|
throw new Error(
|
|
`No recent failed POST webhook history entry after ${waitedMs}ms (lines=${lastLineCount}, webhookEntries=${lastWebhookCount}, ${lastSummary})`,
|
|
)
|
|
},
|
|
},
|
|
{
|
|
name: 'automation:cleanup',
|
|
fn: async (client, ctx) => {
|
|
const cleaned = await cleanupAutomationArtifacts(client, ctx)
|
|
return cleaned.length > 0 ? `cleaned: ${cleaned.join(', ')}` : 'nothing to clean'
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:branch delete',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.branchedSessionId) return 'skipped (no branch)'
|
|
await client.invoke('sessions:delete', ctx.branchedSessionId)
|
|
const id = ctx.branchedSessionId
|
|
ctx.branchedSessionId = undefined
|
|
return `deleted branch session: ${id}`
|
|
},
|
|
},
|
|
{
|
|
name: 'sources:delete',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId || !ctx.createdSourceSlug) return 'skipped (no source)'
|
|
await client.invoke('sources:delete', ctx.workspaceId, ctx.createdSourceSlug)
|
|
return `deleted source: ${ctx.createdSourceSlug}`
|
|
},
|
|
},
|
|
{
|
|
name: 'labels:delete (e2e-test)',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.workspaceId || !ctx.e2eTestLabelId) return 'skipped (no e2e-test label)'
|
|
await client.invoke('labels:delete', ctx.workspaceId, ctx.e2eTestLabelId)
|
|
return `deleted label: ${ctx.e2eTestLabelId}`
|
|
},
|
|
},
|
|
{
|
|
name: 'sessions:delete',
|
|
fn: async (client, ctx) => {
|
|
if (!ctx.createdSessionId) return 'skipped (no session)'
|
|
await client.invoke('sessions:delete', ctx.createdSessionId)
|
|
return `deleted session: ${ctx.createdSessionId}`
|
|
},
|
|
},
|
|
{
|
|
name: 'Disconnect',
|
|
fn: async (client) => {
|
|
client.destroy()
|
|
return 'OK'
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
export async function runValidation(
|
|
client: CliRpcClient,
|
|
jsonMode: boolean,
|
|
noSpinner?: boolean,
|
|
workspaceDir?: string,
|
|
validateOptions?: { baseUrl?: string; apiKey?: string; provider?: string },
|
|
): Promise<number> {
|
|
const steps = getValidateSteps()
|
|
const total = steps.length
|
|
const ctx: ValidateContext = {
|
|
workspaceDir,
|
|
baseUrl: validateOptions?.baseUrl,
|
|
apiKey: validateOptions?.apiKey,
|
|
provider: validateOptions?.provider,
|
|
}
|
|
let passed = 0
|
|
let failed = 0
|
|
const results: Array<{ step: string; status: string; detail: string; elapsed: number }> = []
|
|
const totalStart = performance.now()
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i]
|
|
const num = `[${i + 1}/${total}]`
|
|
const plainLen = num.length + 1 + step.name.length
|
|
|
|
// Spinner + live event printer
|
|
// Spinner keeps running until the agent produces real output (text_delta/tool_start).
|
|
// Early events (user_message, connection_changed, usage_update) are buffered or ignored
|
|
// so the spinner stays visible while the agent is thinking.
|
|
let spinner: { stop(): void } | undefined
|
|
if (!jsonMode) {
|
|
let headerPrinted = false
|
|
let accText = ''
|
|
let textFlushed = false
|
|
let bufferedPrompt = ''
|
|
|
|
if (_useColor && !noSpinner) {
|
|
spinner = createSpinner(`${c.cyan(num)} ${step.name}`)
|
|
}
|
|
|
|
const flushText = () => {
|
|
if (textFlushed || !accText) return
|
|
const clean = accText.replace(/\n/g, ' ').trim()
|
|
if (!clean) return
|
|
const display = clean.length > 120 ? clean.slice(0, 120) + '…' : clean
|
|
process.stdout.write(` ${c.dim('↳')} ${c.yellow(display)}\n`)
|
|
textFlushed = true
|
|
}
|
|
|
|
const ensureHeader = () => {
|
|
if (headerPrinted) return
|
|
spinner?.stop()
|
|
process.stdout.write(`${c.cyan(num)} ${step.name}\n`)
|
|
if (bufferedPrompt) {
|
|
process.stdout.write(` ${c.dim('→')} ${c.blue(`"${bufferedPrompt}"`)}\n`)
|
|
}
|
|
headerPrinted = true
|
|
}
|
|
|
|
ctx.onEvent = (ev) => {
|
|
switch (ev.type) {
|
|
// Buffer prompt — shown when agent starts responding
|
|
case 'user_message': {
|
|
const msg = ev.message as any
|
|
let text = ''
|
|
if (typeof msg?.content === 'string') {
|
|
text = msg.content
|
|
} else if (Array.isArray(msg?.content)) {
|
|
text = msg.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join(' ')
|
|
}
|
|
const clean = text.replace(/\n/g, ' ').trim()
|
|
bufferedPrompt = clean.length > 100 ? clean.slice(0, 100) + '…' : clean
|
|
break
|
|
}
|
|
// Agent text — stop spinner, show header + prompt + text
|
|
case 'text_delta':
|
|
ensureHeader()
|
|
accText += String(ev.delta ?? '')
|
|
if (!textFlushed && accText.length > 40) flushText()
|
|
break
|
|
case 'text_complete':
|
|
ensureHeader()
|
|
flushText()
|
|
break
|
|
// Tool use — stop spinner, show header + prompt + tool
|
|
case 'tool_start': {
|
|
ensureHeader()
|
|
flushText()
|
|
const name = String(ev.toolName ?? '?')
|
|
const intent = ev.toolIntent ? ` — "${ev.toolIntent}"` : ''
|
|
process.stdout.write(` ${c.dim('↳')} ${c.dim(`tool: ${name}${intent}`)}\n`)
|
|
accText = ''
|
|
textFlushed = false
|
|
break
|
|
}
|
|
// Ignore internal events (connection_changed, usage_update, etc.)
|
|
}
|
|
}
|
|
} else {
|
|
ctx.onEvent = undefined
|
|
}
|
|
|
|
const stepStart = performance.now()
|
|
try {
|
|
const detail = await step.fn(client, ctx)
|
|
const elapsed = (performance.now() - stepStart) / 1000
|
|
passed++
|
|
results.push({ step: step.name, status: 'OK', detail, elapsed })
|
|
spinner?.stop()
|
|
if (!jsonMode) {
|
|
const dots = c.dim('.'.repeat(Math.max(1, 50 - plainLen)))
|
|
const time = c.dim(elapsed < 1 ? `(${Math.round(elapsed * 1000)}ms)` : `(${elapsed.toFixed(1)}s)`)
|
|
process.stdout.write(`${c.cyan(num)} ${step.name} ${dots} ${c.green('✓')} ${detail} ${time}\n`)
|
|
}
|
|
} catch (e) {
|
|
const elapsed = (performance.now() - stepStart) / 1000
|
|
failed++
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
results.push({ step: step.name, status: 'FAIL', detail: msg, elapsed })
|
|
spinner?.stop()
|
|
if (!jsonMode) {
|
|
const dots = c.dim('.'.repeat(Math.max(1, 50 - plainLen)))
|
|
const time = c.dim(elapsed < 1 ? `(${Math.round(elapsed * 1000)}ms)` : `(${elapsed.toFixed(1)}s)`)
|
|
process.stderr.write(`${c.cyan(num)} ${step.name} ${dots} ${c.red('✗')} ${msg} ${time}\n`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup: branched session
|
|
if (ctx.branchedSessionId && client.isConnected) {
|
|
try {
|
|
await client.invoke('sessions:delete', ctx.branchedSessionId)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
|
|
// Cleanup: if a session was created but delete step hasn't run or failed
|
|
if (ctx.createdSessionId && client.isConnected) {
|
|
try {
|
|
await client.invoke('sessions:delete', ctx.createdSessionId)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
|
|
// Cleanup: automation artifacts
|
|
await cleanupAutomationArtifacts(client, ctx)
|
|
|
|
// Cleanup: if we auto-created a temp workspace, remove it
|
|
if (ctx.createdWorkspace && ctx.workspaceId && client.isConnected) {
|
|
try {
|
|
await client.invoke('workspaces:delete', ctx.workspaceId)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
if (ctx.workspaceRootPath) {
|
|
try {
|
|
const { rm } = await import('fs/promises')
|
|
await rm(ctx.workspaceRootPath, { recursive: true, force: true })
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalSec = ((performance.now() - totalStart) / 1000).toFixed(1)
|
|
|
|
if (jsonMode) {
|
|
out({ total, passed, failed, results, elapsedSeconds: parseFloat(totalSec) }, true)
|
|
} else {
|
|
if (failed === 0) {
|
|
process.stdout.write(`\n${c.green(`✓ ${passed}/${total} passed`)} ${c.dim(`in ${totalSec}s`)}\n`)
|
|
} else {
|
|
process.stdout.write(`\n${c.red(`✗ ${passed}/${total} passed, ${failed} failed`)} ${c.dim(`in ${totalSec}s`)}\n`)
|
|
}
|
|
}
|
|
|
|
return failed > 0 ? 1 : 0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Help
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function printHelp(): void {
|
|
process.stdout.write(`craft-cli — Terminal client for Qwen Code server
|
|
|
|
Usage: craft-cli [options] <command> [args...]
|
|
|
|
Connection:
|
|
--url <ws[s]://...> Server URL (default: $CRAFT_SERVER_URL)
|
|
--token <secret> Auth token (default: $CRAFT_SERVER_TOKEN)
|
|
--workspace <id> Workspace ID (auto-detected if omitted)
|
|
--timeout <ms> Request timeout (default: 10000)
|
|
--tls-ca <path> Custom CA cert for self-signed TLS
|
|
--json Raw JSON output for scripting
|
|
|
|
LLM Configuration (for 'run' command):
|
|
--provider <name> LLM provider (default: qwen, or $LLM_PROVIDER)
|
|
Supported: qwen
|
|
--model <id> Model to use (or $LLM_MODEL)
|
|
--api-key <key> Ignored for Qwen Code; kept for script compatibility
|
|
--base-url <url> Ignored for Qwen Code; kept for script compatibility
|
|
|
|
Commands:
|
|
run <message> Spawn server, send message, stream response, exit
|
|
--workspace-dir <path> Use directory as workspace (creates if needed)
|
|
--source <slug> Enable source (repeatable)
|
|
--mode <mode> Permission mode (default: allow-all)
|
|
--output-format text or stream-json (default: text)
|
|
--no-cleanup Keep session after completion
|
|
--server-entry Path to server/index.ts
|
|
ping Verify connectivity (clientId + latency)
|
|
health Check credential store health
|
|
versions Show server runtime versions
|
|
workspaces List workspaces
|
|
sessions List sessions in workspace
|
|
connections List LLM connections
|
|
sources List configured sources
|
|
session create Create a session (--name, --mode)
|
|
session messages <id> Print session message history
|
|
session delete <id> Delete a session
|
|
send <id> <message> Send message and stream AI response
|
|
cancel <id> Cancel in-progress processing
|
|
invoke <channel> [...] Raw RPC call with JSON args
|
|
listen <channel> Subscribe to push events (Ctrl+C to stop)
|
|
--validate-server Multi-step server integration test
|
|
--verbose, -v Show server stderr output
|
|
|
|
Examples:
|
|
craft-cli run "What files are in the current directory?"
|
|
craft-cli run --source craft-kb "Summarize today's daily note"
|
|
craft-cli run --workspace-dir .github/agents --source craft-public "Read the doc"
|
|
craft-cli run --provider qwen --model qwen3-coder "Summarize this repo"
|
|
echo "Analyze this code" | craft-cli run
|
|
craft-cli ping
|
|
craft-cli sessions
|
|
craft-cli send abc-123 "What files are in the current directory?"
|
|
echo "Summarize this" | craft-cli send abc-123
|
|
craft-cli --validate-server
|
|
craft-cli invoke system:homeDir
|
|
craft-cli --json workspaces | jq '.[].name'
|
|
`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function main(argv: string[] = process.argv): Promise<void> {
|
|
const args = parseArgs(argv)
|
|
|
|
// Set custom CA before any WS connections
|
|
if (args.tlsCa) {
|
|
process.env.NODE_EXTRA_CA_CERTS = args.tlsCa
|
|
}
|
|
|
|
if (args.command === 'help' || args.command === '') {
|
|
printHelp()
|
|
return
|
|
}
|
|
|
|
if (args.command === 'version') {
|
|
const pkg = await import('../package.json')
|
|
out(pkg.version ?? pkg.default?.version ?? 'unknown', false)
|
|
return
|
|
}
|
|
|
|
// run is self-contained — spawns its own server
|
|
if (args.command === 'run') {
|
|
await cmdRun(args)
|
|
return
|
|
}
|
|
|
|
// validate can spawn its own server or use --url
|
|
if (args.command === 'validate') {
|
|
await cmdValidate(args)
|
|
return
|
|
}
|
|
|
|
// All other commands need a server URL
|
|
if (!args.url) {
|
|
err('No server URL. Use --url <ws://...> or set $CRAFT_SERVER_URL')
|
|
process.exit(1)
|
|
}
|
|
|
|
const client = new CliRpcClient(args.url, {
|
|
token: args.token || undefined,
|
|
workspaceId: args.workspace,
|
|
requestTimeout: args.timeout,
|
|
connectTimeout: args.timeout,
|
|
})
|
|
|
|
try {
|
|
switch (args.command) {
|
|
case 'ping':
|
|
await cmdPing(client, args)
|
|
break
|
|
case 'health':
|
|
await cmdHealth(client, args)
|
|
break
|
|
case 'versions':
|
|
await cmdVersions(client, args)
|
|
break
|
|
case 'workspaces':
|
|
await cmdWorkspaces(client, args)
|
|
break
|
|
case 'sessions':
|
|
await cmdSessions(client, args)
|
|
break
|
|
case 'connections':
|
|
await cmdConnections(client, args)
|
|
break
|
|
case 'sources':
|
|
await cmdSources(client, args)
|
|
break
|
|
case 'session': {
|
|
const subCmd = args.rest.shift()
|
|
switch (subCmd) {
|
|
case 'create':
|
|
await cmdSessionCreate(client, args)
|
|
break
|
|
case 'messages':
|
|
await cmdSessionMessages(client, args)
|
|
break
|
|
case 'delete':
|
|
await cmdSessionDelete(client, args)
|
|
break
|
|
default:
|
|
err(`Unknown session subcommand: ${subCmd}`)
|
|
process.exit(1)
|
|
}
|
|
break
|
|
}
|
|
case 'send':
|
|
await cmdSend(client, args)
|
|
break // cmdSend calls process.exit
|
|
case 'cancel':
|
|
await cmdCancel(client, args)
|
|
break
|
|
case 'invoke':
|
|
await cmdInvoke(client, args)
|
|
break
|
|
case 'listen':
|
|
await cmdListen(client, args)
|
|
break // never returns
|
|
default:
|
|
err(`Unknown command: ${args.command}`)
|
|
printHelp()
|
|
process.exit(1)
|
|
}
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
err(msg)
|
|
process.exit(1)
|
|
} finally {
|
|
client.destroy()
|
|
}
|
|
}
|
|
|
|
// Run if executed directly (not when imported by tests)
|
|
if (import.meta.main) {
|
|
main()
|
|
}
|