从零开发Agent CLI第二期:框架搭建与子命令路由实战完整指南教程

2026-06-19阅读 0热度 0
搭建

前言

一个 CLI 工具的流畅度,往往在输入第一条命令时就能判断。具备仪式感的工具,退出码规范、帮助信息详细、命令结构层次分明缺一不可。

上一篇已搭建好工程基建(tsup + Vitest + TypeScript ESM),本节将为 dsk 注入命令行骨架。核心目标明确:

  • 注册 5 个子命令:chatrunsetupinitcompletion
  • 所有命令共享同一套配置加载流程
  • 退出码统一管理,避免散落 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_WORDScompgen,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);
}

这段代码处理了三种场景:

  1. 用户按 Ctrl+C → 触发 SIGINT 处理器,退出码 130。注意此处不可硬编码 process.exit(130),应使用 ExitCode.SIGINT
  2. commander 正常退出--help--version 抛出 code === "commander.helpDisplayed""commander.version",捕获后以 SUCCESS 码退出。
  3. 其他 commander 异常 → 如参数解析失败、命令未找到,commander 会抛出带 exitCode 的异常,直接透传此码。
  4. 未知异常 → 打印错误栈,以 ExitCode.GENERAL_ERROR 退出。

另需注意 await program.parseAsync(process.argv)。Commander 提供 parseAsyncparse 两个版本。若 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 chatdsk run 的业务逻辑仍为占位符——等 agent 会话循环章节再填充
  • middleware 配置加载失败仅回退默认配置,未给用户报错提示(下章补充)
  • 自定义 help 尚未编写测试——手工验证无误,但自动化测试确实缺失(TODO +1)

职责对比:框架搭建 vs 子命令路由

整篇文章交替进行两项工作,此处明确区分:

维度框架搭建子命令路由
核心文件src/index.tssrc/cli/exit-codes.tssrc/cli/middleware.tssrc/cli/help.tssrc/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 自动补全系统

如有疑问欢迎留言,下一篇将阐述配置系统的设计与实现。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策