ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从零打造CLI-Anything:CLI工具模板化与自动生成实践

从零打造CLI-Anything:CLI工具模板化与自动生成实践 在命令行工具这个领域摸爬滚打了这么多年我一直有一个执念能不能把写一个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这层壳命令的解析和帮助信息自动就体面了。有人会问为什么不用 GoGo 的单二进制分发确实香开发效率也很高但交互式问答和模板渲染这块生态还是不如 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-toolgenerators负责编排渲染逻辑比如什么时候问什么问题、调用哪个模板引擎、如何计算目标路径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 gitgithub.com:xxx/xxx.git传进来工具自动拉取模板实际项目里会发现这比本地维护一堆模板灵活太多。我个人在实际操作中的体会是一个真正好用的脚手架它最大的成功不是让用户少敲了多少代码而是让用户建立了一套稳定的工程习惯。CLI-Anything 本身也在做同样的事你第一次用它生成工具时会觉得哇挺快的但真正value在于半年后你再看自己写的代码会发现它还是规规矩矩的没有因为急着上线就变得一团糟。如果你也在做CLI工具相关的项目不妨把这篇里的几个设计原样抄过去你会发现那些曾经让运维和同事抓狂的细节其实早就有解了。如果你在使用这套方案时踩到其他有意思的坑或者想到了更好的设计思路欢迎随时交流毕竟命令行工具这种小东西打磨起来是真有意思。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表