从零开发 Agent CLI(二):CLI 框架搭建与子命令路由

时间:2026-07-04 08:31:42 来源:互联网

一个规范的CLI工具框架,需要从退出码统一、帮助信息优化和命令层次清晰开始构建。上一篇完成了工程基建(tsup + Vitest + TypeScript ESM),本篇文章将为dsk工具搭建完整的骨架结构。

img_6a48546e858c830.webp

前言

一个具备仪式感的CLI工具,在执行命令的瞬间就应传递出专业可靠的感觉。这意味着需要一套规范的框架:退出码不能随意使用,帮助信息需要美观,命令结构层次必须清晰。

本文在已搭建的工程基建(tsup + Vitest + TypeScript ESM)基础上,为dsk构建核心骨架,实现以下目标:

  1. 注册5个子命令:chatrunsetupinitcompletion
  2. 所有命令共享同一套配置加载逻辑
  3. 退出码保持统一,避免出现process.exit(0)process.exit(1)混用
  4. --help信息呈现个性化风格,区别于框架默认样式
  5. 入口能够优雅处理Ctrl+C和commander抛出的异常

我们选择commander库来实现CLI功能。在Node.js生态中,yargs、clack、ink等库各有优势,但commander凭借其简洁、稳定和庞大的社区支持而胜出。


统一退出码

这是一个文件量虽小但值得单独阐述的部分。许多CLI项目会在代码中散布process.exit(1)process.exit(0),长期维护后难以区分每个退出码的含义。

因此,我们首先定义了一组退出码常量:

as const确保TypeScript将值推断为字面量类型,使用如ExitCode.SUCCESS时能被类型检查捕获拼写错误。退出码130遵循Unix惯例(128 + SIGINT信号值2),使shell脚本能够正确判断程序状态。后续引入新错误类型时,只需在此处添加即可。


配置加载中间件

CLI工具启动时最普遍的需求是加载配置。我们需要确保每个子命令在执行前,配置已经被加载并可供使用。Commander提供了hook机制——preAction在每次action执行前被调用,我们利用它实现了一个“配置注入中间件”。

设计思路如下:

  1. DskContext接口作为整个CLI的运行时上下文,后续增加能力(如provider管理器、tool注册表)时,只需在此添加字段。所有命令共享同一数据源。
  2. 配置加载失败不会导致进程崩溃,而是回退到默认配置并通过标准输出提示,而非直接调用process.exit
  3. optsWithGlobals()能够同时获取全局选项和子命令选项,便于后续实现子命令覆盖全局配置的功能。

createCli中注册该中间件时,使用Function.prototype.call保持this指向。Commander的hook回调中thisCommand是被触发的命令实例,通过call将上下文传入,确保中间件函数在正确的this下执行。命令的action中通过this.dskCtx即可获取配置。


自定义帮助信息

Commander默认的--help输出样式较为标准化。我们希望呈现更符合dsk风格的内容:带有颜色、分组清晰并提供示例。

具体实现中,通过暴力覆写commander的helpInformation方法来实现自定义帮助。Commander内部依赖该方法生成帮助文本,直接赋值覆盖是最简洁的方案。

输出效果展示如下:


子命令路由

本节是核心部分,五个子命令各有定位。将src/cli/index.ts改为src/cli/index.tsx(后续需用JSX渲染终端UI),并使用.tsx扩展名让TypeScript正常处理。

几个值得关注的设计点:

exitOverride

这行代码至关重要。Commander默认在--help--version时直接调用process.exit(),但在单元测试中我们不希望真正退出进程。exitOverride()让Commander改为抛出一个CommanderError,从而测试代码可以直接使用rejects.toMatchObject来断言退出码。

TTY检测

dsk chat是一个交互式会话,在管道中运行没有意义(如echo "hello" | dsk chat)。通过检测process.stdin.isTTY提前提示用户,避免进入会话后发现无输出再报错。

completion子命令

该子命令较为特殊,它不调用任何API,仅向终端输出一段shell函数定义。用户将其添加到.bashrc.zshrc中即可获得自动补全功能。选择“输出说明”而非直接安装补全脚本的原因:

  1. 不同操作系统的shell配置路径不同,自动安装容易出错
  2. 用户自行粘贴一次可了解补全脚本的存放位置
  3. 保持简单,13行逻辑即可支持bash和zsh两套

Bash补全使用COMP_WORDScompgen,zsh补全使用_describe,两者覆盖了95%以上的开发者终端场景。

SUBCOMMANDS常量

将子命令定义为一个数组而非到处硬编码字符串,便于bash补全脚本、测试以及后续权限校验引用同一来源。


入口文件:SIGINT与异常规范化

入口文件src/index.ts是用户的第一个接触点,也是异常处理的最后一道防线。该代码处理了四种场景:

  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是异步的(大概率需要调用API),必须使用parseAsync,否则Promise reject会被吞掉。


tsconfig调整

添加.tsx文件后,tsconfig需要同步调整:


测试

测试的关键点在于exitOverride()让Commander抛出异常而非退出进程,测试依赖此行为。测试覆盖了:所有子命令的注册和描述、全局选项、--help/--version的退出码、子命令正常执行、ExitCode常量值,共12个用例。注意最后一个用例:cli.parseAsync(["node", "dsk", "run", "test"])不会抛出异常,因为dsk run的action仅为console.log,未调用process.exit,因此使用resolves而非rejects


跑一下看看效果

在项目根目录执行命令:

img_6a48546e98ac031.webp

运行测试:

img_6a48546ea825532.webp


文件结构总结

本章新增或修改的文件如下:


做了啥以及没做啥

已完成的工作:

  1. Commander架构搭建清晰,每个命令职责分明
  2. 通过preAction hook + 中间件模式实现配置注入对业务逻辑透明
  3. 退出码集中管理,避免散落各处的process.exit
  4. 测试覆盖退出码、子命令注册和帮助信息,为重构提供保障

有意延后处理的问题:

  1. dsk chatdsk run的业务逻辑仍为占位符,等待agent会话循环章节填充
  2. 中间件的配置加载失败仅回退到默认配置,未向用户提供报错提示(下章添加)
  3. 自定义帮助尚未实现自动化测试,手工验证确认正常(需补充TODO)

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

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

框架搭建决定CLI如何规范退出、如何注入配置、呈现何种样式;子命令路由决定CLI能执行哪些功能。两部分虽在同一commit中编写,但职责完全正交:框架不依赖特定子命令,子命令不关心框架的异常处理方式。


延伸阅读

  1. Commander.js官方文档
  2. Node.js退出码规范
  3. Bash自动补全编程指南
  4. Zsh自动补全系统

本篇完成了CLI框架的搭建和子命令路由设计,使工具骨架稳固,为后续业务逻辑开发奠定了坚实基础。