本篇文章是《从零开发一个 Coding Agent》系列第十篇。在上一篇中,我们已经有了一个可以保存对话状态、调用 Agent Loop 的 Agent 类。
但是,用户还没有一个舒服的命令行入口。用户可能会这样启动程序:
powershell
di-code --mode json "请解释这段代码"
Node.js 会把这行命令拆成一个字符串数组,程序需要从这个数组中判断:用户是想看帮助、查看版本,还是要真正运行一次 Agent?如果参数写错了,程序还应该给出清楚的错误,而不是把 --mode 当成问题的一部分发给模型。
这一篇先完成 CLI 最基础、也最容易被忽略的一层:参数解析和静态命令分派。完成后,我们会得到两个入口:
ts
parseCliArgs(args: readonly string[]): CliCommand;
runCli(args: readonly string[], dependencies: CliDependencies): Promise<number>;
本篇暂时不接真实 Provider,不读取 API Key,也不创建真正的进程入口。print 模式如何消费 Agent 事件、JSON 模式如何输出 JSONL,以及 package.json 的 bin 配置,会在后续步骤中继续完成。
先回顾:命令行参数从哪里来
在 Node.js 程序中,命令行参数通常来自 process.argv。例如:
powershell
node dist/main.js --mode json "解释这个函数"
可以粗略地理解为:
ts
[
"node",
"dist/main.js",
"--mode",
"json",
"解释这个函数",
]
前两个元素是 Node 和脚本路径,真正属于应用的参数从 process.argv.slice(2) 开始。为了让解析器容易测试,我们不让 parseCliArgs 直接读取全局的 process.argv,而是把参数数组作为输入传进来:
ts
parseCliArgs(["--mode", "json", "解释这个函数"]);
这样测试不需要启动子进程,也不需要修改当前进程的全局状态。
本篇固定的参数契约
先把规则写清楚,代码才不会在不同地方各自猜测参数的含义。
| 命令行输入 | 解析结果 |
|---|---|
-h、--help |
显示帮助文本 |
-v、--version |
显示版本 |
-p PROMPT、--print PROMPT |
print 模式运行 |
--mode print PROMPT |
print 模式运行 |
--mode json PROMPT |
JSON 模式运行 |
PROMPT 或多个普通参数 |
默认使用 print 模式,并用空格拼接 |
本篇的命令对象使用可辨识联合类型(discriminated union)。它的特点是每个分支都有一个明确的 kind 字段:
ts
type CliCommand =
| { kind: "help" }
| { kind: "version" }
| { kind: "run"; mode: "print" | "json"; prompt: string };
当代码判断 command.kind === "run" 后,TypeScript 才允许访问 mode 和 prompt。这比同时维护 help: boolean、version: boolean、json: boolean 更安全,因为后者允许出现互相矛盾的状态。
参数错误使用单独的 CliUsageError 表示。它代表"用户输入不符合 CLI 语法",不是模型失败、网络错误或程序 Bug。这个区分很重要:runCli 可以把用法错误写到 stderr 并返回退出码 1,而不会误把错误包装成 Agent 事件。
一次命令的完整数据流
下面这条链路是本篇要建立的核心:
这里有一个刻意的设计:runCli 接收 run 回调,而不是自己创建 Agent 或 Provider。于是 --help 和 --version 可以在没有凭据的环境中工作,测试也可以用一个 spy(间谍函数)确认运行路径根本没有被调用。
第一步:写出参数解析器
打开:
text
di-code/packages/coding-agent/src/cli.ts
先定义公共类型和错误类型:
ts
export type OutputMode = "print" | "json";
export type CliCommand =
| { kind: "help" }
| { kind: "version" }
| { kind: "run"; mode: OutputMode; prompt: string };
export class CliUsageError extends Error {
constructor(message: string) {
super(message);
this.name = "CliUsageError";
}
}
接下来实现 parseCliArgs。它做三件事:先处理 help/version,再扫描模式和 prompt,最后检查冲突和空 prompt。
ts
export function parseCliArgs(args: readonly string[]): CliCommand {
const staticOption = args.find((argument) =>
["-h", "--help", "-v", "--version"].includes(argument),
);
if (staticOption !== undefined) {
const kind = staticOption === "-h" || staticOption === "--help" ? "help" : "version";
if (args.length !== 1) {
throw new CliUsageError(`--${kind} must be used on its own.`);
}
return { kind };
}
let mode: OutputMode = "print";
let printAlias = false;
const promptParts: string[] = [];
for (let index = 0; index < args.length; index++) {
const argument = args[index];
if (argument === "-p" || argument === "--print") {
printAlias = true;
continue;
}
if (argument === "--mode") {
const value = args[index + 1];
if (value === undefined) {
throw new CliUsageError("Option --mode requires a value.");
}
if (value !== "print" && value !== "json") {
throw new CliUsageError(`Unsupported mode "${value}". Expected print or json.`);
}
mode = value;
index++;
continue;
}
if (argument?.startsWith("-")) {
throw new CliUsageError(`Unknown option "${argument}".`);
}
if (argument !== undefined) {
promptParts.push(argument);
}
}
if (printAlias && mode === "json") {
throw new CliUsageError("Cannot combine --print with --mode json.");
}
if (promptParts.length === 0) {
throw new CliUsageError("A prompt is required.");
}
return { kind: "run", mode, prompt: promptParts.join(" ") };
}
为什么要 index++
当循环读到 --mode 时,下一个数组元素是它的值。例如:
ts
["--mode", "json", "hello"]
解析器读取 json 后必须把索引再向前移动一次。否则下一轮循环会把 json 当作 prompt 的一部分,最后得到错误的 "json hello"。
为什么拒绝未知选项
--wat 很可能是拼写错误。如果把它悄悄当成 prompt,用户会得到一个看似正常、实际完全错误的模型请求。凡是以 - 开头、但没有在契约中定义的参数,都应该立即报错。
为什么普通参数要最后拼接
Shell 会先处理引号,再把结果交给 Node。"explain this" 会作为一个数组元素,explain this 也可能被拆成两个元素。解析器统一使用 promptParts.join(" "),让多参数输入得到稳定的 prompt。
第二步:让静态命令和运行命令分开
在同一个文件中加入帮助文本、依赖接口和 runCli:
ts
const HELP_TEXT = `Usage: di-code [options] <prompt>
Options:
-p, --print Print only the final assistant text (default)
--mode <mode> Output mode: print or json
-h, --help Show help
-v, --version Show version
`;
export interface CliDependencies {
stdout(text: string): void;
stderr(text: string): void;
run(command: Extract<CliCommand, { kind: "run" }>): Promise<number>;
readonly version: string;
}
export async function runCli(args: readonly string[], dependencies: CliDependencies): Promise<number> {
let command: CliCommand;
try {
command = parseCliArgs(args);
} catch (cause) {
if (cause instanceof CliUsageError) {
dependencies.stderr(`${cause.message}\n`);
return 1;
}
throw cause;
}
switch (command.kind) {
case "help":
dependencies.stdout(HELP_TEXT);
return 0;
case "version":
dependencies.stdout(`${dependencies.version}\n`);
return 0;
case "run":
return dependencies.run(command);
}
}
这里的 stdout 和 stderr 也是函数,而不是直接写死 process.stdout.write。在生产入口中,它们可以连接到 Node 的输出流;在测试中,它们可以是 vi.fn()。这种依赖注入(Dependency Injection)让 CLI 逻辑不会被终端环境绑死。
run 使用 Extract<CliCommand, { kind: "run" }>,表示它只接收运行分支,不可能误传 help 或 version。switch 覆盖了联合类型的三个分支,因此将来新增命令时,TypeScript 会提醒我们补充分派逻辑。
第三步:用测试锁定行为
测试文件是:
text
di-code/packages/coding-agent/test/cli.test.ts
解析器至少要覆盖以下情况:
ts
expect(parseCliArgs(["--help"])).toEqual({ kind: "help" });
expect(parseCliArgs(["-v"])).toEqual({ kind: "version" });
expect(parseCliArgs(["explain", "this"])).toEqual({
kind: "run",
mode: "print",
prompt: "explain this",
});
expect(parseCliArgs(["--mode", "json", "hello"])).toEqual({
kind: "run",
mode: "json",
prompt: "hello",
});
expect(() => parseCliArgs(["--wat", "hello"])).toThrow('Unknown option "--wat".');
expect(() => parseCliArgs([])).toThrow("A prompt is required.");
分派测试要证明静态命令不会调用运行依赖:
ts
const stdout = vi.fn<(text: string) => void>();
const stderr = vi.fn<(text: string) => void>();
const run = vi.fn(async () => 0);
const dependencies = { stdout, stderr, run, version: "0.0.0" };
expect(await runCli(["--help"], dependencies)).toBe(0);
expect(await runCli(["--version"], dependencies)).toBe(0);
expect(run).not.toHaveBeenCalled();
expect(stdout.mock.calls[0]?.[0]).toContain("Usage: di-code");
expect(stdout.mock.calls[1]?.[0]).toBe("0.0.0\n");
还要测试运行命令的转发和错误输出:
ts
const run = vi.fn(async () => 7);
const dependencies = { stdout, stderr, run, version: "0.0.0" };
expect(await runCli(["--mode", "json", "hello"], dependencies)).toBe(7);
expect(run).toHaveBeenCalledWith({ kind: "run", mode: "json", prompt: "hello" });
expect(await runCli(["--wat"], dependencies)).toBe(1);
expect(stderr).toHaveBeenCalledWith('Unknown option "--wat".\n');
当前定向测试应该有 1 个测试文件、8 个测试。运行:
powershell
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run cli.test.ts
看到 8 passed 才能说明参数层和分派层都被覆盖。coding-agent 的测试脚本允许"没有测试也返回 0",所以不能只看进程退出码。
第四步:导出公共入口并检查质量
打开:
text
di-code/packages/coding-agent/src/index.ts
公共入口只需要导出 CLI 契约:
ts
export * from "./cli.ts";
然后执行项目固定的格式、类型和构建检查:
powershell
Set-Location D:\pi\di-code
npx --no-install biome check --write packages\coding-agent\src\cli.ts packages\coding-agent\src\index.ts packages\coding-agent\test\cli.test.ts
npm test --workspace packages/coding-agent -- --run cli.test.ts
npm run check
npm run build --workspace @di-code/coding-agent
预期结果是:Biome 没有剩余错误,CLI 定向测试为 1 file / 8 tests 全部通过,根目录 TypeScript 检查通过,coding-agent 构建成功。dist 是生成目录,不要手工修改。
总结
本篇把命令行入口拆成了两个清晰的层次:
parseCliArgs只负责把字符串数组解析成安全、明确的CliCommand,不创建 Agent,也不产生 I/O。runCli负责静态命令、运行回调以及 stdout/stderr 的分派。- help/version 是不需要凭据的静态路径,并且测试证明它们不会调用运行依赖。
- 参数错误使用
CliUsageError,写入 stderr 并返回退出码1。 - 普通 prompt 默认使用 print 模式,
--mode json明确选择 JSON 模式。
现在,CLI 已经有了稳定的"输入契约"和"命令分派边界"。下一步只需要把 run 回调接到 AgentSession,再分别实现 print 和 JSON 输出,就能形成第一条真正可运行的命令行链路。
git地址:qddidi/di-code
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金