1. 项目概述:CLI-Anything 到底想解决什么问题
如果你写过几个真正的命令行工具,大概会有这种感觉:一个脚本从"能用"到"好用"之间,差的不是功能本身,而是围绕功能的一整套"壳"——参数怎么传、配置怎么管、结果怎么展示、环境怎么切换、别人怎么接手。CLI-Anything 就是冲着这件事去的。它不是一个具体的业务工具,而是一个通用命令行封装框架:你把要做的事情用一种声明式的描述写出来,它负责帮你生成风格统一、参数规范、可扩展的命令行入口。说得直白一点,你只需要关心"做什么",CLI-Anything 来管"怎么调、怎么跑、怎么输出"。
这个项目的受众非常明确:日常要写脚本处理数据、调接口、跑批任务的开发者,以及在团队里维护一堆零散工具、厌倦了每个脚本都要各自实现一套参数解析和帮助文档的人。用它之前,你可能要为一个 200 行的 Python 脚本额外写 150 行 argparse 和 try-except;用它之后,这些变成了一段 YAML 配置。CLI-Anything 的目标是把"新建一个命令行工具"的成本从"半小时起"降到"五分钟内",同时保证所有工具都遵循同样的交互约定。
也有朋友问:现在各种框架不是很多了吗?比如 Click、Commander、Cobra。确实,这些都很成熟。但 CLI-Anything 的思路不太一样——它不绑定具体语言,也不要求你用特定框架写代码,而是把"命令行工具"拆成"声明 + 执行器"两层。你的执行逻辑可以是 Python 脚本、Node 脚本、Shell 命令、HTTP 请求,甚至是一个远程的函数调用;CLI-Anything 只负责统一入口、参数解析、环境注入和输出格式化。这样做最直接的好处是:团队里不管谁用什么语言实现的内部工具,到了使用者面前,都是同一种交互体验。
2. 设计思路与核心架构:把"命令行工具"这件事拆成四层
2.1 配置即声明:为什么选择 YAML 而不是硬编码
CLI-Anything 的第一层是"声明层"。它使用 YAML 来描述一个命令的完整信息:命令名称、参数列表、执行方式、环境变量、输出规则。没有选择硬编码的理由很简单——硬编码等于把工具的定义散落在代码仓库的各个角落,改一个参数名要翻代码、跑测试,最后还要重新部署。而配置化之后,"工具清单"变成一个文件,谁都能审查、谁都能提 MR,变更记录干净清晰。
一份最简配置长这样:
commands: - name: check-http description: "检查一个 HTTP 地址的可用性" params: - name: url type: string required: true help: "要检查的 URL" - name: timeout type: int default: 10 help: "超时时间(秒)" exec: type: shell command: "curl -s -o /dev/null -w '%{http_code}' -m {{timeout}} {{url}}" output: format: raw注意几个设计上的关键点:
- 参数与执行分离:params 定义了"用户能输入什么",exec 里的
{{url}}是运行时替换点。这样后续即使把 shell 换成 Python 执行器,用户的交互方式完全不变。 - 显式的描述:description 和 help 不只是给人看的,CLI-Anything 会用它们自动生成
--help输出。 - 执行器的可替代性:exec.type 指定执行器类型,框架内部据此选择对应的 handler。这个字段是整个扩展机制的钥匙。
2.2 参数解析器:要覆盖 90% 的常见需求而不是 100% 的怪癖
命令行参数解析本身不难,难的是"解析完之后的约束"。CLI-Anything 内置了一套参数规则引擎,用它来标注参数的 type、required、default、choices、validator,这比直接在代码里写 if-else 要规范得多。设计取舍是:只提供必要的基础类型(string、int、float、bool、choice、list、json),不支持那些花哨的正则式校验语法。因为从实际使用来看,真正卡住用户的往往不是校验能力不够,而是"数字被解析成了字符串""布尔值传了 false 变成了 True"这类低级 bug。
我在设计时给每个 type 都配了对应的"解析后处理器",写进文档里的规则很简单:
| 类型 | 输入示例 | 解析行为 | 常见坑 |
|---|---|---|---|
| string | hello world | 原样保留 | 不要把带空格的参数直接塞进 shell 命令,必须加引号 |
| int | 8080 | 转为整数 | 传08080会按八进制解析,必须显式用十进制 |
| bool | true/false | 识别 true、false | 来自 Web 表单的"false"字符串会被 Python 视为 True,务必用bool()包装后判断 |
| json | {"a": 1} | 解析为对象 | 在 shell 中传 JSON 要小心引号转义,建议用户用单引号包整个 JSON |
这些看起来琐碎,但确实是实操中反复踩坑的地方。CLI-Anything 在解析阶段就把类型转换做掉,执行层拿到的永远是"干净的 Python 对象",这个问题从源头上就避免了。
2.3 执行引擎层:统一调度,统一结果
执行引擎是 CLI-Anything 的"内核"。它负责三件事:加载配置、解析命令行参数、调用对应执行器。三件事都要做得稳定,并且要有合理的拦截点。
我把它实现成一个简单的管道:
class Engine: def __init__(self): self.handlers = {"shell": ShellHandler(), "python": PythonHandler(), "http": HttpHandler(), "none": NoopHandler()} def run(self, config, argv): parsed = self.parse_args(config, argv) handler = self.handlers.get(config["exec"]["type"]) if handler is None: raise UnknownHandlerError(config["exec"]["type"]) result = handler.execute(config["exec"], parsed) self.format_output(result, config.get("output", {}))关键设计是parse_args与execute的分离。我在 parse 阶段会把参数解析结果存为一个"上下文对象",里面既包含用户输入,也包含从环境变量和配置文件注入的默认值。这样在执行阶段,逻辑无论是 shell 还是 Python,都只跟这个上下文打交道,不会出现"一个执行器要自己去读环境变量,另一个要自己去读配置文件"的混乱局面。
从维护角度说,新增一种执行器就是在 handlers 字典里注册一个新类,实现execute和probe两个方法。整个框架的扩展点收敛到一个接口上,比在代码里堆 if-else 要清爽得多。
3. 实操:把"任意任务"封装成统一命令
3.1 安装与环境准备
CLI-Anything 用 Python 实现,包名为cli-anything。建议用虚拟环境安装,这个工具本身依赖不多,核心依赖就是pyyaml和click(用它做命令行的基础解析,不冲突),再加一个jinja2用来做参数注入模板的渲染。
python -m venv .venv source .venv/bin/activate pip install cli-anything安装完成后,运行cli-any init会在当前目录生成一个示例配置文件cli.yaml以及一个commands/目录。CLI-Anything 的约定是:在哪个目录执行,就加载哪个目录下的 cli.yaml。这个约定让每个项目都可以自带一套"项目专属命令",这是我最喜欢的一点——它不像全局安装的工具那样脱离上下文,而是能感知项目环境。
如果团队想要集中维护公共命令,也支持设置CLI_ANYTHING_CONFIG环境变量,指向一个全局的 YAML 文件。优先级上,当前目录配置 > 全局配置。这个规则简单直接,几乎不需要额外文档。
3.2 一个可复现的真实案例:封装一个 HTTP 健康检查工具
光说概念没意思。我用 CLI-Anything 封装一个团队内每天都在用的"HTTP 健康检查"命令,整个过程下来不超过十分钟。假设需求是这样的:输入一批 URL,每个地址做一次 GET 请求,记录状态码和响应时间,超过 500ms 的标红,最后输出一张表格。
第一步,创建配置文件cli.yaml:
commands: - name: healthcheck description: "批量检查 HTTP 地址的健康状态" params: - name: urls type: list required: true help: "URL 列表,用逗号分隔" - name: timeout type: float default: 3.0 help: "单个请求超时时间(秒)" - name: threshold type: float default: 0.5 help: "响应时间阈值(秒),超过即标记为慢" exec: type: python script: "scripts/healthcheck.py" output: format: table第二步,写执行脚本scripts/healthcheck.py。它的输入来自 CLI-Anything 注入的ctx(上下文对象),输出就是一个 list of dict,CLI-Anything 的 table 格式化器会负责打印表格:
import time import requests def run(ctx): results = [] urls = ctx.get("urls") timeout = ctx.get("timeout") threshold = ctx.get("threshold") for url in urls: url = url.strip() if not url.startswith("http"): url = "http://" + url start = time.perf_counter() status = "ERR" elapsed = 0.0 try: resp = requests.get(url, timeout=timeout) status = str(resp.status_code) except Exception as e: status = f"ERR: {type(e).__name__}" finally: elapsed = time.perf_counter() - start flag = "SLOW" if elapsed > threshold else "" results.append({ "url": url, "status": status, "elapsed_ms": f"{elapsed * 1000:.1f}", "flag": flag, }) return results第三步,运行命令:
cli-any healthcheck --urls "https://httpbin.org/status/200,https://httpbin.org/delay/2" --timeout 2输出效果类似:
+--------------------------------------+--------+------------+------+ | url | status | elapsed_ms | flag | +--------------------------------------+--------+------------+------+ | http://httpbin.org/status/200 | 200 | 120.3 | | | http://httpbin.org/delay/2 | 200 | 2000.1 | SLOW | +--------------------------------------+--------+------------+------+整个流程做下来,用户层零代码,执行层也只需要写纯函数,不涉及任何 argparse 或参数处理代码。这就是 CLI-Anything 的核心价值:使用者看到的是统一、规范的命令行;开发者只需要关心业务逻辑本身。
3.3 进阶:通过环境注入与上下文串联实现"动态命令"
单个命令封装不难,真正让 CLI-Anything 产生质变的是上下文的跨命令共享。也就是说,一个命令产生的输出可以作为下一个命令的输入,从而把散落的操作串联成工作流。
CLI-Anything 提供了一个隐藏参数--with,用法是cli-any healthcheck --with last-result --urls "{{last_result.hosts}}"。这里的{{last_result.hosts}}会从上一次命令输出的 JSON 结果中取值。我说的命令本身并不强制要求输出 JSON,但当你想要串联多个命令时,建议把输出格式设为json或"json + 表格"的混合模式。
实际例子:我先跑一个pull-container-images命令拿到当前环境里一组镜像名,再串一个scan-images命令去扫描漏洞。传统做法是在两个脚本之间用临时文件传数据,CLI-Anything 直接帮你把数据挂在上下文里,过程对用户完全透明。
cli-any pull-list --env prod cli-any scan-images --with last-result --images "{{last_result.images}}"这个特性让我在自动化巡检类场景里省了大量胶水代码。但要注意,这依赖命令约定输出 JSON 结构,所以写执行器时尽量都返回 list 或 dict,格式化交给 CLI-Anything,不要把打印行为写死在脚本里。
4. 实战经验:从可用到好用的关键设计细节
4.1 命令命名:保持一致性的三原则
命令命名这个事,刚开始觉得无所谓,等命令多了之后就会很痛苦。CLI-Anything 的配置是集中式的,所以特别容易看出命名混乱的问题。我带队落地时的约定如下:
- 动词开头:
check-http、deploy-app、backup-db,一眼看出这个命令"对谁做了什么"。 - 用中划线而不是下划线:CLI 世界的主流习惯是 kebab-case,例如
kubectl get pods。虽然解析器两者都接受,但统一用中划线能减少使用者的记忆负担。 - 禁止命令名称嵌套过深:理想情况是一级命令就表达完整含义。CLI-Anything 支持子命令,但我不推荐把层级做得很深——真正需要分层时,宁可拆成多个 YAML 文件。
命名是 API 设计的一部分,改起来成本很高。配置框架虽然让"增删命令"变得容易,但一旦团队开始依赖,重命名仍然会让文档、脚本、CI 配置大面积失效。所以我的建议是:新增命令前先看一眼现有清单,确认名称风格一致,再动手写配置。
4.2 参数默认值:不要在生产环境使用有副作用的默认值
这是踩过一次很深的坑之后总结出来的教训。CLI-Anything 允许在参数定义里写 default,但如果你写的默认值是一个"会对外部系统产生副作用"的操作,那么用户在一个受限环境里无意识触发时,会非常危险。
比如这样:
- name: restart-service params: - name: env type: choice choices: [dev, staging, prod] default: dev看起来没问题,默认 dev 很安全。但如果在 CI 脚本里被调用时没传 env,CI 的配置又刚好覆盖了环境变量ENV=prod,那么默认值就会被环境变量覆盖,形成"意外操作线下系统"的隐患。CLI-Anything 的优先级是"命令行参数 > 环境变量 > 配置文件 > 默认值"。这个优先级本身合理,但作为命令的设计者,你要想清楚:如果你的默认值有副作用,宁可把它设成必填,也不要留默认。
在实现时,我给自带网关加了一个side-effect标志,配置里如果标记了这个字段且参数未显式传给值,会额外打印一条警告并要求用户二次确认。遇到这类需求,别嫌麻烦,安全墙永远比事后补救成本低。
4.3 输出格式:不要在你的脚本里 print
很多从脚本转过来的同学,习惯在代码里直接print结果。这在独立脚本时代没问题,一旦接到 CLI-Anything 里就成了问题,因为我上面提到的"上下文共享"依赖结构化输出。正确做法是:执行器返回结构化数据,显示交给框架。这样用户可以用--output json拿到机器可读结果,也可以用--output table拿到人类友好界面,还能用--output raw给 shell 脚本直接消费标准输出。
为此我实现的 handler 有一个约定:如果执行器返回的是一个 list[dict] 或 dict,CLI-Anything 会尝试格式化;如果执行器已经打印了内容(例如内部调用了库的 debug 输出),就必须设置output.raw: true,否则框架会对输出做二次包装,导致格式错乱。这个坑几乎每个刚上手的人都会遇到。
我推荐的做法是,在 Python 执行器里把所有结果收集到results变量中,最后return results,保持执行器本身是"纯函数"。测试时也可以直接 import 这个函数,写单测非常舒服。这对团队代码质量有立竿见影的提升。
5. 常见问题与排查技巧实录
5.1 命令未注册:找不到名称时的排查顺序
cli-any some-command报出"Command not found",第一反应就是查配置。但有时候配置里明明写了,仍然提示找不到。这时候按下面顺序排查:
- 检查当前目录配置文件是否被加载:运行
cli-any doctor,它会打印当前生效配置路径。如果指向的不是你预期的那份 YAML,多半是因为在子目录里执行,而子目录没有自己的配置,CLI-Anything 不会向上层目录去找,只会按"当前目录 > 全局"的顺序。 - 检查 YAML 缩进:YAML 对缩进敏感,而且很多报错信息会很隐晦。比如 commands 下面少缩进了一个字符,配置就被解析成空列表。我用
python -c "import yaml; print(yaml.safe_load(open('cli.yaml')))"快速验证结构是否正确。 - 检查命令名里是否有隐藏字符:从文档复制配置时,名称后面可能带上了一个空格或者全角冒号。肉眼很难发现,但我测试时发现过——用
cat -A cli.yaml可以看到行尾空格。
这种问题 80% 是配置文件的"低级错误",CLI-Anything 的设计默认是"fail loud",即配置有问题就直接报错退出,而不是静默忽略。这实际上帮了不少忙,因为它让问题暴露得又早又明确。
5.2 参数解析出现异常类型错乱
在--timeout 3传到执行器后,ctx.get("timeout")拿到的是一个字符串而不是浮点数——这个问题一般不是 CLI-Anything 配置错了,而是你写了"自定义执行器"但忘了声明参数数据类型。CLI-Anything 在处理自定义扩展时有一个要不要读取参数的取值问题:如果执行器是一个外部命令(比如exec.type: shell),它只能收到字符串,即框架把参数渲染进模板时一定是字符串;只有内置的 python handler 才会传"类型转换后的对象"。我在文档中把这条规则写成了粗体:shell 执行器收到的永远是字符串,python 执行器收到的是解析后的类型。如果你在 shell 模板里对 int 类型做了数字运算,一定要记得显式转换:
command: "result=$(( {{timeout}} + 5 )); echo $result"这里{{timeout}}是字符串 "3",但 shell 的算术展开会自动处理数字字符串,所以没问题。但如果参数是浮点型,shell 里就不要尝试直接用,几乎都会算错,建议改用python -c执行器。
5.3 执行超时:没有全局超时导致的挂死
框架本身是同步执行,如果执行器内部发了个 HTTP 请求而对方一直不响应,整个 CLI 会一直挂着。CLI-Anything 在配置层提供了一个exec.timeout字段,但我得坦白说:我最初没有实现这个字段,后来在监控脚本里遇到一次长达 23 分钟的挂死(某个第三方接口把连接池耗尽,所有请求排队),才被迫加上。
exec: type: python script: "scripts/healthcheck.py" timeout: 30实现方式是给执行器的 execute 方法包一层concurrent.futures.TimeoutError,超时后直接抛异常并结束进程。这也是我的一个经验忠告:在设计执行器时,把可能阻塞的外部调用放到独立线程里,并始终给默认超时。CLI-Anything 内置的 HTTP handler 默认超时是 5 秒,但如果你自己写 Python handler,就得对自己的代码负责。框架没法知道你内部哪一行会阻塞。
5.4 跨平台兼容性:Windows 上最容易炸的环节
项目开发都在 macOS/Linux 上,但团队里有同事用 Windows,命令行一旦涉及 shell 执行就各种出问题。我遇到最多的两类:
- 路径分隔符:模板里写了
/tmp/dir或C:\Users\...这种字面量,到了另一个平台直接失效。最好提供环境变量注入,例如{{env.TEMP}}。 - shell 转义差异:同样的
grep "foo" file在 PowerShell 和 bash 里的行为完全不同。如果执行器是 shell 类型,又不指定shell: bash,Windows 默认会走 PowerShell,很可能直接报错grep: command not found。
CLI-Anything 的解决方案是在配置里新增一个字段:
exec: type: shell shell: bash # 或 powershell command: "..."如果使用 bash 类型,框架在 Windows 上会优先搜索 Git Bash 或 WSL 作为解释器。尽管如此,我还是建议团队尽量把复杂的执行逻辑用 python handler 重写,毕竟 Python 的跨平台性远高于 shell 脚本。这一条在交付给 Windows 用户前几乎必查一遍。
5.5 配置热加载与并发冲突
CLI-Anything 默认每次执行都重新读取 YAML 配置,所以改配置后无需重启,这很符合"工具脚本"的直觉。但并发执行时就会出问题:两个cli-any进程同时第一次运行,同时写缓存文件,就可能导致其中一个读到半个写状态。我加了一层简单的文件锁,用fcntl(Windows 上等效为msvcrt)包裹配置读取和缓存写入的临界区。对于工具类项目,这个简单锁足够,上升到分布式集群环境的话,就不该用本地配置了,建议把配置下发到共享存储或配置中心,CLI-Anything 可以配合存储驱动扩展。
另外注意,CLI-Anything 支持在配置里写"继承/复用"其他命令的公共参数(通过extends字段),但继承逻辑是解析时展开的。如果你改了被继承的父命令参数,所有子命令会在下一次执行时同步生效,无需手动同步。这个机制很爽,但也要小心:改父命令等于动了一批命令的公共接口,务必先在测试环境跑一遍完整链路。
6. 踩坑记录:一个线上事故的复盘
去年的监控系统里,我写了一个巡检命令,通过 CLI-Anything 封装后接入定时任务。有一天报警电话提前响了:某个核心服务的数据库连接数被打满。查下来发现,巡检脚本在启动时加载配置,其中有个参数batch_size默认值是 500。某次业务高峰期,多个巡检任务并发执行,每个任务都按 500 的批量去拉数据,导致数据库连接被瞬间占满。
复盘时有几个层面都出了问题:参数默认值设得太大、没有做并发上限、也没有设置全局超时。用 CLI-Anything 之后,我在配置里加了显式声明:
- name: inspect-db params: - name: batch_size type: int default: 100 side-effect: true help: "每批处理行数,建议不超过 200" exec: type: python script: "scripts/inspect_db.py" timeout: 60同时给执行器加了信号量,限制并发数不超过 3。这个事故让我意识到一个很重要的点:框架本身不会替你规避业务层面的风险,但它提供的声明能力(side-effect、timeout、并发限制)可以被你组合成安全网。如果当初不太依赖隐性默认值,这个故障完全可以在压测阶段暴露出来。
所以我现在配置 CLI-Anything 时,会认真给每个可能产生副作用的参数打上标记,同时把"不传入即报错"作为一种默认策略。宁可多敲几个参数,也不要让默认值替你做了不可控的决定。
7. 后续扩展方向与个人体会
CLI-Anything 目前的实现覆盖了 shell、python、http 三种执行器,以及 JSON、表格、raw 三种输出格式,基本满足了我的日常需要。后续我计划扩展的两个方向:
- 远程执行器:让一个命令不只在本地跑,而是通过 SSH 或消息队列分发到远端节点执行,这样"本地命令"可以平滑地变成"分布式任务"。其实原理不复杂,就是在执行器里加一个 transport 层。
- 命令的权限与审计:既然所有命令都集中声明,那么在这些命令上加一层"谁可以执行"的规则就非常自然。现在我已经能在配置里标记
require-role: ops,下一步打算把执行日志自动输出到统一审计系统,让每条命令都有迹可循。
我做这个项目的最大体会是:一个工具的终极价值,不是初期多写了多少行代码,而是它让你在半年后加一个新功能时,不需要痛苦地修改旧逻辑。CLI-Anything 把"命令入口"和"业务实现"的耦合拆开,这种解耦带来的收益,在项目初期感知不大,但当命令数量涨到 20 个以上、团队人数超过 5 人时,优势就非常明显了。它不算一个惊艳的技术发明,更像一个"规矩的执行者"——你自己定的规矩,它帮你彻底落地。如果你也在为团队的杂牌脚本烦恼,试试把第一个命令用 CLI-Anything 跑起来,很快就能感受到"统一命令行入口"到底能帮你省下多少沟通成本。