135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
import { spawn as defaultSpawn } from "node:child_process";
|
|
import { existsSync as defaultExistsSync } from "node:fs";
|
|
import type { ClientName, ExecResult, DebugInfo, ExecuteOptions } from "./types.js";
|
|
import { ClientNotFoundError, ExecError } from "./types.js";
|
|
|
|
export const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
|
|
codex: (p) => ["exec", "--yolo", p],
|
|
claude: (p) => ["-p", p, "--dangerously-skip-permissions"],
|
|
opencode: (p) => ["run", "--dangerously-skip-permissions", p],
|
|
};
|
|
|
|
export async function executePrompt(
|
|
client: ClientName,
|
|
prompt: string,
|
|
options: ExecuteOptions = {}
|
|
): Promise<ExecResult> {
|
|
if (prompt.trim() === "") {
|
|
throw new ExecError("Prompt cannot be empty", {
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: -1,
|
|
client,
|
|
durationMs: 0,
|
|
});
|
|
}
|
|
|
|
const spawnImpl = options.spawn ?? defaultSpawn;
|
|
const existsSyncImpl = options.existsSync ?? defaultExistsSync;
|
|
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
|
|
const command = options.clientPath ?? client;
|
|
if (options.clientPath && !existsSyncImpl(options.clientPath)) {
|
|
throw new ClientNotFoundError(client);
|
|
}
|
|
|
|
const argBuilder = (CLIENT_ARGS as Record<string, (prompt: string) => string[]>)[client];
|
|
if (!argBuilder) {
|
|
throw new ExecError(`Unknown client: ${client}`, {
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: -1,
|
|
client,
|
|
durationMs: 0,
|
|
});
|
|
}
|
|
const args = argBuilder(prompt);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timedOut = false;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let exitSignal: NodeJS.Signals | null = null;
|
|
const startMs = Date.now();
|
|
|
|
const child = spawnImpl(command, args, {
|
|
shell: false,
|
|
});
|
|
|
|
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill();
|
|
}, timeoutMs);
|
|
|
|
function settle(
|
|
err?: Error | undefined,
|
|
result?: ExecResult | undefined
|
|
): void {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
const durationMs = Date.now() - startMs;
|
|
if (options.debug || options.onDebug) {
|
|
const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null);
|
|
const debugInfo: DebugInfo = {
|
|
command,
|
|
args,
|
|
pid: child.pid ?? undefined,
|
|
exitCode: effectiveExitCode,
|
|
exitSignal,
|
|
durationMs,
|
|
stderrLength: stderr.length,
|
|
stdoutLength: stdout.length,
|
|
noisySuccess: effectiveExitCode === 0 && stderr.length > 0,
|
|
};
|
|
options.onDebug?.(debugInfo);
|
|
}
|
|
if (err) {
|
|
if (err instanceof ExecError) {
|
|
err.result.client = client;
|
|
err.result.durationMs = durationMs;
|
|
}
|
|
reject(err);
|
|
} else {
|
|
resolve({ ...result!, client, durationMs });
|
|
}
|
|
}
|
|
|
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (err.code === "ENOENT") {
|
|
settle(new ClientNotFoundError(client));
|
|
} else {
|
|
settle(
|
|
new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 })
|
|
);
|
|
}
|
|
});
|
|
|
|
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
exitSignal = signal;
|
|
if (timedOut) {
|
|
settle(
|
|
new ExecError(`Execution timed out after ${timeoutMs}ms`, {
|
|
stdout,
|
|
stderr,
|
|
exitCode: -1,
|
|
client,
|
|
durationMs: 0,
|
|
})
|
|
);
|
|
} else {
|
|
settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 });
|
|
}
|
|
});
|
|
});
|
|
}
|