CLI-Anything:插件化命令行框架,让重复运维工作自动化
发布时间:2026/9/28 16:28:15来源:尧图网络
先说说我为什么折腾这个项目。干了这么多年开发和运维我最深的感受就是GUI 操作是给“人”看的命令行操作是给“效率”用的。打开图形界面点十个按钮才能完成的事命令行一句话就做完了。但现实问题是日常工作中的需求太杂——批量重命名、日志分析、配置查找、系统巡检、定时备份……每一个都单独写脚本维护成本极高不写脚本又得一次次手工操作重复劳动让人烦躁。所以我自己搭了一个轻量级的命令行框架名字就叫CLI-Anything。它不是一个具体的单一工具而是一个“插件式”的命令行入口你可以把任意操作封装成模块统一通过一条命令来调用并且支持全局参数、配置文件、dry-run 预览、输出格式切换这些工程化能力。说白了就是把“一切皆文件”的 Unix 哲学平移成“一切皆命令”。这篇文章我会把这套框架从思路、架构、核心实现到高频场景的完整写法都摊开讲也包括我踩过的坑。如果你也经常被重复的零碎任务困扰又不想为每个小需求写一堆互不相干的脚本这篇内容应该能帮你省下不少时间。1. 项目定位为什么需要一个“什么都能干”的命令行框架1.1 先想清楚CLI 到底解决了什么问题很多人对命令行的印象停留在“黑窗口里敲命令”觉得那是上古时代的产物。但实际工作中只要你面对的是批量、重复、需要被脚本自动化的任务CLI 的效率优势是碾压级的。举个最直观的对比你想把某个目录下几百个文件按日期重命名用鼠标操作得一个一个来或者靠第三方软件而命令行只需要一条指令还能同时加上“先预览、再执行、出错回滚”的保护机制。我最初做 CLI-Anything 的动机其实来自一次非常糟糕的经历当时我要在十几台服务器上分别执行日志筛选、目录清理和配置备份手头有旧的 shell 脚本、有 Python 脚本、还有一堆临时拼出来的命令。结果就是参数记不住、输出格式对不上、有的机器还没装对应环境。那之后我意识到——缺的不是某个具体脚本而是一个统一的、可扩展的命令入口。只要把任务封装成模块剩下的就是记一条命令的事。1.2 CLI-Anything 的核心定位这个项目要解决的痛点可以归纳成三句话任务入口统一不管底层是 Node.js、Python、shell 还是调用外部工具对外都统一成anything command [options]。模块可插拔增加新能力不需要改框架本体放一个模块文件进去就能被自动识别加载。输出和参数规范化所有模块共用同一套参数解析、配置加载、日志输出、错误处理机制避免每个脚本各自为政。适合用 CLI-Anything 的人我觉得主要分两类一类是跟我一样的开发运维每天大量处理文件、日志、进程、定时任务另一类是产品、运营、数据分析这类“准技术”岗位经常要做批量文本处理、数据格式转换、定时抓取——只要会写最基础的命令规则就能把重复劳动固化成自己的工具箱。1.3 常见方案的对比为什么不自已写一个个单独的脚本我知道有人会说我直接写一个 shell 脚本不就得了是的脚本能解决单次问题。但当你积累到十几个脚本的时候你就会面临几个新麻烦维度零散脚本CLI-Anything参数处理每个脚本自己解析规则混乱统一解析支持全局参数和配置文件输出格式有的输出文本有的输出 JSON没法直接对接下游统一支持 text / json / table 切换错误处理崩了就是崩了没有统一日志统一的错误码、日志级别、堆栈输出可扩展性每次加功能都要复制脚本改参数新增模块文件即插即用自动化对接难以嵌入更大流程输出可机读JSON命令可组合调用所以 CLI-Anything 本质上不是重复造轮子而是给“零散脚本”加了一层统一骨架。到后期你会发现积累的模块本身就是你的个人知识库新机器的环境初始化只需要同步一个配置文件加一个模块目录。2. 整体架构与设计思路一切皆命令但要守规矩2.1 命令分发模型anything module actionCLI-Anything 的命令模型我设计成了二级结构模块 动作。模块是一个功能域动作是该功能域下的具体操作。anything module action [options]举个例子anything file batch-rename --from img_(\d).png --to photo_$1.png --path ./photos --dry-run anything analyze log --file app.log --level ERROR --top 20 anything sys collect --output json --save report.json anything schedule add --cron 0 2 * * * --cmd anything backup run --path ./data这样的好处是命令的语义非常清晰而且为后续自动补全、文档生成、配置校验提供了稳定的解析基础。很多人写 CLI 工具容易犯的一个错误是把所有功能堆成一个个扁平的长命令比如anything-batch-rename-with-regex-and-preview参数又多又难记。拆成模块 动作之后光靠anything --help就可以逐层探索能力不用死记硬背。2.2 插拔式模块加载约定优于配置模块加载我采用了一个极简约定每个模块是一个 JS 文件放在lib/modules/目录下文件导出一个register(program)方法。框架启动时扫描该目录自动注册所有模块。lib/ cli.js # 入口负责全局参数解析 config.js # 配置加载与合并 logger.js # 统一日志输出 loader.js # 扫描 modules 目录 modules/ file.js # 文件操作模块 analyze.js # 日志分析模块 sys.js # 系统信息模块 schedule.js # 定时任务模块 ...为什么用自动扫描而不是在入口文件里手动require因为当你模块数量越来越多时手动维护一份注册列表就成了负担。自动扫描不仅不用改入口代码还让“新增一个模块”的动作降维成了“放一个文件进去”。这和现代开发框架里“约定优于配置”的思路一脉相承。实际使用中我还会在~/.anything/目录下创建一个plugins/文件夹用于加载个人扩展模块这样框架升级不会覆盖你自己的工具。2.3 全局参数与模块参数的分层设计CLI-Anything 把参数分成两层全局参数和模块参数。全局参数写在命令最前面作用于整个调用过程模块参数跟在具体模块后面只对该模块生效。anything --config ~/.anything/config.yml --verbose file batch-rename --from old --to new我定义的全局参数有这些参数说明--config path指定配置文件路径默认读取~/.anything/config.json--verbose输出详细调试信息含调用栈--quiet只输出错误信息适合写进 cron--json输出机读 JSON方便 jq 或下游程序处理--dry-run全局试运行开关模块自行决定如何预览--format text|table|json输出格式是--json的泛化版这里有一个关键设计原则模块不要自己去读全局参数而是通过框架注入。否则每个模块都得重复解析一遍--verbose、--format既增加工作量又容易产生不一致。正确的做法是框架解析完全局参数后把已经处理好的配置对象包括 verbose 标志、输出格式等传给模块。2.4 配置优先级参数 环境变量 配置文件 默认值任何一种配置都可能来自四个渠道如果处理不好优先级就会出现“改了配置不生效”的诡异问题。CLI-Anything 的处理顺序是命令行参数最高优先本次调用生效环境变量如ANYTHING_FORMATjson配置文件~/.anything/config.json或--config指定路径代码内置默认值最低优先举个例子如果配置文件里写死了format: table但你敲命令时加了--json最终输出应该是 JSON。我之前见过不少工具的困惑点就在这——命令行参数永远应该是用户最高权限的意图表达任何配置文件都不该覆盖用户当场敲下的参数。实现时只需要在读取配置后把命令行参数一层层覆写上去即可。3. 核心实现从零搭起 CLI-Anything 的骨架3.1 技术选型为什么用 Node.js语言选型我最终选了 Node.js。原因有三个跨平台省心Windows、macOS、Linux 都能跑不用为各平台 shell 差异写兼容代码。生态成熟commander做命令解析、chalk做彩色日志、js-yaml读配置文件都是现成的轮子。脚本能力不弱虽然性能不如编译型语言但 CLI 工具的性能瓶颈通常不在语言本身而在 IO。对绝大多数文件处理和系统调用来说Node.js 完全够用。当然用 Python 写也完全可以argparse或click同样能实现这套架构思路是相通的。我个人没有选 Python 是因为团队环境里 Node 更容易统一版本而且我后续想把模块分发做成 npm 包Node 天然契合。3.2 初始化项目与依赖安装先建目录、初始化package.json、安装核心依赖mkdir cli-anything cd cli-anything npm init -y npm install commander glob chokidar js-yaml chalk各依赖的职责commander命令解析与帮助文档生成glob文件名匹配递归查找目录时用chokidar文件监听定时任务模块里用js-yaml配置文件解析支持 JSON 和 YAML 两种格式chalk终端彩色输出然后在package.json里声明 bin{ bin: { anything: ./lib/cli.js } }给cli.js加上可执行权限并以 shebang 开头#!/usr/bin/env node3.3 入口文件全局参数的解析lib/cli.js的职责很简单——先加载配置、初始化 logger再启动模块扫描。#!/usr/bin/env node const { Command } require(commander); const { loadConfig } require(./config); const { logger } require(./logger); const { loadModules } require(./loader); const program new Command(); program .name(anything) .description(CLI-Anything: 一切皆命令的个人工具箱) .version(1.0.0) .option(--config path, 指定配置文件路径) .option(--verbose, 输出调试日志) .option(--quiet, 仅输出错误) .option(--dry-run, 试运行不执行真实变更) .option(--format format, 输出格式: text|table|json, text); program.parseOptions(process.argv); const options program.opts(); const config loadConfig(options.config); loadModules(program, config, options); program.parse(process.argv);这里要注意parseOptions和parse我拆开了用。先用parseOptions把全局参数摘出来再去加载配置和模块最后才让 commander 解析完整命令。这样模块注册时就能拿到全局配置模块内部可以根据 verbose 决定是否输出调试信息。3.4 模块加载器自动发现并注册lib/loader.js实现模块扫描const fs require(fs); const path require(path); function loadModules(program, config, globalOptions) { const moduleDir path.join(__dirname, modules); const pluginDir path.join(process.env.HOME || process.env.USERPROFILE, .anything, plugins); [moduleDir, pluginDir].forEach((dir) { if (!fs.existsSync(dir)) return; fs.readdirSync(dir).forEach((file) { if (!file.endsWith(.js)) return; const modulePath path.join(dir, file); const mod require(modulePath); if (typeof mod.register function) { mod.register(program, { config, globalOptions }); } }); }); return program; } module.exports { loadModules };个人插件目录放在~/.anything/plugins/这个路径是为了把“自带模块”和“个人扩展”分开。实际体验下来这样做非常有用比如我换了新电脑只需把~/.anything/整个目录同步过来所有个人模块和配置就都回来了框架本体甚至不用安装太多东西。3.5 一个完整模块的写法以文件批量重命名为例我拿最常用的文件操作模块来示范。模块就是导出一个register函数注册子命令// lib/modules/file.js const fs require(fs); const path require(path); const glob require(glob); function register(program, ctx) { const { logger, format } ctx; program .command(file batch-rename) .description(按正则批量重命名文件) .requiredOption(--from pattern, 匹配文件名用的正则如 img_(\\d)\\.png) .requiredOption(--to template, 替换模板支持 $1 捕获组如 photo_$1.png) .option(--path dir, 目标目录, .) .option(--dry-run, 只预览不执行) .action(async (options) { const files glob.sync(**/*, { cwd: options.path, nodir: true, absolute: true }); const regex new RegExp(options.from); let changedCount 0; for (const file of files) { const filename path.basename(file); const newName filename.replace(regex, options.to); if (newName filename) continue; const newPath path.join(path.dirname(file), newName); if (options.dryRun) { console.log(${filename} - ${newName}); } else { fs.renameSync(file, newPath); logger.info(Renamed: ${filename} - ${newName}); } changedCount; } if (options.dryRun) { console.log([dry-run] 预计影响 ${changedCount} 个文件); } else { console.log(完成共重命名 ${changedCount} 个文件); } }); } module.exports { register };这里有几个细节值得展开requiredOption保证关键参数缺失时直接报错退出避免模块内部到处做判空。--dry-run是每个模块都必须支持的选项输出“将发生什么”而不真正执行。这个习惯能让你在跑批量操作前永远有个安全的预演环节。我自己在实际使用中还会打印新旧文件名对照表这样预览结果一眼就能看清而不是只给个“预计影响 X 个文件”的模糊数字。3.6 统一配置加载与输出格式处理配置加载的核心逻辑是“按优先级合并”。lib/config.js简化实现const fs require(fs); const path require(path); const yaml require(js-yaml); const DEFAULTS { format: text, dryRun: false, logLevel: info, backupDir: ./backup }; function loadConfig(customPath) { const home process.env.HOME || process.env.USERPROFILE; const configPath customPath || path.join(home, .anything, config.json); let fileConfig {}; if (fs.existsSync(configPath)) { const raw fs.readFileSync(configPath, utf8); fileConfig configPath.endsWith(.yaml) || configPath.endsWith(.yml) ? yaml.load(raw) : JSON.parse(raw); } const envConfig { format: process.env.ANYTHING_FORMAT, dryRun: process.env.ANYTHING_DRY_RUN true, logLevel: process.env.ANYTHING_LOG_LEVEL }; return { ...DEFAULTS, ...fileConfig, ...envConfig }; }关于输出格式我提一个比较重要的点当输出格式是 JSON 时日志信息与正式输出必须分离。也就是说调试日志走stderr正式结果走stdout。这样用户执行anything analyze log --json | jq .时日志不会混进管道导致 JSON 解析失败。在我最初的一版实现里就犯过把日志直接console.log出去的错误结果接了管道之后下游程序全都因为多余的日志行而崩溃。4. 实操五个高频场景的模块实现4.1 批量文本替换一处修改全局生效这个模块适合在多个文件里统一替换某个字符串或正则。设计很简单指定目录、指定匹配规则、指定替换模板然后先预览后执行。anything text replace --path ./src --from oldFunction\(\) --to newFunction() --dry-run关键点是要支持文件编码检测。很多文本文件看着是 UTF-8实际上可能是 GBK 或者其他编码。我遇到过最典型的坑就是替换之后中文全部变成乱码。解决办法是读文件时先尝试 UTF-8如果解码出现异常再尝试 GBK写入时统一用 UTF-8 并注意是否带 BOM。匹配规则上区别“字面量替换”和“正则替换”也很重要我用一个--type literal|regex的选项来实现两种模式默认字面量避免用户误写正则导致替换结果和预期不符。4.2 日志分析哪类错误最多一眼看清排查线上问题时我最常用的手段是把日志里的错误按类型聚合。CLI-Anything 的analyze log模块直接支持按正则提取内容并统计 Top Nanything analyze log --file app.log --type error --top 10 --format table内部逻辑是读取日志文件用正则库匹配错误行提取错误码或关键字然后按出现次数排序输出。输出表格包含三列排名、出现次数、错误关键词。这个模块真正方便的地方在于它可以直接接上一个模块——比如先anything text replace批量清理掉旧格式日志里的无用字段再anything analyze log做聚合两个命令用管道串联一气呵成。对于超大日志文件我建议加一个--tail 5000选项只处理文件最后五千行。因为出问题时通常最新的多次报错就足够定位了没必要扫描整个 GB 级别的日志。4.3 系统巡检一条命令拿到全貌系统信息采集模块sys collect的任务是汇总 CPU、内存、磁盘、负载、网络等状态输出成 JSON 或表格。anything sys collect --format json --save /tmp/sys-report.json实现上Node.js 没有直接内置系统信息 API但可以通过读取/proc/下的文件Linux或使用os模块拿到内存和 CPU 信息。磁盘信息则依赖df命令的输出来解析或者调用系统工具获取。我实际使用中会给模块加一个--baseline参数把当前状态和上个时间点的基线做对比直接输出“CPU 使用率上升 20%”“磁盘剩余空间下降 30%”这样有结论的报告而不是冷冰冰的数字。注意不同操作系统的/proc/stat和df输出格式有差异所以这个模块一定要做平台判断保证 Linux、macOS 下都能正常运行。4.4 文件监听与自动执行任务触发好帮手定时任务需求大家都有但我个人更常用的是“监听目录变化后自动触发命令”。比如把文件丢进某个目录就自动完成压缩、转码或备份。anything watch run --path ./inbox --exec anything text replace --from TODO --to DONE --path ./inboxchokidar库监听目录新增、修改、删除事件在事件回调里调用child_process.exec执行指定的命令。我踩过的坑是监听目录内文件被修改触发命令后命令又写回该目录导致再次触发形成死循环。解决办法是事件回调里设置一个短暂延迟并且只响应文件类型的变化忽略目录本身同时明确指定要监听的文件扩展名列表减少无效触发。4.5 定时备份简单可靠的数据保险定时任务模块schedule用node-cron实现支持标准 cron 表达式anything schedule add --name daily-backup --cron 0 2 * * * --cmd bash /opt/backup.sh内部实现就是把任务记录保存到一个 JSON 文件里然后由后台守护进程读取并执行。写这个模块时我最大的体会是任务持久化文件和执行日志必须分开存。任务文件只记录“什么时候执行什么命令”执行日志则记录每次运行的时间和输出两者混淆会导致后续排查困难。另外命令执行前最好把工作目录切换到一个确定的目录比如~/.anything/workdir避免 cron 的默认工作目录和你预期的不同导致命令找不到文件。5. 常见问题与排查技巧实录5.1 命令找不到PATH 与环境问题装好全局命令后终端却报anything: command not found这是最常见的起步问题。原因基本就两个一是npm link没有成功建立软链二是全局 bin 目录不在你的PATH中。排查办法npm link # 在项目目录执行建立全局链接 which anything # 看实际二进制路径 echo $PATH # 检查 bin 目录是否在里面如果是 npm 全局目录未加入 PATH最稳妥的做法是把$(npm prefix -g)/bin加入 shell 配置文件.bashrc或.zshrc。这个问题在 Windows 上更常见一些因为 Node 的全局 bin 目录可能不在系统 PATH 中。5.2 参数解析意外引号在作怪正则表达式里的\d在命令行里经常会因为引号处理不当而失效。比如# 错误正则里的反斜杠在多数 shell 中会先被解释一层 anything file batch-rename --from img_(\d).png --to photo_$1.png实际传递到 Node.js 里的字符串可能已经变成了img_(d).png导致匹配失败。解决办法有两个要么在参数值外面用单引号包裹img_(\d).png要么在正则里把反斜杠写成双反斜杠。我一直以来的习惯是涉及正则的命令一律用单引号并且先在--dry-run下测试匹配是否生效再真正执行。5.3 中文乱码编码不一致问题文件读写、日志输出中文字符乱码绝大多数是因为源文件不是 UTF-8或者终端本身不是 UTF-8 编码。CLI-Anything 的处理原则是读取文本文件时先用iconv-lite做编码嗅探避免直接按 UTF-8 解码导致 GBK 文件乱码。所有输出统一用process.stdout.write且检查终端LANG环境变量是否包含UTF-8。Windows 下建议在命令前设置chcp 65001切换代码页或者直接在 Node 里设置process.env.NODE_ENV。5.4 大文件处理卡顿流式读取第一次用这个框架处理一个 3GB 的日志文件时我直接fs.readFileSync把整个文件读进内存结果进程崩溃了。后来所有文件处理模块都改成了流式读取方式const readline require(readline); const rl readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity }); rl.on(line, (line) { // 逐行处理不占用大量内存 });流式处理配合--tail N参数基本能覆盖 99% 的大文件分析场景。我在项目里也预留了并行处理的空间需要做 CPU 密集型任务时用worker_threads把文件按行数切片分给多个 worker实测在四核机器上提速接近 3 倍。5.5 权限不足批量操作需谨慎批量重命名和删除文件时偶尔会遇到 EACCES 权限错误。我的处理建议是先以当前用户身份试跑一次 dry-run再检查目标目录是否有写权限最后执行真实操作。遇到权限不足时不要无脑sudo因为 sudo 执行的进程环境、PATH、配置路径可能都不一样很容易造成“命令在 sudo 下找不到配置”或“生成的文件归 root 所有”的麻烦。更好的做法是把当前用户加入目标目录的权限组。6. 经验总结与扩展方向这个项目做到现在我最大的一个体会是CLI 工具的价值不在于它本身多复杂而在于它把复杂留给了封装者把简单留给了使用者。我封装一个模块可能需要二十分钟但之后每次使用只需要几秒钟。从次数上看一个高频模块哪怕为我省下两分钟用上一百次就回本了。这也是我把所有琐碎任务都往 CLI-Anything 里塞的根本原因。在实际操作中我还有一个特别推荐的扩展方向就是给 CLI-Anything 加上“命令别名”和“组合命令”的能力。比如我在~/.anything/aliases.yml里定义了backup: - anything file batch-rename --from temp --to bak --path ./data - anything schedule add --name auto-backup --cron 0 3 * * * --cmd tar -czf data.tar.gz ./data然后执行anything go backup框架会按顺序执行这两条命令。这让 CLI-Anything 从“单命令工具箱”升级成了“工作流编排器”。后续我还想加入交互式选择面板让不熟悉命令行的人也可以用方向键挑选命令执行。最后再分享一个小技巧把常用命令写成 shell 函数包装。我自己的~/.bashrc里就有一行alias rranything file batch-rename --dry-run这样我只需要敲rr --from x --to y即使只是临时预览一下匹配结果也能享受框架带来的统一体验。工具这种东西用久了就会形成肌肉记忆而一个好的 CLI 框架值得你花时间把肌肉记忆打磨得更顺手一些。
网站建设高端定制企业官网