最近我把工作里一半以上的重复操作都搬进了终端,靠的是一个叫 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 ./docs,Commander 会自动把参数传给 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-Anything
3.1 初始化项目与依赖
开始之前,先确认本机装了 Node.js 18 以上版本,因为我后面示例里的fetch是 Node 18 才内置的。然后建目录、初始化项目:
mkdir cli-anything && cd cli-anything npm init -y npm install commander@latestpackage.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)}?format=3` 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 any='clia'以后执行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: clia | npm 全局 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 这件事,表面上是在写代码,实际上是在重新审视自己的工作流:哪些步骤是真正需要我判断的,哪些只是惯性重复?每封装一个插件,我就会把那件事的流程重新梳理一遍,反而比原来更清楚每一步的前置条件和失败风险。如果你也想折腾一个类似的工具,我的建议很简单:别一上来就想着做大而全的框架,先挑一件你每周都会手动重复三次以上的事情,把它变成第一个插件。工具会越用越顺手,慢慢你就会找到属于自己的命令行工作方式。