从零开发Agent CLI第二期:框架搭建与子命令路由实战完整指南教程
前言
一个 CLI 工具的流畅度,往往在输入第一条命令时就能判断。具备仪式感的工具,退出码规范、帮助信息详细、命令结构层次分明缺一不可。
上一篇已搭建好工程基建(tsup + Vitest + TypeScript ESM),本节将为 dsk 注入命令行骨架。核心目标明确:
- 注册 5 个子命令:
chat、run、setup、init、completion - 所有命令共享同一套配置加载流程
- 退出码统一管理,避免散落
process.exit(0)与process.exit(1) --help输出应像人工撰写,而非框架默认的生硬格式- 入口需优雅处理
Ctrl+C及各类异常
选用 Commander 实现。Node.js CLI 生态中可选 yargs、clack、ink 等,但 Commander 以简洁、稳定、社区广泛著称,满足 dsk 当前需求。
统一退出码
文件虽小,却值得专门阐述。
多数 CLI 项目在代码中散布 process.exit(1) 或 process.exit(0),随时间推移,各退出码的真实含义变得模糊不清。
因此优先定义一组常量:
// src/cli/exit-codes.ts/** dsk 退出码规范 */
export const ExitCode = {
/** 正常执行完成 */
SUCCESS: 0,
/** 通用错误 */
GENERAL_ERROR: 1,
/** 配置错误 */
CONFIG_ERROR: 2,
/** 用户通过 Ctrl+C 中断 */
SIGINT: 130,
} as const;
as const 确保 TypeScript 将值推导为字面量类型,后续使用如 ExitCode.SUCCESS 时,类型检查可直接捕获拼写错误。退出码 130 遵循 Unix 惯例(128 + SIGINT 信号值 2),遵循此约定可使 shell 脚本正确判断状态。
未来引入新错误类型时,直接添加至此常量对象即可,一目了然。
配置加载中间件
CLI 启动时最常见的操作是加载配置。每个子命令执行自身业务逻辑前,配置必须已就绪并可用。
Commander 提供 hook 机制——preAction 在每次 action 执行前被调用。借此构建“配置注入中间件”:
// src/cli/middleware.tsimport type { Command } from "commander";
import type { Config } from "../config/index.js";
import { loadConfig } from "../config/index.js";/**
* dsk 运行时上下文。
* 通过 commander 的 preAction hook 注入到每个命令中。
*/
export interface DskContext {
config: Config;
verbose: boolean;
}export async function loadConfigMiddleware(this: Command): Promise<DskContext> {
const opts = this.optsWithGlobals() as { verbose?: boolean; config?: string };
const verbose = opts.verbose ?? false; let config: Config;
try {
config = await loadConfig(opts.config);
} catch {
const { defaultConfig } = await import("../config/index.js");
config = defaultConfig;
} return { config, verbose };
}
设计思路清晰:
DskContext接口代表 CLI 的运行时上下文。后续若增加能力(如 provider 管理器、tool 注册表),只需在此添加字段。所有命令共享同一数据源。- 配置加载失败不会导致进程崩溃——回退到默认配置,通过标准输出提示,而非直接
process.exit。 optsWithGlobals()能同时获取全局选项和子命令选项,便于子命令覆盖全局配置。
在 createCli 中注册:
program.hook("preAction", async (thisCommand) => {
const ctx = await loadConfigMiddleware.call(thisCommand);
(thisCommand as unknown as Record<string, unknown>).dskCtx = ctx;
});
注意此处使用 Function.prototype.call 保持 this 指向。Commander 的 hook 回调中 thisCommand 即被触发的命令实例,通过 call 传递上下文,使中间件函数在正确的 this 下执行。
命令 action 中可通过 this.dskCtx 获取配置:
const ctx = (this as unknown as Record<string, unknown>).dskCtx as DskContext;
类型转换略显粗犷,但胜在简单。后续若复杂度提升,可对 Commander 类型做 declaration merging,目前暂无需折腾。
自定义帮助信息
Commander 默认的 --help 输出较标准化。为使风格贴合 dsk——带颜色、分组清晰、包含示例,需自定义。
// src/cli/help.tsimport type { Command } from "commander";
import chalk from "chalk";export function customHelp(program: Command): string {
const lines: string[] = []; lines.push("");
lines.push(chalk.bold("用法:"));
lines.push(` ${chalk.cyan("dsk")} ${chalk.dim("[global-options]")} ${chalk.green("" )} ${chalk.dim("[options]")}`);
lines.push(""); const globalOpts = program.options.filter(
(o) => o.long !== "--help" && o.long !== "--version" && o.long !== "--config",
);
if (globalOpts.length > 0) {
lines.push(chalk.bold("全局选项:"));
for (const opt of globalOpts) {
const flags = [opt.short, opt.long].filter(Boolean).join(", ");
lines.push(` ${chalk.cyan(flags.padEnd(24))} ${opt.description ?? ""}`);
}
lines.push("");
} lines.push(chalk.bold("内置选项:"));
for (const flag of ["-h, --help", "-V, --version"]) {
const opt = program.options.find(
(o) => o.long === (flag.includes("help") ? "--help" : "--version"),
);
if (opt) {
lines.push(` ${chalk.cyan(flag.padEnd(24))} ${opt.description ?? ""}`);
}
}
lines.push(""); const cmds = program.commands.filter((c) => !c.name().startsWith("help"));
if (cmds.length > 0) {
lines.push(chalk.bold("命令:"));
for (const cmd of cmds) {
lines.push(` ${chalk.green(cmd.name().padEnd(24))} ${cmd.description()}`);
}
lines.push("");
} lines.push(chalk.bold("示例:"));
lines.push(` ${chalk.dim("# 启动交互式对话")}`);
lines.push(" dsk chat");
lines.push(` ${chalk.dim("# 让 AI 执行一个任务")}`);
lines.push(" dsk run 修改所有 TODO 注释");
lines.push(` ${chalk.dim("# 运行配置向导")}`);
lines.push(" dsk setup");
lines.push(` ${chalk.dim("# 生成 shell 自动补全")}`);
lines.push(" dsk completion");
lines.push(""); return lines.join("n");
}
然后在 createCli 中直接覆写 Commander 的 help 方法:
program.helpInformation = () => customHelp(program);
直接赋值覆盖。Commander 内部依赖 helpInformation() 方法生成帮助文本,覆写是最干净的方式。
输出效果大致如下:
用法:
dsk [global-options] [options]全局选项:
--verbose 开启详细日志输出内置选项:
-h, --help 显示帮助信息
-V, --version 显示版本号命令:
chat 启动交互式对话会话
run 执行一次性任务
setup 运行配置向导
init 生成项目记忆文件
completion 输出 shell 自动补全说明示例:
# 启动交互式对话
dsk chat
# 让 AI 执行一个任务
dsk run 修改所有 TODO 注释
# 运行配置向导
dsk setup
# 生成 shell 自动补全
dsk completion
子命令路由
重点环节。五个子命令各有明确定位。
将 src/cli/index.ts 改为 src/cli/index.tsx(后续需用 JSX 渲染终端 UI),使用 .tsx 扩展名让 TypeScript 毫无怨言:
// src/cli/index.tsximport { Command } from "commander";
import { loadConfigMiddleware } from "./middleware.js";
import { customHelp } from "./help.js";const SUBCOMMANDS = ["chat", "run", "setup", "init", "completion"];export function createCli(): Command {
const program = new Command();
program.exitOverride(); program
.name("dsk")
.description("基于 DeepSeek 的 AI 编程助手终端工具")
.version("0.0.0", "-V, --version", "显示版本号")
.option("--verbose", "开启详细日志输出")
.option("--config " , "指定配置文件路径"); program.helpInformation = () => customHelp(program); program.hook("preAction", async (thisCommand) => {
const ctx = await loadConfigMiddleware.call(thisCommand);
(thisCommand as unknown as Record<string, unknown>).dskCtx = ctx;
}); // ── chat 子命令 ──────────────────────────────
program
.command("chat")
.description("启动交互式对话会话")
.action(async function () {
if (!process.stdin.isTTY) {
console.error("dsk chat 需要交互式终端。如需执行一次性任务,请使用 dsk run。");
process.exit(1);
}
console.log("dsk chat — 待实现(第07章)");
}); // ── run 子命令 ───────────────────────────────
program
.command("run")
.description("执行一次性任务")
.argument("[prompt...]", "任务描述")
.option("--model " , "指定使用的模型")
.action(async function (_prompt: string[]) {
console.log("dsk run — 待实现(第07章)");
}); // ── setup 子命令 ─────────────────────────────
program
.command("setup")
.description("运行配置向导")
.option("--export", "以 JSON 格式导出配置")
.option("--test", "测试 API Key 连通性")
.action(async function () {
console.log("dsk setup — 待实现(第14章)");
}); // ── init 子命令 ──────────────────────────────
program
.command("init")
.description("在当前项目下生成项目记忆文件(AGENTS.md)")
.action(async function () {
console.log("dsk init — 待实现(第11章)");
}); // ── completion 子命令 ────────────────────────
program
.command("completion")
.description("输出 shell 自动补全配置说明(bash/zsh)")
.argument("[shell]", "shell 类型", /^(bash|zsh)$/i)
.action(async function (shell?: string) {
if (!shell) {
console.log("请指定 shell 类型:dsk completion bash 或 dsk completion zsh");
return;
} if (shell === "bash") {
console.log(`# dsk bash 自动补全
_dsk_completion() {
local cur=${COMP_WORDS[COMP_CWORD]}
if [[ ${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "${SUBCOMMANDS.join(" ")}" -- "${cur}") )
return 0
fi
COMPREPLY=( $(compgen -W "--verbose --config --model" -- "${cur}") )
}
complete -F _dsk_completion dsk`);
} else {
console.log(`# dsk zsh 自动补全
_dsk_completion() {
local -a commands
commands=(
"chat:启动交互式对话会话"
"run:执行一次性任务"
"setup:运行配置向导"
"init:生成项目记忆文件"
"completion:输出 shell 自动补全说明"
)
_describe 'dsk commands' commands
}
compdef _dsk_completion dsk`);
}
}); return program;
}
几个值得关注的设计点:
exitOverride
program.exitOverride();
此行极其关键。Commander 默认在 --help 和 --version 时直接调用 process.exit(),但单元测试中不希望真实退出进程。exitOverride() 使 Commander 改为抛出一个 CommanderError,测试代码便可使用 rejects.toMatchObject 断言退出码。
TTY 检测
if (!process.stdin.isTTY) {
console.error("dsk chat 需要交互式终端。...");
process.exit(1);
}
dsk chat 是交互式会话,在管道中执行无意义(如 echo "hello" | dsk chat)。检测 process.stdin.isTTY 可提前告知用户,而非进入会话后报错。
completion 子命令
此子命令特殊——不调用任何 API,仅向终端输出一段 shell 函数定义。用户将其添加至 .bashrc 或 .zshrc 即可获得自动补全。
选择“输出配置说明”而非直接安装补全脚本,基于三点考量:
- 不同操作系统的 shell 配置路径各异,自动安装易出错
- 用户手动粘贴一次即可知晓补全脚本位置
- 保持代码简洁,13 行逻辑覆盖 bash 与 zsh
bash 补全采用 COMP_WORDS 与 compgen,zsh 补全使用 _describe。二者覆盖了 95% 以上开发者的终端场景。
SUBCOMMANDS 常量
const SUBCOMMANDS = ["chat", "run", "setup", "init", "completion"];
定义为数组而非四处硬编码,使 bash 补全脚本、测试、后续权限校验均能引用同一来源。
入口文件:SIGINT 与异常规范化
入口文件 src/index.ts 是用户的第一个接触点,也是异常处理的最后一道防线:
#!/usr/bin/env nodeimport { createCli } from "./cli/index.js";
import { ExitCode } from "./cli/exit-codes.js";process.on("SIGINT", () => {
process.exit(ExitCode.SIGINT);
});const program = createCli();try {
await program.parseAsync(process.argv);
} catch (err: unknown) {
const error = err as { exitCode?: number; code?: string }; if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
process.exit(error.exitCode ?? ExitCode.SUCCESS);
} if (typeof error.exitCode === "number") {
process.exit(error.exitCode);
} console.error(String(err));
process.exit(ExitCode.GENERAL_ERROR);
}
这段代码处理了三种场景:
- 用户按 Ctrl+C → 触发
SIGINT处理器,退出码 130。注意此处不可硬编码process.exit(130),应使用ExitCode.SIGINT。 - commander 正常退出 →
--help和--version抛出code === "commander.helpDisplayed"或"commander.version",捕获后以 SUCCESS 码退出。 - 其他 commander 异常 → 如参数解析失败、命令未找到,commander 会抛出带
exitCode的异常,直接透传此码。 - 未知异常 → 打印错误栈,以
ExitCode.GENERAL_ERROR退出。
另需注意 await program.parseAsync(process.argv)。Commander 提供 parseAsync 和 parse 两个版本。若 action 为 async(大概率,因需调用 API),必须使用 parseAsync,否则 Promise reject 会被吞掉。
tsconfig 调整
新增 .tsx 文件后,tsconfig 需同步修改:
{
"include": ["src/**/*.ts", "src/**/*.tsx"],
}
不做此调整,tsc --noEmit 会忽略 .tsx 文件,类型检查等于空转。
测试
测试中的关键在于:exitOverride() 使 Commander 抛异常而非退出进程,我们的测试依赖此行为:
import { describe, it, expect } from "vitest";
import { createCli } from "../src/cli/index.js";
import { ExitCode } from "../src/cli/exit-codes.js";describe("createCli", () => {
const cli = createCli(); it("should return a Command instance with name dsk", () => {
expect(cli.name()).toBe("dsk");
}); it("should register the chat subcommand", () => {
const cmd = cli.commands.find((c) => c.name() === "chat");
expect(cmd).toBeDefined();
expect(cmd!.description()).toBe("启动交互式对话会话");
}); it("should register the run subcommand", () => {
const cmd = cli.commands.find((c) => c.name() === "run");
expect(cmd).toBeDefined();
expect(cmd!.description()).toBe("执行一次性任务");
}); it("should register the setup subcommand", () => {
const cmd = cli.commands.find((c) => c.name() === "setup");
expect(cmd).toBeDefined();
expect(cmd!.description()).toBe("运行配置向导");
}); it("should register the init subcommand", () => {
const cmd = cli.commands.find((c) => c.name() === "init");
expect(cmd).toBeDefined();
expect(cmd!.description()).toBe("在当前项目下生成项目记忆文件(AGENTS.md)");
}); it("should register the completion subcommand", () => {
const cmd = cli.commands.find((c) => c.name() === "completion");
expect(cmd).toBeDefined();
expect(cmd!.description()).toContain("shell 自动补全");
}); it("should ha ve the --verbose global option", () => {
const opts = cli.options.map((o) => o.long);
expect(opts).toContain("--verbose");
}); it("should ha ve the --config global option", () => {
const opts = cli.options.map((o) => o.long);
expect(opts).toContain("--config");
}); it("should output version with --version (exitCode=0)", async () => {
await expect(
cli.parseAsync(["node", "dsk", "--version"]),
).rejects.toMatchObject({ exitCode: ExitCode.SUCCESS });
}); it("should output help with --help (exitCode=0)", async () => {
await expect(
cli.parseAsync(["node", "dsk", "--help"]),
).rejects.toMatchObject({ exitCode: ExitCode.SUCCESS });
}); it("run subcommand should exit with SUCCESS", async () => {
await expect(
cli.parseAsync(["node", "dsk", "run", "test"]),
).resolves.toBeDefined();
});
});describe("ExitCode constants", () => {
it("should ha ve the correct values", () => {
expect(ExitCode.SUCCESS).toBe(0);
expect(ExitCode.GENERAL_ERROR).toBe(1);
expect(ExitCode.CONFIG_ERROR).toBe(2);
expect(ExitCode.SIGINT).toBe(130);
});
});
测试覆盖:所有子命令注册与描述、全局选项、--help/--version 退出码、子命令正常执行、ExitCode 常量值。共 12 个用例。
注意最后一个用例:cli.parseAsync(["node", "dsk", "run", "test"]) 不会抛异常,因为 dsk run 的 action 仅为 console.log,未调用 process.exit。此处使用 resolves 而非 rejects。
跑一下看看效果
现在项目根目录执行:
$ npx dsk --help
$ npx dsk --version
0.0.0$ npx dsk unknown-command
# commander 会报错,退出码 1
跑测试:
文件结构总结
本章新增/修改的文件:
src/
├── cli/
│ ├── exit-codes.ts # 新增 — 退出码常量
│ ├── help.ts # 新增 — 自定义帮助信息
│ ├── index.tsx # 重写 — CLI 主路由(.ts 改 .tsx)
│ └── middleware.ts # 新增 — 配置加载中间件
├── index.ts # 修改 — SIGINT + 异常处理
tests/
└── cli.test.ts # 修改 — 新增 7 个用例
tsconfig.json # 修改 — include .tsx
做了啥以及没做啥
做对了:
- Commander 架构搭得整洁,命令各司其职
- preAction hook + 中间件模式使配置注入对业务透明
- 退出码集中管理,杜绝散落各处的
process.exit - 测试覆盖退出码、子命令注册、帮助信息,重构时心中有底
有意没做的(留到后续处理):
dsk chat与dsk run的业务逻辑仍为占位符——等 agent 会话循环章节再填充- middleware 配置加载失败仅回退默认配置,未给用户报错提示(下章补充)
- 自定义 help 尚未编写测试——手工验证无误,但自动化测试确实缺失(TODO +1)
职责对比:框架搭建 vs 子命令路由
整篇文章交替进行两项工作,此处明确区分:
| 维度 | 框架搭建 | 子命令路由 |
|---|---|---|
| 核心文件 | src/index.ts、src/cli/exit-codes.ts、src/cli/middleware.ts、src/cli/help.ts | src/cli/index.tsx |
| 解决的问题 | 异常处理、退出码规范、配置注入、help 定制——子命令无需关心这些 | 命令注册、参数声明、参数解析、分发执行——路由到正确的 handler |
| 类比 Web 框架 | Express 的 app.use(errorHandler)、全局 middleware、view engine 配置 | Express 的 router.get('/users', handler)、Vue Router 的 route table |
| 关注点 | CLI 作为系统的生命周期与边界 | CLI 作为路由器的流量分发 |
| 可测试性 | 通过 exitOverride 测试退出码与异常路径 | 通过 parseAsync 测试命令解析与参数提取 |
| 扩展方式 | 添加新 hook、全局选项、异常类型 | 添加新 .command()、.argument()、.option() |
| 改动影响范围 | 影响所有子命令的行为 | 仅影响被注册的那个子命令 |
换一个角度:框架搭建决定 CLI 如何结束(退出码)、如何运行(配置注入)、长什么样(help)。子命令路由决定 CLI 能做什么。
两部分虽写入同一 commit,但职责完全正交——设计中有意保持:框架不依赖特定子命令,子命令不关心框架如何处理异常。
延伸阅读
- Commander.js 官方文档
- Node.js 退出码规范
- Bash 自动补全编程指南
- Zsh 自动补全系统
如有疑问欢迎留言,下一篇将阐述配置系统的设计与实现。


