第05章 让CLI长得像Claude Code
第05章 让CLI长得像Claude Code
作者:亢AIRTC | 源码地址:https://github.com/kang-airtc/cli-mini-book
第 3、4 两章解决了“能跑、能装”的问题,但实际工程里这远远不够。一份成熟的 Agent CLI 应当具备清晰的子命令分组、统一的全局选项、可预测的退出码、以及人类可读与机器可解析两种输出模式。下文以 OpenCode、Claude Code 的命令布局为参照,把 my-agent-node-cli 扩展为一个布局规范、Agent 友好的 CLI。
5.1 子命令布局的设计
5.1.1 头部Agent产品的命令树
观察主流 Agent CLI,可以发现命令布局有相近的结构:核心动作(chat、run)独立放在顶层、配置与认证类归入 config/auth 分组、扩展能力(mcp、hooks)独立成子命令组、辅助调试类(status、doctor)单独存在。下面给出一个综合借鉴后的命令树骨架。

如图 5-1 所示,命令树按职责分四类:核心交互、状态查询、配置管理、扩展能力。这种分组让用户在 my-agent --help 输出中能在 10 秒内找到自己要的子命令,也让后续添加新功能时有清晰的归属位置。
5.1.2 全局选项的约定
全局选项是所有子命令共用的开关,典型的有 --verbose、--quiet、--config <path>、--output <format>。commander 用 .option() 直接挂在顶层 program 上即可生效。
program
.name('my-agent')
.description('My Agent CLI')
.version('1.0.0')
.option('-v, --verbose', '输出详细日志')
.option('-q, --quiet', '只输出错误')
.option('--config <path>', '指定配置文件路径')
.option('--output <format>', '输出格式: text 或 json', 'text');
子命令的 action 可以通过 program.opts() 读取全局选项的值。需要注意的是,commander 把 program.opts() 解析为对象时,长选项名 --config 对应字段名 config,长选项名 --output 对应字段名 output。
5.2 子命令分组的实现
5.2.1 单文件展开与分文件组织
子命令多了之后,把所有逻辑写在一个 cli.ts 里会变得难以维护。my-agent-node-cli 的 src 目录已经初步演示了分文件组织。下面以 chat 与 status 两个常见子命令为例,给出推荐的目录结构。
src/
├── cli.ts # 入口,仅注册子命令
├── commands/
│ ├── chat.ts # chat 子命令实现
│ ├── status.ts # status 子命令实现
│ ├── config/
│ │ ├── get.ts
│ │ └── set.ts
│ └── auth/
│ └── login.ts
└── utils/
└── output.ts # 共享的输出格式工具
cli.ts 只做注册,不写业务逻辑,类似下面的样子。
import { Command } from 'commander';
import { registerChat } from './commands/chat';
import { registerStatus } from './commands/status';
import { registerConfig } from './commands/config';
const program = new Command().name('my-agent').version('1.0.0');
registerChat(program);
registerStatus(program);
registerConfig(program);
program.parse();
每个子命令模块导出一个 register<Name>(program) 函数,由 cli.ts 统一调用。这样新增子命令只需要写一个新文件 + 在 cli.ts 加一行注册。
5.2.2 嵌套子命令组
config 是典型的需要二级子命令的功能:config get、config set、config unset、config list。commander 用 .command() 链式嵌套即可表达。
export function registerConfig(program: Command) {
const cfg = program.command('config').description('配置项管理');
cfg.command('get <key>')
.description('读取配置项')
.action((key) => { /* ... */ });
cfg.command('set <key> <value>')
.description('写入配置项')
.action((key, value) => { /* ... */ });
cfg.command('list')
.description('列出所有配置')
.action(() => { /* ... */ });
}
用户敲 my-agent config --help 会得到 get、set、list 的列表;敲 my-agent config get --help 会得到 get 子命令的详细帮助。这种自动生成的多级帮助文本是 commander 相对手写参数解析的最大价值。
5.3 输出双模式:给人看与给Agent看
5.3.1 两种输出的根本差异
读者要意识到,CLI 输出在 Agent 时代承担两类不同的契约。给人看的输出追求可读性:彩色、表情符号、对齐排版、关键字突出。给程序看的输出追求可解析性:稳定的字段名、不变的层次结构、可以被 jq 直接处理的 JSON。

如图 5-2 所示,同一条 my-agent status 命令,text 模式输出彩色块状信息,json 模式输出严格的结构化字段。
5.3.2 输出格式分发器
把这两种模式实现为一个统一的工具函数,所有子命令通过它输出。core 思路是根据 --output 选项分派到不同的渲染器。
// src/utils/output.ts
type OutputMode = 'text' | 'json';
export function emit(data: Record<string, unknown>, mode: OutputMode) {
if (mode === 'json') {
process.stdout.write(JSON.stringify(data) + '\n');
return;
}
// text 模式:按字段名缩进打印
for (const [k, v] of Object.entries(data)) {
console.log(`${k}: ${v}`);
}
}
子命令的 action 把要展示的数据组装为一个对象,再交给 emit 决定怎么呈现。
import { emit } from '../utils/output';
export function registerStatus(program: Command) {
program.command('status').action(() => {
const data = {
version: '1.0.0',
auth: 'logged in',
model: 'claude-sonnet-4-6',
};
const mode = program.opts().output as 'text' | 'json';
emit(data, mode);
});
}
读者可以通过两条命令对比效果。
my-agent status
# version: 1.0.0
# auth: logged in
# model: claude-sonnet-4-6
my-agent status --output json
# {"version":"1.0.0","auth":"logged in","model":"claude-sonnet-4-6"}
后者可以直接被另一个 Agent 通过 JSON.parse() 消费,前者更适合开发者在终端目视检查。
注意:json 输出模式下,绝对不能往 stdout 写非 JSON 内容。任何调试日志、提示信息都必须改用 stderr。否则下游
jq或 Agent 解析时会因混入了非 JSON 行而失败。这是 CLI 给程序用的最容易踩的坑之一。
5.4 退出码、错误处理、ANSI颜色
5.4.1 退出码约定
退出码是 CLI 与调用方之间的契约,必须语义稳定。Agent CLI 推荐的退出码语义如表 5-1 所示。
表 5-1 Agent CLI推荐的退出码语义
| 退出码 | 含义 | 典型场景 |
|---|---|---|
| 0 | 成功 | 命令正常完成 |
| 1 | 一般运行时错误 | 模型调用失败、文件不存在 |
| 2 | 参数错误 | 必填选项缺失、值格式不合法 |
| 124 | 超时 | 调用超过用户设定的 timeout |
| 130 | 用户中断 | Ctrl+C 触发 SIGINT |
如表 5-1 所示,退出码不只是给人看,也是给上游 Agent 用作判断分支的依据。一致的退出码语义使得 Bash 脚本可以用 if my-agent xxx; then ... fi 来做控制流。
5.4.2 错误信息的流分发
错误信息必须写到 stderr,不能混在 stdout 里。这一原则在 5.3.2 节的注意框已经说过,但值得在错误处理代码中再次强调。
import { emit } from '../utils/output';
function fail(msg: string, code = 1): never {
process.stderr.write(`error: ${msg}\n`);
process.exit(code);
}
// 调用示例
if (!options.token) {
fail('missing required option: --token', 2);
}
这种集中式的 fail 函数把“打印错误 + 退出”压缩为一行,避免业务代码里到处出现 console.error 后忘记调用 process.exit 的情况。
5.4.3 ANSI颜色与TTY检测
text 模式可以使用 ANSI 颜色让输出更易读,但需要在两种场景下关闭:标准输出被重定向到文件、--output json 模式、用户显式 --no-color。检测方法是 process.stdout.isTTY。
const useColor =
process.stdout.isTTY &&
program.opts().output !== 'json' &&
!process.env.NO_COLOR;
const green = (s: string) => useColor ? `\x1b[32m${s}\x1b[0m` : s;
console.log(green('✓ Login successful'));
环境变量 NO_COLOR 是一个跨工具的事实标准(参见 no-color.org),尊重它能避免和上游脚本产生冲突。
注意:当 CLI 被 Agent 通过子进程调用时,标准输出默认不是 TTY,因此 isTTY 返回 false,颜色自动关闭。这是 ANSI 颜色不会污染 Agent 解析的天然保护。但若读者自己写了一个强制带色彩的逻辑,会破坏这一保护。
5.5 本章小结
至此 my-agent-node-cli 在表面上已经具备 Claude Code 类产品的核心特征:分组的子命令树、统一的全局选项、双模式输出、稳定的退出码、TTY 友好的颜色策略。最重要的设计原则只有一条:CLI 的输出是契约,给人看的契约和给 Agent 看的契约必须分开维护,绝不混淆。
下一章切换到 Python,用 click 与 rich 重做一遍上述全部功能。读者将看到两种生态在同一问题域里的差异:装饰器式 API 与链式 API 的写法对比、entry_points 与 bin 的概念映射、rich 库带来的表格与进度条能力。
更多推荐



所有评论(0)