Summary
Every failure mode — not found, invalid API key, network down, usage errors — produces the same observable outcome: exit code 1 and a plain-English error: <prose> line on stderr. This holds even under --json: stdout stays empty and the error is still unstructured prose. A script or coding agent driving traceroot cannot distinguish "retry later" (network) from "re-authenticate" (auth) from "give up" (not-found) from "I built the command wrong" (usage) — short of parsing English strings that silently break the first time a message is reworded.
The machinery already exists and is unused: CliError carries an exitCode field (src/output.ts:74-82) that defaults to 1, and none of the 43 new CliError(...) call sites passes a second argument. The uniform behavior is pinned by tests/output.contract.test.ts, so the contract tests need to evolve with the fix.
Current behavior
traceroot traces get <bad-id> --json → stderr error: Trace not found, stdout empty, exit 1.
traceroot status --api-key tr-invalid --json → stderr error: Invalid API key, stdout empty, exit 1.
traceroot traces list --host http://localhost:9999 --json → stderr error: request to … failed: fetch failed, exit 1.
traceroot traces list --limit banana → stderr error: --limit must be a positive integer, exit 1.
Four failure classes; identical exit code, no machine-readable output anywhere. Mechanics: reportError (src/output.ts:111-115) returns err.exitCode (always the default 1) and has no awareness of --json.
Proposal
- Distinct exit codes per failure class — e.g.
2 usage/validation, 3 auth (401/403), 4 not-found (404), 5 network/timeout, keeping 1 for unexpected internal errors. Map HTTP statuses to classes centrally in src/api/client.ts where the CliError is thrown.
- JSON error envelope under
--json: emit {"error":{"code":"not_found","message":"Trace not found"}} (one line; pick stdout or stderr and document it) so a JSON consumer never falls back to prose parsing.
- Document the exit-code table in README and
--help.
- Related:
src/cli.ts:81-83 calls process.exit(code) immediately after the stderr write, which can truncate the message on piped stderr (notably Windows); process.exitCode is already set — let the process drain naturally.
- Extend
tests/output.contract.test.ts to pin the new codes and envelope.
Impact
- Automated consumers retry the wrong thing or give up wrong — they can't tell auth-expired from doesn't-exist. This is the gap between "has
--json" and "is actually scriptable".
- CI cannot gate on failure class; a network blip and a genuine error look identical.
- Prose-parsing workarounds turn every error-message improvement into a silent breaking change.
Acceptance criteria
- Not-found, auth, network, and usage failures exit with distinct, documented codes; unexpected internal errors keep exit 1.
- With
--json, every failure also emits a single-line {"error":{"code":<stable-string>,"message":<text>}}; code values are a stable, documented enum.
- Without
--json, stderr output is unchanged in shape; only the exit code differs.
- The exit-code table appears in README and is discoverable from
--help.
process.exit immediately after a stderr write is removed in favor of process.exitCode.
- Contract tests assert exit code per failure class, envelope shape under
--json, and stdout purity for human-mode errors.
Summary
Every failure mode — not found, invalid API key, network down, usage errors — produces the same observable outcome: exit code 1 and a plain-English
error: <prose>line on stderr. This holds even under--json: stdout stays empty and the error is still unstructured prose. A script or coding agent drivingtracerootcannot distinguish "retry later" (network) from "re-authenticate" (auth) from "give up" (not-found) from "I built the command wrong" (usage) — short of parsing English strings that silently break the first time a message is reworded.The machinery already exists and is unused:
CliErrorcarries anexitCodefield (src/output.ts:74-82) that defaults to 1, and none of the 43new CliError(...)call sites passes a second argument. The uniform behavior is pinned bytests/output.contract.test.ts, so the contract tests need to evolve with the fix.Current behavior
traceroot traces get <bad-id> --json→ stderrerror: Trace not found, stdout empty, exit 1.traceroot status --api-key tr-invalid --json→ stderrerror: Invalid API key, stdout empty, exit 1.traceroot traces list --host http://localhost:9999 --json→ stderrerror: request to … failed: fetch failed, exit 1.traceroot traces list --limit banana→ stderrerror: --limit must be a positive integer, exit 1.Four failure classes; identical exit code, no machine-readable output anywhere. Mechanics:
reportError(src/output.ts:111-115) returnserr.exitCode(always the default 1) and has no awareness of--json.Proposal
2usage/validation,3auth (401/403),4not-found (404),5network/timeout, keeping1for unexpected internal errors. Map HTTP statuses to classes centrally insrc/api/client.tswhere theCliErroris thrown.--json: emit{"error":{"code":"not_found","message":"Trace not found"}}(one line; pick stdout or stderr and document it) so a JSON consumer never falls back to prose parsing.--help.src/cli.ts:81-83callsprocess.exit(code)immediately after the stderr write, which can truncate the message on piped stderr (notably Windows);process.exitCodeis already set — let the process drain naturally.tests/output.contract.test.tsto pin the new codes and envelope.Impact
--json" and "is actually scriptable".Acceptance criteria
--json, every failure also emits a single-line{"error":{"code":<stable-string>,"message":<text>}};codevalues are a stable, documented enum.--json, stderr output is unchanged in shape; only the exit code differs.--help.process.exitimmediately after a stderr write is removed in favor ofprocess.exitCode.--json, and stdout purity for human-mode errors.