Failing
A cli that always exits 0 is a cli whose caller — a shell script, a CI job, an agent — cannot tell
whether it worked. clibuilder reports failures two ways: it rejects invocations it cannot make sense
of, and it gives your command a way to say it failed.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
The command did what was asked, including no-ops. |
1 |
The command was called correctly but could not complete. |
2 |
The command was called incorrectly — an unknown option, a missing argument, a value of the wrong type. |
The codes are exported as exitCodes so you do not have to remember the numbers:
import { exitCodes } from 'clibuilder'
exitCodes.success // 0exitCodes.error // 1exitCodes.usage // 2This is the convention a caller driving your cli through a shell expects: 2 means do not retry
this invocation, it is malformed, while 1 means the invocation was fine, the world was not.
Codes above 2 belong to your cli
Section titled “Codes above 2 belong to your cli”clibuilder names three codes and no more. Clis that need finer exit codes, such as 3 for an
authentication failure or 4 for not found, set them on the error themselves with
CliError’s exitCode, any integer from 1 to 255. No named set above 2
is defined, in core or in @clibuilder/axi, because existing clis disagree on what 3 to 7 mean.
The exit code says success, failure or usage error. The code string on the error says which
failure. Scripts branch on the first, agents on the second, so give every failure a stable code
even when you also pick a custom exit code.
clibuilder sets process.exitCode rather than calling process.exit(), so pending writes to stdout
are never truncated — node exits with the recorded code once your cli is done.
Usage errors
Section titled “Usage errors”Anything the parser cannot reconcile with the command’s arguments and options is a usage error,
and the cli exits 2. How it is reported depends on the
output mode.
A command group is a command that has commands but no run, including a cli with no default().
It is incomplete without a sub command, so invoking it bare is a usage error too.
The global options are --help, --version, --verbose, --silent, --debug-cli, --format and
--show-config (added by .use(config())). They are accepted by every command, so they are never reported as unknown. --help and
--version are answered even when the rest of the command line is invalid, and both exit 0:
asking for help is not a usage error.
Usage errors with output: 'axi'
Section titled “Usage errors with output: 'axi'”With output: 'axi', the default, and no onUsageError handler,
clibuilder reports a usage error as one coded error on stdout, in the caller’s
--format, else the CLI’s format. It writes
nothing to stderr, prints no help, and exits 2:
$ my-cli deploy prod --port 70000error: invalid value for option --port: expected an integer from 1 to 65535, received "70000"code: invalid-valueoption: --portvalue: "70000"help[2]: "Usage: my-cli deploy <arguments> [options]","expected for --port: int 1..65535 (received \"70000\")"Only the first error is reported, ranked in this order: unknown option, conflicting options, invalid value, expect single, missing argument, extra arguments. A mistyped option often causes the errors after it, such as a value it swallowed being reported as a missing argument.
code |
Fields | help after the usage line |
|---|---|---|
unknown-option |
option |
the command’s options |
conflicting-options |
option, conflictsWith |
none |
invalid-value |
option or argument, value (as typed) |
allowed for --level: low, high (received "x") for a t.enum or --format, else the constraint of a t declaration, such as int 1..65535 or string /^v\d+$/ |
expect-single |
option or argument, value |
none |
missing-argument |
argument, or option for a required t option |
the command’s arguments, or its options |
extra-arguments |
arguments |
the command’s arguments, or a group’s commands |
missing-command |
command |
the group’s commands |
missing-command is a command group invoked bare. A config that fails the command’s schema is
reported the same way, with code invalid-config, the messages for each failing key as fields, and
exit code 1. Neither prints the help.
An onUsageError handler, on cli() or on a command, still takes over. @clibuilder/axi’s
createUsageErrorHandler() writes the same fields, except that it joins every error into one
message, codes an extra argument unexpected-argument, and has no usage line, option, argument
or value fields.
Usage errors with output: 'console'
Section titled “Usage errors with output: 'console'”With output: 'console', and in clibuilder 11.5.0, every error found is reported by name on
stderr, followed by the command’s help message on stdout:
$ my-cli deploy --dyr-rununknown option --dyr-run
Usage: my-cli deploy <arguments> [options]...Every problem found is reported, not just the first:
$ my-cli deploy --bogusunknown option --bogusmissing required argument <target>A command group invoked bare prints its help. A config that fails the command’s schema is reported
as config fails validation: and the messages for each key, followed by the help, and exits 1.
Reporting usage errors yourself
Section titled “Reporting usage errors yourself”To choose the format, the stream, and what is printed, declare an onUsageError handler.
clibuilder then prints nothing for usage errors. It calls the handler with the structured errors, the
matched command, and that command’s ui. To cover the whole cli, pass the handler to cli():
import { cli } from 'clibuilder'
cli({ name: 'my-cli', version: '1.0.0', onUsageError(errors, { command }) { const flags = Object.keys(command.options ?? {}).map((k) => `--${k}`) for (const e of errors) { if (e.type === 'invalid-key') { process.stdout.write(`${JSON.stringify({ code: 'unknown-flag', flag: `--${e.key}`, flags })}\n`) } else { process.stdout.write(`${JSON.stringify({ code: e.type })}\n`) } } }})Each error is a cli.UsageError, identified by its type:
type |
code (since 12.0.0) |
Fields |
|---|---|---|
invalid-key |
unknown-option |
key: the unknown option, without dashes |
conflicting-options |
conflicting-options |
key, conflictsWith: the two options, as typed |
missing-argument |
missing-argument |
name: the argument |
extra-arguments |
extra-arguments |
name, values: the arguments nothing declared |
invalid-value |
invalid-value |
key, value, message |
expect-single |
expect-single |
key, value, keyKind |
command is the command the invocation matched, with its declared arguments and options.
ui.showHelp() prints that command’s text help on stdout. It is deprecated: in clibuilder 12,
list what the caller needs in your own report instead.
A command can declare its own handler. It covers that command and its sub-commands. clibuilder calls exactly one handler, the first it finds in this order:
- the matched command’s own handler;
- the handler of the nearest command that contains it;
- the handler passed to
cli().
If none is found, clibuilder reports the error as described above.
This is how a plugin keeps its error format in any cli that loads it with
@clibuilder/plugins. Declare the handler on the
plugin’s group command:
import type { PluginActivationContext } from '@clibuilder/plugins'import { list, send } from './commands.js'
export function activate(ctx: PluginActivationContext) { ctx.addCommand({ name: 'mux', onUsageError(errors) { for (const e of errors) process.stdout.write(`${JSON.stringify({ code: e.type })}\n`) }, commands: [send, list] })}The cli still exits 2. To exit with another code, return it from the handler. If the handler throws,
parse() rejects with that error.
Failing from a command
Section titled “Failing from a command”Throw CliError from run(). clibuilder reports it once, with its help lines, and exits with the
code you chose. With output: 'axi', the default, the report is encoded on stdout (see
Errors with output: 'axi'). With output: 'console' the message and
the help lines go to stderr. parse() resolves to the exit code instead of rejecting, so the
failure is reported to the user rather than surfacing as an unhandled rejection with a stack trace.
import { CliError, command, exitCodes, t } from 'clibuilder'
const fields = ['name', 'version', 'description']
export const view = command({ name: 'view', description: 'show a package', options: { fields: { description: 'fields to show', type: t.array(t.string()).optional() } }, async run(args) { const unknown = (args.fields ?? []).filter((f) => !fields.includes(f)) if (unknown.length > 0) { throw new CliError(`unknown field: ${unknown.join(', ')}`, { exitCode: exitCodes.usage, help: `valid fields: ${fields.join(', ')}` }) } // ... }})CliError takes:
| Option | Type | Description |
|---|---|---|
exitCode |
number |
The code the cli exits with. Defaults to exitCodes.error (1). Since 12.0.0: any integer from 1 to 255; 0 and any other value throw a RangeError. |
code |
string |
Since 12.0.0. A stable kebab-case string that says which failure this is, such as not-found. Defaults to error. Anything else throws a RangeError. |
details |
Record<string, scalar | scalar[]> |
Since 12.0.0. Extra fields an agent can act on, such as the name that was not found. Flat: each value is a string, number, boolean, null, or a list of them. A key may not be error, code or help. |
help |
string | string[] |
What the caller should do about it. Reported after the message, one line each. |
cause |
unknown |
The underlying error, kept for the caller to inspect. |
The error carries them as message, code, details, help (always a list) and exitCode.
You can also pass the whole report as one object:
throw new CliError({ message: `no plugin named ${name}`, code: 'not-found', details: { name }, help: 'run `my-cli plugins list` to see the installed plugins', exitCode: 4})The exit code says success, failure, or usage error. The code says which failure. Scripts
branch on the first and agents on the second. clibuilder uses only 0, 1 and 2; a CLI that wants its
own codes above 2, such as 3 for not found, sets them itself.
Use exitCodes.usage when the invocation itself was wrong — a value your schema could not have
caught, such as an option value that is only valid for certain other values. Use the default for
everything else: a network call that failed, a file that was not there, a build that did not
succeed.
Any other error thrown from run() is a defect rather than a reported failure, so it keeps
propagating out of parse() untouched.
Errors with output: 'axi'
Section titled “Errors with output: 'axi'”With output: 'axi', the default, the error is an
answer: clibuilder renders it once on stdout, and writes nothing to stderr for it. The fields are
flat, in this order: error (the message), code, the details fields, then help, which is left
out when empty. The encoding is the caller’s
--format, else the CLI’s
format, toon unless you set another:
$ my-cli plugins show fooerror: no plugin named foocode: not-foundname: foohelp[1]: run `my-cli plugins list` to see the installed pluginsWith --format json or format: 'json' the same error is one line of JSON, and with text it is
key: value lines. The keys are the ones @clibuilder/axi and
axi-sdk-js use. Under --verbose, a debugging copy of the
error, with its stack, is written to stderr as a debug line. The CLI exits with the error’s
exitCode.
With output: 'console', and in clibuilder 11.5.0, clibuilder prints the message and the help
lines on stderr instead, one line each.
An error already reported
Section titled “An error already reported”CliError.reported(exitCode) is for a failure that something else already printed, such as
@clibuilder/axi’s output.error(). The CLI exits with exitCode, and prints nothing for it on
stdout or stderr, in either output, so the failure is not reported twice. It is
new in 12.0.0, like the rest of this section.
Asserting on failures in tests
Section titled “Asserting on failures in tests”testCommand() reports the exit code alongside the result and the messages, and no process is
harmed:
import { testCommand } from 'clibuilder/testing'
test('view rejects an unknown field', async () => { const { exitCode, messages } = await testCommand(view, 'view --fields bogus')
expect(exitCode).toBe(2) // the CliError report: `error: "unknown field: bogus"`, `code: error` and the help expect(messages).toContain('unknown field: bogus')})exitCode is undefined when the command did not fail — the same as a process that exits 0.