#!/usr/bin/env bun import { parseFlags, parseNumFlag, parseOperationArgs } from "./parse.ts" import { EXIT_ERROR, EXIT_USAGE, exitCodeFor, renderHuman, renderJson, } from "./format.ts" import { confirmHuman, prodHumanGateDeps } from "./human-gate.ts" import { OPERATIONS, executeOperation, findByVerb, type ExecuteOperation, } from "../registry.ts" import { DISPATCH_KINDS } from "../domain/state-machine.ts" import type { ToolResult } from "../result.ts" function usage(): string { return [ "apnea — multi-role workflow driver", "", "Usage: apnea [args] [--json]", "", ...OPERATIONS.map( (o) => ` ${`${o.verb} ${o.usage ?? ""}`.trim().padEnd(38)} ${o.summary}`, ), " resume resume an existing run", "", "apnea reset-rounds also accepts [--i-am-human] (CLI only — skips the TTY", "confirmation prompt that only this surface has; see README.md).", "", `dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`, "", "Exit codes: 0 ok · 1 refused/error · 2 usage · 3 still waiting (call again)", ].join("\n") } /** * Renders a usage failure through the same `renderJson`/`renderHuman` split * as every other exit path, so a `--json` caller gets parseable output on * exit 2 too instead of a plain-text message that breaks its parser. The * full command listing is only useful to a human reading a terminal, so it's * appended after the rendered error and only outside `--json` mode. */ function printUsageError(message: string, json: boolean): void { const result: ToolResult = { ok: false, error: message } console.error( json ? renderJson(result) : `${renderHuman(result)}\n\n${usage()}`, ) } export async function main(argv: string[]): Promise { const [verbRaw, ...rest] = argv const json = parseFlags(rest).flags.has("json") if (!verbRaw) { printUsageError("usage: apnea [args] [--json]", json) return EXIT_USAGE } if (verbRaw === "help" || verbRaw === "--help") { const parsed = parseOperationArgs("help", rest, { surface: "cli" }) if (!parsed.ok) { printUsageError(parsed.message, json) return EXIT_USAGE } console.log(usage()) return 0 } // Resume is an action on start; abandon has its own human confirmation. const isAction = verbRaw === "resume" const op = findByVerb(isAction ? "start" : verbRaw) if (!op) { printUsageError(`unknown command: ${verbRaw}`, json) return EXIT_USAGE } const parsed = parseOperationArgs(verbRaw, rest, { surface: "cli" }) if (!parsed.ok) { printUsageError(parsed.message, json) return EXIT_USAGE } const { flags, values, positional } = parsed const built = buildParams( op.verb, isAction ? verbRaw : null, flags, values, positional, ) if (!built.ok) { printUsageError( built.message ?? `usage: apnea ${op.verb} ${op.usage}`.trim(), json, ) return EXIT_USAGE } if (op.humanOnly && op.verb !== "abandon") { const gate = String(built.params.gate ?? "") const confirmed = await confirmHuman( gate, prodHumanGateDeps, flags.has("i-am-human"), ) if (!confirmed.ok) { const result: ToolResult = { ok: false, error: confirmed.reason } console.error(json ? renderJson(result) : renderHuman(result)) return EXIT_ERROR } } const result = await executeWithSignals( executeOperation, op.verb, built.params, ) const text = json ? renderJson(result) : renderHuman(result) if (result.ok) console.log(text) else console.error(text) return exitCodeFor(result) } export type SignalTarget = { on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown off: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown } export async function executeWithSignals( execute: ExecuteOperation, verb: string, params: Record, target: SignalTarget = process, exit: (code: number) => void = (code) => process.exit(code), ): Promise { const controller = new AbortController() let interrupts = 0 const abort = () => controller.abort() const onInterrupt = () => { interrupts += 1 if (interrupts >= 2) { // A second Ctrl+C must always terminate, even if an operation stopped // observing the abort signal. controller.abort() exit(130) return } abort() } target.on("SIGINT", onInterrupt) target.on("SIGTERM", abort) try { return await execute(verb, params, { signal: controller.signal }) } finally { target.off("SIGINT", onInterrupt) target.off("SIGTERM", abort) } } export type BuildParamsResult = | { ok: true; params: Record } | { ok: false; message?: string } export function buildParams( verb: string, action: string | null, flags: Set, values: Map, positional: string[], ): BuildParamsResult { switch (verb) { case "abandon": return { ok: true, params: { confirm: values.get("confirm"), stop_panes: flags.has("stop-panes") || undefined, stopped_work: flags.has("stopped-work") || undefined, acknowledge_corrupt: flags.has("acknowledge-corrupt") || undefined, }, } case "start": { if (action) return { ok: true, params: { action } } const goal = positional.join(" ").trim() if (!goal) { return { ok: false, message: "usage: apnea start [--allow-dirty] [--slug=name]", } } return { ok: true, params: { action: "start", goal, slug: values.get("slug"), allow_dirty: flags.has("allow-dirty") || undefined, }, } } case "dispatch": { const kind = positional[0] if (!kind || !DISPATCH_KINDS.includes(kind as never)) { return { ok: false, message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework] [--redeliver]`, } } return { ok: true, params: { kind, rework: flags.has("rework") || undefined, redeliver: flags.has("redeliver") || undefined, }, } } case "wait": { // `--timeout` and `--budget` are the same knob: how long THIS call // blocks. The role's deadline comes from config, stamped at dispatch. const poll = parseNumFlag(values, "poll") if (!poll.ok) { return { ok: false, message: `usage: apnea wait [--poll=] (got --poll=${poll.raw})`, } } const budget = parseNumFlag(values, "budget") if (!budget.ok) { return { ok: false, message: `usage: apnea wait [--budget=] (got --budget=${budget.raw})`, } } const timeout = parseNumFlag(values, "timeout") if (!timeout.ok) { return { ok: false, message: `usage: apnea wait [--timeout=] (got --timeout=${timeout.raw})`, } } return { ok: true, params: { poll_ms: poll.value, budget_ms: budget.value ?? timeout.value, }, } } case "commit": { const message = positional.join(" ").trim() return { ok: true, params: { message: message || undefined, no_remaining_phases: flags.has("done") || undefined, }, } } case "status": return { ok: true, params: {} } case "reset-rounds": { const gate = positional[0] if (!gate) { return { ok: false, message: "usage: apnea reset-rounds " } } return { ok: true, params: { gate } } } case "setup": return { ok: true, params: { project: flags.has("project") || undefined, force: flags.has("force") || undefined, agents_md: flags.has("agents-md") || undefined, }, } default: return { ok: false } } } if (import.meta.main) { process.exit(await main(process.argv.slice(2))) }