
1. CLI-Anything 到底是什么先弄清楚这个理念在解决什么问题终端党大概都有过这种经历某个操作今天要做一次、明天要做一次、后天还要做一次于是你打开编辑器写了个脚本跑通了就放在角落里。等三个月后再想起来要用脚本还在但你已经忘了它怎么传参、输出格式是什么、能不能中途断掉重跑。重新读一遍代码的功夫比手动做十次还累。CLI-Anything 这个理念说白了就是给这种“临时脚本越来越正式、零散工具越来越通用”的需求找一个标准答案。核心思想是凡是你需要反复执行的、有明确输入和输出的操作都值得被封装成一个设计良好的命令行工具。不管它背后跑的是 Python、Node、Shell 还是编译好的二进制对外呈现的都应该是一套清晰、可预期、可组合的命令行接口。我第一次接触这个概念是在自己写了十几个互不相通的脚本之后。有的是 Bash有的是 Python有的甚至是从同事那儿拷来的 Ruby 脚本。每个脚本都有自己的使用习惯有的看环境变量、有的读配置文件、有的直接在脚本头部硬编码了一堆路径。用起来完全没有统一体验更别说拿到别的机器上复用。后来我意识到问题的根源不在于“脚本写得不好”而在于我从来没按“工具”的标准去设计它们。这个理念能解决的实际问题很具体第一统一入口——所有能力都收敛到一个命令下比如anything build、anything check、anything publish比记住七八个脚本名靠谱得多。第二约定输入输出——参数怎么传、结果怎么显示、错误怎么反馈都有规范可循。第三支持组合——标准命令行工具天然可以用管道、、脚本循环等手段串联这点是 GUI 和 Web 界面永远追不上的。适合读这篇文章的人我认为有两类。一类是已经开始写脚本、但脚本还停留在“自用工具”阶段的开发者你即将遇到的问题我都踩过。另一类是自己所在团队里有一堆内部小工具、想把这些工具整理成体系的技术负责人。这篇文章不会只讲概念会用大量实际代码和命令把整个从“零散脚本”到“标准 CLI”的过程拆开。2. 核心设计思路一个好的命令行工具是怎么拆出来的2.1 先设计命令树再写第一个函数很多人写 CLI 的毛病是装好参数解析库然后从第一行代码开始写逻辑写到哪算哪。这样出来的工具通常只有一两个子命令参数名混乱还会出现--path、--dir、--folder同时存在的情况。正确顺序是先画命令树。所谓命令树就是把你的工具所有能力按“动词-名词”层级组织起来的结构。以我做过的一个 Markdown 批量处理工具为例最外层是工具名mdpress下面有build、preview、validate、clean四个子命令。每个子命令又有自己专属的参数比如build有--output、--template、--minify而clean只有一个--all标志。命令树画好之后代码结构就跟着树长出来了。每个叶子节点对应一个命令处理函数命令处理函数只做三件事校验参数、调用核心逻辑、格式化输出。任何不属于这三件事的操作都从命令行层拿出去。这里有个很常见的误区把参数解析和业务逻辑耦合在一起。我见过有人直接在业务函数里读取process.argv这种写法前期模型简单时没什么但一旦命令数超过三个、参数超过五个就会失控测试也写不了。正确做法是把所有参数的读取、校验、默认值归一集中在命令层业务层接收的是已经解析好的普通函数参数。2.2 参数设计遵循直觉别搞发明创造命令行参数设计是有行业惯例的不需要自己发明。这么多年下来POSIX 和 GNU 已经给出了很好的参考规范实用中也应当遵循几条不成文的原则短标志用于高频、易记的选项比如-ooutput、-fforce、-vverbose。长标志用于完整表达含义比如--output、--force、--verbose。布尔开关只负责“开/关”两种状态凡是需要带值的参数比如输出目录就必须显式声明它接受一个值不能靠猜。命名上多单词参数统一用小写连字符风格--output-dir、--no-cache、--dry-run这是现代命令行工具的主流。--outputDir这种驼峰写法在参数里非常少见除非你用的框架自己内部这么约定。参数解析的具体行为也值得花时间想清楚。以--output为例它后面跟的路径是相对路径还是绝对路径不存在时自动创建还是报错覆盖已有文件前要不要确认这些问题没有标准答案但你的 README 里至少要写清楚工具自己要有明确且一致的行为。2.3 交互式输入能用但别滥用CLI 不等于让人一边运行一边回答问题。真正好用的命令行工具遵循 Unix 哲学一次运行做好一件事所有输入通过参数或标准输入流传入输出写入标准输出流。这样脚本可以自动化可以放进定时任务可以接到流水线里。但这也不绝对。一些交互式命令比如npm init、git commit弹编辑器是有存在理由的——它们的目的是引导用户完成难以用参数表达的复杂初始化过程。权衡标准很简单如果这个交互能被默认值替代就做成非交互如果不能就做成交互。最忌讳的是同时提供这两种模式但在代码里写两套逻辑维护成本会翻倍。我的建议是在命令行层做一个可选的交互模式开关。比如mdpress init默认非交互用--interactive可以进入问答流程。这样自动化场景直接把所有参数写在命令里人类用户可以享受一步一步的引导两边都不吃亏。3. 从零到可用一个 CLI-Anything 工具的完整实现3.1 项目结构与依赖怎么选我选了 Node.js 生态演示因为它是前端和后端工程师都熟悉的环境起步最快。Python 的argparse或 Go 的cobra思路完全一致只是 API 不同理解了下面这套设计换语言只是换工具包而已。先初始化项目mkdir mdpress cd mdpress npm init -y npm install commander chalk promptscommander负责参数解析chalk负责彩色输出prompts负责交互式问答。这三个库加起来体积不大而且都是各自领域事实上的标准库。目录结构遵循命令树的映射mdpress/ bin/ index.js src/ commands/ build.js preview.js validate.js init.js lib/ markdown-renderer.js file-utils.js config.jsbin/index.js只做一件事加载所有命令注册到commander上然后调用parse()。src/commands/下的每个文件对应命令树的一个叶子节点文件命名即命令名。3.2 用 Commander 注册子命令和参数以一个真实可运行的简化版为例下面是build命令的核心代码// src/commands/build.js import { Command } from commander; import { renderMarkdownFile } from ../lib/markdown-renderer.js; import { ensureDir, resolvePath } from ../lib/file-utils.js; export function buildCommand() { const cmd new Command(build) .description(将 Markdown 文件渲染为 HTML) .argument(input, 要处理的 Markdown 文件路径) .option(-o, --output dir, 输出目录, dist) .option(-t, --template file, HTML 模板文件路径) .option(--minify, 压缩输出 HTML) .option(--dry-run, 只打印将执行的步骤不写文件) .action(async (input, options) { const inputPath resolvePath(input); const outputDir resolvePath(options.output); if (options.dryRun) { console.log([dry-run] 渲染 ${inputPath} - ${outputDir}); return; } ensureDir(outputDir); await renderMarkdownFile(inputPath, outputDir, { template: options.template, minify: options.minify, }); }); return cmd; }注意几个细节。第一位置参数input用尖括号表达“必填”如果用户没传commander会自动报错并显示用法说明不需要手动判断。第二-o, --output dir里的dir表示这个参数必须带值而且默认值是dist。第三--dry-run是个布尔标志它不存在“需要传值”的情况。主入口文件的注册// bin/index.js #!/usr/bin/env node import { Command } from commander; import { buildCommand } from ../src/commands/build.js; import { previewCommand } from ../src/commands/preview.js; import { validateCommand } from ../src/commands/validate.js; import { initCommand } from ../src/commands/init.js; const program new Command(); program .name(mdpress) .description(Markdown 处理命令行工具) .version(1.0.0); program.addCommand(buildCommand()); program.addCommand(previewCommand()); program.addCommand(validateCommand()); program.addCommand(initCommand()); program.parse();#!/usr/bin/env node是让她能在终端直接执行的关键。接着在package.json里加{ bin: { mdpress: ./bin/index.js } }然后npm link就可以在任意终端直接运行mdpress了。3.3 输入输出规范stdout、stderr 与退出码这部分是最容易被新手忽略、但恰恰是 CLI 工具专业度的分水岭。一个规范的命令行工具必须分清正常输出走 stdout警告和错误走 stderr出错时返回非零退出码。原因很实际。如果你把警告信息也打到 stdout那么别人拿你的工具去接管道时mdpress build | grep title会把警告和真正结果混在一起整条链路就废了。用 Node 实现很简单// 正常运行结果 console.log(已生成 ${fileCount} 个 HTML 文件); // 警告信息 console.warn(警告: 模板 ${templatePath} 不存在使用默认模板); // 或者直接用 process.stderr.write() // 致命错误 console.error(错误: 无法读取文件 ${inputPath}); process.exit(1);退出码的约定一般0代表成功非零代表失败。如果工具内部可能遇到多种错误可以用不同退出码区分。比如1表示文件处理失败2表示参数错误3表示配置缺失。定义清楚之后外面写脚本就能用if mdpress build; then或$?去做条件判断。3.4 彩色输出体验好但得有分寸终端彩色输出能让信息层级清晰很多比如文件名用青色、路径用黄色、错误用红色。chalk可以非常简单地实现import chalk from chalk; console.log(chalk.green(✔ 成功渲染 ${inputPath})); console.log(chalk.yellow(→ 输出到 ${outputDir})); console.error(chalk.red(✘ 找不到文件 ${inputPath}));但这里有个大坑彩色输出在非交互终端、CI 环境或管道重定向时会变成乱码。因为颜色本质是向终端发送转义序列不是所有环境都支持。正确做法是检测环境再决定要不要上色。chalk提供的一个好帮手是chalk.level你可以根据环境手动降级# 强制无颜色 NO_COLOR1 mdpress build # 强制有颜色 FORCE_COLOR1 mdpress build更彻底的做法是统一用supports-color这个库检测当前终端是否支持颜色不支持就关闭所有彩色输出。这个细节会让你的工具有一种“老练”的感觉——它不会打扰用户也不会在重定向日志里留下[31m这样的转义序列垃圾。3.5 配置持久化别让用户每次敲一长串参数命令行参数用得越久越会发现有些选项是“这台机器上永远不变”的。比如团队内部规定 HTML 输出目录固定是build/html模板路径固定是团队共享的某个文件。如果每次都敲-t /path/to/team-template.html --output build/html用户迟早会放弃你的工具。规范做法是引入配置文件。可以是一个约定命名的文件比如mdpress.config.json工具启动时自动读取。也可以同时读取用户级配置~/.config/mdpress.json和项目级配置./.mdpressrc后者覆盖前者。设计优先级为命令行参数 项目级配置 用户级配置 默认值。代码实现也很直白function mergeConfig(cliOptions) { const userConfig loadJsonIfExists(~/.config/mdpress.json); const projectConfig loadJsonIfExists(.mdpressrc); return { // 默认值 template: default.html, minify: false, // 用户级配置覆盖默认值 ...userConfig, // 项目级配置覆盖用户级配置 ...projectConfig, // 命令行参数优先级最高 ...cliOptions, }; }注意...展开的顺序从低优先级到高优先级最后一次展开的值会覆盖之前同名键。这是配置合并的核心技巧。4. 实操心得与常见问题排查实录4.1 最常见的坑路径处理与参数边界第一个大坑是路径分隔符。我早期写的很多脚本在 mac 上运行完美放到 Windows 上就崩溃。原因是写死了/拼接路径。跨平台的正确做法是永远用path.join和path.resolve它们会根据当前系统自动选择正确的分隔符。第二个大坑是参数里带空格。用户在 shell 里敲mdpress build My Document.md时如果内部用字符串拼接再传给别的东西就极其容易出问题。正确做法是把路径作为数组元素传给函数绝不自己拼 shell 命令。如果实在需要调用外部命令用child_process.execFile而不是execexecFile不经过 shell天然规避注入问题。第三个大坑是空目录与空文件。ensureDir之后马上写入这在绝大多数环境没问题但在某些网络文件系统上目录创建到生效可能有延迟最好在写入前做一次显式的access检查或干脆用mkdir的 recursive 模式并在创建后立即确认。4.2 调试技巧给用户留一盏灯CLI 工具最怕的是用户报了个错但你没留下任何线索定位问题。我的做法有三个第一全局增加--verbose标志。默认只输出必要信息打开后把每一步的输入参数、中间结果、耗时都打出来。program.option(-v, --verbose, 输出调试信息); // 在业务代码中 function log(level, message) { if (level debug !program.opts().verbose) return; console.error([${level}] ${message}); }第二用环境变量控制更底层的调试。比如DEBUGmdpress:* node bin/index.js可以启动 Node 的调试日志。这样线上用户不会背一堆输出轰炸真出问题又能解开谜团。第三写测试。很多人觉得 CLI 不好测试其实只要把业务逻辑拆成独立函数测试根本不难。用node:test或者vitest把纯函数测好命令行层只测参数解析和命令分发。4.3 常见问题速查表现象原因解决方案命令运行后没有输出输出写到了 stdout但被调用方只看了 stderr检查管道重定向方向确认是21还是12参数解析顺序错乱子命令和全局选项混用全局选项放program上子命令专属选项放子命令上颜色转义符出现在日志文件非 TTY 环境没关颜色用supports-color检测或支持NO_COLOR环境变量目录创建时报 EEXIST并发执行时目录已被其他人创建mkdir加{ recursive: true }忽略 EEXIST 错误退出码永远为 0没显式处理错误main().catch(err { console.error(err); process.exit(1); })用户说“命令找不到”npm link 失效或 PATH 未设置确认package.json的 bin 字段重新npm link4.4 实战中我拿到的教训有一次我在一个 CI 流水线里遇到诡异问题同一个命令在本地跑得好好的到 Jenkins 上就报“找不到模板文件”。排查了很久才发现Jenkins 执行环境的工作目录和本地完全不同我代码里用了相对路径但假设了工作目录固定。从此之后我的所有工具都接受一个显式参数指定工作目录或者用配置文件里的绝对路径做兜底。另一个教训是不要在工具里“安静失败”。早期版本里如果某个文件渲染失败我会console.warn一下然后继续处理剩下的文件。结果用户以为全部成功根本没人注意到警告。后来改成如果存在失败文件处理完其他文件后最后统一列出失败清单并返回非零退出码。这才算真正暴露了问题而不是把问题藏起来。5. 进阶玩法把 CLI-Anything 用出花来5.1 Shell 自动补全降低使用门槛的关键一步一个命令行工具如果没法用 Tab 补全每次要敲一长串子命令和参数用户基本上用两次就不想用了。commander原生支持生成补全脚本在安装时自动配好。文档里写得很简单但实际操作有几个坑。以 Bash 为例commander提供了program.completion(mdpress)方法生成补全脚本。但这里的坑在于补全脚本是在运行时读取工具帮助信息来动态生成的所以工具的帮助信息必须准确完整。如果你的--help输出里子命令描述含糊补全提示也就没什么价值。为了补全功能顺利工作注意子命令里不要用.alias(m)这样的短别名做主要入口补全是按照描述文本解析的别名处理容易出线。另外有些 shell 需要安装bash-completion包才能让动态补全生效这一步经常被跳过。5.2 管道与组合让工具融入更大的链条CLI 工具最强的优势就是可组合性。设计时多考虑一步工具就能从“单机可用”升级为“链条可用”。首先工具输出应该支持被其他程序重读。比如mdpress的validate子命令我设计它的输出格式是“一行一个检测结果”而不是人类阅读的表格。这样mdpress validate | grep error就能快速筛选出错误项。如果输出格式太多样管道就废了。其次给工具加上 stdin 输入的能力。规则很简单如果用户传了文件路径就处理文件如果没传路径但 stdin 不是 TTY终端就读取 stdin。这符合 Unix 工具的基本直觉if (!input !process.stdin.isTTY) { const content fs.readFileSync(0, utf-8); // fd 0 是 stdin // 处理 content }这样一来cat README.md | mdpress validatestdin也能工作工具的自然应用面大幅扩展。5.3 插件化设计核心做小能力开放CLI-Anything 的“Anything”最终体现就是插件化。一个工具不可能替你覆盖所有场景但可以由你留出接口让别人或未来的你低成本扩展。插件化设计最简单可行的模式是两个入口一是插件目录扫描工具启动时扫描某个固定目录下的所有.js文件每个文件导出注册函数动态添加到命令树里二是通过 npm 包名约定比如mdpress-plugin-*用户装了什么插件工具就自动加载什么。实现一个轻量插件系统并不复杂// src/lib/plugin-loader.js import { readdir } from fs/promises; import path from path; export async function loadPlugins(program) { const pluginDir process.env.MDPRESS_PLUGIN_DIR || ./plugins; try { const files await readdir(pluginDir); for (const file of files.filter(f f.endsWith(.js))) { const mod await import(path.join(pluginDir, file)); if (typeof mod.default function) mod.default(program); } } catch (err) { // 插件目录不存在就跳过不阻塞主流程 } }任何插件只要导出一个接收program的函数就能往主命令上挂新子命令。这让工具的边界变得非常开放。5.4 与更大型的工具链结合最后聊聊成熟的 CLI 工具如何嵌入团队工作流。我自己的项目现在做了三件事一是把mdpress build绑定到 npm 脚本或 CI 流程中。GitLab CI 的before_script里先npm install -g mdpress然后用--config指定项目配置。这让文档发布流程在所有人电脑上表现一致。二是用 shell 脚本做组合编排。一个publish.sh依次执行 markdown 渲染、图片压缩、代码库提交、远程部署任何一步失败都立即停止并打出清晰的错误日志。三是写了一个编辑器插件它的实现不是直接调用 GUI 面板而是用child_process调mdpress的 API。这样内置功能越来越复杂的工具在多个场景下都能保持行为一致。关于 CLI-Anything 最后想说的我写这篇内容时特意把“命令树设计”“参数规范”“输出规范”“配置优先级”“插件化”这几个点展开讲是因为我自己就是从“脚本乱堆”走过来的。那种早期放任自由写脚本的爽感最终都要用一段长达几周的恶补来偿还。与其等到脚本数量失控再去统一不如从第一个工具开始就按这套思路设计。如果你手头已经有散落的脚本也不用立刻推翻重写。先挑一个最常用的把它包装成 CLI-Anything 风格的工具把命令树、参数、配置规范立起来。用过两周你会发现那种“所有操作都有章法、还能被流水线复用”的感觉真的很上头。