Skip to content

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.

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 // 0
exitCodes.error // 1
exitCodes.usage // 2

This 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.

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.

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.

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:

Terminal window
$ my-cli deploy prod --port 70000
error: invalid value for option --port: expected an integer from 1 to 65535, received "70000"
code: invalid-value
option: --port
value: "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.

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:

Terminal window
$ my-cli deploy --dyr-run
unknown option --dyr-run
Usage: my-cli deploy <arguments> [options]
...

Every problem found is reported, not just the first:

Terminal window
$ my-cli deploy --bogus
unknown option --bogus
missing 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.

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:

  1. the matched command’s own handler;
  2. the handler of the nearest command that contains it;
  3. 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.

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.

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:

Terminal window
$ my-cli plugins show foo
error: no plugin named foo
code: not-found
name: foo
help[1]: run `my-cli plugins list` to see the installed plugins

With --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.

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.

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.