burgee
Concepts

Exit codes and errors

Eight typed exit codes a caller can branch on, one error envelope under --json, and a fixed rule for which stream a failure goes to.

What it is

An exit code is the one thing every caller reads — a shell, a CI runner, an agent — before it decides what to do next. burgee makes it a contract: eight named codes, each with one meaning, and nothing else reaches process.exitCode.

CodeNameMeansThe caller should
0OKthe command completedcarry on
1RUNTIMEthe command ran and failed; never accompanied by helpread the message
2USAGEbad arguments, unknown command, missing flag, a prompt with nobody to answerrewrite the command
3CONFIGa config file or the environment could not be loaded or validatedfix the environment
4CANCELLEDthe user or caller cancelled, or the command needs a decision (actionRequired)decide, then run a next command
5AUTHa credential is missing, expired or refusedlog in and run it again
6TIMEOUTthe deadline --timeout set passedrun it again with more time
130SIGINTinterrupted, after the terminal was restoredstop

ExitCode and isExitCode() are exported from burgee. --schema carries the table as exitCodes, so an agent reads the codes from the program instead of from this page, and root --help prints it under Exit codes:, one line per code with what to do about it. The section is generated from ExitCode, so a code appears in help the day it joins the contract (R4: lists every ExitCode, by number and name, under "Exit codes:" after the commands in help-snapshot.test.ts).

Why it exists

A CLI that exits 1 for everything tells a caller "something went wrong" and nothing about what to do. The split above is by response: USAGE says fix the command, CONFIG says fix the environment, AUTH says fix the credential, and none of them needs the message parsed. AUTH is 5 rather than gh's 4 because 4 was already CANCELLED, and moving a published code is a breaking change for everyone who branches on it (the reasoning is in exit-code.ts). TIMEOUT is 6 rather than GNU timeout's 124 for the same reason: 124 is inside the 7–125 range defineError hands to authors, and 6 was the one number the contract had reserved and nobody could hold (D-20261011-timeout-exit-code). A program run under timeout 5s still exits 124, because the wrapper sets it.

A deadline is the caller's. Every command takes --timeout <duration> (500ms, 5s, 2m, or a bare number of milliseconds). When it passes, ctx.signal aborts with the timeout as its reason, the run's ctx.onExit handlers run once, and the process leaves with 6 and error: timed out after … ms (a handler awaiting forever under --timeout 50 exits TIMEOUT, its cleanup ran once, and ctx.signal aborted in timeout.test.ts).

The rules

Only the eight. isExitCode() accepts exactly these (recognises only the eight codes in exit-code.test.ts). exit-code-lock.test.ts reads them out of exit-code.ts and fails on any bare numeric literal passed to an exit call or assigned to process.exitCode (no bare literal reaches an exit outside the two front-ends), and on any package's named exit constant that is not one of them (every exit constant any package declares is one of the eight). The two exceptions are the commander and yargs front-ends, which reproduce their host's own exit codes because the host's suite grades exactly that.

A thrown error becomes a code in a fixed order (describeFailure in failure.ts):

  1. ctx.exit(code) with one of the eight leaves silently with that code.
  2. ctx.actionRequired(…) is 4, with its next commands.
  3. UsageError is 2, AuthError is 5, and seniority's ConfigError is 3.
  4. A class made with defineError({ name, code }) leaves with its own code.
  5. An object whose code is the string 'USAGE', 'CONFIG', 'CANCELLED' or 'AUTH' leaves with that code. This is how caique's "nobody is there to answer" refusal reaches exit 2 with no dependency between caique and burgee.
  6. An unknown or malformed option from the parser is 2, with a hint and, where one is close enough, the spelling it meant.
  7. Anything else — any other Error, a rejection, a thrown string — is 1.

Your own codes. defineError({ name, code }) claims a code from 7 to 125 for one class: below 7 is the contract, and 126 and above mean not executable, not found and killed by a signal to a shell. A second class claiming a code is refused when it is defined, and a subclass leaves with its parent's code (defineError (E7) in define-error.test.ts).

A signal is not an exit code. On SIGINT the program's cleanup runs, the terminal is restored, and the process dies of the signal, which a shell reports as 130 (Shutdown has the mechanism; dies of the signal rather than exiting, which is what 130 actually means in pty-signal.test.ts).

The envelope, and where errors go

Runstdoutstderr
successthe result as textwarnings, such as a deprecation
success, --json{"ok":true,"data":…,"meta":{"provenance":…}}warnings
a result naming a non-zero exitCode, --json{"ok":false,"data":…,"meta":{"provenance":…}}warnings
failurenothingerror: <message>, then hint: and fix: when the error carries them, then usage: when there is no fix; then, unless stderr is a terminal, {"error":{"kind":…,"code":…,"message":…}}
failure, --json{"ok":false,"error":{"kind":…,"code":…,"message":…,"hint"?:…,"fix"?:…,"usage"?:…}}{"error":{"kind":…,"code":…,"message":…}}
needs a decision, --json{"ok":false,"status":"action_required","reason":…,"message":…,"next":[…],"error":{"kind":"cancelled","code":4,…}}{"error":{"kind":"cancelled","code":4,…}}

Under --json a failure goes to stdout, alone, so a caller that parses stdout gets exactly one JSON document whether the command succeeded or not; error.code is the numeric exit code and error.kind the word for it. hint is for a person and fix is a command a caller can run. Pinned by $name: the envelope is on stdout, alone, and stderr is the one structured line (R6) and puts a usage failure on stdout too, so one rule covers every --json failure in json-failure.test.ts.

Every failure names its kind, so a consumer branches on a word rather than parsing prose: runtime, usage, config, cancelled, auth, timeout and interrupted, one per code above, or the kind your own class declares. --schema lists them all under errors, each with its exit code and whether a retry may succeed. And stderr ends with the same failure as one JSON line whenever it is not a terminal a person reads: always under --json, and after the prose when stderr is piped or captured. At a terminal nothing changes. That last line is what clispec's "Structured errors" and cli-agent-lint's SD-1 read, and it means a caller that only keeps stderr still gets the kind and the code. The examples below are run with stderr piped, so they show it.

ok is the exit code's verdict. A command that finishes and names a non-zero exitCode in its result, like burgee check refusing a plugin or burgee migrate with refusals, keeps its report in data and says "ok":false, because it exits non-zero. Exit 1 there does not mean nothing happened: burgee migrate has rewritten every file it could, and its report's first line, and its summary, says partial: N files rewritten, M refused (Migrate has its codes). Pinned by says ok: false in its --json envelope on a refusal, because it exits 1, and still carries the fix in program.test.ts.

A failure says what to do next. fix is one line to run, and it is filled in only when there is exactly one. It is always the caller's own command line, from the program name, with one thing corrected: an unknown option replaced by the flag it was nearest to (--nmae=ada becomes --name=ada), an unknown command's word replaced by the command it was nearest to, or, when the word was really an argument, the one command below that takes it inserted before it (config user.name becomes config get user.name). A word typed with dashes where a command goes is matched by its name, so --get is config get. A fix is never run for you. The one exception is a request for JSON in another CLI's spelling or position (--format json, or --json ahead of the command), which is not a refusal at all: the command runs with --json (Agent surfaces). When there is none and the code is USAGE or RUNTIME, the failure carries usage instead. That is the failing command's usage line from the program name, then its visible options with the notes help prints: required, default, choices, relations and env. An unknown command carries the commands that exist and points at --schema, and at --explain for where a value came from. Either list is at most 8 rows, and more names the --help that has the rest. It is a reminder, not a help screen: no description, no examples, no global options, and the exit code is the one the table above gives. AUTH, CONFIG and your own codes carry no usage, because their remedy is not an option. In the envelope it is "usage":{"command":"deploy push [options]","options":[{"name":"--target <value>","description":"where to push (required)"}]}, with commands in place of options for an unknown command. Pinned by failure-teaches.test.ts.

On burgee/commander and burgee/yargs, error.code is a string ('usage', 'auth', 'runtime'), and a parse error keeps the host's own code, such as commander.unknownOption: the front-ends reproduce their host, and add neither kind nor the stderr line.

Your own class's kind is its name in snake case, less a trailing Error (QuotaError is quota), or the one it declares: defineError({ name: 'QuotaError', code: 7, kind: 'quota', retryable: false, description: 'the target is out of quota' }). A declared kind is snake_case and one class's alone, or defineError throws. A kind someone already holds, such as a class named TimeoutError beside the contract's timeout, is told apart by its code (timeout_12).

Run it

deploy.mjs
import { AuthError, defineCommand, defineError, defineProgram, run } from 'burgee';

const QuotaError = defineError({ name: 'QuotaError', code: 7 });

const program = defineProgram({
  name: 'deploy',
  commands: [
    defineCommand({
      name: 'push',
      description: 'Push a build',
      effects: 'non_idempotent',
      options: {
        target: { type: 'string', required: true, description: 'where to push' },
        token: { type: 'string', description: 'an access token' },
      },
      run: ({ options }) => {
        if (options.token === undefined) throw new AuthError('no access token', 'pass --token', 'run `deploy login` first');
        if (options.target === 'moon') throw new QuotaError('the moon is out of quota', { hint: 'try --target mars' });
        return { target: options.target, pushed: true };
      },
    }),
  ],
});

await run(program);
node deploy.mjs push --target prod --token t --json
{"ok":true,"data":{"target":"prod","pushed":true},"meta":{"provenance":{"target":{"source":"flag","location":"--target"},"token":{"source":"flag","location":"--token"}}}}

A missing credential is 5, as prose on stderr or as the envelope on stdout:

node deploy.mjs push --target prod
error: no access token
hint: pass --token
fix: run `deploy login` first
{"error":{"kind":"auth","code":5,"message":"no access token"}}
node deploy.mjs push --target prod --json
{"ok":false,"error":{"kind":"auth","code":5,"message":"no access token","hint":"pass --token","fix":"run `deploy login` first"}}
{"error":{"kind":"auth","code":5,"message":"no access token"}}

A missing option and a mistyped command are both 2. With no fix to run, the missing option carries what push takes:

node deploy.mjs push --token t --json
{"ok":false,"error":{"kind":"usage","code":2,"message":"missing required option --target","hint":"pass --target <value>","usage":{"command":"deploy push [options]","options":[{"name":"--target <value>","description":"where to push (required)"},{"name":"--token <value>","description":"an access token"}]}}}
{"error":{"kind":"usage","code":2,"message":"missing required option --target"}}

A mistyped command is corrected, and the fix is the caller's own line with the word replaced:

node deploy.mjs psuh --target prod
error: unknown command "psuh"
hint: did you mean push?
fix: deploy push --target prod
{"error":{"kind":"usage","code":2,"message":"unknown command \"psuh\""}}

An author's own class leaves with its own code:

node deploy.mjs push --target moon --token t --json
{"ok":false,"error":{"kind":"quota","code":7,"message":"the moon is out of quota","hint":"try --target mars"}}
{"error":{"kind":"quota","code":7,"message":"the moon is out of quota"}}

Where the rules live

On this page