
聊一个我最近花两个晚上搓出来的小工具名字叫cua全称 Command Utility Assistant说白了就是一个跑在终端里的“命令管家”。它解决的痛点是日常开发中总有随手写在某个角落的脚本、经常敲但总记不全的命令、散落在各处快照里的临时操作等真要复用的时候不是翻历史就是翻文档效率低得让人抓狂。这个工具能把常用命令和脚本片段集中管理按标签检索再通过一条cua run xxx直接执行特别适合经常写脚本、有大量可复用低频操作、想好好打理个人数字化工作流的人。这篇就记录一下我从设计到落地的完整过程以及踩过的几个大坑。1. 为什么会有这个工具从“记不住”到“管不住”1.1 我的脚本散落现场先说说这个工具出现之前的日常。我电脑里大概有三个地方装满了“临时”脚本第一个是~/bin里面既有正经工具也有只跑过一次的测试脚本第二个是各种项目的scripts/目录里面堆了一堆只有当时那个项目能用的部署脚本第三个是聊天工具里那个“文件传输助手”记不清什么时候传了个release.sh上去换新电脑之后完全忘了这回事。真正让我崩溃的一次是在部署前端项目时。整个部署流程大概是六条命令按顺序执行拉代码、跑构建、跑测试、打包、传到服务器、登录服务器重启进程。那天我漏了构建那一步直接把旧包传上去了线上愣是跑了一个小时旧版本。这种事你没法靠“下次注意”解决因为人脑对低频操作的记忆就是不可靠的越是偶尔干一次的事越容易在步骤之间跳行。后来我试过把部署流程写成一个完整的.md文档放在项目仓库里。文档确实写得清楚但每次要用的时候还是得先找到文档、打开编辑器、复制第一行命令、回终端粘贴执行再切回文档复制第二行……这种来回切窗口的成本在紧急排障的时候会被无限放大。更麻烦的是文档里写的是“示例参数”真正执行的时候还得临时替换成环境对应的地址和分支替换错了又是一次线上事故。1.2 方案选型为什么做成 CLI 而不是 GUI 或网盘有了痛点之后我第一反应是找现成工具。市面上的命令备忘工具其实不少有些做得还很好最后没选它们的原因很现实要么太重装一个 Electron 应用就为了记几条命令要么太慢打开要好几秒要么太封闭数据存在某个云端服务里没法用自己的 Git 管。后来我干脆在纸上列了一下需求发现真正高频场景只有三个往里面存命令要快、找命令要快、执行命令也要快。顺着这个思路答案就自己冒出来了——只有终端里的 CLI 工具能同时满足这三个条件。终端本身就是开发者触达速度最快的界面敲一个cua run deploy不过几毫秒跟打开一个图形界面找按钮完全不是一个量级。做个简单的对比CLI 方案和另外几种方案的核心差异在这几个维度对比维度CLI 工具本地 GUI 应用云端文档/网盘首次触达耗时秒级秒到分钟级分钟级离线可用性完全离线依赖本地安装需网络/缓存数据可迁移性纯文本Git 友好依赖导出格式依赖平台可组合性能 grep、管道、脚本调用差差这轮对比让我确定了一条原则工具本身的复杂度必须低到可以随时重写它只负责存储和调用不负责画界面也不负责在线协作。后面所有设计决定都围绕这条原则展开。2. cua 的核心设计拆解2.1 三层结构仓库、条目、调用我设计的cua把整体拆成了三层仓库层、条目层、调用层。仓库层就是一个真实目录默认在~/.cua里面放所有条目文件条目层是每一个独立的 YAML 文件一个条目就是一条可复用的命令或脚本调用层负责把条目读出来、替换参数、交给 Shell 执行。这个结构最大的好处是“目录即仓库文件即条目”。你不用理解任何数据库概念往里ls一下就知道自己存了多少东西。每个条目由名称、描述、标签、脚本模板四部分组成长这样name: deploy description: 构建并部署到测试服务器 tags: [deploy, test, frontend] usage: cua run deploy --branch main script: | git pull origin {{branch}} make build make test scp dist/app.tar.gz usertest-server:/opt/app/ ssh usertest-server systemctl restart app当时我也有过用 SQLite 或者 JSON 存储的念头最终拍板用 YAML 是因为三个理由第一纯文本可读打开就能看懂不用专门写导出程序第二可以直接丢进 Git哪天改坏了还能git diff回看第三以后想迁移到别的工具拿一个脚本把 YAML 全转走就行了不存在数据库锁死的问题。这种“随时可退出”的设计能让人用得特别安心。2.2 四个子命令add / run / list / sync最小可用的cua只做了四个动作对应四个子命令add负责交互式录入新条目run负责执行已有条目list负责检索sync负责用 Git 做跨设备同步。功能不多但刚好覆盖我所有的使用场景。cua add做的是交互式引导流程是输入名称、输入描述、输入标签、粘贴脚本主体最后确认保存。之所以做成交互式而不是纯参数是因为脚本主体往往很长一行命令写不完整交互式粘贴体验最自然。保存后自动生成刚才那种 YAML 文件同时检查命名冲突重名会直接拒绝写入避免后面执行时定位歧义。cua run deploy --branch main是最核心的调用动作。它读入deploy.yaml把{{branch}}替换成main然后把整个 script 段交给系统默认 Shell 去执行。为什么要特意支持这种占位符而不是直接存死命令因为真实场景里命令的变数太多了部署分支、目标环境、端口号、日期几乎每个命令都有几个“每次可能不同”的位置。没有模板机制就意味着同一个操作要存好几个变体检索起来特别乱。cua list支持按名称模糊搜索、按标签过滤和全量列表三种方式。一开始我只做了全量列表但条目超过二十个之后就发现纯列出来根本扫不到目标于是补上了过滤逻辑。现在cua list deploy会列出所有名称或标签里带deploy的条目配合终端的高亮显示找东西非常快。cua sync更像是一个工程化保障。它的实现思路就是确保~/.cua是一个 Git 仓库每次调用 sync 时先提交本地新增和修改再拉取远端变更最后推送本地。有了它我换电脑后只需执行一次git clone所有命令模板就全回来了。2.3 模板与占位符参数感知的命令占位符机制是整个工具的灵魂。刚开始我用的是最简单的字符串替换直接.replace({{branch}}, branch)跑了一周发现两个问题一是替换顺序存在隐患如果两个占位符名字有包含关系比如{{branch}}和{{branch_name}}按顺序替换会把第二个也污染二是替换后的值需要可靠地传给 Shell不能因为参数里带空格或引号就变形。后来我把解析逻辑改成“先提取所有占位符再统一替换”并用命名参数的方式传入 Shell。具体做法下文会详细写。这里想说的是给命令模板引入参数能力本质上是在“命令”上建立了一层非常薄的抽象你不必记住某条命令的所有位置参数只需要知道cua run deploy --branch main哪个值放在哪里。这个抽象每节省一次思考都是在极高频地降低出错概率。有了这种参数化能力之后我能做很多有意思的事。比如我存了一个weekly-report条目内容是git log --since{{week_start}} --prettyformat:%h %an %s ~/weekly_report.txt每周一跑一次cua run weekly-report --week_startlast monday五分钟内就能把上周所有人提交的东西汇总出来。这种把“低频但繁琐”的工作沉淀成模板的用法才是cua最大的价值。3. 实操过程与核心环节实现3.1 环境准备与最小实现骨架我选择了 Python 3 来做这个工具标准库足够覆盖所有功能不需要装任何第三方依赖。为什么不用 Go 或 Rust因为我的使用场景里根本没有性能瓶颈而 Python 的字典和 YAML 友好度在快速迭代时太舒服了。如果你更希望跑得飞快下次完全可以用 Go 重写一套相同协议这也是目录加 YAML 设计带来的好处。项目结构非常简单~/.cua/ entries/ deploy.yaml restart-app.yaml weekly-report.yaml cua.py cua.sh核心程序只维护一个ENTRIES_DIR路径加上argparse解析子命令。先看最关键的run和add部分#!/usr/bin/env python3 import argparse import os import re import subprocess import sys from pathlib import Path BASE_DIR Path(os.environ.get(CUA_HOME, Path.home() / .cua)) ENTRIES_DIR BASE_DIR / entries PLACEHOLDER_RE re.compile(r\{\{\s*(\w)\s*\}\}) def load_entry(name: str) - dict: entry_path ENTRIES_DIR / f{name}.yaml if not entry_path.exists(): sys.exit(f[cua] 条目 {name} 不存在先执行 cua add 添加) import yaml with open(entry_path, encodingutf-8) as f: return yaml.safe_load(f) def fill_script(script: str, pairs: dict): used set() def _replace(match): key match.group(1) used.add(key) if key not in pairs: sys.exit(f[cua] 缺少参数 {key}可用参数为 {pairs.keys()}) return pairs[key] result PLACEHOLDER_RE.sub(_replace, script) return result def cmd_run(args): entry load_entry(args.name) script fill_script(entry[script], vars(args).get(parameter, {})) shell os.environ.get(SHELL, /bin/bash) proc subprocess.run(script, shellTrue, executableshell, textTrue) sys.exit(proc.returncode)这里的写法有几点很实用用正则\{\{\s*(\w)\s*\}\}只匹配{{名称}}形态的占位符顺带兼容了{{ branch }}这种带空格的写法used集合被设计用来记录脚本里出现的所有占位符如果用户传了多余参数可以忽略如果缺参数则直接报错退出。我特意把 YAML 解析放在函数内而不是模块顶部这样即使机器上没有pyyaml工具本身依然能启动到错误提示那一步而不是一上来就崩溃。add子命令的逻辑同样不复杂def cmd_add(args): name args.name if (ENTRIES_DIR / f{name}.yaml).exists(): sys.exit(f[cua] 条目 {name} 已存在不允许覆盖) print(粘贴脚本主体结束后单独输入一行 END 确认) lines [] while True: line input() if line.strip() END: break lines.append(line) entry { name: name, description: input(描述): tags: input(标签逗号分隔).split(,), script: \n.join(lines), } ENTRIES_DIR.mkdir(parentsTrue, exist_okTrue) with open(ENTRIES_DIR / f{name}.yaml, w, encodingutf-8) as f: import yaml yaml.safe_dump(entry, f, allow_unicodeTrue, sort_keysFalse) print(f[cua] 已保存 {name})写完这个最小闭环后我立刻开始往里面存真实条目每天存两三个两周后积累了近三十个高频命令整个工具已经变成了日常不可或缺的一部分。这里有个很深的体会新工具刚做出来时往往会因为“不够顺手”而被抛弃破解方法是第一天就强迫自己把它用在真实任务上把最痛的那条部署流程存进去而不是先纠结要做什么完美功能。3.2 与 Shell 集成让 cua 裸奔在终端程序本身写好后还需要做一层“终端接入层”否则每次敲python3 ~/.cua/cua.py run deploy太拖沓。我做的接入方式是在.bashrc/.zshrc中加一行别名alias cuapython3 $HOME/.cua/cua.py有了这一行cua run deploy才能保住“快”这个核心体验。但这里有一个非常重要的坑Shell 别名默认不会被子 Shell 继承也就是说如果你在cua的脚本里执行cua list系统会报“command not found”。解决办法是在脚本入口使用完整路径或者把别名定义也写进~/.bashrc并在脚本头部 source 一下。我个人直接用完整路径反正也不依赖别的工具递归调用自己。第二个集成点是让run的执行环境跟当前终端一致。上面 Python 代码里特意用了os.environ.get(SHELL)去拿当前用户的默认 Shell并用executable参数传给subprocess.run这就回避了“脚本写的是 bash 语法而用户在 zsh 下执行”的兼容问题。如果你存的是纯 POSIX 命令几乎不会有感知如果你存了 zsh 特有的alias动态展开在 bash 下就会出现诡异问题。我的建议是条目一律写bash -lc作为执行前缀让所有条目有一个统一的解释器避免猜来猜去。还有一个容易被忽略的点执行完命令后cua run是否要回传退出码。答案是必须回传。因为很多人会把cua run deploy cua run health-check连起来用如果工具吞掉了退出码后面的就不起作用了整个串联逻辑全乱。我上面代码里sys.exit(proc.returncode)干的就是这件事。3.3 Git 同步与多机使用跨设备同步是工具能不能长期用下去的关键。我的方案不搞任何中心化服务直接让~/.cua变成一个 Git 仓库。做法就是标准的“本地提交 远程推送”但对个人工具来说有几个细节值得注意。sync子命令的完整流程是先进入~/.cua目录git add -A所有改动用一个临时分支名提交比如sync-$(date %s)然后git pull --rebase拉取远端最后git push。--rebase很重要因为两台机器同时修改不同条目时默认的 merge 会产生一个难看的合并提交rebase 则始终保持线性历史看git log会清爽很多。我踩过的第一个坑是换行符。Windows 机器默认会检查 CRLF如果某次在 Windows 上编辑了 YAML再回 Linux 同步整个文件可能被自动转换Git 看到一个大 diff。后来我用echo * textauto ~/.cua/.gitattributes强制 Git 统一处理为 LF这个问题基本绝迹。第二个坑是敏感信息。命令模板里经常出现服务器地址、用户名甚至密码这种东西一旦推到远端仓库就有泄露风险。我的建议是需要密钥的地方一律不写死改用 Shell 环境变量占位比如scp dist/app.tar.gz $DEPLOY_USER$DEPLOY_HOST:/opt/app/而$DEPLOY_USER这些值放在根目录之外的~/.cua.env里并且.gitignore掉。这样同步的只是“命令形状”秘密永远留在本机。第三个坑是并发编辑下的文件丢失。我用 rebase 策略后基本没有冲突但万一两台机器同时新增了同名条目Git 会在 rebase 时停下。这种场景别慌git status看哪个文件冲突手动保留想要的那一版git add后git rebase --continue就行。数据文件一般很小冲突不会太痛苦。4. 常见问题与排查技巧实录4.1 执行环境不一致导致的诡异报错存了第一批条目后我最常遇到的麻烦是“在终端里手动敲没事一通过cua run就怪”。排查下来大部分原因是 shebang 缺失或不对。比如一个 Python 脚本片段写的是python3 script.py这在 bash 里没问题但如果你存了一个只写了print(hi)的片段并且让它作为可执行文件直接运行系统就会按照当前 Shell 的解析规则搞出各种输入重定向错误。排查手段很简单先file查看脚本类型再用which确认解释器路径。如果你发现某种奇怪字符被 Shell 展开掉了十有八九是因为cua的脚本字符串被直接放进了subprocess.run的shellTrue分支而shellTrue会先跑一遍当前 Shell 来解析整段文本遇到$、反引号、通配符都会被提前处理。遇到这种场景建议在条目里给关键命令加上单引号保护或者把整个片段用一个bash -lc ...的引号壳包起来。4.2 占位符替换与 Shell 引号地狱参数化方便但引号问题是被反复摩擦的地方。设想你的条目里有一条ssh userhost grep {{keyword}} /var/log/app.log当你执行cua run query --keywordERROR: timeout时替换后的字符串变成了grep ERROR: timeout没问题可如果关键字里本身带有单引号比如ERROR: its timeout替换后直接语法崩溃。我最后的解决方案是放弃把参数直接拼进命令字符串的浪漫想法改用数组传参。Shell 支持把参数作为位置参数传给子进程只要不经过字符串拼接引号问题就自然消失。实现上把script段改成调一个固定的entry.sh而所有参数通过环境变量注入比如在cmd_run里设置os.environ[CUA_ARG_keyword] value脚本里写grep $CUA_ARG_keyword。这种间接层让每个参数都是独立的环境变量彻底规避了引号解析地狱。4.3 同步冲突与误删恢复有一次在两台机器上工作A 机器改了deploy.yamlB 机器忘了 pull 又改了同一个文件并 push直接在远端制造了一个分叉。等 A 机器执行cua sync时Git 提示需要先拉取。我当时的处理方式是git fetch然后git log --oneline --graph --all查看两条分支的最新提交挑更完整的那一版覆盖再git push --force。个人工具没有协作者force push 是安全的但要确认没有从中继设备丢失重要数据。误删条目的恢复就更简单了因为目录本身就是 Git 仓库git log -- entries/deploy.yaml能看到历史版本git checkout commit -- entries/deploy.yaml即可找回。这个操作我确实用过一次删掉一个不用的旧条目两周后又想要里面的命令多亏有 Git 兜底不然就会永远丢失。所以我强烈建议哪怕你只有一台机器也要把~/.cua初始化成 Git 仓库并至少每两周 commit 一次纯本地历史就是最好的后悔药。以下是我整理的一份快速排障表基本覆盖我跑这个工具期间遇到的所有问题问题现象可能原因解决办法cua run提示 command not found别名未在非交互 Shell 中生效在脚本中使用完整 Python 路径参数带空格导致脚本执行异常字符串直接替换后拼接改用环境变量注入参数Windows 同步后大量文件变动CRLF 换行符被自动转换加入.gitattributes并设置textauto同步时 merge 冲突多机修改同一条目git status 手动解决 rebase{{param}}被 Shell 提前解释使用了${{param}}或反引号确认脚本内不使用$前缀命令含密钥被推送到远端明文写入条目改为调用环境变量文件并忽略之删除条目后后悔没有版本管理把~/.cua设为 Git 仓库并定期提交5. 还能怎么扩展把 cua 变成工作流入口工具做到这里已经稳定跑了两个多月我现在养成了一个习惯新命令在终端里成功执行一遍之后马上cua add存下来而不是“等下次需要时再查”。这相当于给每一次尝试都留下了一条可复用的路径。当条目数量超过五十个后我又做了一点小扩展让cua list支持输出 JSON这样可以在终端里写个小插件按名称模糊搜索并把结果直接渲染成下拉列表。你可以把它想象成给终端加了一个快捷键面板。另外两个现在很想做的方向是团队共享和定时任务。团队共享其实不需要复杂服务器只要大家共用同一个 Git 远端仓库配合分支保护就能实现成员只向main分支提合并经过一次 Code Review 后命令模板就会越来越规范。定时任务则更简单因为条目本身已经是可执行的直接用系统 cron 去调python3 ~/.cua/cua.py run health-check就能实现周期巡检这比写一堆新的 shell 脚本要整洁得多。最后聊一下替换方案。我之前也试过成熟的命令备忘工具有的支持插件生态有的支持全文搜索但用一段时间后还是回到自己的cua上了。核心原因是“小工具的生命周期绑定在维护成本上”如果某天cua的某处设计满足不了我我可以只花一个晚上就改造它而第三方工具一旦设计方向不对你只能被动适应。小工具的意义从来不是功能多全而是它跟你自己的使用习惯严丝合缝。这是我做这个项目收获最大的地方也是我建议每个重度终端用户都尝试一次“自己做工具”的原因——哪怕只是一个 200 行的 Python 脚本只要你每天都在用它它就是可信赖的。