Skip to content
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,26 @@ traceroot findings list --detector <detector-id> --since 7d --json | jq '.data[]
traceroot findings get --trace 99224be337d725fd5e8f2e7b45dc22ef
```

### Exit codes

Every command exits with a class-specific code so scripts can branch on the kind
of failure — retry a network blip, re-authenticate, or give up on a missing
resource — without parsing prose.

| 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). |
| `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. |

On failure the human-readable message goes to stderr as `error: <message>`. Under
`--json` the failure is written to stderr instead as exactly one line —
`{"error":{"code":"<class>","message":"<text>"}}` — while stdout stays empty, so a
`jq` pipeline over stdout is never corrupted by an error.

## Skills & agents

Make your coding agent TraceRoot-aware without touching your application source. The
Expand Down
7 changes: 5 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { homedir } from "node:os";
import { isAbsolute, relative, sep } from "node:path";
import { CliError } from "../output.js";
import { CliError, ExitCode } from "../output.js";
import { claudeAdapter } from "./claude.js";
import { codexAdapter } from "./codex.js";
import { genericAdapter } from "./generic.js";
Expand All @@ -19,7 +19,10 @@ export const AGENT_IDS: readonly AgentId[] = ALL_AGENTS.map((a) => a.id);
export function requireAgent(id: string): AgentAdapter {
const adapter = ALL_AGENTS.find((a) => a.id === id);
if (adapter === undefined) {
throw new CliError(`Unknown agent '${id}'. Supported agents: ${AGENT_IDS.join(", ")}.`);
throw new CliError(
`Unknown agent '${id}'. Supported agents: ${AGENT_IDS.join(", ")}.`,
ExitCode.usage,
);
}
return adapter;
}
Expand Down
3 changes: 2 additions & 1 deletion src/agents/select.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CliError } from "../output.js";
import { CliError, ExitCode } from "../output.js";
import { type Prompt, dim, isInteractive, readLine } from "../prompt.js";
import { AGENT_IDS, requireAgent } from "./index.js";
import type { AgentAdapter, AgentId } from "./types.js";
Expand Down Expand Up @@ -40,6 +40,7 @@ export async function resolveAgentOrPrompt(input: ResolveAgentInput): Promise<Ag
if (json || !interactive) {
throw new CliError(
`Missing required option --agent.\nChoose one of: ${AGENT_IDS.join(", ")}.\nExample:\n ${example}`,
ExitCode.usage,
);
}

Expand Down
36 changes: 30 additions & 6 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CliError } from "../output.js";
import { CliError, ExitCode } from "../output.js";
import type { paths } from "./generated/schema.js";

/** Default per-request timeout when a caller doesn't specify one. */
Expand Down Expand Up @@ -81,6 +81,21 @@ function isErrorBody(value: unknown): value is ErrorBody {
return typeof value === "object" && value !== null;
}

/**
* Classifies a non-2xx HTTP status into a CLI exit-code class so scripts can tell
* re-auth (401/403) from give-up (404) from an unexpected server error. Anything
* else (5xx, other 4xx) is treated as internal (1).
*/
function exitCodeForStatus(status: number): number {
if (status === 401 || status === 403) {
return ExitCode.auth;
}
if (status === 404) {
return ExitCode.notFound;
}
return ExitCode.internal;
}

/**
* Creates a thin typed client over the public REST API. No network activity
* occurs on construction — only the request methods call `fetch`.
Expand All @@ -92,10 +107,13 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
try {
parsedHost = new URL(base);
} catch {
throw new CliError(`invalid host URL: ${base}`);
throw new CliError(`invalid host URL: ${base}`, ExitCode.usage);
}
if (parsedHost.protocol !== "http:" && parsedHost.protocol !== "https:") {
throw new CliError(`unsupported host scheme: ${parsedHost.protocol} (expected http or https)`);
throw new CliError(
`unsupported host scheme: ${parsedHost.protocol} (expected http or https)`,
ExitCode.usage,
);
}
const headers = {
authorization: `Bearer ${opts.apiKey}`,
Expand All @@ -118,7 +136,7 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
// echo back request contents and leak the api key. Mention only the host.
const message = err instanceof Error ? err.message : String(err);
const safe = message.split(opts.apiKey).join("<redacted>");
throw new CliError(`request to ${base} failed: ${safe}`);
throw new CliError(`request to ${base} failed: ${safe}`, ExitCode.network);
}
}

Expand All @@ -128,7 +146,10 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
// api-key-free message naming the host and the timeout budget.
function throwIfTimeout(err: unknown): void {
if (opts.timeoutMs !== undefined && err instanceof Error && err.name === "TimeoutError") {
throw new CliError(`request to ${base} timed out after ${opts.timeoutMs / 1000}s`);
throw new CliError(
`request to ${base} timed out after ${opts.timeoutMs / 1000}s`,
ExitCode.network,
);
}
}

Expand All @@ -142,7 +163,10 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
} catch {
// Ignore unreadable / non-JSON error bodies.
}
throw new CliError(detail ?? `request failed with status ${res.status}`);
throw new CliError(
detail ?? `request failed with status ${res.status}`,
exitCodeForStatus(res.status),
);
}

/** Reads a JSON body, translating a body-phase timeout into the same message. */
Expand Down
77 changes: 66 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { Command, Option } from "commander";
import { Command, CommanderError, Option } from "commander";
import { registerCommands } from "./commands/index.js";
import { colorizeError, handlePipeError, reportError } from "./output.js";
import { CliError, ExitCode, handlePipeError, reportError } from "./output.js";
import { getVersion } from "./version.js";

export function buildProgram(): Command {
const program = new Command();
program.name("traceroot").description("TraceRoot command line interface").version(getVersion());
// Drop the implicit `help [command]` subcommand; `-h, --help` already covers it.
program.helpCommand(false);
// Color commander's own errors (unknown command/option) the same red as the
// central error handler, so every error message is consistent.
// Make commander throw a CommanderError instead of calling process.exit, so
// its native failures (unknown option/command, missing option argument) reach
// the central handler in run() and follow the standard exit-code and `--json`
// envelope contract. Set before registerCommands so subcommands inherit it.
program.exitOverride();
// Suppress commander's own error printing: with exitOverride the same message
// travels on the thrown CommanderError and run() reports it exactly once via
// reportError (consistent prose/JSON shape and color). Help output is
// unaffected — it goes through writeOut/writeErr, not outputError.
program.configureOutput({
outputError: (str, write) => write(colorizeError(str)),
outputError: () => {},
});
// Surface program-wide global flags (e.g. `--json`) in every subcommand's
// `--help` under a "Global Options" section, so they're discoverable where
Expand Down Expand Up @@ -53,13 +60,29 @@ export function buildProgram(): Command {
.option("--json", "emit machine-readable JSON output for supported commands")
.option("--timeout <ms>", "per-request network timeout in milliseconds (default: 30000)");
registerCommands(program);
// Make the exit-code contract discoverable from `traceroot --help` so scripts
// know how to branch on failures (mirrors the README table).
program.addHelpText(
"after",
[
"",
"Exit codes:",
" 0 success",
" 1 internal unexpected/internal error",
" 2 usage invalid arguments or options",
" 3 auth authentication required or invalid",
" 4 not_found the requested resource does not exist",
" 5 network network failure or timeout (retryable)",
"",
'Under --json, failures also print {"error":{"code","message"}} to stderr.',
].join("\n"),
);
// Root action: lets global flags parse without a subcommand, while still
// rejecting an unrecognized operand so unknown-command handling is preserved.
program.action((_opts, command: Command) => {
const operands = command.args;
if (operands.length > 0) {
command.error(`error: unknown command '${operands[0]}'`, { exitCode: 1 });
return;
throw new CliError(`unknown command '${operands[0]}'`, ExitCode.usage);
}
// No subcommand given: show help and exit non-zero. Help goes to stderr
// (per the output contract: human text never pollutes stdout). An explicit
Expand All @@ -69,6 +92,19 @@ export function buildProgram(): Command {
return program;
}

/**
* Ends the process with `code` once stderr has drained. The empty write is
* queued behind any pending stderr chunks, so its completion callback fires only
* after the error text has fully reached a pipe/file — then it is safe to call
* `process.exit` without truncation. Exiting explicitly matters: a failed request
* can leave a pending socket (e.g. undici's connect timeout) holding the event
* loop open for seconds after the error was reported.
*/
function exitAfterStderrDrain(code: number): void {
process.exitCode = code;
process.stderr.write("", () => process.exit(code));
}

export async function run(argv: string[]): Promise<void> {
// Exit cleanly when a downstream reader (e.g. `head`, `jq`) closes the pipe:
// turn the resulting EPIPE into a quiet exit instead of a Node stack trace.
Expand All @@ -77,10 +113,29 @@ export async function run(argv: string[]): Promise<void> {
try {
await buildProgram().parseAsync(argv);
} catch (err) {
const code = reportError(err);
process.exitCode = code;
if (code !== 0) {
process.exit(code);
// The central catch has no resolved Context, so detect `--json` straight from
// argv (the accepted approach) to pick the machine-readable error envelope.
const json = argv.includes("--json");
if (err instanceof CommanderError) {
if (err.exitCode === 0) {
// `--help` / `--version`: normal output was already written; exit 0
// naturally so stdout flushes on its own.
return;
}
if (err.code === "commander.help") {
// Help was already rendered (e.g. bare `traceroot`); the CommanderError
// message is only a placeholder, so add no error line.
exitAfterStderrDrain(err.exitCode);
return;
}
// A commander-native usage failure (unknown option, missing option
// argument, …). Its own printing is suppressed in buildProgram, so report
// it here exactly once, stripping commander's "error: " prefix (the
// reporter adds its own).
const message = err.message.replace(/^error: /, "");
exitAfterStderrDrain(reportError(new CliError(message, ExitCode.usage), { json }));
return;
}
exitAfterStderrDrain(reportError(err, { json }));
}
}
11 changes: 10 additions & 1 deletion src/commands/detectors/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { Command } from "commander";
import type { ApiClient, ListDetectorsParams } from "../../api/client.js";
import { CliError, type Writers, defaultWriters, logProgress, writeJson } from "../../output.js";
import {
CliError,
ExitCode,
type Writers,
defaultWriters,
logProgress,
writeJson,
} from "../../output.js";
import { createStyler } from "../../render/style.js";
import { renderTable } from "../../render/table.js";
import { formatTimestamp } from "../../util/index.js";
Expand Down Expand Up @@ -129,11 +136,13 @@ export function registerDetectorsList(detectors: Command): void {
if (value !== undefined && bareDate.test(value)) {
throw new CliError(
`unexpected argument(s): ${strayJoined}.\n\nDid you mean to quote the timestamp?\n traceroot detectors list ${flag} "${value} ${strayJoined}"\n\nTimestamps with spaces must be passed as one shell argument.\nISO 8601 also works:\n traceroot detectors list ${flag} 2026-06-23T20:31:02Z\n traceroot detectors list ${flag} 2026-06-23T14:31:02-06:00`,
ExitCode.usage,
);
}
}
throw new CliError(
`unexpected argument(s): ${strayJoined}. 'detectors list' takes no positional arguments. If you meant a time filter, --from/--to take a single ISO 8601 timestamp with no spaces, e.g. --from 2026-06-23T14:29:54Z (or with an offset, 2026-06-23T14:29:54-06:00).`,
ExitCode.usage,
);
}
const opts = command.opts();
Expand Down
7 changes: 4 additions & 3 deletions src/commands/findings/get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import type { ApiClient, FindingDetail } from "../../api/client.js";
import { type Writers, CliError, defaultWriters, writeJson } from "../../output.js";
import { CliError, ExitCode, type Writers, defaultWriters, writeJson } from "../../output.js";
import { createStyler } from "../../render/style.js";
import { formatTimestamp } from "../../util/index.js";
import { contextFromCommand, requireApiClient } from "../shared.js";
Expand Down Expand Up @@ -29,10 +29,10 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
const hasTrace = traceId !== undefined && traceId.trim() !== "";

if (hasFinding && hasTrace) {
throw new CliError("provide either a finding id or --trace, not both");
throw new CliError("provide either a finding id or --trace, not both", ExitCode.usage);
}
if (!hasFinding && !hasTrace) {
throw new CliError("provide a finding id, or --trace <trace-id>");
throw new CliError("provide a finding id, or --trace <trace-id>", ExitCode.usage);
}

const finding = hasFinding
Expand Down Expand Up @@ -128,6 +128,7 @@ export function registerFindingsGet(findings: Command): void {
if (command.args.length > 1) {
throw new CliError(
`unexpected argument(s): ${command.args.slice(1).join(" ")}. 'findings get' takes a single finding id (or use --trace).`,
ExitCode.usage,
);
}
const opts = command.opts();
Expand Down
11 changes: 10 additions & 1 deletion src/commands/findings/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { Command } from "commander";
import type { ApiClient, ListFindingsParams } from "../../api/client.js";
import { type Writers, CliError, defaultWriters, logProgress, writeJson } from "../../output.js";
import {
CliError,
ExitCode,
type Writers,
defaultWriters,
logProgress,
writeJson,
} from "../../output.js";
import { createStyler } from "../../render/style.js";
import { renderTable } from "../../render/table.js";
import { formatTimestamp } from "../../util/index.js";
Expand Down Expand Up @@ -144,11 +151,13 @@ export function registerFindingsList(findings: Command): void {
if (value !== undefined && bareDate.test(value)) {
throw new CliError(
`unexpected argument(s): ${strayJoined}.\n\nDid you mean to quote the timestamp?\n traceroot findings list ${flag} "${value} ${strayJoined}"\n\nTimestamps with spaces must be passed as one shell argument.\nISO 8601 also works:\n traceroot findings list ${flag} 2026-06-23T20:31:02Z\n traceroot findings list ${flag} 2026-06-23T14:31:02-06:00`,
ExitCode.usage,
);
}
}
throw new CliError(
`unexpected argument(s): ${strayJoined}. 'findings list' takes no positional arguments. If you meant a time filter, --from/--to take a single ISO 8601 timestamp with no spaces, e.g. --from 2026-06-23T14:29:54Z (or with an offset, 2026-06-23T14:29:54-06:00).`,
ExitCode.usage,
);
}
const opts = command.opts();
Expand Down
4 changes: 3 additions & 1 deletion src/commands/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { displaySkillPath } from "../agents/index.js";
import { resolveAgentOrPrompt } from "../agents/select.js";
import {
CliError,
ExitCode,
type Writers,
defaultWriters,
logInfo,
Expand All @@ -13,8 +14,8 @@ import {
} from "../output.js";
import { confirm, dim, isInteractive, readLine, yellow } from "../prompt.js";
import { buildInstrumentPrompt } from "../prompts/instrumentPrompt.js";
import { type RepoDetection, detectRepo } from "../repo/detect.js";
import { createStyler } from "../render/style.js";
import { type RepoDetection, detectRepo } from "../repo/detect.js";
import { formatBytes } from "../util/index.js";

/** Default location for the generated prompt when neither --print nor --output is given. */
Expand Down Expand Up @@ -107,6 +108,7 @@ export async function runInstrument(deps: RunInstrumentDeps): Promise<void> {
} else {
throw new CliError(
`Missing required option --output.\nProvide a path to write the prompt, or use --print.\nExample:\n traceroot instrument --agent ${agent.id} --output ${DEFAULT_PROMPT_PATH}`,
ExitCode.usage,
);
}

Expand Down
6 changes: 3 additions & 3 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "../api/client.js";
import { writeConfig as realWriteConfig } from "../config/manager.js";
import type { AuthSource } from "../config/resolve.js";
import { CliError, type Writers, defaultWriters, logInfo, writeJson } from "../output.js";
import { CliError, ExitCode, type Writers, defaultWriters, logInfo, writeJson } from "../output.js";
import { apiKeyLabel, identity } from "../render/identity.js";
import { createStyler } from "../render/style.js";
import { DEFAULT_HOST } from "./constants.js";
Expand Down Expand Up @@ -94,10 +94,10 @@ export async function runLogin(deps: LoginDeps): Promise<void> {
} else if (deps.isInteractive) {
apiKey = (await deps.promptHidden("API key: ")).trim();
} else {
throw new CliError(MISSING_KEY);
throw new CliError(MISSING_KEY, ExitCode.auth);
}
if (apiKey === "") {
throw new CliError(MISSING_KEY);
throw new CliError(MISSING_KEY, ExitCode.auth);
}

let host: string;
Expand Down
Loading
Loading