Skip to content

One CLI substrate package, one CLI shape — devlink and toolconfig merge into cli-tool

State: open/accepted

  • devlink and toolconfig become one package, @sksizer/cli-tool at packages/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 CliIo output seam, the exit-code rule, and a thin defineCli builder over commander.
  • Argv parsing is commander, not a house parser. sdlc already drives it, already tames it with exitOverride, and configureOutput routes it through the io seam.
  • Usage errors exit 1. 0 success, 1 any error including a bad argument, 10–63 reserved 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.

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.

ConcernState today
Output seamFour 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 parsingSeven 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 code2 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.

  • devlink is not on the publish channel. publish-libs.json has no devlink entry, yet waypost depends on "@sksizer/devlink": "^0.1.0" — a registry range, not workspace:* — and waypost’s needs lists only detect-runners. A standalone waypost snapshot would resolve devlink from npm, where nothing publishes it.
  • detect-runners crashes under its own bin. It ships a node bin over dist/, and defaults its PATH lookup to Bun.which in src/collect.ts and src/cached.ts. A bare node bin/detect-runners.js dies with ReferenceError: Bun is not defined before printing anything — not an edge case, the first thing it does.
  • agent-dev’s bin points at nothing. dfv → ./dist/cli.js, but no dist/ exists and build emits no cli.js. Running it is a module-not-found.
  • obsidian-bases’ bin works by accident. It points straight at src/cli.ts under a #!/usr/bin/env node shebang 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 no engines to 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.

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:

EntryContents
.CliIo / processIo / captureIo, CliContext, the exit-code constants, defineCli, runMain
./configToday’s toolconfig, exports unchanged
./devlinkToday’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.

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.

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.

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.

CodeMeaning
0Success
1A well-formed command that failed against the state it found
2The command cannot run as typed
10–63A 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.

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.

  • 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.json needs entry, which is only for libraries this repo publishes.
  • publish-libs.json loses the toolconfig key and gains cli-tool; repo-cleaner.needs is rewritten and waypost.needs gains it, which closes the missing-devlink gap. No mirror repo has to be renamed, because none of the affected mirrors exist yet.
  • docs/LIBRARIES.md loses two rows and gains one. The “pr-updater is next” claim is dropped rather than carried forward.
  • Commander reads --no-gh as the negation of a gh option, so repo-cleaner’s flag is declared as gh defaulting true. Same spelling, cleaner semantics.
  • detect-runners and waypost migrate 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.

Each PR carries the cli-refactor label.

  • This decision and S-0015-standalone-cli-shape.
  • Create packages/ts/cli-tool; git mv both source trees into it; write the substrate entry; repoint repo-cleaner, waypost and the eleven sdlc import sites; update publish-libs.json and docs/LIBRARIES.md.
  • detect-runners + waypost: adopt the seam and defineCli, drop the two Bun.which defaults, and give a malformed command its own exit code.
  • repo-cleaner: drop MainIo, adopt defineCli, 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: OpIo re-exports CliIo, the op taxonomy moves into the band, configureOutput at every generated Command.
  • The nine standalone scripts under solutions/ontological/skills/: every exit goes through EXIT, and ensure_ready_mutate.ts’s own 3/4/5 move to 20/19/12, continuing sdlc’s op-error numbering.
  • The same nine scripts against rules 2 and 3: output goes through the CliIo their CliContext carries, flags are declared to defineCli, and runMain is the shim under import.meta.main.
  • Whether @sksizer/cli-tool should also absorb command-seam. Every standalone tool that shells out already depends on both, and the argument from “one substrate package” applies again. Left out here because command-seam has consumers that are not CLIs.
  • Whether the 10–63 band wants any structure across tools, or stays each tool’s own numbering. Answered at the bottom only: 0, 1 and 2 mean the same thing everywhere, and the band above them stays each tool’s own, so 10 still reads differently per tool.

← Back to Decisions