从零开发一个 Coding Agent(十):实现 CLI 参数解析与静态命令

本篇文章是《从零开发一个 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.jsonbin 配置,会在后续步骤中继续完成。

先回顾:命令行参数从哪里来

在 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 才允许访问 modeprompt。这比同时维护 help: booleanversion: booleanjson: boolean 更安全,因为后者允许出现互相矛盾的状态。

参数错误使用单独的 CliUsageError 表示。它代表"用户输入不符合 CLI 语法",不是模型失败、网络错误或程序 Bug。这个区分很重要:runCli 可以把用法错误写到 stderr 并返回退出码 1,而不会误把错误包装成 Agent 事件。

一次命令的完整数据流

下面这条链路是本篇要建立的核心:

sequenceDiagram participant User as 用户 participant CLI as runCli participant Parse as parseCliArgs participant Run as 注入的运行回调 User->>CLI: 参数数组 CLI->>Parse: 解析参数 alt help 或 version Parse-->>CLI: 静态命令 CLI-->>User: stdout,返回 0 else run Parse-->>CLI: mode + prompt CLI->>Run: 转发运行命令 Run-->>CLI: 返回退出码 CLI-->>User: 返回同一个退出码 else 参数错误 Parse--xCLI: CliUsageError CLI-->>User: stderr,返回 1 end

这里有一个刻意的设计: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);
	}
}

这里的 stdoutstderr 也是函数,而不是直接写死 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 是生成目录,不要手工修改。

总结

本篇把命令行入口拆成了两个清晰的层次:

  1. parseCliArgs 只负责把字符串数组解析成安全、明确的 CliCommand,不创建 Agent,也不产生 I/O。
  2. runCli 负责静态命令、运行回调以及 stdout/stderr 的分派。
  3. help/version 是不需要凭据的静态路径,并且测试证明它们不会调用运行依赖。
  4. 参数错误使用 CliUsageError,写入 stderr 并返回退出码 1
  5. 普通 prompt 默认使用 print 模式,--mode json 明确选择 JSON 模式。

现在,CLI 已经有了稳定的"输入契约"和"命令分派边界"。下一步只需要把 run 回调接到 AgentSession,再分别实现 print 和 JSON 输出,就能形成第一条真正可运行的命令行链路。

git地址:qddidi/di-code

如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金

相关推荐
阿黎梨梨1 小时前
Next.js 全栈开发:从 SPA 的痛点到 SSR 的破局之道
前端·后端
沐道PHP1 小时前
CRMEB多店版diy装修位置偏移修复
前端
前端小白乘风1 小时前
github Copilot 接入deepseek-v4-pro
人工智能
犀利的毛豆豆1 小时前
useCallback
前端
犀利的毛豆豆1 小时前
React.memo和useCallback组合使用
前端
还不秃顶的计科生1 小时前
具身智能论文学习1:PaLM: Scaling Language Modeling with Pathways
人工智能·语言模型·palm
qq_454245031 小时前
Agent数据价值分类存储原则
大数据·人工智能·分类
怪奇云呼军1 小时前
从声音特征到 CRM 回流:闪电智能 Voice Agent 沟通策略自适应系统 v1 实战
android·人工智能·python·音视频·语音识别
Python私教1 小时前
AI 并行编码的 Worktree 生命周期:创建、隔离与安全回收
人工智能·git