Exit codes and errors
Seven 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: seven 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 |
| 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.
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).
The rules
Only the seven. isExitCode() accepts exactly these (recognises only the seven 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 seven). 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 seven 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 |
| failure | nothing | error: <message>, then hint: and fix: when the error carries them |
failure, --json | {"ok":false,"error":{"code":…,"message":…,"hint"?:…,"fix"?:…}} | nothing |
needs a decision, --json | {"ok":false,"status":"action_required","reason":…,"message":…,"next":[…],"error":{"code":4,…}} | nothing |
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.
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 empty, $name: plain mode is unchanged — prose on stderr, nothing on stdout and puts a usage failure on stdout too, so one rule covers every --json failure in
json-failure.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.
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{"ok":false,"error":{"code":5,"message":"no access token","hint":"pass --token","fix":"run `deploy login` first"}}A missing option and a mistyped command are both 2:
{"ok":false,"error":{"code":2,"message":"missing required option --target","hint":"pass --target <value>"}}error: unknown command "psuh"
hint: run --help to see the available commandsAn author's own class leaves with its own code:
{"ok":false,"error":{"code":7,"message":"the moon is out of quota","hint":"try --target mars"}}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.