最先想清楚的一件事是CLI-Anything 不是一个能被一键 npm install 的现成工具而是我把日常高频操作用命令行重新组织后的产物。当初冒出来的念头很简单——我发现自己在 GUI 里重复做同样的事情太多了重命名几十个文件、批量压缩图片、整理日志、检查远程服务器状态、翻 Markdown 笔记找某个结论。每个动作单独看都不难问题在于它们分散在不同软件里每次都要用鼠标点好几层菜单。于是我决定做一个自己的“命令行总入口”把零散脚本、系统命令、第三方工具全部收敛到一个统一的交互界面上。1. “Anything”是怎么收敛出来的需求盘点与边界定义1.1 先列清单别急着写代码项目启动第一周我没有碰编辑器而是拿一张纸把所有“每周至少做三次”的事情列出来。最终清单大概是三类文件操作、文本处理、系统状态检查。文件操作包括批量重命名、转码、压缩、同步文本处理包括从日志里提取关键字段、把 Markdown 表格转成 CSV、替换多个文件中的重复文本系统状态检查包括查看磁盘占用、连接超时检测、服务进程是否活着。这三类操作有一个共同点它们都是“输入一批东西输出一个结果”而且大概率可以用现有命令行工具组合出来。真正值得自己写的部分其实很薄——不需要重新实现压缩算法也不需要发明一种新的脚本语言我需要做的只是把命令调用方式统一、参数校验做好、输出格式稳定下来让每次执行都能像填一张表单那样直观。1.2 通用命令行工具与个人脚本之间的空档市面上有很多优秀的单点工具比如 fd、ripgrep、jq、ImageMagick它们各自解决一类问题组合使用确实能覆盖大部分场景。但直接裸用它们有两个不舒服的地方一是参数风格不统一fd 的过滤写法、jq 的查询语法、ImageMagick 的选项风格差别很大大脑切换成本高二是高频操作往往需要固定的参数组合这些组合每次手敲很容易出错。传统解决方式是写 shell 别名或 Makefile但它们都有各自的毛病别名只能存放静态字符串没法灵活交互Makefile 擅长“构建”对于“批量的、带输入参数的数据处理”用起来别扭。CLI-Anything 的定位就是填补这个空档——一个极薄的封装层把参数校验、交互选择、输出格式化统一处理底层仍然调用成熟的系统工具。1.3 我给自己定的四条设计约束每个操作必须对应一个 YAML 配置块不写独立脚本文件新增一个操作只需加几行配置。命令模板只做变量替换不使用 eval、不使用 shell 动态拼接杜绝注入风险。交互选择器必须支持关键词过滤因为操作多了之后靠翻菜单找命令是灾难。所有输出统一走 stderr 打日志、stdout 打结果方便嵌套到其他管道里。接下来的所有实现都是在满足这四条约束的前提下展开的。约束不是限制而是把“Anything”这个听起来很虚的词变成可执行方案的第一步。2. 整体架构一个入口加四个命令族2.1 入口与命名anything 是总控子命令是场景主程序入口叫anything对应的 shell 补全、文档、测试都围绕它展开。子命令没有设计成“一堆动词”而是按照使用场景分成了四个命令族子命令职责典型场景anything do执行配置好的批处理操作批量压缩、批量重命名、批量替换anything find在文件、命令历史、配置项中检索找文件、找日志关键字、翻历史命令anything watch定时轮询并输出状态变化检查端口连通、网速波动、服务存活anything make把临时命令固化为可复用模板把敲过一次的复杂命令存入配置库选择这四个名字不是因为它们有多形象而是因为它们在日常沟通中出现频率最高。“帮我 do 一下”、“find 一下那个配置”、“watch 一下服务”叫起来顺口记起来不费劲。子命令多了反而会造成选择困难四个足够覆盖绝大多数个人使用场景。2.2 YAML 配置块把一条复杂命令变成一张填空表单很多人一听说“用 YAML 定义命令”就觉得多此一举认为直接写 shell 函数不香吗。我这么设计的原因只有一个shell 函数对参数的位置要求太严格而且没有任何类型提示写的时候爽三个月后回来看根本不知道某个参数到底期望什么。CLI-Anything 中一个典型的配置块长这样name: images-resize description: 批量缩放 images 目录下的 jpg/png hook: | find {{ input }} -type f \( -name *.jpg -o -name *.png \) -print0 | xargs -0 -I {} magick {} -resize {{ width }}x{{ height }} -quality {{ quality }} {}_small.{{ ext }} params: input: type: path default: ./images width: type: int default: 1280 height: type: int default: 720 quality: type: int default: 80 ext: type: enum[jpg,png] default: jpg这个配置块的价值不在于把命令“存起来”而在于把可变部分显式声明成了params。执行时anything do images-resize会先问你五个问题每个问题都带默认值和类型校验确认以后才用这些值去填充hook里的{{ input }}、{{ width }}等占位符。命令本身并不复杂复杂的是“永远记住正确参数”这件事配置块替我做掉了。2.3 对比 Makefile、alias、脚本管理器的取舍项目里我曾经同时维护着三种执行方式alias、Makefile 目标、零散脚本。CLI-Anything 逐步接管之后我认真比较过一次各自的适用场景alias适合“没有参数永远原样执行”的命令比如把git log --oneline --graph --decorate缩写成gl。一旦需要传参alias 的弱点立刻暴露只能仓促拼接。Makefile适合有依赖关系的构建流程它有天然的 target 依赖图但在数据处理场景下目标和方法并不总是线性依赖写起来反而别扭。独立脚本适合逻辑复杂度高、需要几十行代码才能完成的任务CLI-Anything 的配置块无法承载这种复杂度。我的结论是 CLI-Anything 不去替代任何一种只是接管“中等复杂度的数据处理批处理操作”。一条命令配一个 YAML 块不够写逻辑时才降级为脚本构建链路复杂时仍用 Makefile纯静态命令交给 alias。边界清晰后项目结构反而清爽了许多。3. 参数解析与执行的实现细节接住用户的输入才是最难的3.1 占位符替换看起来简单做起来全是意外第一版实现用了最天真的str.replace直接把{{ input }}替换成用户输入的值然后扔给subprocess.run(shellTrue)。跑通第一个示例时感觉一切正常直到我尝试处理一个带空格的文件名整个命令瞬间裂开。问题不在于替换本身而在于替换后拼接出的整条命令在传递给 shell 时会被二次解析。用户输入./my images到了 shell 眼里就变成了两个参数。更危险的是如果输入里带有$(rm -rf /)或反引号shell 会在执行前先展开它这就是标准的命令注入。CLI-Anything 的最终方案是放弃整条命令字符串的 shell 执行改成逐参数传递import subprocess import shlex def run_hook(hook_template, params): tokens shlex.split(hook_template) resolved [t.replace({{ input }}, params[input]) if {{ in t else t for t in tokens] subprocess.run(resolved, checkFalse)先把 hook 模板用shlex.split拆成 token 列表再逐 token 做占位符替换。这样用户输入即使包含空格、引号、特殊符号最终也只是作为参数列表中的一个独立元素传给subprocess.runshell 完全不会参与二次解析。像xargs -0这类需要拼接的场景也一律通过-0参数让文件内容以 null 字节分隔避免文件名里的换行和空格造成干扰。3.2 三个真实踩过的参数坑第一个坑是通配符提前展开。最初在配置里写find {{ input }} -name *.jpg当{{ input }}被替换成某个路径后只要这个路径下存在匹配的文件shell 就会先展开通配符导致 find 收到的搜索路径变成一串展开后的文件名。解决方法是明确告诉使用者参数值一律视为字面量要匹配模式请在 hook 里用引号包住*。第二个坑是路径含空格引发的连锁故障。单个参数用 subprocess 列表传递可以解决主命令的问题但find ..... -exec sh -c ...这种二次调用的写法会把文件名重新拼成字符串空格问题卷土重来。后来我把所有-exec sh -c改成了-exec tool {} 或配合-print0使用从源头避免字符串拼接。第三个坑是参数校验缺失导致的“假成功”。比如宽度传成了英文abcImageMagick 会直接报错退出但那个错误码被吞掉了CLI-Anything 仍然显示“任务完成”。后来我给每个参数类型加了静态校验int 就用正则匹配数字enum 就检查是否在候选集内path 就检查是否可读。校验失败时立即抛错绝不进入执行环节。def validate_param(param_def, value): ptype param_def.get(type, str) if ptype int: if not re.fullmatch(r\d, value): raise ValueError(f参数 {param_def[name]} 需要整数收到 {value!r}) elif ptype enum: candidates param_def[choices] if value not in candidates: raise ValueError(f参数 {param_def[name]} 只能是 {candidates}收到 {value!r})3.3 执行阶段的状态管理超时、取消、退出码命令行工具如果执行到一半卡死用户通常只能 CtrlC 杀掉整个进程。CLI-Anything 的做法是给每次执行加上超时控制默认 300 秒超过就主动终止并返回 124 退出码。同时把 stdout 和 stderr 分开捕获正常处理结果走 stdout执行进度和错误提示走 stderr这样即使嵌套在其他脚本里也不会污染管道数据。退出码的处理也是重点。很多 shell 命令遵循 0 成功、非 0 失败的约定但不同工具的非 0 码含义差异很大。我统一约定任何子命令的非零退出都记录到执行报告里并在最后汇总输出但不会因为单个文件失败就中断整批任务。批处理场景下部分失败本身就是预期内情况重要的是让使用者看到“哪些成功了、哪些失败、失败原因是什么”。4. 交互体验改造命令行不能让人觉得像黑盒4.1 进度反馈是“能用”和“好用”的分水岭命令行工具最容易犯的错误是执行时毫无输出用户盯着光标闪烁完全不知道任务状态。CLI-Anything 对耗时操作统一做了两件事开始执行时打印任务名称和参数摘要每处理完一个子项就在 stderr 输出一个进度点任务结束输出汇总统计。看起来技术含量不高但这几个输出解决了使用者大半的焦虑感。进度输出我坚持写到 stderr 而不是 stdout。很多命令的 stdout 是要被管道接走继续处理的如果混入进度信息下游解析器会直接崩溃。这个习惯是从 jq 的文档和 man page 里学来的现在成了我所有脚本的统一约定。4.2 一个 150 行的交互选择器比自己想的简单CLI-Anything 的操作列表越来越长之后靠--help翻找变得低效。参考 fzf 的思路我在工具内部实现了一个轻量交互选择器读取 stdin 里的候选列表显示在当前终端支持关键词过滤方向键上下移动回车选中Esc 取消。实现核心不到 150 行 Python完全没用到 curses 库。def select_from(items, filter_text): shown [i for i in items if filter_text.lower() in i.lower()] idx 0 while True: render_menu(shown, idx) key read_key() if key DOWN: idx min(idx 1, len(shown) - 1) elif key UP: idx max(idx - 1, 0) elif key in (BACKSPACE, CHAR): filter_text update_filter(filter_text, key) idx 0 shown [i for i in items if filter_text.lower() in i.lower()] elif key ENTER: return shown[idx] if shown else None elif key ESC: return None渲染部分每一行只输出操作名、描述和当前高亮标记清屏用 ANSI 转义序列实现整体逻辑和普通 Web 前端的列表筛选几乎没有区别。实测下来即使不装 fzf 和 gum这个选择器的日常使用也足够顺滑。4.3 Shell 补全让终端“认识”你的命令CLI-Anything 安装后的第一件事是为 bash 和 zsh 生成补全脚本。补全逻辑并不复杂列出所有 YAML 配置块的操作名让 shell 在输入anything do TAB时直接展示候选操作。参数部分则读取配置块里的 params 定义用户按 TAB 时补出参数名等于把 YAML 配置变成了 shell 自带的提示信息。_anything_completion() { local cur${COMP_WORDS[COMP_CWORD]} local prev${COMP_WORDS[COMP_CWORD-1]} if [[ ${COMP_WORDS[1]} do ]]; then local ops$(anything list --short) COMPREPLY( $(compgen -W ${ops} -- $cur) ) return fi if [[ $prev anything ]]; then COMPREPLY( $(compgen -W do find watch make list -- $cur) ) return fi } complete -F _anything_completion anything补全脚本让命令发现的成本降到了接近零搭配交互选择器后我实际操作时几乎不需要记忆命令名只需要记得大概的语义TAB 两次就能找到目标操作。5. 真实场景实测CLI-Anything 具体干了哪些重活5.1 案例一批量图片压缩从 20 分钟到 30 秒我之前压缩图片用的是 GUI 软件每张图都要打开、导出、选质量几十张图一个下午就没了。CLI-Anything 化之后执行anything do images-resize输入目录、目标尺寸、质量剩下的交给 hook 处理。实测在 800 张图片的目录上跑一遍总耗时约 30 秒全部完成输出报告里列出了 13 张因为源文件损坏而失败的图片路径。这个案例的收获不在压缩本身而在“可重复性”。GUI 操作每次都要重新找菜单、重新调参数CLI 配置固化后下一次执行只需要回看 shell 历史记录就能原样复现这让我真正意识到配置即资产的涵义。5.2 案例二服务器状态巡检报告自动化以前早上第一件事是登录远程服务器敲df -h、ps aux | grep nginx、ping几个命令把输出手动贴到记事本里对比昨天的数据。CLI-Anything 的watch子命令把这三条操作绑定成了一个检查项每 10 分钟轮询一次把结果写进 CSV再用make report生成一个 Markdown 摘要。anything watch server-health --every 600 --template health_check.yaml anything make report --input server-health.csv --format md轮询逻辑本身很简单核心难点在于历史数据对比。CLI-Anything 的做法是给 watch 子命令加了一个diff参数只有当前值相比上次变化超过阈值时才记录一行避免磁盘占用率、内存使用量这些波动数据把 CSV 撑爆。5.3 案例三把零散 Markdown 笔记转成结构化表格写博客和研究笔记时我习惯把灵感随手记在 Markdown 文件里但这些笔记的结构五花八门想统一整理非常困难。我写了一个markdown-scan配置用 ripgrep 抓取所有标题和标签再用 jq 组装成 JSON最终输出为 CSV 或 Markdown 表格交给表格软件处理。name: markdown-scan description: 从笔记目录提取标题和标签输出表格 hook: | rg -n ^#|标签[:]|Tags[:] {{ input }} | sed s/:.*// | awk -F/ {print $NF, $0} | jq -R -s -c split(\n)[:-1] | map(split(\t))这个案例证明了一个观点CLI-Anything 不需要自己实现复杂数据处理只需要用 YAML 把已有工具按正确顺序串起来。rg 负责找sed 负责清洗awk 负责重构jq 负责结构化CLI-Anything 负责让这一串命令可以被反复调用而不出错。6. 维护与测试给“个人工具”最后一层保险6.1 项目半年后回头看最值钱的是文档CLI-Anything 初期只有一个简单的 README列了十几个配置块的用法。三个月后我发现有些配置块已经完全不记得当初的用途尤其是那些带特殊参数的。我从那时起强制自己遵守一条纪律每一个配置块必须写上描述、参数说明、示例命令、预期输出写完之后还要在 README 里生成一份索引表定期更新。文档的另一个部分是我自己的操作记录。每次执行完anything do xxx工具会自动把执行时间、参数、退出码、输出摘要追加到一个audit.log文件里。这个日志在排障时价值巨大——当配置修改后行为异常可以直接回溯到上一次正常执行时的参数和结果快速定位是哪个参数变动引入了问题。6.2 最小但有效的自动化测试个人工具最容易犯的错是“只测 happy path”。CLI-Anything 的测试策略很简单针对每个配置块写三个测试用例一个用默认参数跑通完整流程一个故意传错参数类型验证校验逻辑一个用带空格和特殊字符的文件名验证安全性。def test_images_resize_with_spaces(): runner CommandRunner() result runner.run(do images-resize, input./my images, width100, height100, quality80) assert result.exit_code 0 assert result.stdout.count(_small.jpg) len(files_in_dir(./my images))这些测试不追求覆盖率数字只求锁定“命令不会因为异常输入而爆炸”这条底线。个人的 CLI 工具崩坏率最高的就是边界条件把边界条件固定住日常使用的容错率会大幅提高。6.3 拆掉重写的判断标准CLI-Anything 发展到后期我一度想给它加插件机制、远程同步、Web 管理界面冷静下来后都砍掉了。判断标准是一句话这个功能是否让“执行一条命令”这个动作变慢。插件机制会增加加载耗时远程同步会引入网络不可用时的失败风险Web 管理界面更是直接把重心从终端挪到了浏览器。这些方向不是不好只是不适合“个人命令行工具”这个定位。反过来如果哪一天我发现配置块里开始大量出现“超过 100 行的复杂 shell 逻辑”那就说明事情已经超出 YAML 配置的舒适区应该拆成独立脚本而不是继续往配置里塞。CLI-Anything 的边界不是一个技术限制而是一种自我约束——让工具保持薄、保持快、保持可审计。回看整个项目CLI-Anything 给我最大的启发不是技术选型而是“把高频操作沉淀成可复用资产”这件事本身。当一条复杂命令从“敲过一次就忘”变成“配置化、可检索、可测试”的固定操作所有重复劳动都会慢慢变成积累。如果你也每天被一堆零散命令困扰建议别急着写大脚本先从一条 YAML 配置开始把最常用的那条命令固化下来连续固化十几条之后你自己的“Anything”自然就有了雏形。