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. - Continue to the filesystem root. Git repositories and the home directory do not bound command discovery.
- The configured user-global root is never collected as a local overlay, even when the walk passes through it.
- Append the user-global root exactly once, last.
- 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.
| 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.
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.
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. Inside each root, select the
longest prefix of the command words whose directory contains a runnable
script.*. - 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.
- Words beyond the selected prefix, plus everything after
--, become the script's argv in that order. - 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.
- 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). - Resolution reads the filesystem on every invocation. There is no persistent or time-based command-path cache.
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 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 --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, including linked file or directory contents, invalidates the trust; cyclic directory links are rejected. Untrusted or changed overlays fail with exit code 3 and acli --trusthint. --list,--which,help, completion, and--doctornever require trust.cli <cmd>always checks 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 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 |
- Words map to directories; a directory with exactly one
script.*is a command. - Roots are searched nearest-local first, user-global last.
- Namespace-only matches fall through to later roots.
- The first root with a runnable prefix wins; within it, 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 once a runnable prefix exists.
- Local overlays need
cli --trustto execute; the global root does not. - Everything above is inspectable without running anything:
--list,--which,--doctor. - Every invocation reads the live filesystem; no command-path cache is used.