中文资讯 掘金 AI Thu, 13 Aug 2026 正文 ok 1268 words

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

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

本文是《从零开发一个 Coding Agent》系列第十篇,重点实现 CLI 的参数解析与静态命令分派。文章定义了 `parseCliArgs` 和 `runCli` 两个入口,通过可辨识联合类型规范 help、version 与 run 三类命令,并用 `CliUsageError` 区分用户输入错误与运行时错误。CLI 支持 `--help`、`--version`、`--print`、`--mode print`、`--mode json` 及默认 prompt 模式,未知选项和缺失 prompt 会返回退出码 `1`。这种将解析、I/O 和实际 Agent 运行解耦的设计,使静态命令无需凭据即可执行,也便于开发者通过依赖注入和测试验证命令分派行为。
打开原文相关 6 · 机会 1
media3.2 KB media589 B media9.2 KB

中文

本篇文章是《从零开发一个 Coding Agent》系列第十篇。在上一篇中,我们已经有了一个可以保存对话状态、调用 Agent Loop 的 Agent 类。

但是,用户还没有一个舒适的命令行入口。用户可能会这样启动程序:

di-code --mode json "请解释这段代码"

Node.js 会把这行命令拆成一个字符串数组,程序需要从这个数组中判断:用户是想查看帮助、查看版本,还是要真正运行一次 Agent?如果参数写错了,程序还应该给出清晰的错误,而不是把 --mode 当成问题的一部分发送给模型。

本篇先完成 CLI 最基础、也最容易被忽略的一层:参数解析和静态命令分派。完成后,我们会得到两个入口:

parseCliArgs(args: readonly string[]): CliCommand; runCli(args: readonly string[], dependencies: CliDependencies): Promise;

本篇暂时不接入真实 Provider,不读取 API Key,也不创建真正的进程入口。print 模式如何消费 Agent 事件、JSON 模式如何输出 JSONL,以及 package.json 的 bin 配置,会在后续步骤中继续完成。

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

在 Node.js 程序中,命令行参数通常来自 process.argv。例如:

node dist/main.js --mode json "解释这个函数"

可以粗略地理解为:

[ "node", "dist/main.js", "--mode", "json", "解释这个函数", ]

前两个元素是 Node 和脚本路径,真正属于应用的参数从 process.argv.slice(2) 开始。为了让解析器更容易测试,我们不让 parseCliArgs 直接读取全局的 process.argv,而是将参数数组作为输入传入:

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 字段:

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 事件。

一次命令的完整数据流

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

mermaid 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(间谍函数)确认运行路径根本没有被调用。

第一步:编写参数解析器

打开:

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 时,下一个数组元素就是它的值。例如:

["--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]

Options: -p, --print Print only the final assistant text (default) --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; readonly version: string; }

export async function runCli(args: readonly string[], dependencies: CliDependencies): Promise { 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 会提醒我们补充分派逻辑。

第三步:用测试锁定行为

测试文件是:

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”,所以不能只看进程退出码。

第四步:导出公共入口并检查质量

打开:

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

专栏:从零开发一个 Coding Agent - 东方小月的专栏 - 掘金

English / 原文

本篇文章是《从零开发一个 Coding Agent》系列第十篇。在上一篇中,我们已经有了一个可以保存对话状态、调用 Agent Loop 的 Agent 类。

但是,用户还没有一个舒服的命令行入口。用户可能会这样启动程序:

di-code --mode json "请解释这段代码"

Node.js 会把这行命令拆成一个字符串数组,程序需要从这个数组中判断:用户是想看帮助、查看版本,还是要真正运行一次 Agent?如果参数写错了,程序还应该给出清楚的错误,而不是把 --mode 当成问题的一部分发给模型。

这一篇先完成 CLI 最基础、也最容易被忽略的一层:参数解析和静态命令分派 。完成后,我们会得到两个入口:

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。例如:

node dist/main.js --mode json "解释这个函数"

可以粗略地理解为:

[
	"node",
	"dist/main.js",
	"--mode",
	"json",
	"解释这个函数",
]

前两个元素是 Node 和脚本路径,真正属于应用的参数从 process.argv.slice(2) 开始。为了让解析器容易测试,我们不让 parseCliArgs 直接读取全局的 process.argv,而是把参数数组作为输入传进来:

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 字段:

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 事件。

一次命令的完整数据流

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

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(间谍函数)确认运行路径根本没有被调用。

第一步:写出参数解析器

打开:

di-code/packages/coding-agent/src/cli.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。

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 时,下一个数组元素是它的值。例如:

["--mode", "json", "hello"]

解析器读取 json 后必须把索引再向前移动一次。否则下一轮循环会把 json 当作 prompt 的一部分,最后得到错误的 "json hello"。

为什么拒绝未知选项

--wat 很可能是拼写错误。如果把它悄悄当成 prompt,用户会得到一个看似正常、实际完全错误的模型请求。凡是以 - 开头、但没有在契约中定义的参数,都应该立即报错。

为什么普通参数要最后拼接

Shell 会先处理引号,再把结果交给 Node。"explain this" 会作为一个数组元素,explain this 也可能被拆成两个元素。解析器统一使用 promptParts.join(" "),让多参数输入得到稳定的 prompt。

第二步:让静态命令和运行命令分开

在同一个文件中加入帮助文本、依赖接口和 runCli:

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 会提醒我们补充分派逻辑。

第三步:用测试锁定行为

测试文件是:

di-code/packages/coding-agent/test/cli.test.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.");

分派测试要证明静态命令不会调用运行依赖:

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");

还要测试运行命令的转发和错误输出:

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 个测试。运行:

Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run cli.test.ts

看到 8 passed 才能说明参数层和分派层都被覆盖。coding-agent 的测试脚本允许“没有测试也返回 0”,所以不能只看进程退出码。

第四步:导出公共入口并检查质量

打开:

di-code/packages/coding-agent/src/index.ts

公共入口只需要导出 CLI 契约:

export * from "./cli.ts";

然后执行项目固定的格式、类型和构建检查:

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 - 东方小月的专栏 - 掘金