在命令行工具这个领域摸爬滚打了这么多年,我一直有一个执念:能不能把"写一个CLI工具"这件事本身也给工程化、模板化。正好最近在整理自己的脚手架方案,命名就叫 CLI-Anything,目标是"给我一个命令名和几个核心参数,剩下的代码结构、参数解析、帮助文档、日志输出、安装打包全自动生成"。这篇文章就把这套方案从思路到落地完整拆一遍,包括我踩过的坑和最终的推荐配置,如果你也经常写小工具或者想让团队统一CLI开发规范,这篇文章应该能给你不少直接能抄的东西。
1. 项目整体设计与需求拆解
1.1 为什么还需要一个"CLI生成器"
很多人第一反应是:写命令行为什么不用 Commander、Click、Argparse 这些现成库?我承认,这些库确实把参数解析、子命令分发这些问题解决得非常好,但"解决参数解析"和"帮你把整个项目骨架搭好"是两码事。一个真正要交付给别人用的CLI工具,除了解析参数之外,至少还牵扯到工程初始化、配置文件读写、日志分级、错误处理、单元测试、打包分发、自动补全脚本、README文档维护。这些工作每次写新工具都要重新做一遍,而且不同工具之间风格还不统一。
CLI-Anything 的思路很简单:它不是一个库,而是一个"脚手架生成器 + 工程约定"的组合。你只需要回答几个交互式问题,比如工具叫什么名字、想用什么语言实现、需要哪些子命令、要不要支持配置文件,它就能在一秒钟之内拉出一个完整的、可以直接开发甚至直接发布的 CLI 项目骨架。后面你再往里面填业务逻辑就行。
这解决了两类痛点。对个人开发者来说,省掉了大量重复的初始化工作,昨天写一个日志分析工具,今天写一个代码格式化工具,项目结构完全一致,不用重新适应;对团队来说,统一的骨架意味着统一的代码风格、统一的文档模板、统一的发布流程,新成员上手成本大幅降低。我自己最早写这个方案的动机,就是受不了组里每个工具长得都不一样。
1.2 核心需求梳理
把需求拆开来看,CLI-Anything 要解决的核心问题其实可以归纳成下面几条:
第一,交互引导。用户不应该去翻文档记参数,而是在终端里通过清晰的问答确认需求。类似npx create-react-app那种交互体验,选语言、选框架、选功能模块。
第二,模板渲染。根据用户的选项,把预先设计好的项目模板渲染成目标目录。这里要注意的是,模板不是简单复制,必须支持动态插入变量,比如项目名、包名、类名前缀这些。
第三,开箱即用的工程化能力。生成出来的项目必须自带日志、配置管理、测试框架、Lint 规则、CI 配置,不能是空壳子。用户拿到就能写业务,写完就能跑测试,跑完就能发版。
第四,跨平台兼容。生成的工具必须同时支持 Windows CMD、PowerShell、macOS 和 Linux 主流的 Bash/Zsh,甚至要考虑 Git Bash、WSL 这些特殊环境。
第五,可扩展的模板仓库。用户能在自己的项目里维护额外的模板,CLI-Anything 支持加载本地或者远程的模板源。
顺着这条线往下想,你会发现自己需要的不是一行hello world,而是一整个体系。好在现在的生态已经很成熟了,真正要自己手写的代码并没有想象中那么多。
2. 技术选型的逻辑与核心原理
2.1 运行时与语言:为什么优先 Node.js
语言选型是整个方案的基石。CLI-Anything 的模板支持多语言输出,也就是它可以用 JavaScript、Python、Go 这些不同语言去生成目标CLI工具,但脚手架本身我建议用 Node.js 来实现。理由是它在这三个层面都有优势:
- 交互能力:
prompts或inquirer这两套库把命令行交互做到了极致,单选框、多选框、自动补全、模糊匹配都是现成的,Python 那边虽然有questionary,但成熟度和生态还是差一些。 - 模板生态:Node 生态有非常成熟的模板引擎,比如
ejs,既可以处理纯文本模板,又不会像 Jinja2 那样在复杂逻辑上绕来绕去。 - 分发简单:用 Node 写的 CLI 可以直接通过
npm install -g分发,配合pkg还能打成单文件二进制,甚至能跨平台交叉编译。加上oclif或者commander这层壳,命令的解析和帮助信息自动就体面了。
有人会问为什么不用 Go?Go 的单二进制分发确实香,开发效率也很高,但交互式问答和模板渲染这块生态还是不如 Node 丰富。更重要的是,CLI-Anything 的定位是"快速生成多语言项目",脚手架本身的开发速度和迭代效率权重更高,所以 Node 反而更适合。
2.2 参数解析与命令组织:Commander 是底线
无论生成的工具最终用了什么语言,脚手架自身以及推荐的模板都必须有严谨的参数解析层。我在 CLI-Anything 的模板体系里默认使用 Commander,用它的原因有三个:
- 子命令嵌套:
tool config set key value这种三层甚至四层命令结构,Commander 原生支持,而且每个子命令可以单独定义参数。 - 自动生成帮助:根据命令定义自动生成
--help输出,格式专业且符合主流习惯。这个对工具的使用体验影响极大,很多人低估了帮助文档的重要性。 - Option 的类型处理:
--port <number>会自动帮你转成数字,--force自动变成布尔值,--config <path>还能直接搭配fs.existsSync做存在性校验。
如果你选择用 Python 模板,那对应位置我推荐 Click,理由也类似,装饰器风格接近 "所见即所得",参数校验和能力扩展都很顺手。但核心思路一致:解析层和业务逻辑必须分离。
2.3 模板引擎与文件渲染机制
模板引擎这块,我最终定的是 EJS。为什么不用 Mustache?因为 Mustache 的无逻辑原则在简单场景下很优雅,但一旦需要根据选项去决定渲染哪些文件,生搬硬套就会出现各种 hack。EJS 允许在模板里写少量逻辑判断,比如:
<% if (withDocker) { %> FROM node:20-alpine ... <% } %>这样同一套模板就能按需生成不同的文件组合。要注意的是,模板里写逻辑必须克制,只允许做条件判断和循环,绝对不允许出现复杂计算的逻辑,否则模板会变成一团乱麻。
文件渲染这块还有一个关键点:点文件的处理。.gitignore、.npmrc、.env.example这类以点开头的文件在模板目录里通常直接写成.gitignore.ejs,渲染的时候把.ejs后缀去掉,再确保名字以.开头。这里容易踩坑,如果你在 Windows 上开发,文件管理器对点文件的支持很糟,但模板系统内部不受这个影响。
2.4 交互设计:怎么问问题也很讲究
交互式问答不是把问题一股脑抛给用户就完事了,顺序和默认值都会影响体验。CLI-Anything 的经验是:
- 先问"你要生成什么语言的工程",这是决定后续问题集合的关键分支。
- 再问"工具的名字是什么",这个名字要同时用于包名、命令名和目录名,所以要做合法性校验,不允许大写字母、不允许以数字开头、不允许空格。
- 接着问功能模块勾选,比如"是否包含配置文件支持""是否包含日志系统""是否内置自动更新",这里是多选,默认全选,用户可以直接回车跳过。
- 最后确认一次总览信息,展示即将生成的目录结构和关键配置,确认后开始渲染。
另外要提供一个--non-interactive模式,也就是所有参数都通过命令行传入,便于在 CI 环境里自动化生成项目。这个能力看起来不起眼,但实际应用价值很高,很多开发者用脚手架搭项目就是在自动化流程里完成的。
3. 实操过程:从初始化到完整CLI工具
3.1 搭建脚手架本体
整个项目结构分三层:commands、generators、templates。commands负责处理用户输入的命令,比如cli-anything create my-tool;generators负责编排渲染逻辑,比如什么时候问什么问题、调用哪个模板引擎、如何计算目标路径;templates就是一堆 EJS 模板文件。
第一版脚手架的核心命令就这么几句话:
cli-anything create <project-name> [--language node|python|go] [--template basic|advanced] [--force] cli-anything list # 列出所有可用模板 cli-anything init # 在当前目录生成配置文件 cli-anything doctor # 检查本机环境是否满足模板要求create命令先解析参数,如果--language没传就进入交互式问答,然后把回答收集成一个 config 对象,交给 Generator。Generator 内部根据 language 和 template 两个字段定位到templates/node/basic/目录,用匹配的规则遍历所有文件,逐个渲染再写入目标位置。
3.2 生成一个 Node.js 版 CLI 工具
假设我们要生成一个名为my-cli的工具,选择 Node.js 和基础模板,生成出来的目录结构大概是这样的:
my-cli/ ├── bin/ │ └── index.js ├── src/ │ ├── commands/ │ │ ├── init.js │ │ └── list.js │ ├── utils/ │ │ ├── logger.js │ │ └── config.js │ ├── index.js ├── test/ │ └── commands.test.js ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── package.json ├── README.md └── LICENSEpackage.json是最关键的模板文件。它需要在渲染时动态设置name、version、description、bin字段,然后通过ejs插入用户配置。模板里的关键部分长这样:
{ "name": "<%= projectName %>", "version": "0.1.0", "description": "<%= description %>", "bin": { "<%= commandName %>": "bin/index.js" }, "scripts": { "test": "node --test", "lint": "eslint .", "prepublishOnly": "npm run test && npm run lint" }, "dependencies": { "commander": "^11.0.0" } }bin/index.js里就是标准的 Commander 入口:
#!/usr/bin/env node const { Command } = require('commander'); const program = new Command(); program .name('<%= commandName %>') .description('<%= description %>') .version('<%= version %>'); program .command('init') .description('initialize config file') .option('-f, --force', 'overwrite existing config') .action((options) => { // 具体业务逻辑 }); program.parse(process.argv);这里有一个关键技巧:shebang 行必须是文件的第一行,前面不能有任何内容,包括 BOM 头。如果你在 Windows 上用某些编辑器保存了带 BOM 的 UTF-8 文件,bin/index.js执行时会直接报 "No such file or directory",因为系统试图把一个不可见字符当作解释器路径。我建议所有模板文件保存时统一使用 UTF-8 无 BOM。
3.3 初始化 Git 仓库与配置文件
脚手架在渲染完文件之后,还会做几件收尾工作:自动执行git init、根据模板里预设的.gitignore规则做一次git status检查、尝试安装依赖。这个"尝试"很微妙,因为用户可能根本不想现在就npm install,所以默认策略是只生成命令提示,把决定权交给用户,只有在--install参数被显式传入时才真的执行安装。
配置文件这块,生成出来的工具默认支持三层配置合并:默认值 < 用户配置文件 < 环境变量 < 命令行参数。这个优先级顺序非常重要。我在最初设计时把环境变量和命令行参数的优先级放反了,结果生产环境里出现过一次"明明命令行传了正确参数,却被环境变量里的旧值覆盖"的事故。自那以后这个优先级顺序就成了模板里的固定约定,没有特殊情况不允许改动。
具体到代码实现,config.js大概是这样的:
function loadConfig(overrides = {}) { const defaults = { host: '127.0.0.1', port: 3000 }; const fileConfig = readConfigFile(); // 读取用户配置文件 const envConfig = { host: process.env.MY_CLI_HOST, port: process.env.MY_CLI_PORT ? Number(process.env.MY_CLI_PORT) : undefined }; return { ...defaults, ...fileConfig, ...omitUndefined(envConfig), ...overrides }; }这里omitUndefined是我自己加的,因为process.env.XXX在变量不存在时返回undefined,直接...envConfig会把默认值覆盖掉。教训就是:环境变量的合并要格外小心 undefined 语义,你要区分"没设置"和"设置为空字符串"。
3.4 日志系统与错误处理
一个CLI工具如果出错时只知道打印一行红色文字然后退出,那不是一个合格的工程产品。CLI-Anything 的模板里内置了一套分级日志系统:debug、info、warn、error四种级别。开发调试时通过--verbose开启 debug 输出,默认情况下只展示 info 和更高级别的信息。
错误处理方面有几个约定:
- 业务错误:比如配置文件不存在、网络请求失败,不打印堆栈,只打印友好提示并给出修复建议。
- 参数错误:Commander 的默认行为是打印帮助信息并退出,但我觉得默认帮助信息不够友好,所以模板里会重写这个过程,错误类型不同展示的信息也不同。
- 未预期错误:这类错误一定要打印堆栈,而且还要附带一个包含工具版本号和 Node 版本号的诊断信息,方便用户提交 issue 时直接复制。
还有退出的状态码也需要留心。成功的命令退出码是 0,业务错误是 1,而参数错误应该用 2。很多脚本科自动化时会对退出码做判断,状态码混乱会让集成工作非常痛苦。
3.5 打包与分发:pkg 和自动补全
CLI-Anything 支持的 Node 模板里预置了两个高级能力:打包成单文件二进制和生成 shell 自动补全脚本。
单文件二进制用pkg实现,配置在package.json里:
"pkg": { "targets": ["node18-linux-x64", "node18-macos-x64", "node18-win-x64"], "outputPath": "dist" }自动补全脚本这边,Commander 原生支持生成补全,只需要在工具里加一个completion命令:
my-cli completion bash # 生成 bash 补全 my-cli completion zsh # 生成 zsh 补全生成的脚本需要让用户source进去,但更专业的做法是引导用户写进自己的.bashrc或.zshrc。模板里的 README 都按操作系统分好了章节,照着复制粘贴即可。
4. 实践中的常见问题与排查速查
4.1 参数冲突与命名陷阱
最典型的问题之一:为子命令定义了一个-f, --force,同时又在大命令上定义了-f, --format,这时my-cli -f到底是谁的?Commander 处理这个的方式是就近匹配,子命令优先,但用户在直觉上会认为是全局的。这是一个非常容易引发真实事故的设计陷阱。
我的建议是:所有全局参数放在根命令上并确保名字在子命令中不重复,或者干脆放弃全局参数,每个子命令单独定义。如果你非要保留全局 option,在子命令执行时通过program.opts()和command.opts()分开取,并明确在文档里写清楚。
4.2 Windows 环境下脚本无法执行
生成出来的工具的bin文件在 Linux 和 macOS 上可以直接运行,但在 Windows 上如果你的package.json里没有用到.cmd桥接,会遇到执行策略导致的失败。解决路径是:npm 在安装全局包时会自动生成.cmd包装,但前提是你的 bin 路径不能指向一个目录,而必须是文件。
另外一个很隐蔽的是换行符问题。模板文件在 Windows 上被检出为 CRLF 后,shebang 会变成#!/usr/bin/env node\r,在 Linux 上就会报错说找不到/usr/bin/env的变体。解决办法是.gitattributes里强制规定文本文件的换行格式,或者打包发布时统一用 LF。GitHub Actions 里跑测试时最容易暴露这个问题。
4.3 npm 包体积膨胀
CLI 工具的依赖树往往会出乎意料地大。你以为只装了commander一个包,但npm ls一看,连带依赖可能超过数百个模块。这带来的直接问题是安装慢、磁盘占用大,如果做单文件二进制打包还容易触发 pkg 的解析错误,因为它需要静态分析每个依赖的入口文件。
一个提升体验的做法是尽量选择零依赖或者依赖较少的库。比如日志输出完全可以不引入chalk,用 ANSI 转义序列几行代码就搞定了,虽然便利性差一些,但响应速度和体积都是肉眼可见的好处。如果你确实要用颜色库,推荐只在本地开发时启用,发布版的工具不要强制依赖。
4.4 模板渲染精度问题
EJS 在渲染代码文件的时候,可能会因为模板中的<%或<%=代码痕迹没有正确转义而产生错位。比如你要生成一个 Vue 模板文件,而 Vue 的模板语法里也有<%相关的表达式,这就冲突了。解决方式是把 EJS 的分隔符改成其他组合,比如[[ ]]。这个设置在引擎初始化时一次性搞定:
const ejs = require('ejs'); ejs.delimiter = '?'; // 改用 <?= ... ?> // 或者 ejs.openDelimiter = '[', ejs.closeDelimiter = ']';更稳妥的策略是:模板文件不直接包含目标框架的模板语法,遇到这种情况先在代码里用Raw String保存,再通过JSON.stringify转义后注入。
4.5 测试覆盖的盲区
CLI 工具的测试和普通 Web 项目差别很大。你很难用jest去 mock process.argv,因为每个子命令执行后都会调用process.exit。推荐的做法是把"解析参数"和"执行业务逻辑"彻底分离,用依赖注入的方式组织代码:
// 可测试部分 async function run(config) { /* 纯逻辑 */ } // 入口部分 program.action((options) => { const config = loadConfig(options); run(config).catch(handleError); });这样单元测试只需要大量测试run函数和各种配置组合,而入口部分留给集成测试去覆盖。模板里默认带的就是这种结构,这个习惯我一直沿用到了所有项目里,确实能省掉很多和进程生命周期纠缠的测试烦恼。
4.6 常见问题速查表
| 症状 | 可能原因 | 快速排查手段 |
|---|---|---|
| 命令输完了没反应 | 参数解析器没有正确挂载或者 action 没定义 | 执行my-cli --help,看子命令是否列出 |
提示EACCES权限错误 | 全局安装目录没有写权限 | 用sudo npm install -g或者配置 npm 全局目录 |
| 配置文件改了不生效 | 配置文件路径解析错误,或者环境变量覆盖了 | 执行my-cli config get看当前实际值 |
| 中文字符乱码 | 终端编码和生成文件的编码不一致 | 在工具入口强制process.stdout.write用 UTF-8 |
--verbose不输出debug日志 | 日志级别在配置解析之前就被写死了 | 在日志初始化代码里重新读取参数设置级别 |
| 二进制包在macOS上被Quarantine拦截 | 未签名应用被系统隔离 | 执行xattr -dr com.apple.quarantine <file> |
| 自动补全找不到命令 | 补全脚本没有重新生成,命令名变更后忘记刷新 | 运行my-cli completion bash > /usr/local/etc/bash_completion.d/my-cli |
这个表不是完整手册,但它覆盖了我自己实际项目中踩过的八成问题。遇到新问题的时候,建议先把工具自身带的--debug输出完整复制一份,再对照排查,基本能定位到具体模块。
5. 实操心得:几个值得坚持的工程习惯
5.1 每个工具都要有"干跑模式"
CLI-Anything 生成的每个项目都内置了一个--dry-run选项。它的作用是把命令执行后会产生的文件变更、配置写入、网络请求全部模拟输出一遍,但不真正执行。这个习惯源于一次事故:我曾经在生产环境误执行了一个清理命令,命令行参数漏传了--force校验,直接删掉了一部分缓存目录。自那以后我给所有CLI工具都加了干跑模式,并且约定线上危险操作必须先干跑再加--yes确认。
干跑模式的实现并不复杂,核心是把"副作用操作"封装成队列,在干跑模式下只打印队列内容,不执行。模板里预置了一个executor.js模块专门管理这件事,后面所有工具都能复用。
5.2 配置项的"可发现性"设计
一个好的 CLI 工具不只是"能用",还要让用户能自己摸索出全部能力。CLI-Anything 的模板里,my-cli config list会把所有配置项连同默认值一起列出来;my-cli config explain <key>会打印该项的完整文档。这个设计让用户不再需要频繁翻 README,工具的自主性明显增强。
如果你开发的工具配置项非常多,强烈建议设计这个机制,因为绝大多数用户不会主动去看文档。
5.3 模板的版本管理与演进
模板本身也需要版本管理,不能永远停留在第一版。CLI-Anything 的模板目录里有一个meta.json,记录了模板版本、最低运行时版本、适用平台等信息。升级到新模板时,脚手架比较当前版本和目标版本的差异,让用户选择是强制升级还是保留本地修改。
这里面有个重要的细节:用户在生成之后可能修改过模板文件,直接覆盖会毁掉他的改动。所以标记好哪些文件是"生成后可自由修改"的(比如业务代码),哪些是"升级时会覆盖"的(比如构建配置),这一点必须在文档里写清楚。我在第一版里没做区分,结果一个朋友升级模板后,他自定义的命令逻辑全被覆盖了,那个场面相当尴尬。
5.4 让工具自己诊断环境
CLI-Anything 里有个doctor命令,它检测当前机器的 Node 版本、npm 源配置、全局目录权限、是否安装 git、是否配置了 SSH key 等环境信息,然后输出一张诊断表,标记出哪些项可能带来问题。这个设计也被模板继承了,生成的每个工具都有my-cli doctor,它比让用户手动贴一堆报错信息要高效得多。
实际的开发里,很多环境问题找过来的时候,原因大同小异,无非是版本太低、路径不对、权限不足。doctor命令把检查项集中起来,让用户在反馈 issue 时顺手贴一份诊断结果,效率翻倍。
6. 后续还能怎么扩展
这套方案的可扩展面其实比我一开始预想的要大。目前已经有人在模板库里加了 Rust 和 C# 的 CLI 模板,还有人把pipx和brew的分发配置也塞进了模板里,这样一来生成的工具几乎适配所有主流的安装渠道。
我自己在规划的下一个能力是"Docker 内开发环境模板",也就是生成的工具默认带一个devcontainer.json,配合 GitHub Codespaces 直接用。这个对团队协作的吸引力挺大的,因为新成员再也不用花半天时间搭本地开发环境了。
另外还有一个想法是把模板仓库做成远程加载模式,用户直接把--template git@github.com:xxx/xxx.git传进来,工具自动拉取模板,实际项目里会发现这比本地维护一堆模板灵活太多。
我个人在实际操作中的体会是:一个真正好用的脚手架,它最大的成功不是让用户少敲了多少代码,而是让用户建立了一套稳定的工程习惯。CLI-Anything 本身也在做同样的事,你第一次用它生成工具时会觉得"哇,挺快的",但真正value在于半年后你再看自己写的代码,会发现它还是规规矩矩的,没有因为急着上线就变得一团糟。如果你也在做CLI工具相关的项目,不妨把这篇里的几个设计原样抄过去,你会发现那些曾经让运维和同事抓狂的细节,其实早就有解了。
如果你在使用这套方案时踩到其他有意思的坑,或者想到了更好的设计思路,欢迎随时交流,毕竟命令行工具这种小东西,打磨起来是真有意思。