CLI Principles
CLI Principles Section 5 Draft

5. Errors and exit codes

Source 05-errors.md

Back to the spec index

Goal: every CLI fails in the same shape, so an agent can branch on fields and a human can read the text.

One error shape, and it is the JSON-RPC 2.0 error object. JSON-RPC defines an error as {code, message, data}, where code is an integer, message is a short string, and data is anything. The CLI’s error is that object, so anything that already speaks JSON-RPC can pass it on unchanged, and the word code has one meaning. Our own fields live in data.

{
  "error": {
    "code": -32006,
    "message": "Task T-12 changed since you read it.",
    "data": {
      "kind": "conflict",
      "exit": 6,
      "parameter": "-if-version",
      "retryable": false,
      "remediation": "Run `tracker task get -id T-12`, check the change, then repeat with the new version.",
      "next": ["tracker task get -id T-12"],
      "details": { "expected": "v7", "actual": "v9" },
      "cli": "tracker",
      "command": "task set-status",
      "requestId": "01J..."
    }
  }
}
  • A command prints {"error": {...}} on stderr, as above.
  • Success mirrors it (section 4). A command prints {"result": ...} on stdout.
  • code (JSON-RPC): an integer from the table below. It follows JSON-RPC’s reserved ranges, so any JSON-RPC client understands it. It is not the exit code.
  • message (JSON-RPC): one plain sentence for a human. No stack trace, no colour.
  • data.kind: a short, stable word. This is the contract. Agents branch on it.
  • data.exit: the process exit code for that kind. One kind, one exit code.
  • data.parameter: the parameter at fault, if there is one, in its canonical spelling (3a).
  • data.retryable: true only if repeating the same call could succeed (lock held, network blip). Never true for invalid_input, unsupported, forbidden, or conflict.
  • data.retryAfter: optional, whole seconds. How long to wait before repeating, when the CLI knows (a locked file, a rate limit). Only present with retryable: true. Whole seconds, like -lock-timeout (section 7).
  • data.remediation: what to do next, in plain words. Optional. A CLI includes it when it knows a fix.
  • data.next: zero or more exact commands that follow from the remediation, ready to run. Optional.
  • data.details: free-form object for facts about this kind. Keys are documented per kind and per command in describe. Optional.
  • data.cli, data.command, data.requestId: where it came from.
  • Required: code, message, data.kind, data.exit. Everything else is optional, including remediation. New fields may be added later. Readers ignore fields they do not know.
  • Unknown data.kind: treat it by data.exit, then by code. New kinds are added only with a new exit code.

Where and how it is printed

  • On failure, stdout stays empty (only confirmation_required writes to stdout, see section 6) and the error goes to stderr.
  • JSON mode (-output json, or non-TTY): stderr holds exactly one {"error": {...}} object on one line, and nothing else. (With -verbose the diagnostic lines of section 12 come first, and the object is the last line.) YAML mode: the same object as YAML. text and md use the human form below.
  • Human form. The error is written as markdown, then shown by the output rules in section 4:
    • -output md: the markdown as is. Marks included, no colour, even on a terminal.
    • text, styling on: the same content with the marks dropped. Bold spans are written in bold (for example Error: and the labels). Code spans are shown in colour (for example -if-version and the commands).
    • Source for the human form:
      **Error:** Task T-12 changed since you read it.
      
      - **kind:** `conflict`
      - **parameter:** `-if-version`
      - **fix:** Run `tracker task get -id T-12`, check the change, then repeat with the new version.
      
    • Layout: a first line **Error:** <message>, a blank line, then one bullet per present field in this order: kind, parameter, fix (from remediation). A retryable: true error adds - **retryable:** yes. next commands, if any, follow as - **next:** and a code span each. details and the other data fields are not shown in the human form. They are in json and yaml.
  • Several problems at once (for example three bad parameters): one error, kind invalid_input, with data.details.problems listing each as {parameter, message}. Never print several error objects.

Error formats at a glance. One error, shown in every format. All go to stderr, stdout is empty, and the exit code is 6.

-output json (one line on the wire, wrapped here):

{"error": {
  "code": -32006,
  "message": "Task T-12 changed since you read it.",
  "data": {
    "kind": "conflict", "exit": 6, "parameter": "-if-version", "retryable": false,
    "remediation": "Run `tracker task get -id T-12`, check the change, then repeat with the new version.",
    "next": ["tracker task get -id T-12"],
    "details": {"expected": "v7", "actual": "v9"},
    "cli": "tracker", "command": "task set-status"
  }}}

-output yaml:

error:
  code: -32006
  message: Task T-12 changed since you read it.
  data:
    kind: conflict
    exit: 6
    parameter: -if-version
    retryable: false
    remediation: Run `tracker task get -id T-12`, check the change, then repeat with the new version.
    next:
      - tracker task get -id T-12
    details:
      expected: v7
      actual: v9
    cli: tracker
    command: task set-status

-output md:

**Error:** Task T-12 changed since you read it.

- **kind:** `conflict`
- **parameter:** `-if-version`
- **fix:** Run `tracker task get -id T-12`, check the change, then repeat with the new version.
- **next:** `tracker task get -id T-12`

text, styling on: the same lines with no ** and no backticks. Error: and the labels (kind:, parameter:, fix:, next:) are in bold. conflict, -if-version, and the commands are in colour.

How to think about error output. This describes the model a conforming CLI must behave as if it followed. It says nothing about how a CLI is written or in which language. There are two families, and they differ.

  1. json and yaml are two serializations of one object. (yaml is optional, see section 4. Where a CLI supports it, these rules apply.)
    • There is one error object, the JSON-RPC error object from this section. The JSON output is that object written as JSON. The YAML output is the same object written as YAML.
    • The two outputs carry exactly the same keys, values, types, and key order. Parse both and compare them, and the results are equal.
    • Nothing is added or removed for one format. No YAML-only comments, anchors, or extra documents. No JSON-only fields. If a field is worth having, it goes in the object and appears in both.
    • The object is the source of truth, and each format is only how it is written down. Neither format is derived from the other’s text.
    • The same holds for {"result": ...} on success.
  2. text and md are two renderings of one markdown error.
    • The markdown human form (above) is made from the object. It is a short summary, not the full object. It shows the message, kind, parameter, fix, and next, and leaves out details and other fields.
    • md is the markdown left unrendered, with marks and no colour.
    • text is the same markdown rendered for the terminal: marks replaced by styling (bold for bold spans, colour for the other spans) when styling is on.
    • Never write a separate text layout. If text and md can differ in anything but marks and styling, something is wrong.
  3. The markdown is not a serialization of the object. It is lossy and cannot be turned back into the error. Anything a program needs to read comes from json or yaml. Anything a person needs to read comes from text or md.
  4. The model. error object, then either serialize as json or yaml, or write as markdown, then show as text (rendered) or md (raw). How a CLI achieves this is up to its author.
  5. Conformance checks. A black-box check (section 10) runs the CLI, parses the json and yaml outputs (for CLIs that support yaml) and checks they are equal. It strips the styling (bold and colour) from text and checks the result equals md with the marks removed.

Kinds, exit codes, and JSON-RPC codes. Exit codes and kind words are never renumbered or reused once published. This is the recommended table (exit 0 to 4 follow Arcjet). An adopter may choose other numbers, and then keeps them fixed:

Exit kind JSON-RPC code Meaning
0 success
1 internal -32603 unexpected failure, a bug, or an unclassified error
2 auth -32002 not logged in, token expired, credential refused
3 invalid_input -32602 bad or missing parameter, failed local validation
3 invalid_input -32601 unknown command (JSON-RPC “method not found”)
3 invalid_input -32600, -32700 malformed request, unparseable JSON
4 confirmation_required -32004 mutation waiting for -confirm (a normal result with status, not an error, see section 6; the code is reserved)
5 not_found -32005 the named resource does not exist
6 conflict -32006 precondition failed (6a), or the thing already exists
7 locked -32007 another process holds the lock (section 7)
8 unavailable -32008 remote or dependency down or timed out, usually retryable
9 forbidden -32009 the caller is not permitted to run the command, or the remote refused the action
10 cancelled -32010 the command was interrupted (Ctrl-C or termination). It releases its lock, leaves no partial write, and reports anything already applied in data.details.applied
11 rate_limited -32011 a remote asked the caller to slow down. retryable: true, with retryAfter when known
12 unsupported -32012 a format or feature this CLI does not offer, for example -output yaml on a CLI without YAML. parameter names the parameter, and the fix lists what is supported
  • JSON-RPC reserves -32768 to -32000. The standard ones (-32700, -32600, -32601, -32602, -32603) keep their meaning. Our own codes use the implementation range -32000 to -32099, as -32000 - exit for exit 2 and up (exit 10 is -32010).
  • One kind has one exit code. A kind may have more than one JSON-RPC code where JSON-RPC already defines several protocol faults that all mean “bad input”.

Rules

  • Validate input locally, before any side effect, and fail with invalid_input.
  • A mutation that fails after part of it was applied reports what was applied in data.details.applied, and says so in message. It never claims “nothing changed” unless that is true (6a: atomic).
  • Never put secrets in an error, including values of sensitive parameters. Print the parameter name, not the value.
  • Debug detail (stack, raw remote response) is not printed by default. With -verbose it is printed as diagnostic lines (section 12).
  • internal errors say how to report a bug: repeat the command with -verbose and include the requestId (section 12).
  • Error text follows the same readability goals as parameters: concrete, uses canonical parameter names, and shows the exact fix.
  • Warnings that do not fail the command go in a warnings list beside result (section 4).
  • Messages are English only. kind and code carry the meaning.