1. 为什么会有 cua:被日常重复命令逼出来的小工具
1.1 一个让人烦躁到忍无可忍的场景
做开发这行,最烦的往往不是写代码,而是每天反复敲那几十条记不全的破命令。Docker 清镜像、查端口占用、进服务器、拉测试数据、改下环境变量、跑一遍回归脚本……每一条我都见过,可真要用的时候,脑子里就剩个模糊印象。翻 shell 历史记录翻半天,还得靠猜。后来用过几款命令管理工具,要么太重,装了一堆用不上的功能,配置学习成本比命令本身还高;要么太死板,存进去容易,想搜出来、改一下、跑起来,步骤多得让人想砸键盘。
于是在一个加班到凌晨的晚上,我建了一个空文件夹,名字就叫cua。这个文件夹后来长成了一个命令行小工具,解决的问题很简单:让我用最快的速度,从自己攒的命令库里找到想要的那条命令,然后一键复制,或者确认后直接执行。现在它已经在我所有工作电脑上跑了大半年,稳定、够快、零依赖,整个项目核心代码不到 300 行。
1.2 为什么不自接用现成方案
我们其实不缺命令管理工具,pet、navi、how2、tldr都试过。它们的定位各有侧重:navi擅长把命令做成交互式速查表,pet侧重剪贴板式快速存取,how2调用 AI 接口实时查答案。但我的需求比较怪,是“命令库 + 快速搜索 + 可执行”三合一,而且必须完全离线。我是一个特别习惯把工具做成自己形状的人,与其花一小时去研究别人的配置语法、改造成自己的工作流,不如花一个晚上写一个只有自己需要的功能的小工具,没有多余的抽象,没有用不到的功能。
所以当时给cua定了三条铁律,到现在都没变:
- 单文件可运行,依赖只允许用 Python 标准库。
- 所有数据存成本地 JSON,可以直接打开看、手动改。
- 交互要快,从输入到出结果不能有明显的等待感,必须在 0.1 秒内完成。
1.3 cua 这个名字的来历
没有多么高深的意思。拼音里“快”的发音接近kuai,但很多人打快了就成了cua。它在我们这行不算规范拼写,却特别形象——敲键盘时指尖一滑,命令就出来了。我故意用它给工具命名,就是想提醒自己:这个工具的唯一使命是快,如果哪天它变慢了、变复杂了,就该删掉重来。
如果你也想自己搞一个类似的个人效率工具,我的建议是先想清楚它服务的场景到底是什么,别贪多。我见过很多人把这种小工具做成“第二大脑”,什么都往里面塞,最后不堪重负被抛弃。cua从一开始就只接受一类东西:可重复执行的命令行操作。
2. cua 的核心设计:一张命令库表 + 三层匹配逻辑
2.1 命令数据组织
命令库的数据结构决定了整个工具的灵活度,我在设计时参考了头部命令工具的通用做法,但做了一点简化。每条命令都包含这些字段:
| 字段 | 说明 | 示例 |
|---|---|---|
id | 唯一标识,用于快速操作 | docker-clean |
title | 命令的用途描述,一句话讲清 | 清理无用Docker资源 |
tags | 标签数组,关联搜索 | [docker, clean, disk] |
cmd | 要执行的完整命令 | docker system prune -af --volumes |
safe | 标记是否安全,直接执行前提示 | false |
usage | 使用次数,用于排序 | 12 |
我刻意把每条命令的字段控制在六个以内。不是因为多加几个字段会更难写,而是因为人是懒惰的,字段越多,存命令的心理负担越重。你想想,如果存一条命令要填描述、填分类、填环境、填注意事项、填创建人,你根本不会坚持用。cua的核心思路是让存取过程短到形成肌肉记忆:选中一条命令,按快捷键,输入执行内容,回车,完事。没有任何中间环节。
命令库的存储使用 UTF-8 编码的 JSON 文件,我特意强调了 UTF-8,因为这背后就有一个我在第三部分会讲的坑——JSON 中文乱码问题。文件结构大概是这样的:
{ "version": 1, "commands": [ { "id": "docker-clean", "title": "清理无用Docker资源", "tags": ["docker", "clean", "disk"], "cmd": "docker system prune -af --volumes", "safe": false, "usage": 12 } ] }2.2 搜索逻辑:三层匹配
工具好不好用,搜索逻辑占了 80%。很多命令工具只做子串匹配,结果是一条命令里明明含有你要的关键词,却因为顺序对不上就搜不到,非常挫败。cua的搜索分三层:
第一层是标题拼音首字母匹配。比如我搜dc,就能匹配docker-clean这样的标题。这层匹配主要用来快速定位高频命令,几乎不需要输入完整单词。
第二层是关键词子串匹配。输入的关键词会按空格拆分,拆出来的每一段都必须出现在title、tags、cmd至少其中一个字段里。比如输入docker clean,它需要既包含docker又包含clean,才算命中。这个逻辑模拟了搜索引擎的 AND 规则,能大幅度降低误报。
第三层是标签前缀匹配。标签本身就是拿来被检索的,所以只要标签开头包含了搜索词,就算命中。比如disk标签能匹配di。我把这一层单独拎出来的原因是,很多命令的文字描述里根本不会出现“磁盘”这样的词,只有加一个标签才能把它捞出来。
三层匹配的执行顺序不是固定依次执行,而是并行计算各自命中分数,最后汇总。我给每层设置了不同的加权系数:标题命中加 3 分,标签命中加 2 分,命令内容命中加 1 分。排序时分数高的排前面,同分再看使用频率。这样用户最常用的命令会自然浮到前面。
2.3 安全边界:哪些命令直接跑,哪些只回显
命令库里的命令分两类。一类是只读、无副作用的,比如查看端口、查看磁盘占用、打印当前目录结构,这类命令可以在确认后直接执行。另一类是有破坏性副作用的,比如删除镜像、清空日志、重启服务,这类命令一律只回显到终端,等待我复制手动执行。
为什么不做成全部自动执行?因为工具是用来提效的,不是用来制造事故的。我见过有人把所有命令都丢给 AI 全自动执行,结果一次误删目录,后悔都来不及。命令管理工具的职责边界应该是“帮助人更快地做决策”,而不是取代人的决策。
这个安全策略的实现也不复杂,就是读取每条命令里的safe字段。如果是false,在交互界面里显示[危险]标记,并且不提供“直接执行”选项。
3. 手把手实现 cua 的完整过程
3.1 目录设计与环境准备
我用了 Python 3.10+,因为新版语法能让代码更短,比如list[str]这类内建泛型。整个项目只有三个文件,这是刻意为之的,做到结构清楚又不需要额外封装:
cua/ ├── cli.py # 命令行入口,负责交互 ├── core.py # 搜索、匹配、排序逻辑 ├── store.py # 数据读写、JSON 管理 └── commands.json # 命令库(用户数据)构建的第一步是在本地初始化一个 Python 虚拟环境:
mkdir cua && cd cua python3 -m venv .venv source .venv/bin/activate为什么不直接把脚本放到全局?因为虚拟环境隔离了 Python 版本和依赖,后面想打包成单一可执行文件也方便。不过在实际使用中,我是直接给cli.py做了一个软链到/usr/local/bin/cua,方便任何目录下都能调用:
ln -s "$(pwd)/cli.py" /usr/local/bin/cua chmod +x cli.py3.2 存储层:读写 JSON 时最容易翻车的细节
store.py是整个项目里踩坑最多的地方。先看代码:
import json from pathlib import Path from typing import Any DEFAULT_PATH = Path.home() / ".cua" / "commands.json" def load_commands(path: Path = DEFAULT_PATH) -> list[dict[str, Any]]: if not path.exists(): return [] with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data.get("commands", []) def save_commands(commands: list[dict[str, Any]], path: Path = DEFAULT_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) data = {"version": 1, "commands": commands} with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)注意save_commands里那行ensure_ascii=False,这个参数极其关键。Python 的json.dump默认会把所有非 ASCII 字符转成\uXXXX的转义序列,也就是说你明明写的是清理无用Docker资源,存进文件里却变成\u6e05\u7406...。文件本身没错,但你手动打开编辑时会崩溃,而且文件中残留一堆转义字符。
再说说路径设计:把命令库放在~/.cua/commands.json,而不是项目目录下。这很重要。因为命令库是用户数据,如果放在项目目录里,每次用 Git 更新工具代码时就会产生冲突。分离之后,代码随便升级,数据不会丢。
3.3 搜索与匹配:核心逻辑
core.py实现了前面说的三层匹配。这里的关键不是代码本身,而是如何避免一个常见的性能陷阱——每条命令都做多次正则编译。
import re from collections import defaultdict from typing import Iterable def split_keywords(query: str) -> list[str]: return [k for k in query.strip().lower().split() if k] def match_score(command: dict, keywords: list[str]) -> int: title = command.get("title", "").lower() cmd = command.get("cmd", "").lower() tags = [t.lower() for t in command.get("tags", [])] score = 0 matched_all = True for kw in keywords: kw_score = 0 if any(t.startswith(kw) for t in tags): kw_score = max(kw_score, 2) if kw in title: kw_score = max(kw_score, 3) elif kw in cmd: kw_score = max(kw_score, 1) if kw_score == 0: matched_all = False break score += kw_score if not matched_all: return -1 # 使用频率作为附加排序权重 score += min(command.get("usage", 0) // 5, 5) return score def search(commands: list[dict], query: str) -> list[dict]: if not query.strip(): return sorted(commands, key=lambda c: -c.get("usage", 0)) keywords = split_keywords(query) scored = [(match_score(c, keywords), c) for c in commands] scored = [item for item in scored if item[0] > 0] scored.sort(key=lambda x: (-x[0], -x[1].get("usage", 0))) return [c for _, c in scored]我解释几个设计决策:
- 关键词全部转小写,保证搜索大小写不敏感。
- 每个关键词在单个命令里只取最高分层的分数,避免“标题和标签同时命中”导致同一条命令的得分虚高。这是借鉴了搜索引擎中“一个文档里同一关键词只算一次”的思路。
- 使用频率的加分设置了上限 5 分,避免高频命令永久霸榜。这样偶尔搜到但确实更好的命令也有机会浮上来。
标题首字母匹配藏在这里:kw in title。因为我把docker-clean和dc都放在标题里,所以搜dc其实是子串匹配,并不需要额外做拼音转换。这算是用一个小技巧绕开了拼音库的依赖。如果你想支持真正的拼音首字母匹配,可以引入pypinyin,但那就违背了零依赖的原则,所以我没做。
3.4 交互界面:不引入第三方库也能获得不错的选单
交互部分使用标准库的argparse解析参数,然后进入一个简单的选单循环。
import argparse import shlex import subprocess import sys from core import search from store import load_commands, save_commands def run() -> None: parser = argparse.ArgumentParser(description="cua - 命令高效管理工具") parser.add_argument("query", nargs="*", help="搜索关键词") parser.add_argument("-a", "--add", action="store_true", help="添加命令") parser.add_argument("-e", "--edit", action="store_true", help="编辑已有命令") parser.add_argument("-x", "--exec", action="store_true", help="直接执行选中的命令") args = parser.parse_args() commands = load_commands() if args.add: add_command(commands) return query = " ".join(args.query) results = search(commands, query) if not results: print("没有匹配的命令。") return for idx, cmd in enumerate(results[:10], start=1): danger = " [危险]" if not cmd.get("safe", True) else "" usage = cmd.get("usage", 0) print(f"{idx:>2}. {cmd['title']}{danger} (使用{usage}次)") print(f" {cmd['cmd']}") choice = input("输入序号回车执行/复制,q退出: ").strip() if choice.isdigit(): selected = results[int(choice) - 1] handle_selected(selected, args.exec)选单界面的信息密度是刻意控制的。只显示标题、危险标记、使用次数和命令内容,不显示标签、不显示 id,避免视觉噪音。这种设计让屏幕能容纳更多结果,10 条足够,再多其实有点选择困难了。
handle_selected里包含两种行为:
def handle_selected(selected: dict, exec_now: bool) -> None: safe = selected.get("safe", True) if exec_now or safe: print(f"执行: {selected['cmd']}") confirm = input("确认?(y/N) ").strip().lower() if confirm != "y": print("已取消。") return try: subprocess.run(selected["cmd"], shell=True, check=False) except KeyboardInterrupt: print("\n执行已中断。") else: print("这条命令有副作用,只复制不直接执行。") import pyperclip # 演示说明,实际未使用这里需要注意,我注释了import pyperclip,这只是演示。实际项目中为了零依赖,我用的是各平台的剪贴板命令组合:macOS 用pbcopy,Linux 用xclip或wl-copy。这一点可以用一个函数封装。
3.5 添加命令:让工具自举的关键
一个命令管理工具如果不能方便地添加命令,那就废了。add_command的设计遵循“最少提问”原则:
def add_command(commands: list[dict]) -> None: title = input("用途描述: ").strip() cmd = input("命令内容: ").strip() tags_input = input("标签(逗号分隔): ").strip() tags = [t.strip() for t in tags_input.split(",") if t.strip()] safe = input("是否安全(无副作用)? [y/N]: ").strip().lower() safe = safe in ("y", "yes") new_cmd = { "id": title[:20].replace(" ", "-").lower(), "title": title, "tags": tags, "cmd": cmd, "safe": safe, "usage": 0, } commands.append(new_cmd) save_commands(commands) print("已添加。使用 cua <关键词> 搜索。")这套交互没有做复杂的表单校验,输入空标题就退出的逻辑也没写。我承认这有点粗糙,但实际用下来,反而觉得粗糙点好——不会有那么多“系统提示”来打断你。你只是快速往里灌了一条命令,它安静地存下来就完事。
4. 实测中遇到的坑与排查链路
4.1 JSON 中文乱码和ensure_ascii的教训
这个问题我在前面提了一嘴,但排查过程值得完整记录。第一次写完save_commands后,我打开commands.json,看到的一整行是这种:
"\u6e05\u7406\u65e0\u7528Docker\u8d44\u6e90"当时以为是自己编码写错了,程序运行的时候读出来又没问题。后来排查发现,读取没问题是因为json.load会自动把\uXXXX还原成中文,问题只出现在保存环节。这是 Python 的json模块设计如此,不是 bug,但确实让人意外。
排查链路是这样的:我打印了save_commands后文件的内容,再用hexdump看字节,发现数据完全合法,就是转义了。最后反复看文档,才想起ensure_ascii=False这个参数。这种事属于“知道是坑就好”,但不知道时真的很浪费时间。解决后我养成了一个习惯:凡是json.dump写文件,一律带上ensure_ascii=False和indent=2,无论当前数据有没有中文。
4.2 子进程执行命令时环境变量丢失
另一个比较隐蔽的坑是环境变量。有段时间我在cua里直接跑ssh系列命令,明明终端里手动执行没问题,但从cua里执行就报command not found。排查过程让人抓狂,因为手动跑就是好的,通过工具跑就废。
最后我把视野转向了子进程环境。原来我用的subprocess.run没有指定env,按道理它应该继承父进程环境,但问题是终端里我用了pyenv、nvm这类环境管理工具,它们会在 shell 的配置文件里动态设置PATH。而cua是从一个非交互式 shell 启动的,没有加载.bashrc/.zshrc,所以PATH里压根没有那些被动态添加的路径。
解决方式有两种:一是在启动cua时采用交互式 shell 的方式加载配置,二是干脆在配置里写明完整路径。我选了第二种,更稳,不依赖用户 shell 配置。所以我在命令库里存ssh命令,会写成:
/usr/local/bin/ssh user@example.com -i ~/.ssh/id_ed25519这样不管在哪个环境里执行,都能找得到。如果你的命令依赖某个特定版本的 Python 或是 Node,也建议写全路径或者把工具软链到/usr/local/bin下,避免不必要的混乱。
4.3 终端宽度与长命令换行
长命令回显时有个容易忽略的小问题:终端宽度不够,命令会换行,然后选单的编号就对不齐了。我在 Linux 的 GNOME Terminal 上没遇到,但在 iTerm2 里经常遇到。尤其命令里有长 URL 或长参数时,一行显示不下,换行后直接占了下一行的位置。
解决思路也不复杂:检测终端的列数,超过列宽的文本用省略号截断。
import os def fit_width(text: str, max_width: int = 80) -> str: if len(text) <= max_width: return text return text[: max_width - 1] + "…"至于终端宽度怎么获取,os.get_terminal_size().columns是标准库最简单的方式。不过我这里偷了个懒,固定用 80 字符。对大多数终端来说够用了,如果你想要完美显示,可以根据终端宽度动态调整。
4.4 Ctrl-C 中断后留下半截状态
最后一个坑出现在交互录入命令时。如果用户在input()输入过程中按了Ctrl-C,程序会直接抛KeyboardInterrupt退出。如果此时已经打开了一个临时文件,或者在写入 JSON 的中途被打断,可能留下一个半截的commands.json。
排查链路:某次添加命令时手滑按了 Ctrl-C,之后再次运行cua报 JSON 解析错误。我以为是命令库文件坏了,检查后才发现是保存那一步正好被打断,写了一半就退出了。
修复策略很简单,加一层异常处理,并且在退出前保证文件原子性写入。原子性写入的做法是:先往临时文件写,写成功后os.replace,这个操作在 Linux 上是原子的,不会产生半截文件。
import os import tempfile def save_commands_atomic(commands: list[dict], path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump({"version": 1, "commands": commands}, f, ensure_ascii=False, indent=2) os.replace(tmp_name, path)自打用了这个写法,再也没有出现过命令库损坏的问题。写文件这种操作,十个工程师里大概只有一两个会在一开始就考虑原子化,但等你真正被坑过一次,就会永远记住。
5. cua 的进阶玩法:把单机工具变成协作工具
5.1 用 Git 管理命令库
cua的命令库是纯 JSON 文件,天然适合放进 Git 仓库管理。我建了一个私有仓库,专门存commands.json,然后通过软链把它指到一个共享目录:
~/.cua/commands.json -> ~/workspace/cua-commands/commands.json这样每次修改命令后,执行git commit就完成了一次版本快照。好处太多了:误改可以回滚;换了新电脑,git clone一下就能恢复所有命令;团队里几个人共享这个仓库,大家都能扩充命令库,遇到好命令直接同步。
团队协作时,我还给自己加了一个 “review 机制”。毕竟提交到共享仓库的内容会影响别人,所以每次从远端拉下来后,我会用git diff看一下云端多了哪些命令,发现有意思的才保留到本地。这套流程虽然简单,但让命令库设置变得更加谨慎,不容易塞进一堆私人的一次性命令。
5.2 模板变量与参数化命令
静态命令库解决了“发现”问题,还没解决“变通”问题。比如我有一条创建备份的命令,里面的日期每天都不一样。早期我只能存一条模板,每次用的时候手动改日期,麻烦。
后来我给cua加了一个简单的变量替换机制。命令内容里支持{today}、{yesterday}、{env:变量名}这类占位符:
{ "id": "backup-db", "title": "备份数据库到备份目录", "tags": ["backup", "db"], "cmd": "mysqldump -u root mydb | gzip > /backup/mydb_{today}.sql.gz", "safe": true }执行前,工具会替换{today}为当前日期,{env:变量名}会从环境变量里取值。替换完成后仍然遵循先回显后确认的规则。这一改动让命令库的适用范围一下子扩大了,不只是静态命令,还可以是能够配合当天上下文来执行的动态命令。
5.3 使用频率统计与“命令瘦身”
cua记录每条命令的使用次数。这个数据看似简单,但没有它,搜索结果排序就会很原始。我每隔两周会做一次“命令瘦身”:把使用次数为 0 且超过 30 天没被搜索出来的命令归档到archive.json。这个区别很关键,归档不是删除,而只是把它从主搜索里移出去,保持主库精简,将来需要还能找回来。
有一次我清理了 40 多条闲置命令,发现搜索响应从感官上的“很快”变成了“明显更快”。虽然数据量不大,但减少候选条目本身就能降低心理负担,这个收益超过了实际性能提升。
5.4 下一步:接入 AI 做自然语言搜索
现在cua用的是关键词匹配,已经足够快。但有一个场景始终处理不好:我记得目的但不记得关键词。比如我想“清一下 Docker 的缓存”,我脑子里没有一个具体命令术语,只有这个目的。
目前我的解决方式是给这类命令加了很多目的型标签,比如docker clear、docker cache clean、docker free space。但标签的数量终究有限,遇到没打过的标签就搜不到。
我最近在尝试的一个方向是让本地大模型解析自然语言,把用户输入“帮我清理下 Docker 的缓存”转换成后端搜索逻辑,而不是直接生成命令。因为生成命令不可控,风险太高,但识别意图并转成标准标签是安全的。这个方案还在测试,思路已经清楚:数据流是“自然语言输入 -> 模型识别意图与标签 ->cua关键词搜索 -> 回显确认”。等这条路跑通,cua就能更接近它名字的初衷——快,再快一点。
写在最后的一点经验
cua从头到尾不是一个复杂项目,它就是一个“刚好够用”的工具。但正是这样一个小项目,让我体会到了工具和需求匹配的重要性。真正好用的个人工具,不是大而全,而是让使用者建立完整的“肌肉记忆”——你一抬手就知道怎么用它解决问题。如果你也想动手做类似的东西,我的建议是:先忍痛用一周不方便的笨办法把自己的真实高频命令一条条记录下来,再动手写工具。不要凭空开需求清单,那样做出来的工具往往不好用。