diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index b9fbbc9..32ec4cd 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -1,5 +1,9 @@ import minimist from "minimist"; -import { spawnSync } from "node:child_process"; +import { detectClients as realDetectClients } from "./detect.js"; +import { executePrompt as realExecutePrompt } from "./execute.js"; +import { resolveClient as realResolveClient } from "./dispatch.js"; +import { resolveConfig as realResolveConfig } from "./config.js"; +import { CLIENT_NAMES } from "./constants.js"; import { type ClientName, type ClientInfo, @@ -7,83 +11,56 @@ import { ClientNotFoundError, } from "./types.js"; -const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; -const VERSION_FLAGS: Record = { - codex: "--version", - claude: "--version", - opencode: "--version", -}; - -function whichSync(cmd: string): string | undefined { - const isWin = process.platform === "win32"; - const result = spawnSync(isWin ? "where" : "which", [cmd], { - encoding: "utf-8", - }); - if (result.status === 0) { - return result.stdout.trim().split("\n")[0]; - } - return undefined; -} - -export function discoverClients(): ClientInfo[] { - return CLIENT_NAMES.map((name) => { - const path = whichSync(name); - if (!path) { - return { name, found: false }; - } - const versionResult = spawnSync(name, [VERSION_FLAGS[name]], { - encoding: "utf-8", - }); - const version = - versionResult.status === 0 ? versionResult.stdout.trim() : undefined; - return { name, path, version, found: true }; - }); -} - -export function execClient( - client: ClientName, - prompt: string, - clientPath?: string -): ExecResult { - const path = - clientPath ?? discoverClients().find((c) => c.name === client)?.path; - if (!path) { - throw new ClientNotFoundError(client); - } - const result = spawnSync(client, [prompt], { - encoding: "utf-8", - stdio: ["inherit", "pipe", "pipe"], - }); - return { - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - exitCode: typeof result.status === "number" ? result.status : 1, - }; +export interface CliDeps { + detectClients?: () => ClientInfo[]; + executePrompt?: ( + client: ClientName, + prompt: string + ) => Promise; + resolveClient?: ( + prompt: string, + config?: { client?: ClientName; defaultClient?: ClientName } + ) => ClientName | null; + resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName }; + stdoutWrite?: (chunk: string) => void; + stderrWrite?: (chunk: string) => void; } function printHelp(): void { console.log(`AI CLI Dispatch Usage: - ai-cli-dispatch list [--json] - ai-cli-dispatch exec --client --prompt + ai-cli-dispatch list [--json|--text] + ai-cli-dispatch run --client --prompt [--json|--text] + ai-cli-dispatch dispatch [--client ] [--json|--text] ai-cli-dispatch --help Clients: codex, claude, opencode`); } -export async function main(argv: string[]): Promise { +export async function main( + argv: string[], + deps: CliDeps = {} +): Promise { + const detectClients = deps.detectClients ?? realDetectClients; + const executePrompt = deps.executePrompt ?? realExecutePrompt; + 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 rawArgs = argv.slice(2); - // When run via tsx, argv[2] is the script path (e.g. src/cli.ts); skip it. const parseArgs = rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; const args = minimist(parseArgs, { string: ["client", "prompt"], - boolean: ["json", "help", "debug"], + boolean: ["json", "text", "help", "debug"], alias: { h: "help" }, }); + const jsonMode = !args.text; + if (args.help) { printHelp(); return 0; @@ -91,9 +68,14 @@ export async function main(argv: string[]): Promise { const command = args._[0]; + if (!command) { + printHelp(); + return 1; + } + if (command === "list") { - const clients = discoverClients(); - if (args.json) { + const clients = detectClients(); + if (jsonMode) { console.log(JSON.stringify(clients, null, 2)); } else { for (const c of clients) { @@ -106,39 +88,111 @@ export async function main(argv: string[]): Promise { return 0; } - if (command === "exec") { + if (command === "run") { const client = args.client as ClientName | undefined; const prompt = args.prompt as string | undefined; + if (!client || !CLIENT_NAMES.includes(client)) { - console.error( - `Error: --client must be one of ${CLIENT_NAMES.join(", ")}` - ); - return 1; - } - if (!prompt) { - console.error("Error: --prompt is required"); - return 1; - } - try { - const result = execClient(client, prompt); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - return result.exitCode; - } catch (err) { - if (err instanceof ClientNotFoundError) { - console.error(err.message); - return 1; + const message = !client + ? "--client is required" + : `Unknown client: ${client}`; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); } - throw err; + return 1; + } + + if (!prompt) { + const message = "--prompt is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + try { + const result = await executePrompt(client, prompt); + 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; } } - if (command) { - console.error(`Unknown command: ${command}`); - } else { - console.error("Error: no command given"); + if (command === "dispatch") { + let prompt = args.prompt as string | undefined; + if (!prompt && args._.length > 1) { + prompt = args._.slice(1).join(" "); + } + + if (!prompt) { + const message = "prompt is required"; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + return 1; + } + + const config = resolveConfig(); + const explicitClient = args.client as ClientName | undefined; + const client = resolveClient(prompt, { + client: explicitClient, + defaultClient: config.defaultClient, + }); + + 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; + } + + try { + const result = await executePrompt(client, prompt); + 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 message = `Unknown command: ${command}`; + if (jsonMode) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(message); } - printHelp(); return 1; } diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 39fa9c5..ec5c80d 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -1,66 +1,403 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { main, discoverClients, execClient } from "../src/cli.js"; +import { main } from "../src/cli.js"; import { ClientNotFoundError } from "../src/types.js"; +import type { ClientInfo, ExecResult, ClientName } from "../src/types.js"; -describe("discoverClients", () => { - it("returns all three configured clients", () => { - const clients = discoverClients(); - assert.strictEqual(clients.length, 3); - const names = clients.map((c) => c.name); - assert.deepStrictEqual(names, ["codex", "claude", "opencode"]); - }); +function captureOutput() { + const logs: string[] = []; + const errors: string[] = []; - it("returns ClientInfo with found boolean", () => { - const clients = discoverClients(); - for (const c of clients) { - assert.strictEqual(typeof c.found, "boolean"); - } - }); -}); + 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("main", () => { - it("returns 0 for --help", async () => { - const code = await main(["node", "cli.ts", "--help"]); - assert.strictEqual(code, 0); + const mockClients: ClientInfo[] = [ + { name: "codex", found: true, path: "/usr/bin/codex", version: "1.0.0" }, + { name: "claude", found: true, path: "/usr/bin/claude", version: "2.0.0" }, + { name: "opencode", found: false }, + ]; + + const mockResult: ExecResult = { + stdout: "output", + stderr: "", + exitCode: 0, + }; + + it("returns 0 for --help and prints usage", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + assert.ok(out.logs.some((l) => l.includes("Usage:"))); + } finally { + out.restore(); + } }); - it("returns 0 for list", async () => { - const code = await main(["node", "cli.ts", "list"]); - assert.strictEqual(code, 0); - }); - - it("returns 0 for list --json", async () => { - const code = await main(["node", "cli.ts", "list", "--json"]); - assert.strictEqual(code, 0); - }); - - it("returns 1 for exec without --client", async () => { - const code = await main(["node", "cli.ts", "exec", "--prompt", "hello"]); - assert.strictEqual(code, 1); - }); - - it("returns 1 for exec without --prompt", async () => { - const code = await main(["node", "cli.ts", "exec", "--client", "codex"]); - assert.strictEqual(code, 1); + it("prints usage and returns 1 for bare invocation", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts"]); + assert.strictEqual(code, 1); + assert.ok(out.logs.some((l) => l.includes("Usage:"))); + } finally { + out.restore(); + } }); it("returns 1 for unknown command", async () => { - const code = await main(["node", "cli.ts", "bogus"]); - assert.strictEqual(code, 1); + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "bogus"]); + assert.strictEqual(code, 1); + const err = out.errors[0] ?? out.logs[0]; + assert.ok(String(err).includes("Unknown command")); + } finally { + out.restore(); + } }); - it("returns 1 when no command is given", async () => { - const code = await main(["node", "cli.ts"]); - assert.strictEqual(code, 1); + it("list prints JSON array of clients by default", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "list"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 0); + assert.strictEqual(out.logs.length, 1); + const parsed = JSON.parse(out.logs[0]); + assert.deepStrictEqual(parsed, mockClients); + } finally { + out.restore(); + } }); -}); -describe("execClient", () => { - it("throws ClientNotFoundError when client path is empty", () => { - assert.throws( - () => execClient("codex", "test prompt", ""), - (err: unknown) => err instanceof ClientNotFoundError - ); + it("list prints human-readable output with --text", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "list", "--text"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 0); + assert.ok(out.logs.some((l) => l.includes("codex:"))); + assert.ok(out.logs.some((l) => l.includes("claude:"))); + assert.ok(out.logs.some((l) => l.includes("opencode:"))); + } finally { + out.restore(); + } + }); + + it("run executes client with prompt and prints JSON result", async () => { + const out = captureOutput(); + try { + const executed: { client: ClientName; prompt: string }[] = []; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + executePrompt: async (client, prompt) => { + executed.push({ client, prompt }); + return mockResult; + }, + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(executed.length, 1); + assert.strictEqual(executed[0].client, "codex"); + assert.strictEqual(executed[0].prompt, "hello"); + const parsed = JSON.parse(out.logs[0]); + assert.deepStrictEqual(parsed, mockResult); + } finally { + out.restore(); + } + }); + + it("run prints text output with --text", async () => { + const out = captureOutput(); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + try { + const code = await main( + [ + "node", + "cli.ts", + "run", + "--client", + "codex", + "--prompt", + "hello", + "--text", + ], + { + detectClients: () => mockClients, + executePrompt: async () => ({ + stdout: "hello-out", + stderr: "hello-err", + exitCode: 0, + }), + stdoutWrite: (chunk) => stdoutChunks.push(chunk), + stderrWrite: (chunk) => stderrChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stdoutChunks.join(""), "hello-out"); + assert.strictEqual(stderrChunks.join(""), "hello-err"); + } finally { + out.restore(); + } + }); + + it("run returns 1 and prints error JSON when client is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "run", "--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("run returns 1 and prints error text when client is missing in text mode", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "run", "--prompt", "hello", "--text"], + { + detectClients: () => mockClients, + } + ); + assert.strictEqual(code, 1); + assert.ok(out.errors[0].includes("client")); + } finally { + out.restore(); + } + }); + + it("run returns 1 and prints error JSON when prompt is missing", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "run", "--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("run returns 1 for unknown client", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "run", "--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("run returns 1 and prints error JSON when client not found", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + { + detectClients: () => mockClients, + executePrompt: async () => { + throw new ClientNotFoundError("codex"); + }, + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("dispatch routes positional prompt and prints JSON result", async () => { + const out = captureOutput(); + try { + const executed: { client: ClientName; prompt: string }[] = []; + const code = await main( + ["node", "cli.ts", "dispatch", "use claude to write tests"], + { + detectClients: () => mockClients, + executePrompt: async (client, prompt) => { + executed.push({ client, prompt }); + return mockResult; + }, + resolveClient: () => "claude", + resolveConfig: () => ({ paths: {} }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(executed.length, 1); + assert.strictEqual(executed[0].client, "claude"); + assert.strictEqual(executed[0].prompt, "use claude to write tests"); + const parsed = JSON.parse(out.logs[0]); + assert.deepStrictEqual(parsed, mockResult); + } finally { + out.restore(); + } + }); + + it("dispatch routes --prompt flag and prints JSON result", async () => { + const out = captureOutput(); + try { + const executed: { client: ClientName; prompt: string }[] = []; + const code = await main( + ["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor"], + { + detectClients: () => mockClients, + executePrompt: async (client, prompt) => { + executed.push({ client, prompt }); + return mockResult; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {} }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(executed.length, 1); + assert.strictEqual(executed[0].prompt, "use codex to refactor"); + } finally { + out.restore(); + } + }); + + it("dispatch respects --client override", async () => { + const out = captureOutput(); + try { + const executed: { client: ClientName; prompt: string }[] = []; + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--client", "opencode"], + { + detectClients: () => mockClients, + executePrompt: async (client, prompt) => { + executed.push({ client, prompt }); + return mockResult; + }, + resolveClient: (_p, cfg) => cfg?.client ?? null, + resolveConfig: () => ({ paths: {}, defaultClient: "claude" }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(executed[0].client, "opencode"); + } finally { + out.restore(); + } + }); + + it("dispatch returns 1 when prompt cannot be resolved", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something"], + { + detectClients: () => mockClients, + executePrompt: async () => mockResult, + resolveClient: () => null, + resolveConfig: () => ({ paths: {} }), + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("resolve")); + } finally { + out.restore(); + } + }); + + it("dispatch returns 1 when resolved client is not found", async () => { + const out = captureOutput(); + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something"], + { + detectClients: () => mockClients, + executePrompt: async () => { + throw new ClientNotFoundError("claude"); + }, + resolveClient: () => "claude", + resolveConfig: () => ({ paths: {} }), + } + ); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("not found")); + } finally { + out.restore(); + } + }); + + it("dispatch returns 1 when no prompt is given", async () => { + const out = captureOutput(); + try { + const code = await main(["node", "cli.ts", "dispatch"], { + detectClients: () => mockClients, + }); + assert.strictEqual(code, 1); + const parsed = JSON.parse(out.errors[0]); + assert.ok(parsed.error.includes("prompt")); + } finally { + out.restore(); + } + }); + + it("dispatch prints text output with --text", async () => { + const out = captureOutput(); + const stdoutChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--text"], + { + detectClients: () => mockClients, + executePrompt: async () => ({ + stdout: "done", + stderr: "", + exitCode: 0, + }), + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {} }), + stdoutWrite: (chunk) => stdoutChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stdoutChunks.join(""), "done"); + } finally { + out.restore(); + } }); });