# How to add a --json flag and stable exit codes to a CLI

> Print one JSON document on stdout, every message on stderr, and a four-line exit code table in the help, then prove a script can branch on it.

Source: https://voxgig.com/howto/json-flag-and-stable-exit-codes

- Audience: api-producer
- Level: beginner
- Languages: go
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Give the CLI a `--json` flag that writes exactly one JSON document to stdout, and send every progress line and error to stderr. Print a table of four exit codes in `--help`: 0 success, 1 the request failed, 2 usage error, 4 authentication required. A script then branches on `$?` before it parses anything, and a golden-file test pins the document shape so a renamed field fails the build instead of the script.

---
## You will need

Go 1.24 or later, `jq` 1.7 or later, and Bash 5 for the consumer scripts. Verified 2026-10-21
against Go 1.24.7, jq 1.7, and Bash 5.2. The sample is a two-command tool over an in-memory
inventory that stands in for the API your CLI calls, so nothing on this page touches a network. It
reads and never writes, so confirmation flags for destructive commands are out of its scope.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AWS CLI `--output json` and exit codes](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html) | You want a precedent for separating usage, configuration, and service failures | Eight documented codes whose meanings overlap, so a script still reads stderr to tell 254 from 255 | Your tool has one kind of failure a caller can act on |
| [clig.dev guidelines](https://clig.dev/#guidelines) | Any CLI, as the baseline: machine output on stdout, messages on stderr, non-zero on failure | Principles without numbers, so the exit code table and its stability are still yours to write | No case: every other row builds on it |
| [curl exit codes](https://curl.se/docs/manpage.html) | One transport with dozens of distinct causes that callers need without parsing text | About a hundred numbers to keep stable forever, most of which no script ever branches on | Your failures fit in a handful of actions a caller can take |
| [gh `--json` with `--jq` and exit codes](https://cli.github.com/manual/gh_help_exit-codes) | A tool CI jobs call and filter in place | A required field list on every `--json` call, and an embedded jq engine to ship and keep current | You want `--json` to print the whole record with no arguments |
| [sysexits.h](https://man.freebsd.org/cgi/man.cgi?query=sysexits&sektion=3) | A program called by mailers that already read those fifteen numbers | Fifteen codes from 64 up, deprecated by their own manual page, that no shell or CI runner interprets | Anything that is not a mail transport |

gh and the AWS CLI mark the two ends of one decision. gh documents four codes and tells you to
check each command's own page for more, which keeps the table memorable at the cost of a
guarantee. The AWS CLI documents eight, and two of them, 254 and 255, both cover an error from the
service, so a script that wants the difference reads stderr anyway.[^1] curl's numbered list
serves a tool with a hundred distinct causes, of which a script using `--fail` meets one, 22, for
any HTTP status of 400 or higher.

## Keep stdout for the document and stderr for everything else

The two streams are the contract, and the flag only changes what each one carries. With `--json`,
stdout receives one document followed by one newline, and stderr receives any progress line and,
on failure, a one-line JSON error. Without the flag, stdout gets a table and stderr gets the same
messages as sentences.

```go title="main.go"
// emit writes the result. With --json that is one document and one trailing
// newline, and nothing else ever touches stdout. Without it, a table.
func emit(stdout io.Writer, asJSON bool, doc any, rows []meter) int {
	if asJSON {
		if err := json.NewEncoder(stdout).Encode(doc); err != nil {
			return exitFailed
		}
		return exitOK
	}
	table(stdout, rows)
	return exitOK
}

// report writes a failure to stderr, as JSON when the caller asked for JSON,
// and returns the code the shell will see. Stdout stays empty on failure, so
// `out=$(meters ... --json)` is either a document or nothing.
func report(stderr io.Writer, asJSON bool, f failure) int {
	if asJSON {
		json.NewEncoder(stderr).Encode(struct {
			Error failure `json:"error"`
		}{f})
	} else {
		fmt.Fprintf(stderr, "meters: %s\n", f.Message)
	}
	return f.Code
}
```

`report` never writes to stdout, so `out=$(meters show mtr_zzz --json)` leaves `out` empty on every
failure, and a consumer has one place to look for data and another for reasons. The JSON error
carries a `code` token, `not_found`, beside the sentence, because the sentence is what gets
reworded in the next release. [clig.dev](https://clig.dev/#guidelines) states the rule in one line
each: primary output to stdout, messaging to stderr.

Build the binary and run it six ways, with the exit code and both streams labelled:

```bash
go build -o meters . && ./demo.sh
```

```text output
list --json                    exit 0
  stdout  {"meters":[{"id":"mtr_8f2","serial":"SN-40199","state":"installed"},{"id":"mtr_31a","serial":"SN-40200","state":"retired"}]}
  stderr  meters: fetched 2 meters
show mtr_8f2 --json            exit 0
  stdout  {"meter":{"id":"mtr_8f2","serial":"SN-40199","state":"installed"}}
show mtr_zzz --json            exit 1
  stderr  {"error":{"code":"not_found","message":"no meter with id mtr_zzz"}}
frobnicate --json              exit 2
  stderr  {"error":{"code":"unknown_command","message":"no command named frobnicate, run meters --help"}}
list --json, no token          exit 4
  stderr  {"error":{"code":"auth_required","message":"authentication required: set METERS_TOKEN"}}
list, for a person             exit 0
  stdout  ID       SERIAL    STATE
  stdout  mtr_8f2  SN-40199  installed
  stdout  mtr_31a  SN-40200  retired
  stderr  meters: fetched 2 meters
```

The first and last entries are the same `list` command, printing a table for a person and a
document for a pipe, with the progress line on stderr both times. The three failures leave stdout
empty.

## Print the exit codes where the caller will look

A code a script branches on has to be published where a script author looks, which is `--help`.
The sample keeps the table in one slice, so the help text, the tests, and the program read one
list.

```go title="main.go"
// Four exit codes are the whole contract. A caller that sees 1 tries another
// id, 2 reads the help, and 4 logs in. A short list is a list callers check.
const (
	exitOK     = 0
	exitFailed = 1
	exitUsage  = 2
	exitAuth   = 4
)

// One table feeds the help text and the tests, so the codes the help lists
// cannot drift from the codes the program returns.
var exitCodes = []struct {
	code    int
	meaning string
}{
	{exitOK, "success"},
	{exitFailed, "the request failed: no such meter, or the service refused it"},
	{exitUsage, "usage error: unknown command, flag, or argument"},
	{exitAuth, "authentication required: set METERS_TOKEN"},
}
```

The numbers follow gh, which [documents](https://cli.github.com/manual/gh_help_exit-codes) 0 for
success, 1 for any failure, 2 for a cancelled command, and 4 for authentication required.[^2]
Copying a table that scripts already know is worth more than designing a better one. Code 4 earns
its place. A missing or expired token is the failure a CI job can repair by itself, by running a
login step, while a missing record cannot be repaired that way. The command line is checked before
the token, so a typo exits 2 without one.

```bash
./meters --help
```

```text output
Usage: meters <command> [--json]

Commands:
  list      List every meter
  show ID   Show one meter

Flags:
  --json    Print exactly one JSON document on stdout; errors become JSON on stderr
  --help    Print this help

Exit codes:
  0  success
  1  the request failed: no such meter, or the service refused it
  2  usage error: unknown command, flag, or argument
  4  authentication required: set METERS_TOKEN
```

The help goes to stdout because it was asked for; a usage error goes to stderr with code 2 and a
hint to run `--help`.

## Branch on the code before you parse the document

A consumer script reads `$?` first and `jq` second, and the `case` statement is the whole pattern.

```bash title="drive.sh"
set -euo pipefail
id=${1:?usage: drive.sh METER_ID}

code=0
out=$(./meters show "$id" --json) || code=$?

case $code in
  0) jq -r '"\(.meter.id) is \(.meter.state)"' <<<"$out" ;;
  1) echo "no meter $id, nothing to check" >&2; exit 1 ;;
  2) echo "drive.sh is out of step with the meters CLI, read meters --help" >&2; exit 2 ;;
  4) echo "set METERS_TOKEN before running drive.sh" >&2; exit 4 ;;
  *) echo "meters exited $code, which drive.sh does not know" >&2; exit 1 ;;
esac
```

`out=$(...) || code=$?` is the line to copy. Under `set -e`, a failing command substitution in an
assignment ends the script, so the status is caught on the same line and the `case` gets to see
it.

```bash
METERS_TOKEN=tok_8f2 ./drive.sh mtr_8f2
```

```text output
mtr_8f2 is installed
```

Run it without the token and the script stops with its own instruction, printed after the tool's
JSON error:

```bash
./drive.sh mtr_8f2
```

```text output
{"error":{"code":"auth_required","message":"authentication required: set METERS_TOKEN"}}
set METERS_TOKEN before running drive.sh
```

A GitHub Actions step is the same `case` with one difference. The runner starts a `run` step with
[`bash -e`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), so
a non-zero exit on a line of its own ends the step before any branching. The assignment form from
`drive.sh` keeps the step alive long enough to decide.

```yaml title="ci-step.yml"
- name: Check the meter before the rollout
  id: meter
  env:
    METERS_TOKEN: ${{ secrets.METERS_TOKEN }}
  run: |
    code=0
    out=$(meters show mtr_8f2 --json) || code=$?
    case $code in
      0) echo "state=$(jq -r .meter.state <<<"$out")" >> "$GITHUB_OUTPUT" ;;
      1) echo "::warning::meter mtr_8f2 is not in the inventory" ;;
      4) echo "::error::METERS_TOKEN is missing or expired"; exit 4 ;;
      *) echo "::error::meters exited $code"; exit "$code" ;;
    esac
```

The `::error::` and `::warning::` lines are
[workflow commands](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands)
that annotate the run, and `$GITHUB_OUTPUT` hands the state to the next step.

## Check it worked

A golden file pins the shape of the document, and the test fails when a field is renamed, added,
or reordered. `go test -update` rewrites the file on purpose, so the diff in the pull request is the
contract change, reviewed as one.

```go title="main_test.go"
func TestListJSONMatchesTheGoldenFile(t *testing.T) {
	var stdout, stderr bytes.Buffer
	if code := run([]string{"list", "--json"}, token, &stdout, &stderr); code != exitOK {
		t.Fatalf("exit %d, stderr %q", code, stderr.String())
	}
	golden := filepath.Join("testdata", "list.golden.json")
	if *update {
		if err := os.WriteFile(golden, stdout.Bytes(), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	want, err := os.ReadFile(golden)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(stdout.Bytes(), want) {
		t.Fatalf("stdout differs from %s\n got: %s\nwant: %s", golden, stdout.Bytes(), want)
	}
}
```

```json title="testdata/list.golden.json"
{"meters":[{"id":"mtr_8f2","serial":"SN-40199","state":"installed"},{"id":"mtr_31a","serial":"SN-40200","state":"retired"}]}
```

Four more tests cover the rest: stdout holds one document or nothing, and every code returned is in
the help. Progress stays on stderr, and a `--json` failure is JSON wherever the flag sits.

```bash
go test -v . | grep -E '^(=== RUN|PASS$|FAIL$)'
```

```text output
=== RUN   TestListJSONMatchesTheGoldenFile
=== RUN   TestStdoutCarriesOneDocumentOrNothing
=== RUN   TestExitCodesAreTheOnesTheHelpLists
=== RUN   TestMessagesStayOffStdout
=== RUN   TestErrorsAreJSONWhereverTheFlagSits
PASS
```

The run ends in `PASS`. The `grep` drops the per-test timings, which differ between machines.

## When it goes wrong

jq stops at the first byte that is not JSON. A progress line, an update notice, or a deprecation
warning printed to stdout in front of the document breaks every consumer the day it ships. The
tool's own tests stay green, because they never piped anything. The script below pipes the clean
output, then the same output with one notice line in front of it:

```bash
./pitfall.sh
```

```text output
clean stdout, piped into jq:
mtr_8f2

the same document with a notice printed to stdout in front of it:
jq: parse error: Invalid numeric literal at line 1, column 7

a field the script spells wrong, read without -e and then with it:
null
jq exited 0
null
jq exited 1
```

jq gave up at the colon after the first word of the notice and never reached the document. The
fix belongs in the tool: every line that is not the document goes to stderr, and
`TestMessagesStayOffStdout` enforces it.

The second failure is silent. A script asks for `.meter.status` when the field is `state`, jq
prints `null`, and the exit code is 0, so the pipeline continues with an empty value. Pass
[`-e`](https://jqlang.org/manual/) and jq exits 1 when its last output is `null` or `false`, which
turns a typo into a failed step.

The third is the Actions runner itself. A step that writes `meters show mtr_zzz --json` on a line of
its own, with the `case` below it, never reaches the `case`, because `bash -e` ends the step at the
first non-zero status. Catch the status on the assignment, as `ci-step.yml` does, or give the step
`shell: bash {0}` to opt out of `-e`.

## When not to do this

Do not give every failure its own number. The AWS CLI's 252 to 255 and the 64 to 78 of
`sysexits.h` both exist, and a script author who meets them reads the manual once and then tests
for any non-zero status. Four codes that each imply a different action are what a `case` statement is
written for; a fifth distinction belongs in the JSON error's `code` field, where the consumer that
cares can read it.

Do not adopt `sysexits.h` because it looks like a standard. Its own manual page marks it deprecated
and discouraged, and the numbers mean something only to the mail programs written to read them.

Do not put the JSON error on stdout. A consumer that captures stdout then has to check whether the
document is a result or a failure, which is the parsing-prose problem in a different costume. The
exit code says which it is, and stderr holds the detail.

Do not renumber. A code is an interface the moment one script branches on it. Add codes at the
end and leave the meaning of the existing ones alone, as curl's manual promises for its own.[^3]

Do not skip the flag because the output is already one value per line. Scripts parse that too,
and the first column you add breaks them.

## Related how-tos

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Write CLI help that people and agents can both use](/howto/help-text-people-and-agents-can-use)

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [Test interactive prompts and terminal behavior in a CLI](/howto/test-interactive-prompts-and-tty-behaviour)

## Last verified

Verified 2026-10-21 against Go 1.24.7, jq 1.7, and Bash 5.2. Every output block is what the
command preceding it printed. The GitHub Actions step was not run on a runner; its handling of the
exit code follows the runner's documented shell invocation. The gh, AWS CLI, curl, and `sysexits.h`
codes are read from their manuals, not measured.

[^1]: An exit status is one byte by the time anyone reads it. POSIX lets `exit()` take any
integer and says that only the least significant eight bits, `status & 0377`, reach the parent
through `wait()`, in its
[specification of exit](https://pubs.opengroup.org/onlinepubs/9699919799/functions/exit.html). A
program that exits with 256 therefore reports success, and a C program that returns -1 reports
255, the number the AWS CLI uses for a failure it does not classify further.

[^2]: gh exits 2 when a command is cancelled. Bash keeps the same number for a different event:
its [manual](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html) says every
built-in command returns 2 for incorrect usage, meaning invalid options or missing arguments. The
[AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html) returns 2
when the command could not be parsed. So a 2 from `gh` reports an interruption, while a 2 from
`test` or `aws` reports a typo, and a script that treats the number as portable has to know which
program it ran.

[^3]: curl's [manual](https://curl.se/docs/manpage.html) numbers its exit codes from 0 to 100 and
defines 87 of them. The other fourteen, 20, 24, 29, 32, 40, 44, 46, 50, 51, 57, 62, 75, 76, and
81, are not defined, and the manual says nothing about where they went. It closes the list with
two sentences instead: more error codes might appear in future releases, and the existing ones
are meant to never change.