Skip to content

The shape of a standalone CLI — one io seam, one parser, one exit-code rule

State: open/active

  • A standalone CLI exports main(argv, ctx) and never exits the process itself; the bin shim owns the exit.
  • Output goes through CliIo from @sksizer/cli-tool. Nothing below the shim names a process stream.
  • Flags are declared to defineCli. Nothing hand-parses argv, and nothing hand-writes a usage string.
  • 0 success, 1 a failure, 2 a malformed command — the same 2 in every tool — and 10–63 for a tool’s own outcome taxonomy.
  • --output <form> is the format flag; a bare --json only ever aliases one of its values.
  • The bin runs under node over dist/. Bun-only is an exception that names the APIs forcing it.

These bind every standalone CLI this repo owns, wherever it lives: the publishable tools under packages/ts/, sdlc itself, and the scripts a skill shells out to under solutions/ontological/plugin/plugins/sdlc/skills/. A program a person or another program invokes by name and reads an exit code from is a CLI, and the rules below are what one of those looks like here.

A CLI exports main(argv, ctx): number | Promise<number>. It never calls process.exit, and it never reads process.argv or process.cwd() — both arrive as arguments, which is what lets a test drive the whole command in process.

The bin shim is the only place that touches the process: it builds the real context, calls main, and assigns process.exitCode. runMain from @sksizer/cli-tool is that shim’s body.

CliIo is { stdout(text), stderr(text) } and nothing else. A handler that writes to process.stdout cannot be tested without monkeypatching a global, and a patch that leaks on a missed finally corrupts every other test in the process.

Exit codes are not part of this seam — they are return values. There is no stdin member: an interactive surface owning the terminal is a different thing, and folding it in blurs both.

Ambient values a command needs beyond output — cwd, env — travel in CliContext, not by widening CliIo.

Flags go to defineCli, which builds a commander command underneath. A hand-rolled for+switch over argv is not permitted, and neither is a hand-written usage string: help text renders from the same declaration the parser reads, so the two cannot drift.

This is the P-0004-leverage-best-tool case. --flag=value, short flags, --no-x negation and required-value checking are commander’s problem.

CodeMeaning
0Success
1A well-formed command that failed against the state it found
2The command cannot run as typed
10–63This tool’s own outcome taxonomy
64+Not available: sysexits.h claims 64–78, the shell claims 126+

A malformed command exits 2 in every tool — an unknown flag or subcommand, a wrong argument count, a value of the wrong type, a missing required argument. Say it with EXIT.usage; EXIT.error is every other failure.

The split is worth a number because a script can act on it: 2 means fix the command line, 1 means look at the state. A malformed command is decided from the argument list and the tool’s own schema with nothing read, so re-running it can only fail the same way. 2 is also the conventional choice — argparse and most GNU tools already use it — so a caller is not learning a house rule.

The band is for outcomes of the work — “nothing to sweep” as against “something failed to delete” — declared by the tool and documented at its main. It is never a second spelling of the three universal codes.

--output <form>, listing the forms the tool actually produces — text|json where those are the two, list|json|table where there are three.

A tool may keep the bare spellings it already had (--json, --list, --table) as aliases for the matching --output value, and must not let one of them mean anything else. The alias is for the invocations already written down; --output is the one flag a reader has to learn.

bin/<name>.js, #!/usr/bin/env node, importing ../dist/…, with engines.node declared. Bun stays the development runtime — bun run cli.ts — and the package’s "bun" export condition still gives a bun program the TypeScript source when it imports the library. The bin is not how that is delivered.

A bin pointing straight at a .ts file does not count, even where it happens to work: node has stripped types natively since 22.18, so such a bin runs on a recent runtime and fails on an older one, with no engines field saying which.

A tool that genuinely needs Bun APIs ships #!/usr/bin/env bun over the source instead, and names the APIs at the shim. waypost is the case: Bun.serve and Bun.build are its --serve mode and page bundler.

A library reached by a node bin uses no Bun.* global. Where one is unavoidable it sits behind an injectable option whose default is portable — a Bun.which default under a node bin is a crash, not a preference.

sdlc is governed by S-0004-sdlc-cli-llm-head-deterministic-tail. It drives the same commander underneath, but its flags are generated from the op registry rather than declared per command. Every other rule here binds it, including the io seam and the exit-code band.

Its OP_ERROR_EXIT_CODES table is the worked example of the band: the lease protocol’s outcomes, the backlog model error, the service-lifecycle tier and the dirty-worktree refusal occupy the bottom of the band, while UNKNOWN_OP is 2 and the generic failures are 1. solutions/ontological/cli/README.md carries the whole table, skill-script codes included, so the band stays one number space across everything sdlc ships.

Seven standalone CLIs in packages/ts/ each answered these questions separately, and the answers diverged: four definitions of a two-method output interface, seven hand-rolled argv loops, two different usage exit codes, and two spellings of the JSON flag.

Divergence was the visible cost. The invisible one was that no review had a bar to hold a new CLI to, and three bins shipped in a state nobody would have signed off on: a node bin that dies on Bun.which before printing anything, a bin pointing at a dist/ nothing emits, and a bin that runs only because the local node is new enough to strip types. See D-LLJY-cli-tool-substrate.

A one-off script under scripts/ is not a standalone CLI: it has no bin, no published surface and no consumer outside this repo.

A skill script under solutions/ontological/plugin/plugins/sdlc/skills/ is one, and holds to rules 1-5. Rule 6 does not reach it: it has no bin and is not published, so it keeps #!/usr/bin/env bun over its own source and runMain under import.meta.main is its shim.

Adding a CLI: depend on @sksizer/cli-tool, export main(argv, ctx), declare flags to defineCli, and let runMain be the whole bin shim. The rules above are then satisfied by construction, apart from the exit-code band, which is a choice each tool documents at main.

Changing one: the conversions are mechanical and independently landable — the io seam, the parser and the bin are three separate edits. Run the bin with node, not bun, before calling it done; that is the check the three broken bins would not have survived.


← Back to Standards