Status: accepted Package: cli Date: 2026-07-02 Amended: 2026-07-06 (v0.2 surface: trust model, completions, --edit/--rm, templates, cli-cwd pragma, --doctor, MCP server mode, command packs)
Projects need a low-friction way to keep small operational commands close to the repository that owns them while still allowing proven commands to become user-global tools. Package scripts, Makefiles, and one-off shell aliases all cover parts of this need, but they do not provide one consistent local-first lookup rule, a portable command directory shape, or a stable machine-readable inventory for developer tools.
@async/cli will provide a small Node package and binary that treats command
directories as the CLI surface. The same command shape works in a repo-local
.cli/ overlay and in the user-global ~/.cli tree.
Create async/cli as the package for @async/cli. It exposes a Node 24+ cli
binary that routes shell words to filesystem scripts.
cli gh pullruns:
.cli/gh/pull/script.ts
Commands are directory-backed. A command directory is runnable only when it
contains exactly one script.{ts,mts,js,mjs} file.
- Start from the caller's current working directory.
- Walk upward collecting
.cli/directories, nearest first. - Stop after the nearest ancestor containing
.git. - If no Git root exists, walk only until
$HOMEor filesystem root. - Never collect
~/.cliduring upward local discovery. - Append
~/.cliexactly once as the root command tree. - Allow
ASYNC_CLI_GLOBAL_ROOTto replace~/.clifor tests and advanced use. - Allow
ASYNC_CLI_PROJECT_ROOTto replace discovered Git-root behavior for tests and controlled launchers.
Ignored path segments during discovery, routing, help, listing, and suggestions:
help
lib
node_modules
.*
_*
script is not reserved as a command segment. This is valid:
.cli/foo/script.js
.cli/foo/script/script.js
Leading _ marks private helper paths. Names containing underscores remain
valid command segments.
- Command words map to nested directories.
- Resolution uses the longest matching command prefix inside the first overlay that has any match.
- A nearer local match shadows parent and root matches, even if the shadowed command is deeper.
- If no local overlay matches the command path, fall back to the root command tree.
- Remaining arguments are forwarded unchanged to the script.
--ends command routing early and forwards everything after it.- Multiple
script.*files in one command directory fail as ambiguous. --listand--whichshow all shadowing layers, including nested local overlays.
Example:
cli gh pull 123 --rebaseresolves to:
command: gh pull
script argv: ["123", "--rebase"]
Namespace shadowing is deliberate. If local .cli/gh/script.ts exists, then:
cli gh clone xruns the local gh command with:
["clone", "x"]
even when ~/.cli/gh/clone/script.ts exists.
Scripts are standalone Node ESM programs.
- Default scaffold is
script.ts. .jsand.mjsrun directly with Node..tsand.mtsrun through Node 24 native type stripping.- TypeScript syntax that Node cannot strip, such as
enumornamespace, fails with Node's own error. - Scripts read arguments from
process.argv.slice(2). - Scripts run from the caller's original working directory by default.
- Stdio is inherited.
- Exit codes and signals are propagated.
- Scripts own their own validation, prompts, and task-specific help.
The runner injects:
CLI_SCRIPT
CLI_ROOT
CLI_SCOPE
CLI_PROJECT_ROOT
CLI_COMMAND
CLI_CALLER_CWD
A head-of-file comment within the first 16 lines selects the script's working directory:
// cli-cwd: project-rootValues:
caller(default): the caller's original working directory.project-root: the discovered Git root; falls back to the caller's working directory outside a repo.script-dir: the command directory containing the script.
Unknown values fail with an actionable error. CLI_CALLER_CWD always carries
the caller's original working directory regardless of the pragma.
If the first line of script.* is a comment of the form:
// cli: Open a pull request against mainthe one-line description appears in help, --list, --list --json, and the
managed agent pointer output. Missing descriptions are represented as empty
strings in JSON.
The built-in surface is:
cli
cli help
cli help gh
cli --list
cli --list --json
cli --which gh pull
cli --new gh pr
cli --new gh pr --root
cli --new gh pr --template worker
cli --edit gh pull
cli --rm gh pull
cli --rm gh pull --root
cli --rm gh --force
cli --cp gh pull
cli --cp gh pull --to root
cli --cp gh pull --to local
cli --mv gh pull
cli --mv gh pull --to root
cli --mv gh pull --to local
cli --add https://example.com/org/pack.git
cli --add https://example.com/org/pack.git --to local
cli --add https://example.com/org/pack.git --prefix vendor
cli --trust
cli --trust --status
cli --untrust
cli --doctor
cli --doctor --json
cli --completions bash
cli --complete -- gh pu
cli --mcp
cli --agents
cli --agents --write
cli --agents --check
cli --agents --claude
cli --versioncliprints help and the available command tree.cli helpprints usage.cli help <prefix>lists matching subcommands below that prefix.cli --listprints all visible commands and marks shadowed commands.cli --list --jsonprints the stable programmatic listing.cli --which <cmd...>prints the selected script and shadowed alternatives.cli --new <cmd...>creates a command directory withscript.ts.cli --new <cmd...> --rootcreates under the root command tree.cli --cp <cmd...>defaults to--to root.cli --cp <cmd...> --to rootcopies the nearest matching local command directory into the root command tree.cli --cp <cmd...> --to localcopies a root command directory into the current Git root's.cli.cli --mv <cmd...>defaults to--to root.cli --mv <cmd...> --to rootmoves the nearest matching local command directory into the root command tree.cli --mv <cmd...> --to localmoves a root command directory into the current Git root's.cli.cli --edit <cmd...>opens the resolved script in$VISUALor$EDITOR(falling back tovi).cli --rm <cmd...>removes the whole command directory from the nearest matching local overlay (or the root tree with--root) and prunes empty parents. Directories containing nested commands require--force.cli --add <git-url>installs a command pack (see Command Packs).cli --trust,cli --untrust, andcli --trust --statusmanage local overlay trust (see Trust Model).cli --doctoraudits the command trees (see Doctor).cli --completions <shell>and the hiddencli --completehelper provide shell completions (see Completions).cli --mcpserves the command tree over MCP stdio (see MCP Server Mode).cli --agentsmanages repo context file discoverability.
--new target selection:
- Use the nearest existing local
.cliif one exists. - Otherwise create under the Git root
.cli. - Outside a Git repo, require
--root.
cli --new <cmd...> --template <name> copies a template directory instead of
writing the default scaffold:
- Templates live in
_templates/<name>/under any command root, searched nearest-local first, then the user-global tree. - The leading underscore keeps
_templatesout of routing, listing, and help. - A template directory is copied verbatim and must produce exactly one
top-level
script.{ts,mts,js,mjs}in the new command directory. - A missing template fails and lists the available template names.
Move rules:
- Move the whole command directory.
- Preserve the command path.
- Refuse to overwrite an existing target unless a future
--forceoption is added. - Remove empty source parents after moving.
- Do not copy sibling
lib/or_lib/directories. - Warn if
script.*has relative imports escaping the command directory via../, because the command may not survive a move cleanly.
Copy rules:
- Copy the whole command directory.
- Preserve the command path.
- Refuse to overwrite an existing target unless a future
--forceoption is added. - Do not copy sibling
lib/or_lib/directories. - Warn if
script.*has relative imports escaping the command directory via../, because the command may not survive a copy cleanly.
.cli commands are human-first, but coding tools working inside a repo should
discover and prefer them over ad-hoc equivalents. The committed pointer block is
how repo context files tell tools that the live command tree exists.
Default target is the repo root AGENTS.md. --claude explicitly targets
CLAUDE.md. There is no arbitrary file target in v1.
cli --agents
cli --agents --write
cli --agents --check
cli --agents --claude
cli --agents --claude --write
cli --agents --claude --checkcli --agents prints the managed block for the selected target. --write
upserts it idempotently between markers in the selected file, creating the file
when missing. --check exits nonzero if the block is missing or outdated.
Managed block:
<!-- async-cli:begin -->
## Project commands (async/cli)
This repo defines runnable commands under `.cli/` (plus user-global `~/.cli`),
executed via the `cli` binary from `@async/cli`.
- Discover: `cli --list --json` (commands, descriptions, script paths)
- Inspect: `cli --which <words...>`
- Run: `cli <words...> [args...]` (e.g. `cli gh pull 123`)
Prefer a matching `.cli` command over improvising the same task.
<!-- async-cli:end -->The block is a static pointer by design. The live tree comes from --list, so
committed docs do not need to embed command listings.
cli --completions <bash|zsh|fish> prints a completion script for the given
shell. The scripts delegate to the hidden helper:
cli --complete -- <words...>which prints one candidate per line: next command segments below the typed
prefix, filtered to non-shadowed commands, or built-in flags when the first
word starts with -. Completion never executes scripts and never fails
loudly; errors produce no candidates.
cli --doctor [--json] audits every discovered command root and reports:
- errors: ambiguous command directories with multiple
script.*files, and an unreadable trust store. - warnings: scripts importing through
../, empty command directories, untrusted or changed local overlays, and outdated managed context blocks. - infos: missing
// cli:descriptions, shadowed commands, and repos with no context pointer at all.
Exit code is 1 when any error is present, otherwise 0. --json emits
{ version, problems, summary } for tooling.
cli --mcp runs a Model Context Protocol server over stdio using
newline-delimited JSON-RPC 2.0, with zero runtime dependencies. It handles
initialize, ping, tools/list, and tools/call.
- Every non-shadowed command becomes a tool. Command words are joined with
__and sanitized to MCP-safe names (gh pullbecomesgh__pull). - Tool descriptions come from the
// cli:line. - Each tool accepts
{ "args": ["..."] }and forwards them to the script. tools/callcaptures stdout and stderr (capped at 1 MiB each) and reports nonzero exits asisError: true.- Commands from untrusted local overlays are excluded from
tools/listand refused at call time while trust enforcement is active.
cli --add <git-url> [--to root|local] [--prefix <name>] [--force] installs
commands from another repository:
- The source is anything
git cloneaccepts. Cloning is shallow and lands in a temporary directory that is always cleaned up. - The pack's command tree is its
.cli/directory; a repo without.cli/is not a pack. - Without
--prefix, each top-level command directory installs under the target tree. A pack with a runnable command at its.cli/root requires--prefix. - With
--prefix <name>, the whole pack tree installs under that single namespace directory. - Existing target directories are refused unless
--force, which replaces them whole. - The default target is the user-global tree.
--to localinstalls into the current Git root's.cliand records trust for that overlay, since the install is an explicit consent action.
cli --list --json is the stable programmatic surface:
{
"version": 1,
"roots": [{ "path": "/repo/.cli", "scope": "local" }],
"commands": [
{
"command": "gh pull",
"script": "/repo/.cli/gh/pull/script.ts",
"scope": "local",
"description": "Open a PR against main",
"shadows": []
}
]
}shadows lists script paths this command hides across overlays.
- Repo:
async/cli - Package:
@async/cli - Binaries:
cli,async-cli - Exports:
discoverRoots(options)listCommands(options)resolveCommand(options, args)runCommand(options, args)createCommand(options, commandPath)copyCommand(options, commandPath)moveCommand(options, commandPath)removeCommand(options, commandPath)addPack(options, source)runDoctor(options)complete(options, words)andcompletionScript(shell)runMcpServer(options, io)- trust helpers:
trustLocalOverlays,untrustLocalOverlays,localOverlayTrust,overlayTrustState,recordOverlayTrust,removeOverlayTrust,ensureOverlayTrusted,hashOverlayTree,isTrustEnforced,trustStorePath
Environment overrides:
ASYNC_CLI_GLOBAL_ROOT
ASYNC_CLI_PROJECT_ROOT
ASYNC_CLI_TRUST (set to "off" to disable trust enforcement)
- Unknown command: concise error, nearest suggestions, and
cli helphint. - Partial namespace: list available subcommands below the matched prefix.
- Ambiguous
script.*directory: list the conflicting files. - Unsafe path segment in routing,
--new,--rm,--cp,--mv, or--add --prefix: reject empty segments,.,.., absolute paths, path separators, ignored names, hidden segments, and leading-underscore segments. - No Git root for
--newwithout--root,--rmwithout--root,--cp --to local,--mv --to local, or--add --to local: print an actionable message. - Untrusted or changed local overlay at execution time: exit 3 with a
cli --trusthint. - Missing template: exit nonzero listing available template names.
- Invalid pack or failed
git clone: exit nonzero with the git error tail. --agents --checkdrift: exit nonzero with acli --agents --writehint.- Script failure: preserve the script's own exit code.
.cli scripts are arbitrary code, equivalent to package scripts or Makefiles,
and local overlays arrive with cloned repositories. Because a nearer local
overlay can shadow user-global commands, running commands from an untrusted
overlay is refused by default — the direnv model.
- The user-global tree is always trusted.
- Local overlays must be trusted explicitly with
cli --trust, which records a content hash of the whole overlay tree (scripts,lib/, everything) in.trust.jsonunder the user-global root. - Any content change invalidates trust: execution fails with exit 3 until the
user reviews and re-runs
cli --trust. cli --trust --statusreportstrusted,changed, oruntrustedper overlay.cli --untrustrevokes trust.- Read-only surfaces (
--list,--which,help, completions) never require trust; execution surfaces (cli <cmd>, MCPtools/call) always check it. - Mutations performed through the CLI are consent:
--new,--cp --to local,--mv --to local, and--add --to localrecord or refresh trust for the target overlay when it is fresh or was already trusted. They never silently bless a pre-existing untrusted overlay. ASYNC_CLI_TRUST=offdisables enforcement for tests and controlled environments.
- Argument parsing for user scripts.
- Generated per-command help from script metadata.
- Interactive trust prompts; trust is explicit via
cli --trust. - Non-JavaScript entrypoints such as
.shor.py. - Runtime dependency management for scripts.
- Cross-platform shell launcher behavior beyond Node process spawning.
- Arbitrary context files for
--agents; onlyAGENTS.mdand explicit--claudeare in scope. - A hosted pack registry; packs are plain Git repositories.
_docs/cli/ADR_0.md_docs/cli/ADR_1_root_package.md_docs/cli/ADR_2_router_runtime.md_docs/cli/ADR_3_agent_integration.md- future
cli/**files described by the later ADR slices
- ADR review against this charter.
- Future package verification defined by ADR 1 through ADR 3.
- Public wording leakage scan before docs are treated as complete.
- The active ADR set exists under
_docs/cli/. - ADR 0 defines the package charter and full v1 behavior.
- Later ADRs break implementation into scaffold, router/runtime, and agent integration slices.
- Each implementation slice names allowed files, verification commands, and stop conditions.
- Stop if an existing active or archived CLI ADR conflicts with this charter.
- Stop if a real
async/clirepository already exists with incompatible package scope or command semantics. - Stop before adding runtime dependencies for TypeScript execution unless Node 24 native type stripping cannot satisfy the accepted v1 contract.