拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CLI-Anything:用声明式YAML构建命令行工具编译框架

CLI-Anything:用声明式YAML构建命令行工具编译框架

1. 为什么我最终造了个“CLI 编译器”

1.1 粘贴复制 argparse 才是最大的重复代码

我的工作目录里脚本数量超过两位数之后,最先崩掉的不是磁盘空间,而是我的耐心。每个脚本都要重复同一套动作:接收参数、校验类型、打印帮助、处理异常。今天给 A 脚本加一个--verbose,明天给 B 脚本补一个--format json,改完还要测试各种参数组合,我越来越觉得,真正的重复代码不是业务函数,而是整个“命令行外壳”本身。

于是“CLI-Anything”这个项目名就这么出现了。它的定位很直白:把任何一类能力封装成命令行工具,不限定是 Python 函数、HTTP 接口、SQL 查询还是外部二进制。你只要给一份声明式描述,框架负责生成参数解析、类型校验、帮助文本、自动补全和退出码。说得夸张一点,它不是帮你写 CLI 的库,而是给 CLI 写的编译器。

这个思路最开始是从一次很反直觉的对比里出来的。那时我同时维护三个工具:一个定时任务脚本、一个数据导出脚本、一个内部小服务的管理命令。三者语言不同、参数风格不同、输出格式不同,但都是在“把某些输入转换成某些结果”。我当时就冒出一个念头:与其反复给每个工具写解析器,不如定一种通用描述格式,把工具自身当作一个可插拔的“执行器”,让框架统一处理那些烦人但必须有的细节。

CLI-Anything 的核心循环一直没变:读描述、建命令树、收集参数、校验、执行、格式化输出。外面看起来就是最普通的命令行工具,但所有“壳”都由同一套骨架生成。我在实际使用中最大的感受是:改动参数时只需要改 YAML,不用动代码;新增工具时只需要写适配器和用例,不用重造轮子。

1.2 “描述”取代“实现”:CLI-Anything 的最初原型

第一版原型只有两百行代码,思路非常朴素:你写一个 YAML,里面列出命令名、参数、执行目标,我按这个描述生成一个 argparse 解析器。当时的运行目标只支持 Python 模块里的函数,因为我自己最常用的是函数调用。

command: name: ping description: 测试一个地址是否可达 input: - name: host type: string required: true - name: count type: int default: 4 run: target: python_function module: mytools.network function: ping_host

这段描述跑起来之后,你能直接得到一个带--host、--count、--help的命令。最让我吃惊的不是它能用,而是它几乎不费力气就拿到了跨工具一致性:所有命令的帮助格式长得一样,参数错误提示的风格统一,退出码规则一致。后来我逐渐把“函数调用”这个目标扩展为“适配器”,才真正开始包住各种常见场景。

现在回看,这个项目最大的价值不是省了多少行代码,而是建立了一个“命令层的叙事方式”。团队里的人看到一个 YAML,就知道这个命令能干什么、要传什么参数、输出会长什么样;看到一个新的适配器,就知道如何把别的系统接进来。下面几章我会把整个架构拆开讲,包括描述层、适配器层、内置机制,以及我从真实使用里踩出来的一堆坑。

2. 描述层如何让一份普通 YAML 变成完整命令树

2.1 最小示例:一个命令只描述 name、description、input、run

CLI-Anything 所有功能的入口都叫“命令描述”,简称 CD。一份 CD 至少包含四部分:命令叫什么、命令有什么用、需要哪些输入、最终要执行什么。

command: name: datex description: 日期转换与偏移计算 input: - name: date type: datetime required: true help: 原始日期,格式 2025-04-01 - name: offset type: int default: 0 help: 增加或减少的天数,负数表示往前 run: target: python_function module: myutils function: shift_date

框架加载这份 CD 后,会生成一棵命令树,并把输入参数注册成解析项。你需要知道的第一个原则是:input 里的每一项最终都会映射到命令行的一个--name参数,而 name 本身就是这个参数的唯一定义,它不需要别的别名。对于布尔类型,映射成--flag后不需要传值;对于文件类型,框架会自动检查路径存在性。

第二个原则是:run 段的 target 决定用哪个适配器。CLI-Anything 默认内置函数、HTTP、SQL、Shell 四种适配器,target 就是适配器注册名。如果你在自己的项目里引入了 CLI-Anything 作为库,也可以注册自定义 target,后面章节我会给自定义适配器的实际代码。

2.2 类型推导的顺序:显式声明、函数注解、默认值

如果 YAML 里写了type,框架直接采用;如果没有显式声明,而目标是 Python 函数,适配器会读取函数签名里的类型注解;注解也没有,再退回默认值的实际类型。这个推导顺序我建议不要乱调,因为显式声明最稳定,函数注解适合快速开发,默认值推导则只是因为“兜底”。

需要小心的是 bool 类型。Python 里bool("False")是 True,所以框架对布尔参数做了专门处理,只接受true/false/1/0/yes/no这几个字面量,避免用户写--flag False时踩惊天大坑。

下表是通用类型和命令行外观的对应关系:

YAML type命令行表现Python 目标类型
string直接接收文本str
int整数,自动校验范围int
float浮点数,自动校验范围float
bool--flag或--flag=truebool
datetime接受2025-04-01T10:30:00格式datetime
path自动展开~,解析相对路径Path
json接收 JSON 字符串,解析成 dict/listobject
enum只允许枚举值列表中的字面量str
list可重复传参,逗号分隔也能拆list

这个表越往后越是 CLI-Anything 相对原生 argparse 的优势所在。原生 argparse 里,path 类型只是字符串,json 类型要自己写转换函数,enum 要再包一层choices=。在描述层统一之后,这些转换规则变成所有命令共享的默认行为,新加一个命令的成本被压得非常低。

2.3 嵌套子命令的三个约定

CLI-Anything 支持多级子命令,但保留了三个硬性约定,避免设计失控:

第一,一个 CD 文件代表一个叶子命令,不能在一个文件里塞两个没有关系的动作。第二,如果一个目录下有多个 CD 文件,目录名自动变成命名空间前缀,例如user/list.yaml会生成user list这条命令。第三,只有一个执行入口,任何层级的命令都要落在某个 target 上,不允许出现“目录节点也有执行逻辑”的情况。

约定的好处是,生成 shell 补全时能穷举命令树,不用猜测。实际项目里,我一般按照“业务领域/动作”组织文件:billing/list.yaml、user/create.yaml。这样无论命令数量增长多少,命名空间不会乱。

在内部实现上,框架会把 YAML 文件解析成字典,然后构建一棵CommandNode树。CommandNode负责把子节点注册成 argparse 的 subcommand,把 input 注册成参数,再把 run 配置交给适配器。你不需要知道这棵树多深,只要记得叶子节点的 run 才是真正干活的部分,其他层级的 run 是不允许写的。

3. 适配器设计:函数、HTTP、SQL 三种形态的归一化

3.1 函数适配器:用 inspect 读取参数签名

所有适配器都遵循同一个极小协议:能描述自己能接收什么参数,能执行实际调用,能提前做一次无害的演练。函数适配器是第一个实现的,它把 Python 函数当成最终目标。

class PythonFunctionAdapter: def __init__(self, module_name, func_name): self.module_name = module_name self.func_name = func_name self._func = None def describe(self): import inspect obj = self._load() sig = inspect.signature(obj) result = [] for name, param in sig.parameters.items(): result.append({ "name": name, "default": param.default, "annotation": param.annotation, }) return result def execute(self, params): fn = self._load() return fn(**params) def _load(self): import importlib if self._func is None: mod = importlib.import_module(self.module_name) self._func = getattr(mod, self.func_name) return self._func

这里最核心的是describe()返回的参数列表,CLI-Anything 会拿它补全 YAML 里没写的 type、default 和 required。执行时则直接把解析好的字典**params丢给函数。这种设计让已有业务函数可以零侵入接入,不需要在函数里调框架任何东西。

3.2 HTTP 适配器:把 OpenAPI 描述变成子命令

我大量用到“把内部 HTTP 接口包装成 CLI”的场景。HTTP 适配器建议接入一份 OpenAPI 描述文件,框架根据路径和方法自动生成子命令。以只读接口为例,默认只允许 GET 方法,只有显式配置才允许 POST。

command: name: todo description: 与本地待办服务交互 adapter: http openapi: http://127.0.0.1:8000/openapi.json method: GET path: /todos output: table

执行cx todo list时,适配器会把子命令名和路径绑定,再根据 OpenAPI 里的参数定义,把 query 参数暴露为命令行参数。返回体如果是 JSON,输出层负责渲染成表格或纯文本。这个适配器的设计重点是隔离:CLI 只管请求和展示,不关心服务内部实现。

实操中你不需要害怕路径里有复杂嵌套,例如/users/{id}/orders。CLI-Anything 会把路径参数{id}转成--id的必填参数,和普通 query 参数放一起。这样即使是专为前端设计的接口,也能用命令行完成调试。

3.3 SQL 适配器:查询文件加参数模板,不做 ORM

SQL 适配器的设计原则是:只做参数绑定和结果展示,不提供增删改的通用入口。我把连接信息固定成只读连接,执行的 SQL 必须来自预先声明好的sql_file,不允许用户直接在命令行里面传一段 SQL。

command: name: monthly-report description: 生成指定月份的销售汇总 adapter: sql connection: sqlite:///sales.db sql_file: queries/monthly.sql input: - name: month type: string required: true output: table

monthly.sql里只写 SQL,参数用{{ month }}占位:

SELECT strftime('%Y-%m', order_date) AS month, product_name, SUM(amount) AS total_amount FROM orders WHERE strftime('%Y-%m', order_date) = '{{ month }}' GROUP BY product_name ORDER BY total_amount DESC;

绑定参数时框架会对{{ }}做转义处理,不能把占位符当成普通字符串拼接。实际执行时使用参数化查询,既照顾了 SQL 注入风险,也能让数据库缓存执行计划。你只要保证queries目录里的文件都是内部可控的,这个工具就非常稳。

4. 那些“看不见但很加分”的内置机制

4.1 自动补全和 help:人的时间比协议格式值钱

CLI-Anything 内置两套帮助机制:一套是--help时打印的完整说明,另一套是 shell 补全。补全脚本生成不需要额外安装点,框架会把命令树里的叶子命令、参数名、枚举值导出成固定格式,再转换成 bash/zsh 的补全函数。

设计的时候我坚持一个原则:帮助信息不能只写参数类型,必须能看到默认值和可选项。比如枚举值参数,直接列出可接受的值;文件参数说明是否已存在。这样使用者不用去翻文档,就能自己摸索完整命令。

自动补全里最容易被忽略的是“目录命名空间补全”。如果命令是billing list --status pending,那么你敲cx billing之后按 Tab,应该只提示list和这个命名空间下的其他叶子命令,而不是像某些工具一样把所有子命令一股脑列出来。实现方式就是遍历CommandNode的树结构,只给当前节点相邻层级的候选。

4.2 三种输出模式:human、table、json

CLI-Anything 的默认输出是 human 模式,适合人眼阅读;table 模式适合多个结果列;json 模式适合程序消费。你可以在 CD 里声明默认 output,也可以在命令行用--output json覆盖。

human 模式会丢掉一些结构化字段,只输出关键信息;table 模式会做列宽计算,避免中文被截成乱码;json 模式输出json.dumps(result, ensure_ascii=False, indent=2),保证 Unicode 可读。这里有个细节:如果输出内容包含大段文本,比如一段日志或者一个对象详情,我通常建议默认用 json,否则脚本解析起来会痛苦。

输出层还有个和错误处理相关的设计:任何层层都往 stderr 写过程日志,stdout 只留给最终结果。这样做是为了让cx report --month 2025-04 > result.json能拿干净结果,而不是混入一堆提示信息。

4.3 退出码和 stderr:让脚本敢去编程性使用这个 CLI

我一直觉得,如果一个命令行工具只给人用、不给脚本用,那它只能算半个工具。CLI-Anything 固定了一套退出码约定:成功返回 0;参数错误返回 2;目标执行失败返回 3;用户中断返回 130。这套约定记录在帮助文档里,所有适配器共用。

参数错误和业务错误分开是关键。参数错误是调用者写的命令不合法,比如类型不对、枚举值非法;业务错误是目标本身报错,比如数据库连不上、HTTP 返回 500。如果不区分,脚本做自动化重试时就会把“写错命令”和“服务故障”混在一起处理。

实现上,适配器执行异常会被包装成ExecutionError,由外层主循环统一处理。主循环捕获到UsageError时打印到 stderr 并返回 2,捕获到ExecutionError时返回 3。这个错误分类是我用到现在收益最大的一项设计。

5. 三个可复现的实操案例

5.1 案例一:把一个日期处理函数变成“日期转换命令行”

先写一个普通的工具函数:

# myutils.py from datetime import datetime, timedelta def shift_date(date_str: str, offset_days: int = 0) -> str: dt = datetime.fromisoformat(date_str) return (dt + timedelta(days=offset_days)).date().isoformat()

再写对应的 CD:

command: name: datex description: 输出偏移指定天数后的日期 input: - name: date type: string required: true - name: offset type: int default: 0 run: target: python_function module: myutils function: shift_date

现在执行:

$ cx datex --date 2025-04-01 --offset 3 2025-04-04

这个案例看起来简单,但实际覆盖了参数解析、默认值、函数调用三个环节。如果你以后要把shift_date换成任意本地函数,只要保持模块路径能导入就行,连测试代码都不用动。

5.2 案例二:把一个待办列表 HTTP 服务包成五个子命令

假设本地有一个待办服务,返回 JSON。写一个最简单的只读 CD:

command: name: todo description: 待办列表查询 adapter: http base_url: http://127.0.0.1:8000 mapping: list: method: GET path: /todos get: method: GET path: /todos/{id}

mapping的作用是把子命令名和方法路径一一对应。执行:

$ cx todo list | id | text | done | |----|--------|------| | 1 | 写周报 | false |

如果你打开了 OpenAPI 自动映射,连mapping都可以省掉,框架直接从/openapi.json里生成所有路径。我建议小服务用手写 mapping,大服务用 OpenAPI,这样每个团队都能按自己的维护习惯来。

5.3 案例三:把一条 SQL 查询变成月度报表命令

继续用上一节的 SQL 适配器。你只需要确认数据库连接串没有写死密码,而是通过环境变量读取:

$ export SALES_DB=sqlite:///sales.db $ cx monthly-report --month 2025-04 | month | product_name | total_amount | |---------|--------------|--------------| | 2025-04 | 键盘 | 12800 |

我最喜欢这个用法的地方是:它把一次手工查询变成了团队里人人可用的命令,而且 SQL 文件本身还是纳入版本管理的。你不需要让业务人员会写 SQL,只需要让他记得cx monthly-report --month 2025-04。

6. 用久了才沉淀下来的十几个坑与默认值

6.1 路径参数不能无脑透传

第一个坑是路径参数。很多 CLI 新手会把用户输入的路径直接拼进业务代码,结果~没展开、相对路径不是从当前目录出发、Windows 路径反斜杠被当成转义。CLI-Anything 里的 path 类型会在解析阶段就做标准化:展开~,转成绝对路径,统一用/分隔,并且保留原始输入副本以便调试。

如果业务函数接收的是字符串,但配置成 path 类型,框架会传给str(Path(...))的结果;如果业务函数期望Path对象,框架直接传入。这个约定避免了“本地能跑、服务器上路径就炸”的问题。提示一下:凡是涉及文件读取的命令,都尽量把参数声明成 path 而不是 string。

6.2 编码、断行与 Windows 终端的温柔陷阱

第二个坑来自终端编码。在 Windows 上,默认代码页可能是 GBK,Python 打印中文时容易遇到UnicodeEncodeError。CLI-Anything 的做法是在入口位置统一设置输出编码:

if sys.stdout and hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")

如果还遇到更老的环境,可以设置环境变量PYTHONUTF8=1。另一个和断行有关的问题是表格列宽:中文字符在终端里宽度算 2,不能直接用字符串长度算对齐。CLI-Anything 用了东亚宽度计数,否则表格格式会在含中文时乱掉。

6.3 不是每次运行都要等真函数:dry-run 与超时

第三个坑是被长任务卡住。有些命令背后是复杂的本地计算或外部服务调用,用户只是想看看参数有没有写对,结果直接等了几十秒。CLI-Anything 对所有适配器都实现了--dry-run,执行时只打印将要调用的目标、参数映射结果和预计输出格式,不真正执行。

对于真正需要限时的场景,函数适配器和 HTTP 适配器都支持timeout配置。函数适配器会用线程池包一层,超时后返回超时错误;HTTP 适配器直接使用请求库的超时参数。默认超时我在项目里设置为 30 秒,你可以根据业务调整。

run: target: python_function module: myutils function: slow_task timeout: 120

6.4 白名单和只读默认值:CLI 是入口,也是边界

最后一个坑是能力边界。CLI-Anything 可以被当做一个胶水层,但胶水层不能成为后门。所以我在框架里做了几个限制:Shell 适配器只允许执行白名单列表内的可执行文件;SQL 适配器强制走只读连接;HTTP 适配器默认只允许 GET。

这三个限制表面上看是收缩了能力,实际是保护了使用的人。一个通用工具容易被滥用,与其出了问题再补救,不如在设计阶段就把高风险动作标成“显式开启”。我的原则是:默认最小权限,命令如果要执行外部程序,必须在 YAML 里写清楚executable名称和允许参数前缀,任何没登记的都会在解析时直接拒绝。

7. 我对 CLI-Anything 下一步的期待

用过一段时间后,我最希望它做的事不是继续加适配器,而是把“命令描述”变成一种可共享、可组合的标准。比如两个命令之间可以声明依赖关系,一个命令执行完自动触发另一个命令;又比如把 CD 导出成 JSON Schema,这样编辑器可以自动补全 YAML 字段,第三方工具也能根据同一份描述生成 Web UI。

还有一个我很想要的能力是“会话模式”。现在每个命令都是单次执行,但很多运维场景需要先查状态、再改配置、最后再看结果,这中间的状态一般放在 shell 环境变量或临时文件里。如果能用会话上下文把多次命令串起来,CLI 的脚本化能力会向前一大步。

就我个人体会而言,CLI-Anything 最让我满意的不是代码量少,而是它逼着我用“描述”而不是“命令”去思考工具设计。每当我准备给脚本加一个没有写在 CD 里的参数时,第一反应是去修改描述文件,而不是立刻动手写解析逻辑。这个小小的思维转换,比任何一个具体功能都值钱。

返回列表