feat(M5): CLI Integration

This commit is contained in:
2026-05-18 18:39:33 -05:00
parent 0879ffe39f
commit 4f59258b20
2 changed files with 524 additions and 133 deletions
+139 -85
View File
@@ -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<ClientName, string> = {
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<ExecResult>;
resolveClient?: (
prompt: string,
config?: { client?: ClientName; defaultClient?: ClientName }
) => ClientName | null;
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; 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 <client> --prompt <prompt>
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch --help
Clients: codex, claude, opencode`);
}
export async function main(argv: string[]): Promise<number> {
export async function main(
argv: string[],
deps: CliDeps = {}
): Promise<number> {
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<number> {
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<number> {
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;
}