Status: PROPOSAL — pending the maintainer's decision. This documents the design behind the open PR (
jdalton:feat/external-subcommands→nubjs/nub, "feat: git-style external subcommand dispatch (nub-<verb>)", #200, Refs #162). Thev0.xstatus records the proposed ship band, not a settled commitment.
An unknown nub <verb> resolves a binary named nub-<verb> (git/cargo/kubectl style) and runs it, forwarding the remaining argv verbatim:
nub cook ./app.ts --release → exec nub-cook ./app.ts --release
nub foo bar → exec nub-foo bar
The insertion point is the unknown-bareword bail in run_nub (crates/nub-cli/src/cli.rs, the else arm around line 1396 — after the file-path/init/known-script/PM-redirect checks, where today nub bails with "not a nub command"). Built-ins, the node/pm/agent namespaces, and the PM engine verbs are all matched earlier (subcommand_found / dispatch_subcommand), so a plugin can never shadow a core verb — core always wins, and only an otherwise-unrecognized bareword reaches the plugin probe.
Only the prefixed name nub-<verb> is probed — never the bare <verb> — so a typo (nub buidl) looks for nub-buidl and falls through to the existing helpful error, rather than exec'ing some unrelated buidl binary that happens to be on PATH. This prefixed-only rule is the core typo-safety property.
node_modules/.binfirst, viafind_bin(nub_core::workspace::scripts::find_bin), which walks up from the cwd — so a hoisted workspace-root.binentry counts. A plugin is then just a devDependency:nub add -D nub-cook, andnub cookworks in that project. This is the nub-idiomatic, project-scoped path — the plugin version is pinned in the lockfile, travels with the repo, and is reproducible.PATHnext, for globally-installed plugins (npm i -g nub-cook, a cargo-installed binary, etc.).
No registry, no manifest, no "nub" config field declaring plugins. Resolution is purely "is there a nub-<verb> binary reachable?" — KISS/DRY, and brand-safe (no authored config surface to police).
A plugin runs through the same launch_bin that nub run/nubx use for node_modules/.bin entries. Consequence: a JS/TS plugin executes under nub's Node augmentation — TypeScript Just Works, the registered hooks are active, exactly the runtime a nub user expects (consistent with nub's core value prop). A native (non-Node) plugin is exec'd directly. --node / NODE_COMPAT flow through unchanged — a plugin invoked under compat mode runs with augmentation off, same as any other launched bin.
The brand boundary governs public surfaces a user imports, installs, types, or authors. This mechanism stays clear of it:
- The
nub-<verb>binary name is mechanism, not a public API. It follows the universal tool-subcommandPATHconvention —git-<x>,cargo-<x>,kubectl-<x>. A binary name nub probes is internal dispatch plumbing, not anub:*import namespace, not aglobalThis.nub, not a"nub"config field. It is squarely in the internals-exempt zone. - The npm package must avoid the
@nub/*scope (the one forbidden scope). Acceptable homes: an unscoped package (nub-cook), or a vendor/author scope (PerryTS-org,jdalton-scoped, etc.).@nubjs/*is reserved for first-party-blessed plugins only — a community plugin must not squat the project org. - No
nub:*module namespace, no publicglobalThis.nub, no"nub"user-authored config field are introduced by any part of this — the package author writes their own tool; nub merely dispatches to it by name.
This extension point is what lets cook leave core entirely:
- Drop
cook.rs, thevendor/perrysubmodule, andcook'sSUBCOMMANDS/ forwards entries from the nub CLI. - Ship
cookas a standalonenub-cookpackage. It shells out toperryfor the AOT compile, and tonub <file>for the verify oracle —nubis onPATH, so the verify-before-trust step still works unchanged. nub-cookthen iterates on its own cadence, tightly coupled to perry's release rhythm, outside nub's release train. nub stops carrying a perry-coupled verb and a vendored submodule in core.
The user-facing spelling is unchanged: nub cook ... still works, now via dispatch to the nub-cook plugin instead of a built-in verb.
PATH-resolved tool-<verb> dispatch is the dominant CLI-extension convention — this design adopts it, it does not invent it.
- cargo (closest analog — same ecosystem/language):
cargo foo→cargo-fooonPATH. The whole ecosystem rides it (cargo-nextest,cargo-edit,cargo-watch,cargo-binstall, evencargo-clippy);cargo --listenumerates discovered externals. - git (canonical):
git foo→git-foo(PATH+GIT_EXEC_PATH) —git-lfs,git-flow,hub. - kubectl (
kubectl-foo; thekrewmanager is built entirely on it), gh (gh-foo+gh extension), docker (cli-plugins —docker-buildx,docker-composev2), brew, perf, and npm (historically).
They all converge on the same four rules this design uses — built-ins win, prefixed-only probing, no required registry (a plugin is just a reachable executable), and verbatim arg-forwarding — which is the strongest validation that the specific choices here are the well-trodden ones.
pnpm is a deliberate non-example, and the contrast is instructive. pnpm offers no verb-plugin system: its command set is a static, closed array (pnpm/src/cmd/index.ts), and an unknown verb falls through to pnpm run <verb> (an implicit npm-script) via fallbackCommand: 'run' — never to external-binary dispatch. pnpm's only extension axis is resolution hooks — .pnpmfile.cjs/.mjs (readPackage, afterAllResolved, preResolution, beforePacking, updateConfig, filterLog, importPackage) plus top-level custom resolvers/fetchers, and installable hook-plugins via configDependencies + a pnpm-plugin-* / @pnpm/plugin-* naming convention. None of those add a CLI verb; they all operate at the resolution/fetch/manifest/config layer. Two takeaways for nub:
- pnpm spends its unknown-verb slot on implicit script-running — exactly the behavior nub deliberately refuses (no implicit
nub test/nub startshortcuts; always explicitnub run). So nub's unknown-bareword slot is free, and thenub-<verb>dispatch slots into it with no collision. - pnpm's
pnpm-plugin-*/@pnpm/plugin-*naming is itself prior art for thenub-<verb>+@nubjs/*-allowed-but-@nub/*-forbidden shape. - The resolution-hook axis is orthogonal to this change. If nub ever wants a
.pnpmfile-equivalent (custom resolvers/fetchers, manifest rewriting), that is an aube-engine concern — nub's PM engine is the vendored aube, not pnpm — and a separate decision from CLI-verb dispatch.
- (a) Zero core change.
nub exec cook/nubx cookalready work today ifcookships as anode_modules/.binentry — no new dispatch code needed. Rejected as the primary path because you lose the barenub cookspelling; the whole value of #162 is keeping the clean git-style verb surface while moving the implementation out of core. (This remains a real fallback — see below.) - (b) A manifest/registry plugin system — plugins declared in a config file, a
"nub"/"plugins"field, an install-time registration step. Rejected: heavier, introduces an authored-config brand surface to police, adds a registration/lifecycle to maintain, and buys nothing over "isnub-<verb>reachable?". The convention-over-configuration path (a)node_modules/.bin+ (b)PATHis strictly simpler and matches every prior-art tool.
Running a node_modules/.bin entry — or a PATH binary — is the same trust surface nub run / nub exec / nubx already expose. A devDependency's bin is already executable by every script and every nub run; dispatching nub <verb> to it adds no new risk class. The prefixed-only probe (nub-<verb>, never bare <verb>) is the one added safety property: a typo can't cause nub to exec an arbitrary same-named PATH binary.
This is a smaller, more separable decision than the cook feature itself. The question splits cleanly:
- "Do I want a git-style extension point for nub?" — this doc / #200. A ~one-arm dispatch change, no new config surface, no vendored dependency, brand-clean.
- "Do I want a perry-coupled
cookverb and avendor/perrysubmodule living in core?" — #162. The heavier, more-entangled commitment.
Taking the extension point lets the second question be answered "no, ship it as a plugin" without losing the nub cook UX. It is also the natural home for experimental and community verbs — anything that shouldn't be gated on, or coupled to, nub's own release train. And it is the fallback path if cook (#162) is held: even without core changes, cook-as-a-plugin works via nub exec / nubx today (alternative (a)); the extension point just restores the bare nub cook spelling on top.