Skip to content
GitHubRSS

src/cli/index.ts — top-level command dispatcher

Argv → subcommand dispatch; the cli module’s contract. Hand-rolled (no commander / yargs / cac) to keep the dependency tree slim and behaviour trivially predictable. Each subcommand handler lives in a sibling src/cli/<name>.ts.

export async function run(argv: readonly string[]): Promise<number>
// Re-exports for tests + programmatic embedders:
export {
detectFlow,
parseConcurrency,
parseRunArgs,
resolveRunOptions,
type RunArgs,
} from './run.js'
export { parsePruneArgs, parseDuration, parseSize } from './cache.js'
export { parseLockArgs, type LockArgs } from './lock.js'
export { parseInitArgs, type InitArgs } from './init.js'
export { parseShowArgs, type ShowArgs } from './show.js'
export { parseWhyArgs } from './why.js'
export { parseLastArgs } from './last.js'
export { formatBytes } from './format.js'
export { registerCoreAlias } from './core-alias.js'

run(argv) returns the exit code. bin.ts sets process.exitCode to it and lets the event loop drain — no process.exit, no stdout.end: Bun drops what a pipe has not yet taken when process.exit follows a large write (2026-09-15), and on 1.3.11 stdout.end’s callback fired early too (2026-09-20). A verb never calls process.exit itself.

Argv first tokenHandler
runcli/run.ts:runCmd(rest)
watchcli/watch.ts:watchCmd(rest); the OS watcher, its poller fallback and the mtime clock are cli/watch-fs.ts, the event filters cli/watch-filter.ts
cachecli/cache.ts:cacheCmd(rest)
lockcli/lock.ts:lockCmd(rest)
initcli/init.ts:initCmd(rest) — the scripts mapping through the migration seam; scaffolds an empty workspace instead of refusing
upgradecli/upgrade.ts:upgradeCmd(rest)
showcli/show.ts:showCmd(rest)
infocli/info.ts:infoCmd(rest)
whycli/why.ts:whyCmd(rest)
lastcli/last.ts:lastCmd(rest)
completionscli/completions.ts:completionsCmd(rest, pluginVerbs) — a bash / zsh / fish script over the verb table and each verb’s help cut
help / --help / -h / (empty)cli/help.ts:printHelp(pluginVerbs) — plugin verbs listed with their plugin; help <name> for no verb is refused as vx <name> is
version / --versionprocess.stdout.write('vx <VERSION>\n')
anything elsecli/plugin-commands.ts:resolvePluginCommand — the workspace’s plugins are asked, in order, for a commands entry (vx mcp from @vzn/vx-mcp is one); a verb resolving a non-integer, or throwing anything but a UserError, fails naming the plugin; nothing found → unknown command, a near-miss hint when one exists, why plugin verbs could not be looked up when the workspace file failed to load, and a vx help pointer, plus a second line naming where a verb can come from when neither hint applies; exit 1

Per-subcommand parsers / handlers carry their own argv-walk loops. See:

Every verb that opens the cache or lists plugin verbs resolves the workspace through cli/workspace-config.ts:loadCliWorkspace: the workspace config with the plugin config stage applied, the plugin list, and the cache dir derived from the staged config. The stage shapes cacheDir, so a verb reading the file raw would open a directory the run never used. Plugin warnings go to stderr. A plugin verb the dispatcher could never reach — one naming a core verb, or one two plugins both declare — is refused by the workspace VALIDATOR (validateWorkspace, against util/verbs.ts), so a run refuses it exactly as a reading verb or the plugin-verb lookup does.

  • No global flags (no --debug, no --quiet, no --color). Color is gated by env (NO_COLOR / FORCE_COLOR / TTY).
  • No completion installed for you: vx completions bash|zsh|fish prints the script; sourcing it is the user’s.
  • No subcommand aliases beyond the help / version sugar (stats was removed, H-19).
  • No service commands — there is no daemon, server or worker verb in core and no separate service package (removed 2026-09-02). A plugin that wants a verb adds it through commands; vx mcp is the reference.

tests/cli.test.ts covers the dispatcher table — help, version, unknown subcommand, and the service-command redirects (each moved command must NOT report “unknown command”). Per-subcommand parser tests live alongside.

To swap in a parser library, keep run(argv): Promise<number> and keep the per-subcommand re-exports stable (tests import them directly). Everything else can change.