merge M3 into implement/2026-05-19-ai-cli-dispatch-fixes
This commit is contained in:
@@ -28,12 +28,13 @@ npm install
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/ai-cli-dispatch list --json
|
scripts/ai-cli-dispatch list --json
|
||||||
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
|
scripts/ai-cli-dispatch run --client codex --prompt "refactor this function"
|
||||||
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
|
scripts/ai-cli-dispatch run --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 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
|
## Client Discovery
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ Run `list` to see which clients are installed and their resolved versions.
|
|||||||
|
|
||||||
## Background Jobs
|
## 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
|
```typescript
|
||||||
import { startJob, getJob, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js";
|
import { startJob, getJob, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js";
|
||||||
|
|||||||
@@ -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<ExecResult>,
|
||||||
|
client: ClientName,
|
||||||
|
prompt: string,
|
||||||
|
timeoutMs: number | undefined,
|
||||||
|
debug: boolean,
|
||||||
|
ctx: RunContext
|
||||||
|
): Promise<number> {
|
||||||
|
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<Job>,
|
||||||
|
client: ClientName,
|
||||||
|
prompt: string,
|
||||||
|
timeoutMs: number | undefined,
|
||||||
|
debug: boolean,
|
||||||
|
ctx: RunContext
|
||||||
|
): Promise<number> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import minimist from "minimist";
|
import minimist from "minimist";
|
||||||
import { detectClients as realDetectClients } from "./detect.js";
|
import { detectClients as realDetectClients } from "./detect.js";
|
||||||
import { executePrompt as realExecutePrompt } from "./execute.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 { resolveClient as realResolveClient } from "./dispatch.js";
|
||||||
import { resolveConfig as realResolveConfig } from "./config.js";
|
import { resolveConfig as realResolveConfig } from "./config.js";
|
||||||
import { CLIENT_NAMES } from "./constants.js";
|
import { CLIENT_NAMES } from "./constants.js";
|
||||||
@@ -9,8 +17,16 @@ import {
|
|||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
type ExecResult,
|
type ExecResult,
|
||||||
type DebugInfo,
|
type DebugInfo,
|
||||||
|
type Job,
|
||||||
|
type JobStatus,
|
||||||
ClientNotFoundError,
|
ClientNotFoundError,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import {
|
||||||
|
reportError,
|
||||||
|
reportCliError,
|
||||||
|
handleSyncRun,
|
||||||
|
handleAsyncRun,
|
||||||
|
} from "./cli-helpers.js";
|
||||||
|
|
||||||
export interface CliDeps {
|
export interface CliDeps {
|
||||||
detectClients?: () => ClientInfo[];
|
detectClients?: () => ClientInfo[];
|
||||||
@@ -19,6 +35,11 @@ export interface CliDeps {
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||||
) => Promise<ExecResult>;
|
) => Promise<ExecResult>;
|
||||||
|
startJob?: (
|
||||||
|
client: ClientName,
|
||||||
|
prompt: string,
|
||||||
|
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||||
|
) => Promise<Job>;
|
||||||
resolveClient?: (
|
resolveClient?: (
|
||||||
prompt: string,
|
prompt: string,
|
||||||
config?: { client?: ClientName; defaultClient?: ClientName }
|
config?: { client?: ClientName; defaultClient?: ClientName }
|
||||||
@@ -26,6 +47,29 @@ export interface CliDeps {
|
|||||||
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName; timeout?: number };
|
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName; timeout?: number };
|
||||||
stdoutWrite?: (chunk: string) => void;
|
stdoutWrite?: (chunk: string) => void;
|
||||||
stderrWrite?: (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<string, number> = {
|
||||||
|
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 {
|
function printHelp(): void {
|
||||||
@@ -33,10 +77,25 @@ function printHelp(): void {
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ai-cli-dispatch list [--json|--text]
|
ai-cli-dispatch list [--json|--text]
|
||||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
|
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
|
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||||
|
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
|
||||||
|
ai-cli-dispatch status <job-id> [--json|--text]
|
||||||
|
ai-cli-dispatch results <job-id> [--json|--text]
|
||||||
|
ai-cli-dispatch cancel <job-id> [--json|--text]
|
||||||
|
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
|
||||||
|
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
|
||||||
ai-cli-dispatch --help
|
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`);
|
Clients: codex, claude, opencode`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,18 +105,24 @@ export async function main(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const detectClients = deps.detectClients ?? realDetectClients;
|
const detectClients = deps.detectClients ?? realDetectClients;
|
||||||
const executePrompt = deps.executePrompt ?? realExecutePrompt;
|
const executePrompt = deps.executePrompt ?? realExecutePrompt;
|
||||||
|
const startJob = deps.startJob ?? realStartJob;
|
||||||
const resolveClient = deps.resolveClient ?? realResolveClient;
|
const resolveClient = deps.resolveClient ?? realResolveClient;
|
||||||
const resolveConfig = deps.resolveConfig ?? realResolveConfig;
|
const resolveConfig = deps.resolveConfig ?? realResolveConfig;
|
||||||
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
|
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
|
||||||
const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.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 rawArgs = argv.slice(2);
|
||||||
const parseArgs =
|
const parseArgs =
|
||||||
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
|
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
|
||||||
|
|
||||||
const args = minimist(parseArgs, {
|
const args = minimist(parseArgs, {
|
||||||
string: ["client", "prompt", "timeout"],
|
string: ["client", "prompt", "timeout", "max-age"],
|
||||||
boolean: ["json", "text", "help", "debug"],
|
boolean: ["json", "text", "help", "debug", "sync"],
|
||||||
alias: { h: "help" },
|
alias: { h: "help" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,25 +161,14 @@ export async function main(
|
|||||||
const prompt = args.prompt as string | undefined;
|
const prompt = args.prompt as string | undefined;
|
||||||
|
|
||||||
if (!client || !CLIENT_NAMES.includes(client)) {
|
if (!client || !CLIENT_NAMES.includes(client)) {
|
||||||
const message = !client
|
return reportCliError(
|
||||||
? "--client is required"
|
!client ? "--client is required" : `Unknown client: ${client}`,
|
||||||
: `Unknown client: ${client}`;
|
jsonMode
|
||||||
if (jsonMode) {
|
);
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(`Error: ${message}`);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
const message = "--prompt is required";
|
return reportCliError("--prompt is required", jsonMode);
|
||||||
if (jsonMode) {
|
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(`Error: ${message}`);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = resolveConfig();
|
const config = resolveConfig();
|
||||||
@@ -123,28 +177,11 @@ export async function main(
|
|||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||||
|
|
||||||
try {
|
const ctx = { jsonMode, stdoutWrite, stderrWrite };
|
||||||
const result = await executePrompt(client, prompt, {
|
if (args.sync) {
|
||||||
timeoutMs,
|
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === "dispatch") {
|
if (command === "dispatch") {
|
||||||
@@ -154,13 +191,7 @@ export async function main(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
const message = "prompt is required";
|
return reportCliError("prompt is required", jsonMode);
|
||||||
if (jsonMode) {
|
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(`Error: ${message}`);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = resolveConfig();
|
const config = resolveConfig();
|
||||||
@@ -171,26 +202,86 @@ export async function main(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const message = "Could not resolve client from prompt";
|
return reportCliError("Could not resolve client from prompt", jsonMode);
|
||||||
if (jsonMode) {
|
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(`Error: ${message}`);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
const parsedTimeout =
|
||||||
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await executePrompt(client, prompt, {
|
const job = await startJob(client, prompt, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
debug,
|
debug,
|
||||||
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
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) {
|
if (jsonMode) {
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} else {
|
} else {
|
||||||
@@ -199,23 +290,84 @@ export async function main(
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
return reportError(err, jsonMode);
|
||||||
if (jsonMode) {
|
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error(message);
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `Unknown command: ${command}`;
|
if (command === "cancel") {
|
||||||
if (jsonMode) {
|
const jobId = args._[1];
|
||||||
console.error(JSON.stringify({ error: message }, null, 2));
|
if (!jobId) {
|
||||||
} else {
|
return reportCliError("job-id is required", jsonMode);
|
||||||
console.error(message);
|
}
|
||||||
|
|
||||||
|
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: <number>[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 =
|
const isMain =
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user