Skip to content

feat(cli): classify failures with distinct exit codes and a JSON error envelope#62

Open
dark-sorceror wants to merge 7 commits into
mainfrom
feat/error-exit-codes
Open

feat(cli): classify failures with distinct exit codes and a JSON error envelope#62
dark-sorceror wants to merge 7 commits into
mainfrom
feat/error-exit-codes

Conversation

@dark-sorceror

@dark-sorceror dark-sorceror commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #55

Every failure mode used to look identical from the outside: exit code 1 and an error: <prose> line on stderr, even under --json. A script driving traceroot had no way to tell "retry later" (network) from "re-authenticate" (auth) from "give up" (not-found) from "I built the command wrong" (usage) without parsing English error strings.

CliError already carried an exitCode field that every one of its ~43 call sites left at the default 1. This PR wires it up: each failure is now classified into one of five exit codes, and under --json every failure emits a single structured error line instead of prose.

How it works

Code Class JSON code Meaning
0 success The command completed.
1 internal internal Unexpected/internal error (the default when nothing else fits).
2 usage usage Invalid arguments or options — bad flag value, unknown agent/skill, missing required input, or a commander-native failure (unknown option/command, missing option argument).
3 auth auth Authentication required or invalid: HTTP 401/403, or no local credentials.
4 not_found not_found The requested resource does not exist (HTTP 404).
5 network network Network failure or timeout — transient, so a retry may succeed.

Human-mode stderr is unchanged in shape (error: <message>); only the exit code differs. Under --json, a failure instead writes exactly one line to stderr and leaves stdout empty, so a jq pipeline is never corrupted by an error:

{"error":{"code":"not_found","message":"Trace not found"}}

HTTP statuses are mapped to classes centrally in src/api/client.ts (401/403 → auth, 404 → not_found, everything else non-2xx → internal); network errors and timeouts map to network; a malformed --host maps to usage.

Commander's own failures (unknown option, unknown command, missing option argument) used to bypass CliError entirely and call process.exit directly. program.exitOverride() now routes them through the same central handler, so traceroot traces list --bogusflag and traceroot boguscmd exit 2 with the standard error: line (or JSON envelope under --json), reported exactly once — commander's own printing is suppressed to avoid a double message. --help and --version are unaffected: they still print to stdout and exit 0.

process.exit was also being called immediately after the stderr write, which could truncate the message on a piped/slow stderr (notably on Windows). The process now sets process.exitCode and writes an empty chunk to stderr, exiting only once that write's callback confirms the pipe has drained — closing the gap without reintroducing the truncation risk, and without leaving a pending socket (e.g. an undici connect-timeout) holding the event loop open for seconds after the error was already reported.

What's included

Exit-code machinery

  • src/output.tsExitCode enum (usage/auth/notFound/network/internal), exitCodeToString, CliError's exitCode param, and reportError growing a { json } option that switches between the prose line and the JSON envelope

HTTP/network classification

  • src/api/client.tsexitCodeForStatus maps 401/403 → auth, 404 → not_found, else → internal; network errors and AbortSignal.timeout → network; malformed --host → usage

CLI entry point

  • src/cli.tsprocess.exitCode instead of an immediate process.exit; exitAfterStderrDrain helper that exits only after stderr has flushed; program.exitOverride() plus a CommanderError branch in run() that routes commander-native usage errors through reportError (exit 2, single message, JSON envelope support), while --help/--version keep exiting 0 through their normal path

Call-site classification (each CliError now passes an explicit ExitCode)

  • src/agents/index.ts, src/agents/select.ts — unknown/missing --agent → usage
  • src/skills/registry.ts, src/skills/select.ts — unknown/missing skill → usage
  • src/commands/shared.ts — missing API key / host → auth
  • src/commands/login.ts — missing API key in non-interactive login → auth
  • src/commands/traces/list.ts, src/commands/detectors/list.ts, src/commands/findings/list.ts, src/commands/findings/get.ts — bad --limit/--from/--to/stray arguments → usage
  • src/commands/instrument.ts — missing --output in non-interactive mode → usage
  • src/context.ts — invalid --timeout → usage
  • src/util/index.ts — invalid --since duration → usage

Docs

  • README.md — new "Exit codes" section documenting the table and the --json envelope shape
  • src/cli.ts — the same table appended to traceroot --help output

Tests

  • tests/output.test.tsreportError's JSON-envelope branch, exitCodeToString mapping, unchanged human-mode behavior
  • tests/api/client.test.ts — HTTP status → exit-code class mapping, network/timeout → network, bad host → usage
  • tests/output.contract.test.ts — spawns the real binary: per-class exit codes, envelope shape and stdout purity under --json, commander-native cases (unknown option/command, missing option argument, --help/--version still exit 0), all run against an isolated temp cwd (mkdtempSync) so a developer's stray repo .env can't leak credentials into the test and flip a missing-credentials case into a network one

Preserved behavior (deliberate, not overlooked)

  • requestOptional's 404 → null (used by findFindingByTrace) is unaffected — that path never throws, so it never goes through the exit-code contract
  • Bare traceroot still prints help to stderr and exits 1
  • "already exists, use --force" preconditions stay internal (1) — not a usage/auth/not-found/network failure by this taxonomy

Test plan

  • vitest — 615/615 tests pass, including strengthened contract tests that spawn the real binary and assert per-class exit codes, JSON envelope shape, commander-native cases, and the isolated temp cwd
  • Typecheck and lint/format clean
  • traceroot traces get <bad-id> --json against app.traceroot.ai → exit 4, {"error":{"code":"not_found",...}}
  • traceroot status --api-key <invalid> --json → exit 3, code: "auth"
  • traceroot status with no credentials configured → exit 3
  • traceroot traces list --host http://dead-host --json → exit 5, code: "network"
  • traceroot traces list --host <connect-timeout-host> → exits 5 in ~880ms (previously lingered ~10s before the process actually ended)
  • traceroot traces list --limit banana → exit 2; same for traceroot --bogusflag and traceroot boguscmd, including the single-line usage envelope with empty stdout under --json
  • traceroot traces get <valid-id>, --help, --version → exit 0; --help output includes the exit-code table
  • 2MB of stderr piped through a slow/throttled reader arrives complete (no truncation from the drain-before-exit change)

Summary by cubic

Adds distinct exit codes for CLI failure classes and a single-line JSON error envelope under --json, so scripts can branch without parsing prose. Also routes commander usage errors through the same handler and waits for stderr to flush before exiting.

  • New Features

    • Exit codes: 2 usage, 3 auth, 4 not_found, 5 network (default 1 internal).
    • Under --json, failures write exactly one line to stderr: {"error":{"code","message"}}; stdout stays empty.
    • Central HTTP mapping: 401/403 → auth, 404 → not_found, other non-2xx → internal; network errors/timeouts → network; malformed --host → usage.
    • commander native errors (unknown command/option, missing option arg) now exit 2 and respect --json; --help/--version still exit 0. Help includes an exit-code table.
    • On failure, the process exits only after stderr drains to prevent truncated output and lingering event-loop timers.
  • Migration

    • Branch on exit codes instead of parsing stderr: 5 retry, 3 re-auth, 4 give up, 2 fix input.
    • When using --json, read errors from stderr (one JSON line). Keep stdout for data pipelines.

Written for commit f09fead. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 20 files

Re-trigger cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every failure exits 1 with plain-prose stderr — scripts can't branch on failure class, even under --json

1 participant