做完一个叫 CLI-Anything 的小项目之后我最大的感受是命令行工具原来可以不用一个个硬编码而是“描述出来”的。CLI-Anything 的定位一句话就能说清——你给它一份 JSON 或 YAML 描述文件它就把里边的命令、参数、选项、执行逻辑全部变成一套可交互、带校验、有进度反馈的完整命令行工具。这个思路解决了我最头疼的问题团队里散落十几个脚本入口五花八门参数全靠猜新人来了完全不敢碰。如果你也经常在终端里跑重复工作流或者维护着一堆零散脚本这篇文章会讲清楚 CLI-Anything 的核心设计、手写实现思路以及我在真实项目里踩过的坑希望能让你少走一段弯路。1. 项目定位CLI-Anything 到底在解决什么问题1.1 脚本泛滥与“CLI 三难”我见过太多这样的项目根目录下一堆build.sh、deploy.py、batch_process.js每个人写的入口都不一样。有的脚本接受环境变量有的靠修改文件头部常量有的干脆就是“直接改最后三行”。你问这个脚本怎么用写的人自己都要翻半天代码才能想起来参数是--input还是-i输出写到哪儿去了。这种混乱状态背后其实就三个问题我叫它“CLI 三难”。第一是参数难传脚本的参数校验完全随缘传错了不会报错只会产生一个稀里糊涂的结果。第二是帮助难查没有--help没有补全使用者只能靠猜和翻源码。第三是行为难以组合脚本之间不能通过管道传递结果更没法在 CI 里稳定调用因为退出码有时是 0有时是 1全看心情。CLI-Anything 的出发点就是把这“三难”一次性解决。它的思路不是写一个新的命令行框架让你从头撸代码而是把命令的定义和执行彻底分离——你用一份配置文件描述“这个命令叫什么、接收什么参数、执行哪段逻辑”运行时负责解析、校验、调用和输出。这样一来散落的脚本逻辑可以原封不动地变成一个个标准命令但使用体验完全不同了。1.2 为什么用描述文件驱动而不是传统硬编码有人可能会问Node 生态有 commander、Python 有 click、Go 有 cobra这些成熟框架不好吗它们当然好我自己也常用。但当你同时维护十几个脚本且这些脚本的语言还不一样时问题就变成了“每个脚本都要单独写一套 CLI 壳”。用 commander 写一个命令至少得写program.command(...).option(...).action(...)这一套代码量不大但量变引起质变而且每个脚本的写法还不统一。CLI-Anything 选择“描述文件驱动”的核心原因是配置是数据而数据可以被多种工具消费。同一个cli-anything.json既能生成命令行入口又能被 CI 用来检查帮助文本还能自动生成 README 的命令列表。这就把“写 CLI 壳”这件事从写代码降维成了写配置任何会 JSON 语法的人都能维护。当然这个取舍不是没有代价。描述驱动的表达能力有限复杂交互逻辑还是得落到 handler 文件里写代码所以 CLI-Anything 的设计哲学是“配置负责解析与路由代码负责逻辑与渲染”。分清边界之后灵活性和规范性都能保住。1.3 感性认识一个命令从“跑不通”到“跑得爽”空讲概念不如直接看例子。这是一份最简单的配置文件{ name: demo, commands: [ { name: hello, description: 跟你打个招呼, arguments: [ { name: name, type: string, required: true } ], handler: commands/hello.js } ] }配合一个不到五行的commands/hello.jsmodule.exports async function ({ values }) { console.log(Hello, ${values.name}!); };运行起来就是$ ca hello world Hello, world! $ ca hello 缺少参数 name 在终端里敲下命令的瞬间参数校验、帮助提示、执行调用全都有了。这只是一个最小样例但它已经把 CLI-Anything 最核心的体验传递出来了描述命令而不是编写命令。 ## 2. 核心机制拆开 CLI-Anything 的四大骨架 ### 2.1 命令注册与路由argv 如何变成指令 命令行的本质是接收一个字符串数组也就是 process.argv 去掉前两项之后剩下的部分。CLI-Anything 要做的第一件事就是把这个数组解析成“用户意图”。比如 ca md2html ./docs --output ./site 这个字符串应该被拆成“命令名 md2html、位置参数 ./docs、选项 output ./site”。 注册阶段会把配置文件里的所有命令构建成一个路由表常用 Map 结构键是命令名值是完整的命令定义。匹配时先找第一个不以 - 开头的 token拿去查路由表命中就继续解析剩余部分没命中就输出“命令不存在”并返回退出码 2。这一步听起来简单但实际项目里要注意子命令问题。如果你的命令是 ca doc build 这种两级结构那么路由表就要设计成支持嵌套而不是简单的一层 Map。我在 CLI-Anything 里用的办法是构建一颗命令树每个节点都可以有自己的 subcommands匹配时逐层下钻直到叶子节点。 路由这层还要处理一个特殊情况--help。用户敲 ca build --help 时不应该再去做参数校验和 handler 调用而是直接打印这个命令的完整说明。所以匹配到命令节点后我会先检查剩余 token 里有没有 -h 或 --help有的话就进入帮助输出流程整个解析流程直接短路。 ### 2.2 参数解析与类型校验CLI 的“地板”不能晃 参数解析是命令行工具最容易做烂的部分。用户在终端里输入的全是字符串但你的业务逻辑可能需要 number、boolean、数组甚至枚举这就需要一个从字符串到具体类型的转换层同时还要处理缺参、多参、非法枚举值等情况。 CLI-Anything 的参数模型分成两类位置参数 arguments 和选项 options。位置参数按顺序解析定义时声明 required 来决定是否必须。选项则是 --key value 或 --keyvalue 形式支持 boolean 开关和默认值。类型转换我实现了一个 coerce 函数Number(raw) 给 number 类型raw true 给 boolean 类型String(raw).split(,) 给 array 类型。逻辑不复杂但错误处理必须给足信息。 校验的优先级也需要想清楚。我的顺序是先解析位置参数再解析选项最后统一做默认值填充和枚举校验。之所以把默认值填充放在最后是因为用户传了值和没传值应该走不同路径不能一上来就填默认值然后把用户传的值覆盖掉。举个典型场景某个选项同时声明了 default: normal 和 enum: [high, normal, low]如果用户显式传了 --priority high那默认值就不能再参与任何判断。 ### 2.3 执行引擎与插件体系让命令“长”出来 CLI-Anything 的执行流程是一条清晰的责任链加载器读取配置注册器构建命令路由解析器处理 argv校验器检查参数最后运行器加载 handler 文件并调用。每个环节各司其职这样任何一个环节要做替换或扩展都不会波及其他部分。 handler 是真正的业务逻辑所在CLI-Anything 对它的接口约定很轻导出一个异步函数接收一个上下文对象 { values, cwd }values 是解析校验后的参数集合cwd 是当前工作目录。为什么把上下文收敛成一个对象而不是散装传参因为这样后续扩展时不需要改函数签名。比如以后我想在上下文里加一个 logger 对象或者加一个 getConfig() 方法所有现有 handler 都不用动只是多了可用能力。 “插件”这个东西在描述驱动框架里其实有两层含义。一层是框架层面的插件比如你想新增一种参数类型 path可以扩展解析器的类型表。另一层是业务层面的扩展一个命令执行前和执行后往往需要做统一处理比如打日志、计时、上报埋点。CLI-Anything 在运行器里预留了 beforeHandler 和 afterHandler 两个钩子配置好之后每次执行命令都会走一遍这让很多横切逻辑不用到处复制粘贴。 ### 2.4 输出与交互约定好 CLI 的一半是“体面” 工具好用不好用一半取决于参数设计另一半取决于输出设计。CLI-Anything 从一开始就约定了几件事。 stdout 只输出业务数据stderr 只输出错误和日志。这个约定在终端直接看可能觉得无所谓但一旦你写 ca md2html ./docs | grep donestdout 里混进几行日志管道解析就会崩。退出码同样重要0 代表成功1 代表参数或运行时错误2 代表命令找不到。很多脚本不在乎退出码结果 CI 跑挂了都不知道挂在哪一步。 交互体验上进度反馈是用户感知最明显的部分。console.log 会带上换行刷新进度条时会看到满屏滚动正确的做法是用 process.stdout.write(\r...)通过回车符让光标回到行首重新绘制。CLI-Anything 内置了一个简单的进度条渲染函数后面实操部分我会给出核心代码。还有一个小细节非交互环境下不要输出 ANSI 颜色和进度条否则 CI 日志会变得很难看。判断方式很简单!process.stdout.isTTY 时就把进度输出退化成普通日志。 ## 3. 实操从零手写一个最小可用 CLI-Anything ### 3.1 目录设计与初始化 理论讲完直接上手写一个能跑的最小实现。我会用 Node.js 的 CommonJS 模块零第三方依赖核心代码两百行左右。这样做的目的是让你看清每一行在干什么不被依赖库的魔法掩盖。 项目结构这样安排 text cli-anything/ ├── package.json ├── bin/ │ └── cli.js ├── lib/ │ ├── loader.js │ ├── registry.js │ ├── parser.js │ └── runner.js └── commands/ └── md2html.jsbin目录放可执行入口lib放框架核心commands放具体的业务 handler。package.json 里声明bin字段这样通过 npm link 就能在终端敲一个全局命令名。初始化 package.json{ name: cli-anything, version: 0.1.0, bin: { ca: bin/cli.js }, type: commonjs }入口文件加上#!/usr/bin/env nodeshebang用chmod x bin/cli.js给可执行权限之后就能在终端里直接运行了。3.2 手写参数解析器核心代码整个框架最核心的是参数解析器我把它放在lib/parser.js里贴出完整代码function coerce(raw, type) { switch (type) { case number: return Number(raw); case boolean: return raw true || raw true; case array: return String(raw).split(,); default: return String(raw); } } function parseArgs(argv, cmd) { const values {}; const errors []; if (cmd.arguments) { const positionalTokens argv.filter((t) !t.startsWith(--)); cmd.arguments.forEach((arg, i) { const v positionalTokens[i]; if (v undefined) { if (arg.required) { errors.push(缺少参数 ${arg.name}); } } else { values[arg.name] coerce(v, arg.type); } }); } for (let i 0; i argv.length; i) { const t argv[i]; if (!t.startsWith(--)) continue; const eq t.indexOf(); let key; let rawVal; if (eq ! -1) { key t.slice(2, eq); rawVal t.slice(eq 1); } else { key t.slice(2); const next argv[i 1]; if (next !next.startsWith(--)) { rawVal next; i; } else { rawVal true; } } const opt (cmd.options || []).find((o) o.name key); if (!opt) { errors.push(未知选项 --${key}); continue; } values[key] coerce(rawVal, opt.type); } for (const opt of cmd.options || []) { if (values[opt.name] undefined opt.default ! undefined) { values[opt.name] opt.default; } if (opt.enum values[opt.name] ! undefined !opt.enum.includes(values[opt.name])) { errors.push(选项 --${opt.name} 必须是 ${opt.enum.join(/)} 之一); } } return { values, errors }; } module.exports { parseArgs };这段代码有三个地方值得反复看。第一个是位置参数的提取方式argv.filter((t) !t.startsWith(--))。这是最小实现能跑通但不够严谨因为选项的值如果恰好不以--开头也会被当成位置参数处理。我后面会在“常见问题”章节专门讲这个边界情况。第二个是选项值的获取逻辑优先看显式赋值拿不到就看下一个 token 是不是一个普通值不是的话就按 boolean 处理。这就能支持--watch这种开关型选项。第三个是默认值和枚举校验放在最后统一做。这保证了“用户传了值就尊重用户没传值才用默认”而且可以在一个循环里完成所有选项的最终状态确认。3.3 注册第一个真实命令Markdown 批量转 HTML框架有了解析器下一步注册一个真实命令。我选了“批量把 Markdown 转成 HTML”这个场景因为它在日常写作和文档维护中太常见了。配置文件cli-anything.json{ name: demo, description: CLI-Anything 演示工具, commands: [ { name: md2html, description: 把目录下的 Markdown 批量转成 HTML, arguments: [ { name: input, type: string, required: true, description: Markdown 文件目录 } ], options: [ { name: output, type: string, default: dist, description: 输出目录 }, { name: watch, type: boolean, default: false, description: 是否监听文件变化 } ], handler: commands/md2html.js } ] }对应的 handler 写在commands/md2html.jsconst fs require(fs); const path require(path); module.exports async function ({ values }) { const inputDir path.resolve(values.input); const outputDir path.resolve(values.output || dist); fs.mkdirSync(outputDir, { recursive: true }); const files fs.readdirSync(inputDir).filter((f) f.endsWith(.md)); if (files.length 0) { console.log(没有找到 Markdown 文件); return; } files.forEach((file, index) { const md fs.readFileSync(path.join(inputDir, file), utf-8); const html render(md); const outFile path.join(outputDir, file.replace(/\.md$/, .html)); fs.writeFileSync(outFile, html); process.stdout.write([${index 1}/${files.length}] ${file} - ${outFile}\n); }); }; function render(md) { return md .replace(/^### (.*)$/gm, h3$1/h3) .replace(/^## (.*)$/gm, h2$1/h2) .replace(/^# (.*)$/gm, h1$1/h1) .replace(/\*\*(.*?)\*\*/g, strong$1/strong); }render函数是一个最小实现只处理了标题和加粗别拿去生产环境用真要生产环境直接用 marked 或 unified 处理更靠谱。这里重点是看 handler 的写法结构就是“拿到 values干活输出结果”。每个文件的处理进度直接写到 stdout而不是用 console.log这是有意为之——后面接管道或者重定向日志时stdout 和 stderr 不会互相污染。入口文件bin/cli.js把整个执行链串起来#!/usr/bin/env node const path require(path); const { loadConfig } require(../lib/loader); const { buildRegistry } require(../lib/registry); const { parseArgs } require(../lib/parser); const { runHandler } require(../lib/runner); const configPath process.argv[2] --config ? process.argv[3] : ./cli-anything.json; const argv process.argv.slice(2); const config loadConfig(configPath); const registry buildRegistry(config); if (argv.includes(--help) || argv.includes(-h)) { for (const cmd of config.commands) { console.log(${cmd.name}\t${cmd.description}); } process.exit(0); } const route registry.match(argv); if (!route) { console.error(命令不存在使用 --help 查看所有可用命令); process.exit(2); } const parsed parseArgs(argv, route); if (parsed.errors.length 0) { console.error(parsed.errors.join(\n)); process.exit(1); } runHandler(route, parsed.values).catch((err) { console.error(err.message || err); process.exit(1); });执行引擎lib/runner.js负责加载 handler 并调用const path require(path); async function runHandler(route, values) { const handlerPath path.resolve(process.cwd(), route.handler); const handler require(handlerPath); await handler({ values, cwd: process.cwd() }); } module.exports { runHandler };到此一个最小可用的 CLI-Anything 就跑起来了。执行效果$ node bin/cli.js md2html ./docs --output ./site [1/3] intro.md - /site/intro.html [2/3] guide.md - /site/guide.html [3/3] api.md - /site/api.html3.4 交互体验进度条、着色与退出码上面的版本能输出进度信息但还不够“现代 CLI”的感觉。接下来补上两个关键体验进度条和退出码语义。进度条的核心是“单行刷新”。用\r回到行首覆盖写当前行内容就能实现原地刷新效果function renderProgress(current, total) { const width 30; const done Math.floor((current / total) * width); const bar █.repeat(done) ░.repeat(width - done); const percent Math.round((current / total) * 100); process.stdout.write(\r处理中 [${bar}] ${percent}%); if (current total) { process.stdout.write(\n); } }注意两点第一输出完最后一项要补一个\n否则下一个 shell 提示符会和进度条挤在同一行。第二如果当前环境不是 TTY比如 CI 里跑就别输出那种带\r的进度条直接退化成普通日志即可。判断方法就是process.stdout.isTTY。退出码的事看起来小实际很重要。我在 CLI-Anything 里定了三种0 成功1 参数错误或运行时错误2 命令未找到。这个语义和很多系统工具是一致的CI 脚本拿到非 0 退出码就能判断失败。关于异常处理我建议只把“业务错误”打到 stderr像库函数抛出的堆栈除非设置了--verbose或环境变量DEBUG1否则不要默认打印完整堆栈。用户看到一堆 Intrinsic TypeError 只会觉得这个工具很烂。4. 真实项目里的血泪坑问题排查与速查表4.1 数字参数与负数被忽略的解析陷阱参数解析里最常见的坑是负数被当成新的选项。比如你有一个命令ca scale --count -1解析器遍历到--count后面看到下一个 token 是-1如果判断条件写的是“不以--开头就当值”那-1会被当成值的候选但如果你写的是“以-开头就跳过”--count就会变成 boolean而-1变成了未知选项。我踩过坑之后给出的解法分两步一是在解析选项时判断“如果选项类型是 number并且下一个 token 能通过Number()转换就把它当作值”而不是看它是否以--开头。二是给用户提供的形式作为逃生通道--count-1永远不会被误判。我在 CLI-Anything 里两者都支持解析时优先聪明识别用户侧提供稳定写法兜底。这个设计让我以后再也没被负数问题坑过。4.2 stdin 未消费导致的“幽灵挂起”遇到过最诡异的一个问题是命令执行完了但进程就是不退出。排查了半天发现是 handler 里某个逻辑读取了 stdin但没消费完Node 的事件循环被挂起的流监听一直占着进程没法自然结束。出现这个问题的场景通常是这样某个 handler 里写了process.stdin.on(data, ...)来支持交互输入但命令执行的路径没有走到关闭流的逻辑。解决办法很简单只有真正需要交互输入时才去监听 stdin用完立即process.stdin.pause()或者process.stdin.destroy()。如果你只是偶尔需要从管道读数据建议统一提供一个readStdin()辅助函数内部消费完数据后立刻释放避免每个 handler 都自己折腾流。4.3 异常处理与退出码管道的体面handler 里调用一个不存在的路径fs.readFileSync会直接抛异常。如果没有统一捕获Node 会把堆栈打到 stderr并且退出码变成 1这在终端看问题不大但 CI 日志会被几百行堆栈淹没真正的错误原因反而看不出来。CLI-Anything 的解法是在入口处加一层全局 catch把异常信息收敛成一行。用户想看详细堆栈可以通过环境变量DEBUG1打开。另外有个细节不要在 catch 之后直接process.exit(1)而应该先设置process.exitCode 1然后让进程自然结束。这样做的好处是stdout 里可能还有一些未写完的数据可以正常冲刷出去不至于截断半行输出。还有一个管道场景的经典问题你执行ca md2html ./docs | head -5如果输出足够多head提前关闭了管道你这边再写 stdout 就会触发EPIPE错误。解决办法是在进程级忽略这个错误process.stdout.on(error, (err) { if (err.code EPIPE) process.exit(0); });。不然你的命令会因为一个无害的管道关闭而报红。4.4 跨平台兼容Windows 用户的眼泪命令行工具天然容易踩跨平台坑。第一是路径分隔符path.join和path.resolve能自动处理但如果你手写了字符串拼接路径在 Windows 上就会炸。第二是环境变量bash 里FOObar ca foo这种写法在 Windows 的 cmd 里完全不支持建议工具内部只读取process.env由用户在各自的 shell 里管理环境变量。第三是删除文件很多脚本喜欢直接映射rm -rf这在 Windows 下会失败建议用 Node 的fs.rm并传recursive: true, force: true。还有一个不太容易注意的点可执行文件的 shebang。在 Linux/macOS 上chmod x之后可以直接跑但 Windows 下靠的是系统关联 Node.exe通常要用户自己配置。CLI-Anything 里我很早就接受了这个现实文档里直接写明“Windows 用户请通过node bin/cli.js运行”或者装一个 shim不要在框架层面浪费太多精力去搞跨平台启动器。4.5 排查速查表最后把上面所有问题整理成一张速查表方便你遇到问题时直接对照。现象常见原因解决办法--count -1解析失败解析器把-1当成选项number 类型下尝试Number()转换或提示用户用--count-1命令执行完毕但进程不退出stdin 流未消费或未关闭用完即pause()/destroy()提供统一readStdin()命令偶发报EPIPE下游head/grep提前关闭管道监听 stdout 的 error忽略EPIPE异常Windows 下 handler 路径找不到手写路径分隔符或rm -rf用法用path.join/fs.rm避免 shell 特有语法CI 日志里堆栈过载未统一 catch 异常全局 catch默认打印单行错误DEBUG1开堆栈--help被当成命令路由查找优先于 help 判断进入命令路由前先检查-h/--helpboolean 选项传--watch false不生效解析器把false当作值coerce 后变成 trueboolean 类型不要紧跟在false后使用--watchfalse或只做开关我在实际使用中最深的体会是CLI 工具的“小”是相对的它看起来只有几段代码但做好之后对工作效率的提升是全方位的。CLI-Anything 这个项目让我把原来那些没人敢碰的脚本慢慢变成了团队里人人能用的标准工具。前阵子我还给它加了一个很实用的扩展根据配置文件自动生成 Zsh 补全脚本。原理不复杂遍历命令树把参数和选项转成_arguments规范输出到补全文件。这个功能上线之后连我那个从不用终端的同事都开始主动敲 Tab 了。这大概就是做命令行工具最值得的一刻——你写的框架真正降低了别人使用命令行的门槛。如果你也在维护一堆脚本不妨试试这种“描述优先”的设计思路先从一个小命令开始你会很快感受到它的价值。