最近我把工作里一半以上的重复操作都搬进了终端靠的是一个叫 CLI-Anything 的小工具。它不是厚重框架更像是给零散脚本准备的统一入口每天要做的批量重命名、查天气、调接口、起服务全都可以收进同一条命令下面用一套参数规范去调用。这篇文章会把整个设计和落地过程拆开来讲包括架构怎么选、插件怎么写、shell 怎么接、哪些坑我替你踩过最后附上一份可以拿走的 Node.js 实现。如果你经常在终端里敲命令或者手头攒了几十个不知道往哪归置的小脚本这个思路特别适合你。哪怕你只写过几个简单的.sh文件也能从里面找到一种把脚本组织成“产品”的方式而不是永远停留在“test1.sh、test2.sh、test_final.sh”。1. 为什么要做 CLI-Anything终端里藏着的高频操作1.1 终端不是“程序员专用界面”而是操作系统的公共接口很多人一提到命令行第一反应是“这是程序员才用的东西”。但如果你认真观察日常工作会发现终端其实是一套通用的、可记录的交互方式。鼠标点击的整个过程是不可复制的而敲过的每一条命令都会留在 shell history 里。一旦操作变成文本它就可以被搜索、被复制、被编排甚至被做成定时任务。CLI-Anything 的核心思路就是把这种文本化能力放大不仅让你能敲命令还让你自己能随时给这个命令体系增加新命令。我举个例子。以前我每周五都要整理下载目录里面散落着report-v1.pdf、report-final.pdf、report_2025.pdf这类文件手工改名很烦。后来我写了一个几十行的脚本把这些文件统一改成report-YYYYMMDD.pdf。脚本本身不难但过两周再去找它往往就忘了放在哪个目录、怎么传参。这类问题不是个别现象脚本越来越多入口越来越乱到最后自己都不想用了。CLI-Anything 就是在这一步切入的。它不替代你的脚本而是给脚本一个“家”。每个小工具都按固定的接口注册进去由统一入口负责参数解析、帮助信息、错误提示和配置读取。你不需要记住每个脚本的路径也不需要回忆那个脚本的参数顺序只需要记住一条命令和它的子命令名就够。1.2 CLI-Anything 到底解决了什么问题拆开来看CLI-Anything 解决的是三个具体痛点。第一脚本入口碎片化。真实工作里绝大多数人的脚本都散落在~/scripts、项目目录、甚至临时文件夹里。想用的时候先得找路径再 grep 一下参数效率很低。统一入口之后所有功能都挂在同一条主命令下面按命令名自然联想认知负担小很多。第二参数规范不统一。有人习惯--name有人习惯-n还有人习惯位置参数。不同的脚本有不同的规则遇到不常用的工具你还得重新读代码才懂怎么用。CLI-Anything 要求每个插件都用相同的规范声明命令、参数、说明用户上手新插件的成本就大幅降低。第三组合能力缺失。单独一个脚本能做的事很有限但多个脚本连起来可以做流水线。比如“拉取数据、格式化、批量重命名、生成摘要”这一串操作放在统一命令体系里就可以用配置文件编排成一条复合命令剩下的交给机器。没有统一入口之前这一步很费劲因为你得手动串联各种脚本的调用方式。1.3 谁来用、用在哪场景与人群CLI-Anything 最适合三类人。第一类是每天在终端里进进出出的开发者。这类人已经熟悉 shell 基本操作但缺少一套整理个人脚本的规范CLI-Anything 刚好补上这一层组织能力。第二类是测试、运维、数据相关的从业者。他们的日常工作里充满了重复的 API 调用、环境切换、日志筛查这些动作都适合封装成命令。第三类是喜欢折腾效率工具的普通用户。即使不会写复杂的程序只要照着模板改一改配置也能把天气查询、待办清单、文件整理这类功能塞进终端。适用场景就更宽了。从技术角度看查接口状态、批量操作文件、调数据库、发 HTTP 请求都合适。从生活角度看查天气、换算汇率、记账、列待办也都能做成插件。CLI-Anything 的边界不在于它能做什么而在于你愿不愿意把某件事拆成“输入-处理-输出”三段式。只要这件事能拆出来它就值得变成一条命令。2. 设计思路与核心选型解析2.1 插件化架构一个入口N 个扩展CLI-Anything 最核心的设计是插件化架构。主程序只做三件事解析全局参数、加载插件、分发子命令。插件文件负责具体逻辑。这样主程序和插件之间的耦合非常低新增一个功能只需要往plugins目录里放一个文件不需要改动主程序。我选 Node.js 来实现主要是看重三点。第一它对 JSON 和 HTTP 的支持很顺手写配置读取和接口调用都很直接。第二npm 生态成熟后续想加命令行交互、彩色输出、单元测试都有现成库。第三JavaScript 本身是解释型语言插件文件可以动态加载非常契合这种“目录即插件列表”的设计。目录结构设计成了这样cli-anything/ ├── package.json ├── cli.js ├── lib/ │ └── config.js └── plugins/ ├── rename.js └── weather.jscli.js是入口文件负责遍历plugins目录按顺序把每个插件注册到 Commander 实例上。插件只需要导出固定格式的元信息主程序不关心插件内部实现了什么。如果你以后想用 Python、Go 或 Rust 重写一遍接口定义不变插件迁移成本也不会太高。这种架构带来的好处很直接插件的开发、调试、删除都是独立的不会互相影响。某个插件出了问题你只需要看那一个文件不需要在整个代码库里大海捞针。2.2 参数解析与交互式输入从“命令”到“对话”命令行工具的第一印象取决于参数好不好记、帮助信息清不清楚。CLI-Anything 使用 Commander 做参数解析每个插件需要声明自己的子命令格式、参数列表和帮助文本。举一个简单例子批量重命名插件可以定义成这样module.exports { command: rename from to [dir], description: 批量替换文件名中的字符串, handler: (from, to, dir .) { // 插件逻辑 } }from是必填参数[dir]是可选参数。用户在终端里敲clia rename old new ./docsCommander 会自动把参数传给 handler不需要你在插件里手动解析process.argv也不需要写一堆字符串处理代码去判断参数到底传没传。除了固定参数CLI-Anything 还可以扩展交互式输入。比如某个插件的参数特别多每次敲全很麻烦可以让用户先执行clia weather由插件提示“请输入城市名”输入后再继续。这个体验比让用户记忆一长串参数友好得多。有一点需要特别注意如果插件 handler 是异步函数入口文件必须调用program.parseAsync(process.argv)而不是program.parse(process.argv)。Commander 的parse方法不会等待异步 handler 完成命令可能在请求还没发出去的时候就提前退出了。这个坑我一开始踩过后来所有插件统一用 async 写法入口统一走parseAsync问题才消失。2.3 配置体系把个性化设置抽离出来好的命令行工具应该允许用户通过配置文件调整行为而不是每次都在命令里带一堆参数。CLI-Anything 的配置读取设计在lib/config.js里默认读取用户目录下的~/.clia/config.json。const fs require(fs) const os require(os) const path require(path) function loadConfig() { const configPath path.join(os.homedir(), .clia, config.json) if (fs.existsSync(configPath)) { return JSON.parse(fs.readFileSync(configPath, utf-8)) } return {} } module.exports { loadConfig }配置文件长这样{ defaultCity: 上海, timeout: 5000, editor: code }weather插件在用户没有传城市名时就去读config.defaultCity。这样既保留了命令行的灵活性又照顾了高频场景的便捷性。配置体系最大的价值是把“代码”和“个性偏好”分开。插件代码可以放 Git 仓库里共享配置文件留在每个人的电脑上。比如团队里大家都用同一个clia工具但每个人可以设置自己默认的编辑器、默认的下载目录。这一点差异在传统脚本里往往靠改代码实现容易冲突在 CLI-Anything 里天然就是配置项。3. 从零搭建用 30 分钟做出自己的 CLI-Anything3.1 初始化项目与依赖开始之前先确认本机装了 Node.js 18 以上版本因为我后面示例里的fetch是 Node 18 才内置的。然后建目录、初始化项目mkdir cli-anything cd cli-anything npm init -y npm install commanderlatestpackage.json里需要配置 bin 字段让clia命令可以直接运行入口文件{ name: cli-anything, version: 1.0.0, bin: { clia: ./cli.js }, dependencies: { commander: ^12.0.0 } }创建cli.js加上可执行权限chmod x cli.js到这里项目骨架就搭好了剩下的核心工作都在“注册机制”和“插件文件”里。3.2 实现插件加载内核三处容易写错的地方入口文件cli.js的逻辑并不复杂#!/usr/bin/env node const path require(path) const fs require(fs) const { Command } require(commander) const program new Command() program .name(clia) .description(CLI-Anything: 统一命令行入口) .version(1.0.0) const pluginDir path.join(__dirname, plugins) function loadPlugins(dir) { return fs.readdirSync(dir) .filter(f f.endsWith(.js)) .map(f require(path.join(dir, f))) } loadPlugins(pluginDir).forEach(plugin { program .command(plugin.command) .description(plugin.description) .action(plugin.handler) }) program.parseAsync(process.argv)代码本身很直白但我建议你注意三个细节。第一遍历插件目录时一定要过滤非.js文件。目录里如果有说明文档、临时文件直接 require 会报错而且错误信息很让人摸不着头脑。第二require的路径要基于__dirname拼接不要用相对路径。终端里执行命令时当前工作目录可能是任何地方插件目录相对于入口文件来说位置是固定的所以必须以__dirname为基准。第三action 里直接传plugin.handler是可以的但如果你需要在调用 handler 前做一些通用处理比如打印日志、计算耗时、注入上下文建议包一层代理函数统一把program实例和配置对象传给插件。我用过的最舒服的扩展方式是把loadConfig()的结果挂在program上插件通过this访问。这样代码不臃肿每个插件又都能拿到全局配置。3.3 写两个真实插件批量重命名与天气查询先写批量重命名插件这是文件操作里最高频的场景之一。const fs require(fs) const path require(path) module.exports { command: rename from to [dir], description: 批量替换文件名中的字符串, handler: (from, to, dir .) { const targetDir path.resolve(dir) const files fs.readdirSync(targetDir) let count 0 for (const name of files) { if (name.includes(from)) { const newName name.split(from).join(to) fs.renameSync(path.join(targetDir, name), path.join(targetDir, newName)) console.log( ${name} - ${newName}) count } } console.log(完成共重命名 ${count} 个文件) } }注意我用了name.split(from).join(to)而不是name.replaceAll(from, to)。前者兼容性更好在低版本 Node 里也不会报错。另外代码里先做了一次path.resolve(dir)用户传入相对路径时后续的所有拼接都以解析后的绝对路径为准不容易出错。再写天气查询插件。这里我借用了一个公开天气服务实际使用时你可以把它换成自己熟悉的天气 API。const { loadConfig } require(../lib/config) module.exports { command: weather [city], description: 查询城市天气未指定城市时读取默认配置, handler: async (city) { const config loadConfig() const target city || config.defaultCity if (!target) { console.log(请传入城市名或在 ~/.clia/config.json 中配置 defaultCity) return } const url https://wttr.in/${encodeURIComponent(target)}?format3 try { const res await fetch(url) if (!res.ok) { console.log(查询失败HTTP ${res.status}) return } const text await res.text() console.log(${target}: ${text.trim()}) } catch (err) { console.log(请求出错${err.message}) } } }注意这里的 handler 是 async 函数。fetch一旦超时或断网会抛异常所以必须用 try/catch 包住否则用户会看到一堆 JavaScript 堆栈信息而不是一句友好的提示。3.4 接入 shell全局命令、快捷键与自动补全插件写好后使用npm link把clia链接到全局目录npm link然后执行clia --help这样在任何目录下都可以直接调用 CLI-Anything。如果你觉得clia这个名字不好记可以再设置 shell 别名alias anyclia以后执行any weather 北京也一样。自动补全能大幅提升使用体验。我用的 zsh 方案是在~/.zshrc里加一段补全函数思路很简单解析clia --help里的子命令名生成补全建议。compdef _clia any _clia() { local -a commands commands(${(f)$(clia --help 2/dev/null | awk /^ [a-z]/{print $2})}) _describe command commands }这段代码不复杂但有一个前提插件子命令建议全部用小写字母开头这样awk的正则匹配才稳定。早期我插件里有个API-Test命令大小写混在一起补全列表经常漏掉它。后来统一命名规范问题就消失了。4. 踩坑合集CLI-Anything 使用中的常见问题与排查方法4.1 常见问题速查表用了一段时间之后我把自己遇到过的、以及身边同事踩过的问题整理成了下面这张速查表基本覆盖了 CLI-Anything 最常见的故障场景。现象可能原因解决办法command not found: clianpm 全局 bin 目录不在 PATH 里执行npm config get prefix把输出目录加入 PATH异步请求总是没结果但代码看起来没问题入口用了parse而不是parseAsync把program.parse改成program.parseAsync中文文件名乱码或输出乱码Windows 终端默认编码不是 UTF-8在终端执行chcp 65001或统一在入口文件设置编码插件加载失败报模块找不到require 路径写错了基准目录统一用path.join(__dirname, ...)批量重命名提示目录不存在用户传入的目录不存在代码没做校验先在 handler 里检查fs.existsSync再继续执行同一目录里多个插件互相覆盖命令子命令命名冲突为插件统一加前缀如rename:、task:4.2 编码、路径、异步几个容易忽略的细节第一个容易被忽略的是 Windows 下的编码问题。在 Windows 的 CMD 或 PowerShell 里终端默认编码有可能是 GBK而 Node 脚本输出的是 UTF-8中文就会出现乱码。最简单的处理方式是在入口文件最上面加一段process.stdout.setDefaultEncoding(utf-8)同时建议在文档里提示 Windows 用户先执行chcp 65001切换代码页。第二个是路径分隔符问题。在 Windows 上路径分隔符是反斜杠而 Linux 和 macOS 上是斜杠。插件里拼接文件路径时不要手工拼字符串一定使用path.join。我自己早期写过一个文件归档插件在 macOS 上跑得好好的同事在 Windows 上一跑就找不到文件最后发现问题就出在字符串拼接路径上。第三个是异步调用的错误处理。CLI 工具和普通 Web 服务不一样没有前端页面兜底错误信息就是唯一反馈。插件里每一个可能抛异常的操作建议都用 try/catch 包住并把可读的提示输出到终端。比如网络请求失败你直接打印请求出错xxx用户知道下一步去检查网络但如果你不处理打印一屏堆栈用户只会觉得工具烂。4.3 安全边界插件等于本地代码CLI-Anything 的灵活性和风险来自同一个点插件本质上就是可以在你电脑上执行任意代码的本地程序。你写自己的插件没问题但如果要安装别人分享的插件就要多留一个心眼。不要用sudo运行 CLI-Anything也没必要。普通用户的权限已经足够覆盖绝大多数日常操作。如果某个操作非要管理员权限那大概率说明你需要换个思路而不是盲目给工具提升权限。不要在配置文件或插件里硬编码密码、Token 这类敏感信息。配置文件的权限保护很弱一旦被其他进程读到账号就泄露了。真要集成需要鉴权的服务优先使用环境变量或者系统自带的凭据管理能力。不安装来路不明的第三方插件。和 npm 包一样插件可以访问文件系统、网络、进程环境。用之前先看一遍源码理解它到底做了什么。CLI-Anything 的“万物可接入”理念应该在明确信任边界的前提下使用。5. 从个人工具到团队工作台CLI-Anything 的下一步扩展5.1 用 Git 仓库分发团队插件CLI-Anything 做好之后很快会产生第二个需求怎么和同事共享最简单的方案是把整个项目放到 Git 仓库里团队每人 clone 下来然后各自在自己的机器上npm install npm link。但这里有个问题配置文件会被覆盖。我的做法是在仓库里只提交config.example.json每个人复制成自己的~/.clia/config.json再按喜好调整。代码做到“开箱即用”配置做到“因人而异”这样团队协作才顺畅。如果团队规模更大还可以把插件目录单独切成一个仓库通过 Git submodule 或简单的拉取脚本挂到主项目里。比如plugins/下每个子目录对应一个独立插件包主入口启动时递归扫描。这样的话不同团队维护不同插件互不干扰。5.2 配置化任务编排让 CLI-Anything 自动跑起来单条命令的威力有限多条命令串起来才是流水线。我在配置里增加了一个tasks字段用来定义复合任务{ tasks: { cleanup: [ rename _tmp _archive ./downloads, weather 上海 ] } }再写一个简单插件来执行这些任务const { execSync } require(child_process) const { loadConfig } require(../lib/config) module.exports { command: run taskName, description: 执行配置文件中预定义的命令序列, handler: (taskName) { const config loadConfig() const tasks config.tasks || {} const task tasks[taskName] if (!task) { console.log(未找到任务${taskName}) return } for (const line of task) { console.log( ${line}) execSync(line, { stdio: inherit, shell: true }) } } }有了这个能力你早上只需要敲一条clia run cleanup它就自动完成整个流程。如果配置了 shell 的定时任务甚至可以做到真正的无人值守。需要注意的是execSync会阻塞主进程如果任务列表里有网络请求或者长时间运行的操作建议拆成异步子进程避免把终端卡死。我实际使用中会把耗时操作放在任务列表的最后或者单独用 Cron 去处理这样体验最好。最后说一点个人体会。做 CLI-Anything 这件事表面上是在写代码实际上是在重新审视自己的工作流哪些步骤是真正需要我判断的哪些只是惯性重复每封装一个插件我就会把那件事的流程重新梳理一遍反而比原来更清楚每一步的前置条件和失败风险。如果你也想折腾一个类似的工具我的建议很简单别一上来就想着做大而全的框架先挑一件你每周都会手动重复三次以上的事情把它变成第一个插件。工具会越用越顺手慢慢你就会找到属于自己的命令行工作方式。