CLI-Anything:一个命令搞定所有命令行工具,告别碎片化
发布时间:2026/9/28 17:14:23来源:尧图网络
一句大实话命令行工具这几年越来越卷但卷的方向有点跑偏。大家不是缺工具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数组传参不经过 shell6. 我的实际使用体会和后续打算CLI-Anything 从最早的一个整理脚本慢慢长成了现在我日常离不开的入口。我最大的感受是工具的价值在于让重复的事情不再占用注意力。以前每次整理文件、转换格式我都得重新想一遍怎么做现在只要敲一条命令剩下的交给任务本身。踩过几次坑之后我对给 CLI 做统一入口这件事有了更深的体会它真正的难点不在写代码而在克制。克制住往主程序里塞功能的冲动克制住把参数搞得很复杂的冲动克制住这个功能我自己用不上但加上也无妨的冲动。CLI-Anything 最让我庆幸的决定就是把扩展机制做成了注册制新增任务只需要写一个文件、调一个register核心框架基本没怎么动过。后续我打算再加几个方向一是把任务配置从本地文件挪到支持远程同步这样我换电脑后能一键恢复所有习惯二是准备给每个任务写一个帮助文档生成器基于options定义自动生成 Markdown 文档三是考虑加一个简单的 Web 面板让不习惯终端的人也能跑同一个任务体系。如果你也在为同样的工具碎片化问题头疼不妨先别急着写一堆一次性脚本试着搭一个最小可用的注册中心然后把第一个任务整理文件做出来。这个投资回报率远比你想象的划算。
网站建设高端定制企业官网