资讯详情

cua:一个零依赖的 Python 命令行命令管理工具

📅 2026/9/24 21:42:10 | 华诺云谱 👁 阅读
cua:一个零依赖的 Python 命令行命令管理工具
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-cleantitle命令的用途描述一句话讲清清理无用Docker资源tags标签数组关联搜索[docker, clean, disk]cmd要执行的完整命令docker system prune -af --volumessafe标记是否安全直接执行前提示falseusage使用次数用于排序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, encodingutf-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(parentsTrue, exist_okTrue) data {version: 1, commands: commands} with open(path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)注意save_commands里那行ensure_asciiFalse这个参数极其关键。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, keylambda 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(keylambda 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(descriptioncua - 命令高效管理工具) parser.add_argument(query, nargs*, help搜索关键词) parser.add_argument(-a, --add, actionstore_true, help添加命令) parser.add_argument(-e, --edit, actionstore_true, help编辑已有命令) parser.add_argument(-x, --exec, actionstore_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], start1): 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], shellTrue, checkFalse) except KeyboardInterrupt: print(\n执行已中断。) else: print(这条命令有副作用只复制不直接执行。) import pyperclip # 演示说明实际未使用这里需要注意我注释了import pyperclip这只是演示。实际项目中为了零依赖我用的是各平台的剪贴板命令组合macOS 用pbcopyLinux 用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_asciiFalse这个参数。这种事属于“知道是坑就好”但不知道时真的很浪费时间。解决后我养成了一个习惯凡是json.dump写文件一律带上ensure_asciiFalse和indent2无论当前数据有没有中文。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 userexample.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(parentsTrue, exist_okTrue) fd, tmp_name tempfile.mkstemp(dirstr(path.parent), suffix.tmp) with os.fdopen(fd, w, encodingutf-8) as f: json.dump({version: 1, commands: commands}, f, ensure_asciiFalse, indent2) 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从头到尾不是一个复杂项目它就是一个“刚好够用”的工具。但正是这样一个小项目让我体会到了工具和需求匹配的重要性。真正好用的个人工具不是大而全而是让使用者建立完整的“肌肉记忆”——你一抬手就知道怎么用它解决问题。如果你也想动手做类似的东西我的建议是先忍痛用一周不方便的笨办法把自己的真实高频命令一条条记录下来再动手写工具。不要凭空开需求清单那样做出来的工具往往不好用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。