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:
- 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.
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. 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.
Resolution rules
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.
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 --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.
Trust gate
- 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.
Execution contract
Once resolved and trusted, the script runs through the executable hosting the
CLI. The installed cli and async-cli binaries use Node 24+. An explicit
deno run -A npm:@async/cli/cli ... invocation uses Deno 2.7+ for the CLI and
the selected command:
- argv: resolved extra words then
---forwarded words, atprocess.argv.slice(2). Deno commands may also useDeno.args. - 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. Under Node,.ts/.mtsuse Node 24 native type stripping; under Deno they use Deno's TypeScript support.
Runtime selection is per CLI invocation, not per command. The router does not
infer a runtime from script extensions, shebangs, deno.json, or installed
executables. Deno's -A mode intentionally grants the same current-user access
as the Node path; overlay trust remains the command-execution boundary.
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
- 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.
- The host runtime applies to the whole invocation: Node for installed binaries, or Deno when the published CLI is launched explicitly with Deno.