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.
| Code | Name | Means | The caller should |
|---|---|---|---|
| 0 | OK | the command completed | carry on |
| 1 | RUNTIME | the command ran and failed; never accompanied by help | read the message |
| 2 | USAGE | bad arguments, unknown command, missing flag, a prompt with nobody to answer | rewrite the command |
| 3 | CONFIG | a config file or the environment could not be loaded or validated | fix the environment |
| 4 | CANCELLED | the user or caller cancelled, or the command needs a decision (actionRequired) | decide, then run a next command |
| 5 | AUTH | a credential is missing, expired or refused | log in and run it again |
| 6 | TIMEOUT | the deadline --timeout set passed | run it again with more time |
| 130 | SIGINT | interrupted, after the terminal was restored | stop |
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):
ctx.exit(code)with one of the eight leaves silently with that code.ctx.actionRequired(…)is 4, with itsnextcommands.UsageErroris 2,AuthErroris 5, and seniority'sConfigErroris 3.- A class made with
defineError({ name, code })leaves with its own code. - An object whose
codeis 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. - An unknown or malformed option from the parser is 2, with a hint and, where one is close enough, the spelling it meant.
- 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
| Run | stdout | stderr |
|---|---|---|
| success | the result as text | warnings, 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 |
| failure | nothing | error: <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
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);{"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:
error: no access token
hint: pass --token
fix: run `deploy login` first
{"error":{"kind":"auth","code":5,"message":"no access token"}}{"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:
{"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:
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:
{"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
- Spec: burgee E1–E7.
- Your CLI is an agent tool — the
action_requiredenvelope andnext[]. - The floor — the requirements every CLI on burgee meets.
Agent surfaces
One declaration projected into every form a caller reads — help, the --json envelope, --schema, an MCP server, completions — and a program that stops and says what to run instead of prompting an agent.
Shutdown and terminal restore
closeout's one registry for every way a process leaves: three phases, one deadline for the whole shutdown, the terminal restored last, and a process that dies of the signal it was sent.