Skip to content

CLI 配置与错误体验

本课交付结果

你将交付 loadConfigformatCliErrorexitCodeForStatus:配置优先级为 CLI > env > file > default;密钥类参数禁止出现在 argv;五种运行状态映射稳定退出码,并输出可操作中文错误。

岗位问题

Runtime 能跑不等于产品可用。配置来源冲突、密钥出现在 shell history、取消被当普通失败、错误只打印 stack,都会让自动化和用户体验失控。CLI 是安全边界也是操作契约。

前置检查

前置知识快照

先验证第 2 课 CLI 骨架:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 02

本课扩展为 model、baseUrl、timeoutMs 配置和状态退出语义。

第 2 课解决“命令能启动”,本课解决“人和自动化可以长期依赖”。CLI 同时有三类消费者:终端用户阅读中文输出,shell/CI 读取退出码,Runtime 接收经过校验且不含秘密的配置。任何一层把内部 stack、模糊状态或冲突配置泄给下一层,产品行为都会漂移。

配置优先级是一条公开契约:显式 CLI 覆盖进程环境,环境覆盖项目配置文件,文件覆盖安全默认值。这个顺序不是对象 spread 的偶然结果,而应在测试中逐字段证明。每个来源只承载允许字段,API key 等秘密从专门环境/secret store 注入 Provider,不进入可打印的 RuntimeConfig。

argv 会出现在 shell history、进程列表、任务日志和诊断工具中,因此即使用户主动输入也必须拒绝 secret flag。拒绝发生在读取值、构建 config 和 Provider 初始化之前;错误告诉用户使用环境变量或凭证存储,却绝不回显秘密。环境变量也不是完美 secret store,但暴露面通常小于参数,并可替换为系统 keychain。

原理拆解

mermaid
flowchart TD
  A[安全 defaults] --> B[file 覆盖]
  B --> C[env 覆盖]
  C --> D[扫描 argv secret flags]
  D --> E[解析 allowlist CLI flags]
  E --> F[逐字段 schema 校验]
  F --> G[生成无秘密 RuntimeConfig]
  G --> H[运行 Agent]
  H --> I[状态映射退出码]
  H --> J[错误 code 映射安全中文]

先用 defaults 建基线,file 覆盖,再读取环境,最后逐个解析 argv。任何 --api-key--token--secret 形式在解析值前拒绝。timeout 必须是正整数,baseUrl 必须为 HTTP(S)。

退出码:completed=0、failed=1、cancelled=130、permission_denied=77、budget_exceeded=75。

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/36-cli-productization/starter test
pnpm --dir bootcamps/coding-agent/labs/36-cli-productization/solution test

Starter 应声明 实现 CLI 配置优先级;Solution 应通过 5 项测试。

失败实现

ts
const config = {
  ...cli,
  ...process.env,
  ...JSON.parse(await readFile("config.json", "utf8")),
};
console.error(error.stack);
process.exit(1);

失败实现让文件反向覆盖 CLI,把整个环境和秘密混入 config,字段没有类型与边界;所有失败都是退出一,用户取消、权限拒绝和预算耗尽不可区分;stack 暴露路径与内部实现。更糟的是直接 process.exit 会跳过 Trace flush、MCP close 与临时资源清理。

配置合并应逐字段表达来源:

ts
const config: CliConfig = {
  model: env.CODE_AGENT_MODEL ?? file?.model ?? defaults.model,
  baseUrl: env.CODE_AGENT_BASE_URL ?? file?.baseUrl ?? defaults.baseUrl,
  timeoutMs: Number(
    env.CODE_AGENT_TIMEOUT_MS ?? file?.timeoutMs ?? defaults.timeoutMs,
  ),
};
for (const [flag, value] of parseAllowedArgv(argv)) {
  if (flag === "--model") config.model = value;
  if (flag === "--base-url") config.baseUrl = value;
  if (flag === "--timeout-ms") config.timeoutMs = Number(value);
}

关键实现讲解

argv 只允许明确 flag 并要求下一个值,未知参数立即失败。formatCliError 根据稳定 code 映射“配置无效”“权限被拒绝”“预算已耗尽”,其他值归一为“运行失败”,不泄漏 stack。

defaults 必须安全、有限且不依赖用户机器。例如默认模型名称明确、baseUrl 指向预期服务、timeout 和预算均为正上限、写权限默认 ask 而非 auto-approve。默认值进入文档和 --help,升级改变默认模型或成本边界视为产品变更,不能在 patch 版本静默发生。

项目配置文件只从规范 workspace 中发现,例如 codeagent.config.json,路径经过 realpath/边界校验,文件大小有限,JSON 经过 schema。不要向父目录无限搜索并加载陌生配置,否则在 /tmp 或 monorepo 子目录运行会继承意外权限。是否允许用户级全局配置要明确层级,并禁止其中存 secret 明文。

配置文件未知字段应报错或警告,不能默默拼写错。例如 maxStep 若被忽略,用户以为限制生效,Agent 实际使用默认更大预算。安全相关字段采用严格错误;未来可扩展字段通过 schemaVersion 和 deprecation 迁移。错误指出字段名与安全相对文件,不打印全部 config。

环境变量映射逐个 allowlist。CODE_AGENT_MODELCODE_AGENT_MAX_STEPS 等进入普通配置,OPENAI_API_KEY 只由 Provider factory读取且不返回 config。不要 Object.assign(config, process.env),这会把数百字段与秘密带入日志、Trace、错误报告和插件 context。

空环境变量的语义需定义。通常空 model 不是“未设置”,而是无效显式值,应报 model 不能为空;若希望回退默认,读取层应在文档说明并统一 trim。布尔值只接受 true/false,不把任意非空字符串包括 false 当真。数字先保留原字符串用于字段错误,再转有限正整数。

CLI parser 先扫描所有 argv token 是否匹配 secret flag,覆盖 --api-key value--api-key=value、下划线、token、secret 与大小写变体。扫描在未知参数错误之前,这样恶意/误用 secret 不会被后续错误日志带出。error 只说禁止通过命令行,不包含 token 值或完整 argv。

允许 flag 用显式表,不支持任意 --key value 自动映射。每个需要值的 flag 检查下一 token 存在且不是另一个 flag;未知参数立即报错,避免拼写错误静默进入任务文本。布尔 flag 如 --auto-approve-writes 不读取值,并在 help 中明确高风险。

任务描述与 flag 的边界也要稳定。run <task> --workspace ... 只把指定位置或 -- 分隔后的文本当 task;路径和命令参数不混进用户提示。包含短横线的自然语言需要清晰引用或 -- 规则。测试覆盖缺 task、重复 flag、flag 缺值与未知 command。

baseUrl 用 URL 构造器解析,只允许 http/https,拒绝 file、data、javascript、用户名密码和 fragment。生产可要求 https,localhost 开发例外;尾斜线规范化,Provider 拼 endpoint 时不能产生双路径或覆盖已有 pathname。URL 错误不回显内嵌 credential,即使 parser 收到了它。

timeoutMs 与 budgets 每个字段都是有限正整数,并设合理最大值,防止极大数字溢出 setTimeout 或让 Agent 几乎无界。零是否表示禁用很危险,课程统一拒绝;若产品支持 unlimited,需要显式枚举且高权限批准,不能用魔法零。

配置对象构造完成后返回新对象,budgets 深复制或冻结,避免 Runtime/Plugin 修改后影响后续运行。公开 print-config 命令只显示非秘密字段与每项来源,可把 Provider credential 显示成“已配置/未配置”,绝不显示值、长度或前缀。

ts
it("applies precedence without retaining secrets", async () => {
  const config = await loadConfig({
    file: { model: "file" },
    env: { CODE_AGENT_MODEL: "env", OPENAI_API_KEY: "seeded-secret" },
    cli: { model: "cli" },
  });
  expect(config.model).toBe("cli");
  expect(JSON.stringify(config)).not.toContain("seeded-secret");
});

错误采用稳定 code 与安全 cause,而不是让调用者解析中文。INVALID_CONFIG 显示“配置无效”并给修复方向,PERMISSION_DENIED 显示目标能力和如何批准,BUDGET_EXCEEDED 显示哪个预算,未知异常归一为“运行失败”。调试模式可写受控 Trace id,不把 stack 直接给终端。

formatCliError 仍要对 message 做秘密与控制字符清洗。Provider 可能抛含 API key 的错误,文件系统 message 含绝对路径,攻击性输入含换行伪造下一条日志;sanitize 替换已知 token、规范换行、限制字节。默认用户输出与内部诊断分通道,二者都不应包含真实 secret。

stdout 专门放正常结果,stderr 放错误、警告与进度;--json 模式每次只输出一个版本化 JSON 对象,脚本不解析彩色中文。TTY 模式可以颜色和 spinner,非 TTY 自动关闭。任何进度不污染 stdout 的机器结果,尤其 shell 管道和 CI artifact。

退出码是兼容 API。completed=0,普通 failed=1,cancelled=130 对应常见 Ctrl-C 语义,permission_denied=77,budget_exceeded=75。总项目可能使用自己的稳定表,例如 budget code 3,只要文档、测试与发布版本一致。不要随文案重构改变 code;新增状态选择非冲突值并提供迁移说明。

信号处理第一次 SIGINT 调 controller.abort,允许 Agent、Provider、MCP 和 Trace 正常清理;第二次可强制退出但仍给简短告警。主函数 await runCli 返回 code 后设置 process.exitCode,不立即 process.exit。finally 关闭组合资源,即使 close 失败也清洗错误并决定是否改变原状态。

资源关闭顺序通常先阻止新动作、取消运行、flush Trace/checkpoint、关闭 MCP/Plugin worker、删除临时目录。多个 close 错误聚合为安全诊断,不覆盖原任务错误;若结果 completed 但关键审计 flush 失败,审计强制模式应返回失败而非零。

权限交互在 TTY 可提示 allow/deny,非交互 CI 遇到 ask 必须稳定失败,不能永远等待 stdin。--auto-approve-writes 是明确危险 flag,help 和运行开头显示;网络、发布等更高影响权限不能被同一通用 flag 全开。批准记录绑定具体动作参数。

config provenance 便于排查:每字段记录 default/file/env/cli 来源,但不记录秘密值。用户可运行 config doctor 查看最终 model、预算、workspace 和哪些变量被识别;检测配置文件权限过宽、workspace 不存在、Provider key 缺失,但健康检查不发真实模型请求除非显式选择。

workspace 必须存在、是目录、realpath 后符合产品边界。CLI 不自动创建拼错路径,也不接受文件当 root。启动时读取 AGENTS.md、Skill 与 Plugin 都以同一 root;输出中显示安全相对路径或用户明确输入,不把内部临时真实路径散落日志。

版本与 help 同样是产品面。--version 可在无配置、无网络下成功,--help 列 command、flag、优先级、secret 警告与退出码。错误 flag 输出简短原因和相关 usage,不打印几十页帮助。文档示例只用环境变量占位,绝不提交真实形式 key。

shell completion 生成允许 command/flag,但不补全 secret 或敏感文件内容。配置路径补全限制本地,任务历史是否保存需用户明确同意;默认 readline/history 可能落盘用户 prompt。交互模式使用受控 history policy和清除命令。

兼容性测试对错误 code、exit code、JSON schema 和关键中文做快照或精确断言;自由描述可更灵活。调用实际 bin 的进程测试验证 stdout/stderr 与退出状态,单元测试只验证函数。不同操作系统信号行为单独测,Windows cancellation 采用平台适配。

安全测试在 argv 注入唯一 sk-...,扫描终端输出、Trace、config JSON 和子进程参数,确认完全不存在;在 Provider factory spy 上断言未调用。错误测试注入含 secret 的 Error,format 后只有 REDACTED。只断言 loadConfig 抛错不够证明秘密没有走旁路。

可观测性记录 command、status、duration、预算摘要、错误 code 与版本,不记录 task、argv、env 或 workspace 绝对路径。CLI telemetry 默认关闭或明确同意,组织部署按政策启用。运行完成输出 runId/reportHash,用户可用它查询受控 Trace。

完整验收让 file 提供 model/baseUrl、env 覆盖 timeout/model、CLI 再覆盖 model,确认最终逐字段来源;传 secret flag、未知 flag、zero timeout、file URL 分别失败且 Provider 零调用;五状态退出码固定;错误含中文行动建议且秘密/stack 不出现;取消后资源 close 一次。

配置 schema 演进要有迁移路径。文件带 schemaVersion,旧字段如 timeout 迁移到 timeoutMs 时给一次 deprecation warning 和移除版本;冲突同时出现新旧字段直接错误,不能随机取一个。自动重写配置必须用户显式批准并原子写入,默认只提示命令,避免 CLI 修改仓库。

Provider 与 model 的组合需要交叉校验。某 Provider 不支持指定模型或工具调用时,在 Runtime 启动前返回配置错误;不要等第一次昂贵请求才发现。Provider registry 提供可用名称和 capability,未知值列出安全候选。baseUrl 自定义时标明信任风险并禁止把一个 Provider 的凭证发送到未批准主机。

凭证读取应绑定 Provider 与 endpoint。环境变量 key 存在不代表可发给任何 OpenAI-compatible URL;默认官方 endpoint 可使用标准变量,自定义 baseUrl 要使用专用变量或用户确认 host。防止恶意项目配置把 OPENAI_API_KEY 导向攻击者服务。host 与批准记录进入本地安全配置,不进仓库。

凭证生命周期在 Provider factory 创建时读取,尽量不挂在全局单例;请求 header 构造后不进入 Trace,运行结束释放引用。CLI 的 doctor 只检测变量是否存在,不验证/打印格式细节。认证失败提示重新配置,不能把响应 Authorization 或请求 dump 打出来。

非交互模式由 --no-input 或检测非 TTY 决定。任何需要用户选择、权限审批或覆盖确认的步骤返回稳定 code 和 JSON actionRequired,不等待 stdin。CI 可通过明确 policy 文件预批准低风险动作,高风险外部动作仍拒绝。TTY 与 CI 使用同一 Runtime 结果,不同仅在交互适配器。

JSON 输出 schema 至少含 schemaVersion、command、status、summary、runId、usage、evaluation、error 和 exitCode;缺失字段用 null 还是省略要固定。秘密和绝对路径先净化,数字保持机器精度。每次 run 只向 stdout 写最终对象,进度到 stderr 或关闭,避免下游 jq 解析失败。

日志级别 verbose/debug 不能成为泄密后门。debug 可增加模块、阶段、耗时和 error code,但请求 header、prompt、工具敏感参数与完整环境仍禁止。内部 stack 写权限受控文件并关联 runId,默认终端只给 --trace <runId> 查询建议。生产构建 source map 策略也要防源码公开。

错误链保留 cause 便于内部诊断,但 format 只走 allowlist 字段。AggregateError 关闭多个资源时,终端汇总“有两个资源关闭失败”,详情写脱敏 Trace;原任务 status 与 audit failure 按政策合并。不能让最后一个 close error 覆盖真正 permission denial。

退出码在不同 shell 只可靠到零至二百五十五,选值在范围内。信号退出常表达为一百二十八加信号号,130 对 SIGINT 合理;Windows 无相同信号时仍保持产品 code。文档提供表和脚本示例,例如 budget code 可触发缩小任务,permission code 不应自动重试。

子命令 traceevalconfigrun 各有最小参数集合,不把 run 的 model key 解析带到只读 trace。utility command 应在无 Provider key 下可用;--help/--version 更不能触发配置文件、Plugin 或网络。命令路由先识别纯 utility,再加载所需依赖,实现快速安全启动。

启动性能可分 parser、config、extensions、Provider 和 Runtime 阶段测量。Skill/Plugin/MCP 按需加载,普通 --help 毫秒级,run 在首个模型调用前输出可取消的初始化状态。慢配置不能用无限 spinner,deadline 到期给具体阶段与 runId。性能指标不包含 task 内容。

自动更新会改变二进制、默认和兼容协议,不应在执行任务时悄悄发生。CLI 可检查新版本并在 stderr 提示,下载/安装由独立命令和用户批准;组织部署锁版本。报告记录 CLI commit/version,使 Trace 和 Eval 能复现当时入口行为。

打包发行需验证 shebang、可执行权限、Node 版本范围、依赖锁、许可证与平台。安装后 smoke 在空临时 workspace 运行 version/help/fake-provider read,不需要真实 key;卸载不删除用户 Trace/config 除非明确选择。二进制签名和校验和进入 release artifact。

路径输出兼顾可操作与隐私。在本地 TTY 可显示 workspace 相对路径,JSON/telemetry 只显示 hash 或用户提供形式;错误来自临时副本时映射回逻辑路径。控制字符、ANSI escape 与双向 Unicode 在文件名中转义,防终端欺骗。颜色只由 CLI 自己添加,外部 message 先清洗。

配置冲突提示应给出来源,而不是只说最终值无效。例如 env timeout 覆盖合法文件但值 zero,错误写“CODE_AGENT_TIMEOUT_MS 必须是正整数”,用户知道改哪里。secret 来源例外不显示值。provenance 数据在解析时伴随每字段,完成后可丢弃敏感变量引用。

团队可定义 policy config,但项目仓库不能降低组织硬上限。最终配置是 defaults/file/env/CLI 的功能值,再与 organization policy 求交集:预算取较小、权限取子集、endpoint 取 allowlist。CLI 显示“请求值被策略收紧”,不能把策略当普通高优先级字段让用户覆盖。

测试错误文案时避免整段大快照阻碍改善,精确锁定 code、前缀、行动建议和秘密不存在;help/JSON schema 用快照保证自动化兼容。端到端子进程测试注入 fake Provider,不访问网络,检查真实 exitCode 和流。发布前再在三大平台跑 smoke。

一次产品级演练包括:新用户无 config 看到缺 credential 行动建议;开发者用 file+env+CLI 得到可解释最终值;误把 key 放 argv 立即安全拒绝;CI 权限 ask 返回稳定 JSON/code;Ctrl-C 后 Trace flush 与 Server close;完成 run 输出 summary、usage、Eval hash。覆盖这条旅程,CLI 才算产品入口。

CLI 的最终承诺是“相同输入与配置身份得到相同解释,秘密不因便利越过边界,每种终止都能被人和脚本理解,退出前资源有明确归宿”。这个承诺连接之前所有 Runtime 能力与之后的真实 Provider,任何一项缺失都会在生产环境放大。

产品评审可以让一位未参与开发的同事只看 --help 完成配置、运行、取消与查看 Trace,再让一个 shell 脚本仅凭 JSON 和 exit code 区分成功、预算、权限和取消。如果任何步骤需要阅读源码或猜错误含义,CLI contract 仍不完整。可用性测试与自动化契约测试共同构成交付证据。

已知限制也应出现在帮助与文档:当前支持哪些 Provider、是否非交互审批、配置文件位置、秘密推荐来源、Trace 保留位置和退出码范围。明确限制比自动猜测更可靠,特别是自定义 endpoint 与自动写入。用户知道边界,才能做正确风险选择。

最终发布前用空环境变量、只读 workspace、断网、错误 key、超小预算、SIGINT 与资源 close 失败做故障矩阵。每种都在有限时间内结束、有稳定 code、无 secret、无孤儿资源。CLI 能在失败时保持可理解,才真正具备产品品质。

这些故障演练还要从实际打包后的可执行入口运行,而不是只调用内部函数。只有真实 shell、环境、信号、stdout/stderr 和退出状态共同通过,才证明用户拿到的产物与测试中的产品契约一致。

入口行为必须与文档和自动化长期保持一致。

始终如此。

运行与验证

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 36
pnpm --filter @coding-agent/cli test

测试证明 CLI model 胜过 env/file,env timeout 胜过 file/default;--api-key sk-secret 在任何 Provider 调用前被拒绝。

真实运行输出

text
✓ tests/lab.test.ts (5 tests)
Test Files  1 passed (1)
Tests       5 passed (5)

项目 CLI 还覆盖 args/config/runtime:Fake Provider 读取、无批准写入阻断、预算与取消退出码、秘密输出脱敏、AGENTS.md 系统上下文、Eval 摘要与资源 close。产品化验证必须同时看函数与真实入口。

常见失败与排查

故障案例 1

症状:用户传 --model cli,实际仍使用 file/env;print-config 与 Runtime 又不同。

根因:对象 spread 顺序错误,不同模块各自解析来源,空值语义不一致。

定位:为每字段构造四层不同值,打印安全 provenance 并在 Provider spy 检查最终 model。

修复:唯一 loadConfig 按 default→file→env→CLI 逐字段覆盖,完成后统一 schema,再把不可变对象交 Runtime。

故障案例 2

症状:API key 出现在 shell history、进程列表、错误或 JSON config,虽然命令最终拒绝。

根因:解析值后才检查,完整 argv/env 被日志化,format error 未脱敏。

定位:注入唯一种子并扫描输出、Trace、config、Provider spy 与进程参数,检查拒绝发生时点。

修复:argv 首轮扫描 secret flag 并零调用拒绝;环境秘密由 Provider 专用读取,所有输出经过 sanitize。

故障案例 3

症状:Ctrl-C、权限拒绝、预算耗尽都退出一,CI 无法决定重试;或 process.exit 留下 MCP 子进程。

根因:状态被压成异常字符串,主函数立即退出未走 finally。

定位:逐状态运行真实入口,检查 exit status、stdout/stderr、close spy 与活动 PID。

修复:稳定 status→code 表;AbortSignal 结构化取消,await 清理后设置 process.exitCode,关闭失败安全报告。

课后作业

加入配置文件发现、--json 错误模式和 deprecation warning;写快照测试保证错误文案与退出码兼容自动化。

验收 Rubric

维度通过标准常见扣分
优先级CLI > env > file > default来源顺序漂移
密钥argv 全面拒绝接受后再提醒
校验URL/timeout/model 严格无效值进入 Runtime
退出五状态稳定全部返回 1

总项目增量

总项目包:@coding-agent/cli

总项目路径:apps/cli/src/config.tsapps/cli/src/errors.ts

总项目验证命令:pnpm --filter @coding-agent/cli test

CLI 契约完成后,下一课接入真实形态但可完全 fake 的 OpenAI-compatible Provider。

延伸阅读

  • Twelve-Factor App 配置。
  • Unix exit status 约定。
  • Secret exposure through process arguments。

方案对比与工程取舍

通用参数库可快速提供 help、子命令与类型,但 secret 拒绝、配置 provenance 和退出语义仍需产品层设计;手写 parser 对课程小表更透明。随着 command 增多可迁移库,但保留 allowlist、首轮秘密扫描与现有兼容测试。

环境变量易于 CI 和容器,却难轮换、会被子进程继承;系统 keychain 或 secret broker 更安全但跨平台复杂。课程使用环境变量作为明确最小路径,RuntimeConfig 永不保存 key,为以后替换秘密来源留下接口。

丰富中文 TTY 输出提升可用性,纯 JSON 提升自动化。两者不要用同一混杂流:内部结果先形成版本化结构,再分别渲染。退出码保持共同语义,脚本不需要解析自然语言,用户也不必读原始 JSON 才知道如何修复。

下一课衔接

CLI 已能安全选择 model、baseUrl、timeout 与预算,并把秘密留在专用来源。下一课实现 OpenAI-compatible Provider:注入 fetch、归一 Action/usage、只对 429/特定 5xx 有界重试,用 AbortSignal 真正停止请求,并确保任何错误都不回显 API key。

从零实现 Mini Code Agent Runtime