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.
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"
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 |
Given the caller's working directory, roots are discovered in this exact order:
- Resolve the working directory (
options.cwdfor the API,process.cwd()for the binary). - Walk upward one directory at a time. Every directory that contains a
.cli/directory contributes a local overlay, nearest first. - Stop the walk after the nearest ancestor that contains
.git(the project root). If no project root exists, walk until$HOMEor the filesystem root, whichever comes first. ~/.cliis never collected by the walk, even when the walk passes through$HOME.- Append the user-global root exactly once, last.
- Drop duplicate paths, keeping the first occurrence.
Example. With this layout:
~/work/app/.cli/ <- project root (~/work/app has .git)
~/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. ~/.cli (root)
Order is precedence: 1 beats 2 beats 3.
| Variable | Effect |
|---|---|
ASYNC_CLI_GLOBAL_ROOT |
Replaces ~/.cli as the user-global root |
ASYNC_CLI_PROJECT_ROOT |
Pins the project root instead of searching for .git |
ASYNC_CLI_TRUST |
off disables trust enforcement |
The first two exist for tests and controlled launchers; most setups never set them.
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. scriptis not reserved as a command word..cli/foo/script/script.jsdefines the commandfoo script. Only thescript.*filename is special.
Names containing underscores elsewhere are ordinary command words: foo_bar
is routable, _foo is not.
cli <words...> [args...] resolves in these steps:
- 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. - 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. - Visit command roots in discovery order. The first root that contains any directory prefix of the command words captures the command. Later roots are not consulted, even if they hold a longer or "better" match. This is the first-overlay-wins rule.
- Inside the capturing root, select the longest prefix of the command words
whose directory contains a runnable
script.*. Shorter runnable prefixes lose to longer ones within the same root. - Words beyond the selected prefix, plus everything after
--, become the script's argv in that order. - If the capturing root has matching directories but no runnable script along the prefix, resolution fails: with subdirectories it is a partial-namespace error listing the available subcommands; without them it is an unknown command.
- If no root captures, the command is unknown; the error suggests up to five near matches by first word.
- A command directory with two or more
script.*files is ambiguous and fails, listing the conflicting files. Ambiguity is never resolved by extension priority. - Before execution (and only execution — inspection is always allowed), the
trust gate applies: scripts selected from a
localroot run only if that overlay is trusted (see Trust below).
Layout:
~/work/app/.cli/gh/script.ts (project overlay)
~/.cli/gh/clone/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 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 one. The local overlay contains the directory
gh, so it captures every command starting with gh — including gh clone,
which only exists as a runnable script in the global tree. The local gh
script receives clone x as arguments. The global gh clone is shadowed.
From outside any project, cli gh clone x runs the global ~/.cli/gh/clone
script with argv ["x"] — nothing shadows it there.
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 overlay shadows farther overlays and the global root for the whole namespace it captures, even where the nearer tree is shallower.
- Within one root, deeper runnable prefixes win; across roots, nearer roots win. Nearness beats depth.
- Nothing is merged. Namespaces do not union across roots; the capturing root is authoritative for everything under the captured prefix.
Shadowing is always visible:
cli --listmarks shadowed entries with(shadowed).cli --list --jsongives each command ashadowsarray (script paths it hides) and ashadowedboolean.cli --which gh cloneprints the selected script plus every shadowed alternative.cli --doctorreports 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.
- Commands from the user-global root always run.
- Commands from a local overlay run only when that overlay is trusted:
cli --trustrecords a content hash of the entire overlay; any change to any file under it invalidates the trust; untrusted or changed overlays fail with exit code 3 and acli --trusthint. --list,--which,help, completion, and--doctornever require trust.cli <cmd>and MCPtools/callalways check it.ASYNC_CLI_TRUST=offdisables the gate for controlled environments.
Once resolved and trusted, the script runs as a plain Node process:
- argv: resolved extra words then
---forwarded words, atprocess.argv.slice(2). - cwd: the caller's working directory, unless the script opts out with a
// cli-cwd: project-rootor// cli-cwd: script-dircomment in its first 16 lines. - stdio: inherited. Exit code: the script's own. Fatal signals map to
128 + signal. .js/.mjsrun directly;.ts/.mtsrely on Node 24 native type stripping (TypeScript syntax that Node cannot strip, likeenum, fails with Node's own error).
Injected environment:
| Variable | Value |
|---|---|
CLI_SCRIPT |
Absolute path of the running script |
CLI_ROOT |
Command root that captured the command |
CLI_SCOPE |
local or root |
CLI_PROJECT_ROOT |
Project root, or empty string outside a repo |
CLI_COMMAND |
The matched command words, space-joined |
CLI_CALLER_CWD |
The caller's working directory, regardless of pragma |
- Words map to directories; a directory with exactly one
script.*is a command. - Roots are searched nearest-local first, user-global last.
- The first root containing any prefix directory captures the command entirely.
- Within the capturing root, the longest runnable prefix wins.
- Leftover words plus
---forwarded words become the script's argv. help,lib,node_modules,.x, and_xnever route.- Two
script.*files in one directory is an error, never a preference. - Nearness beats depth; nothing merges across roots.
- Local overlays need
cli --trustto execute; the global root does not. - Everything above is inspectable without running anything:
--list,--which,--doctor.