一句大实话:命令行工具这几年越来越卷,但卷的方向有点跑偏。大家不是缺工具,ffmpeg、ImageMagick、jq、pandoc,随便拎出来一个都是行业里能打的老将,真正缺的是一个能把这些家伙捏到一起的"统一入口"。
我做的这个叫CLI-Anything的项目,核心诉求非常简单:把日常那些零散的、重复的操作,全部收敛成anything <任务名> [参数]这样的命令格式。不管你是要整理下载目录、批量压缩图片、批量重命名视频,还是把一堆 Markdown 转成 HTML,都不用再临时翻文档、回忆参数,统一走一个入口就行。
这篇文章不是想跟你说"我又造了个轮子",而是把这套东西从设计动机到落地实现,再到我实际使用中踩过的坑,完整地拆给你看。如果你也受够了"为了干一件小事,要翻三四个工具文档"的日子,那这个思路应该对你有参考价值。
1. 项目动机:我们真正缺的不是工具,是入口
1.1 被工具碎片化支配的日常
我在写 CLI-Anything 之前,电脑上长期处于一个魔幻状态:下载目录乱七八糟,积压了上百个文件;想给人传一份资料,得先确认对方的设备能不能打开;视频素材存了好几个版本,文件名毫无规律。
每一次处理这些琐事,都是一次"工具切换 + 参数回忆"的痛苦过程。比如给一批视频降码率,我要写:
ffmpeg -i input.mp4 -b:v 1M -bufsize 1M -maxrate 1.5M output.mp4换个需求,给图片统一调整尺寸,又要切到 ImageMagick 或者 sharp 的语法。更别提 JSON 处理要用 jq、文档转换要用 pandoc,每一个工具都是一套独立的语法体系,记忆成本极高。
这还只是"单工具耗时"的问题。更隐蔽的是"组合工具"的成本。整理一个文件,你还得先ls看目录,再写循环脚本,再处理异常情况,最后确认输出——这一连串操作,每一步都可能出问题。
1.2 一个顺手但很痛的点
有一次我整理素材,大概要做四件事:把图片全部转成 WebP、把视频掐头去尾、把文件名里的空格换成下划线、最后生成一个清单文件。
这件事如果纯靠手动命令,少说也要二十分钟。当时我一边敲一边烦躁:为什么没有一种方式,让这些操作变成"一句话"?
于是 CLI-Anything 的想法就冒出来了:如果每个任务都是一段独立逻辑,注册到一个统一的命令路由表里,我只需要记住一句话就行——anything <任务名> <参数>。任务内部怎么调用 ffmpeg、怎么处理文件、怎么输出日志,都不需要使用者关心。
1.3 这个项目适合谁
说真的,CLI-Anything 不是给那些"只想用现成软件"的人准备的,它面向的是这些角色:
- 开发者:经常要在命令行里处理文件、跑脚本、做批量操作,需要一套可以快速扩展的工具集。
- 运维:平时要处理日志、检查配置、批量改文件,把常用操作固化成命令能省大量时间。
- 内容创作者/自媒体:经常要转格式、压缩视频图片、批量重命名素材,需要简单可复用的命令。
- 效率爱好者:喜欢折腾自动化、把重复劳动交给脚本的人。
它的价值不在于"内置了多少功能",而在于"你能多快地把一个新需求变成一条命令"。
2. 架构设计思路:CLI 从来不是一个文件的事
2.1 三层结构:适配、注册与执行
CLI-Anything 整体架构我拆成了三层,每层职责单一:
| 层级 | 职责 | 代表模块 |
|---|---|---|
| CLI 适配层 | 接收用户输入,解析子命令和参数 | src/index.js |
| 任务注册中心 | 管理所有任务,查询、校验、列出任务 | src/registry.js |
| 执行器 | 真正干活的具体任务模块 | src/tasks/*.js |
这个结构其实借鉴了前端路由的思路:CLI 层就像一个路由器,用户输入的命令是 URL,任务注册中心是路由表,执行器就是路由对应的页面组件。
用户输入anything organize --dir ~/Downloads时,实际发生的事情是:适配层把organize识别为子命令,把--dir ~/Downloads解析成参数对象,然后去注册中心查organize对应的执行器,把参数传进去运行。
这样设计的好处非常直接:新增任务时不需要改主程序。你只需要在src/tasks/下新建一个文件,然后在入口处注册一下,任务就立刻可用了。
2.2 插件协议:让每个任务自成一体
我定义了一个最精简的插件协议,每个任务只需要提供几个字段:
{ name: 'organize', // 子命令名,用户在命令行输入的标识 description: '整理目录文件', // 帮助信息里展示的描述 options: [], // 任务支持的参数声明 run: async (args) => { } // 执行函数,接收解析后的参数对象 }这个协议的设计原则是"少即是多"。一开始我考虑过加version、dependencies、hooks之类的字段,后来全部砍掉了。因为任务越轻量,越容易被人理解和使用。
run函数接收的args是一个解析好的对象,比如{ dir: '~/Downloads', recursive: true, dryRun: false }。任务内部不需要再做字符串切割、类型判断,这些琐事全部交给适配层。
这里有个实操心得:协议字段宁可少,不要多。协议定得太重,写新任务的人会有心理负担,看到一个任务文件要写一二百行模板代码,多半就不想写了。轻量协议配合一份简洁的 README,是最好的传播方式。
2.3 为什么选 Node.js 而不是 Python 或纯 Shell
选型这事儿我纠结了挺久,最后选了 Node.js,理由有三个。
第一,JSON 与 JavaScript 对象无缝衔接。CLI 工具大量涉及配置文件和参数解析,用 Node 处理 JSON 几乎零成本,这在 Python 和 Shell 里还要多一步转换。
第二,生态成熟。commander、yargs、minimist、chalk、progress这些库都是现成的,做 CLI 几乎是"开箱即用"。
第三,跨平台性比想象中更好。Node 的fs、path模块对 Windows 的处理比 shell 脚本友好得多,我后面会专门说 Windows 兼容性的问题。
当然,用 Python 也完全可以,协议和分层思路是通用的,语言只是载体。你没有必要为了这个项目重新学一门语言,选你熟悉的就好。
2.4 安全边界:设计时必须想清楚的三条线
命令行工具天然和"执行代码"绑定,安全问题躲不开。我在设计时立了三条铁律:
- 路径参数必须校验:比如整理目录的任务,接收
--dir后要先判断目录是否存在、是否有权限,避免把用户导航到奇怪的地方、做出危险操作。 - 禁止把用户输入直接拼进 shell 命令:需要调用 ffmpeg 或 ImageMagick 时,用
child_process.execFile而不是exec,并且把参数作为数组传递,防止命令注入。 - 增加 dry-run 模式:高风险操作(删除、覆盖、大量移动)在执行前可以加
--dry-run选项,先打印"将要做什么",确认无误后再真正执行。
这三条线看着基础,但能拦住大多数事故。命令行工具犯错,往往不是能力问题,而是"没想到会这样"。
3. 动手实现:从零搭起核心框架
3.1 初始化工程和依赖
先建一个项目目录,用 npm 初始化:
mkdir cli-anything && cd cli-anything npm init -y npm install minimist chalk依赖我只用了两个:minimist负责参数解析,chalk负责终端彩色输出。没用commander是因为想刻意保持轻量,让核心机制能一眼看透。
目录结构如下:
cli-anything/ ├── package.json ├── bin/ │ └── anything.js # 真正的命令行入口 ├── src/ │ ├── index.js # 主入口:参数解析 + 分发 │ ├── registry.js # 任务注册中心 │ └── tasks/ │ └── organize.js # 示例任务:整理文件在package.json里加上bin字段:
{ "name": "cli-anything", "bin": { "anything": "./bin/anything.js" } }然后给bin/anything.js加上执行权限和 shebang:
#!/usr/bin/env node require('../src/index.js');3.2 任务注册中心:三十行代码承载整个体系
注册中心是整个系统的核心,但代码量并不多:
// src/registry.js const tasks = new Map(); function register(task) { if (!task.name || typeof task.run !== 'function') { throw new Error(`任务 ${task.name || '(未命名)'} 必须包含 name 和 run 字段`); } if (tasks.has(task.name)) { throw new Error(`任务 ${task.name} 已存在,请更换名称`); } tasks.set(task.name, task); } function get(name) { return tasks.get(name); } function list() { return Array.from(tasks.values()) .map(t => ({ name: t.name, description: t.description })) .sort((a, b) => a.name.localeCompare(b.name)); } module.exports = { register, get, list };这里有一个重要细节:register方法里的重复任务检测。刚开始我没有加这个判断,结果任务多了以后,某个名字被注册两遍,新注册的覆盖了旧的,排查了半天才找到原因。后来加了tasks.has(task.name)检查,再也没出过这种幺蛾子。
另一个值得说的是list方法做了排序。任务多起来以后,帮助信息的排布直接影响使用体验,按名称排序虽然是个小动作,但会让输出看起来非常规整。
3.3 入口与分发器:解析子命令、调用任务
主入口负责的事情很简单:拿参数、查任务、执行。
// src/index.js const minimist = require('minimist'); const chalk = require('chalk'); const { get, list } = require('./registry'); const organize = require('./tasks/organize'); const tasks = { organize }; Object.values(tasks).forEach(task => require('./registry').register(task)); const rawArgs = process.argv.slice(2); if (rawArgs.length === 0 || rawArgs[0] === 'help' || rawArgs[0] === '--help') { showHelp(); process.exit(0); } const taskName = rawArgs[0]; const task = get(taskName); if (!task) { console.error(chalk.red(`未找到任务: ${taskName}`)); showHelp(); process.exit(1); } const parsedArgs = parseTaskArgs(task, rawArgs.slice(1)); task.run(parsedArgs).catch(err => { console.error(chalk.red(`任务 ${taskName} 执行失败: ${err.message}`)); process.exit(1); }); function showHelp() { console.log(chalk.bold('Usage: anything <task> [options]')); console.log(''); console.log(chalk.bold('可用任务:')); for (const t of list()) { console.log(` ${chalk.green(t.name.padEnd(12))} ${t.description}`); } } function parseTaskArgs(task, args) { const parsed = minimist(args); // 把 options 中声明的参数做默认值处理 const result = { ...parsed }; for (const opt of task.options || []) { const key = opt.name; if (result[key] === undefined && opt.default !== undefined) { result[key] = opt.default; } if (result[key] === undefined && opt.required) { throw new Error(`缺少必填参数: --${key}`); } } return result; }这个分发器其实已经把参数校验和默认值处理都包进来了,执行器拿到的参数就是"干净的"。
这里有个我实际用下来非常受益的设计:执行器永远不直接接触process.argv。所有的参数解析逻辑集中在适配层,任务文件只管从args对象里取值,这让每个任务都变得非常好测试,也因为多个任务之间不会出现"参数解析方式不一致"的混乱。
3.4 第一个任务:5 分钟搞定文件自动分类整理
有了框架,写第一个任务就很快。以"整理下载目录"为例:
// src/tasks/organize.js const fs = require('fs'); const path = require('path'); const RULES = { images: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif'], documents: ['.pdf', '.doc', '.docx', '.txt', '.md', '.xlsx', '.pptx', '.csv'], archives: ['.zip', '.rar', '.tar', '.gz', '.7z', '.bz2'], videos: ['.mp4', '.mkv', '.mov', '.avi', '.webm'], code: ['.js', '.ts', '.py', '.go', '.java', '.sh', '.html', '.css', '.json'], music: ['.mp3', '.flac', '.wav', '.aac'], others: [], }; module.exports = { name: 'organize', description: '将目录中的文件按扩展名分类到子目录', options: [ { name: 'dir', alias: 'd', type: 'string', default: '.', description: '目标目录,默认当前目录' }, { name: 'dryRun', alias: 'dry', type: 'boolean', default: false, description: '只预览不执行' }, { name: 'flat', alias: 'f', type: 'boolean', default: false, description: '忽略嵌套子目录' }, ], async run(args) { const targetDir = path.resolve(args.dir); if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { throw new Error(`目录不存在或不是文件夹: ${targetDir}`); } const files = args.flat ? fs.readdirSync(targetDir, { withFileTypes: true }) .filter(f => f.isFile()) .map(f => f.name) : walkDir(targetDir); const summary = {}; for (const file of files) { const ext = path.extname(file).toLowerCase(); const category = Object.keys(RULES).find(key => RULES[key].length === 0 ? false : RULES[key].includes(ext) ) || 'others'; if (!summary[category]) summary[category] = 0; summary[category]++; if (args.dryRun) { console.log(`[预览] ${file} -> ${category}/`); } else { const sourcePath = path.join(targetDir, file); const destDir = path.join(targetDir, category); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } const destPath = path.join(destDir, file); // 如果目标已存在,加时间戳后缀,避免覆盖 const finalDest = avoidOverwrite(destPath); fs.renameSync(sourcePath, finalDest); } } if (!args.dryRun) { console.log('整理完成,统计如下:'); console.table(summary); } }, }; function walkDir(dir) { const results = []; const stack = [dir]; const root = path.resolve(dir); while (stack.length) { const current = stack.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { if (entry.isDirectory()) { if (!entry.name.startsWith('.')) { stack.push(path.join(current, entry.name)); } } else if (entry.isFile()) { results.push(path.join(root, entry.name)); } } } return results; } function avoidOverwrite(destPath) { if (!fs.existsSync(destPath)) return destPath; const parsed = path.parse(destPath); return path.join(parsed.dir, `${parsed.name}_${Date.now()}${parsed.ext}`); }运行效果:
$ anything organize --dir ~/Downloads --dry-run [预览] 截图2024.png -> images/ [预览] resume.pdf -> documents/ [预览] project.zip -> archives/ [预览] script.js -> code/ $ anything organize --dir ~/Downloads 整理完成,统计如下: images 12 documents 3 archives 2 code 5这里有几个设计细节值得说明。
第一,--dry-run不是可选项,而是必须项。文件移动操作一旦失误很难挽回,先预览一遍能规避绝大多数误操作。我已经把这个习惯固化到所有涉及文件改写的任务里了。
第二,avoidOverwrite函数。批量移动文件时最怕的就是目标目录已有同名文件,直接覆盖会丢失数据。加时间戳后缀虽然丑,但至少安全。
第三,walkDir使用栈而不是递归。虽然 Node 的递归函数对几百个目录没问题,但遇到深层嵌套或极多目录时,递归很容易触发调用栈上限。栈实现虽然多写几行,但可靠得多。
4. 进阶实战:让它真正接管你的日常工作
4.1 统一包装外部 CLI:给 ffmpeg 做一层"记忆保险"
CLI-Anything 最实用的地方在于,可以给那些参数复杂的第三方工具做"记忆封装"。我用得最多的是对 ffmpeg 的封装。
比如批量压缩视频这个需求,参数细节真的恼人。我用一个compress-video任务把它包起来:
// src/tasks/compressVideo.js const { execFile } = require('child_process'); const { promisify } = require('util'); const path = require('path'); const execFileAsync = promisify(execFile); module.exports = { name: 'compress-video', description: '批量压缩视频文件,输出到指定目录', options: [ { name: 'input', alias: 'i', type: 'string', required: true, description: '输入目录或文件' }, { name: 'output', alias: 'o', type: 'string', default: './compressed', description: '输出目录' }, { name: 'rate', alias: 'r', type: 'string', default: '1M', description: '视频码率,默认 1M' }, ], async run(args) { const inputPath = path.resolve(args.input); const outputDir = path.resolve(args.output); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const files = fs.statSync(inputPath).isDirectory() ? fs.readdirSync(inputPath).filter(f => f.endsWith('.mp4') || f.endsWith('.mkv')) : [path.basename(inputPath)]; for (const file of files) { const inputFile = path.join(inputPath, file); const outputFile = path.join(outputDir, path.basename(file, path.extname(file)) + '.mp4'); console.log(`正在压缩: ${file}`); await execFileAsync('ffmpeg', [ '-i', inputFile, '-b:v', args.rate, '-bufsize', args.rate, '-maxrate', `${parseInt(args.rate) * 1.5}M`, '-y', outputFile, ]); } console.log(`压缩完成,输出目录: ${outputDir}`); }, };这里最关键的实践是使用了execFile而不是exec。execFile会把参数以数组形式直接传给可执行文件,不做 shell 解释,这样既避免了空格和特殊字符问题,也杜绝了命令注入风险。这一条经验,是从一次视频文件名里带括号和空格的惨痛教训中得来的。
不过需要说明的是,调用外部 CLI 的前提是这些 CLI 已经安装在系统里,且ffmpeg在 PATH 环境变量中。如果你在别的机器上运行,优先检查依赖是否存在:
$ which ffmpeg这个封装让"需求到命令"的距离缩短了一个量级。以前需要翻文档组合参数,现在只需要:
anything compress-video -i ./raw -o ./output -r 1.5M4.2 任务参数设计:好参数是设计出来的
任务多了以后,参数命名就显得格外重要。我有几个原则:
- 短参数只做别名,不承担主要语义。
-r可能是--rate也可能是--recursive,容易混淆,所以长参数名才是稳定接口,短参数只是顺手给老用户的加速器。 - 布尔参数必须能明确表达"开关"。比如
--dry-run我不用--no-execute,因为后者的否定语义容易让人绕晕。 - 路径类参数统一要求绝对路径。在任务内部用
path.resolve做一次归一化,避免"当前目录在哪"这种隐式状态带来的困扰。
我见过很多 CLI 工具做得烂,不是功能不行,而是参数设计混乱。用户到了一个新任务面前,看--help都猜不出该传什么,这就是设计失败。参数表本身就是任务的文档,我第一次写options时就把描述写清楚,后面省了无数答疑。
4.3 输出反馈:别让用户对着黑屏发呆
CLI 工具最容易被忽略的体验是输出反馈。我定的几条基线:
- 默认不打印冗余信息,只有失败和关键结果会出现。
- 有一个
--verbose选项,打开后打印每个文件的详细处理过程。 - 长时间任务必须有进度反馈,哪怕是简单的"已完成 10/20"也好过傻等。
对于批量任务,我写过一个小工具函数,在终端打印进度行:
function updateProgress(current, total) { const pct = Math.round((current / total) * 100); process.stdout.clearLine(); process.stdout.cursorTo(0); process.stdout.write(`进度: ${current}/${total} (${pct}%)`); }这里要注意的是,写stdout时用process.stdout.write而不是console.log,前者不会自动换行,方便在同一行刷进度。
退出码(exit code)也非常重要。任务成功返回0,异常返回1,这样才能在 shell 脚本里和其他自动化工具安全组合。如果每个任务都静默失败或返回0,那命令管道和 CI 就完全没法用它。
5. 常见问题与排坑实录
5.1 终端提示"anything 不是内部或外部命令"
这通常不是代码问题,而是bin没生效。用 npm 全局安装后,anyting命令会被放到全局 bin 目录里。如果依然找不到,先检查 PATH:
$ npm bin -g $ echo $PATH还有一个容易忽视的点:bin/anything.js文件必须第一行是#!/usr/bin/env node,并且文件有执行权限(Linux/macOS 下chmod +x)。Windows 上 npm 会自己处理.cmd包装脚本,一般不需要手动配置。
5.2 参数解析遇到空格和引号
传路径时如果路径里有空格,shell 会把它拆成两个参数。正确用法是加引号:
$ anything organize --dir "/Users/me/My Downloads"但在任务内部,绝对不要自己再去拼接路径字符串。始终用path.join,并且把从参数里拿到的值视为"不可信输入"。之前有一个 bug 是路径里带!导致 shell 历史扩展,后来统一改成数组传参 +execFile后不再出现。
5.3 大批量文件扫描慢、内存暴涨
最初我用fs.readdirSync一次性读出所有目录项,文件多的时候直接卡住。后来换成"栈 + 分批处理",并且用withFileTypes: true避免多次stat调用。
还有一个性能陷阱是:对每个文件都调用path.join和fs.existsSync,文件多了以后都是不小的开销。优化思路是先在内存里做完整规划,再批量执行文件操作,能减少大量无谓的 I/O。
5.4 Windows 兼容性
Windows 的坑比想象中多:
- 路径分隔符:用
path.join统一处理,不手写/或者\。 - 文件占用:Windows 上文件被其他进程占用时,
renameSync会报EPERM,需要有重试或跳过机制。 - 大小写不敏感:Windows 文件名不区分大小写,但
RULES映射里后缀的大小写假设必须兼容,所以我统一toLowerCase()。 - 符号链接权限:在 Windows 上创建符号链接往往需要管理员权限,所以任务里没依赖 symlink,只用最基础的
rename和copy。
下面是一张速查表,整理了我踩过的坑:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 命令找不到 | PATH 未配置或 bin 文件无执行权限 | npm link后检查全局 bin 目录,文件加 shebang 和权限 |
| 文件移动时 EPERM | 目标文件被占用或权限不足 | 捕获错误并跳过,输出警告,不整体崩溃 |
参数解析结果带_字段 | minimist 会把非短参数塞进_数组 | 只使用声明的 options,不要直接遍历_ |
Windows 下路径多了\ | 手工拼接路径 | 统一使用path.join |
| 大量文件处理无反馈 | 没有进度输出 | 实现updateProgress并开启 verbose |
文件名包含%或& | shell 转义错误 | 用execFile数组传参,不经过 shell |
6. 我的实际使用体会和后续打算
CLI-Anything 从最早的"一个整理脚本"慢慢长成了现在我日常离不开的入口。我最大的感受是:工具的价值在于"让重复的事情不再占用注意力"。以前每次整理文件、转换格式,我都得重新想一遍怎么做,现在只要敲一条命令,剩下的交给任务本身。
踩过几次坑之后,我对"给 CLI 做统一入口"这件事有了更深的体会:它真正的难点不在写代码,而在克制。克制住往主程序里塞功能的冲动,克制住把参数搞得很复杂的冲动,克制住"这个功能我自己用不上但加上也无妨"的冲动。CLI-Anything 最让我庆幸的决定,就是把扩展机制做成了注册制,新增任务只需要写一个文件、调一个register,核心框架基本没怎么动过。
后续我打算再加几个方向:一是把任务配置从本地文件挪到支持远程同步,这样我换电脑后能一键恢复所有习惯;二是准备给每个任务写一个"帮助文档生成器",基于options定义自动生成 Markdown 文档;三是考虑加一个简单的 Web 面板,让不习惯终端的人也能跑同一个任务体系。
如果你也在为同样的工具碎片化问题头疼,不妨先别急着写一堆"一次性脚本",试着搭一个最小可用的注册中心,然后把第一个任务"整理文件"做出来。这个投资回报率,远比你想象的划算。