我本来没想过要专门写一个命令行工具,直到上个月末我数了一下自己的~/scripts目录,里面躺了十三个脚本:有清理日志的、有统计代码量的、有批量重命名的、还有一个文件名带 fix 字样的,但我已经完全不记得它当初要修什么问题。那段时间手头两个项目正好在赶进度,每天重复最多的就是三件事:扫代码里遗留的 TODO/FIXME 标记、查某个目录为什么突然膨胀、顺手清掉各种临时文件。
于是我开始琢磨,与其继续往~/.bashrc里堆 alias、在 scripts 目录里翻各种名字都记不全的脚本,不如写一个名字短到根本不需要记忆的命令——它叫cua,读作"咔"。内部全称是 Command Utility Assistant,但大家更习惯叫它"咔",因为用起来的感觉就是"咔"一下出结果。这篇文章完整记录我从零到一写它的过程:为什么要做、怎么从一堆想法里砍到只剩三个功能、核心实现里那些值得注意的细节,以及后来实测中踩过的几组坑。
1. 为什么我要给终端写一个叫 cua 的小工具
1.1 脚本堆积成山之后的真实困境
先说背景。这个项目不是公司立项,也不是某个团队分配的任务,纯粹是个人把日常操作烦到头之后的自救。天天写代码的人,终端里总会有一些"高频但琐碎"的动作:进到项目目录后想看有没有人留了 FIXME、发版前想确认哪些旧的构建缓存占地方、磁盘告警后想快速定位到底是哪个依赖目录在膨胀。
这些事情系统自带命令都能做,只是组合起来很痛苦。比如我想统计某个目录下面有多少 Python 文件和多少 JSON 文件,可以用find加wc组合,但输出格式丑得没法看;想看哪个子目录体积最大,可以用du -sh */再sort,但大目录一多,输出一堆路径,还要自己数。更麻烦的是,这套组合命令我两周不用就会忘,下次又要翻 shell 历史。
1.2 三个高频动作的共性
我花了一个晚上,把自己在终端里执行过的历史命令按次数排了个序,发现排在最前面的操作高度集中:查找代码里的 TODO/FIXME/HACK 并生成报告、查看目录体积分布和文件类型统计、清理特定类型的缓存文件。这三个动作的共同点是:单步执行都很简单,但每一步输出格式都不一样,中间还要拼接、排序、过滤,真正落地操作时往往要敲五六行命令。
这时候我开始意识到,我需要的不是一个"更快的 grep",也不是一个"更好看的 du",而是一个把多步操作收敛成一个固定入口的小工具。它得满足三个条件:名字短到不用记、输出格式稳定到不用想、行为安全到不用怕。
1.3 "一个单词"为什么这么重要
工具的名字长度和它的使用频率其实是强相关的。ls、cd、grep这些命令为什么天天用?因为它们短。终端里的输入成本是很实在的——一个命令如果四个字母以上,还要带一堆参数,我大概率会犹豫要不要直接手写循环。给工具起名cua的时候,我其实是想要那种"敲下去不需要停顿"的感觉,就像按一次快捷键。
这个思路后来也被验证了。我用了一段时间cua之后,发现自己写临时脚本的频率明显变低了。以前遇到重复三次的操作,第一反应是"写个脚本吧",现在第一反应是"这能不能用 cua 做?",因为用现成的收敛入口比造一个新脚本要快得多。
2. 功能收敛比功能堆砌难得多:从 13 个想法砍到 3 条命令
2.1 最初的欲望清单
新手做工具最容易犯的毛病是贪多,我也不例外。第一版设计稿列了 13 个功能:批量重命名、重复文件查找、端口占用检查、批量压缩、编码探测、git 历史清理、日志轮转、缓存清理、TODO 扫描、目录体积统计、大文件定位、文件类型分布、构建产物清理。每个功能我当时都能说出一个"真的很需要"的日常场景。
功能多本身不是问题,问题在于:每多一个功能,命令的 help 信息就多一段,参数就多几个,出 bug 的面就大一圈。一个定位是"个人效率工具"的命令,如果使用前还得看一遍文档,它的价值就已经减半了。
2.2 三条筛选标准
我给自己定下来三条近乎苛刻的保留标准:
- 每周至少用五次,少一次就砍掉,说明还没痛到需要为它做一个命令。
- 能不能用一行系统命令替代?如果能,就不进
cua。 - 参数不能超过一个,超过一个就说明这个功能还不够收敛。
按这个标准过了一遍,13 个想法淘汰到只剩 3 个。批量重命名,我用rename加正则就够了;端口占用检查,一条lsof -i搞定;大文件定位,find -size +100M也很好用。真正留下来的是scan、usage、clean三条子命令。
2.3 为什么现成命令的组合替代不了
有人可能会说,TODO 扫描用grep -rn "TODO"不就行了?目录统计用du -sh不就行了?单看确实行。但实际场景里,我要的并不是"把含 TODO 的行打出来",而是"给我一份按目录归类的报告,去掉第三方依赖目录,只看业务代码,带上最后修改时间和优先级关键字"。这个逻辑用纯 shell 写当然也能写,但写出来基本就是一段几十行的 awk 加 sed 大杂烩,换台机器就得重新调试。
cua的价值不是"检测",而是"把复杂的前置条件和后续处理都封装起来,让使用者只记一个单词"。scan、usage、clean的输出格式是完全统一的,每个命令最多接受一个路径参数,没有需要翻文档才能理解的开关。
3. 核心实现里的几个细节,以及每个选择背后的理由
3.1 为什么用 Python 而不是 Shell 或 Go
做这个选择时我很明确:工具面向的是"人的效率",不是"机器的极致性能"。Shell 脚本写起来是快,但三个命令放到一起的规模肯定会超过五百行,控制复杂度就比较吃力;Go 和 Rust 编译出来确实快,但为了一个个人使用的工具起一个完整的编译项目,我感觉维护成本偏高了。最后落到了 Python 3 标准库,全程零第三方依赖,核心思路是:启动速度可以接受(实测大约 150ms 左右),但代码必须以"可读"为第一优先级。
用 Python 还有个附带好处:三个子命令可以放在同一个项目里,共用路径处理和输出格式化的代码。入口文件只负责参数分发,实际逻辑按子命令拆到commands/目录。项目结构长这样:
cua/ ├── cli.py # 入口:解析子命令并分发 ├── commands/ │ ├── __init__.py │ ├── scan.py # scan:扫描 TODO/FIXME 等标记 │ ├── usage.py # usage:目录体积与文件类型分布 │ └── clean.py # clean:按白名单清理缓存 └── install.sh # 安装脚本:软链到 ~/.local/bininstall.sh做的事情很简单:把cli.py软链到~/.local/bin/cua,这样任何目录下都能直接敲cua,不需要考虑 PYTHONPATH。我在脚本里加了 shebang,配合可执行权限,整体体验和原生二进制没什么区别。
3.2scan子命令:真正的难点是区分"源码"和"噪音"
scan的核心逻辑是递归扫描目录,按正则找出 TODO、FIXME、HACK 标记,然后输出一个带文件路径、行号、内容和优先级的报告。听起来简单,但处理真实项目时有几个很实际的问题:
第一,二进制文件不能碰。如果扫描函数对每个文件都用文本方式读一遍,遇到图片、打包产物会直接报编码错误,也可能拖慢整个过程。我的做法是先做一次文件大小判断,超过 10MB 的直接跳过,然后以二进制模式读文件头,检测内容里是否包含大量空字节来判断是不是文本文件。
第二,依赖目录要跳过。扫描node_modules、.git、dist这类目录没有意义,绝大多数情况下还特别慢。我在实现里维护了一个跳过目录名单,同时也支持用户在家目录下放一个配置文件,按项目类型追加需要忽略的目录。
第三,输出格式要稳定。scan的结果最后可以输出成两种形态:终端直接看到的缩略列表,以及一份 Markdown 格式的待办清单。这样我可以直接开源到团队内部,别人拿去跑一下也能直接看懂。
3.3usage子命令:体积统计里藏着文件系统的小陷阱
usage的功能是递归统计每个子目录的体积,同时按扩展名做归类,直接给出 top 15 的目录和 top 10 的文件类型。用 Python 实现时,最核心的循环其实很朴素:
def scan_volume(root): total = 0 ext_map = {} dir_map = {} for path in Path(root).rglob("*"): try: if path.is_symlink(): continue if not path.is_file(): continue size = path.stat().st_size total += size ext = path.suffix.lower() or "[noext]" ext_map[ext] = ext_map.get(ext, 0) + size parent = str(path.parent) dir_map[parent] = dir_map.get(parent, 0) + size except OSError: continue return total, dir_map, ext_map这段代码里有一个关键的细节:path.is_symlink()必须放在path.is_file()之前。Python 的Path.is_file()默认会跟随符号链接,如果不先排除,你统计出来的体积会把链接指向的文件也重复算一次,而且当链接出现环时可能直接卡死在遍历里。
还有一个细节是关于st_size的:它统计的是表的文件大小,不是磁盘占用。对于普通场景没问题,但如果你要关心那个用了大量小文件导致块开销很大的目录,这里的数字会偏小。我在输出里加了一行提示,说清楚这是"文件总大小"而不是"磁盘占用",避免误读。
3.4clean子命令:安全边界怎么画才靠谱
clean是最危险的一个子命令,因为它要删东西。第一版实现里我踩过误删的坑(后面有详细排查过程),所以在最终版里设计了一套强制安全规则:
- 只允许清理预置白名单里的路径模式,比如
~/Library/Caches/*/xxx-tool/,任何其他模式一律不在默认集合内。 - 删除前必须确认目标目录存在且满足三层校验:真实路径前缀匹配、路径深度不低于三层、预估占用不超过某个上限。
- 默认不直接删除文件,而是先按体积排序预览,让你看到要清理的内容后再加
--confirm真正执行。
我把"安全默认"当成设计底线。与其冒失地做一个"快但可能出事"的工具,不如故意做得慢一点、谨慎一点。对一个个人效率工具来说,出一次事故要损失的信任成本远比省下的那两秒大。
4. 踩坑实录:四段花费一整晚的排查经历
4.1scan偶发内存暴涨:一次性读文件的代价
用了一阵子之后,我发现在某些项目目录上跑scan会偶发内存升高,而且总是在脚本快结束时才出现,一开始很难定位。第一反应是正则回溯的问题,但把正则简化后问题还在。后来我用 htop 盯着一颗比较大的模拟项目跑,发现内存曲线是突然在某个阶段涨上去的——问题不在正则,而在文件读取方式。
原因找到了:早期实现的scan会用path.read_text()读整个文件,一个几百 MB 的自动生成日志被当作文本文件整个读进内存,内存自然爆掉。修复方式很简单,把整文件读取改成逐行读取,超过 10MB 的文件直接标记为"超大文件已跳过",不进扫描范围。
这个排查过程有几个值得记录的地方:不要一上来就怀疑最复杂的环节,先看数据;内存问题尽量不要靠猜,开着资源监视器复现一次就清晰了。
4.2usage在符号链接目录上卡死:rglob隐藏的死循环
usage上线后没几天,我跑一个大型项目时发现它 30 秒都没结束。当时第一反应是目录太大,顺手Ctrl-C看了一眼调用栈——好家伙,卡在某一次递归遍历里不动了。
根因是符号链接环:项目里有几个链接指向了项目根目录的上级,再往上一级又有链接指回自身,形成了一个环。我最初的实现用的Path.rglob("*"),默认情况下它还是会一路跟下去。修复方案很干脆:遍历时统一走os.scandir配合手动记录已经访问过的目录 inode,遇到重复就直接剪枝。现在即使在诡异的符号链接结构下,usage也最多会多花一点时间扫一遍,不会再卡死。
4.3clean差点删错位置:字符串拼接路径的教训
这个坑是我最后怕的一个,好在发生在模拟环境里。早期clean实现清缓存时,路径拼接我用的是字符串+而不是os.path.join。某次测试时我把清理目标的根路径写成了~/tmp-test,却在清理规则里拼出了~/tmp-test2,两条路径在外表上看起来差不多,结果clean把~/tmp-test2里的一堆内容删空了。当时环境里全是模拟文件,影响不大,但整个过程让我出了一身冷汗。
修复不只是换一个函数:我把所有路径操作全部切到了pathlib,同时给删除动作加了三道防线——真实路径前缀必须精确匹配、目标路径层级严格校验、删除前先输出全部文件列表。从那以后我再也没有在clean上出过事故。这里最想提醒的是:写任何带删除能力的工具,路径处理都不能图省事用拼字符串,这一点在跨平台上尤其重要。
4.4 跨平台编码问题:Windows 日志和 macOS 路径同时出事
我在类 Unix 环境里开发的,后来有一版给一个用 Windows 的同事试了一下,在读取一份 GBK 编码的日志时直接抛了UnicodeDecodeError。排查时发现两个独立的根因:
第一,编码问题。在 Windows 上很多日志文件用的不是 UTF-8。我的修复方式是在读取文本时同时尝试 UTF-8 和系统默认编码,都失败就带上errors='replace'兜底,同时声明该文件有编码替换风险。第二,路径分隔符问题。虽然pathlib已经处理掉大部分路径差异,但我发现自己在字符串格式化时还是有一两处' / '拼接,这在类 Unix 上正常,到了 Windows 上就会产生假路径。最终统一改成f"{path}"或者直接用Path对象格式化。
我把这四段经历整理成了一个小表,方便自己以后自查:
| 现象 | 根因 | 修复 |
|---|---|---|
| scan 内存偶发暴涨 | 整文件读入内存 | 逐行读取 + 大小上限 |
| usage 某些目录卡死 | 符号链接成环 | 记录 inode 并剪枝 |
| clean 删错位置 | 字符串拼接路径 | pathlib + 三重校验 |
| Windows 上编码报错 | 非 UTF-8 日志 | 多编码尝试 + 替换兜底 |
4.5 每改一个 bug,都要拿真实目录做回归
这些坑里最核心的教训是:一个工具的价值不仅在于功能,更在于你改完一个 bug 之后,原来能跑的场景没有被影响。我后来整理了一个小的 fixture 集合,里面有一个带着符号链接环的项目结构、一个混着 GBK 和 UTF-8 文件的目录、一个包含超大文件的目录,每次改动代码后都会把这三个场景全部跑一遍。这套回归流程虽然简单,但帮我挡掉了至少两次新引入的问题。
5. 实测数据与使用习惯:快不是唯一标准,顺手才是
5.1 三个子命令的实际耗时
为了确认这个工具没有白做,我在一个大约有 3000 个文件、合计 180MB 左右的模拟项目目录上做了一轮实测。环境是普通笔记本,冷缓存状态:
| 命令 | 耗时 | 内存增量 |
|---|---|---|
| cua usage | 约 1.2s | 约 40MB |
| cua scan | 约 3.8s | 约 60MB |
| cua clean | 约 2.0s | 约 25MB |
作为对比,用系统自带的du -sh看总大小只需 0.05s,但它只给一个数字;要看按目录分布还得配合find加awk,整体算下来也要 2 秒以上,输出格式还要自己调。scan和直接grep的对比也是一样的道理,grep找得快,但把结果整理成按文件分组的 Markdown 格式报告,处理起来同样不省事。
这些数据说明一个点:对这类工具来说,真正的时间瓶颈不在命令本身花掉的那几秒钟,而在于"拼接多条命令、排列输出、记忆语法"时消耗的心智成本。cua宁可多花零点几秒,也要让输出在一眼内可读。
5.2 使用半年后的真实验感
我现在基本养成了三个固定习惯:每次开始新功能前跑一次cua scan,确认没有遗留的 TODO 和 FIXME;每周下班前在项目目录下跑一次cua usage,看看哪个目录在偷偷变大,及时把不需要的依赖清理掉;每月用cua clean清一轮开发工具缓存。三个命令加起来每天消耗的时间不超过两分钟,但它帮我挡住过至少两次"磁盘满了才发现"的尴尬。
如果你也有一个塞满零散脚本的目录、一堆自己想不起来作用的 alias,我的建议是别急着再写新脚本,先花一个晚上统计自己的高频动作,砍掉那些低频需求,然后把剩下的三五个动作做成一个"名字短到不用记"的收敛入口。做完之后你会发现,工具本身写得好不好还在其次,"给自己的终端减轻记忆负担"这个习惯,才是真正值钱的东西。