An agent runs your CLI, gets a table with box-drawing characters, and parses the wrong column.1 The next command prompts for confirmation, the agent sends nothing because it has no terminal, and the process hangs until a timeout. The transcript shows a tool that worked perfectly by hand and cannot be automated at all.
What you get
You will end up with a CLI whose output is parseable, whose exit codes distinguish a bad request from bad usage, and which never waits for a keystroke. It stays pleasant to use by hand. This is for you if people are wiring your CLI into agents and scripts.
Short answer
Add a --json flag that covers output and errors alike, and give every failure a stable reason
token beside its message. Reserve one exit code per kind of failure, so a caller branches without
parsing prose. Never prompt: a write refuses without an explicit flag whether or not a terminal is
attached, and the help is available as data.
You will need
Node 22 or later, and a CLI you can change. The conventions come from the command line interface guidelines, and the exit code discipline from the POSIX conventions that shells already assume.2
Voxgig maintains sdkgen. This page compares its generated CLI with hand-written tools, an MCP server, and the published guidelines.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| AGENTS.md beside the CLI | The tool is already good and agents need to know its conventions | A document that drifts from the tool unless something checks it | The tool itself is the problem rather than its discoverability |
| An MCP server instead | Agents are the main consumer and you want typed tools with schemas | A second surface to ship and keep in step with the CLI | People use the CLI by hand as much as agents use it |
| The clig.dev guidelines | Any CLI, as the baseline everything else builds on | Reading and applying them, which is an afternoon rather than a sprint | Nothing: this is the floor, not an alternative |
| The sdkgen go-cli target | You already generate an SDK and want a CLI over the same model | A generated tool whose conventions are the generator’s rather than yours | Your CLI does more than wrap an API |
The decision people actually face is whether to ship a CLI or an MCP server for agent use. A CLI already exists in most cases, works in any environment with a shell, and is scriptable by people too. An MCP server carries argument schemas, which removes a class of mistake an agent makes with flags, and it is another artifact to build and version.
Do the CLI work first either way. An MCP server over a tool that prompts and prints tables inherits both problems, and the fixes below are what a good server would need underneath.
Make the machine shape complete
--json has to cover the failures too, or half the interaction is still prose.
const fail = (code, reason, detail, json) => ({
code,
stdout: '',
stderr: (json ? JSON.stringify({ ok: false, reason, detail }) : `error: ${detail}`) + '\n',
})
The reason field is the one that matters. A message is for a person and gets reworded. A token
such as not_found is a value an agent can branch on. Keep both in the same document, so neither
has to be inferred from the other.
Keep errors on standard error and payloads on standard output. A caller piping output into a parser then gets valid JSON on success and nothing at all on failure. That is a cleaner contract than a stream which sometimes carries an error object instead.
Never wait for a keystroke
Confirmation is a flag, not a prompt.
if (spec.writes && !parsed.values.yes) {
if (isTTY) return { code: EXIT.needsConfirmation, stdout: `Retire ${rest[0]}? Re-run with --yes.\n`, stderr: '' }
return fail(EXIT.needsConfirmation, 'confirmation_required', `pass --yes to ${command}`, parsed.values.json)
}
Checking whether a terminal is attached is fine for wording the message. It is not fine for deciding whether the command may proceed. A rule that relaxes when no terminal is present makes the tool loosest for unattended automation, which is where it needs to be strictest.
Give confirmation its own exit code. An agent that gets code 3 knows to re-run with a flag, which is a different response from a failed request, and both are different from a typo in the command name.
Check it worked
Drive the tool the way an agent does, then the way a person does.
node demo.mjs
driven by an agent, no terminal attached
list --json exit 0 {"ok":true,"data":[{"id":"mtr_8f2","serial":"SN-40199","state":"installed"},{"id":"mtr_31a","serial":"SN-40200","state":"retired"}]}
show a known meter exit 0 {"ok":true,"data":{"id":"mtr_8f2","serial":"SN-40199","state":"installed"}}
show a missing meter exit 1 {"ok":false,"reason":"not_found","detail":"no meter with id mtr_zzz"}
retire without --yes exit 3 {"ok":false,"reason":"confirmation_required","detail":"pass --yes to retire"}
retire with --yes exit 0 {"ok":true,"data":{"id":"mtr_8f2","serial":"SN-40199","state":"retired"}}
a command that does not exist exit 2 {"ok":false,"reason":"unknown_command","detail":"no command named delete"}
Each of the four exit codes across the six calls tells the caller to do something different. Code 1 means the request was wrong, so try another id. Code 2 means the tool was used wrongly, so read the help.3 Code 3 means add a flag. Nothing here requires reading an English sentence.
The last two lines of the demo are the same tool with a terminal attached, and the refusal is identical. Only the wording changes: a person gets a question and an agent gets a token. A CLI whose safety rules depend on who is watching is a CLI nobody can reason about.
node --test cli.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 123.223116
When it goes wrong
An agent hangs on a command. Something is reading from standard input. Search for every prompt, and make each one a flag with a refusal when it is absent.
JSON output is interleaved with progress messages. A spinner or a log line went to standard output. Send everything that is not the payload to standard error. Progress output belongs there anyway, because a person watching still sees it and a pipe does not.
The agent retries a write it already made. A timeout hid a success. Give write commands an idempotency key or make them safe to repeat, and say which in the help.
Parsing breaks after a release. A field was renamed in the JSON shape. Version the machine output, and treat it as an interface with the same rules as your API.
When not to do this
Do not remove the human output. A CLI that only speaks JSON is worse for the person debugging at two in the morning, and they are the reason it exists. Two shapes of the same answer is the goal, not one shape that serves neither well.
Do not add --json to one command and call it done. Partial coverage is worse than none, because a
caller writes the parser and then meets the command that has no machine shape.
Do not lean on the sdkgen go-cli target to make a tool agent-ready by itself. It gives every operation the same conventions, which is most of the battle, and the confirmation rules and exit code meanings for your own destructive commands are still yours to decide.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
- POSIX
sets all three down under Exit Status for Commands, and adds that the shell reports the full eight
bits through
$?. A tool choosing codes of its own is choosing from what is left.
Footnotes
-
Unicode keeps the box-drawing characters in a block of their own, U+2500 to U+257F, which Blocks.txt names Box Drawing and UnicodeData.txt fills with 128 names. Every one begins BOX DRAWINGS. Of the names, 78 contain LIGHT, 71 contain HEAVY, 33 contain DOUBLE, 12 contain DASH, and 4 contain ARC, which is how a table’s corners came to be round. The counts overlap, because a line can be heavy at one end and light at the other. The block exists so that a screen of text can draw a window around itself, and it does the job well enough that a parser walks into the wall. ↩︎ Back to text
-
The shell keeps three answers for itself. A command that is not found exits 127, one that is found and is not executable exits 126, and one that a signal ended reports something greater than ↩︎ Back to text
-
Two has company and a rival. Every Bash builtin returns 2 for incorrect usage, which the manual glosses as generally invalid options or missing arguments. BSD’s
sysexits.h, which first appeared in 4BSD, gives the same failure the nameEX_USAGEand the number 64. FreeBSD’s manual page for the header now calls the interface deprecated and discouraged. Its BUGS section reads, in its entirety, that the interface is not portable and the choice of an appropriate exit value is often ambiguous. ↩︎ Back to text