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"; import { type ClientName, 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[]; executePrompt?: ( client: ClientName, 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 } ) => ClientName | null; 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 { console.log(`AI CLI Dispatch Usage: ai-cli-dispatch list [--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`); } export async function main( argv: string[], deps: CliDeps = {} ): 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", "max-age"], boolean: ["json", "text", "help", "debug", "sync"], alias: { h: "help" }, }); const jsonMode = !args.text; const debug = !!args.debug; if (args.help) { printHelp(); return 0; } const command = args._[0]; if (!command) { printHelp(); return 1; } if (command === "list") { const clients = detectClients(); if (jsonMode) { 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 === "run") { 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; 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") { let prompt = args.prompt as string | undefined; if (!prompt && args._.length > 1) { prompt = args._.slice(1).join(" "); } if (!prompt) { return reportCliError("prompt is required", jsonMode); } const config = resolveConfig(); const explicitClient = args.client as ClientName | undefined; const client = resolveClient(prompt, { client: explicitClient, defaultClient: config.defaultClient, }); if (!client) { 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 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 { if (result.stdout) stdoutWrite(result.stdout); if (result.stderr) stderrWrite(result.stderr); } return 0; } catch (err) { return reportError(err, jsonMode); } } 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); } } 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 = 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)); }