第 3 章:CLI 命令路由

一个专业的命令行工具,首先要能解析自己的参数。


3.1 核心问题

第 2 章我们有了终端 UI,但 npx tsx src/main.tsx 就是全部了。
真实的 Coding Agent 需要多种启动方式:

claude                           # 交互式 REPL
claude "修复 main.ts 的类型错误"  # 带初始 prompt 启动
claude -p "总结这个文件"          # 非交互,打印到 stdout(管道友好)
claude --resume abc-123          # 恢复历史会话
claude mcp list                  # 子命令:管理 MCP 服务器
claude --version                 # 版本信息
claude --help                    # 帮助文档

这些启动模式如何统一路由到正确的处理逻辑?


3.2 原理讲解

Commander.js:Node.js CLI 的标准解法

Commander.js 是 Node.js 生态最广泛使用的 CLI 框架,Claude Code 使用其 TypeScript 增强版 @commander-js/extra-typings

核心 API:

const program = new Command()

// 全局参数(可选位置参数)
program.argument('[prompt]', 'Your prompt', String)

// 选项(--flag 或 -f)
program.option('-p, --print', 'Print response and exit')
program.option('--cwd <dir>', 'Working directory')

// 子命令
const mcp = program.command('mcp').description('Manage MCP servers')
mcp.command('list').action(() => { /* ... */ })
mcp.command('add <name> <url>').action((name, url) => { /* ... */ })

// 解析并执行
program.action(async (prompt, opts) => { /* 主命令处理 */ })
await program.parseAsync(process.argv)

Commander 的工作原理parseAsync 遍历 process.argv,匹配子命令和选项,填充解析结果,然后调用匹配到的 .action() 回调。

两种解析模式

Commander 支持两种子命令模式:

模式 写法 适用场景
内联 action .command('list').action(() => {...}) 逻辑简单,与主程序共享代码
独立文件 .command('serve', 'Start server', {executableFile: '...'}) 逻辑复杂,需要独立进程

Claude Code 的 mcp 子命令使用内联 action + 动态 import 模式——子命令被懒加载,避免启动时加载不必要的模块。

Claude Code 的三种启动模式

理解这三种模式,是理解整个系统的钥匙:

模式 A:交互式 REPL(默认)
  claude
  claude "带初始 prompt 的 REPL"
    → 启动 Ink UI,进入消息循环,持续等待用户输入

模式 B:打印模式(管道友好)
  claude -p "prompt"
  echo "prompt" | claude -p
    → 不启动 UI,直接输出到 stdout
    → 支持 --output-format json / stream-json
    → 可被脚本、其他程序调用

模式 C:子命令
  claude mcp list / add / remove
  claude config / doctor / update
    → 管理类操作,不启动 Agent 循环

isInteractive(第 1 章)和 -p/--print 选项共同决定走哪条路径:

if (options.print || !process.stdin.isTTY) {
  // 模式 B:打印模式
  await runPrintMode(prompt, options)
} else {
  // 模式 A:交互式 REPL
  await launchRepl(...)
}

3.3 Claude Code 源码中的关键细节

主命令选项全景

Claude Code 的 program 有 40+ 个选项,分为几类:

// src/main.tsx(精简)
program
  .name('claude')
  .argument('[prompt]', 'Your prompt', String)

  // --- 输出控制 ---
  .option('-p, --print', 'Print response and exit (pipe-friendly)')
  .option('--output-format <format>', ...) // text | json | stream-json
  .option('--input-format <format>', ...)  // text | stream-json

  // --- 会话控制 ---
  .option('-c, --continue', 'Continue most recent conversation')
  .option('-r, --resume [id]', 'Resume by session ID or picker')
  .option('--fork-session', 'Create new session ID when resuming')

  // --- 模型控制 ---
  .option('--model <model>', "e.g. 'sonnet' or 'claude-sonnet-4-6'")
  .option('--effort <level>', 'low | medium | high | max')
  .option('--thinking <mode>', 'enabled | adaptive | disabled')

  // --- 权限控制 ---
  .option('--permission-mode <mode>', ...)
  .option('--dangerously-skip-permissions', '...')
  .option('--allowed-tools <tools...>', ...)
  .option('--disallowed-tools <tools...>', ...)

  // --- 上下文注入 ---
  .option('--system-prompt <prompt>', ...)
  .option('--append-system-prompt <prompt>', ...)
  .option('--add-dir <directories...>', ...)

  // --- 调试 ---
  .option('-d, --debug [filter]', 'e.g. "api,hooks" or "!file"')
  .option('--verbose', ...)

  // --- 主命令 action ---
  .action(async (prompt, options) => { ... })

子命令结构

claude
├── mcp                   ← MCP 服务器管理
│   ├── serve             ← 启动 Claude Code MCP Server
│   ├── add               ← 添加 MCP 服务器
│   ├── remove            ← 删除 MCP 服务器
│   ├── list              ← 列出所有 MCP 服务器
│   ├── get               ← 查看某个 MCP 服务器详情
│   └── add-json          ← 通过 JSON 添加
├── config                ← 配置管理(get/set/reset)
├── doctor                ← 环境检查
├── install               ← IDE 集成安装
└── update                ← 自动更新

启动时的性能优化:eagerParseCliFlag

Commander 的 parseAsync 是同步的——它会阻塞直到所有 action 注册完成。
但 Claude Code 有大量 import 语句,模块加载本身就需要 ~100ms。

解决方案:eagerParseCliFlag 在 import 阶段就提前读取关键 flag(不依赖 Commander):

// src/utils/cliArgs.ts
export function eagerParseCliFlag(flag: string): boolean {
  return process.argv.includes(flag)
}

// src/main.tsx 顶部,在所有 import 完成前就已经知道是否是 --print 模式
const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print')

这让打印模式下可以跳过不必要的 Ink、Keychain 等模块的加载。

preAction hook:全局前置处理

// src/main.tsx
program.hook('preAction', async thisCommand => {
  // 在任何 action 执行前运行:
  // 1. 应用 --cwd 切换工作目录
  // 2. 验证 --session-id 格式
  // 3. 初始化遥测
  // 4. 加载企业策略(MDM)
})

Commander 的 preAction hook 让全局初始化逻辑与具体命令解耦——不需要在每个 action 中都重复这些步骤。


3.4 最小化产出物

代码骨架位于 ../chapters/03/src/,参考实现位于 ../chapters/03/solution/

本章要实现什么

../chapters/03/src/main.ts 中完成 CLI 路由逻辑。

接口规范(已提供,不要修改):

const VERSION = '0.1.0'

// 打印模式:输出 "[Agent] (echo) <prompt>"
async function runPrintMode(prompt: string): Promise<void>

// 交互模式:启动第 2 章的 Ink App
async function runInteractiveMode(initialPrompt?: string): Promise<void>

你需要实现

  1. runPrintMode():打印 [Agent] (echo) <prompt>
  2. runInteractiveMode():用 render() 启动第 2 章的 App 组件
  3. Commander 程序,支持:
    • --version → 打印 0.1.0
    • -p/--print → 打印模式
    • --cwd <dir> → 切换工作目录(更新 state.cwd
    • -d/--debug → 打印调试信息到 stderr
    • config list 子命令 → 列出配置
    • config get <key> 子命令 → 获取配置值
  4. preAction hook:处理 --cwd--debug
  5. 主 action:根据 isPrint 决定走打印模式还是交互模式

关键约束

  • 必须 import 第 1 章的 state../../01/src/state.js
  • 必须 import 第 2 章的 App../../02/src/app.js
  • 管道模式(stdin 非 TTY)也走打印模式

验收

cd docs/chapters/03
npm install
npm test

卡住时查看 ../chapters/03/solution/main.ts


3.5 设计要点:为什么不用 yargsminimist

Claude Code 选择 Commander 而非其他 CLI 框架,原因:

  1. TypeScript 类型推导@commander-js/extra-typingsopts 的类型完全来自 .option() 定义,无需手写类型
  2. 子命令树program.command().command() 可以无限嵌套,适合 claude mcp add stdio 这样的多级命令
  3. preAction / postAction hook:跨命令的全局处理不需要中间件包装
  4. 帮助文档自动生成--help 的输出格式与选项声明一一对应,不需要额外维护文档

3.6 本章小结

概念 要记住的要点
Commander.js argument + option + command + action 四件套
parseAsync 遍历 process.argv,匹配并执行对应 action
三种启动模式 交互式 REPL / 打印模式(-p)/ 子命令
preAction hook 全局前置处理(cwd 切换、调试初始化等)
eagerParseCliFlag 在 Commander 解析前提前读取关键 flag,加速启动
懒加载子命令 import() 在 action 内部,避免冷启动加载不必要模块
组合前两章 state 来自第 1 章,App 来自第 2 章;第 3 章是纯粹的"路由层",不重复定义任何逻辑

至此,三章产出物已经形成真正的组合关系:

chapters/01/src/state.ts          ← 地基:进程状态 + 信号处理
        ↑ import
chapters/02/src/app.tsx           ← UI 层:Ink 终端界面
        ↑ import
chapters/03/src/main.ts           ← 路由层:CLI 参数解析,分发到正确模式

CLI 路由层是"分发枢纽":它决定这次进程应该运行哪种模式,然后把控制权交给对应的层。
接下来,我们开始构建 Agent 真正的能力——工具执行引擎。


下一章

第 4 章:工具抽象与执行引擎