我只能说,CLI-Anything 这个项目,我做了差不多半年多才敢拿出来说“能用”。起因特别朴素:当时我在同时维护好几套内部系统,每天要在终端里反复敲同样的命令,团队的同事还各自维护着命名乱七八糟的脚本——check.sh、sync.py、doit.py、run_this_thing.sh。每次交接都像做一次考古发掘。后来我实在受不了,就把所有散装的、手工的、靠记忆的终端操作全部收敛到一个统一命令行工具里,名字就叫 CLI-Anything。
这篇文章不是某个现成开源库的上手指南,而是我对“如何把所有重复操作搬进命令行”这个想法的完整复盘:为什么这么做、底层怎么设计、最小可用版本怎么写、实际落地会撞上哪些墙。适合正在搞内部工具、DevOps 脚本、或者单纯想让自己少熬夜敲命令的人参考。
1. 为什么我会自己动手写一个“CLI 万能工具箱”
1.1 团队里“一人一套命令”的混乱现场
你到一个新团队,第一周最痛苦的事情是什么?我永远会选“搞懂同事的命令习惯”。
有人用grep -r "xxx" ~/workspace找代码,有人会先cd进目录再用rg;有人写了个deploy.sh只在特定目录下能用,你换个目录跑就直接报错;还有人喜欢在.bashrc里堆几十个别名,这些别名一换机器就全部失效。更离谱的是,线上出故障的时候,大家七嘴八舌喊的命令格式都不一样,运维那边收到的指令五花八门,谁也不敢保证自己敲的就是正确的那一条。
CLI-Anything 想解决的第一件事,就是给“命令”一个明确的、可检索的、团队统一的家。
我没打算发明新东西,只是把散落的脚本、别名、操作手册里的一段段复制粘贴,统一成一套“命令面板”。不管手头这个操作原来是用 Bash 写的、Python 写的,还是直接调用某个二进制,最终呈现出来都是一个名字清晰、帮助完整、参数可控的命令。
1.2 CLI-Anything 想解决的四个问题
我自己整理了一下,这个项目真正盯着的其实是四个痛点:
一是入口统一。所有操作都通过cli-anything xxx进入,不再需要记住“这个脚本在哪个目录”“那个别名是哪台机器配的”。
二是配置可读、可共享。把命令定义写进 YAML,而不是藏在代码逻辑里。新人来了不用翻脚本,直接cli-anything --help就能看到全部能力。
三是参数标准化。不管是启动服务、连数据库,还是批量操作仓库,参数透传规则一致,团队里再也不会出现“有人传绝对路径、有人传相对路径”的混乱。
四是可自动补全、可自动化。命令被集中注册之后,shell 补全脚本、CI 脚本、监控脚本都能统一调用,人机和机器调用走的是同一条路。
这四个问题解决完,最大的变化就是:团队开始有了一套“活的操作手册”,而且这本手册本身就是可执行的。
1.3 为什么通用方案救不了我
很多朋友听到这里会反问:Alias 不就行了?Makefile 不就行了?甚至 Ansible 不也行吗?
说实话,这些方案我都试过。Alias 本质上还是“私人笔记”,没法组织复杂的帮助信息、参数校验和多命令联动;Makefile 适合以项目为单位,但你要用它管理跨项目、跨机器的操作,语法会变得越来越拧巴;Ansible 那种重量级方案,适合整机级别的编排,为了敲一条“连上开发库”的命令去引一套 Playbook,有点杀鸡用牛刀。
CLI-Anything 的定位是夹在它们中间的一层:比 alias 结构化,比 Makefile 更横向,比 Ansible 更轻量。它不做配置管理,也不替代运维编排系统,它只做一件事——把散装命令变成统一命令面板。
| 方案 | 适合场景 | 短板 |
|---|---|---|
| Shell Alias | 个人快速别名 | 不可共享、无帮助、无参数规范 |
| Makefile | 单个项目任务编排 | 跨项目复用弱,语法受限 |
| Ansible / Playbook | 整机配置与编排 | 重,不适合日常轻量命令 |
| Taskfile / Justfile | 项目任务统一 | 仍是项目视角,不是全局工具视角 |
| CLI-Anything | 跨项目、跨机器的个人/团队命令面板 | 需要自己维护注册表 |
所以我在代码注释里写的第一句话就是:“别把简单的事情做复杂,CLI-Anything 只是命令的收纳盒。”
2. CLI-Anything 的核心设计:把散装命令变成声明式命令面板
2.1 命令注册表与 YAML 配置
CLI-Anything 的第一个设计决策,是“命令定义”和“命令执行”必须分离。
命令定义我选择了 YAML。为什么不用 JSON?因为 YAML 支持注释,我可以直接在配置里写“为什么这个命令要用这个端口”“这个参数什么时候会踩坑”。这些信息在代码里写很容易被忽略,放在配置文件的注释里,反而能被下一个维护者看到。
一个标准的命令定义长这样:
commands: - name: goadmin.run exec: "cd ~/work/admin && nohup ./bin/admin-server > logs/admin.log 2>&1 &" desc: "启动后台管理服务(开发环境)" tags: [service, admin] - name: db.mysql.dev exec: "mysql -h 127.0.0.1 -P 3306 -u root -p" desc: "连接开发库" env: MYSQL_PWD: "{PREPEND}dev123" passthrough: true - name: git.worktree.sync exec: "~/bin/git-sync-worktrees.sh" desc: "同步所有 worktree 到最新代码"字段我只保留了必要的几个:name是命令的唯一标识,exec是真正要执行的命令模板,desc是对外展示的帮助信息,env可以注入环境变量,passthrough控制用户传入的参数是否原样拼到命令后面。
有些细节是踩过坑之后才补上的。比如env里的{PREPEND},这是我自己约定的一种表达式:默认情况下注入环境变量会覆盖原值,但有些场景我想在原有PATH或PYTHONPATH后面追加一段路径。这里用替换标记来区分“覆盖”和“追加”两种意图,后面第三部分代码里会体现具体实现。
2.2 把执行器、参数透传、环境注入分开
很多初学者会把这一层混在一起写:一个函数接收命令名,然后subprocess.run("cd xxx && bash yyy.sh " + args)。这么写短期内能用,但一旦命令数量超过二十个,你会发现问题特别多。
CLI-Anything 把执行逻辑拆成三层:
第一层是解释层,负责把命令名解析成注册表里的一条记录;第二层是渲染层,负责把exec模板、环境变量、用户传入参数渲染成最终要运行的命令字符串;第三层是执行层,只负责subprocess调用并返回退出码。
这样拆完,最大的好处是每一层都能单独测试。我最常测的就是渲染层——写一个命令定义,然后故意传各种奇怪的参数进去,看渲染结果里有没有出现路径穿越、shell 注入、引用丢失。如果不把渲染和执行分开,这种测试基本没法写。
参数透传我默认是开启的,但也支持关闭。passthrough: false的命令,比如db.mysql.dev这类只负责“连接开发库”的入口,用户传进来的任何参数都会被忽略,避免有人不小心把--host=线上IP拼进去。这个开关救过我好几次。
2.3 自动补全和帮助文档是“免费”生成的
这是我坚持“声明式配置”的真正理由。
只要你把命令定义集中在一个结构化文件里,那么你天然就能生成三样东西:--help文档、shell 自动补全、命令列表。CLI-Anything 启动时会扫描所有配置,生成一个内部索引,然后暴露三个子命令:
cli-anything --help # 输出所有命令和帮助 cli-anything list --plain # 输出纯命令名列表,供脚本和补全使用 cli-anything list --tag git # 按标签过滤命令自动补全脚本只做一件事:调用cli-anything list --plain,然后用compgen把结果交给 shell。这个思路让补全永远跟实际配置同步——不用写完一个命令再去手动更新补全脚本。
实测下来,Zsh 和 Bash 两边我都配了。Bash 用complete -F,Zsh 用compdef。接入完成后,cli-anything <TAB>就能列出所有命令,效果跟用 oh-my-zsh 的插件差不多,但命令列表完全由我们自己掌控。这一点在团队推广时非常加分——新人只要装一次补全,以后基本不需要背命令名。
3. 从零实现一个可用的最小版本
3.1 目录结构与启动模块
CLI-Anything 的最小版本我故意写得很薄,核心技术栈就是 Python 标准库加一个 PyYAML。目录结构非常简单:
cli-anything/ ├── cli_anything.py # 主入口 ├── commands.yaml # 命令定义 ├── setup.sh # 安装补全脚本 └── README.md启动模块只有一个文件。我一开始也想拆成 cmd、registry、runner 好几个模块,但后来觉得,工具的核心价值在“命令定义”和“使用体验”,不在代码架构的复杂程度。拆得太细反而会让想贡献命令的同事望而却步。当然,这是针对最小版本,后面命令多了再拆也不迟。
cli_anything.py的主入口逻辑很直白:解析参数,第一个参数是命令名,后面的参数是透传给命令的 argv;如果没有命令名就输出帮助。
3.2 三个核心函数:注册、加载、执行
下面这段是我实际在用的极简版本,你可以直接拿去改:
#!/usr/bin/env python3 import os import subprocess import sys from pathlib import Path from typing import Any, Dict, List, Optional import yaml REGISTRY: Dict[str, Dict[str, Any]] = {} CONFIG_PATH = Path(__file__).parent / "commands.yaml" def load_config(path: Path = CONFIG_PATH) -> None: """读取 YAML 并注册所有命令。""" if not path.exists(): print(f"[warn] config not found: {path}", file=sys.stderr) return data = yaml.safe_load(path.read_text(encoding="utf-8")) for item in data.get("commands", []): name = item["name"] REGISTRY[name] = { "exec": item.get("exec", ""), "desc": item.get("desc", ""), "env": item.get("env", {}), "passthrough": item.get("passthrough", True), "tags": set(item.get("tags", [])), } def render(definition: Dict[str, Any], args: List[str]) -> str: """把 exec 模板、环境变量、参数拼成最终命令字符串。""" exec_str = definition["exec"] if definition["passthrough"] and args: exec_str += " " + " ".join(args) return exec_str def run_command(name: str, args: List[str]) -> int: """按命令名查找并执行,返回值透传到系统退出码。""" if name not in REGISTRY: print(f"[error] unknown command: {name}", file=sys.stderr) return 127 definition = REGISTRY[name] full_command = render(definition, args) env = os.environ.copy() for k, v in definition["env"].items(): if v.startswith("{PREPEND}"): env[k] = env.get(k, "") + " " + v[len("{PREPEND}"):].strip() else: env[k] = v print(f">>> {full_command}") return subprocess.run(full_command, shell=True, env=env).returncode def list_commands(tag: Optional[str] = None) -> List[str]: names = [] for name, definition in REGISTRY.items(): if tag and tag not in definition["tags"]: continue names.append(name) return sorted(names) def main() -> int: argv = sys.argv[1:] if not argv or argv[0] in ("-h", "--help", "help"): print("CLI-Anything command panel") print("usage: cli-anything <command> [args...]") print() for name in sorted(REGISTRY): desc = REGISTRY[name]["desc"] print(f" {name:<24} {desc}") return 0 if argv[0] == "list": tag = None if len(argv) >= 3 and argv[1] == "--tag": tag = argv[2] for name in list_commands(tag): print(name) return 0 load_config() # 这里有个细节:先加载配置再处理 list,否则 list 拿不到注册表 return run_command(argv[0], argv[1:]) if __name__ == "__main__": sys.exit(main())这段代码你可能会发现一个小问题:我在main()里先判断了list分支,但此时load_config()还没调用,REGISTRY是空的。我在注释里也提到了“先加载配置再处理 list”,正确顺序应该是在main()开头就执行load_config()。这是一个看起来很蠢但很容易犯的小 bug,尤其当你以为“list 不需要配置”的时候。实际上 list 本身就是读配置,不加载肯定拿不到数据。
渲染层我用的是shell=True。理论上讲用shell=False加参数列表更安全,但实际使用中,很多命令本身带着管道、重定向、cd,如果强制走shlex.split拆分,用户写命令定义的时候会非常难受。所以我保留shell=True,但把安全责任前移到了命令定义端,这一点在第五部分会展开讲。
3.3 一行命令接入 Bash / Zsh 自动补全
补全脚本我放在setup.sh里,内容很短:
# setup.sh CLI_ANYTHING_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cli_anything.py" _cli_anything_complete() { local cur cur="${COMP_WORDS[COMP_CWORD]}" if [[ "${COMP_CWORD}" -eq 1 ]]; then local commands commands=$(python3 "$CLI_ANYTHING_PATH" list --plain 2>/dev/null) COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) else COMPREPLY=() fi } complete -F _cli_anything_complete cli-anythingZsh 那边稍微有点区别,但核心思路一样:
# .zshrc autoload -U +X compinit && compinit _cli_anything() { local -a commands commands=("${(@f)$(cli-anything list --plain 2>/dev/null)}") _describe 'command' commands } compdef _cli_anything cli-anything这里有个容易踩的坑:补全脚本里调用cli-anything list的时候,如果cli-anything不在 PATH 里,补全就会静默失败。我建议在 setup.sh 里做一次软链,把入口放到/usr/local/bin,同时列出所有注册命令,避免新人装完发现 TAB 没反应却找不到原因。
实测下来,自动补全对使用频率的提升非常明显。在没有补全之前,我自己都经常敲错长命令名,有了补全之后,基本只需要cli-anything m<TAB>就能匹配到目标。
4. 把日常工作流搬进去:三个真实玩法
4.1 Git 多仓库巡检与一键提交
我手头管理的代码仓库,加上同事的,一共有十几个。以前每周做一次状态巡检,都是用一条超长 for 循环自己敲。现在我把这条循环做成命令里的一步:
commands: - name: git.audit exec: "~/bin/git-audit.sh" desc: "巡检所有本地仓库状态(未提交、未推送、停用分支)" tags: [git, audit]git-audit.sh的内容很简单,就是遍历一个预置的仓库清单,然后对每个仓库执行git status --short --branch,按仓库分组输出。关键点是:仓库清单不是硬编码在脚本里的,而是从 CLI-Anything 的配置里读。这样你想加一个仓库,只需要在 YAML 里加一行,命令自动就能感知到。
git_repos: - name: admin path: "~/work/admin" - name: api-gateway path: "~/work/api-gateway" - name: cli-anything path: "~/work/cli-anything"一键提交同理:git.commit命令允许传入 commit message,然后对已修改的仓库自动git add -A && git commit,但绝不自动 push。这是我特意设置的边界:提交是本地的、可反悔的,推送是远程的、有后果的。CLI-Anything 可以把操作变简单,但不能把危险操作变得太简单。
4.2 数据库连接字符串统一管理
第二个真实场景是数据库连接。团队里的每个环境、每个项目的库连接信息都不同,以前大家都把连接串贴在本地文档里,换台机器就找不到。
我在 CLI-Anything 里建了一组db.*命令:
commands: - name: db.mysql.dev exec: "mysql -h 127.0.0.1 -P 3306 -u dev_user -p" desc: "连接开发库 dev_db" env: MYSQL_PWD: "dev_password_placeholder" tags: [db, mysql] - name: db.redis.prod exec: "redis-cli -a '{PREPEND}${REDIS_PASSWORD}'" desc: "连接生产 Redis(慎用!只读操作)" tags: [db, redis]这个玩法的好处是:连接信息只存在于配置中心或本机环境变量里,代码库里不会出现明文的数据库密码。CLI-Anything 本身不负责管理密钥,它只是负责把环境变量注入到命令执行环境里。密码还是放在.env或系统的密钥管理服务里,CLI-Anything 的env字段只做引用。
踩过的坑是:MYSQL_PWD这种环境变量在命令行里会出现在进程列表里(通过ps能看见),不适合生产环境。生产环境我建议用凭据文件或者专门的密钥管理工具,CLI-Anything 负责的是“让你不用背命令名”,不是“让你把密码写进 YAML”。
4.3 服务启停与日志轮转
日常开发免不了要反复启动、停止、重启本地服务。你要是给每个服务单独写 alias,alias 数量会爆炸。我的做法是把它收敛成三条统一命令:serv.ops.start <name>、serv.ops.stop <name>、serv.ops.log <name>。
commands: - name: svc.start exec: "~/bin/svc-ctrl.sh start {name}" desc: "启动服务(可选参数 name,默认全部)" tags: [service]这里我用的是{name}占位符,而不是把参数直接透传给脚本。为什么这么绕?因为我想在 CLI-Anything 这一层做合法性校验。比如svc.start只允许传配置里已有的服务名,不能传../../something这种路径穿越字符串。渲染层发现名字不在白名单里,直接拒绝执行,而不是把参数拼进 shell 交给脚本。这招极大降低了误操作概率。
同时服务启动后的日志我统一重定向到~/.local/logs/{service}.log,再配一个svc.log命令直接tail -f。表面上看这只是个小技巧,实际上它让“服务日志在哪”这个问题有了唯一答案,省了无数次到处找日志的时间。
5. 工程落地时最容易踩的坑
5.1 命令名冲突和命名空间设计
项目刚开始,我给命令起名很随意:deploy、sync、clean。结果同事那边自己写的 alias 里也有一个deploy,两边一撞,整个终端行为都变得不可预测。
后来我制定了命名空间规范:所有命令必须以“领域.动作”的格式命名。比如数据库相关用db.,Git 相关用git.,服务相关用svc.,运维操作用ops.。这样冲突概率大幅下降,而且补全的时候按领域前缀分组,视觉上也很清晰。
命名空间规范要让全团队遵守,最好的办法不是写文档,而是在注册表加载时强制校验:
def validate_name(name: str) -> bool: return "." in name and all(part.isidentifier() for part in name.split("."))不合规的命令直接加载报错并要求修改。宁可前期麻烦一点,也不要让两个人同时用同一个命令名。
5.2 安全问题:不能打着“方便”的旗号开后门
这是我最想强调的一点。CLI-Anything 这类工具本质上是“把命令的执行权集中到一个入口”,如果做不好权限控制,它就是个超级后门。
我的经验是几条红线:
第一,不把危险操作做成无确认命令。删除文件、重置数据库、强制覆盖分支这类操作,必须在exec命令模板里自带提示或二次确认,不能在 CLI-Anything 里偷偷绕过。
第二,配置文件的权限要收敛。commands.yaml里如果包含注入环境变量的逻辑,那这个文件就是敏感文件。我默认umask 077,并且只允许管理员组写。
第三,不要把秘密直接写进 YAML。YAML 只放变量引用,例如{PREPEND}${REDIS_PASSWORD},真实密码放到环境变量或密钥服务里。CLI-Anything 执行时从进程环境里取值。
第四,透传参数时要过滤危险字符。我维护了一个黑名单正则:
BLOCKED_PATTERNS = [r";\s*(rm|shutdown|reboot|mkfs)", r"\|\s*(sh|bash)\s*-"]如果透传参数里命中这些模式,直接拒绝执行。虽然黑名单无法覆盖所有情况,但它挡住了最常见的破坏性命令。
5.3 参数转义和 shell 注入这个老问题
前面提到我用了shell=True,那 shell 注入问题就无法回避。实际项目里我补了三层防御。
第一层是渲染层:用户透传的参数会经过一次shlex.quote处理,确保每个参数被当作单个字符串传入,不会因为空格或引号被拆成多个 token。
第二层是白名单校验:凡是命令定义里带{name}、{service}这类占位符的,渲染前必须通过字符串集合校验,不允许直接拼用户输入。
第三层是执行层:subprocess.run的shell=True只用于命令模板本身,如果某个命令定义里需要执行动态脚本,我会尽量让它走bash -c "固定的脚本模板" -- "$args"这种写法,把用户输入放在位置参数里,而不是直接插入命令字符串。
这三层做完,我不敢说绝对安全,但至少挡住了我在真实环境中遇到过的绝大多数问题。进度再紧,这三层也不能去。
5.4 跨平台与环境不一致
CLI-Anything 一开始是在我的 macOS 上写的,换到 Linux 服务器之后,第一波问题就是路径分隔符和工具链差异。sed -i在 macOS 和 GNU 环境的参数格式不一样,nohup的日志重定向行为也有些细微差别。
我的解决办法是:不要在命令模板里写全平台通用的命令,而是封装成平台专属脚本,由 CLI-Anything 统一调度。
也就是说,YAML 的exec字段尽量只指向~/bin/xxx.sh,平台相关的逻辑都收敛到对应平台的脚本里。CLI-Anything 只保证“命令入口一致”,不承诺“底层实现一致”。在团队里你经常会有 macOS 和 Linux 混跑的情况,这个边界画清楚之后,很多莫名其妙的兼容性问题都消失了。
另外,python3解释器的路径在服务器上五花八门。我建议入口脚本第一行使用#!/usr/bin/env python3,并且 setup.sh 里自己做一次软链,把可用的解释器绑定好。这个细节让至少三个同事免遭command not found的折磨。
6. 在团队里铺开这个工具的实际经验
6.1 渐进式迁移策略
直接在团队里说“从今天开始所有操作都用 CLI-Anything”,大概率会遭到强烈反弹。我的策略是先挑三个收益最明显的场景做试点:数据库连接、Git 巡检、本地服务启停。
这三个场景的共同特点是:大家每天都在敲,命令又长又容易记错。先把这三个场景做成命令面板,然后每次在群里被人问“开发库怎么连”,我就回一条cli-anything db.mysql.dev。几次之后,同事们自己就会把常用的几条命令目前记下来了。
渐进式迁移最大的好处是不强制,大家用顺手之后,新命令的提案也会主动来找你。项目运行三个月之后,团队里连“重新加载前端资源”这种小操作也有人提议加进面板了。
6.2 共享配置和交接
让“一人一套命令”变成“一套命令所有人用”,配置的发布机制很关键。我目前用的是 Git 私有仓库管理commands.yaml和setup.sh,安装只需要三步:
git clone <company>/cli-anything.git ~/.cli-anything cd ~/.cli-anything && bash setup.shsetup.sh 会完成三件事:把cli_anything.py软链到/usr/local/bin/cli-anything,生成补全脚本并写入 shell 配置,然后跑一次cli-anything list验证所有命令是否可执行。
新同事入职的当天,我直接在终端敲一遍这三行,剩下的他自己 TAB 一下就看懂了。交接成本从原来的“一页 Markdown 文档”下降到“一条脚本”。
6.3 下一步我打算做什么
CLI-Anything 目前还在迭代,下一步我想给它加上一个插件机制:每个命令定义不局限于 YAML,还可以是 Python 函数,这样复杂逻辑就不用绕道 Bash 脚本了。方向其实很明确——CLI-Anything 不应该只是“命令的收纳盒”,它应该成为团队统一的“操作接口”,前端 Web 控制台、CI/CD 流程、监控报警脚本都往这里接,所有入口只有一个地方可查。
最后说一点个人体会:工具真正让人解脱的时刻,不是“我写了一套完美的命令面板”,而是“团队里再也没人问我这个命令在哪敲了”。CLI-Anything 最值钱的不是代码,而是这一堆被收敛进统一入口的、原本只存在于个人笔记里的操作知识。把操作知识结构化、可执行、可共享,这件事值得每一个长期维护项目的人做一遍。