1. 为什么我要把“一切操作”都搬进终端——CLI-Anything的诞生背景
大概是从我重度使用终端开始,就一直有个很烦的痛点:日常开发里有一堆高频但重复的操作,分布在各种工具和网页里。查个天气要开浏览器,看个汇率要翻APP,调个内网接口要打开Postman,部署个小服务要登录云控制台点半天。哪怕只是想把一个markdown文件转成HTML,我也得想半天该用哪个包、装没装过、参数是什么。这种割裂感其实很消耗注意力,尤其是你正在终端里写代码,思路正顺的时候,切出去开浏览器查个东西,再切回来,上下文早就断干净了。
所以我一直想做一个自己的“万能命令行工具箱”,把所有频繁碰到的操作全部收敛到终端里,统一入口、统一风格、统一输出格式。这也是CLI-Anything这个项目的核心理念:不是再写一堆“一次性”的脚本,而是搭建一套可持续扩展的 CLI 框架,把“任何东西”都变成终端里的一条命令。这里说的“任何东西”,既包括调用外部HTTP接口获取数据,也包括执行本地的重复性任务,还包括把一些你常用的第三方CLI包装成统一的子命令。
CLI-Anything本质上是一个基于 Node.js 的命令行工具框架,核心思路只有一句话:你只需要写一个很小的配置文件(YAML 或者 JSON),声明“命令叫什么、要做什么、参数怎么解析、结果怎么展示”,它就帮你把这些全部变成可执行的终端命令。听起来有点像微服务的“服务编排”,只不过编排的对象是终端命令。这样做的收益很直接:
- 不需要为每个小需求新建一个项目、写一堆脚手架代码;
- 配置即代码,团队里其他成员拿到同一份配置就能跑出同样的命令;
- 新增一个命令的成本从“几小时”降到“几分钟”,因为大部分重复逻辑已经被框架消化掉了。
这个项目的定位也很清楚:适合那些整天泡在终端里的开发者、运维工程师、数据分析师,以及所有想把日常工具链“统一收编”的人。它不追求取代现有的专业工具,而是做一个“终端入口”,把你常用的能力聚合到一起。下面我从设计思路、实操步骤到踩坑经验,把我从零到一把这个项目跑起来的完整过程写给你看。
2. 核心设计拆解:命令动态加载机制与配置协议
在开始写代码之前,我花了不少时间想清楚两件事:一是命令如何“动态加载”,二是配置协议长什么样。这两个问题决定了这个工具好不好用、后不后悔。
2.1 动态加载:为什么不做硬编码注册,而是要做目录扫描
最朴素的做法,是在主程序里维护一个命令注册表,每加一条命令就改一次源码,加一个registerCommand()调用。这个方案在小工具里没毛病,但只要命令一多,主入口文件就会膨胀到没法看,而且每加一条命令都要重新发版。
所以 CLI-Anything 采用了“按目录自动发现 + 按需加载”机制:
- 约定一个命令目录(默认
~/.cli-anything/commands/,项目内也可以用./commands/); - 每次启动时扫描该目录下所有的
.yaml/.json文件; - 每个文件名就是命令名(比如
weather.yaml对应ca weather); - 根据配置文件里的
type字段,把命令路由到不同的“执行引擎”。
这个机制实现起来其实只有几十行代码,但收益非常大:团队里加一个新命令就是往目录里丢一个配置文件,不需要动主程序。我后来还加了一个“懒加载”逻辑:只有真正执行某条命令时才读取并解析对应文件,启动速度几乎不受命令数量影响。
2.2 配置协议:一条命令最少需要哪些字段
在设计配置格式时,我参考了 Claude 的 MCP 工具描述风格,也借鉴了传统 CLI 框架(比如 Commander、Yargs)的参数声明方式,最后定下来一套比较克制但足够用的协议:
name: weather description: 查询指定城市的实时天气 type: http method: GET url: "https://api.example.com/v1/weather" params: city: alias: c type: string required: true description: "城市名称,如 北京" default: "北京" headers: Authorization: "Bearer ${CLI_ANYTHING_API_KEY}" output: format: table fields: - name: 城市 field: city - name: 温度 field: temperature - name: 天气 field: condition这套字段的设计逻辑是这样的:
name和description不用说,是给人看的;type字段决定走哪个引擎,现在支持http、shell、script、template四种,后续可以扩展db、docker等;params定义的是命令的参数协议,包含了参数别名、类型、是否必填、默认值,这套结构跟commander的 option 声明本质上是一样的,但因为是声明式写在配置里,所以可以随时改,随时生效;headers里可以用${ENV_VAR}形式引用环境变量,这样 token、密钥这些敏感信息不会硬编码到配置文件里;output.format定义结果的展示方式,现阶段支持json、table、text三种,后续规划中还有csv和markdown。
有人可能会问:这跟直接写 curl 有什么区别?区别在于:curl 的参数解析、错误处理、输出格式化都得你自己写 shell 逻辑,而配置文件声明完之后,CLI-Anything 帮你统一完成参数校验、错误捕获、超时重试、彩色输出这些脏活。命令多了之后,维护成本差异会非常明显。
2.3 三种执行引擎:HTTP请求之外的另一种“万物”入口
光做 HTTP 调用还不够“Anything”,所以我设计了另外两种引擎来覆盖更多场景:
- shell 引擎:执行本地 shell 命令,适合“读取磁盘信息”“批量处理文件”这类操作;
- script 引擎:执行一段指定的脚本文件(Node.js 或 Python),适合需要写逻辑、需要循环分支处理的场景;
- template 引擎:根据模板生成文件,比如生成项目脚手架、草稿文档、请求报文等。
这三个引擎的配置差异就在type字段和对应的执行参数上,对用户的感知是统一的:敲一条命令,填好参数,拿到结果。这部分我在后面“典型应用场景”里会逐一演示。
3. 从零搭建 CLI-Anything 的完整实操过程
下面这部分是真正的“抄作业”环节。我会按实际开发的顺序,把环境准备、项目初始化、核心模块实现、安装与验证每一步都讲清楚。如果你想直接拿代码去跑,可以照着做;如果想知道“为什么要这样写”,我也把设计考量写在对应的位置。
3.1 环境准备:Node.js 版本与包管理工具的选型
我用的运行环境是 Node.js 18+,因为要用到fetch(Node 18 起原生支持)和node:fs/promises的现代化 API,可以少装不少依赖。如果你还在 Node 16,也问题不大,补一个node-fetch包就行,但没必要。
包管理工具我建议直接用pnpm。不是因为它快,而是因为这个项目是一个“命令式工具库”,依赖树干净很重要,pnpm的隔离策略能避免很多“幽灵依赖”问题。装法很简单,一行命令:
npm install -g pnpm然后初始化项目:
mkdir cli-anything cd cli-anything pnpm initpackage.json里重点改两个字段:bin和type:
{ "name": "cli-anything", "version": "0.1.0", "type": "module", "bin": { "ca": "./bin/ca.js" } }我把命令名取成ca,其实是“CLI Anything”的缩写。短命令名对日常使用体验影响很大,敲cli-anything weather 北京和敲ca weather 北京,感觉完全不一样。你可能以后天天要敲这个命令,所以选一个顺手的短名字非常重要。
3.2 目录结构与核心模块职责分配
项目结构上,我控制在五个文件夹,核心逻辑不超过五百行:
cli-anything/ ├── bin/ │ └── ca.js # 入口文件,负责启动 ├── src/ │ ├── cli.js # 参数解析、命令分发 │ ├── loader.js # 扫描命令目录,读取配置 │ ├── engines/ │ │ ├── http.js # HTTP请求引擎 │ │ ├── shell.js # 本地命令执行引擎 │ │ ├── script.js # 脚本执行引擎 │ │ └── template.js # 模板渲染引擎 │ ├── formatter.js # 输出格式化(table/json/text) │ └── utils.js # 公共工具函数 ├── commands/ # 用户命令配置目录(示例) │ ├── weather.yaml │ └── docker-ps.yaml └── package.json这个结构是经过几轮迭代后稳定下来的。一开始我图省事,把所有逻辑都塞进一个index.js,结果到 300 行的时候就乱到不想看了。拆模块的收益不是说代码少,而是每一块的职责清晰了,加新引擎、改格式化逻辑都只动对应文件,不会“牵一发动全身”。
3.3 入口文件与参数解析:如何实现一条命令的完整闭环
bin/ca.js其实只做一件事:读取process.argv,拿到命令名,分发到src/cli.js。真正的参数解析在cli.js里,核心逻辑用node:util的parseArgs就够了,不需要引第三方库:
// src/cli.js import { parseArgs } from 'node:util'; import { loadCommand, listCommands } from './loader.js'; export async function run() { const { positionals, values } = parseArgs({ allowPositionals: true, options: { help: { type: 'boolean', short: 'h' }, verbose: { type: 'boolean', short: 'v' }, config: { type: 'string', short: 'c' }, }, }); const [commandName, ...restArgs] = positionals; if (!commandName || values.help) { // 打印帮助信息,列出所有可用命令 const commands = await listCommands(); console.log('CLI-Anything - 可用命令:'); for (const cmd of commands) { console.log(` ${cmd.name.padEnd(20)} ${cmd.description}`); } return; } const command = await loadCommand(commandName); if (!command) { console.error(`未知命令: ${commandName}`); process.exit(1); } // 根据配置里的 params 声明,对用户输入的参数做二次解析 const parsedParams = parseParams(command.params, restArgs); const engine = getEngine(command.type); const result = await engine.execute(command, parsedParams); printOutput(result, command.output); }这里有一个容易踩的细节:外层用parseArgs解析的是全局选项,内层还要根据每条命令的params声明再做一次解析。比如ca weather 北京 --verbose,外层的parseArgs会先“吃掉”--verbose,剩下的positionals就是['weather', '北京'],然后内层再按照weather.yaml里params的声明,把北京放到city参数上。这个两层解析的顺序不能反,否则--verbose会被当成位置参数,命令就错乱了。
3.4 命令加载器:目录扫描、缓存与配置解析的细节
loader.js是整个框架的地基。我在这里做了一个很关键的设计决策:扫描与解析分离,并且加缓存。扫描只负责拿到命令文件的路径列表,解析只发生在命令真正执行时。这样首次输入ca查看帮助列表的速度很快——只需要读文件名,不需要解析 YAML。只有当你真正执行某条命令时,才去读配置、校验必填参数、组装请求。
// src/loader.js import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import YAML from 'yaml'; const commandDirs = [ path.join(process.cwd(), 'commands'), path.join(os.homedir(), '.cli-anything', 'commands'), ]; const cache = new Map(); export async function listCommands() { const results = []; for (const dir of commandDirs) { const files = await readdir(dir).catch(() => []); for (const file of files) { if (file.endsWith('.yaml') || file.endsWith('.json')) { const name = path.basename(file).replace(/\.(yaml|json)$/, ''); const raw = await readFile(path.join(dir, file), 'utf-8'); const desc = raw.match(/#\s*@description\s+(.+)/)?.[1] || ''; results.push({ name, description: desc, dir }); } } } return results; } export async function loadCommand(name) { if (cache.has(name)) return cache.get(name); for (const dir of commandDirs) { const file = path.join(dir, `${name}.yaml`); const raw = await readFile(file, 'utf-8').catch(() => null); if (raw) { const config = YAML.parse(raw); cache.set(name, config); return config; } } return null; }几个值得注意的点:
- 支持两个命令目录:当前项目的
commands/和用户全局的~/.cli-anything/commands/,本地命令优先。这个设计让团队项目可以自带一份公共命令,个人习惯的私有命令则放在全局目录里,互不干扰。 - 描述信息用注释提取,而不是启动时就去 YAML 解析整个文件。虽然 YAML 解析本身也不慢,但命令多了以后,列表页响应时间会积少成多。用正则提取
# @description这种注释字段,压测下来性能好很多。 - 缓存是 Map 而非磁盘缓存,因为每次命令进程是独立启动的(CLI 特性决定的),进程内的 Map 缓存已经适用。如果你要做
ca交互式常驻模式(REPL),再考虑持久化缓存。
3.5 格式化输出:让终端结果不再“张牙舞爪”
输出格式化这个模块,看起来不起眼,实际对用户体验的提升极大。我第一次跑weather命令的时候,接口直接吐了一串 JSON,终端里挤成一坨,眼睛根本找不到温度在哪。后来我加了formatter.js,专门处理展示逻辑。
table格式的实现我直接用cli-table3,但之前试过自己用padEnd拼,等宽字符对 CJK 字符宽度计算不准,表格一有中文就歪。这个坑不必自己踩,直接用成熟库最稳。
// src/formatter.js import Table from 'cli-table3'; export function printOutput(result, outputConfig = {}) { const format = outputConfig.format || 'json'; if (format === 'json') { console.log(JSON.stringify(result, null, 2)); return; } if (format === 'table' && Array.isArray(result)) { const fields = outputConfig.fields || Object.keys(result[0] || {}); const table = new Table({ head: fields.map(f => (typeof f === 'string' ? f : f.name)), style: { head: ['cyan'], border: ['gray'] }, }); for (const row of result) { table.push(fields.map(f => (typeof f === 'string' ? row[f] : row[f.field]))); } console.log(table.toString()); return; } console.log(result); }这里有个隐藏的坑,也是我后来才想明白的:HTTP 接口返回的数据结构不可控。有的接口返回{ data: [...] },有的返回{ result: { list: [...] } },如果不做一层“取数路径”的配置,表格格式化根本不知道渲染哪个字段。后来我加了一个output.path配置项,写类似data.items这样的指针,指向要渲染的数组。解析用简单的path.split('.').reduce()循环就够了,不需要引 lodash。这个改动我觉得是这个项目最值得的一处“产品细节”。
4. 把高频操作搬进终端:CLI-Anything 的典型应用场景
框架搭好了,到底能干吗?这一节我用真实场景过一遍,你看完应该会有自己的灵感。
4.1 HTTP 场景:一条命令查询天气、股价、汇率
先看最普通的 HTTP 请求场景。假设我要查天气,我只需要在commands/目录下新建一个weather.yaml,跟我在 2.2 节里写的一样。然后执行:
ca weather 北京终端立即输出一个整齐的表格:
┌──────┬──────┬──────────┐ │ 城市 │ 温度 │ 天气 │ ├──────┼──────┼──────────┤ │ 北京 │ 27°C │ 晴 │ └──────┴──────┴──────────┘这里要注意的是:很多免费天气 API 的响应字段名是英文,你要是直接输出 JSON,每次都要在脑子里做字段映射。但有了output.fields配置,你可以把temp映射成温度,把condition映射成天气,终端输出的就是人话。
同理,我可以再建一个exchange.yaml,type 是 http,url 指向汇率接口,字段映射为货币对、汇率、更新时间。以后要查美元兑人民币,敲一下就得,完全不用开浏览器。
4.2 shell 场景:封装 docker 常用操作,省去记参数
docker 命令本身不复杂,但一些组合操作很容易忘。比如“查看当前目录下所有容器中占用内存最大的三个”,这一条命令的正常写法是:
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}" | sort -k 2 -h -r | head -n 4你确定你能在需要的时候,不打错一个字母地背出这一段?大概率不行。用 CLI-Anything 的 shell 引擎,配置写成这样就行:
name: docker-top description: 查看内存占用最高的三个容器 type: shell command: | docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}" | sort -k 2 -h -r | head -n 4 output: format: text从此以后,ca docker-top一键搞定。这类配置文件的本质,其实是把你脑袋里那些“记得很模糊、每次都要搜索一下”的命令片段,外置成一个稳定的、即敲即用的命令库。
4.3 script 场景:批量重命名文件并生成汇总报告
shell 引擎解决“一行命令”的问题,但有时候逻辑一复杂,写成 shell 反而痛苦。比如我要把一个目录下的所有.png文件按拍摄时间重命名为IMG_0001.png这样的序号格式,然后生成一份旧名到新名的对照表。这段逻辑写 shell 是能写,但可读性差,而且跨平台经常出问题。
用 script 引擎,配置如下:
name: rename-photos description: 批量重命名照片并生成对照表 type: script script: scripts/rename_photos.js params: dir: alias: d type: string required: true description: "照片目录" output: format: table fields: - name: 旧文件名 field: oldName - name: 新文件名 field: newName实际执行脚本的代码(简化版):
// scripts/rename_photos.js import { readdir, rename, stat } from 'node:fs/promises'; import path from 'node:path'; export async function main(params) { const dir = params.dir; const files = await readdir(dir); const pngFiles = files.filter(f => f.endsWith('.png')); const results = []; pngFiles.sort((a, b) => { // 按修改时间排序 return stat(path.join(dir, a)).mtimeMs - stat(path.join(dir, b)).mtimeMs; }); for (let i = 0; i < pngFiles.length; i++) { const oldName = pngFiles[i]; const newName = `IMG_${String(i + 1).padStart(4, '0')}.png`; await rename(path.join(dir, oldName), path.join(dir, newName)); results.push({ oldName, newName }); } return results; }脚本引擎的机制就是:框架把用户参数解析好后,注入main(params)函数,脚本导出的main的返回值交给formatter.js统一渲染。这意味着你写的脚本只需要关心业务逻辑,不用考虑终端交互和输出排版。
4.4 template 场景:一键生成项目脚手架
做前端或 Node 项目的同学应该都有感觉,每次开新项目,复制粘贴那套package.json、tsconfig.json、.gitignore模板特别烦人。template 引擎就是为这个设计的:
name: new-component description: 生成一个标准 React 组件模板 type: template template: templates/react-component.hbs params: name: alias: n type: string required: true description: "组件名称" withTest: alias: t type: boolean default: false description: "是否生成测试文件"模板文件用简单的模板占位符即可,比如把{{componentName}}替换成用户传入的参数。这样生成的文件结构统一、命名规范,团队协作时减少了很多“你的目录结构和我的不一样”这种摩擦力。
5. 开发中踩过的坑:跨平台兼容与终端环境的暗礁
工具写得再顺手,也躲不过真机环境的考验。这一节记录几个我在开发过程中印象最深的问题,按“坑—原因—解法”的链路写清楚。
5.1 Windows 与 Unix 的 shell 差异:不是所有命令都长一样
我一开始主要在 macOS 上开发测试,shell 引擎也没做特判,自我感觉良好。后来在 Windows 上跑,docker-top命令直接输出错误。排查后发现问题出在sort -k 2 -h -r这段管道,Windows 的sort命令根本不是什么 GNU coreutils,参数完全不兼容。
这不是一个能靠“多写几行配置”解决的问题,而是平台抽象问题。现在的做法是:shell 引擎在执行命令前,先判断当前平台,如果是win32,走cmd /C,如果是其他平台,走/bin/sh -c。同时,在配置协议里加了一个platform字段,允许分平台配置命令:
name: docker-top type: shell command: darwin: "docker stats --no-stream --format ... | sort ..." linux: "docker stats --no-stream --format ... | sort ..." win32: "powershell -Command \"docker stats --no-stream --format ... | Sort-Object ...\""这个改动虽然让配置复杂了一点,但换来的是跨平台的一致性。做 CLI 工具,第一原则永远是“在哪个环境都能用”,而不是“在我的环境里能用”。
5.2 中文编码问题:stdout 的字符集陷阱
另一个经典坑是中文输出乱码。我在某次执行包含中文文件名的脚本时,终端输出一堆锟斤拷。原因是 Windows 下 Node.js 的 stdout 默认编码是utf8,但 Windows 的终端(尤其是老版 cmd 和 PowerShell)默认代码页是GBK,两边对不上就是乱码。
有两种解法:
- 在入口文件
bin/ca.js顶部增加process.stdout.setEncoding('utf8'),确保 Node 侧输出显式统一; - 同时建议用户在 Windows Terminal 或 VS Code 的终端里运行,确保 shell 的代码页是
65001(UTF-8)。
这个问题在 macOS 和 Linux 上不存在,但如果你面向的是跨平台用户,测试清单里必须包含 Windows。
5.3 进程不会自动退出:为什么你的 CLI 命令总是“卡住”
还有一个特别容易忽视的问题:用fetch发起 HTTP 请求后,Node.js 进程退出不了,终端一直“卡着”。排查了很久才发现,原因是 HTTP keep-alive 连接没有显式关闭。Node 18 的原生fetch底层用了undici,默认有 keep-alive 机制,连接保持在事件循环里挂着,进程就以为还有事情要做,不退出。
解法是在 HTTP 引擎执行完毕后,拿到undici的全局Agent并调用close():
import { Agent } from 'undici'; const agent = new Agent(); const res = await fetch(url, { agent }); // ... 处理响应 ... await agent.close();当然你也可以不管,用process.exit()强制退出,但那不是优雅的做法——可能会丢掉还在缓冲的输出、中断正在进行的清理逻辑。借鉴的是“让事件循环自然清空”的思路,最好还是显式关闭代理连接。这个坑极其隐蔽,但只要你写过超过 30 秒没有退出的 CLI 命令,八成就会遇到它。
5.4 颜色输出与管道操作:终端美学背后的兼容性难题
我给输出加彩色之后,本地跑得很欢。但有一次同事把结果通过管道重定向到文件,发现里面全是 ANSI 转义字符,文件直接没法看。这是 CLI 工具的经典问题了:在 TTY 环境里可以花哨,但在管道环境里必须干净。
解决方案是判断process.stdout.isTTY——如果是终端,就输出带颜色和表格样式的富文本;如果不是终端(管道或重定向),就输出纯文本或 JSON,方便后续程序消费。cli-table3本身也支持检测,但更稳妥的做法是在formatter.js里做一次统一判断,有 TTY 用完整样式,没有就降级成纯文本。
6. 进阶玩法与扩展思路:从“能用”到“好用”的关键一跃
框架基本能跑之后,我又陆续加了几个提升体验的功能,这里挑三个我觉得最有价值的展开说。它们不需要太多代码,但非常影响日常使用感受。
6.1 全局配置文件与“私有命令”隔离
~/.cli-anything/这个全局目录除了放命令配置,还有一个config.yaml,用来放全局变量。比如有些 HTTP 接口的 baseUrl、token 等。配置协议里引用环境变量的方式前面说过(${VAR}),但环境变量粒度太粗,不适合多环境切换。我后来支持了@global.config的引用语法,让命令配置可以读取全局配置文件里的某个 key。
这个设计的好处在团队场景尤其明显:项目里的公共命令放在仓库的commands/目录,个人习惯的命令和密钥变量放在自己的~/.cli-anything/下,两者互不干扰。公共命令引用的全局变量,各自在自己电脑上配置,不会因为某个人的 token 过期牵连全组。
6.2 自动补全:让命令行体验真正“丝滑”
用 CLI 工具最舒服的状态是:敲几个字母,按 Tab,命令和参数自动补全。我把completion子命令做进去之后,整体的体验上升了一个台阶。
实现原理不复杂:
- 框架启动时如果发现
completion参数,打印一段 shell 函数代码; - 用户把这行代码加到
.zshrc或.bashrc; - shell 在补全时调用
ca completion --bash/--zsh --command <前缀>,返回候选列表。
具体来说,zsh补全函数大概长这样:
# compdef _ca ca _ca() { local -a commands commands=($(ca completion --list)) compadd -- $commands }这个功能本身不难,但每个家里有几百条命令笔记的老终端用户都知道:补全才是 CLI 工具“可用性”和“玩具”之间的分水岭。没有补全,命令多了根本记不住;有了补全,任何新命令你只需要知道前两三个字母就够了。
6.3 与 Git Hooks 结合:把命令写进工作流
最后一个玩法是“把 CLI-Anything 命令嵌入 Git Hooks”。比如我配置了一个ca check-pr命令,它内部串联三条指令:先检查代码格式、再跑单元测试、最后做一次依赖安全检查。然后在.husky/pre-push里写:
#!/usr/bin/env sh ca check-pr || exit 1这样每次git push之前,自动执行一整串质量门槛,而且因为命令本身是声明式配置,团队里的每个人都用同一套标准,哪怕他今天只写了一行代码,也要过这一关。类似的工作流还可以应用到:发布前自动更新版本号、备份数据库、生成变更日志等。CLI-Anything 在这里的角色就是一个“流程编排入口”,把散落在文档里的“发布手册”“上线检查单”变成可执行、可强制、可追溯的实际命令。
7. 给后来者的经验总结:把这些年踩坑换来的教训一次说清
说一千道一万,CLI 工具能不能活下来,最终还是取决于两件事:命令好不好写,命令好不好敲。我在打磨 CLI-Anything 的过程中,最大的体会是——
第一,命令定义的描述必须认真写。description不是可有可无的装饰,它直接出现在ca的帮助列表里。你写完十条命令后回头看,如果每条描述都像“查询天气”这种一眼能看懂的话,这工具就成功了一半。如果你的描述写的是“调用第三方接口获取城市实时气象数据并格式化展示”,没人愿意读第二遍。
第二,不要为了“万物”而万物。CLI-Anything 的理念是“Anything”,但不代表把所有操作都硬塞进来。有些场景天生不适合 CLI,比如复杂的图形化交互、大篇幅的文本编辑。我做这个项目的取舍标准是:如果这个操作能通过一条命令或一个配置文件表达清楚,且执行频率够高,就值得收进来。否则,开着浏览器也不是世界末日。
第三,尽量保持依赖最小化。CLI 工具和 Web 服务的最大区别是启动速度。每多装一个依赖,启动时间就长一点,用户感知的“卡顿”就强一点。CLI-Anything 最终的核心依赖只有两个:yaml和cli-table3,其他能自己写的就自己写。这不是技术洁癖,而是 CLI 工具的生存法则——没人愿意等一个 800 毫秒才响应“查天气”的命令。
第四,命令的退出码要规范。我在最初版本里,引擎执行失败也返回 0(成功),导致 Git Hooks 里的|| exit 1形同虚设。后来统一了错误处理协议:任何引擎失败都必须抛出异常,由外层run()捕获后设置process.exitCode = 1。这个细节在单条命令手动执行时无所谓,但一旦嵌进自动化流水线,退出码就是唯一的“是/否”信号。
我个人实际使用中最喜欢的一个扩展玩法,其实特别简单:我有一个dns命令,用来查域名的解析记录,每次排查网络问题的时候敲一下,比登录各种云控制台方便太多了。工具本身不一定多么高级,但能在你想不起来命令细节的时候,条件反射地敲出正确的指令,就是这类“命令行万物箱”存在的最大价值。
最后再分享一个实际使用中的小技巧:不要在~/.cli-anything/commands/下堆太多一次性命令。我现在的做法是,每周清理一次,把用过但超过一周没再碰的命令移到archive目录。这样主命令列表永远是干净的,留下来的都是“高频刚需”。命令多了,管理成本会反噬你的效率,这是所有效率工具都逃不过的宿命。保持精简,才能让每个命令都是“随手敲出来”的肌肉记忆。
如果你也打算动手做一个类似的工具,或者直接改造成适合自己习惯的命令行工具箱,希望这篇记录能帮你省掉一些弯路。从“记录命令”到“设计一套命令系统”,最难的其实不是技术,而是你想清楚哪些东西值得收敛到这一亩三分地里。