Skip to content

Instantly share code, notes, and snippets.

@jdalton
Created June 27, 2026 02:30
Show Gist options
  • Select an option

  • Save jdalton/c71ee66b3099dfc0757142da611d9712 to your computer and use it in GitHub Desktop.

Select an option

Save jdalton/c71ee66b3099dfc0757142da611d9712 to your computer and use it in GitHub Desktop.
nub: git-style external subcommands (nub-<verb>) — design RFC for nubjs/nub#200

External subcommands (nub-<verb> plugins)

Status: PROPOSAL — pending the maintainer's decision. This documents the design behind the open PR (jdalton:feat/external-subcommandsnubjs/nub, "feat: git-style external subcommand dispatch (nub-<verb>)", #200, Refs #162). The v0.x status records the proposed ship band, not a settled commitment.

The model

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.

Resolution order

  1. node_modules/.bin first, via find_bin (nub_core::workspace::scripts::find_bin), which walks up from the cwd — so a hoisted workspace-root .bin entry counts. A plugin is then just a devDependency: nub add -D nub-cook, and nub cook works 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.
  2. PATH next, 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).

Augmentation

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.

Brand-boundary analysis

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-subcommand PATH convention — git-<x>, cargo-<x>, kubectl-<x>. A binary name nub probes is internal dispatch plumbing, not a nub:* import namespace, not a globalThis.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 public globalThis.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.

Externalizing nub cook — the point of #162

This extension point is what lets cook leave core entirely:

  • Drop cook.rs, the vendor/perry submodule, and cook's SUBCOMMANDS / forwards entries from the nub CLI.
  • Ship cook as a standalone nub-cook package. It shells out to perry for the AOT compile, and to nub <file> for the verify oracle — nub is on PATH, so the verify-before-trust step still works unchanged.
  • nub-cook then 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.

Prior art

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 foocargo-foo on PATH. The whole ecosystem rides it (cargo-nextest, cargo-edit, cargo-watch, cargo-binstall, even cargo-clippy); cargo --list enumerates discovered externals.
  • git (canonical): git foogit-foo (PATH + GIT_EXEC_PATH) — git-lfs, git-flow, hub.
  • kubectl (kubectl-foo; the krew manager is built entirely on it), gh (gh-foo + gh extension), docker (cli-plugins — docker-buildx, docker-compose v2), 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 start shortcuts; always explicit nub run). So nub's unknown-bareword slot is free, and the nub-<verb> dispatch slots into it with no collision.
  • pnpm's pnpm-plugin-* / @pnpm/plugin-* naming is itself prior art for the nub-<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.

Alternatives considered

  • (a) Zero core change. nub exec cook / nubx cook already work today if cook ships as a node_modules/.bin entry — no new dispatch code needed. Rejected as the primary path because you lose the bare nub cook spelling; 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 "is nub-<verb> reachable?". The convention-over-configuration path (a) node_modules/.bin + (b) PATH is strictly simpler and matches every prior-art tool.

Trust / security model

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.

Framing for the maintainer

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 cook verb and a vendor/perry submodule 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment