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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
AWS CLI --output json and exit codes | 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 | 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 | 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 | 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 | 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.
// 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.
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.
// 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.
Related how-tos
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
-
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 throughwait(), 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 -
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
ghreports an interruption, while a 2 fromtestorawsreports a typo, and a script that treats the number as portable has to know which program it ran. ↩︎ Back to text -
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