Skip to content

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 作为普通参数传入,解析器就成为确定性函数:同样的 argvcwd 永远得到同样结果。进程入口仍可传入真实 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 test

Starter 三项测试都会因 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 返回 completedfailedblockedbudget_exceededcancelled 等领域状态,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 写入中,需要统一取消并保留可审计终态。

代码实验

打开本课 Lab

失败实现

下面的写法在 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] 是为了建立解析模式。若未来加入 inittraceeval,每个命令会有自己的位置参数和 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.stdoutio.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.exitCodeprocess.exit() 的生命周期差异是什么?composition root 为什么可以知道具体实现,而内部包不应该?

验收 Rubric

维度通过标准常见扣分
命令语法只接受 run,任务非空且角色正确把 flag 当任务
参数扫描正确消费 workspace 值,拒绝未知或缺值靠固定数组下标猜测
路径稳定使用显式 cwd 解析绝对路径深层重复读取 cwd
纯函数边界解析不访问网络、文件或全局输出解析时直接启动 Runtime
生命周期退出码由外层映射,资源可在 finally 关闭库代码调用 process.exit
证据Starter 按声明失败,Solution 三项通过只演示 happy path

总项目增量

总项目包:@coding-agent/cli

总项目路径:apps/cli/src/args.tsapps/cli/src/main.ts

总项目测试:apps/cli/tests/args.test.tsapps/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.argvcwd、退出码和信号的官方文档,同时观察你日常使用的 CLI:哪些错误进入 stderr,未知参数是否失败,取消后返回什么退出码。这些小合同决定了工具是否能被可靠自动化。

可以再做一个逆向练习:任选一个你经常使用的命令行工具,故意输入未知参数、缺失参数、相对路径和中断信号,记录它的输出通道与退出码。然后判断它更像“人类交互界面”还是“稳定机器接口”。把观察结果与本课 parseCliArgsrunCli 两层边界对照,你会发现可靠 CLI 的价值不在参数数量,而在错误能否尽早、稳定、无副作用地被识别。

当这条入口边界稳定后,未来增加图形界面或编辑器插件也只需替换适配层,不必重写 Agent 核心。

从零实现 Mini Code Agent Runtime