Skip to content

第35章 开一扇可靠的闸——CLI ​

本章导读: 你会写 src/cli.ts 的 runCli、runInteractive 和纯函数 exitCodeForStopReason。这一步的关键是分离契约:可测试的 CLI 函数靠注入 ask/write 摆脱终端,退出码 0/2/130 各有含义,而 main() 才是把异常变成 stderr 的边界。

会话和循环都有了。小a兴冲冲地想把 main.ts 当成所有入口——不管什么命令,都从这里进。老z拦住他:“先别急着合并。**可测试的 CLI 函数与生产 argv 入口承担不同契约。**你把它揉成一个,测试没法写,退出码也没法保证。”

小a:“那到底要几个入口?”

“三个,各管各的。本章只实现一次问答,不实现 REPL、流式界面或诊断框架。”

三个入口,三个责任 ​

符号输入输出/退出语义不负责什么
main.tsprocess.argv、环境变量只打印最后一条 assistant content,并把 stop reason 映射为进程退出码signal、session、REPL
runCli()注入 client、工具、ask、write输出每条非空 assistant content,并返回 Promise<number>创建真实 Provider
runInteractive()标准输入输出调一次 runCli() 后关闭 readline连续 REPL

argv 的生产路径 ​

“先看生产路径。”老z打开 main.ts:

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敲出同文件的另一段:

ts
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。它不会静默吞掉异常消息。”

小a:“为什么要把 key 从错误消息里 redact?异常又不是模型吐的。”

“因为异常消息里可能带 key。”老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 没法用它认证。脱敏的边界是进程边界——出 stderr 之前洗,进网络之前不能洗。”

小a:"那 formatMainError 为什么是导出的纯函数,而不是嵌在 catch 里?"

"因为它要测试。"老z说,"cli-exit.test.ts 里直接调 formatMainError(new Error(...), apiKey),断言输出匹配 [REDACTED] 且匹配不到原始 key——这要求函数不碰 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含义脚本怎么判断
0complete循环完整结束正常收尾
2max_turns轮次耗尽,没跑完结果可能不完整
130aborted调用方主动中止$? -eq 130 就是被中断
1(main 捕获异常)启动或运行失败要看 stderr 诊断

"注意 1 不在 exitCodeForStopReason 里。"老z说,"1 是 main() 的 catch 分支设的(见 main.ts 的 process.exitCode = 1)——它对应的是异常,不是 stop reason。四种结局:三种正常退出的 stop reason 由纯函数映射,一种异常由 main 兜底。纯函数管'正常收尾长什么样',catch 管'意外发生时怎么交代',两件事分开。"

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

可测试的一次问答 ​

小a:“那 runCli() 怎么写才能测试?”

“靠注入。”老z打开 cli.ts,先指签名:

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——它只负责把文本吐出去。测试就靠注入这两个,完全不碰真实终端。”

小a:“为什么 write 是同步的?模型输出明明是异步的。”

“因为 write 接的不是模型流,是 runAgent 跑完之后的整条消息。”老z指后面那段循环,“你看:runCli 先 await runAgent(...) 拿到完整的 result,再遍历 result.messages 把每条 assistant content 写出去。模型的流式过程在 loop 内部消化掉了,到 write 这一层已经是一个字符串。所以 write 不需要是 async——它只是个副作用接口。**异步的是 loop,同步的是展示。**这两层分开,测试就能用一个数组当 write,把所有输出收进来断言。”

小a:"那 ask 为什么又是 async?一个输入提示也要异步?"

"因为 ask 背后可能是终端,而终端 I/O 是异步的。"老z说,"runInteractive 里 ask 接的是 rl.question(p)——readline/promises 的 question 返回 Promise<string>。但测试里它可以接 async () => 'hello',甚至可以接同步函数的 Promise 包装。ask 的类型由最重的实现决定——终端是异步的,所以接口就是异步的;write 最重的实现是 stdout.write,它同步就够。 一个接口是 async 还是 sync,看它的真实实现里最贵的那个,不看你觉得哪个"看起来对"。"

ts
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说,“你看退出码的语义:0 是正常结束,2 是轮次耗尽,130 是中止,1 是异常。用户敲了个空回车,既不是模型出问题,也不是被取消,更不是异常——他就是没给输入。返回 0 让脚本知道‘这次没有要处理的’,不需要报警。测试里专门传 ask: async () => " " 验证这一条,还断言 client.calls === 0——空输入在任何模型调用之前就以 0 返回,连模型都没碰过。”

“signal 被传入 loop。取消在 runAgent() 的 checkpoint 生效;客户端或工具若不响应 signal,调用方只能等待它们交还控制权。”

小a:“那 runCli 的系统提示词是什么?它自己定的?”

“对,它内置了一句:Careful coding assistant. Never include credentials in messages.”老z说,“注意它强调‘凭据不写进消息’——这是呼应安全章的最小暴露原则,也是 CLI 层唯一自己写死的规则。每层可以有自己的 system 补充,但都要先想清楚‘为什么要写这一句’。”

小a:“它输出什么?所有文本吗?”

“看这行:for (const message of result.messages) if (message.role === "assistant" && message.content) options.write(message.content)——它把每条非空 assistant content 都写出来,而 main 只打印最后一条。这就是 runCli 和 main 的契约差异:一个面向多轮展示,一个面向脚本取最后结果。”

两个入口的契约差异并排看,别再混:

维度runCli()main()
输入ask(可注入)process.argv
输出每条非空 assistant content最后一条,空则 ?? ""
退出码返回 number设 process.exitCode
异常原样 reject格式化到 stderr + exit 1
凭据不碰校验并 redact
典型用途多轮展示、测试脚本取最终结果

"注意最后一行的差异:runCli 不校验 API key,因为它不创建 client——client 是注入的。"老z说,"main 才管凭据,因为它要 new AnthropicModelClient。谁创建资源,谁负责校验;runCli 只消费注入的东西,就不用背凭据的债。 这也是测试能跑通的原因:测试注入 fake client,runCli 从头到尾不知道有 API key 这回事。"

“退出码映射在哪?”小a问。

“同一个纯函数,测试直接断言它:”

ts
export function exitCodeForStopReason(stopReason: RunResult["stopReason"]) {
  switch (stopReason) {
    case "complete": return 0;
    case "max_turns": return 2;
    case "aborted": return 130;
  }
}

“0 表示循环完整结束,130 是约定的中止语义,2 表示轮次耗尽;它们不是 HTTP 状态码。runCli() 仍将模型/工具异常以 reject 交给调用者;生产 main() 才是把这类异常格式化为 stderr 并设为 1 的边界。”

小a:“为什么 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:

ts
return await runCli({ ...options,
  ask: (p) => rl.question(p),
  write: (text) => stdout.write(`${text}\n`),
});

“finally 中 rl.close() 使无论成功、异常或中止都关闭句柄;但它只调用一次 runCli。”

小a:“那它为什么叫 interactive?”

“因为输入来自终端,不因为它维护多轮会话。”老z说,“把一次 readline 包装误写成 REPL,会掩盖历史、命令、流式刷新和 Ctrl-C 状态机都尚未实现。”

小a:“那我加个 while 循环包住 runCli 不就是 REPL 了吗?”

“表面上是,但 REPL 要回答四个问题,每一个现在都没有答案。”老z说,“第一,会话怎么保持——runInteractive 每次都新开一个 runAgent,前一轮的消息不会自动带进下一轮。第二,屏幕怎么刷新——现在是 stdout.write 一次性吐整条消息,没有流式刷新。第三,取消怎么传递——runInteractive 没传 signal,Ctrl-C 走的是 Node 默认的 SIGINT 行为,不进 loop。第四,历史和命令补全——readline 默认有,但你没配。加一个 while 循环只是把‘没解决’藏起来,不是把‘没解决’变成‘已解决’。”

"理想 REPL" 和现状之间的差距,用表量化一下:

REPL 该有的现状缺口在哪
多轮会话保持每轮新 runAgent,消息不延续要 sessionPath 或循环内传 messages
流式输出stdout.write 一次性吐整条要接入 client.stream 而非 runAgent
Ctrl-C 取消走 Node 默认 SIGINT要装配 signal 并传入 loop
历史、补全readline 默认行为要配 prompt 策略,未动

"每一格都是独立的工作项,不是'加个 while'能覆盖的。"老z说,"而且注意第三格:runCli 明明接收 signal,runInteractive 却故意没传——这不是疏忽,是把'取消语义'留给真正实现 REPL 的那一天。 现在传一个没人处理的 signal,只是多一个永远为 aborted 的假通道。"

正常与失败路径 ​

小a:“那正常和失败,各自走什么路?”

“正常路径分成两条:main 是 argv 输入 → runAgent → 打印最后一条 assistant content → 设置 0、2 或 130;runCli() 是问答输入 → runAgent → 依次输出每条非空 assistant content,并返回相同映射。”老z说,“失败路径包括空输入提前返回、AbortSignal 在 checkpoint 触发 130、maxTurns 返回 2、缺少生产环境变量以 stderr 和 1 失败,以及模型/工具异常从 runCli() 向上 reject、由生产 main 以 stderr 和 1 处理。”

小a:“approval 的时候取消呢?”

“approval await 内发生取消时,循环不会启动工具,补齐取消结果并返回 aborted,runCli() 因而返回 130;该竞态已有独立 fake 回归测试。文本输出也可能为空:main 用 ?? "" 打印,不能将空输出当成成功回答质量证明。”

小a:“那 session 呢?这个入口会自动续接吗?”

“main.ts 和 runCli() 都没有传入 sessionPath。因此当前一次性入口不会自动恢复或写入 JSONL;会话 round-trip 只在直接调用 runAgent({ sessionPath }) 时成立。把会话能力写进核心和把它装配到产品入口是两项不同工作。”

把这一章的路径全部收进一张表,验收时逐条对:

场景走向退出码
空输入任何模型调用前返回0
正常回答complete0
轮次耗尽max_turns2
signal 在 checkpoint 触发aborted(补齐取消结果)130
缺 API key 或模型名formatMainError 到 stderr1
模型/工具异常runCli reject → main 格式化1

"这张表验证一件事:退出码不是工具自己设的,是 stop reason 和异常状态一路映射出来的。"老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)。测试 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 表示外部中止。这三个数字不是 HTTP 状态码,而是 Unix 进程退出语义的约定,脚本可以据此判断发生了什么。模型或工具抛出的异常不会被吞掉,而是以 reject 交给调用者;只有 main() 才是把这些异常格式化为 stderr 并设置退出码 1 的边界。

三个入口的分工是这一章的骨架:可测试的 CLI 函数靠注入摆脱终端,生产 argv 入口靠 main 校验凭据、格式化错误,runInteractive 只做一次 readline 包装。谁创建资源,谁负责校验——runCli 只消费注入的 client 和工具,就不背凭据的债。同一个 exitCodeForStopReason 被两个入口共享,保证脚本路径和问答路径的退出语义一致;formatMainError 是导出的纯函数,测试直接断言 [REDACTED],不碰 process.env。把边界拆成纯函数,是这一章一切可测试性的来源。

关于退出码要分清两件事:130 是 Unix 的 128+2 约定,借力既有的进程语义,不是我们发明的;但 main 尚未装配 signal,所以 130 在 npm start 路径里实际不会出现——它只在 runCli 接到已 abort 的 signal 时返回。exitCodeForStopReason 把三种 stop reason 完整覆盖,不等于三种路径都能在生产入口触发。映射完整性是契约,入口装配是另一份契约,别把两者混成一句"支持 Ctrl-C"。

但当前入口只适合脚本式的一次问答。持续交互需要全新的契约:会话怎么保持、屏幕怎么刷新、取消怎么传递——这些都不是 runCli 能解决的,runInteractive 的四个缺口(会话、流式、取消、历史)每一项都是独立工作项。给 runInteractive 加一个 while 循环,只是把"没解决"藏起来。接受"入口是一次性的",和接受"产品需要 REPL",是两个诚实的决定——本章交付的是前者,并且把后者的账列清楚了。

阶段验收 ​

sh
cd examples/mini-agent
npm test
npm run typecheck

离线测试为 runCli 注入 fake client,并直接断言共享纯映射的 complete/max turns/aborted 三种退出码及 API key 脱敏错误格式;它们不启动真实子进程,也不调用 Provider。不要把 npm start 加入无凭据测试。