1. 我为什么写CLI-Anything:命令行入口本不该重复造
先说一下我遇到的实际场景。过去一个月里我连续给团队做了三个小工具:一个是把内部订单查询接口包成命令行,方便运营直接跑;一个是把每天凌晨的报表任务改成可手动触发的脚本;还有一个是给测试同学用的批量造数工具。三个工具的业务完全不同,但代码结构几乎一模一样:先写一个argparse参数解析,再写几个函数处理逻辑,最后补上help文档。做到第三个的时候我实在不想再Ctrl+C、Ctrl+V了,于是开始琢磨——能不能用一个统一的框架,让“写一个CLI工具”这件事变成“写一份配置文件”?
这就是CLI-Anything的由来。简单说,它是一个配置驱动的命令行生成框架:你只负责描述“我的工具有哪些命令、每个命令接收什么参数、实际执行时做什么”,CLI-Anything读取这段描述后,自动生成一个完整可用的CLI程序。对于团队内部工具这种“比脚本重、比平台轻”的场景,它能把开发时间从两个小时压缩到二十分钟。
我的核心诉求有三个:第一,命令参数的定义必须直观,最好是一份能看懂文本;第二,底层执行逻辑要足够灵活,至少能同时覆盖HTTP调用、本地脚本、Python函数这三类常见情况;第三,生成的CLI在终端里的交互体验要像认真写的工具,而不是脚手架产物。这三个诉求直接决定了CLI-Anything的设计方向。
这里也要坦白说清楚边界:CLI-Anything不是要取代Click、Typer这类成熟框架,也不是要做一个通用的shell替代品。它瞄准的是那些反复出现的内部工具开发场景——业务逻辑不复杂、但要频繁加参数、改入口、调交互。与其每次从零写,不如把变化的部分收敛到配置里。下文我会把整个项目的设计、实现和踩坑过程完整拆开讲。
2. 配置驱动的三元素模型:命令、参数、执行器
2.1 为什么选择配置驱动而不是代码驱动
很多朋友看到这里会问:直接用Click不香吗?写代码也很简洁,而且类型检查、参数校验、自动补全全都现成。我承认Click确实好,但“写代码”和“写配置”之间有一个微妙的差异:写代码意味着每次加一个命令你都要经过“改文件、走测试、重新发布”的完整链路,即使只是改一个参数名;而写配置意味着你可以把工具的元信息抽离出来,甚至做成在线编辑、热加载、按团队共享。
CLI-Anything把“命令是什么”和“命令怎么做”分开:配置文件负责描述前者,执行器负责实现后者。配置里不出现任何业务逻辑,只出现参数名、类型、默认值、校验规则、执行器标识以及传给执行器的参数映射。这样一来,新增一个工具基本不需要动代码,需要改逻辑时也只改执行器本身,边界很清楚。
2.2 配置格式与命令定义
CLI-Anything使用YAML作为主配置格式,因为它在缩进结构上比JSON更接近自然语言,团队非技术人员也能看懂。一个最基本的配置文件长这样:
tools: order-query: description: 查询订单状态 args: - name: order_id required: true help: 订单ID,以ORD开头 - name: verbose flag: true alias: -v help: 输出详细信息 execution: type: http url: https://api.internal.example.com/v1/orders/${order_id} method: GET headers: Authorization: "Bearer ${env.API_TOKEN}"这里定义了一个名为order-query的命令,它的参数体系清楚地列在args下,execution部分说明它要发起一次HTTP GET请求。注意${order_id}和${env.API_TOKEN}这两种占位符:前者引用命令行参数,后者引用环境变量。CLI-Anything在执行前做一次统一的插值解析,把命令实际执行时需要的值全部确定下来。
为什么推荐用这个配置模型?因为我把团队里最常见的三种命令做了归纳:调用HTTP接口的、执行本地脚本的、调用Python函数的。它们都可以抽象成“入参 + 执行目标 + 结果输出”这三个阶段,而配置刚好能完美覆盖这三个阶段。
2.3 参数定义的完整字段
参数是CLI工具的骨架,参数定义的质量直接决定使用体验。CLI-Anything支持以下参数规则,我列一张表方便对照:
| 字段 | 作用 | 示例 |
|---|---|---|
| name | 参数名,生成CLI时的长选项名 | order_id |
| alias | 短选项名 | -o |
| required | 是否必填 | true / false |
| default | 默认值,缺省时自动注入 | 100 |
| type | 参数类型,支持string、int、float、bool、json | int |
| flag | 是否为开关型参数,不带取值 | true |
| choices | 合法取值列表 | [prod, dev, test] |
| validator | 自定义校验规则,引用内置或注册的校验函数 | regex: ^ORD\d+$ |
| help | 参数帮助文本 | 订单ID,以ORD开头 |
| multiple | 是否可重复传入,收集为列表 | true |
这里要特别说下type为json的场景。很多内部脚本需要传入结构化参数,例如造数工具要传一组用户属性。如果让用户在shell里写JSON字符串,转义简直是一场灾难。CLI-Anything允许这种参数从文件读取或从stdin接收:--user-data @users.json,以@开头时自动按文件读入并解析。这样既绕开了shell引号地狱,又保持了参数表达力。
2.4 执行器:把配置映射到真实动作
配置文件里的execution是CLI-Anything与Click这类框架最大的区别。execution支持type有http、shell、python三种,分别对应三大场景。
http执行器内部基于requests实现,支持method、url、headers、params、body、timeout、retry,以及一个result_path字段用于从JSON响应中提取指定内容打印。比如接口返回结构是{"data":{"order":{"status":"PAID"}}},你只想看到status,result_path直接写data.order.status即可。
shell执行器接收command字段,把命令行参数按映射填入命令模板,然后用subprocess运行。支持cwd、env、shell_mode(默认False,推荐列表方式传参避免注入问题)。
python执行器指向一个可导入的函数路径,例如__main__:handle_order或src.main:run,CLI-Anything负责把参数解析成关键字参数传进去。这是整个工具里最灵活的方案,适合那些逻辑复杂、不适合硬编码进配置的场景。
3. 从零搭建:四步把任意HTTP接口变成CLI命令
3.1 安装与初始化
CLI-Anything的安装很简单,我用Python写的,直接走pip:
pip install cli-anything目前支持Python 3.9及以上版本。安装完成后有一个主命令cli-anything和配套的生成命令anygen,前者用于运行由配置描述的命令,后者用于把配置打包为独立脚本。初始化一个项目目录:
mkdir mytools && cd mytools cli-anything initinit会在当前目录生成一个默认的tools.yaml,里面有一段示例配置和一个executors/目录,自定义执行器放在这里。整个项目就这两部分,没有其他隐藏依赖。
3.2 写第一份配置:订单查询命令
我们继续用订单查询这个例子。假设内部接口是:
GET /v1/orders/{order_id} Header: Authorization: Bearer <token>现在把它变成命令行工具。打开tools.yaml,填入:
tools: order-query: description: 查询订单状态 args: - name: order_id alias: -o required: true help: 订单ID - name: verbose flag: true alias: -v help: 显示完整响应 execution: type: http url: https://api.internal.example.com/v1/orders/${order_id} method: GET headers: Authorization: "Bearer ${env.API_TOKEN}" timeout: 10保存后就可以直接跑了:
cli-anything run order-query -o ORD20250101 -v执行流程是:CLI-Anything先解析全局参数和order-query的子命令参数,把order_id提取出来,然后读取配置中的execution块,检查环境变量API_TOKEN是否存在,做插值替换,组装请求头,发起请求。响应返回后默认打印状态码和核心字段,加上-v则打印完整响应体。
3.3 调试与配置热加载
CLI-Anything有一个对开发体验很有用的特性:热加载。每次执行命令时,它会检查tools.yaml的修改时间,如果有变化就重新解析配置。这意味着你调整参数或新增命令时不需要重新启动任何东西,直接保存配置再跑一次就是新行为。
我建议初学者在调试阶段用cli-anything doctor验证配置:
cli-anything doctor tools.yaml它会把配置中的语法问题、缺失字段、引用了不存在的执行器类型、参数名重复等问题一次性列出来。我在开发时踩过“参数名写错导致插值后URL中出现null”的坑,doctor能在运行前直接抓出这类问题。配合热加载,调试一个命令基本可以控制在五分钟以内。
3.4 打包成独立脚本
如果团队同事不想安装Python环境,CLI-Anything还支持把某个命令冻结成独立脚本:
anygen build order-query --output bin/order-query这会生成一个带shebang的脚本,内部内嵌了配置文件及必要依赖逻辑。目标机器上只要有Python 3.9+就能直接运行。本质上它做了一个“配置 + 解释器入口”的打包,而不是真编译成二进制。好处是分发简单,坏处是仍然依赖Python运行时。如果团队环境没有Python,建议还是走Docker镜像的方式,官方模板里也提供了对应的Dockerfile。
4. 参数体系与交互体验:一个真正好用的CLI还差什么
4.1 子命令与嵌套结构
单一命令远远不够,CLI-Anything支持在tools下定义子命令树。配置里用subcommands字段来嵌套:
tools: order: description: 订单相关操作 subcommands: query: args: - name: order_id required: true cancel: args: - name: order_id required: true - name: reason help: 取消原因这样用户执行的是cli-anything run order query -o ORD123和cli-anything run order cancel -o ORD123 --reason "用户申请"。嵌套层级目前最多支持三层,再深就违背CLI工具的直觉了。实际项目中我认为两层已经覆盖了绝大多数场景。
4.2 自动生成的help与命令补全
配置驱动的另一个好处是帮助文本不需要手写。CLI-Anything根据tool描述、参数help字段自动生成带缩进的帮助面板:
Usage: cli-anything run order-query [OPTIONS] 查询订单状态 Options: -o, --order_id TEXT 订单ID [required] -v, --verbose 显示完整响应 --help Show this message and exit.如果你觉得默认格式不够好看,也支持通过templates/help.j2提供一个Jinja2模板覆盖样式。但我实际用下来,默认格式在大多数场景已经够用,没必要为花哨浪费时间。
命令补全方面,CLI-Anything支持输出shell补全脚本:
cli-anything completion bash > ~/.local/share/bash-completion/completions/cli-anythingzsh、fish也都有对应输出。开启补全后,按Tab可以自动补全命令名、参数名,甚至choices字段里定义的取值。这个特性是团队同学反馈提升最大的——他们不需要记住参数拼写,直接Tab就能看到候选。不过要说明,动态的参数取值(比如从环境或接口获取)目前还不支持补全,这是后续计划中的功能。
4.3 交互式参数补全
除了纯命令行模式,CLI-Anything还内置了一个交互模式:
cli-anything run order-query --interactive进入后它会在终端里逐项提问,让你填写必填参数。这个模式对不熟悉命令行的运营同学非常友好。内部实现很简单:遍历配置中的参数列表,对每个required但有默认值缺失的参数,使用inquirer风格的提示做输入;flag型参数显示yes/no;带choices的显示数字编号让用户选择。整个交互逻辑只有两百多行代码,但极大地扩展了工具的适用人群。
4.4 输出格式与颜色
终端输出不该是白底黑字的一坨。CLI-Anything内置了几种输出模式:plain(纯文本)、table(表格)、json。默认是plain,配合-v输出详细信息。如果配置里声明response_type为table,且接口返回的是一个对象数组,CLI-Anything会把结果渲染成对齐的表格:
ORDER_ID STATUS CREATE_TIME ORD20250101 PAID 2025-01-01 12:00:00 ORD20250102 PENDING 2025-01-02 09:30:00颜色方面,我一直克制不用过多颜色——只有错误信息用红色,成功提示用绿色,警告用黄色。原因是在自动化脚本和CI管道里,颜色输出反而会变成一坨乱码,所以CLI-Anything检测到非tty环境会自动禁用颜色。这也是一个容易踩的坑,后面章节细说。
5. 踩坑实录:三处反复折腾的问题与完整排查链路
5.1 配置解析中的类型沼泽:YAML的true不是Python的True
第一个坑出现在配置解析层。我最初直接使用PyYAML加载配置文件,然后很自然地用if arg.flag:判断一个参数是不是flag类型。结果发现一个诡异的bug:在部分配置文件里,flag字段写成false时,Python侧拿到的值不是False而是字符串"false",导致条件判断永远为真,所有参数都变成了开关参数。
排查过程值得复述一下。我先怀疑是参数读取的代码出了问题,但在单元测试里直接构造YAML字符串再load,类型又是正确的。继续缩小范围,发现出问题的配置文件是从JSON转成YAML的——JSON里的false被某个转换工具转成了未加引号的false,PyYAML加载后确实会变成False。但真正的问题出现在另一个地方:有人手写了flag: false时前面有中文全角空格,解析器把"false"当成了字符串。本质上这是YAML的宽松类型解析造成的。
最终的修复方案有两个层面。第一,CLI-Anything在加载配置后做严格的schema校验,使用pydantic模型,把所有bool字段都显式声明为bool类型,一旦发现字符串"false"直接报错并提示正确写法。第二,在文档里明确禁用大写的False、FALSE以及未加引号的字符串,统一规定bool一律写true/false。
这个坑的启示是:配置驱动的工具一定要在解析边界做类型强制,绝对不能依赖YAML的隐式类型转换。你放松一点,用户就能用出千奇百怪的类型错误。
5.2 子进程的exit code与信号传递:shell执行器为什么总返回0
第二个坑相对更深。shell执行器最初是这样实现的:
result = subprocess.run(cmd, shell=True, capture_output=True) return result.returncode听起来没问题,但实际调用时发现:一条明明失败的脚本,CLI-Anything却返回0。因为它在前面加了一句export SOME_VAR=...,整条命令变成了export SOME_VAR=1 && /path/to/script.py,subprocess拿到的是整条复合命令的返回值,也就是最后一条命令的返回值——如果脚本前面成功、后续失败,这个错误会被吞掉。
更隐蔽的问题出在信号处理。当我用Ctrl+C中断一个长时间运行的shell命令时,子进程收到了KeyboardInterrupt,但主进程的异常处理把这次中断当作普通异常吞掉,exit code返回1而不是130。这就导致在CI脚本里,命令行工具的退出码完全不可信。
完整的修复方案是这样的:
import signal def run_shell(cmd, ...): proc = subprocess.Popen( cmd, shell=False, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, ) def propagate(signum, frame): proc.send_signal(signum) old_int = signal.signal(signal.SIGINT, propagate) old_term = signal.signal(signal.SIGTERM, propagate) returncode = proc.wait() signal.signal(signal.SIGINT, old_int) signal.signal(signal.SIGTERM, old_term) return returncode注意这里把shell_mode默认改成了False,命令以列表形式传入,避免复合命令带来的返回码污染。同时用Popen替代run,把SIGINT和SIGTERM转发给子进程,让终端的中断行为符合用户直觉。修复后,无论脚本主动退出、被信号杀死、还是整体管道失败,CLI-Anything的返回码都能真实反映执行结果。
5.3 非tty环境下的彩色输出与进度条灾难
第三处硬伤与终端体验有关。我在http执行器里最开始用tqdm显示下载进度条,在真实终端里效果很好。但团队CI管道调用时,日志系统把一大片\r字符和进度百分比全打到了日志文件里,几千行日志全是这种垃圾内容。才意识到:进度条这类动态刷新输出的组件,必须在检测到标准错误是tty时才启用。
CLI-Anything现在的做法是统一封装了一个console模块,在初始化时检测sys.stderr.isatty()和sys.stdout.isatty(),并暴露console.progress_enabled和console.color_enabled两个开关。所有动态输出组件都先查这两个开关再决定行为。非tty环境下,http执行器只打印最终结果,不渲染中间进度。
这个坑也引出一个产品层面的思考:CLI工具的输出必须区分两种消费方——人和程序。人喜欢颜色、进度条、动态刷新,程序只需要稳定单调的纯文本输出。好的CLI设计应该提供类似--no-color、--output=json这类的显式控制,并且默认行为要做到“在管道里也不制造噪音”。
5.4 一个典型的完整排查链路示例
最后用一个综合的排查案例把上面的方法论串起来。某个周末我收到反馈:执行anygen build生成的脚本在同事的笔记本上直接报错“ModuleNotFoundError: cli_anything”。这个脚本明明在本机测试过没问题。
我按照下面的链路排查:
- 先确认报错出现的阶段。在同事机器上直接执行生成的脚本,同样报错,说明不是运行时依赖缺失,而是脚本本身找不到模块。
- 查看脚本头部,发现
anygen build生成的脚本是从当前环境的site-packages读取CLI-Anything的依赖,并没有真正把依赖打入脚本。本机能跑是因为本机装了CLI-Anything,同事机器没装。 - 这是个典型的“构建产物依赖宿主环境”问题。修复方案不再苛求单文件可移植,而是改为生成一个自包含目录,同时提供
--standalone选项,借助pip的--target参数把依赖装进脚本同级的vendor/目录,运行时优先从vendor导入。 - 验证时清空了系统site-packages里的CLI-Anything,用
PYTHONPATH=/tmp/emptytest模拟纯净环境,生成的脚本终于可以独立运行。
这条排查路径的关键是不要被“报错信息”带走,而是先搞清楚报错发生在“构建时”还是“运行时”,以及“本机可用”和“目标环境可用”之间的差异在哪里。
6. 扩展机制与进阶玩法:如何让CLI-Anything长出你自己的方言
6.1 自定义执行器的编写与注册
内置的http、shell、python执行器只能覆盖一半的场景,另一半需要自定义。CLI-Anything在executors/目录里预留了插件协议:你只需要继承BaseExecutor,实现run(context)方法即可。
# executors/sql_executor.py from cli_anything.executor.base import BaseExecutor class SqlExecutor(BaseExecutor): name = "sql" def run(self, ctx): sql_template = ctx.execution["sql"] params = ctx.rendered_args sql = sql_template % params # 这里是伪代码,实际需要引入数据库驱动 results = db.query(sql) return self.format_table(results)写完后,在配置里直接用type: sql:
execution: type: sql sql: "SELECT * FROM orders WHERE id = %(order_id)s"框架会自动发现并注册这个执行器。插件机制的限制很少:只要run方法返回一个可打印对象即可。返回字符串直接输出,返回list会自动按表格渲染,返回dict则按JSON输出。这套约定统一了输出格式,避免每个执行器自搞一套。
6.2 钩子机制:在命令执行前后插入行为
很多工具需要在执行前检查认证、执行后记录日志。CLI-Anything提供了一套轻量级钩子,在配置文件顶层定义:
hooks: before_all: - type: shell command: "check_auth.sh" after_run: - type: python target: "hooks.notify:slack"钩子分三个生命周期:before_all(任何命令执行前)、before_command(具体命令执行前,能拿到命令名和参数)、after_run(命令执行后,能拿到执行结果和耗时)。钩子失败时默认不会阻断主命令,除非声明fail_fast: true。这个机制很好地解决了团队内部工具普遍需要的审计需求——每次调用都留痕,出了问题可以追溯。
6.3 从配置生成README
这是我个人非常喜欢的一个功能。CLI-Anything可以把tools.yaml中的描述、参数、示例自动渲染成一个README文档片段:
anygen docs tools.yaml --format markdown生成的内容包括命令列表、每个命令的使用说明、参数表格、一个实际示例。团队新人看文档就可以上手,不用再追着老同事问“这个参数啥意思”。而且因为文档来源于配置,配置更新后重新生成即可,docs-as-code的效果就出来了。当然,自动生成的文档只适合做参考性质的使用说明,复杂的操作指南还是需要手写补充。
6.4 多配置文件与团队共享
当工具数量超过二十个的时候,单一tools.yaml会变得很长。CLI-Anything支持在配置里用imports引入其他文件:
imports: - tools/order.yaml - tools/report.yaml - tools/admin.yaml拆分成多个文件后,团队可以按业务线分别维护自己的命令定义。配合Git,不同业务线在各自的文件上做Merge Request,冲突概率大幅下降。我在实际项目里按团队结构分成sales、operations、platform三组配置,每个人都有权限更新自己那块,主配置文件的变更次数降到了每月个位数。
7. 一些使用心得与推荐用法
CLI-Anything在这个项目里已经稳定运行了几个月,我个人的体会可以浓缩成几条:
第一,配置驱动的价值不在于少写几行代码,而在于让工具的元信息变得可见、可审查、可共享。运营能在Pull Request里直接评审一个参数名是否合理,这在以前是不可想象的。第二,执行器的边界一定要划清楚:简单逻辑放http和shell,复杂逻辑放自定义执行器,绝对不要尝试在配置里写业务逻辑,否则最终会得到一个比代码还难维护的配置文件。第三,命令的输出在设计初期就要确定“消费方”——是给人看还是给脚本用,如果两者都要,务必提供类似--output=json这样的切换开关。
如果你也想在自己的团队里落地类似方案,我建议从小处开始:先挑两三个手工操作频繁、逻辑相对简单的接口做成CLI命令,放到群里让大家试用。等大家真的开始依赖它,再逐步扩展命令范围。记得把热加载和doctor命令尽早同步给团队,减少他们试错的成本。最后再分享一个小技巧:把cli-anything completion bash接入团队统一的shell初始化脚本,新同事第一天配好环境就能享受完整的命令补全——这件小事带来的好评往往比工具本身更多。