主题
Runtime 骨架与 CLI
本课交付结果
第 1 课得到了一份可靠的任务契约,但它还躺在测试代码里。真实用户不会手写 TypeScript 对象再调用函数,他们会在终端输入类似 coding-agent run "修复加法" --workspace demo 的命令。看起来只是把几个字符串放进对象,实际上这里是产品第一次接触操作系统环境:参数可能缺失、顺序可能错误、当前目录可能变化、标准输出会被脚本消费,进程退出码还会决定 CI 是否继续。
本课交付 parseCliArgs(argv, cwd)。它只接受 run 命令,要求非空任务,识别可选的 --workspace,拒绝未知参数,并把工作区相对于显式传入的 cwd 解析为绝对路径。你还会理解为什么解析器不应直接启动 Agent,为什么库函数不应随意调用 process.exit(),以及一个可测试的 CLI composition root 应如何连接参数、配置、Runtime、输出和资源关闭。
完成后,输入 run 修复加法 --workspace demo 会产生不可变的 RunCommand;缺少任务、未知 flag 或缺失路径则在任何模型调用之前给出明确错误。这个骨架会一路演进到第 36 课的产品级 CLI,但最重要的边界现在就建立:解析是纯函数,执行是可注入流程,退出码是结果映射,而不是散落在业务代码中的副作用。
岗位问题
很多命令行工具最初只有一个 index.ts:读取 process.argv,检查几个字符串,创建客户端,运行任务,打印结果,最后 process.exit(1)。功能少时很直接;功能一多,测试便必须修改全局参数、当前目录、环境变量和标准输出。某个深层函数突然退出进程,会跳过 finally、Trace flush 和临时目录清理。错误信息有时写 stdout、有时写 stderr,自动化调用方无法稳定判断。
Coding Agent 的 CLI 更敏感。它需要选择工作区、Provider、模型、预算和审批策略,任何一个错误都可能扩大副作用。命令行是“不可信文本”进入 Runtime 的入口,不是一个方便的字符串袋子。它必须先把语法错误、配置错误和运行失败分层:语法错误由参数解析器处理;配置错误由配置加载器处理;模型、工具和测试失败由 Runtime 处理;最终 CLI 才把结构化状态映射为人类文本和退出码。
另一个常见问题是隐式 cwd。如果解析器内部直接调用 process.cwd(),单元测试只能切换全局进程目录;并行测试会互相影响,嵌入式调用方也无法指定环境。把 cwd 作为普通参数传入,解析器就成为确定性函数:同样的 argv 与 cwd 永远得到同样结果。进程入口仍可传入真实 process.cwd(),但核心逻辑不再依赖全局状态。
CLI 还承担兼容性合同。脚本会依据退出码重试或报警,人类会依据错误文字修正输入,文档示例会长期存在。随意接受未知 flag 看似“宽容”,实际可能把拼写错误静默忽略;今天输入 --workpace,Agent 却在默认根目录运行。保守解析让错误尽早暴露,也给未来新增选项留下明确路径。
前置检查
前置知识快照
先理解 Node.js 进程的四个基础面。process.argv 包含运行时和脚本路径,应用通常从第三项开始解析;本 Lab 直接接收已经切片的参数。process.cwd() 是进程当前目录,不等于脚本所在目录。stdout 适合正常机器可读结果,stderr 适合诊断和失败。进程退出码 0 表示成功,非零表示不同失败类别;Unix 惯例中 130 常表示收到中断信号。
还要区分“解析”和“执行”。解析函数回答用户写了什么,并把相对值规范化;它不检查仓库是否存在,不创建模型客户端,也不运行任务。执行层接收解析结果与依赖,负责真正副作用。这个分离使参数边界可以用毫秒级单元测试覆盖,而端到端测试只关注少量组合路径。
运行前置验证:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 01
pnpm --dir bootcamps/coding-agent/labs/02-runtime-skeleton/starter testStarter 三项测试都会因 EXERCISE_NOT_IMPLEMENTED:实现 run 命令解析 失败。不要修改命令格式来迎合实现;合同已经由测试固定。输入数组不包含 Node 可执行文件和脚本路径,例如 ['run', '修复加法', '--workspace', 'demo']。
原理拆解
CLI 的数据流应当像一条分层管道,而不是一个巨型函数:
mermaid
flowchart LR
A[process.argv] --> B[parseCliArgs 纯解析]
B -->|语法错误| X[stderr + exit 1]
B --> C[加载配置与环境]
C -->|配置错误| X
C --> D[composition root 组装依赖]
D --> E[Runtime.run]
E --> F{结构化运行状态}
F -->|completed| G[stdout + exit 0]
F -->|failed/blocked| H[stderr + 非零退出码]
F --> I[finally 关闭资源]parseCliArgs 从左到右扫描参数。第一个 token 必须是 run,第二个必须是非空且不能以 -- 开头的任务。后面的 token 目前只允许 --workspace <path>。遇到未知 flag 立即拒绝;遇到缺值也立即拒绝。工作区默认使用传入的 cwd,显式路径则通过 resolve(cwd, value) 规范化为绝对路径。
为什么任务只占一个 token?这个 Lab 把 shell 的引号处理视为外层职责。用户输入 "修复加法并更新测试" 时,shell 会把整句交给应用。如果忘记引号,多余位置参数应被拒绝或由更高级解析器明确拼接,不能无声丢失。本课合同简洁,目的是看清扫描器状态,而不是提前引入完整 CLI 框架。
composition root 的责任
composition root 是“知道所有具体实现”的最外层。它可以读取真实环境变量、选择 Fake 或 OpenAI-compatible Provider、构建 Tool Registry、创建 Trace sink,然后把这些依赖传入 Runtime。内部包只依赖接口,不知道命令行、环境变量或具体供应商。这样测试 Agent Loop 时可以传 Fake Model,测试 CLI 时可以传一个返回固定结果的 Runtime,双方不会通过全局单例耦合。
一个好的 composition root 很薄:参数解析、配置合并、依赖构造、调用、结果映射、资源关闭。业务规则不应在这里复制。例如最大步骤数由配置和 LoopGuard负责,CLI 只负责把 --max-steps 转成正整数。权限由 Sandbox 决定,CLI 只传递 --auto-approve-writes。薄入口让 Web、IDE 或 API 未来可以复用同一 Runtime,而不用模拟终端。
退出码为什么不能由深层代码决定
process.exit() 会立即终止事件循环,尚未 flush 的日志、Trace 和文件写入可能丢失,finally 也可能没有机会完成异步清理。深层库调用它还会让测试进程一起退出。更好的模式是 runCli() 返回数字,最外层入口设置 process.exitCode。Runtime 返回 completed、failed、blocked、budget_exceeded、cancelled 等领域状态,CLI 统一映射为稳定退出码。
这种映射也保留语义。普通失败可以是 1,被安全策略阻止可用 2,预算耗尽可用 3,用户中断可用 130。自动化系统能够据此区分“代码失败,应调查”“权限被拒,应人工确认”和“预算太小,可调整配置”。如果 Runtime 只抛异常,所有情况都会坍缩成一个不透明的错误。
从参数语法到配置优先级
参数解析完成并不代表配置已经完成。命令行只是一种来源,真实产品还会读取项目配置文件、环境变量和默认值。四者应有明确优先级,例如命令行覆盖环境变量,环境变量覆盖项目文件,项目文件覆盖内置默认值。这个合并过程应创建新配置并验证最终结果,不能让各组件在运行中各自读取环境,否则同一个 Provider 可能看到不同模型名,预算和审批策略也可能前后不一致。
为什么 API Key 不应该成为普通 flag?命令行通常会进入 shell 历史,在某些操作系统上还能被其他进程看到。--api-key 即使解析正确,也会扩大泄漏面。更安全的入口是环境变量、系统凭证存储或只在内存中传入的宿主配置;CLI 可以选择 Provider,却不应把秘密回显到解析结果、错误或 Trace。参数合同不仅决定“能传什么”,也决定“哪些数据明确禁止从这条通道进入”。
配置加载还要处理空值与来源诊断。环境变量存在但为空,通常不应覆盖一个有效项目配置;用户指定了未知 Provider,应在构建网络客户端前失败。为了排障,可以记录“模型来自 CLI、预算来自项目配置”,但只能记录来源名和非敏感值。这样当一次运行行为异常时,工程师能够复原配置决策,而不需要猜测进程当时读取了什么。
CLI 是人机界面,也是机器接口
人类喜欢有颜色、建议和分段说明,脚本喜欢稳定、简洁、可解析。一个成熟 CLI 往往同时提供文本模式与 JSON 模式。文本模式可以写“达到最大步骤数,请使用 --max-steps 调整”;JSON 模式则返回版本化对象 { status, summary, steps, exitCode }。两种模式应来自同一个结构化结果,不能分别拼装业务结论,否则字段和语义会漂移。
stdout 与 stderr 的区分同样属于兼容性。正常结果即使内容为空也应走 stdout;诊断、失败和资源关闭警告走 stderr。进度日志若与最终 JSON 混在 stdout,会让 jq 或上层程序解析失败。常见做法是文本进度写 stderr,最终机器结果独占 stdout;或者提供静默标志。课程最小实现暂不加入这些选项,但从第一天就把 I/O 注入为接口,可避免未来大规模改造。
信号处理是另一条机器接口。用户按下 Ctrl+C 后,外层创建 AbortController,把 signal 传给 Runtime、模型和工具,等待它们安全停止,再返回 130。不要把 SIGINT 处理散落在每个包,也不要收到信号就立即退出。Agent 可能正处于文件事务、子进程执行或 Trace 写入中,需要统一取消并保留可审计终态。
代码实验
失败实现
下面的写法在 happy path 上似乎有效,却把几乎所有错误吞掉:
ts
export function parseCliArgs(argv: readonly string[], cwd: string): RunCommand {
return {
command: "run",
task: argv[1] ?? "",
workspace: argv[3] ?? cwd,
};
}它不验证命令名,接受空任务,假设工作区永远在第四个位置,保留相对路径,并会把未知 flag 当成普通值。参数稍有变化就得到错误对象,而不是明确失败。
正确实现显式扫描剩余参数:
ts
export function parseCliArgs(argv: readonly string[], cwd: string): RunCommand {
if (argv[0] !== "run") throw new Error("只支持 run 命令");
const task = argv[1];
if (task === undefined || task.trim() === "" || task.startsWith("--")) {
throw new Error("run 命令缺少任务");
}
let workspace = cwd;
for (let index = 2; index < argv.length; index += 1) {
const flag = argv[index];
if (flag !== "--workspace") throw new Error(`未知参数:${String(flag)}`);
const value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error("--workspace 缺少路径");
}
workspace = resolve(cwd, value);
index += 1;
}
return Object.freeze({ command: "run", task: task.trim(), workspace });
}核心测试证明相对路径在解析边界被消除:
ts
it("parses task and resolves workspace against cwd", () => {
expect(parseCliArgs(
["run", "修复加法", "--workspace", "demo"],
"/repo",
)).toEqual({
command: "run",
task: "修复加法",
workspace: resolve("/repo", "demo"),
});
});另外两项测试拒绝缺少任务、错误命令、未知参数和缺失的工作区值。它们共同定义 CLI 的公开语法,比实现内部使用 for、状态机或解析库更稳定。
关键实现讲解
先验证 argv[0] 是为了建立解析模式。若未来加入 init、trace、eval,每个命令会有自己的位置参数和 flag 集合。没有命令分派就直接解释后续 token,会让不同命令的语法混在一起。错误使用 只支持 run 命令,让用户立刻知道当前版本的能力边界。
任务检查里的 task.startsWith('--') 很重要。输入 ['run', '--workspace', 'demo'] 时,第二个 token 不是任务,而是 flag;如果只判断是否存在,就会把 --workspace 当任务,把 demo 当未知参数。解析器需要理解 token 的角色,不只是数组位置。
循环中手动增加 index 是一个小型状态机。读到 --workspace 后,下一项属于它的值,因此消费后必须额外前进一次。值缺失或仍以 -- 开头说明用户忘了提供路径;不能把下一个 flag 当路径。对未知 flag 立即报错,则防止拼写错误退回默认行为。
返回对象冻结与第 1 课保持一致:解析完成的命令是一个事实值。后续配置层可以从它创建新对象,但不应就地改写用户输入。如果需要覆盖工作区,应在明确的配置优先级函数里完成并留下测试,而不是让任意组件修改命令。
总项目的 parseArgs 会扩展到多个命令、Provider、base URL、模型和预算,但仍保持同一原则。它禁止通过命令行传 API Key,因为命令行可能出现在 shell 历史和进程列表;它要求预算为正整数;它把非 run 命令的多余位置参数视为错误。课程 Lab 是最小骨架,总项目展示骨架如何不破坏边界地长大。
用依赖注入守住测试边界
如果 runCli 内部直接 new OpenAIProvider()、读取真实环境并创建文件系统 Trace,测试一个未知 flag 也可能触发网络或写盘。更可控的签名会把 Provider、Runtime 工厂、I/O、环境、当前目录、取消信号和资源关闭函数作为依赖。生产入口传真实实现,测试传记录调用的轻量替身。这里的依赖注入不是为了追求框架,而是确保语法错误真的在副作用之前发生。
测试还应观察“没有发生什么”。缺少任务时,Provider 的 complete 调用次数应为零;配置失败时,不应创建工作区文件;取消前置时,不应执行工具;无论成功或失败,close 都应调用一次。只有正向结果的测试容易漏掉副作用边界,而 Coding Agent 的安全性很大一部分正来自这些负面保证。
最后,入口层要做脱敏。深层错误可能意外包含请求头、token 或带凭证的 URL。CLI 在写入任何通道前执行兜底清理,但这不是让下层随意泄漏的许可证;Provider 和 Trace 仍应在更靠近源头处脱敏。多层防护的意义是某一层遗漏时,最终用户界面不会直接暴露秘密。
运行与验证
bash
pnpm --dir bootcamps/coding-agent/labs/02-runtime-skeleton/starter test
pnpm --dir bootcamps/coding-agent/labs/02-runtime-skeleton/solution test
pnpm --filter @coding-agent/cli test真实运行输出
Starter 的三项测试都因同一个声明缺口失败;Solution 的真实摘要为:
text
✓ ../tests/lab.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)检查一个具体解析结果时,可在临时测试或 REPL 中传入固定 cwd,不要依赖当前机器路径。期望结果是任务去除首尾空白、工作区成为 /repo/demo。错误用例应在创建 Provider 之前失败,因此不会产生网络请求、Trace 文件或工作区改动。
把 CLI 集成到产品时,还要验证 stdout/stderr 与退出码。例如 completed 写 stdout 并返回 0;安全阻止写 stderr 并返回 2;取消返回 130。测试应注入 io.stdout、io.stderr 函数收集文本,而不是劫持全局 console。这样既能确认内容,也能确认通道。
常见失败与排查
故障案例 1
症状:用户输入 --workpace demo,程序没有报错,却在当前仓库执行任务。根因:解析器忽略未知 flag,并保留默认工作区。定位:记录传入的 token 序列,检查扫描器的 default 分支;为每个文档 flag 写拼写错误用例。修复:未知参数立即抛错,只有显式支持的 flag 才能被消费。
故障案例 2
症状:同一条命令从仓库根目录运行成功,从包目录或 CI 运行却找不到文件。根因:相对工作区未在解析时结合明确 cwd 规范化,或深层组件再次调用了 process.cwd()。定位:在测试中分别传 /repo 和 /repo/apps/cli,比较解析结果;搜索 Runtime 内部的 process.cwd()。修复:CLI 边界只解析一次绝对工作区,并沿依赖向下传递。
故障案例 3
症状:模型失败后 Trace 最后一条事件缺失,临时资源没有关闭,测试进程也突然结束。根因:深层函数调用 process.exit(1),绕过外层 finally。定位:搜索所有 process.exit,检查调用栈是否位于可复用包;注入会记录调用的 close()。修复:深层返回结构化状态或抛出可分类错误,CLI 返回退出码,只有最外层设置 process.exitCode。
额外常见问题是把所有错误都写 stdout。管道工具会把诊断当成正常结果继续处理。约定正常摘要进入 stdout,失败与关闭警告进入 stderr;若需要 JSON 模式,则两个通道都使用稳定结构,而不是混入彩色日志。
课后作业
第一项作业是支持 --max-steps <n>。要求只接受正整数,拒绝零、小数、负数、NaN 和缺值;返回值放在独立 budgets 对象中,不直接启动 LoopGuard。写出至少五个边界测试。
第二项作业是实现 runCli(argv, io, dependencies) 的极小版本。依赖中注入 cwd、一个返回固定状态的 Runtime 和 close;函数必须在成功、失败和异常时都调用关闭函数,并把状态映射为退出码。禁止在被测函数中读取全局 stdout 或退出进程。
第三项作业是设计 --json 模式。说明普通文本与 JSON 输出的字段、stdout/stderr 规则、版本字段和错误结构。思考一旦脚本依赖 JSON 字段,哪些变化属于 breaking change。
知识检查:为什么显式传 cwd 比在解析器内部读取全局值更可靠?未知 flag 为什么应失败而不是忽略?process.exitCode 与 process.exit() 的生命周期差异是什么?composition root 为什么可以知道具体实现,而内部包不应该?
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 命令语法 | 只接受 run,任务非空且角色正确 | 把 flag 当任务 |
| 参数扫描 | 正确消费 workspace 值,拒绝未知或缺值 | 靠固定数组下标猜测 |
| 路径稳定 | 使用显式 cwd 解析绝对路径 | 深层重复读取 cwd |
| 纯函数边界 | 解析不访问网络、文件或全局输出 | 解析时直接启动 Runtime |
| 生命周期 | 退出码由外层映射,资源可在 finally 关闭 | 库代码调用 process.exit |
| 证据 | Starter 按声明失败,Solution 三项通过 | 只演示 happy path |
总项目增量
总项目包:@coding-agent/cli
总项目路径:apps/cli/src/args.ts、apps/cli/src/main.ts
总项目测试:apps/cli/tests/args.test.ts、apps/cli/tests/runtime.test.ts
总项目验证命令:pnpm --filter @coding-agent/cli test
本课把 cli-runtime 能力接到第 1 课任务合同之前。总项目支持 run/init/trace/eval/skills,并把 Provider、模型、base URL、预算和自动写入审批作为显式配置。runCli 统一脱敏输出、映射状态与关闭资源,证明最小解析骨架可以演进而不把业务逻辑塞回入口。
延伸阅读
方案对比与工程取舍
手写扫描器依赖少、控制流透明,适合命令少且用于教学的项目;随着子命令、别名、默认值和帮助文本增长,成熟 CLI 框架能自动生成 usage、补全和错误格式,但也可能把解析结果绑定到框架对象。无论选择哪种库,都应在框架适配层立刻转换为自己的 ParsedArgs,不要让 Runtime 依赖某个 CLI 包。
单体入口实现速度快,却把全局状态、业务规则和资源生命周期混在一起。composition root 多了一层接口和依赖注入,看似代码更多,换来的是可测试、可嵌入和可替换。当 Agent 未来同时服务终端、IDE 和 HTTP API 时,这个边界会避免复制整个运行流程。
下一课衔接
现在程序知道用户要运行什么、在哪里运行,却还没有“思考来源”。下一课将定义 Model Provider 接口,并用确定性的 Fake Model 代替真实网络调用。CLI 会选择具体 Provider,Runtime 只依赖接口;这一分层让同一任务能够在课程测试中完全离线,在真实演示中再切换到外部模型。
延伸阅读时可查看 Node.js 关于 process.argv、cwd、退出码和信号的官方文档,同时观察你日常使用的 CLI:哪些错误进入 stderr,未知参数是否失败,取消后返回什么退出码。这些小合同决定了工具是否能被可靠自动化。
可以再做一个逆向练习:任选一个你经常使用的命令行工具,故意输入未知参数、缺失参数、相对路径和中断信号,记录它的输出通道与退出码。然后判断它更像“人类交互界面”还是“稳定机器接口”。把观察结果与本课 parseCliArgs、runCli 两层边界对照,你会发现可靠 CLI 的价值不在参数数量,而在错误能否尽早、稳定、无副作用地被识别。
当这条入口边界稳定后,未来增加图形界面或编辑器插件也只需替换适配层,不必重写 Agent 核心。