一个专业的命令行工具,首先要能解析自己的参数。
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>
你需要实现:
runPrintMode():打印[Agent] (echo) <prompt>runInteractiveMode():用render()启动第 2 章的App组件- Commander 程序,支持:
--version→ 打印0.1.0-p/--print→ 打印模式--cwd <dir>→ 切换工作目录(更新state.cwd)-d/--debug→ 打印调试信息到 stderrconfig list子命令 → 列出配置config get <key>子命令 → 获取配置值
preActionhook:处理--cwd和--debug- 主 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 设计要点:为什么不用 yargs 或 minimist?
Claude Code 选择 Commander 而非其他 CLI 框架,原因:
- TypeScript 类型推导:
@commander-js/extra-typings让opts的类型完全来自.option()定义,无需手写类型 - 子命令树:
program.command().command()可以无限嵌套,适合claude mcp add stdio这样的多级命令 preAction/postActionhook:跨命令的全局处理不需要中间件包装- 帮助文档自动生成:
--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 真正的能力——工具执行引擎。