第 3 章:CLI 命令路由

# 第 3 章:CLI 命令路由

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

---

## 3.1 核心问题

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

```bash
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](https://github.com/tj/commander.js) 是 Node.js 生态最广泛使用的 CLI 框架,Claude Code 使用其 TypeScript 增强版 `@commander-js/extra-typings`。

核心 API:

```typescript
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` 选项共同决定走哪条路径:

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

---

## 3.3 Claude Code 源码中的关键细节

### 主命令选项全景

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

```typescript
// 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):

```typescript
// 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:全局前置处理

```typescript
// 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 路由逻辑。

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

```typescript
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)也走打印模式

### 验收

```bash
cd docs/chapters/03
npm install
npm test
```

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

---

## 3.5 设计要点:为什么不用 `yargs` 或 `minimist`?

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

1. **TypeScript 类型推导**:`@commander-js/extra-typings` 让 `opts` 的类型完全来自 `.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 章:工具抽象与执行引擎](04-tool-engine)