实现篇第6章 开一扇可靠的闸——CLI

本章导读: 你会写
src/cli.ts的runCli、runInteractive和纯函数exitCodeForStopReason。这一步的关键是分离契约:可测试的 CLI 函数靠注入ask/write摆脱终端,退出码 0/2/130 各有含义,而main()才是把异常变成 stderr 的边界。
会话和循环都有了。小A兴冲冲地想把 main.ts 当成所有入口,不管什么命令,都从这里进。老Z拦住他:“先别急着合并。可测试的 CLI 函数和生产 argv 入口承担的是不同契约,揉成一个,测试没法写,退出码也没法保证。”
“那到底要几个入口?”
“三个,各管各的。本章只实现一次问答,不实现 REPL、流式界面或诊断框架。”
三个入口,三个责任
| 符号 | 输入 | 输出/退出语义 | 不负责什么 |
|---|---|---|---|
main.ts | process.argv、环境变量 | 只打印最后一条 assistant content,并把 stop reason 映射为进程退出码 | signal、session、REPL |
runCli() | 注入 client、工具、ask、write | 输出每条非空 assistant content,并返回 Promise<number> | 创建真实 Provider |
runInteractive() | 标准输入输出 | 调一次 runCli() 后关闭 readline | 连续 REPL |
argv 的生产路径
“先看 argv 进来以后发生什么。”老Z打开 main.ts:
user: process.argv.slice(2).join(" ") || "Describe the workspace.",
maxTurns: 8,
beforeToolCall: denyBashByDefault,“这里把 argv 余项拼成一条用户消息,并装配真实 AnthropicModelClient、工作区工具和默认 bash 拒绝策略,调用一次 runAgent,再筛选最后一条 assistant 文本打印。argv 为空时不进对话循环,用的是默认问题。”
小A:“那 API key 怎么处理?测试的时候可不能读真实 key。”
“这就是凭据边界。”老Z敲出同文件的另一段:
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required for production use; npm test uses no API key.");“正常路径要求同时提供 key 与模型名,缺任一项都会在调用网络之前失败。main() 捕获启动或运行异常后,以稳定的 mini-agent: failed to start or run: stderr 前缀输出受控诊断,并设置退出码 1;格式化时会替换已配置的 API key,key 的值不能写入 stderr。异常消息也不会被静默吞掉。”
“为什么要把 key 从错误消息里 redact?异常又不是模型吐的。”
“防的就是我们自己的错误日志。”老Z说,“SDK 在认证失败时会怎么报错?它可能把请求头打进去,而请求头里就有 x-api-key: sk-ant-...。main() 要是把原始 error.message 直接写进 stderr,key 就泄漏到日志里了。所以 formatMainError 做了一件事:detail.split(apiKey).join("[REDACTED]"),把 key 字面量替换掉。测试里专门构造了一个 new Error(\provider rejected ${apiKey}`),断言输出里能匹配到 [REDACTED]` 且匹配不到原始 key。”
“那为什么不在请求阶段就脱敏,要等到 catch?”小A追问。
“因为 key 是用来发请求的,发的时候不能改。”老Z说,“脱敏只能发生在要把信息写出去的那一刻,也就是 stderr。在内存里 key 必须是原值,否则 SDK 没法用它认证。”
小A:“那 formatMainError 为什么是导出的纯函数,而不是嵌在 catch 里?”
“为了能直接测。”老Z说,“cli-exit.test.ts 里就是直接调 formatMainError(new Error(...), apiKey)。这要求它不碰 process.env、不写终端,输入输出都是值。要是嵌进 main 的 catch,测试就得真的启动进程,还要伪造 ANTHROPIC_API_KEY,单测就背上了集成测试的负担。拆完之后,main 里只剩读 env、调 runAgent、打印、设退出码四件事,其余都是纯函数。”
“退出码呢?”小A接着问,“总不能全都返回 0 吧?”
“在打印最后一条 assistant content 后,main() 和 runCli() 都调用同一个纯映射:complete 为 0,max_turns 为 2,aborted 为 130。所以 npm start 遇到轮次耗尽不会再以 0 结束。main 目前没有装配 signal;aborted 在这里只表示映射完整性,不代表已经支持 Ctrl-C。”
四个退出码的完整语义摊开看:
| 退出码 | stop reason | 含义 | 脚本怎么判断 |
|---|---|---|---|
| 0 | complete | 循环完整结束 | 正常收尾 |
| 2 | max_turns | 轮次耗尽,没跑完 | 结果可能不完整 |
| 130 | aborted | 调用方主动中止 | $? -eq 130 就是被中断 |
| 1 | (main 捕获异常) | 启动或运行失败 | 要看 stderr 诊断 |
“注意 1 不在 exitCodeForStopReason 里。”老Z说,“1 是 main() 的 catch 分支设的(见 main.ts 的 process.exitCode = 1),对应的是异常,不是 stop reason。四种结局里,三种正常退出的 stop reason 由纯函数映射,剩下一种异常由 main 兜底。”
main 的生产路径画成一张图:
process.argv.slice(2).join(" ") 或默认问题
│
▼
main(): 校验 ANTHROPIC_API_KEY / ANTHROPIC_MODEL
│ 缺任一 → throw → formatMainError → stderr + exit 1
▼
runAgent({ client, tools, user, maxTurns: 8, beforeToolCall })
│
▼
筛选最后一条 assistant content ── 打印(空则 ?? "")
│
▼
exitCodeForStopReason(stopReason) → process.exitCode可测试的一次问答
“runCli() 这层,总不能在测试里起个真终端吧?”
“靠注入。”老Z切到 cli.ts,先指签名:
// examples/mini-agent/src/cli.ts,节选
export async function runCli(options: {
client: ModelClient;
tools: Tool[];
ask: (prompt: string) => Promise<string>;
write: (text: string) => void;
signal?: AbortSignal;
beforeToolCall?: BeforeToolCall;
}): Promise<number>“注意 ask 返回 Promise<string>,它可以是终端、可以是 fake、可以是任何输入源;write 是同步的 void,只负责把文本吐出去。测试就靠注入这两个,完全不碰真实终端。”
“为什么 write 是同步的?模型输出明明是异步的。”
“因为 write 接的不是模型流,是 runAgent 跑完之后的整条消息。”老Z指了指后面那段循环,“runCli 先 await runAgent(...) 拿到完整的 result,再遍历 result.messages,把每条 assistant content 写出去。流式过程在 loop 内部消化掉了,到 write 这一层已经是一个字符串,它只是个副作用接口,不需要 async。这样测试就能拿一个数组当 write,把所有输出收进来断言。”
“那 ask 呢?也同步就行了吧?”
“不行,ask 背后可能是终端,终端 I/O 是异步的。”老Z说,“runInteractive 里 ask 接的是 rl.question(p),readline/promises 的 question 返回 Promise<string>。测试里它可以接 async () => 'hello',甚至接一个同步函数的 Promise 包装。一个接口是 async 还是 sync,看真实实现里最重的那个:ask 最重的实现是终端,所以是异步的;write 最重的实现是 stdout.write,同步就够。”
const input = await options.ask("> ");
if (!input.trim()) return 0;
const result = await runAgent({ client: options.client, tools: options.tools, user: input,
maxTurns: 8, signal: options.signal, beforeToolCall: options.beforeToolCall });小A盯着第一行问:“空输入返回 0,这不算撒谎吗?明明什么都没干。”
“因为这是‘用户主动放弃’,不是‘系统出错’。”老Z说,“空回车不在三种 stop reason 里,也没有异常发生,就是没给输入。返回 0 是告诉脚本‘这次没有要处理的’,不用报警。测试里专门传 ask: async () => " " 验证这一条,还断言 client.calls === 0:返回发生在任何模型调用之前。”
“signal 被传入 loop。取消在 runAgent() 的 checkpoint 生效;客户端或工具若不响应 signal,调用方只能等待它们交还控制权。”
小A:“那 runCli 的系统提示词是什么?它自己定的?”
“对,它内置了一句:Careful coding assistant. Never include credentials in messages.”老Z说,“这句强调凭据不写进消息,呼应安全章的最小暴露原则,也是 CLI 层唯一自己写死的规则。每一层都可以有自己的 system 补充,但下笔前先想清楚为什么要这一句。”
小A抢着推理:“它输出的应该是 result.messages 里所有消息吧?”
“只挑 assistant 的非空 content。看这行:for (const message of result.messages) if (message.role === "assistant" && message.content) options.write(message.content)。而 main 只打印最后一条。”
两个入口的契约差异并排放在一起:
| 维度 | runCli() | main() |
|---|---|---|
| 输入 | ask(可注入) | process.argv |
| 输出 | 每条非空 assistant content | 最后一条,空则 ?? "" |
| 退出码 | 返回 number | 设 process.exitCode |
| 异常 | 原样 reject | 格式化到 stderr + exit 1 |
| 凭据 | 不碰 | 校验并 redact |
| 典型用途 | 多轮展示、测试 | 脚本取最终结果 |
“注意最后一行:runCli 不校验 API key,因为 client 是注入的,它不创建。main 才管凭据,因为它要 new AnthropicModelClient。谁创建资源,谁负责校验。测试能跑通也是这个原因:注入 fake client 之后,runCli 从头到尾不知道有 API key 这回事。”
“退出码映射在哪?”小A问。
“同一个纯函数,测试直接断言它:”
export function exitCodeForStopReason(stopReason: RunResult["stopReason"]) {
switch (stopReason) {
case "complete": return 0;
case "max_turns": return 2;
case "aborted": return 130;
}
}“runCli() 仍把模型/工具异常以 reject 交给调用者;生产 main() 才是把这类异常格式化进 stderr、设退出码 1 的边界。”
“为什么是 130,不是别的数字?”
“Unix 的约定。”老Z说,“进程被信号杀掉,退出码通常是 128 加信号编号,SIGINT 是 2,所以 128+2=130。这里的 aborted 对应‘调用方主动中止’,取 130,外层脚本就能沿用同一套习惯:$? -eq 130 就是‘被中断了’。但你要记住,main 现在没装配 signal,130 在 npm start 路径里实际不会出现,只在 runCli 接到已 abort 的 signal 时才返回。exitCodeForStopReason 把三种 stop reason 完整覆盖,这不等于三种路径都能在生产入口触发。”
为什么 runInteractive 不是 REPL
小A看到第三个函数的名字,问:“这个 runInteractive 是不是就是交互式命令行?”
“你看它的实现。”老Z翻到 cli.ts:
return await runCli({ ...options,
ask: (p) => rl.question(p),
write: (text) => stdout.write(`${text}\n`),
});“finally 里的 rl.close() 保证无论成功、异常还是中止都会关闭句柄。但注意,它只调用一次 runCli。”
“那它为什么叫 interactive?”
“名字说的是输入来自终端,不是说它维护多轮会话。”
小A不甘心:“那我加个 while 循环包住 runCli,不就是 REPL 了吗?”
“表面上是。但 REPL 要回答四个问题,现在一个都没有答案。”老Z说,“第一,会话怎么保持:runInteractive 每次都新开一个 runAgent,前一轮的消息不会自动带进下一轮。第二,屏幕怎么刷新:现在是 stdout.write 一次性吐整条消息,没有流式刷新。第三,取消怎么传递:runInteractive 会透传调用方提供的 signal,但没有把 Ctrl-C 的 SIGINT 接到 AbortController,终端取消不会自动传进 loop。第四,历史和命令补全:这里没有配置 completer,也没有自定义持久历史策略。终端层可能保留自己的输入历史,但不能把它当成 mini-agent 的功能承诺。加一个 while 循环,只是把这几个‘没有’包进循环里。”
理想 REPL 和现状之间的差距,用一张表量化:
| REPL 该有的 | 现状 | 缺口在哪 |
|---|---|---|
| 多轮会话保持 | 每轮新 runAgent,消息不延续 | 要 sessionPath 或循环内传 messages |
| 流式输出 | stdout.write 一次性吐整条 | 要接入 client.stream 而非 runAgent |
| Ctrl-C 取消 | 可透传 signal,未连接 SIGINT | 要把 SIGINT 接到 AbortController |
| 历史、补全 | readline 默认行为 | 要配 prompt 策略,未动 |
“每一格都是独立的工作项,加个 while 覆盖不了。”老Z说,“第三格要注意:runInteractive 通过 ...options 把调用方提供的 signal 传给 runCli,后者再传给 runAgent,取消通道已经存在。缺的是终端入口的装配:创建 AbortController,监听 SIGINT 并调用 abort(),把它的 signal 交给这条通道。”
正常与失败路径
“那从头捋一遍:正常怎么走,失败又怎么走?”
“正常路径有两条。”老Z说,“main 是 argv 输入 → runAgent → 打印最后一条 assistant content → 设置 0、2 或 130;runCli() 是问答输入 → runAgent → 依次输出每条非空 assistant content,返回同一个映射。失败路径收在下面这张表里。”
“approval 的时候取消呢?”
“approval await 内发生取消时,循环不会启动工具,会补齐取消结果并返回 aborted,runCli() 因而返回 130,这个竞态已有独立的 fake 回归测试。”老Z顿了顿,补了一句,“还有一种容易看漏:文本输出可能为空,main 用 ?? "" 打印,别把空输出当成回答成功的证明。”
“那 session 呢?这个入口会自动续接吗?”
“不会。main.ts 和 runCli() 都没有传入 sessionPath,当前一次性入口既不自动恢复,也不写入 JSONL;会话 round-trip 只在直接调用 runAgent({ sessionPath }) 时成立。把会话能力写进核心,和把它装配到产品入口,是两项不同的工作。”
这一章所有路径的走向和退出码,验收时逐条对:
| 场景 | 走向 | 退出码 |
|---|---|---|
| 空输入 | 任何模型调用前返回 | 0 |
| 正常回答 | complete | 0 |
| 轮次耗尽 | max_turns | 2 |
| signal 在 checkpoint 触发 | aborted(补齐取消结果) | 130 |
| 缺 API key 或模型名 | formatMainError 到 stderr | 1 |
| 模型/工具异常 | runCli reject → main 格式化 | 1 |
“表里每个场景的退出码,都能在代码里找到对应的映射点。”老Z说,“空输入在 runCli 开头,max_turns 在 exitCodeForStopReason,异常在 main 的 catch。”
signal 在 loop 里的传播路径,画成时序:
runCli({ signal }) ──► runAgent({ signal })
│
checkpoint 1(turn 前) ←── signal.aborted? ──► return { aborted }
│
checkpoint 2(call 前) ←── signal.aborted? ──► recordAbortedCalls
│ return { aborted }
checkpoint 3(call 后) ←── signal.aborted? ──► recordAbortedCalls
│ return { aborted }
client.stream({ signal }) ←─ SDK 自己观察 signal
▼
runCli 收到 stopReason "aborted" → 返回 130“注意 runAgent 在 aborted() 为真时立即返回,不进入下一轮,也不开新流(见 agent.ts)。”老Z说,“测试 returns aborted before opening a model stream(见 agent-branches.test.ts)断言的就是这一点:signal 已中止时 client.calls === 0,模型一次都没被调到,和空输入走的是同一条早退路。”
小结
CLI 入口这一步的价值,是把依赖装配和退出语义显式暴露出来,而不是藏在全局状态里。runCli 通过注入 ask、write、signal 和 beforeToolCall 实现无终端测试,空输入在任何模型调用之前就以 0 返回。退出码的映射是一个纯函数:0 表示循环完整结束,2 表示轮次耗尽,130 表示外部中止,三个数字都遵循 Unix 进程退出语义的约定,脚本可以据此判断发生了什么。模型或工具抛出的异常以 reject 交给调用者,只有 main() 负责把它们格式化为 stderr 并设置退出码 1。
三个入口的分工是这一章的骨架:可测试的 CLI 函数靠注入摆脱终端,生产 argv 入口由 main 校验凭据、格式化错误,runInteractive 只做一次 readline 包装。校验的责任跟着资源创建走,runCli 只消费注入的 client 和工具,因此不用碰凭据。同一个 exitCodeForStopReason 被两个入口共享,脚本路径和问答路径的退出语义保持一致;formatMainError 是导出的纯函数,测试直接断言 [REDACTED],全程不碰 process.env。
退出码上还分着两份契约:映射完整是一份,入口装配是另一份,别把前者说成一句“支持 Ctrl-C”。
当前入口只适合脚本式的一次问答。持续交互需要全新的契约:会话保持、屏幕刷新、取消传递,加上历史和补全,runInteractive 的四个缺口每一项都是独立工作项。
阶段验收
cd examples/mini-agent
npm test
npm run typecheck离线测试为 runCli 注入 fake client,并直接断言共享纯映射的 complete/max turns/aborted 三种退出码及 API key 脱敏错误格式;它们不启动真实子进程,也不调用 Provider。不要把 npm start 加入无凭据测试。
