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

前言
一个具备仪式感的CLI工具,在执行命令的瞬间就应传递出专业可靠的感觉。这意味着需要一套规范的框架:退出码不能随意使用,帮助信息需要美观,命令结构层次必须清晰。
本文在已搭建的工程基建(tsup + Vitest + TypeScript ESM)基础上,为dsk构建核心骨架,实现以下目标:
- 注册5个子命令:
chat、run、setup、init、completion - 所有命令共享同一套配置加载逻辑
- 退出码保持统一,避免出现
process.exit(0)与process.exit(1)混用 --help信息呈现个性化风格,区别于框架默认样式- 入口能够优雅处理
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执行前被调用,我们利用它实现了一个“配置注入中间件”。
设计思路如下:
DskContext接口作为整个CLI的运行时上下文,后续增加能力(如provider管理器、tool注册表)时,只需在此添加字段。所有命令共享同一数据源。- 配置加载失败不会导致进程崩溃,而是回退到默认配置并通过标准输出提示,而非直接调用
process.exit。 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中即可获得自动补全功能。选择“输出说明”而非直接安装补全脚本的原因:
- 不同操作系统的shell配置路径不同,自动安装容易出错
- 用户自行粘贴一次可了解补全脚本的存放位置
- 保持简单,13行逻辑即可支持bash和zsh两套
Bash补全使用COMP_WORDS和compgen,zsh补全使用_describe,两者覆盖了95%以上的开发者终端场景。
SUBCOMMANDS常量
将子命令定义为一个数组而非到处硬编码字符串,便于bash补全脚本、测试以及后续权限校验引用同一来源。
入口文件:SIGINT与异常规范化
入口文件src/index.ts是用户的第一个接触点,也是异常处理的最后一道防线。该代码处理了四种场景:
- 用户按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是异步的(大概率需要调用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。
跑一下看看效果
在项目根目录执行命令:

运行测试:

文件结构总结
本章新增或修改的文件如下:
做了啥以及没做啥
已完成的工作:
- Commander架构搭建清晰,每个命令职责分明
- 通过preAction hook + 中间件模式实现配置注入对业务逻辑透明
- 退出码集中管理,避免散落各处的
process.exit - 测试覆盖退出码、子命令注册和帮助信息,为重构提供保障
有意延后处理的问题:
dsk chat和dsk run的业务逻辑仍为占位符,等待agent会话循环章节填充- 中间件的配置加载失败仅回退到默认配置,未向用户提供报错提示(下章添加)
- 自定义帮助尚未实现自动化测试,手工验证确认正常(需补充TODO)
职责对比:框架搭建 vs 子命令路由
整篇文章交替进行两项工作,这里明确区分:
框架搭建决定CLI如何规范退出、如何注入配置、呈现何种样式;子命令路由决定CLI能执行哪些功能。两部分虽在同一commit中编写,但职责完全正交:框架不依赖特定子命令,子命令不关心框架的异常处理方式。
延伸阅读
- Commander.js官方文档
- Node.js退出码规范
- Bash自动补全编程指南
- Zsh自动补全系统
本篇完成了CLI框架的搭建和子命令路由设计,使工具骨架稳固,为后续业务逻辑开发奠定了坚实基础。