最先想清楚的一件事是: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(shell=True)。跑通第一个示例时感觉一切正常,直到我尝试处理一个带空格的文件名,整个命令瞬间裂开。
问题不在于替换本身,而在于替换后拼接出的整条命令在传递给 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, check=False)先把 hook 模板用shlex.split拆成 token 列表,再逐 token 做占位符替换。这样用户输入即使包含空格、引号、特殊符号,最终也只是作为参数列表中的一个独立元素传给subprocess.run,shell 完全不会参与二次解析。像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使用,从源头避免字符串拼接。
第三个坑是参数校验缺失导致的“假成功”。比如宽度传成了英文abc,ImageMagick 会直接报错退出,但那个错误码被吞掉了,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 执行阶段的状态管理:超时、取消、退出码
命令行工具如果执行到一半卡死,用户通常只能 Ctrl+C 杀掉整个进程。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", width=100, height=100, quality=80) 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”自然就有了雏形。