
我电脑里躺着四十多个命令行工具有管理代码的、有查日志的、有跑测试的、有处理图片的没有一个的参数风格是统一的。git 用--forcecurl 用-fdocker 用--pull alwaysrsync 用-a每次写自动化脚本我都得先去翻一遍 man page生怕记错一个参数。这个叫CLI-Anything的项目就是解决这个别扭处的用一个统一外壳把散落的命令行工具全部“收编”进来让任何命令都遵循同一套注册、解析、校验、输出和补全规范。简单说它是命令行工具的统一网关是运维脚本的入口管理器是一个把“什么都能通过命令行调用”这件事落到实处的框架。适合谁天天在终端里打命令的开发者、要写跨团队 CLI 规范的架构师以及想给内部工具做统一入口的平台工程团队。1. 项目定位与核心思路1.1 最让我头疼的三个真实场景说几个日常工作中每天都会撞上的问题。新同事问“我们服务器怎么重启服务”答案是systemctl restart xxx再问“那查日志呢”答案变成了journalctl -u xxx -f“看磁盘呢”又成了df -h。每样东西都不难难的是它们没有统一的记忆方式。第二个场景是脚本维护。团队里有个交付脚本里面混杂着 curl、grep、awk、jq命令一旦多了输出格式就不一致。有的工具正常输出打到了 stdout错误信息却混进了 stdout有的命令需要手动|| true忽略失败有的命令切换目录又会污染上层 shell 的环境变量。写脚本最烦的不是功能实现是每接一个新工具就要重新踩一遍它的输出和退出码逻辑。第三个场景是内部工具碎片化。测试平台做了个 CLI用 Go 写的部署工具也做了个 CLI用 Node.js 写的数据修复脚本又用 Python 写的。每个都自带一套参数风格有的用--cluster有的用-c连别名都不一样。这个不说服大家统一后面每个接入方都要付出学习成本。CLI-Anything做的事情就是把这类问题一次性收敛它充当中间层底下可以对接任意真实命令上面给使用者提供一致的规则。1.2 它到底“anything”在什么地方项目叫 anything并不意味着它要去实现所有工具的功能。它的核心立场是功能可以不动但使用方式必须统一。也就是说我不关心底层跑的是哪个二进制的哪个版本我只定义一套公约——命令怎么注册、参数怎么解析、输出用什么格式、日志怎么分级、配置怎么合并、补全怎么生成。我把这套公约拆成了四层。第一层是“注册层”每个底层工具在框架里登记自己的名称、描述、参数定义和执行入口。第二层是“解析层”框架统一处理用户敲进来的参数包括短参数、长参数、布尔值、列表值、枚举值的校验。第三层是“调度层”负责调用真实的底层命令并对退出码、超时、并发、环境变量做管理。第四层是“渲染层”负责输出统一的结构化结果默认是人类可读的表格/文本加--json就输出机器可读的 JSON方便脚本对接。这样做的好处非常直接。新工具接入时只需要写一份声明式的定义不需要重复设计参数解析逻辑使用方只需要学会一套规则不管后面底下的命令怎么换外面的人无感脚本调用时只要约定--json所有工具的返回格式就全部收敛了。这其实是“门面模式”的一种实践只不过门面不是代码 API而是终端里的那条命令行。1.3 和 Docker CLI 这类既有标准有什么不同可能有人会问Docker 有自己的 CLIkubectl 也有自己的规范为什么还要自己做一套我的理解是Docker CLI 只管 Docker 生态kubectl 只管 K8s 生态而内部的运维工具、交付脚本、数据处理任务大多没有统一 CLI 标准。你没法让写测试工具的人去遵守 Kubernetes 的参数规范也完全没必要。CLI-Anything解决的是一条“接口沙地”的问题横向统一不同领域工具的使用体验而不是纵向定制某一领域。它更像一个 CLI 工具总线类似服务 Mesh 对整个微服务的治理思路。每个工具还是那个工具但在总线上它必须遵循统一的协议——统一的参数风格、统一的输出结构、统一的错误码语义。这也是我后来敢在公司内部推这套东西的原因成本低接入快立竿见影。2. 架构设计与关键选型2.1 为什么选 Python 而不是 Shell 或 Go第一版我确实用纯 Bash 试过。Shell 进程管理方便但参数解析复杂到离谱想支持--env prod --tags a,b,c这种组合就得写一坨 getopts想要对参数做类型校验几乎等于手写编译器。后来想用 Go 写性能很好编译成单个二进制很方便但问题是插件机制麻烦。如果团队里有人想加一个新工具接入得修改主仓库重新编译这对工具链来说太重了。最终我选了 Python。理由很实在生态成熟argparse/click/typer可以把参数解析的复杂度包掉插件化用importlib就能实现丢一个.py文件进目录就算接入团队里任何写脚本的人都会 Python降低贡献门槛。性能的担忧其实没必要一个 CLI 网关的瓶颈根本不在语言而在启动进程调用底层命令的开销。实测下来在中等负载的机器上框架自身启动加参数校验大概 15ms这完全可接受。2.2 整体模块划分我的实现目录长这样cli_anything/ ├── core/ │ ├── registry.py # 命令注册中心 │ ├── dispatch.py # 调度执行器 │ ├── config.py # 配置加载与合并 │ ├── validate.py # 参数校验 │ └── output.py # 格式化输出 ├── plugins/ │ ├── docker_tools.py │ ├── file_ops.py │ └── log_utils.py ├── resources/ │ └── completions/ # shell 补全脚本模板 └── main.py # 入口核心思想是“注册即接入”。registry.py维护一个全局字典key 是命令名value 是对应的处理器对象。用户在终端敲命令时main.py解析第一层命令名然后交给dispatch.py去调度。plugins目录下的每个文件都是可选的扩展模块文件里定义了若干命令的元信息与处理函数。2.3 命令描述的数据契约为了让统一解析成为可能每个命令必须提供一份描述自身的元数据我用的是字典结构在代码里长这样{ name: svc, description: 管理服务生命周期, args: [ {name: action, choices: [start, stop, restart], help: 目标操作}, {name: service_name, help: 服务名}, ], options: [ {name: --force, kind: bool, default: False, help: 强制执行}, {name: --timeout, kind: int, default: 30, help: 超时秒数}, ], handler: plugins.service_ops:handle_service, }这份契约是整个框架的核心资产。元数据够全后面的补全、校验、帮助文档、JSON 输出都是自动生成的。这也是“anything”能真正统一的基础——只要所有命令都按这个结构描述上层工具就能一视同仁地处理。我甚至把文档生成都自动化了doc子命令直接生成全量命令手册。3. 从零搭建 CLI-Anything 的核心实现3.1 命令注册从装饰器到自动发现最直接的注册方式就是在 plugin 文件里用装饰器声明。简单说装饰器的作用是把函数和元数据绑在一起然后塞进注册中心。我封装了一个command方法它的实现很轻# core/registry.py _registry {} def command(name, description, argsNone, optionsNone): def decorator(func): _registry[name] { name: name, description: description, args: args or [], options: options or [], handler: func, } return func return decorator在插件文件中# plugins/service_ops.py from core.registry import command command( svc, 管理服务生命周期, args[ {name: action, choices: [start, stop, restart], help: 目标操作}, {name: service_name, help: 服务名}, ], options[ {name: --force, kind: bool, default: False, help: 强制执行}, {name: --timeout, kind: int, default: 30, help: 超时秒数}, ], ) def handle_service(action, service_name, forceFalse, timeout30): # 实际的执行逻辑 pass注册中心有了剩下的是自动发现。我的做法是启动时扫描 plugins 目录逐个 import 文件让文件顶层的装饰器触发注册。这样“新增工具 新增一个 py 文件”不需要改主代码。注意自动发现时一定要过滤掉非.py文件并且要做好异常捕获。某个插件 import 失败不应该让整个 CLI 起不来。我在这一步吃过亏后来加了个--debug参数专门打印插件加载失败的原因。3.2 参数解析与校验参数解析不直接交给 argparse因为 argparse 对嵌套命令、动态选项的支持不够顺手。我自己写了一个轻量解析器流程分三步先按顺序消费位置参数再循环消费以--开头的选项最后做类型转换与枚举校验。核心逻辑# core/validate.py def parse_args(argv, args_spec, options_spec): positional [] options {} for opt in options_spec: options[opt[name]] opt.get(default) i 0 while i len(argv): token argv[i] if token.startswith(--): matched None for opt in options_spec: if opt[name] token: matched opt break if not matched: raise ValueError(f未知选项: {token}) if matched[kind] bool: options[token] True else: if i 1 len(argv): raise ValueError(f选项 {token} 需要一个值) i 1 options[token] cast_value(argv[i], matched[kind]) else: positional.append(token) i 1 if len(positional) ! len(args_spec): raise ValueError(f需要 {len(args_spec)} 个位置参数实际提供 {len(positional)} 个) result {} for idx, arg in enumerate(args_spec): value positional[idx] if choices in arg and value not in arg[choices]: raise ValueError(f参数 {arg[name]} 必须是 {arg[choices]} 之一而不是 {value!r}) result[arg[name]] value result.update(options) return resultcast_value负责把字符串转换成 int、float、bool 等目标类型。bool 类型这里有个细节我设计成开关型只允许--force这种写法不允许--forcefalse因为后者容易在脚本中写混。如果需要可配置的布尔值那就该用--force/--no-force这种成对选项来设计一开始就把接口定义清晰后面不用返工。位置参数校验时必须先算清楚长度再逐个检查否则用户少传一个参数时报错信息会误导人。我见过不少工具在参数个数不对时抛出“列表索引越界”就是因为没做前置长度检查。这一点在框架里必须兜住。3.3 统一输出从文本到 JSON 的自动切换统一输出是 CLI-Anything 的另一个“甜点功能”。每个命令的 handler 都返回一个可序列化的 Python 对象上层输出层根据用户是否传了--json来决定渲染方式。默认文本渲染直观JSON 渲染供脚本消费。# core/output.py def render(result, as_jsonFalse): if as_json: print(json.dumps(result, ensure_asciiFalse, indent2)) return if isinstance(result, list): for item in result: # 简单表格每行按 key: value 打平 for k, v in item.items(): print(f{k}: {v}) print(- * 30) else: for k, v in result.items(): print(f{k}: {v})这里有几个设计决策要讲清楚。第一handler 禁止直接print所有输出必须通过返回值交给渲染层。刚开始接入的团队总想省事直接在函数里 print 一行结果结果造成文本模式下能看、JSON 模式下输出结构却被污染。我在拦截器里做了检测如果有人直接往 stdout 写数据会把它们收集并放到_stray_output字段里避免破坏 JSON 结构。第二回车换行统一使用\n。Windows 上运行脚本时如果不注意换行问题生成的 JSON 会在\r\n上出乱。我后来在输出层统一用os.linesep或者干脆强制\n保证生成的 JSON 文件可以跨平台直接用。第三stdout 和 stderr 的分离问题。正常结果走 stdout日志和警告走 stderr。这套规范看着很简单但我翻过公司里好几个内部工具它们都在往 stdout 里打印日志导致xxx | jq. 这种命令直接炸。CLI-Anything 从框架层面强迫所有插件遵守效果立竿见影。3.4 配置加载涵盖默认值、用户配置、环境变量很多工具有配置文件但没有统一的合并规则。CLI-Anything 定义了一套三层配置优先级默认配置 项目本地配置 环境变量。加载逻辑大概是# core/config.py import os import yaml DEFAULTS { log_level: INFO, timeout: 30, auto_proxy: False, default_output: text, } def load_config(): config dict(DEFAULTS) local_file .cli-anything.yaml if os.path.exists(local_file): with open(local_file, r, encodingutf-8) as f: loaded yaml.safe_load(f) or {} config.update(loaded) # 环境变量覆盖 for key in config.keys(): env_key CLI_ANYTHING_ key.upper() if env_key in os.environ: raw os.environ[env_key] config[key] parse_env_value(raw, config[key]) return config环境变量那一层我特意加了解析函数。因为环境变量全是字符串直接覆盖会把原来的 bool 或 int 类型搞坏。parse_env_value会根据默认值的类型做转换比如默认值是False时读取CLI_ANYTHING_AUTO_PROXY1会被转成True默认值是整数时TIMEOUT60会被转成 int。这套配置机制还要做成可检测的。我加了个config子命令一条命令就能看到当前生效的所有配置项以及来源。排查“为什么配置没生效”时少吵架直接看输出就行。3.5 Shell 补全自动生成补全是对终端体验影响最大但又最容易被忽略的功能。CLI-Anything 的做法是根据注册中心的元数据自动生成 bash 和 zsh 的补全脚本不手工维护。因为元数据里已经有命令名、参数名、枚举值、选项名补全脚本完全可以从注册表推导出来。补全脚本生成的关键是要输出补全建议列表每行一项。zsh 环境下compadd可以支持带描述的输出bash 环境下COMPREPLY只能填充单词。我的模板是这样组织的_cli_anything_complete() { local cur${COMP_WORDS[COMP_CWORD]} local commandssvc doc config plugin bench COMPREPLY( $(compgen -W ${commands} -- ${cur}) ) } complete -F _cli_anything_complete cli这个实现能处理第一级命令补全。第二级参数补全要更复杂一些需要对当前已输入的单词位置做判断。我的做法是当COMP_CWORD等于 2 时读取第一个参数对应的choices列表传给compgen -W。实测下来在 zsh 5.8 和 bash 5.1 上都能正常工作。提示生成补全脚本后务必在两种 shell 里分别验证。bash 和 zsh 的补全触发变量名不同不是一套脚本通吃。我之前想省事只写了 bash 版结果 zsh 用户敲了命令不补全后来在 zsh 下用了compdef才解决。3.6 调度执行超时、退出码与并发控制直接调用底层命令不能只做subprocess.run(cmd)这一件事。CLI-Anything 在调度层包了三层东西。第一层是超时控制。subprocess.run的timeout参数会在超时时抛TimeoutExpired。问题是用户不知道这是超时还是程序异常退出。我在 dispatch 层统一捕获把超时转换成退出码 124并输出标准化的错误信息。这个和系统命令timeout的语义保持了一致。第二层是退出码收敛。真实命令的退出码五花八门有的是 1有的是 2有的是 255。CLI-Anything 对外承诺一套退出码语义退出码语义0成功1一般业务错误如校验失败、服务不存在2参数解析错误3底层命令不存在或执行失败124超时这样脚本调用方不用再去猜某个工具的退出码含义。第三层是并发控制。批处理场景下要用多线程同时跑多个底层命令但并发上去了输出就会乱。我用concurrent.futures.ThreadPoolExecutor做并且给了两个限制一个是--parallel/-p指定并发数默认 4另一个是在输出层做结果暂存等所有任务结束后统一打印。这是为了避免多线程同时往 stdout 写导致行内容混在一起。4. 实战把 Docker、文件操作和日志全部收编进来4.1 接入 Docker 常用操作Docker 命令本身不算复杂但它参数多组合也多天天敲docker ps、docker logs、docker exec难免记忆负担。我把高频操作收编成img命令command( img, Docker 镜像与容器快捷管理, args[ {name: action, choices: [list, logs, exec, clean], help: 操作类型}, {name: target, help: 容器名/镜像名}, ], options[ {name: --follow, kind: bool, default: False, help: 跟踪日志输出}, {name: --tail, kind: int, default: 100, help: 日志行数}, ], ) def handle_img(action, target, followFalse, tail100): if action list: return {result: 容器列表, hint: 底层执行 docker ps} if action logs: cmd [docker, logs, --tail, str(tail)] if follow: cmd.append(--follow) cmd.append(target) return run_cmd(cmd) # ... 其他逻辑这里run_cmd是调度层的封装负责捕获输出和错误。接入这类命令时我的体会是不要尝试把所有底层能力都暴露出来只暴露团队真正高频使用的子集否则表面上是“统一”实际上是制造更大的 CLI 表面积。收编的目的是降低认知负担而不是增加功能。4.2 统一 JSON 输出如何改变脚本生态框架上线以后变化最大的是一些交付脚本。以前写脚本要 grep 命令输出再 awk 提取字段现在直接cli img list --json接jq就能拿结构化字段。比如cli img list --json | jq -r .[] | select(.status running) | .name这条命令完全避开了对终端表格宽度和空格的依赖。在框架里我实现了所有内置命令对--json的支持并在文档里明确这条规则任何插件应该优先返回结构化数据文本展示只是默认渲染。后续团队写的脚本全部接 JSON维护成本肉眼可见地下降。4.3 批量场景一条命令跑完多个环境最典型的需求是“同时看所有环境的服务状态”。以前我写 shell 循环串行执行太慢并行又要手动处理输出缓冲。CLI-Anything 内置的并发机制让这变成了一条命令cli svc batch --envs dev,staging,prod --action status --parallel 3 --json实现时调度层把--envs拆分后生成任务列表丢进ThreadPoolExecutor等所有任务结束再统一汇总。实测在三个环境里跑status命令串行耗时 12 秒并行 3 后降到 5 秒写进对时敏脚本非常有价值。不过这里有个安全细则并发执行时如果某环境失败不能因为其它环境成功就让整体退出码是 0。我汇总时会统计成功与失败数量只要有一半以上失败退出码就置为 1并把失败的那个环境名打印在错误信息里。5. 常见问题与排查技巧实录5.1 问题速查表把实际运行中遇到的问题整理成一张表方便直接对照现象可能原因排查/解决插件 import 失败但没报错自动发现阶段异常被吞掉用cli --debug查看加载日志--json输出里混入了日志插件代码直接 print统一用日志接口或收集_stray_output字段zsh 下补全不生效补全函数没注册到compdef执行compdef _cli_anything_complete cliWindows 上生成 JSON 乱码换行符或编码问题输出统一\n写文件时用encodingutf-8并发执行时输出错乱任务内直接 print禁止直接 print由渲染层统一输出环境变量配置不生效类型转换失败检查parse_env_value是否按默认值类型转换底层命令不存在依赖未安装或 PATH 不对调度层检测FileNotFoundError提示安装依赖参数校验报错信息难以理解校验逻辑没做前置长度检查先检查参数个数再检查枚举值5.2 三个印象最深的具体问题先说编码问题这个坑让我耗了一整个下午。在 Windows 的 PowerShell 里跑cli doc --json doc.json生成的文件用 UTF-8 编辑器看没问题但用记事本打开就乱。原因在于默认编码不一致。解决方法是输出时显式指定编码同时生成文件时写encodingutf-8。后来我再也没在 Windows 上手动重定向输出统一改成了cli doc --json --output-file doc.json由框架内部处理编码跨平台就不会出错。第二个是颜色输出对管道的影响。框架早期在渲染层给文本加了 ANSI 颜色人眼看着挺舒服。结果有同事用管道接 grep 时匹配到的是带转义序列的字符串日志里到处都是\x1b[32m。排查后我加了一个检测只有当 stdout 是 TTY 时才启用颜色否则自动禁用。这个判断在 Python 里是sys.stdout.isatty()成本极低收益极大。第三个是超时和卡死问题。有几个底层命令在极端情况下不响应调度层的timeout参数似乎没起作用。查了subprocess源码才发现run(timeout...)在子进程产生孙子进程时会失效——它只能等直接子进程子进程的子进程可能还在后台跑。解决办法是在启动底层命令时创建一个新的进程组超时后把整个进程组杀掉。Python 里用start_new_sessionTrue再os.killpg可以达到这个效果。这个问题不深入底层很容易忽略。5.3 避坑建议提炼想让框架真正通用必须在设计阶段就把“禁止直接 print”写入接入规范这个靠自觉不行必须从监控层兜底。shell 补全脚本不是一次生成永久有效插件更新后要重新生成所以最好把生成动作放在install子命令里。并发数是经验值不要盲目调大多了反而会触发系统文件描述符限制我实测在普通笔记本上 8 并发就已经能看到性能拐点。任何插件都必须支持--json哪怕内部只是返回一个空对象否则脚本调用方就得针对这个插件写特例统一性就破功了。6. 经验总结与扩展方向前后差不多用了半个月把 CLI-Anything 打磨到能日常使用。现在公司里已经有三十多个工具接入新工具接入的平均时间在半小时以内绝大多数时候就是写一个 plugin 文件。复盘下来这个项目的价值不在于代码量而在于它定义了一套大家愿意遵守的规矩。技术选型上Python 帮了大忙生态成熟意味着很多底层能力不用自己造但真正的架构核心是那份注册元数据它把命令的“文档”、“补全”、“校验”、“输出”全部串起来了。后续我想做的扩展至少有三个方向值得琢磨。一是支持插件热加载不重启 CLI 就能动态更新命令列表这个对调试和发布频率高的工具很友好。二是加一个remote执行能力把本地命令转发到远程主机执行返回结构保持一致这样跨机器操作脚本可以统一到一个入口。三是更深入的日志追踪把每次命令执行的参数、耗时、结果、底层进程退出码都记录成结构化日志为后续做自动化审计和命令使用频率统计打好底。我个人在实际使用中的体会是做一个全能的命令行网关真正挑战的不是写框架而是忍住不把实现越做越重。让一切皆可命令行化的关键是给混乱的工具生态立一个统一的规矩而不是再造一堆新功能。这个平衡点值得每一个做 CLI 工具的人细细琢磨。