The Last CLI: Rebuilding the Base 51 of My Tools Were Built Against
Developed by Robert E. Beckner III (Merlin) | rbeckner.com
Every model reached for a different set of libraries, so I built one base and made every project upstream into it. Then I asked Claude Fable for the CLI humanity could still be using in a decade, and Opus 5 and I built what came back — 46 clauses, 92 tests, zero dependencies.
I have 51 command-line tools on this machine. I did not plan for that number. It
happened because a CLI is the shortest distance between an idea and something I
can actually run, and because for the last several years I have had help writing
them faster than I could have alone.
That help came with a habit I noticed early, back when GPT-3.5 and the first
Claude models were the ones I worked with. Ask 3 different models to scaffold a
CLI and you get 3 different opinions about what a CLI is. One reaches for
Commander. One reaches for Inquirer for the prompting. One reaches for Chalk
because output should be colored. Each answer is defensible. Together they are a
tax, because now I own 3 codebases that disagree about argument parsing, about
what a failure looks like, and about which of those libraries I am now
responsible for watching.
Every model reached for a different set of libraries, and I was the one who had to live with all of them#
The tax is not the libraries. It is that improvements stop travelling.
When 3 CLIs disagree about how a command reports failure, a fix in one is a
fix in one. There is nothing to upstream it into. The work does not compound, and
after the tenth tool you are not building leverage, you are maintaining a
portfolio of near-misses.
I had already written about wanting the opposite of that. The whole argument
in How to Turn AI Gains Into Compounding
Infrastructure
is that a gain becomes durable when every dependent project inherits it. A
shared capability surface. A promotion rule. One place where an improvement
lands and spreads.
I had built that layer for AI capability, for workflow, for operations. I had
not built it for the thing I actually make most often.
So I built one base and made every CLI project upstream its improvements into it#
The rule was simple and it was mine to enforce: when a CLI in my estate needed
something better — a cleaner way to register services, a better error path, a
testing helper that made a suite readable — that improvement did not stay in the
project. It went into the base, and the base went out to the others.
That is the whole design. The base is small on purpose. It has no opinion about
what your tool does. It has a strong opinion about what a command is: something
that takes arguments, does work, reports what happened, and leaves.
The folder was created on 6 July 2025, and 2 of my tools were depending on
version 1.0.0 of it the same day. That is the tell: it was not built
speculatively and then adopted. It was extracted from work that already existed,
at the point where copying the same scaffolding between projects had stopped
being reasonable.
It spread quickly, because spreading was the entire idea. 8 repositories were on
it within 25 days. 10 within 11 weeks.
Repositories Adopting the Base, by Week of 2025
Git came later than any of that. The repository was initialised on 12 November
2025, 4 months in, and published the day after — which is why the version
history and the actual history disagree, and why I checked the filesystem rather
than trusting the commit log when I sat down to write this.
Those 10 tools do Cloudflare administration. Local DNS and nginx management.
Deployment against Coolify. Browser automation. Cost reporting across model
providers. Most are private, which is why I am describing them by what they do
rather than by name. The public ones are
aia, which consults several models in
parallel, and the base itself. vssh,
my guarded remote-execution tool, is public too and came out of the same instinct
— build the operator surface once, properly, and stop rebuilding it.
The dividends were real and they were boring, which is the correct shape for
infrastructure dividends. A hardening in one tool showed up in all of them. When
I found that a command could print a red error message and still exit 0 —
telling the human it failed and telling the shell it worked — the fix did not
go into the 16 places in one tool where it had happened. It went into the base,
and every tool inherited it.
By August 2026 the base was working and I still wanted it gone.
Not because it was broken. Because it had accumulated. Because the exit-code
rule I was proudest of had been retrofitted rather than designed in. Because the
world it was written for had shifted underneath it: most of the invocations of
my CLIs are no longer typed by me. They are issued by agents, reading stdout,
stderr, and $? as their only senses.
So instead of patching, I set the terms differently. I gave Claude Fable a
single instruction, and I made it deliberately large:
If this were the last CLI framework humanity built — the one still in service
in a decade — you now have the chance to make it that.
Design it from there. I did not expect a document back. I expected a plan.
Fable came back with a treaty, and the constraint was that the promises had to be few#
What arrived was not a feature list. It was structured as a treaty, split down
the middle by a hard wall.
One half was a contract: what every CLI built on this base guarantees to every
observer, written as numbered clauses in RFC-2119 language — MUST, MUST NOT,
SHOULD, MAY. Twelve families of them. Exit codes. Stream discipline. Machine
output. Self-description. Grammar. Environment. Cancellation. Determinism.
Performance budgets. Compatibility.
The other half was the authoring surface, which was allowed to grow, and which
existed only to make satisfying the contract the path of least resistance.
The reasoning underneath it was the part I found convincing. A design meant to
last a decade cannot bet on fashion, because fashion is what expires. It cannot
bet on cleverness, because cleverness is what you cannot predict in year 8. It
can only bet on the interfaces that have not moved since the 1970s: argument
vectors, 3 streams, an 8-bit exit code, environment variables. And it noted the
one genuinely new fact — that the majority reader of those interfaces is now a
machine that cannot ask a follow-up question.
The clause that ended up organizing everything else was the one it opened with:
One result, many renderings. A command computes a single result. The exit
code, the human text, the JSON document and the streaming lines are all
projections of that one value. They cannot contradict each other, because
there is only one source.
That is the sentence the whole rebuild turns on.
Diagram source
graph LR
A["execute() returns
one value"] --> B["exit code"]
A --> C["rendered text
stdout"]
A --> D["JSON envelope
--json"]
A --> E["NDJSON stream
--ndjson"]
F["logger.error()
ctx.emit()"] -.-> B
F -.-> G["events
stderr"]
Opus 5 and I found the spec was right about the thesis and wrong about 3 things#
This is where the work became ours rather than mine.
I brought the spec to Opus 5 and we built it in a day. Not a clean day. The
useful parts are the places where the document met the estate and lost.
The spec wanted ctx.args to become a record of named arguments. It is the
better design in isolation. It would also have broken every command in every one
of the 10 tools, because they all read ctx.args as an array. We kept the array
and put the typed arguments on ctx.namedArgs beside it. The rule that decided
it was already written in the contract, one clause above: never break a
consumer outranks every other value in the repository, including the contract's
own completeness.
The spec wanted a command group with no verb to be a usage error. Running a
parent command with no subcommand would exit 2. Defensible, and it would have
changed the behavior of every script that runs a bare grouping command to see
its help. We kept printing help and exiting 0.
The spec assumed streaming and the single JSON document were the same
feature. They are not. Streaming a million items in constant memory is the
point of one and impossible in the other, because a caller who asked for a
single document asked for it to be single. We split the behavior and wrote down
which clause governs which.
We also found things the spec could not have known, because they were only
visible from the artifact. A test file that ran 0 tests and reported success,
having killed the runner partway through. Signal handling that exited 0 on
Ctrl-C — an interrupted command reporting that it had succeeded. Two output
helpers that joined their lines with a literal backslash-n, so every table came
back on one line. A colour helper that, once we replaced the
dependency it had wrapped, quietly narrowed its own type signature and broke
code that had not changed a character.
That last one is worth sitting with. It was caught by no test either of us
wrote. It surfaced in a consumer's typecheck during migration, which is the
only place it could have.
The contract only counts because the build fails when a clause has no test#
A promise nothing checks is a comment.
So the conformance suite parses the contract file, finds every clause containing
the word MUST, and fails the build if one of them has no registered test. You
cannot add a promise to this project without adding the thing that proves it, in
the same commit.
Conformance Tests by Contract Family
46 normative clauses. 92 tests mapped to them. 184 tests in total.
And none of those conformance tests run against the source. They build the
package with its own build script, run npm pack, unpack the tarball, write
fixture CLIs that import the unpacked entry point, and spawn them under Node,
Bun and Deno — asserting on the exit status and the bytes exactly as a shell
would see them.
That shape was not an aesthetic choice. This package once shipped a 65 KB stub.
A single "sideEffects": false flag let the bundler tree-shake the router and
the exit-code module out of the artifact while their names stayed in the export
list. The build exited 0. The source suite stayed green the entire time. Only
the artifact was evidence, and nothing was looking at the artifact.
Migrating 7 tools found 3 gates nobody knew were there#
We migrated 7 of the 10 CLIs the same day, and the migration is where the design
got its actual grade.
The dividend landed immediately and cost nothing: because commands in the old
version already returned values — the framework used them only to derive an exit
code, then discarded them — every one of those return values became a JSON
payload on upgrade day. 7 tools gained machine-readable output without a single
command being rewritten.
What we did not expect was the same defect in 3 different tools, none of which
knew about each other. Each had a gate in front of the router: a hand-maintained
list of valid command names, or a startup step that demanded credentials before
anything else ran. In every case the new manifest command — the one that
describes the tool's entire surface in a single call, so an agent can learn it
without reading source — answered with "unknown command" or "missing token."
One of them kept a second copy of its command list and a hand-written help
screen, both of which had drifted from what the tool actually did. Deleting both
took its suite from 52 passing with 3 failing to 57 passing with 0. The largest
tool in the set has 364 tests, and they passed before and after the upgrade
without a source change.
The pattern generalized well enough that it became a written procedure, shipped
inside the package itself. It is 9 steps, and the 2 steps that consume the time
are the 2 nobody anticipates.
Zero dependencies is the only number that needs no monitoring#
The base had 2 runtime dependencies. It now has none.
That was partly aesthetic and mostly arithmetic. On 8 September 2025, an
attacker phished the npm account of Josh Junon, maintainer of some of the most
depended-upon packages in JavaScript, using a fake domain and a live one-time
code. 18 packages were published with malicious versions, including chalk and
debug — packages carrying something on the order of 2.6 billion downloads a
week between them. The payload was a crypto-clipper. Maintainers caught it and
reverted within roughly 2 hours, and the compromised versions were still
downloaded about 2.6 million times in that window.
Chalk is one of the 3 libraries the models kept reaching for when I asked them
for a CLI.
The base was not affected — it never depended on chalk — and I want to be
precise rather than dramatic about that, because it was created 2 months after
the incident. The relevance is not that we dodged something. It is that the
incident describes the class of risk exactly: every dependency is a decade of
someone else's release decisions, and you are trusting an account you do not
control. The colour handling that replaced one dependency is about 60 lines. The
prompting that replaced the other is about 120. Zero is the only number that
needs no monitoring.
The version that shipped is 85 KB, unminified, with no runtime dependencies,
running on Node, Bun and Deno. Every command built on it gets, with no
per-command code:
Guarantee
What it means in practice
Honest exit codes
An error reported to a human is reported to the shell
--json and --ndjson
The value your command returns, in a shape a machine can parse
manifest
The whole tool described in 1 deterministic call, loading nothing
Stream discipline
stdout is payload; every log line is on stderr
Usage errors
Exit 2 for "you called me wrong", distinct from 1 for "I tried and failed"
Prompt safety
A prompt with no terminal fails in milliseconds instead of hanging forever
Cancellation
Ctrl-C aborts the command's signal, then exits 130
The thing I keep coming back to is not any single item on that list. It is that
the list is now checkable. The README's own example runs as a test against the
published tarball, and the numbers quoted in its prose are held to the numbers
the suite produces — a rule that caught its first error within a minute of being
written, where the page said 87 KB and the artifact was 85.
Four years ago the problem was that every model had a different opinion about
what a CLI should be. The answer was never to argue with the opinions. It was to
own the base they all build against, and to write the promises down somewhere a
build can fail.