One CLI substrate package, one CLI shape — devlink and toolconfig merge into cli-tool
State: open/accepted
Summary
Section titled “Summary”devlinkandtoolconfigbecome one package,@sksizer/cli-toolatpackages/ts/cli-tool, with three subpath exports:.(the CLI substrate),./config(today’s toolconfig) and./devlink(today’s devlink).- The substrate entry holds what seven CLIs each wrote themselves — the
CliIooutput seam, the exit-code rule, and a thindefineClibuilder over commander. - Argv parsing is commander, not a house parser. sdlc already drives it,
already tames it with
exitOverride, andconfigureOutputroutes it through the io seam. - Usage errors exit
1.0success,1any error including a bad argument,10–63reserved for a tool’s own outcome taxonomy. sdlc’s op-error codes move into that band. - A published CLI runs under node. The bin is node over
dist/; bun stays the development runtime. Bun-only is the documented exception. - The shape is written down as S-0015-standalone-cli-shape, so the eighth tool costs a dependency rather than a fresh copy of every answer.
Context
Section titled “Context”D-RSWP-standalone-repo-sweep-tool extracted @sksizer/repo-cleaner, and
the paths: work gave @sksizer/toolconfig the shared token map. What those
left behind is seven standalone TypeScript CLIs that answer the same four
questions seven times, and two packages small enough to question on their own —
devlink at 1,364 lines and toolconfig at 530, two consumers each.
A survey of packages/ts/* found the duplication is not impressionistic.
| Concern | State today |
|---|---|
| Output seam | Four definitions of {stdout, stderr}: devlink/cli.ts CliIo, detect-runners/src/io.ts CliIo, repo-cleaner/cli.ts MainIo (adds cwd), lib/registry.ts OpIo. The detect-runners one names itself a copy of the sdlc one |
| Argv parsing | Seven hand-rolled for+switch loops — devlink, detect-runners, waypost, repo-cleaner, agent-dev, manifest-graph, obsidian-bases — each beside a usage string nothing keeps in step with it |
| Usage exit code | 2 in five tools, 1 in repo-cleaner and obsidian-bases, 1 in sdlc. 2 additionally means CAS_FAILED in sdlc’s op taxonomy |
| JSON output | --json in five tools, --output text|json in repo-cleaner, both in sdlc where --json aliases --output json |
The same survey turned up four faults that are not stylistic. Each was confirmed by running the thing, not by reading it.
devlinkis not on the publish channel.publish-libs.jsonhas nodevlinkentry, yetwaypostdepends on"@sksizer/devlink": "^0.1.0"— a registry range, notworkspace:*— and waypost’sneedslists onlydetect-runners. A standalone waypost snapshot would resolve devlink from npm, where nothing publishes it.detect-runnerscrashes under its own bin. It ships a node bin overdist/, and defaults its PATH lookup toBun.whichinsrc/collect.tsandsrc/cached.ts. A barenode bin/detect-runners.jsdies withReferenceError: Bun is not definedbefore printing anything — not an edge case, the first thing it does.agent-dev’s bin points at nothing.dfv → ./dist/cli.js, but nodist/exists andbuildemits nocli.js. Running it is a module-not-found.obsidian-bases’ bin works by accident. It points straight atsrc/cli.tsunder a#!/usr/bin/env nodeshebang with no build step, and runs only because node 22.18+ strips TypeScript types natively. On an older node it fails, and the package declares noenginesto say so.
Three of the four are the cost of having no written shape: each tool decided its own packaging, and nothing checked the decision.
Nothing has been released. git tag --list '*/v*' is empty, @sksizer/devlink,
@sksizer/toolconfig and @sksizer/cli-tool are all 404 on npm, and of the
channel’s mirrors only waypost, agent-call and intersect exist as repos.
The merge is free now and stops being free at the first release.
Decision
Section titled “Decision”Merge devlink and toolconfig into one package, @sksizer/cli-tool, and add
to it the CLI substrate the standalone tools have each been writing. Adopt it
across every standalone CLI, and write the shape down as a standard.
The package has three entries:
| Entry | Contents |
|---|---|
. | CliIo / processIo / captureIo, CliContext, the exit-code constants, defineCli, runMain |
./config | Today’s toolconfig, exports unchanged |
./devlink | Today’s devlink, exports unchanged |
sdlc adopts the io seam and the exit-code band. Its flags stay generated from
the op registry, which is S-0004-sdlc-cli-llm-head-deterministic-tail’s
business, not this decision’s.
The two packages are one package’s worth of thing. Both exist only to serve
a standalone CLI. They share their only dependency, @sksizer/fleet. Their
moon.yml files are identical apart from the prose. Neither is published.
Their consumers already prove it. waypost/bin/way.js is the argument in
six lines: it asks devlink which checkout should answer, then calls main(argv)
and assigns process.exitCode. Devlink resolution and the main/exit-code
contract meet in the same shim, in every tool that has both.
They were split for a consumer that never arrived. Both roster rows in
docs/LIBRARIES.md close with “pr-updater is next”, and the toolconfig
extraction task records that it happened so pr-updater could share the code.
pr-updater does not exist — no PR, no branch, no repo, nothing in history. It
survives only as the worked example for the env-stem rule. Two packages were
shaped around a third caller that was never written, and each ended up with
two.
A written shape is the thing that was actually missing. Four copies of a
two-method interface is a symptom. The Bun.which-under-a-node-bin crash and
the two bins pointing at files nothing emits are the disease: no document said
what a CLI in this repo looks like, so each one decided separately and no
review had a bar to hold it to.
Options considered
Section titled “Options considered”Packaging
Section titled “Packaging”Three packages — keep devlink and toolconfig, add a third for the substrate. Rejected: it makes the profusion worse to solve a problem caused by profusion, and every standalone tool would depend on all three anyway.
Two packages — merge the substrate into toolconfig, leave devlink. Rejected: devlink is the larger of the two and the one with the awkward channel gap, and the bin shim needs both at once.
One package. Chosen. Subpath exports keep the three concerns legible without three manifests, three moon projects and three channel entries.
Argv parsing
Section titled “Argv parsing”A house FlagSpec + usage renderer. Roughly 150 lines, no new dependency,
and a usage string that cannot drift from the parser. Rejected on
P-0004-leverage-best-tool — “lightest abstraction for standardized
deterministic tools where swap is cheap” — and because the two constraints that
seemed to demand a house parser turn out to be already solved.
Commander. Chosen. sdlc has driven it since the registry adapter existed.
.exitOverride() stops it calling process.exit, and the adapter already
catches the thrown CommanderError, returns 0 for --help and maps a usage
error to 1 — the exact rule this decision adopts.
.configureOutput({writeOut, writeErr}) routes its help and error text through
CliIo; sdlc does not use that yet and writes to process.stderr directly, so
adopting it closes a hole rather than inheriting one. Commander has no
dependencies of its own and is already at 12.1.0 in the lockfile.
defineCli is a builder over it rather than a re-export, so the dependency has
one seam if it ever needs replacing.
Usage exit code
Section titled “Usage exit code”Standardize on 1. Tried first, and reverted. It matched what sdlc did at
the time, so the change landed on the five standalone tools rather than the
tool with the most callers. What it cost only became visible in the code: the
config/trust/clean subcommands already split their failures — 2 for a
command that cannot run as typed, 1 for one that failed against the state it
found — and a script could act on that split. Collapsing both to 1 deleted a
capability and left eight documents claiming it still existed.
Standardize on 2. Chosen. The widespread Unix convention (argparse and
most GNU tools), what five of the tools already did, and what lets a caller
branch without parsing stderr: 2 means fix the command line, 1 means look
at the state. The objection that 2 was already CAS_FAILED in sdlc’s
taxonomy is answered by moving that taxonomy into the band, which this decision
requires anyway.
Proposed behaviour
Section titled “Proposed behaviour”Exit codes
Section titled “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 | A tool’s own outcome taxonomy |
64+ | Not ours: sysexits.h is 64–78, and the shell claims 126+ |
EXIT.usage names the 2 so no tool has to choose the number for itself.
sdlc’s OP_ERROR_EXIT_CODES moves into the band: CAS_FAILED through
SERVICE_ERROR from 2–9 to 10–17, and UNKNOWN_OP — a verb that does
not exist — to 2. GENERIC, INVALID_INPUT and INVALID_OUTPUT stay at
1; INVALID_INPUT deliberately, because it covers both a bad argument and an
unparseable file the command was pointed at. repo-cleaner’s “nothing to
sweep” moves from 2 to 10 and its “something failed to delete” from 3 to
11.
Running outside bun
Section titled “Running outside bun”The library layer is already portable; only the bins are not. repo-cleaner
builds clean and, with its dependency chain built, plain node imports
dist/cli.js and runs main(['--help']) to completion. moon run <lib>:build
already chains ^:build, and publish-lib.yml already extracts each snapshot,
runs bun install outside the monorepo and builds it.
Bun-only API use across the publishable set is nearly nil. repo-cleaner,
easy-git, easy-gh, command-seam, fleet, toolconfig, agent-dev,
manifest-graph and obsidian-bases have none; devlink’s three matches are
prose in comments. Only detect-runners (two Bun.which defaults) and
waypost (Bun.serve, Bun.build, Bun.file, Bun.spawnSync) use any.
So the bin becomes #!/usr/bin/env node over dist/, with engines.node
declared. The "bun" export condition is untouched, so a bun program importing
the library still gets the TypeScript source — which is what repo-cleaner’s
current shim comment is protecting, and it does not need the bin to do it.
waypost stays bun-only: Bun.serve and Bun.build are its --serve mode and
its on-demand page bundle, with no node equivalent in the package. Its shim
keeps the bun shebang and names the APIs that require it.
Consequences
Section titled “Consequences”- Every standalone CLI gains a runtime dependency on commander. It has no
dependencies of its own, so this is one node per tree, and it is an ordinary
npm package — not a
publish-libs.jsonneedsentry, which is only for libraries this repo publishes. publish-libs.jsonloses thetoolconfigkey and gainscli-tool;repo-cleaner.needsis rewritten andwaypost.needsgains it, which closes the missing-devlink gap. No mirror repo has to be renamed, because none of the affected mirrors exist yet.docs/LIBRARIES.mdloses two rows and gains one. The “pr-updateris next” claim is dropped rather than carried forward.- Commander reads
--no-ghas the negation of aghoption, sorepo-cleaner’s flag is declared asghdefaulting true. Same spelling, cleaner semantics. detect-runnersandwaypostmigrate together: waypost imports the other’s commands and IO, so the seam cannot move in one without the other.- Three exit codes change in sdlc’s published taxonomy. Anything comparing against the old numbers has to be found before they move.
Migration
Section titled “Migration”Each PR carries the cli-refactor label.
- This decision and S-0015-standalone-cli-shape.
- Create
packages/ts/cli-tool;git mvboth source trees into it; write the substrate entry; repoint repo-cleaner, waypost and the eleven sdlc import sites; updatepublish-libs.jsonanddocs/LIBRARIES.md. -
detect-runners+waypost: adopt the seam anddefineCli, drop the twoBun.whichdefaults, and give a malformed command its own exit code. -
repo-cleaner: dropMainIo, adoptdefineCli, move its outcome codes into the band, node bin. -
agent-dev,manifest-graph,obsidian-bases: same conversion, plus a build and a node shim for each, replacing a bin that cannot run and one that runs only by accident. -
sdlc:OpIore-exportsCliIo, the op taxonomy moves into the band,configureOutputat every generatedCommand. - The nine standalone scripts under
solutions/ontological/skills/: every exit goes throughEXIT, andensure_ready_mutate.ts’s own3/4/5move to20/19/12, continuing sdlc’s op-error numbering. - The same nine scripts against rules 2 and 3: output goes through the
CliIotheirCliContextcarries, flags are declared todefineCli, andrunMainis the shim underimport.meta.main.
Open questions
Section titled “Open questions”- Whether
@sksizer/cli-toolshould also absorbcommand-seam. Every standalone tool that shells out already depends on both, and the argument from “one substrate package” applies again. Left out here becausecommand-seamhas consumers that are not CLIs. - Whether the
10–63band wants any structure across tools, or stays each tool’s own numbering. Answered at the bottom only:0,1and2mean the same thing everywhere, and the band above them stays each tool’s own, so10still reads differently per tool.