Skip to content

Latest commit

 

History

History
272 lines (217 loc) · 10.6 KB

File metadata and controls

272 lines (217 loc) · 10.6 KB

Routing

How cli <words...> picks a script. This is the normative description of the router; SPEC.md is the underlying contract and API_SURFACE.md documents the same behavior as a library.

Mental model

There is no registry and no config file. The filesystem is the router:

cli gh pull 123 --rebase
    ^^ ^^^^ ^^^^^^^^^^^^
    |  |    argv forwarded to the script
    |  +--- command word -> directory "pull"
    +------ command word -> directory "gh"

Command words map to nested directories inside a command root. A directory is a runnable command when it contains exactly one script.ts, script.mts, script.js, or script.mjs. Everything after the matched command words is passed to that script untouched.

.cli/
  gh/
    pull/
      script.ts      <- "cli gh pull"
      lib/           <- helpers, never routed
  deploy/
    script.js        <- "cli deploy"
    staging/
      script.js      <- "cli deploy staging"

Command roots

A command root is a directory tree of commands. Two kinds exist:

Kind Location Scope value Trust
Local overlay .cli/ inside a project local Must be trusted to run
User-global root ~/.cli root Always trusted

Discovery order

Given the caller's working directory, roots are discovered in this exact order:

  1. Resolve the working directory (options.cwd for the API, process.cwd() for the binary).
  2. Walk upward one directory at a time. Every directory that contains a .cli/ directory contributes a local overlay, nearest first.
  3. Continue to the filesystem root. Git repositories and the home directory do not bound command discovery.
  4. The configured user-global root is never collected as a local overlay, even when the walk passes through it.
  5. Append the user-global root exactly once, last.
  6. Drop duplicate paths, keeping the first occurrence.

Example. With this layout:

~/work/.cli/
~/work/app/.cli/
~/work/app/packages/web/.cli/
~/.cli/

running cli from ~/work/app/packages/web/src discovers, in order:

1. ~/work/app/packages/web/.cli   (local)
2. ~/work/app/.cli                (local)
3. ~/work/.cli                    (local)
4. ~/.cli                         (root)

Order is precedence: 1 beats 2 beats 3 beats 4.

Environment overrides

Variable Effect
ASYNC_CLI_GLOBAL_ROOT Replaces ~/.cli as the user-global root
ASYNC_CLI_PROJECT_ROOT Overrides project context and the fallback target for local writes; it does not bound discovery
ASYNC_CLI_TRUST off disables trust enforcement

The first two exist for tests and controlled launchers; most setups never set them.

Project context and local write targets

CommandRoot.projectRoot, the project-root cwd pragma, and CLI_PROJECT_ROOT use ASYNC_CLI_PROJECT_ROOT when it is set. Without the override, a selected local command uses the directory that owns its .cli. A selected user-global command uses the nearest discovered local owner, or the caller's working directory when there is no local overlay.

Local lifecycle destinations (--new, --cp --to local, --mv --to local, and --add --to local) use the nearest existing local .cli. When none exists, they use ASYNC_CLI_PROJECT_ROOT/.cli or the caller's .cli.

Only the --agents context-file subsystem, including its doctor audit, searches for a Git repository root. Git does not affect command discovery, project context, or local lifecycle destinations.

Ignored segments

These names are invisible to routing, listing, help, completion, and suggestions, at every depth:

help          reserved by "cli help"
lib           conventional helper directory
node_modules
.*            any hidden directory (".git", ".cache", ...)
_*            any leading-underscore directory ("_templates", "_lib", ...)

Two consequences worth knowing:

  • Put shared helper code in lib/ or any _* directory next to your scripts; it can never become a command or collide with one.
  • script is not reserved as a command word. .cli/foo/script/script.js defines the command foo script. Only the script.* filename is special.

Names containing underscores elsewhere are ordinary command words: foo_bar is routable, _foo is not.

Resolution rules

cli <words...> [args...] resolves in these steps:

  1. Split words at the first bare --. Words before it are candidate command words; everything after it is forwarded to the script verbatim and never interpreted as command words.
  2. Validate every candidate word. Empty strings, ., .., absolute paths, anything containing / or \, ignored names (help, lib, node_modules), hidden names (.x), and leading-underscore names (_x) are rejected before the filesystem is touched.
  3. Visit command roots in discovery order. Inside each root, select the longest prefix of the command words whose directory contains a runnable script.*.
  4. Stop at the first root with a runnable prefix. A root that contains only matching namespace directories does not capture the request; continue to the next root.
  5. Words beyond the selected prefix, plus everything after --, become the script's argv in that order.
  6. If no root has a runnable prefix, the nearest matching namespace produces a partial-namespace error with its available subcommands. With no useful namespace match, the command is unknown and suggests up to five near matches by first word.
  7. A command directory with two or more script.* files is ambiguous and fails, listing the conflicting files. Ambiguity is never resolved by extension priority.
  8. Before execution (and only execution — inspection is always allowed), the trust gate applies: scripts selected from a local root run only if that overlay is trusted (see Trust below).
  9. Resolution reads the filesystem on every invocation. There is no persistent or time-based command-path cache.

Worked examples

Layout:

~/work/app/.cli/gh/script.ts             (project overlay)
~/work/app/.cli/release/                 (namespace only)
~/.cli/gh/clone/script.ts                (user-global)
~/.cli/release/notes/script.ts           (user-global)
~/.cli/deploy/script.js                  (user-global)

From inside ~/work/app:

Invocation Result
cli gh clone x Runs local gh with argv ["clone", "x"]
cli release notes Runs global release notes; the local namespace has no runnable prefix
cli deploy Runs global deploy (no local deploy prefix exists)
cli gh -- --list Runs local gh with argv ["--list"]
cli ghx Unknown command; suggests gh

The first row is the important runnable-prefix case. The local gh script matches before the global gh clone, so it receives clone x as arguments and shadows the global command. The second row shows the opposite case: a namespace directory without a script does not block a runnable command in a farther root.

With no local .cli ancestor, cli gh clone x runs the global ~/.cli/gh/clone script with argv ["x"] — nothing shadows it there.

Shadowing

Shadowing is deliberate: a repo can override your personal command with a project-specific version by defining the same path (or any prefix of it) closer to the caller.

  • A nearer runnable prefix shadows farther overlays and the global root, even where the nearer command is shallower.
  • Within one root, deeper runnable prefixes win; across roots, nearer roots win. Nearness beats depth.
  • Namespace-only directories do not shadow. Separate runnable branches may therefore resolve from different roots beneath the same namespace.

Shadowing is always visible:

  • cli --list marks shadowed entries with (shadowed).
  • cli --list --json gives each command a shadows array (script paths it hides) and a shadowed boolean.
  • cli --which gh clone prints the selected script plus every shadowed alternative.
  • cli --doctor reports shadowed commands as info-level findings.

Because a cloned repository can use shadowing to capture commands you run by habit, execution from local overlays is gated by trust.

Trust gate

  • Commands from the user-global root always run.
  • Commands from a local overlay run only when that overlay is trusted: cli --trust records a content hash of the entire overlay; any change to any file under it, including linked file or directory contents, invalidates the trust; cyclic directory links are rejected. Untrusted or changed overlays fail with exit code 3 and a cli --trust hint.
  • --list, --which, help, completion, and --doctor never require trust. cli <cmd> always checks it.
  • ASYNC_CLI_TRUST=off disables the gate for controlled environments.

Execution contract

Once resolved and trusted, the script runs as a plain Node process:

  • argv: resolved extra words then ---forwarded words, at process.argv.slice(2).
  • cwd: the caller's working directory, unless the script opts out with a // cli-cwd: project-root or // cli-cwd: script-dir comment in its first 16 lines.
  • stdio: inherited. Exit code: the script's own. Fatal signals map to 128 + signal.
  • .js/.mjs run directly; .ts/.mts rely on Node 24 native type stripping (TypeScript syntax that Node cannot strip, like enum, fails with Node's own error).

Injected environment:

Variable Value
CLI_SCRIPT Absolute path of the running script
CLI_ROOT Command root that selected the command
CLI_SCOPE local or root
CLI_PROJECT_ROOT Explicit override; otherwise the selected local overlay owner, or for a global command the nearest local owner then caller cwd
CLI_COMMAND The matched command words, space-joined
CLI_CALLER_CWD The caller's working directory, regardless of pragma

Rules, distilled

  1. Words map to directories; a directory with exactly one script.* is a command.
  2. Roots are searched nearest-local first, user-global last.
  3. Namespace-only matches fall through to later roots.
  4. The first root with a runnable prefix wins; within it, the longest runnable prefix wins.
  5. Leftover words plus ---forwarded words become the script's argv.
  6. help, lib, node_modules, .x, and _x never route.
  7. Two script.* files in one directory is an error, never a preference.
  8. Nearness beats depth once a runnable prefix exists.
  9. Local overlays need cli --trust to execute; the global root does not.
  10. Everything above is inspectable without running anything: --list, --which, --doctor.
  11. Every invocation reads the live filesystem; no command-path cache is used.