diff --git a/tools/ai-cli-dispatch/SKILL.md b/tools/ai-cli-dispatch/SKILL.md index db86cee..fa4db68 100644 --- a/tools/ai-cli-dispatch/SKILL.md +++ b/tools/ai-cli-dispatch/SKILL.md @@ -28,12 +28,13 @@ npm install ```bash scripts/ai-cli-dispatch list --json -scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function" -scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware" -scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM" +scripts/ai-cli-dispatch run --client codex --prompt "refactor this function" +scripts/ai-cli-dispatch run --client claude --prompt "add tests for auth middleware" +scripts/ai-cli-dispatch run --client opencode --prompt "migrate to ESM" +scripts/ai-cli-dispatch dispatch "use codex to write tests" ``` -Use `--json` for machine-readable command output. +Use `--json` for machine-readable command output. Use `--sync` with `run` or `dispatch` to block until the client returns; the default is async (starts a background job and returns the job ID immediately). ## Client Discovery @@ -47,7 +48,7 @@ Run `list` to see which clients are installed and their resolved versions. ## Background Jobs -For long-running or fire-and-forget tasks, use the programmatic job API instead of `exec`: +For long-running or fire-and-forget tasks, use the programmatic job API or invoke `run` / `dispatch` without `--sync`: ```typescript import { startJob, getJob, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js"; diff --git a/tools/ai-cli-dispatch/src/cli-helpers.ts b/tools/ai-cli-dispatch/src/cli-helpers.ts new file mode 100644 index 0000000..e04da2c --- /dev/null +++ b/tools/ai-cli-dispatch/src/cli-helpers.ts @@ -0,0 +1,85 @@ +import type { ClientName, ExecResult, DebugInfo, Job } from "./types.js"; + +export interface RunContext { + jsonMode: boolean; + stdoutWrite: (chunk: string) => void; + stderrWrite: (chunk: string) => void; +} + +export function reportError(err: unknown, jsonMode: boolean): number { + const message = err instanceof Error ? err.message : String(err); + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); + } + return 1; +} + +export function reportCliError(message: string, jsonMode: boolean): number { + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; +} + +export async function handleSyncRun( + executePrompt: ( + client: ClientName, + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } + ) => Promise, + client: ClientName, + prompt: string, + timeoutMs: number | undefined, + debug: boolean, + ctx: RunContext +): Promise { + try { + const result = await executePrompt(client, prompt, { + timeoutMs, + debug, + onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined, + }); + if (ctx.jsonMode) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.stdout) ctx.stdoutWrite(result.stdout); + if (result.stderr) ctx.stderrWrite(result.stderr); + } + return 0; + } catch (err) { + return reportError(err, ctx.jsonMode); + } +} + +export async function handleAsyncRun( + startJob: ( + client: ClientName, + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } + ) => Promise, + client: ClientName, + prompt: string, + timeoutMs: number | undefined, + debug: boolean, + ctx: RunContext +): Promise { + try { + const job = await startJob(client, prompt, { + timeoutMs, + debug, + onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined, + }); + if (ctx.jsonMode) { + console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2)); + } else { + console.log(`Job ${job.id} started (${job.client}): ${job.status}`); + } + return 0; + } catch (err) { + return reportError(err, ctx.jsonMode); + } +} diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 5be1ef3..b3ddcb5 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -1,6 +1,14 @@ import minimist from "minimist"; import { detectClients as realDetectClients } from "./detect.js"; import { executePrompt as realExecutePrompt } from "./execute.js"; +import { + startJob as realStartJob, + getJob as realGetJob, + getJobResult as realGetJobResult, + cancelJob as realCancelJob, + listJobs as realListJobs, + cleanupJobs as realCleanupJobs, +} from "./jobs.js"; import { resolveClient as realResolveClient } from "./dispatch.js"; import { resolveConfig as realResolveConfig } from "./config.js"; import { CLIENT_NAMES } from "./constants.js"; @@ -9,8 +17,16 @@ import { type ClientInfo, type ExecResult, type DebugInfo, + type Job, + type JobStatus, ClientNotFoundError, } from "./types.js"; +import { + reportError, + reportCliError, + handleSyncRun, + handleAsyncRun, +} from "./cli-helpers.js"; export interface CliDeps { detectClients?: () => ClientInfo[]; @@ -19,6 +35,11 @@ export interface CliDeps { prompt: string, options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } ) => Promise; + startJob?: ( + client: ClientName, + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } + ) => Promise; resolveClient?: ( prompt: string, config?: { client?: ClientName; defaultClient?: ClientName } @@ -26,6 +47,29 @@ export interface CliDeps { resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName; timeout?: number }; stdoutWrite?: (chunk: string) => void; stderrWrite?: (chunk: string) => void; + getJob?: (jobId: string) => Job; + getJobResult?: (jobId: string) => ExecResult; + cancelJob?: (jobId: string) => void; + listJobs?: (options?: { filter?: JobStatus }) => Job[]; + cleanupJobs?: (options?: { maxAgeMs?: number }) => void; +} + +function parseMaxAge(value: string): number | null { + const match = value.match(/^(\d+(?:\.\d+)?)\s*([hmsd]?)$/i); + if (!match) return null; + const num = parseFloat(match[1]); + if (!Number.isFinite(num) || num < 0) return null; + const unit = match[2].toLowerCase(); + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + "": 60 * 60 * 1000, + }; + const multiplier = multipliers[unit]; + if (multiplier === undefined) return null; + return num * multiplier; } function printHelp(): void { @@ -33,10 +77,25 @@ function printHelp(): void { Usage: ai-cli-dispatch list [--json|--text] - ai-cli-dispatch run --client --prompt [--json|--text] - ai-cli-dispatch dispatch [--client ] [--json|--text] + ai-cli-dispatch run --client --prompt [--sync] [--timeout ] [--debug] [--json|--text] + ai-cli-dispatch dispatch [--client ] [--sync] [--timeout ] [--debug] [--json|--text] + ai-cli-dispatch start --client --prompt [--timeout ] [--debug] [--json|--text] + ai-cli-dispatch status [--json|--text] + ai-cli-dispatch results [--json|--text] + ai-cli-dispatch cancel [--json|--text] + ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text] + ai-cli-dispatch cleanup-jobs [--max-age [h|m|s|d]] [--json|--text] ai-cli-dispatch --help +Flags: + --sync Run synchronously and block until the client returns (default is async) + --timeout Timeout in milliseconds (or override via config) + --debug Emit diagnostic JSON to stderr + --max-age Maximum age for cleanup (default unit: hours, e.g. 24h or 30m) + --status Filter jobs by status (running, completed, failed) + --json Output JSON (default) + --text Output plain text + Clients: codex, claude, opencode`); } @@ -46,18 +105,24 @@ export async function main( ): Promise { const detectClients = deps.detectClients ?? realDetectClients; const executePrompt = deps.executePrompt ?? realExecutePrompt; + const startJob = deps.startJob ?? realStartJob; const resolveClient = deps.resolveClient ?? realResolveClient; const resolveConfig = deps.resolveConfig ?? realResolveConfig; const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c)); const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.write(c)); + const getJob = deps.getJob ?? realGetJob; + const getJobResult = deps.getJobResult ?? realGetJobResult; + const cancelJob = deps.cancelJob ?? realCancelJob; + const listJobs = deps.listJobs ?? realListJobs; + const cleanupJobs = deps.cleanupJobs ?? realCleanupJobs; const rawArgs = argv.slice(2); const parseArgs = rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; const args = minimist(parseArgs, { - string: ["client", "prompt", "timeout"], - boolean: ["json", "text", "help", "debug"], + string: ["client", "prompt", "timeout", "max-age"], + boolean: ["json", "text", "help", "debug", "sync"], alias: { h: "help" }, }); @@ -96,25 +161,14 @@ export async function main( const prompt = args.prompt as string | undefined; if (!client || !CLIENT_NAMES.includes(client)) { - const message = !client - ? "--client is required" - : `Unknown client: ${client}`; - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(`Error: ${message}`); - } - return 1; + return reportCliError( + !client ? "--client is required" : `Unknown client: ${client}`, + jsonMode + ); } if (!prompt) { - const message = "--prompt is required"; - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(`Error: ${message}`); - } - return 1; + return reportCliError("--prompt is required", jsonMode); } const config = resolveConfig(); @@ -123,28 +177,11 @@ export async function main( const timeoutMs = Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; - try { - const result = await executePrompt(client, prompt, { - timeoutMs, - debug, - onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined, - }); - if (jsonMode) { - console.log(JSON.stringify(result, null, 2)); - } else { - if (result.stdout) stdoutWrite(result.stdout); - if (result.stderr) stderrWrite(result.stderr); - } - return 0; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(message); - } - return 1; + const ctx = { jsonMode, stdoutWrite, stderrWrite }; + if (args.sync) { + return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx); } + return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx); } if (command === "dispatch") { @@ -154,13 +191,7 @@ export async function main( } if (!prompt) { - const message = "prompt is required"; - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(`Error: ${message}`); - } - return 1; + return reportCliError("prompt is required", jsonMode); } const config = resolveConfig(); @@ -171,26 +202,86 @@ export async function main( }); if (!client) { - const message = "Could not resolve client from prompt"; - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(`Error: ${message}`); - } - return 1; + return reportCliError("Could not resolve client from prompt", jsonMode); } + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; + const timeoutMs = + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; + + const ctx = { jsonMode, stdoutWrite, stderrWrite }; + if (args.sync) { + return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx); + } + return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx); + } + + if (command === "start") { + const client = args.client as ClientName | undefined; + const prompt = args.prompt as string | undefined; + + if (!client || !CLIENT_NAMES.includes(client)) { + return reportCliError( + !client ? "--client is required" : `Unknown client: ${client}`, + jsonMode + ); + } + + if (!prompt) { + return reportCliError("--prompt is required", jsonMode); + } + + const config = resolveConfig(); const parsedTimeout = typeof args.timeout === "string" ? Number(args.timeout) : undefined; const timeoutMs = Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; try { - const result = await executePrompt(client, prompt, { + const job = await startJob(client, prompt, { timeoutMs, debug, onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined, }); + if (jsonMode) { + console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2)); + } else { + console.log(`Job ${job.id} started (${job.client}): ${job.status}`); + } + return 0; + } catch (err) { + return reportError(err, jsonMode); + } + } + + if (command === "status") { + const jobId = args._[1]; + if (!jobId) { + return reportCliError("job-id is required", jsonMode); + } + + try { + const job = getJob(jobId); + if (jsonMode) { + console.log(JSON.stringify(job, null, 2)); + } else { + console.log(`Job ${jobId}: ${job.status}`); + } + return 0; + } catch (err) { + return reportError(err, jsonMode); + } + } + + if (command === "results") { + const jobId = args._[1]; + if (!jobId) { + return reportCliError("job-id is required", jsonMode); + } + + try { + const result = getJobResult(jobId); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { @@ -199,23 +290,84 @@ export async function main( } return 0; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(message); - } - return 1; + return reportError(err, jsonMode); } } - const message = `Unknown command: ${command}`; - if (jsonMode) { - console.error(JSON.stringify({ error: message }, null, 2)); - } else { - console.error(message); + if (command === "cancel") { + const jobId = args._[1]; + if (!jobId) { + return reportCliError("job-id is required", jsonMode); + } + + try { + const job = getJob(jobId); + if (job.status !== "running") { + return reportCliError( + `Job is not running (status: ${job.status})`, + jsonMode + ); + } + cancelJob(jobId); + if (jsonMode) { + console.log(JSON.stringify({ jobId, cancelled: true }, null, 2)); + } else { + console.log(`Job ${jobId} cancelled`); + } + return 0; + } catch (err) { + return reportError(err, jsonMode); + } } - return 1; + + if (command === "list-jobs") { + try { + const filter = args.status as JobStatus | undefined; + const jobs = listJobs({ filter }); + if (jsonMode) { + console.log(JSON.stringify(jobs, null, 2)); + } else { + for (const job of jobs) { + console.log(`${job.id} (${job.client}): ${job.status}`); + } + } + return 0; + } catch (err) { + return reportError(err, jsonMode); + } + } + + if (command === "cleanup-jobs") { + const maxAgeRaw = args["max-age"] as string | undefined; + let maxAgeMs: number | undefined; + if (maxAgeRaw !== undefined) { + const parsed = parseMaxAge(maxAgeRaw); + if (parsed === null) { + return reportCliError( + "Invalid --max-age format. Use: [h|m|s|d], e.g. 24h", + jsonMode + ); + } + maxAgeMs = parsed; + } + + try { + const jobsBefore = listJobs(); + cleanupJobs({ maxAgeMs }); + const jobsAfter = listJobs(); + const count = jobsBefore.length - jobsAfter.length; + if (jsonMode) { + console.log(JSON.stringify({ count }, null, 2)); + } else { + console.log(`Cleaned ${count} jobs`); + } + return 0; + } catch (err) { + return reportError(err, jsonMode); + } + } + + return reportError(`Unknown command: ${command}`, jsonMode); } const isMain = diff --git a/tools/ai-cli-dispatch/tests/cli-helpers.test.ts b/tools/ai-cli-dispatch/tests/cli-helpers.test.ts new file mode 100644 index 0000000..5272c1a --- /dev/null +++ b/tools/ai-cli-dispatch/tests/cli-helpers.test.ts @@ -0,0 +1,278 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + reportError, + reportCliError, + handleSyncRun, + handleAsyncRun, +} from "../src/cli-helpers.js"; +import type { ExecResult, Job } from "../src/types.js"; + +function captureConsole() { + const logs: string[] = []; + const errors: string[] = []; + const origLog = console.log; + const origError = console.error; + console.log = (...args: unknown[]) => logs.push(args.map(String).join(" ")); + console.error = (...args: unknown[]) => errors.push(args.map(String).join(" ")); + return { + logs, + errors, + restore() { + console.log = origLog; + console.error = origError; + }, + }; +} + +describe("reportError", () => { + it("prints JSON error for Error instance when jsonMode=true", () => { + const out = captureConsole(); + try { + const code = reportError(new Error("boom"), true); + assert.strictEqual(code, 1); + assert.strictEqual(out.errors.length, 1); + const parsed = JSON.parse(out.errors[0]); + assert.strictEqual(parsed.error, "boom"); + } finally { + out.restore(); + } + }); + + it("prints plain text error for Error instance when jsonMode=false", () => { + const out = captureConsole(); + try { + const code = reportError(new Error("boom"), false); + assert.strictEqual(code, 1); + assert.strictEqual(out.errors.length, 1); + assert.strictEqual(out.errors[0], "boom"); + } finally { + out.restore(); + } + }); + + it("prints JSON error for non-Error value when jsonMode=true", () => { + const out = captureConsole(); + try { + const code = reportError("plain string", true); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.strictEqual(parsed.error, "plain string"); + } finally { + out.restore(); + } + }); +}); + +describe("reportCliError", () => { + it("prints JSON error with jsonMode=true", () => { + const out = captureConsole(); + try { + const code = reportCliError("missing arg", true); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.strictEqual(parsed.error, "missing arg"); + } finally { + out.restore(); + } + }); + + it("prints prefixed text error with jsonMode=false", () => { + const out = captureConsole(); + try { + const code = reportCliError("missing arg", false); + assert.strictEqual(code, 1); + assert.strictEqual(out.errors[0], "Error: missing arg"); + } finally { + out.restore(); + } + }); +}); + +describe("handleSyncRun", () => { + it("returns 0 and prints JSON result in jsonMode", async () => { + const out = captureConsole(); + try { + const result: ExecResult = { + stdout: "out", + stderr: "err", + exitCode: 0, + client: "codex", + durationMs: 10, + }; + const code = await handleSyncRun( + async () => result, + "codex", + "hello", + 5000, + false, + { jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 0); + assert.strictEqual(out.logs.length, 1); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.stdout, "out"); + } finally { + out.restore(); + } + }); + + it("returns 0 and writes stdout/stderr in text mode", async () => { + const out = captureConsole(); + const stdout: string[] = []; + const stderr: string[] = []; + try { + const result: ExecResult = { + stdout: "out", + stderr: "err", + exitCode: 0, + client: "codex", + durationMs: 10, + }; + const code = await handleSyncRun( + async () => result, + "codex", + "hello", + 5000, + false, + { + jsonMode: false, + stdoutWrite: (c) => stdout.push(c), + stderrWrite: (c) => stderr.push(c), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stdout.join(""), "out"); + assert.strictEqual(stderr.join(""), "err"); + assert.strictEqual(out.logs.length, 0); + } finally { + out.restore(); + } + }); + + it("passes timeoutMs and debug to executePrompt", async () => { + const out = captureConsole(); + try { + let received: any; + const code = await handleSyncRun( + async (_c, _p, opts) => { + received = opts; + return { + stdout: "", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 1, + }; + }, + "codex", + "hello", + 12345, + true, + { jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 0); + assert.strictEqual(received?.timeoutMs, 12345); + assert.strictEqual(received?.debug, true); + } finally { + out.restore(); + } + }); + + it("returns 1 and prints error JSON when executePrompt throws", async () => { + const out = captureConsole(); + try { + const code = await handleSyncRun( + async () => { + throw new Error("fail"); + }, + "codex", + "hello", + undefined, + false, + { jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.strictEqual(parsed.error, "fail"); + } finally { + out.restore(); + } + }); +}); + +describe("handleAsyncRun", () => { + it("returns 0 and prints JSON job in jsonMode", async () => { + const out = captureConsole(); + try { + const job: Job = { + id: "j1", + client: "codex", + prompt: "hi", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + }; + const code = await handleAsyncRun( + async () => job, + "codex", + "hi", + undefined, + false, + { jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "j1"); + assert.strictEqual(parsed.status, "running"); + } finally { + out.restore(); + } + }); + + it("returns 0 and prints text job info", async () => { + const out = captureConsole(); + try { + const job: Job = { + id: "j1", + client: "codex", + prompt: "hi", + status: "running", + startedAt: "2024-01-01T00:00:00Z", + }; + const code = await handleAsyncRun( + async () => job, + "codex", + "hi", + undefined, + false, + { jsonMode: false, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("j1")); + assert.ok(out.logs[0].includes("running")); + } finally { + out.restore(); + } + }); + + it("returns 1 and prints error JSON when startJob throws", async () => { + const out = captureConsole(); + try { + const code = await handleAsyncRun( + async () => { + throw new Error("fail"); + }, + "codex", + "hi", + undefined, + false, + { jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.strictEqual(parsed.error, "fail"); + } finally { + out.restore(); + } + }); +}); diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 5d0d846..f4ce386 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -2,7 +2,16 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { main } from "../src/cli.js"; import { ClientNotFoundError } from "../src/types.js"; -import type { ClientInfo, ExecResult, ClientName } from "../src/types.js"; +import type { ClientInfo, ExecResult, ClientName, Job, JobStatus, DebugInfo } from "../src/types.js"; + +function mockJob(overrides: Partial & { id: string; status: JobStatus }): Job { + return { + client: "codex", + prompt: "hello", + startedAt: "2024-01-01T00:00:00Z", + ...overrides, + } as Job; +} function captureOutput() { const logs: string[] = []; @@ -107,12 +116,12 @@ describe("main", () => { } }); - it("run executes client with prompt and prints JSON result", async () => { + it("run executes client with prompt and prints JSON result with --sync", async () => { const out = captureOutput(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"], { detectClients: () => mockClients, executePrompt: async (client, prompt) => { @@ -132,7 +141,7 @@ describe("main", () => { } }); - it("run prints text output with --text", async () => { + it("run prints text output with --text and --sync", async () => { const out = captureOutput(); const stdoutChunks: string[] = []; const stderrChunks: string[] = []; @@ -147,6 +156,7 @@ describe("main", () => { "--prompt", "hello", "--text", + "--sync", ], { detectClients: () => mockClients, @@ -230,11 +240,11 @@ describe("main", () => { } }); - it("run returns 1 and prints error JSON when client not found", async () => { + it("run returns 1 and prints error JSON when client not found with --sync", async () => { const out = captureOutput(); try { const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"], { detectClients: () => mockClients, executePrompt: async () => { @@ -250,12 +260,12 @@ describe("main", () => { } }); - it("dispatch routes positional prompt and prints JSON result", async () => { + it("dispatch routes positional prompt and prints JSON result with --sync", async () => { const out = captureOutput(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "dispatch", "use claude to write tests"], + ["node", "cli.ts", "dispatch", "use claude to write tests", "--sync"], { detectClients: () => mockClients, executePrompt: async (client, prompt) => { @@ -277,12 +287,12 @@ describe("main", () => { } }); - it("dispatch routes --prompt flag and prints JSON result", async () => { + it("dispatch routes --prompt flag and prints JSON result with --sync", async () => { const out = captureOutput(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor"], + ["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor", "--sync"], { detectClients: () => mockClients, executePrompt: async (client, prompt) => { @@ -301,12 +311,12 @@ describe("main", () => { } }); - it("dispatch respects --client override", async () => { + it("dispatch respects --client override with --sync", async () => { const out = captureOutput(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--client", "opencode"], + ["node", "cli.ts", "dispatch", "do something", "--client", "opencode", "--sync"], { detectClients: () => mockClients, executePrompt: async (client, prompt) => { @@ -344,11 +354,11 @@ describe("main", () => { } }); - it("dispatch returns 1 when resolved client is not found", async () => { + it("dispatch returns 1 when resolved client is not found with --sync", async () => { const out = captureOutput(); try { const code = await main( - ["node", "cli.ts", "dispatch", "do something"], + ["node", "cli.ts", "dispatch", "do something", "--sync"], { detectClients: () => mockClients, executePrompt: async () => { @@ -380,12 +390,12 @@ describe("main", () => { } }); - it("dispatch prints text output with --text", async () => { + it("dispatch prints text output with --text and --sync", async () => { const out = captureOutput(); const stdoutChunks: string[] = []; try { const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--text"], + ["node", "cli.ts", "dispatch", "do something", "--text", "--sync"], { detectClients: () => mockClients, executePrompt: async () => ({ @@ -407,12 +417,12 @@ describe("main", () => { } }); - it("run prints debug diagnostic JSON to stderr with --debug", async () => { + it("run prints debug diagnostic JSON to stderr with --debug and --sync", async () => { const out = captureOutput(); const stderrChunks: string[] = []; try { const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug", "--sync"], { detectClients: () => mockClients, executePrompt: async (_client, _prompt, options?) => { @@ -425,7 +435,8 @@ describe("main", () => { durationMs: 42, stderrLength: 0, stdoutLength: 6, - } as any); + noisySuccess: false, + } satisfies DebugInfo); return { stdout: "output", stderr: "", @@ -452,12 +463,12 @@ describe("main", () => { } }); - it("run ignores non-numeric --timeout and falls back to config timeout", async () => { + it("run ignores non-numeric --timeout and falls back to config timeout with --sync", async () => { const out = captureOutput(); try { let receivedTimeout: number | undefined; const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number", "--sync"], { detectClients: () => mockClients, executePrompt: async (_client, _prompt, options?) => { @@ -474,12 +485,12 @@ describe("main", () => { } }); - it("dispatch ignores non-numeric --timeout and falls back to config timeout", async () => { + it("dispatch ignores non-numeric --timeout and falls back to config timeout with --sync", async () => { const out = captureOutput(); try { let receivedTimeout: number | undefined; const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"], + ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad", "--sync"], { detectClients: () => mockClients, executePrompt: async (_client, _prompt, options?) => { @@ -497,12 +508,12 @@ describe("main", () => { } }); - it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => { + it("dispatch prints debug diagnostic JSON to stderr with --debug and --sync", async () => { const out = captureOutput(); const stderrChunks: string[] = []; try { const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--debug"], + ["node", "cli.ts", "dispatch", "do something", "--debug", "--sync"], { detectClients: () => mockClients, executePrompt: async (_client, _prompt, options?) => { @@ -515,7 +526,8 @@ describe("main", () => { durationMs: 42, stderrLength: 0, stdoutLength: 6, - } as any); + noisySuccess: false, + } satisfies DebugInfo); return { stdout: "output", stderr: "", @@ -538,4 +550,1039 @@ describe("main", () => { out.restore(); } }); + + // Async default tests (S-301) + + it("run async default starts job and returns job ID JSON immediately", async () => { + const out = captureOutput(); + try { + const started: { client: ClientName; prompt: string; options?: any }[] = []; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async (client, prompt, options) => { + started.push({ client, prompt, options }); + return { id: "job-123", client, prompt, status: "running", startedAt: new Date().toISOString() }; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0].client, "codex"); + assert.strictEqual(started[0].prompt, "hello"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-123"); + assert.strictEqual(parsed.client, "codex"); + assert.strictEqual(parsed.status, "running"); + } finally { + out.restore(); + } + }); + + it("dispatch async default starts job and returns job ID JSON immediately", async () => { + const out = captureOutput(); + try { + const started: { client: ClientName; prompt: string; options?: any }[] = []; + const code = await main( + ["node", "cli.ts", "dispatch", "use claude to write tests"], + { + detectClients: () => mockClients, + startJob: async (client, prompt, options) => { + started.push({ client, prompt, options }); + return { id: "job-456", client, prompt, status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "claude", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0].client, "claude"); + assert.strictEqual(started[0].prompt, "use claude to write tests"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-456"); + assert.strictEqual(parsed.client, "claude"); + assert.strictEqual(parsed.status, "running"); + } finally { + out.restore(); + } + }); + + it("run async passes --timeout to startJob", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "30000"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-789", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() }; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 30000); + } finally { + out.restore(); + } + }); + + it("run async ignores non-numeric --timeout and falls back to config timeout", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-t1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() }; + }, + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 600_000); + } finally { + out.restore(); + } + }); + + it("dispatch async passes --timeout to startJob", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--timeout", "45000"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-abc", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 45000); + } finally { + out.restore(); + } + }); + + it("dispatch async ignores non-numeric --timeout and falls back to config timeout", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-t2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 600_000); + } finally { + out.restore(); + } + }); + + it("run async passes --debug to startJob", async () => { + const out = captureOutput(); + const stderrChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + options?.onDebug?.({ + command: "codex", + args: ["exec", "--yolo", "hello"], + pid: 12345, + exitCode: 0, + exitSignal: null, + durationMs: 42, + stderrLength: 0, + stdoutLength: 6, + noisySuccess: false, + } satisfies DebugInfo); + return { id: "job-def", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() }; + }, + stderrWrite: (chunk) => stderrChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stderrChunks.length, 1); + const diag = JSON.parse(stderrChunks[0]); + assert.strictEqual(diag.command, "codex"); + assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]); + assert.strictEqual(diag.pid, 12345); + } finally { + out.restore(); + } + }); + + it("run async returns 1 and prints error JSON when startJob throws", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async () => { + throw new Error("spawn ENOENT"); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("ENOENT")); + } finally { + out.restore(); + } + }); + + it("dispatch async returns 1 and prints error JSON when startJob throws", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something"], + { + detectClients: () => mockClients, + startJob: async () => { + throw new Error("spawn ENOENT"); + }, + resolveClient: () => "claude", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("ENOENT")); + } finally { + out.restore(); + } + }); + + it("run async uses config timeout when --timeout is not provided", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-nt1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() }; + }, + resolveConfig: () => ({ paths: {}, timeout: 300_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 300_000); + } finally { + out.restore(); + } + }); + + it("dispatch async uses config timeout when --timeout is not provided", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "dispatch", "do something"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedTimeout = options?.timeoutMs; + return { id: "job-nt2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 300_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 300_000); + } finally { + out.restore(); + } + }); + + it("run async without --debug does not pass onDebug to startJob", async () => { + const out = captureOutput(); + let receivedOptions: any; + try { + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedOptions = options; + return { id: "job-nd1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() }; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedOptions.onDebug, undefined); + } finally { + out.restore(); + } + }); + + it("dispatch async without --debug does not pass onDebug to startJob", async () => { + const out = captureOutput(); + let receivedOptions: any; + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + receivedOptions = options; + return { id: "job-nd2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedOptions.onDebug, undefined); + } finally { + out.restore(); + } + }); + + it("dispatch async passes --debug to startJob", async () => { + const out = captureOutput(); + const stderrChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--debug"], + { + detectClients: () => mockClients, + startJob: async (_client, _prompt, options) => { + options?.onDebug?.({ + command: "codex", + args: ["exec", "--yolo", "do something"], + pid: 12345, + exitCode: 0, + exitSignal: null, + durationMs: 42, + stderrLength: 0, + stdoutLength: 6, + noisySuccess: false, + } satisfies DebugInfo); + return { id: "job-ghi", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + stderrWrite: (chunk) => stderrChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stderrChunks.length, 1); + const diag = JSON.parse(stderrChunks[0]); + assert.strictEqual(diag.command, "codex"); + assert.strictEqual(diag.durationMs, 42); + } finally { + out.restore(); + } + }); + + // Job lifecycle subcommands (S-302) + + it("start starts job and returns job ID JSON", async () => { + const out = captureOutput(); + try { + const started: { client: ClientName; prompt: string; options?: any }[] = []; + const code = await main( + ["node", "cli.ts", "start", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + startJob: async (client, prompt, options) => { + started.push({ client, prompt, options }); + return { id: "job-start-1", client, prompt, status: "running", startedAt: new Date().toISOString() }; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(started.length, 1); + assert.strictEqual(started[0].client, "codex"); + assert.strictEqual(started[0].prompt, "hello"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-start-1"); + assert.strictEqual(parsed.client, "codex"); + assert.strictEqual(parsed.status, "running"); + } finally { + out.restore(); + } + }); + + it("start returns 1 when client is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "start", "--prompt", "hello"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("client")); + } finally { + out.restore(); + } + }); + + it("start returns 1 when prompt is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "start", "--client", "codex"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("prompt")); + } finally { + out.restore(); + } + }); + + it("status returns running job JSON", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-123"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ id: jobId, status: "running" }), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.id, "job-123"); + assert.strictEqual(parsed.status, "running"); + assert.strictEqual(parsed.client, "codex"); + } finally { + out.restore(); + } + }); + + it("status returns completed job JSON", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-456"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ + id: jobId, + client: "claude", + prompt: "write tests", + status: "completed", + completedAt: "2024-01-01T00:01:00Z", + result: { stdout: "ok", stderr: "", exitCode: 0, client: "claude", durationMs: 100 }, + }), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.status, "completed"); + assert.strictEqual(parsed.result.exitCode, 0); + } finally { + out.restore(); + } + }); + + it("status returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "missing-job"], + { + detectClients: () => mockClients, + getJob: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("status returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "status"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("status prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "status", "job-123", "--text"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ id: jobId, status: "running" }), + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("running")); + assert.ok(out.logs[0].includes("job-123")); + } finally { + out.restore(); + } + }); + + it("results returns ExecResult JSON for completed job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "job-789"], + { + detectClients: () => mockClients, + getJobResult: () => ({ + stdout: "output", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 42, + }), + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.stdout, "output"); + assert.strictEqual(parsed.exitCode, 0); + } finally { + out.restore(); + } + }); + + it("results returns 1 and prints error JSON for still-running job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "job-running"], + { + detectClients: () => mockClients, + getJobResult: () => { + throw new Error('Job "job-running" result is not available (status: running).'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not available")); + } finally { + out.restore(); + } + }); + + it("results returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "results", "missing-job"], + { + detectClients: () => mockClients, + getJobResult: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("results returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "results"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("results prints text output with --text", async () => { + const out = captureOutput(); + const stdoutChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "results", "job-789", "--text"], + { + detectClients: () => mockClients, + getJobResult: () => ({ + stdout: "hello-out", + stderr: "hello-err", + exitCode: 0, + client: "codex", + durationMs: 42, + }), + stdoutWrite: (chunk) => stdoutChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stdoutChunks.join(""), "hello-out"); + } finally { + out.restore(); + } + }); + + it("cancel returns cancelled confirmation for running job", async () => { + const out = captureOutput(); + try { + let cancelledJobId: string | undefined; + const code = await main( + ["node", "cli.ts", "cancel", "job-abc"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ id: jobId, status: "running" }), + cancelJob: (jobId) => { + cancelledJobId = jobId; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(cancelledJobId, "job-abc"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.jobId, "job-abc"); + assert.strictEqual(parsed.cancelled, true); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 and prints error JSON for completed job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "job-done"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ id: jobId, status: "completed", completedAt: "2024-01-01T00:01:00Z" }), + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("running")); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 and prints error JSON for nonexistent job", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "missing-job"], + { + detectClients: () => mockClients, + getJob: () => { + throw new Error('Job "missing-job" not found.'); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("cancel returns 1 when job-id is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "cancel"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("job-id")); + } finally { + out.restore(); + } + }); + + it("cancel prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cancel", "job-abc", "--text"], + { + detectClients: () => mockClients, + getJob: (jobId) => mockJob({ id: jobId, status: "running" }), + cancelJob: () => {}, + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("cancelled")); + assert.ok(out.logs[0].includes("job-abc")); + } finally { + out.restore(); + } + }); + + it("list-jobs returns array of all jobs", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "list-jobs"], + { + detectClients: () => mockClients, + listJobs: () => [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + { id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" }, + ], + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.length, 2); + assert.strictEqual(parsed[0].id, "job-1"); + assert.strictEqual(parsed[1].id, "job-2"); + } finally { + out.restore(); + } + }); + + it("list-jobs filters by --status", async () => { + const out = captureOutput(); + try { + let receivedFilter: string | undefined; + const code = await main( + ["node", "cli.ts", "list-jobs", "--status", "running"], + { + detectClients: () => mockClients, + listJobs: (options) => { + receivedFilter = options?.filter; + return [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + ]; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedFilter, "running"); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].status, "running"); + } finally { + out.restore(); + } + }); + + it("list-jobs prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "list-jobs", "--text"], + { + detectClients: () => mockClients, + listJobs: () => [ + { id: "job-1", client: "codex", prompt: "p1", status: "running", startedAt: "2024-01-01T00:00:00Z" }, + ], + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("job-1")); + assert.ok(out.logs[0].includes("codex")); + assert.ok(out.logs[0].includes("running")); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs returns count of cleaned jobs", async () => { + const out = captureOutput(); + try { + let callCount = 0; + const jobs = [ + { id: "job-1", client: "codex", prompt: "p1", status: "completed", startedAt: "2024-01-01T00:00:00Z" }, + { id: "job-2", client: "claude", prompt: "p2", status: "completed", startedAt: "2024-01-01T00:01:00Z" }, + ]; + const code = await main( + ["node", "cli.ts", "cleanup-jobs"], + { + detectClients: () => mockClients, + listJobs: () => { + callCount++; + return callCount === 1 ? jobs : []; + }, + cleanupJobs: () => {}, + } + ); + assert.strictEqual(code, 0); + const parsed = JSON.parse(out.logs[0]); + assert.strictEqual(parsed.count, 2); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs with --max-age parses hours", async () => { + const out = captureOutput(); + try { + let receivedMaxAgeMs: number | undefined; + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--max-age", "12h"], + { + detectClients: () => mockClients, + listJobs: () => [], + cleanupJobs: (options) => { + receivedMaxAgeMs = options?.maxAgeMs; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedMaxAgeMs, 12 * 60 * 60 * 1000); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs returns 1 for invalid --max-age format", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--max-age", "abc"], + { + detectClients: () => mockClients, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("max-age")); + } finally { + out.restore(); + } + }); + + it("cleanup-jobs prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "cleanup-jobs", "--text"], + { + detectClients: () => mockClients, + listJobs: () => [], + cleanupJobs: () => {}, + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("Cleaned")); + assert.ok(out.logs[0].includes("0")); + } finally { + out.restore(); + } + }); + + // S-303: help output and CLI integration smoke tests + + it("--help output documents start subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("start"), "help should mention start"); + } finally { + out.restore(); + } + }); + + it("--help output documents status subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("status"), "help should mention status"); + } finally { + out.restore(); + } + }); + + it("--help output documents results subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("results"), "help should mention results"); + } finally { + out.restore(); + } + }); + + it("--help output documents cancel subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("cancel"), "help should mention cancel"); + } finally { + out.restore(); + } + }); + + it("--help output documents list-jobs subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("list-jobs"), "help should mention list-jobs"); + } finally { + out.restore(); + } + }); + + it("--help output documents cleanup-jobs subcommand", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("cleanup-jobs"), "help should mention cleanup-jobs"); + } finally { + out.restore(); + } + }); + + it("--help output documents --sync flag", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("--sync"), "help should mention --sync"); + } finally { + out.restore(); + } + }); + + it("--help output documents --timeout flag", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("--timeout"), "help should mention --timeout"); + } finally { + out.restore(); + } + }); + + it("--help output documents --debug flag", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("--debug"), "help should mention --debug"); + } finally { + out.restore(); + } + }); + + it("--help output documents --max-age flag", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("--max-age"), "help should mention --max-age"); + } finally { + out.restore(); + } + }); + + it("--help output documents --status flag for list-jobs", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + const help = out.logs.join("\n"); + assert.ok(help.includes("--status"), "help should mention --status"); + } finally { + out.restore(); + } + }); + + it("start returns 1 for unknown client", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "start", "--client", "bogus", "--prompt", "hello"], + { + detectClients: () => mockClients, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("client")); + } finally { + out.restore(); + } + }); + + it("start prints text output with --text", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "start", "--client", "codex", "--prompt", "hello", "--text"], + { + detectClients: () => mockClients, + startJob: async (client, prompt) => ({ + id: "job-start-txt", + client, + prompt, + status: "running", + startedAt: new Date().toISOString(), + }), + } + ); + assert.strictEqual(code, 0); + assert.ok(out.logs[0].includes("job-start-txt")); + assert.ok(out.logs[0].includes("codex")); + assert.ok(out.logs[0].includes("running")); + } finally { + out.restore(); + } + }); });