How-to › Ship a CLI or REPL

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.

Audience
API producer
Level
beginner
Topic
Make a CLI usable by people, scripts and agents
Languages
Go
Verified

A teammate wires your CLI into a deploy script. The script pipes the output into jq, and the first parse error arrives the day you add a progress line, because that line went to stdout along with the data. The script checks $? as well, but every failure exits 1, so a missing token and a missing record get the same retry loop.

What you get

You will end up with a CLI whose --json output is one document a pipe can trust, whose exit codes a script can branch on, and whose help lists them. This is for you if other people’s scripts and CI jobs call your tool.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS CLI --output json and exit codesYou want a precedent for separating usage, configuration, and service failuresEight documented codes whose meanings overlap, so a script still reads stderr to tell 254 from 255Your tool has one kind of failure a caller can act on
clig.dev guidelinesAny CLI, as the baseline: machine output on stdout, messages on stderr, non-zero on failurePrinciples without numbers, so the exit code table and its stability are still yours to writeNo case: every other row builds on it
curl exit codesOne transport with dozens of distinct causes that callers need without parsing textAbout a hundred numbers to keep stable forever, most of which no script ever branches onYour failures fit in a handful of actions a caller can take
gh --json with --jq and exit codesA tool CI jobs call and filter in placeA required field list on every --json call, and an embedded jq engine to ship and keep currentYou want --json to print the whole record with no arguments
sysexits.hA program called by mailers that already read those fifteen numbersFifteen codes from 64 up, deprecated by their own manual page, that no shell or CI runner interpretsAnything 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.

// 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 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:

go build -o meters . && ./demo.sh
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.

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.

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

./meters --help
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.

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.

METERS_TOKEN=tok_8f2 ./drive.sh mtr_8f2
mtr_8f2 is installed

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

./drive.sh mtr_8f2
{"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, 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.

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

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)
	}
}
{"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.

go test -v . | grep -E '^(=== RUN|PASS$|FAIL$)'
=== 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:

./pitfall.sh
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 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.

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.

Footnotes

  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. 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. ↩︎ Back to text

  2. gh exits 2 when a command is cancelled. Bash keeps the same number for a different event: its manual says every built-in command returns 2 for incorrect usage, meaning invalid options or missing arguments. The AWS CLI 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. ↩︎ Back to text

  3. curl’s manual 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.