尧图精选

CLI-Anything:统一命令行入口,让所有脚本一键管理

🕒 发布时间:2026/9/28 13:31:20 📁 来源:尧图网络
上周我又一次经历了自己给自己添堵的名场面想找一条半年前跑过的数据迁移脚本翻了十几屏终端历史没找到又去翻项目文档也没记录最后只能凭模糊记忆重新拼了一遍。类似的场景我猜大家都不陌生——自己的工具越攒越多它们散落在全局 bin 目录、项目 scripts 里、dotfiles 中有些干脆是某次临时操作留下的孤本。所以我动手做了CLI-Anything。它不是一个又一个命令行工具而是一个可插拔的命令行聚合入口把任意脚本、任意语言、任意接口全部变成anything 子命令下的一个插件。项目起名 Anything 不是噱头而是它真的什么都能收——Node 写的、Python 写的、纯 bash 拼的、甚至是一段远程 API 调用只要能跑就能挂进来。这篇文章我会把它的设计思路、插件协议、核心实现和踩过的坑完整写出来。适合谁看三类人手头积攒了各种小脚本但从来没归档的开发者想在团队里统一分发内部工具而不想每次口头传文件的工程师以及想把 AI 能力和现有命令行工具串起来的人。1. 从命令一团糟到统一入口CLI-Anything 到底在解决什么1.1 散落工具的三宗罪先说痛点。我复盘了自己过去一年的终端使用习惯发现手上的工具有三种形态全局安装的 CLI 工具、项目里定义的 npm scripts、以及一堆躺在某处的一次性 shell 脚本。它们的共同问题是没有统一入口。举几个真实场景。同事给我传了个内部发布脚本我把它丢进 /tmp跑完一次就忘了下次要用只能再找他要。我自己的项目脚手架脚本存在 ~/scripts 下每次新建项目都要先想一遍那个脚本到底叫啥来着。还有一类工具是组合型的——先 lint 再单测最后构建每次都要手动敲三条命令敲错一个参数就白跑一轮。这就是第一宗罪无统一入口。第二宗罪是无发现机制。全局 bin 下的命令装多了连自己都记不全第三宗罪是共享靠口头团队里每个人维护着自己的一份脚本版本漂移、参数不一致是常态。1.2 改造前后的真实对照我做了一张表用来跟团队描述这个项目的价值也分享给你参考场景改造前改造后同事给一个脚本丢到临时目录跑一次就忘anything install team-tools随后全局可用忘记命令参数打开源码翻注释anything help ops:health直接看参数说明混用多语言工具Node 一套、Python 一套、Shell 一套统一anything xxx一个入口新增一个小工具改 PATH、加 alias、建 npm script新建一个插件目录放进去刷新即可生效组合命令手动连敲三条命令中间等输出一个编排型插件搞定 lint test build注意最后一行CLI-Anything 里的插件不一定是自己实现功能它完全可以是一个调度器把现有命令按顺序编排起来。这一点后面在实战案例里会专门展开。1.3 三个硬性设计目标带着上面的痛点我给 CLI-Anything 定了三个设计目标后面所有实现都围绕它们展开第一插件协议必须语言无关。我不能规定插件只能用 Node 写否则和再造一个 npm scripts 有什么区别。一个插件只要是一个目录 一个声明文件 一个可执行入口CLI 就该能把它跑起来。第二命令解析由元数据驱动。插件的名称、参数、用途全部声明化。这样anything help可以自动生成帮助文档模糊搜索有据可查甚至未来做自然语言入口AI 也能读懂插件清单。第三输出必须可组合。命令行生态最值钱的东西是管道pipe。我要求插件在非交互模式下输出 JSON这样anything db:q select ... | anything ai:explain这种组合才可能成立。2. 插件协议设计凭什么把别人的脚本也变成你的子命令2.1 一个插件就是一个目录插件的最小单位是一个目录放在~/.anything/plugins/下面。目录里有三样东西声明文件plugin.json、入口脚本、以及插件自己需要的任何资源文件。目录名不重要真正起作用的是plugin.json里的name字段。~/.anything/plugins/banner/ ├── plugin.json └── run.jsplugin.json是插件的身份证字段如下字段必填说明name是子命令名例如banner、db:qdescription是一句话描述用于帮助列表和模糊搜索version是插件版本冲突检测用runner是解释器类型node/python/bash/goentry是入口文件相对插件目录的路径parameters否参数声明数组驱动命令行解析和帮助生成outputType否text/json/table决定文档提示和非交互输出模式为什么参数要声明化因为这样别人不需要看源码就能自动获得帮助。anything help banner会打印出每个参数名、类型、是否必填、用途。普通脚本把参数约定写在自己脑子里CLI-Anything 把约定写成了数据。2.2 两个最小插件用不同语言写先看一个 Node 插件的完整代码。plugin.json{ name: banner, description: 输出一个带颜色的横幅文字, version: 0.1.0, runner: node, entry: run.js, parameters: [ { name: text, type: string, required: true, description: 要展示的文字内容 } ] }run.jsconst chalk require(chalk); const index process.argv.indexOf(--text); const text process.argv[index 1]; console.log(chalk.bold.cyan( ${text} ));注意这里插件直接读取process.argv拿参数。CLI-Anything 不会替插件解析参数再传对象而是把用户输入原样透传给插件进程。这样对插件作者零约束你想用process.argv、yargs、还是自己手写解析都行。再看一个同功能但用 Python 写的插件。plugin.json里只改两个字段{ runner: python, entry: run.py }run.pyimport sys flag --text idx sys.argv.index(flag) text sys.argv[idx 1] print(f {text} )命令还是anything banner --text helloCLI 内部做的事情是读plugin.json拿到 runner 和 entry然后 spawn 对应的解释器进程。CLI 完全不需要关心插件是什么语言写的这就是Anything字面意义的实现基础。2.3 用元数据动态生成命令行CLI 入口文件的核心逻辑是遍历所有插件为每个插件动态注册子命令。我用 Commander 实现核心代码如下const { program } require(commander); const { loadPlugins } require(./registry); const plugins loadPlugins(); for (const plugin of plugins) { const cmd program .command(plugin.name) .description(plugin.description); for (const param of plugin.parameters || []) { const flag --${param.name} ${param.type}; cmd.option(flag, param.description, param.default); } cmd.action(async (options) { await runPlugin(plugin, options); }); } program.parse(process.argv);这段代码的核心在于插件本身不写任何命令行解析代码。参数列表来自plugin.jsonCommander 负责生成--text、--host、--port这类 flag用户拿到的帮助信息也完全由元数据驱动。插件只需要在入口里把自己关心的参数从process.argv里抠出来用。为什么这样设计因为大部分脚本项目里真正复杂的不是业务逻辑而是 CLI 外壳解析参数、处理帮助、做校验。把这些全部收敛到框架里写插件的人只需要关心给定参数输出结果这件事。插件协议越薄愿意往里塞东西的人就越多。3. 插件发现与模糊查找只记得半个命令也能跑起来3.1 插件从哪来三个搜索层级CLI-Anything 的插件搜索路径有三个层级按优先级从高到低排列项目级.anything/plugins/目录从当前目录逐级向上查找。存放这个项目专属的工具比如数据库连接脚本、项目脚手架。用户级~/.anything/plugins/目录。个人通用的工具比如 git 辅助脚本、文档生成器。全局级/usr/local/lib/anything/plugins或 npm 全局安装的插件包。通常是团队分发的工具集。冲突处理规则是项目级的同名插件覆盖用户级和全局级。理由很好理解——同一个命令deploy:prod在 A 项目里可能是部署静态站点在 B 项目里可能是同步数据库按项目隔离才是最符合直觉的。3.2 模糊匹配打错名字也能找到工具我早期遇到过一个问题插件多了之后用户压根记不住确切名字。ops:health写成opshealth、qa:all写成qa都是常见操作。所以我把模糊匹配做进了入口逻辑。实现思路有两种我最终选择了组合方案const Fuse require(fuse.js); function findPlugin(keyword) { const exact plugins.find(p p.name keyword); if (exact) return exact; const fuse new Fuse(plugins, { keys: [name, description], threshold: 0.4 }); return fuse.search(keyword).slice(0, 1)[0]?.item || null; }先用精确匹配找不到就上模糊搜索。搜索的 key 不仅有name还有description。这意味着用户可以用查数据库这种自然语言去搜也能搜到db:q这个插件。这比死记命令名友好太多。3.3 交互式选择多个候选时让用户挑模糊搜索还有一个衍生问题命中多个插件时到底执行哪个我的做法是命中唯一时直接执行命中多个时进入交互选择。交互列表长这样$ anything health ? 你要运行哪个插件 ops:health 检查所有线上服务健康状态 team:health 查看团队例行任务状态 net:health 网络连通性检测这里有一个必须注意的细节交互式选择只能在 TTY 下启用。如果是在 CI 脚本里通过管道调用anything health交互提示会让整个任务卡死。实现方式是检测process.stdout.isTTY非 TTY 环境下直接报错并列出所有候选让用户在脚本里写明确名称。3.4 帮助体系让工具自己会说话anything list列出全部插件及其一句话描述anything help name显示某个插件的完整参数说明anything alias name 短名给常用插件设置短别名。这三条命令构成一个自解释的发现体系。我见过太多内部工具死在没有文档上。CLI-Anything 的做法是在框架层强制插件作者写description和参数说明从机制上杜绝裸奔脚本。写帮助这件事不应该靠自觉应该靠协议。4. 把日常操作变成插件的五个实战案例4.1 脚手架create:component前端项目里最常见的重复劳动是新建组件。三四个文件、一段样板代码、还要手动改 import。我把它做成了插件// run.js const fs require(fs); const path require(path); const arg (name) { const idx process.argv.indexOf(--${name}); return idx -1 ? process.argv[idx 1] : null; }; const cwd process.cwd(); const name arg(name); const type arg(type) || component; if (!name) { console.error(用法: anything create:component --name 组件名); process.exit(1); } const template // ${type}: ${name}\nexport function ${name}() {\n return null;\n}\n; const dir path.join(cwd, src, type s, name); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, index.js), template); fs.writeFileSync(path.join(dir, style.css), /* ${name} styles */\n); console.log(已创建 ${type}${name});价值点不在模板多精美而在于新成员进项目不需要读文档。anything list看到create:componenthelp里写着参数说明5 分钟就能上手。脚手架类工具是插件的天然场景因为它的输入输出非常规整。4.2 数据库查询db:q我又做了一系列一次性数据查询脚本全都没有归档。后来干脆做成db:q插件参数从plugin.json声明入口用 mysql2 执行 SQL表格输出const mysql require(mysql2/promise); const getArg (name) { const idx process.argv.indexOf(--${name}); return idx -1 ? process.argv[idx 1] : undefined; }; (async () { const conn await mysql.createConnection({ host: getArg(host) || 127.0.0.1, user: getArg(user) || root, password: getArg(password) || , database: getArg(db) || app, }); const [rows] await conn.query(getArg(sql)); console.table(rows); await conn.end(); })();这个插件的实际价值不只是执行 SQL而是让连接参数变成记忆友好的命令。原来每次都要回忆这台机器的数据库密码到底是啥来着现在anything help db:q全部写清楚。配合 JSON 输出模式还能把查询结果直接抛给下一个分析工具。4.3 质量门禁qa:all我前面说过插件可以是编排器。qa:all是最典型的例子它自己一行业务代码都没有只是把三个命令按顺序执行const { spawn } require(cross-spawn); const steps [ [npx, [eslint, src]], [npx, [jest, --ci]], [npm, [run, build]], ]; (async () { for (const [cmd, args] of steps) { const result spawn(cmd, args, { stdio: inherit }); const code await new Promise(resolve result.on(close, resolve)); if (code ! 0) { console.error(步骤失败: ${cmd} ${args.join( )}); process.exit(code); } } console.log(全部检查通过); })();为什么这个值得做因为人类不擅长执行多步且中途可能失败的任务。手敲eslint通过之后大概率忘了跑测试。编排型插件把成功的路径固化下来每次都是同一套可靠流程。4.4 健康检查ops:health这个插件针对有没有想过从终端快速看一遍所有服务状态的需求。它并行请求配置里的一组接口输出每个服务的可达性和响应时间const endpoints [ { name: 订单中心, url: https://api.example.com/orders/health }, { name: 用户中心, url: https://api.example.com/users/health }, { name: 网关, url: https://api.example.com/gateway/health }, ]; (async () { const results await Promise.allSettled( endpoints.map(async ({ name, url }) { const start Date.now(); const res await fetch(url, { signal: AbortSignal.timeout(3000) }); return { name, status: res.status, ms: Date.now() - start }; }) ); for (const r of results) { if (r.status fulfilled) { console.log(${r.value.name}: ${r.value.status} (${r.value.ms}ms)); } else { console.log(${r.reason?.message}: 超时或不可达); process.exitCode 1; } } })();关键细节是process.exitCode 1。这样anything ops:health在现代 CI 流水线里可以直接作为检查节点使用失败即中断发布。4.5 AI 提交信息ai:commit最后一个案例想说明外部 API 也可以是一个插件。ai:commit读取 git diff调用大模型接口生成提交信息const { execSync } require(child_process); const diff execSync(git diff --staged).toString().slice(0, 8000); const apiKey process.env.ANYTHING_AI_KEY; if (!apiKey) { console.error(未找到 ANYTHING_AI_KEY 环境变量); process.exit(1); } const response await fetch(https://api.example-llm.com/v1/completions, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: turbo-text, prompt: 根据以下 diff 生成一句提交信息\n${diff}, }), }); const data await response.json(); console.log(data.choices[0].text.trim());这里有两个实践心得一是 API Key 从环境变量读取绝对不写进插件代码或plugin.json避免一不留神提交到公共仓库二是 diff 长度截断到 8000 字符防止超长 diff 变成昂贵的 token 消耗。把 AI 能力做成插件后团队里的任何人都能用一条命令获得结构清晰的提交信息不用自己配环境、调参数。5. 执行器背后的六个坑我为此重写了三遍 spawn 逻辑5.1 参数拼接导致的引号地狱我最初的实现偷懒用字符串拼接来组装子进程命令// 错误示范 const command python run.py --text${text}; execSync(command);这个写法在参数里出现空格、引号、特殊字符时立刻崩溃。比如--text Hello World会被拆成两个参数。后来我全部改成数组传参把命令行交给 spawn由系统负责正确的参数分隔// 正确做法 const { spawn } require(cross-spawn); spawn(python, [run.py, --text, text], { stdio: inherit });教训很简单永远不要自己拼命令字符串。只要参数来自用户输入拼接就是注入漏洞的温床。数组传参既安全又能正确处理空格。5.2 直接 spawn(bash) 让 Windows 用户原地爆炸第一版很多插件作者默认写spawn(bash, [...])。在 macOS 和 Linux 上好好的一到 Windows 就ENOENT。因为 Windows 根本没有/bin/bash。解决方案是用cross-spawn统一处理跨平台差异并且对 JS 插件默认用process.execPath来启动 Node而不是写死nodeconst { spawn } require(cross-spawn); function runPlugin(plugin, args) { const runnerMap { node: process.execPath, python: process.platform win32 ? python : python3, bash: process.platform win32 ? bash : bash, }; const executable runnerMap[plugin.runner]; return spawn(executable, [plugin.entry, ...formatArgs(args)], { stdio: inherit, }); }注意 Python 在 Windows 上是python在 Unix 系经常是python3这个差异如果不处理同样的插件跨平台就神秘失效。这类细节属于不做不知道做了才崩溃的类型。5.3 非 TTY 环境下交互输出把 CI 打崩有段时间我们在 CI 里调用某个原本交互式的插件结果流水线直接挂起等到超时。原因前面提过插件 detect 到输入来自管道依然弹出了选择列表一旦没有人类去选择就永远卡住。为此我建立了一条约定框架通过环境变量ANYTHING_NON_INTERACTIVE1通知插件当前处于非交互模式。插件拿到这个变量就去掉交互提示直接输出结果。同时框架侧在!process.stdout.isTTY时自动设置该变量。Convention over configuration所有插件默认遵守。5.4 npm link 导致命令更新不生效CLI-Anything 本身用 npm 全局安装开发期我用npm link做本地调试。踩过的坑是发布新版本后执行npm i -g cli-anything命令倒是更新了但插件相关文件还是旧的。原因是 npm link 创建的符号链接指向开发目录全局安装覆盖不到。排查方式很简单which anything看命令实际路径如果在开发目录下就是 link 残留。重置方式npm rm -g cli-anything npm i -g cli-anything给所有 CLI 工具开发者的提醒npm link很方便但发布前一定要在干净环境里验证全新安装不要默认 link 环境等于发布环境。5.5 长时间任务没有输出用户以为进程死了最典型的例子是qa:all跑构建可能一分钟没任何输出。用户体验极差——不是卡死是没反馈。我加了一个朴素的节流输出机制let lastOutput Date.now(); const interval setInterval(() { if (Date.now() - lastOutput 3000) { process.stderr.write(仍在运行已等待 ${(Date.now() - lastOutput) / 1000} 秒...\n); } }, 3000); child.on(stdout, () { lastOutput Date.now(); }); child.on(close, () clearInterval(interval));注意节流日志写入的是stderr而不是 stdout原因见下一个坑。这个机制虽然简单但救了很多次看起来没反应的误判。5.6 stdout 被日志污染JSON 管道彻底报废最后这个坑让我重写了半套日志系统。现象是用户写anything db:q --sql ... | anything ai:explain下游插件收到的不是 JSON 而是夹杂着正在连接数据库...这类日志的脏数据。解决方案是建立严格的输出通道约定插件的正式结果JSON、文本、表格走stdout日志、进度条、诊断信息走stderr框架标记outputType: json时stdout 只允许出现一条完整的 JSON 对象。实现层面框架通过环境变量把输出模式传给插件插件侧约定process.stderr.write用于一切附带信息console.log只用于业务结果。这一条约定让任意插件任意组合成为可能也让 CLI-Anything 保住了 Unix 哲学最后一点尊严。6. 从本机到团队自然语言入口、远程执行与后续想象6.1 让自然语言成为入口的第一步模糊搜索已经允许用描述性关键词找插件更进一步是让用户直接说一句话由框架匹配意图并执行。我在实验分支里写了一个很薄的实现把用户输入交给本地模型让它输出一个 JSON 意图哪些插件最匹配、参数怎么填。$ anything ask 帮我看看线上服务都正常吗 # 内部意图解析: { plugin: ops:health, args: {} } # 随后自动执行 anything ops:health目前这个实验还很糙但方向是对的。当插件清单本身是结构化元数据时AI 读取这份清单的成本极低。未来 CLI 的价值可能不在于记住命令而在于理解意图。6.2 插件的分发与安装CLI-Anything 的插件分发简单粗暴一个 Git 仓库就是一个插件源。anything install git-url会 clone 到用户级插件目录、校验plugin.json、然后注册。团队内部工具的分发成本从口头传文件 手动配置环境降低到一条命令装好。一个负责任的安全提醒从远程安装插件等于执行远程代码。我在实现里默认拦截了不带--trust参数的安装并要求仓库域名出现在可信列表中。任何 CLI 生态的插件分发都必须面对供应链风险CLI-Anything 目前的选择是透明提示 用户显式确认而不是假装看不见风险。6.3 远程执行一条命令管所有机器借助 SSH一个插件可以指定在远程机器上运行anything ops:health --host prod-server-1。实现上框架检测到--host参数后把插件的运行过程包装成远程命令ssh userhost cd ~/.anything/plugins/ops python run.py --host prod-server-1我没有做更花哨的功能一个能用的远程执行 同样的 JSON 输出约定已经覆盖了绝大多数运维场景。这个能力配合定时任务就能实现每天早上 9 点自动检查所有服务器的磁盘占用异常时推送到群聊这类实用自动化。6.4 我自己现在的使用习惯最后分享几个真实的使用习惯。首先我给anything设了一个超短别名a敲a db:q --sql select * from users limit 5比敲完整命令省太多事。其次我把整个~/.anything目录纳入 dotfiles 管理新机器一条安装脚本就能重建全部环境。第三我给自己定了一个规矩任何脚本只要用过两次以上就立刻转成插件归档绝不让它沦为又一个 /tmp 孤儿。这三个习惯不复杂但它们让 CLI-Anything 真正变成了我的工作台。工具的价值不在于堆积而在于当你需要某个能力时入口足够可靠、足够快。CLI-Anything 往这个方向走了一大步而它最让我满意的设计始终是那条最简单的协议任何语言的任何脚本放进插件目录就拥有了统一的入口、帮助、搜索和分发。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →