diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts new file mode 100644 index 0000000..b9fbbc9 --- /dev/null +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -0,0 +1,152 @@ +import minimist from "minimist"; +import { spawnSync } from "node:child_process"; +import { + type ClientName, + type ClientInfo, + type ExecResult, + 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, + }; +} + +function printHelp(): void { + console.log(`AI CLI Dispatch + +Usage: + ai-cli-dispatch list [--json] + ai-cli-dispatch exec --client --prompt + ai-cli-dispatch --help + +Clients: codex, claude, opencode`); +} + +export async function main(argv: string[]): Promise { + 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"], + alias: { h: "help" }, + }); + + if (args.help) { + printHelp(); + return 0; + } + + const command = args._[0]; + + if (command === "list") { + const clients = discoverClients(); + if (args.json) { + console.log(JSON.stringify(clients, null, 2)); + } else { + for (const c of clients) { + const status = c.found + ? `✓ ${c.version ?? "unknown version"}` + : "✗ not found"; + console.log(`${c.name}: ${status}`); + } + } + return 0; + } + + if (command === "exec") { + 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; + } + throw err; + } + } + + if (command) { + console.error(`Unknown command: ${command}`); + } else { + console.error("Error: no command given"); + } + printHelp(); + return 1; +} + +const isMain = + import.meta.url.startsWith("file://") && + !!process.argv[1] && + import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")); + +if (isMain) { + main(process.argv).then((code) => process.exit(code)); +} diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts new file mode 100644 index 0000000..39fa9c5 --- /dev/null +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { main, discoverClients, execClient } from "../src/cli.js"; +import { ClientNotFoundError } 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"]); + }); + + it("returns ClientInfo with found boolean", () => { + const clients = discoverClients(); + for (const c of clients) { + assert.strictEqual(typeof c.found, "boolean"); + } + }); +}); + +describe("main", () => { + it("returns 0 for --help", async () => { + const code = await main(["node", "cli.ts", "--help"]); + assert.strictEqual(code, 0); + }); + + 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("returns 1 for unknown command", async () => { + const code = await main(["node", "cli.ts", "bogus"]); + assert.strictEqual(code, 1); + }); + + it("returns 1 when no command is given", async () => { + const code = await main(["node", "cli.ts"]); + assert.strictEqual(code, 1); + }); +}); + +describe("execClient", () => { + it("throws ClientNotFoundError when client path is empty", () => { + assert.throws( + () => execClient("codex", "test prompt", ""), + (err: unknown) => err instanceof ClientNotFoundError + ); + }); +});