The shape of a standalone CLI — one io seam, one parser, one exit-code rule
State: open/active
Summary
Section titled “Summary”- A standalone CLI exports
main(argv, ctx)and never exits the process itself; the bin shim owns the exit. - Output goes through
CliIofrom@sksizer/cli-tool. Nothing below the shim names a process stream. - Flags are declared to
defineCli. Nothing hand-parsesargv, and nothing hand-writes a usage string. 0success,1a failure,2a malformed command — the same2in every tool — and10–63for a tool’s own outcome taxonomy.--output <form>is the format flag; a bare--jsononly 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.
1. main returns, the shim exits
Section titled “1. main returns, the shim exits”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.
2. Output goes through CliIo
Section titled “2. Output goes through CliIo”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.
3. Flags are declared, not parsed
Section titled “3. Flags are declared, not parsed”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.
4. Exit codes
Section titled “4. Exit codes”| Code | Meaning |
|---|---|
0 | Success |
1 | A well-formed command that failed against the state it found |
2 | The command cannot run as typed |
10–63 | This 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.
5. One format flag
Section titled “5. One format flag”--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.
6. The bin runs under node
Section titled “6. The bin runs under node”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.
7. sdlc is exempt from rule 3 only
Section titled “7. sdlc is exempt from rule 3 only”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.
How to apply
Section titled “How to apply”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.