
1. 从“脚本变CLI”到“万物变CLI”CLI-Anything的起源与目标我经手过的内部工具多了之后有一个感受越来越明显多数脚本并不是不好用而是“难发现”。你写了个 deploy.py功能正常参数也接收但别人用的时候必须去看源码才能知道要传--env还是-e要传dev还是staging。这也是一些工具永远只能在小圈子里流转的原因。我当时就想与其在每一个脚本里重复实现参数解析、帮助文本、校验逻辑不如把“命令行接口”本身做成一种可描述、可生成的东西。于是就有了 CLI-Anything 这个项目。CLI-Anything 的核心定位很直接把任意后端能力包装成一套完整、统一、自文档的命令行工具。这里的“任意”不是营销话术而是它的设计目标。后端可以是 Python 函数、Shell 脚本、外部二进制、REST API甚至是你自己维护的一套命令集合。你只需要写一份描述文件告诉 CLI-Anything 这个命令接受哪些参数、有哪些子命令、调用什么目标剩下的解析、校验、帮助、自动补全全部由它负责。这件事听起来有点像 argparse 或 click 的活但视角完全不同。argparse 是“你在代码里定义参数”CLI-Anything 是“你把接口声明和实现分开”。声明部分是一份独立于语言的 YAML 或 JSON实现部分可以是任意语言、任意形式。好处是命令的“长相”完全一致团队里任何一个人都能通过同一份描述文件理解工具的边界和用法而不是打开不同语言的源码去猜。谁适合用这个东西我认为最适合三类人。第一类是内部平台工程师整天把各种脚本、服务暴露给同事用最需要统一的 CLI 形态。第二类是自动化测试和 DevOps 同学他们经常要把一堆工具串成流水线但每个工具的参数字段都各写各的CLI-Anything 能强制出统一入口。第三类是个人开发者你有一堆“只有自己能看懂”的脚本想快速给它们做一个像样的命令行外壳。CLI-Anything 不是要取代已有 CLI 框架。相反它更像是一层薄的适配层描述文件描述意图生成器把它翻译成真正可执行的命令。理解这一点后我们再往下看它是怎么设计的。2. CLI-Anything的核心语法一份描述文件如何定义参数树CLI-Anything 的入口是一份描述文件。我选择 YAML 作为默认格式原因很简单团队里写惯了运维脚本的人对 YAML 的接受度远高于 Python 代码。JSON、TOML 也能用解析器会按后缀自动识别。下面是一份典型的 deploy 命令描述。name: deploy version: 1.0.0 description: 将构建产物发布到目标环境 target: python:handler_deploy:deploy args: env: type: choice choices: [dev, staging, prod] default: dev desc: 目标环境 tag: type: string required: true desc: 镜像版本号 options: - flag: --force alias: -f type: bool desc: 跳过一致性校验 - flag: --timeout alias: -t type: int default: 30 desc: 单步超时时间秒 subcommands: rollback: description: 回滚到某次部署 target: python:handler_deploy:rollback args: revision: type: string required: true desc: 要回滚到的版本号这段描述定义了一个名为deploy的命令树。args是位置参数按用户输入顺序排列options是可选标志subcommands是子命令可以继续嵌套。最终用户拿到的使用方式是这样的deploy prod v1.2.3 --force --timeout 60 deploy rollback v2.0.0你可能会问为什么参数要分成args和options两类这是命令行工具的基本约定。位置参数适合描述“这个命令的宾语”比如部署到哪个环境、回滚到哪个版本它们是命令执行的核心信息。选项标志适合描述“修饰状态”比如是否强制、超时多久。CLI-Anything 遵守这个约定用户就不需要从帮助文本里猜测参数顺序。类型系统的映射也很关键。CLI-Anything 内部维护了一张“描述类型 → 校验器”的表描述类型校验规则运行时行为string非空字符串原样传入目标int十进制整数自动做int()转换并检查数值范围float浮点数值自动做float()转换booltrue/false/1/0支持--flag单独出现即视为 truechoice必须在 choices 列表内不合法时直接报错并列出可选值list逗号分隔或重复传参自动聚合成列表这个设计解决了实际中一个很常见的问题很多脚本的校验逻辑散落在业务代码里用户报错了才看到一行“非法输入”。CLI-Anything 把校验前置到参数解析阶段参数不对根本不会进入你的业务函数这让命令的行为可预期很多。再往下看target字段是命令和实现之间的桥梁。python:handler_deploy:deploy表示去handler_deploy模块里找deploy这个函数来执行shell:deploy.sh表示执行某个外部脚本http:POST /api/deploy表示通过 HTTP 转发到后端接口。这种多目标设计是 CLI-Anything 能“Anything”的根本原因它只负责把用户输入翻译成一组标准化的键值对然后分发给任意类型的执行器。如果目标是一个 Python 函数参数绑定有自己的一套逻辑。CLI-Anything 会把所有位置参数和选项的值打包成字典然后按参数名去匹配目标函数的形参名。遇到不认识的形参它不会直接报错而是通过inspect.signature去检查发现缺失或多余时给出一条可读性很强的提示。3. 实现侧拆解加载、校验、解析、调度的完整管线CLI-Anything 的实现不复杂但要做到“可诊断、可审计”内部需要拆成五个明确阶段。第一是加载器负责把 YAML、JSON 或 TOML 读成内部对象第二是验证器负责检查描述文件本身有没有写错第三是模型构建器把字典变成参数节点第四是解析器真正处理命令行传入的字符串第五是调度器把最终结果交给目标执行端。验证器是最容易被省略、但绝不能省的部分。很多人以为描述文件里写的都是声明不会像代码一样出错。实际上常见的坑非常多比如type写成了Type、choices写成了choice、两个子命令重名、target语法拼错。这些错误如果不提前拦截命令能正常显示帮助但一点执行就会莫名其妙崩溃。CLI-Anything 的验证器会在每次执行前把所有节点过一遍遇到不合法字段直接打印带文件名和行号的错误而不是到最后一刻才暴露。参数解析器是这套管线里最需要动脑子的部分。它需要处理子命令嵌套、位置参数无序、选项与位置参数混用等情况。核心逻辑可以用一段很像样的伪代码来描述def run(argv, node): if node.is_ambiguous(argv): return handle_ambiguous(argv, node) parsed, tail node.parse(argv) if tail and node.subcommands: sub_name tail[1] sub_node node.subcommands.get(sub_name) if sub_node: return run(tail[1:], sub_node) return dispatch(node.target, parsed)这里有一个很微妙的地方什么时候一个字符串算子命令名什么时候算位置参数CLI-Anything 的规则是如果当前命令定义了子命令并且第一个位置参数与某个子命令名完全相等就切换到子命令。否则把字符串当作当前位置参数正常解析。这套规则在绝大多数情况下是符合直觉的但后面我也会讲到它带来的坑。调度器的实现取决于目标类型。对于python:目标CLI-Anything 动态导入模块、拿到函数、按参数名注入字典然后调用对于shell:目标它会构造一个带参数列表的subprocess.run()而不是把整条命令拼成字符串再丢给 shell这是为了防止注入问题对于http:目标它会把参数包装成 JSON 或查询字符串发请求后按状态码决定返回值。为了让你对内部管线有一个整体认识我把各阶段的职责做成了表格阶段输入输出失败时的表现加载描述文件路径字典对象文件不存在时给出明确路径提示验证字典对象参数节点模型详细列出所有校验错误不跳过解析命令行 argv 列表参数键值对 尾部 token返回错误码 2 并打印用法调度参数键值对 target 信息目标执行结果包装成统一异常并输出退栈既然把管线拆得这么清楚命令行工具本身也继承了同样的诊断风格。你可以在任意命令最后加一个--diag参数CLI-Anything 会把加载、验证、解析到的参数以及最终命中哪个 target 全部打印出来。这个参数是我实际使用中加得最快的一个功能因为很多同事遇到问题时会复制一长串报错过来我只需要让他们重跑一次--diag基本就能定位问题出在描述文件还是目标实现。这里我特别想强调一个原则CLI 生成器本质上是一个编译器。它把“人类可读的描述语言”编译成“操作系统可执行的命令调用”。既然是编译器就必须对输入做严格检查而不是把任何畸形描述都原样透传下去。这也是 CLI-Anything 和“模板字符串拼接式工具”之间最本质的区别。4. 实测三种接入场景函数、API 与二进制命令的统一封装CLI-Anything 光有语法还不够必须能应对真实的工作负载。我实际接入过三种典型场景这里记录一下每种场景的接入方式和要注意的细节。4.1 Python 函数把业务模块变成可执行命令最常用的一种接入是把自己的 Python 函数暴露成命令行。假设你有一个发布模块handler_deploy.py里面有一个deploy(env, tag, forceFalse, timeout30)函数。你甚至不需要为 CLI-Anything 单独写适配代码只要在 YAML 里把target指向它并且让args和options的字段名与函数形参名一致。CLI-Anything 收到参数后会采用inspect.signature自动检查函数签名。如果发现描述里声明了一个函数根本没定义的参数它会给出提示target function handler_deploy:deploy() got unexpected parameter: timeout check spec file: deploy.yaml, option: --timeout这种检查放在调度前而不是调度后避免函数执行到一半才报TypeError。如果参数类型不匹配比如描述里写int但用户传了abc错误信息也会指向参数本身而不是业务代码。另外一个实测中有用的技巧是让目标函数预留**kwargs桶这样新增可选参数时只需要改描述文件不用改业务函数。前提是你确实能接受未知参数被忽略如果未知参数会导致隐患就不要用这个技巧。4.2 REST API把 HTTP 接口包装成可点击命令第二种场景是把 HTTP 接口暴露成 CLI。CLI-Anything 的 HttpTarget 收到了http:POST /api/deploy这类目标后会把解析出的参数按预设策略组包。默认策略是所有位置参数和选项放进 JSON body布尔值原样保留列表字段自动展开成数组。下面是一个实际例子。deploy prod v1.2.3 --force这个命令对应的请求是POST /api/deploy Content-Type: application/json {env: prod, tag: v1.2.3, force: true}听起来很简单但踩过一次坑之后就学乖了不是所有参数都该放 body。比如分页参数、过滤条件通常应该进 query string而不是 body。CLI-Anything 在 option 描述里支持一个position: query字段声明这个选项要放到 URL 查询参数里。这样就不会出现后端接口对不上字段的问题。HTTP 目标还应该配置success_codes默认接受 200、201、204。其他状态码应该以非零退出码结束并打印响应体的精简错误信息。否则你在流水线里调用时接口返回了 500但命令却以 0 退出流水线会以为部署成功了。这个坑我吃过亏所以特意在 HttpTarget 里加上了退出码和响应体分离的设定。4.3 Shell 脚本与外部二进制给旧工具套上新外壳第三种场景是给现成的 shell 脚本或第三方二进制补一个统一外壳。CLI-Anything 的 ShellTarget 接受声明式的参数列表最终调用时使用subprocess.run的列表形态而不是拼字符串。举例来说target: shell:scripts/deploy.sh args: env: type: string required: true options: - flag: --tag type: string required: true生成后的执行等同于subprocess.run([scripts/deploy.sh, prod, --tag, v1.3.0])使用列表形态而不是scripts/deploy.sh prod --tag v1.3.0这种字符串可以避免因为参数里含有空格、引号或$引发的逃逸事故。尤其是当你的参数是从另一个系统同步过来的用户输入时这一点尤其关键。4.4 Shell 自动补全让命令自己会“接话”接入场景如果少了 Shell 补全体验就不完整。CLI-Anything 给 Bash 和 Zsh 都提供了补全脚本生成器。Bash 里你只需要在.bashrc中写一句话complete -C cli-anything complete deploy deploycomplete -C表示当用户按 Tab 时Bash 把当前命令行内容传给cli-anything complete deploy由它输出下一个 token 的所有可能候选。CLI-Anything 会根据描述文件中的参数类型和子命令列表动态决定该补什么。比如用户已经输入了deploy --它只会补出--force、--timeout用户已经输入了deploy pro它会补成prod。这个能力的实现没有用到什么高深技巧就是让补全程序读取同一个描述文件按前缀匹配输出候选。但实际效果非常好尤其对不熟悉工具的新手来说补全本身就是最好的使用说明。5. 进阶玩法别名、交互问答与参数模板的组合技巧基础能力跑通之后CLI-Anything 的使用体验还能再往上走一层。这里分享三个我实际配置过的进阶能力它们难度不高但对日常效率提升非常明显。5.1 全局与局部别名我给很多长命令配过别名。别名可以定义在描述文件的顶层aliases字段里也可以放在用户的全局配置文件~/.cli-anything/aliases.yaml中。假设团队常用的短命令就是deploy但有人总把它打成dp我们可以这样定义aliases: dp: deploy rb: deploy rollback执行dp prod v1.2.3 --force时CLI-Anything 先把命令开头的别名展开成标准命令名再进行正常解析。注意这里我特意采用了“先展开再解析”的策略而不是解析前就把字符串替换一遍。因为展开之后的 token 流还需要继续做子命令匹配和参数解析先展开再解析能保证和手工输入完全等价。5.2 交互式问答第二个实用的玩法是给参数设置prompt: true。当参数没有在命令行里被提供且当前标准输入是 TTY终端时CLI-Anything 会进入问答模式。比如$ deploy 环境 (dev/staging/prod) [dev]: prod 版本号: v1.4.2 是否强制跳过一致性校验? [y/N]: y问答模式不是单纯的便利它在两个场景下价值很大。第一是避免用户看着空空的命令不知道填什么问句本身就是引导。第二是可以根据前面的回答动态决定后面的问题比如环境选了prod才询问是否强制跳过校验选dev就不问。但这里有一个非常重要的经验交互模式必须能被非交互环境禁用。你在 CI 跑流水线时标准输入并不是 TTY如果命令还傻傻地等用户输入进程会挂住。CLI-Anything 的默认规则是“非 TTY 环境下不触发 prompt直接使用默认值”对于没有默认值的必填参数则直接报错退出而不是卡住。你还可以显式传入--non-interactive来强制关闭问答这在包装第三方工具时尤其有用。5.3 参数模板我用得最多的进阶功能是参数模板。它解决的是“同一组参数反复输入”的痛点。比如部署 prod 时总是要传--force --timeout 120你就可以在模板目录里写一个文件# ~/.cli-anything/templates.yaml prod-stable: args: env: prod tag: v1.4.2 options: --force: true --timeout: 120之后只需要执行deploy run prod-stableCLI-Anything 会把模板里的参数先展开再合并命令行的显式参数。合并优先级从高到低是命令行 模板 描述文件默认值。这个设计让模板不至于遮蔽用户临时指定的参数否则调试时会非常别扭。关于模板我还有一个小建议不要在模板里覆盖所有参数。模板最好只固化那些“你心里有数、不用每次确认”的部分把核心变量留给用户输入。比如把 env 固定为 prod、timeout 固定为 120但 tag 不固化因为每次发布版本号都不同。这样模板既能省事又不会让人忘掉关键参数。6. 我踩过的坑10个最容易让CLI生成器翻车的细节CLI-Anything 做到现在帮我省下不少重复劳动但过程并不是一帆风顺。这些坑来自实际使用如果你也在做类似的“命令生成器”应该能提前避开。第一个坑是参数名与目标函数参数名不一致。描述文件里叫env函数形参却叫environmentCLI-Anything 无法自动猜测这种映射。规避办法是引入一个校验阶段用inspect.signature检查描述里的每个参数能否在目标函数里找到对应项找不到就立即报错。宁可启动命令时多花 10 毫秒做检查也别在用户执行到一半时抛TypeError。第二个坑是布尔标志与“否定语义”的冲突。用户习惯用--no-force表示取消强制但描述文件里只写了--force。CLI-Anything 目前的做法是支持--no-前缀作为反向 flag并在帮助文本中显示“默认关闭”。描述文件里则需要明确写出该选项的默认值避免反向语义混淆。第三个坑是子命令和位置参数的同名歧义。deploy prod v1.2.3看起来很正常但如果你恰好在子命令列表里定义过一个叫prod的子命令CLI-Anything 会优先切换到子命令分支。这要求我在验证器里检测“位置参数名称与子命令名称是否冲突”有冲突就拒绝启动而不是让用户在一堆怪异行为里猜来猜去。第四个坑是 Shell 转义。这个坑主要出现在 ShellTarget。实现早期我图省事把参数拼成字符串再交给 shell 执行结果用户传了一个带空格的 tag 就全乱了。后来全部改成subprocess.run的 list 形态不经过shellTrue参数里的特殊字符都不会被解释。如果你非要经过 shell就必须做强制编码但我不推荐这样做。第五个坑是空字符串和缺省值的语义区别。用户执行的deploy v1.0.0和deploy v1.0.0到底是不是一个意思CLI-Anything 的规则是只要位置参数的数量够了就按顺序填充对应参数哪怕值是空字符串只有完全没有传入时才轮到默认值生效。这个规则写进了文档不然很多人会困惑为什么空字符串没有触发默认值。第六个坑是非 TTY 下的交互卡死。这个问题在上面已经提到过实际发生的概率非常高。凡是你写了 prompt 的地方都要检查sys.stdin.isatty()。更要命的是某些 CLI 容器会伪造 TTY让 isatty 返回 true但真正读输入时却永远等不到数据。这种情况下我会再检查一个环境变量作为兜底一旦设置就强制非交互。第七个坑是 HTTP 目标对错误响应的处理。最早我把“请求成功”当成了“命令成功”返回码 0结果流水线里明显部署失败却没有被拦住。后来改成只有响应码落在success_codes里才算成功否则退出码设为与 HTTP 状态码相关的非零值并把响应体中的 error 字段打印出来。这一步对自动化集成至关重要。第八个坑是 Windows 路径与-前缀参数冲突。同一个命令在 Linux 上是--tag v1.0在 Windows 的 PowerShell 里路径D:\a-b-c被解析器当成了选项。我的处理方式是坚持只用--作为选项前缀并且对“指向路径”或“以-开头的字符串”强制要求放在--分隔符之后。描述文件里可以声明哪些参数允许以-开头避免无谓报错。第九个坑是自动补全的启动延迟。当描述文件很大、子命令很多时每次 Tab 都要重新加载 YAML 并解析整棵命令树延迟会非常明显。我后来为补全生成器加了一层缓存索引第一次加载后把编译结果存到.cli-cache目录补全时只读取索引命令的真正执行仍然走完整管线。这个优化之后补全体验才算是真正合格。第十个坑是描述文件的“目标不存在”问题。写描述文件时把模块路径写错了CLI 本身看起来完全正常帮助文本、参数解析都对一执行就崩。所以我给 CLI-Anything 加了一个doctor子命令它能做一次完整预演校验描述、检查目标模块能否导入、目标函数是否存在、HTTP 接口是否可达、模板文件是否合法。我会把doctor写在每个命令的 README 第一行让使用者先跑一次再决定是否继续。回头看我做 CLI-Anything 的最大收获其实不是参数解析代码本身而是它强迫我把每一个命令的“外形”先想清楚再写实现。你先描述命令叫什么、接收什么、输出什么然后再去填业务逻辑工具边界的模糊地带就少了很多。以后你再让我接一个新的内部工具我第一反应已经不是在文档里翻它怎么调用而是想想我能不能直接写一份 YAML 描述让命令行工具替我扛下所有边角处理。