CLI-Anything:用适配器模式统一所有命令行工具入口
发布时间:2026/9/28 23:54:55来源:尧图网络
你有没有过这种经历项目越攒越多调日志要敲一串 findgrep调内部接口要翻 curl 文档跑一个 Python 工具又要记它那套 argparse 参数每个工具的使用方式都长得不一样换个人来用还要重新解释一遍。CLI-Anything 就是冲这个问题来的。它并不是一个具体的业务脚本而是一个“把任何东西变成命令行工具”的适配框架。简单说你只需要写一份很小的声明式配置就能把一个 Shell 脚本、一个 Python 函数、一个内部 HTTP 接口甚至一个 Docker 容器统一暴露成一条标准命令行指令。调用方不需要关心背后是 Python 还是 HTTP统一用clia run 工具名 --参数就行。这篇文章会从设计思路、核心实现、实际案例到排坑技巧完整拆一遍适合正在维护一堆内部工具、想统一入口的开发者参考。1. 为什么我会做 CLI-Anything 这个项目1.1 一个让人头疼的日常工具越来越多入口越来越乱我所在的小组长期维护一批内部工具包括日志分析脚本、用户数据同步任务、订单查询接口、部署辅助脚本。这些工具散落在不同仓库有的用 Bash 写的有的用 Python有的根本没有本地入口只能靠拼 curl 调用公司内网服务。每次有新同学入职光把这些工具的用法讲清楚就要整理一份很长的文档。更麻烦的是不同工具的参数风格完全不一致Bash 脚本习惯用位置参数Python 脚本用--flag valueHTTP 接口的参数又得拼到 query string 或 JSON body 里。自动化流程里想串起两个工具必须专门写胶水代码隔几个月改一次参数就崩一次。我最初的想法很简单能不能做一个统一入口让所有工具都按同一套“语法”被调用这个想法就是 CLI-Anything 的起点。它的定位不是再去造一个像 argparse 或 click 那样的参数解析库而是做一个“工具适配层”把已经存在的各种可执行能力包一层标准的命令行接口。1.2 CLI 作为“通用万能接口”的理由有人可能会问为什么非要统一到命令行统一到 Web 管理后台不好吗我做这个项目时反复权衡过最后依然认为 CLI 是最可靠的通用接口。第一CLI 无处不在。任何开发机上都有终端CI/CD 流水线里可以直接跑命令cron 定时任务也可以直接调度命令。相比一个需要登录的 Web 后台CLI 的可编程性要高得多天然适合被自动化系统调用。第二CLI 的输入输出非常透明。运行一条命令参数、输出、退出码都是标准化的出了问题容易复现也容易在日志里定位。Web 后台的请求链路长、状态复杂对内部小工具来说反而笨重。第三CLI 容易组合。一个命令的输出可以接给另一个命令处理比如把同步任务的输出通过管道交给统计脚本这种能力是 Web 后台很难替代的。CLI-Anything 真正要做到的是给这些不同的工具一个公共的语言外壳。调用方不再关心工具是用什么技术实现的只需要知道“工具名 参数”剩下的事情由适配器去翻译。1.3 这个项目要解决的核心问题CLI-Anything 要解决的核心问题可以拆成三块。第一统一入口。所有被纳入管理的工具都通过同一个二进制clia调用采用clia run 工具名的方式进入通过clia list查看所有可用工具通过clia info 工具名查看某个工具的详细说明。第二统一参数翻译。不同来源的参数风格差异很大CLI-Anything 在中间层做了一层映射用户在命令行上写--file xxx.log --level ERROR适配器负责把它转成 Bash 脚本的位置参数、Python 函数的关键字参数或者 HTTP 请求的 JSON body 字段。第三统一输出格式。所有命令执行后默认输出一段结构化的 JSON包含执行状态、耗时、返回结果或原始输出。后续如果有机器人、监控系统需要对接直接解析 JSON 就行不需要理解每个工具各自的输出。2. 整体设计与核心思路拆解2.1 适配器模式给不同工具一双“同码数的鞋”CLI-Anything 最核心的设计思路是适配器模式。你可以把它想象成电源转换插头世界各地的插座标准不一样但只要有对应的转换头任何插头都能接入任何插座。适配器就是那个转换头它负责把 CLI 标准调用方式转换成目标工具实际能理解的方式。整个项目分成两层上层是统一的 CLI 解析与分派入口下层是可插拔的适配器。每一类工具对应一个适配器模块模块内部实现五件事加载配置、解析参数、构造调用命令、执行、格式化输出。这样做的好处很明显新增一种工具类型时不需要改动核心入口代码只要新增一个适配器在注册表里登记一下就能用。这就像一个系统不断接入新设备驱动才是关键主机本身不需要频繁改造。2.2 注册中心与统一入口为了让clia知道有哪些工具可用CLI-Anything 维护了一个轻量级的注册中心。注册中心实际上就是一个目录里面放着每个工具的配置文件我习惯用 YAML 格式因为可读性最好。每个配置文件定义三件事工具名、适配器类型、以及该适配器需要的参数描述。启动clia run时CLI-Anything 会根据工具名找到对应配置文件加载适配器然后把用户输入的命令行参数交给适配器处理。注册表可以放到用户目录也可以放到项目目录。如果是团队共享我建议放到一个独立的 git 仓库里统一管理谁要添加新工具就提 MR。这样整个团队的“命令行工具清单”就有了版本记录换电脑迁移时直接把注册表仓库拉下来就行。2.3 为什么不用现成的参数解析框架说实话Python 的 click、typerGo 的 cobra都是非常成熟的命令行框架我之前也用过不少。但 CLI-Anything 跟它们不是替代关系而是互补关系。参数解析框架的思路是“你写代码框架帮你解析参数”CLI-Anything 的思路是“你只写配置适配器帮你对接现成的东西”。前者很适合开发一个新命令行工具的开发者后者适合想统一存量工具的团队。因为存量工具可能根本不是你自己写的你不可能去改它的代码也没办法要求它引入某个参数解析框架但你可以通过适配器把它包装起来。另外命令行框架通常强依赖某种语言。CLI-Anything 天然是多语言的一个工具是 Python另一个是 Bash还有一个是 Node.js只要适配器写好了都能统一暴露出来。这一点在内部工具混杂的团队里价值非常大。3. 核心实现把“任意东西”变成命令行3.1 适配器接口设计我看过很多框架最后给适配器定义了一个很精简的接口。每个适配器本质上只需要实现四个方法class Adapter: def load(self, spec: dict): 加载配置缓存参数定义、执行目标等信息 raise NotImplementedError def build_command(self, args: dict) - str: 把解析后的参数变成真正的执行命令 raise NotImplementedError def execute(self, args: dict) - dict: 执行并返回结构化结果 raise NotImplementedError def format_output(self, result: dict, fmt: str) - str: 按 JSON 或 TABLE 格式输出结果 raise NotImplementedErrorbuild_command只在 Shell 类适配器里真正用到Python 和 HTTP 适配器其实不需要拼接命令字符串它们有各自的执行方式。但为了让上层逻辑统一我还是保留了这个方法不适用时直接返回空字符串。实际项目中我并没有要求每个适配器都必须面向对象反而很多适配器是用 Python 模块里的普通函数实现的。接口是一种“约定”不是必须的脚手架。3.2 Shell 适配器最快落地的类型Shell 适配器是最常用、也最容易实现的类型。它的逻辑很简单把用户在命令行输入的参数按配置文件里定义的顺序和标志拼成一段 Shell 命令然后交给subprocess执行。拿一个日志分析工具举例原始使用方式是./scripts/logstat.sh app.log ERROR 100通过 CLI-Anything我们希望用户这样使用clia run logstat --file app.log --level ERROR --limit 100那么配置文件可以写成command: logstat adapter: shell target: executable: ./scripts/logstat.sh args: - name: file positional: 0 required: true help: 日志文件路径 - name: level positional: 1 default: INFO help: 日志级别 - name: limit flag: --limit default: 50 type: int help: 最多输出条数Shell 适配器读到这个配置后执行拼接逻辑时会遵循一条规则有positional定义的参数放在命令尾部有flag定义的参数放--limit 100这样的标志。拼接完成后用subprocess.run(command, shellTrue, capture_outputTrue)执行。这里要注意使用shellTrue有注入风险。好在 CLI-Anything 是内部工具目标用户都是自己的同事。但如果你要开放给更多人使用建议至少对参数做一次白名单校验尤其要禁止参数值里出现;、、反引号等特殊字符。3.3 Python 函数适配器把 def 直接变成子命令第二类常用适配器是 Python 函数适配器。团队里经常有人用 Python 写数据处理工具但入口函数写得比较随意有的是main()有的是sync_users()。CLI-Anything 可以把这个函数直接映射成一个命令。配置文件写法类似command: user-sync adapter: python target: module: ops.sync function: run args: - name: tenant required: true help: 租户 ID - name: dry flag: --dry type: bool default: false help: 只打印执行计划不真正同步执行时适配器先通过importlib动态导入ops.sync模块拿到run函数再把参数作为关键字参数传进去import importlib module importlib.import_module(self.target[module]) func getattr(module, self.target[function]) result func(**kwargs)这里最重要的设计是被调用的 Python 函数不需要依赖 CLI-Anything 的任何代码它就是普普通通的业务函数。这样团队的同事完全不需要理解 CLI-Anything 的实现细节只需要告诉我函数叫什么、参数有哪些我就能注册成命令。对写工具的人来说侵入性为零这是这个方案能推广开的关键原因。3.4 HTTP API 适配器把 URL 变成参数第三类是 HTTP API 适配器。内部系统之间经常通过 HTTP 接口交互但这些接口一般没有本地命令行封装调试时得临时拼 curl 命令麻烦得很。CLI-Anything 的 HTTP 适配器可以把一个接口包装成命令command: order-query adapter: http target: method: GET url: https://api.internal.example.com/orders headers: Authorization: Bearer ${env.TOKEN} args: - name: order_id required: true help: 订单号 http_field: query这个配置文件说明用户在命令行输入clia run order-query --order-id ORD123时适配器会发起一个 GET 请求把 order_id 放到 query string 上请求头里的 Token 从环境变量TOKEN读取。通过http_field字段我们可以指定参数放到请求的query、path、header还是body里。这样一来即使是参数格式复杂的 POST 接口也能被包装成干净的 CLI 命令比如args: - name: item_id required: true http_field: body json_path: item.idjson_path表示参数要嵌到 JSON body 的嵌套位置里面。这个特性让 CLI-Anything 能处理请求体比较复杂的场景而不是只支持扁平参数。4. 实操演示从零到一包装两个真实场景4.1 包装一个日志分析脚本下面用一个完整案例走一遍操作流程。假设我手上有一个日志分析脚本路径是scripts/error_stat.sh作用是从日志里统计各错误码的出现次数。原始用法sh scripts/error_stat.sh /var/log/app/error.log ERROR 20 stat.txt想通过 CLI-Anything 包装后的用法clia run error-stat --file /var/log/app/error.log --level ERROR --top 20第一步创建一个注册文件~/.clia/registry/error-stat.yaml内容如下command: error-stat description: 统计日志中的错误码分布 adapter: shell target: executable: sh script: scripts/error_stat.sh args: - name: file positional: 0 required: true help: 日志文件路径 - name: level positional: 1 default: ERROR help: 错误级别关键词 - name: top flag: --top type: int default: 20 help: 返回前 N 个错误码 env: - LANGen_US.UTF-8第二步在项目目录下初始化clia registry add ~/.clia/registry/error-stat.yaml clia listclia list的输出会列出所有已注册命令可以看到 error-stat 已经出现在列表里。第三步运行命令clia run error-stat --file /var/log/app/error.log --level WARN --top 10Shell 适配器拼接出的实际命令是sh scripts/error_stat.sh /var/log/app/error.log WARN --top 10等等这里有个问题--top是 CLI-Anything 的参数但脚本并不认识它。于是我在 Shell 适配器里加了一条特殊处理逻辑如果一个参数被标记为flag它在命令里的位置不是跟在脚本后面而是作为键值对放在命令最后实际拼接成sh scripts/error_stat.sh /var/log/app/error.log WARN --top 10因为脚本内部本来就支持--top这种标志所以这样拼是可行的。但如果你要包装一个不支持 flag 的旧脚本那就应该把参数全部定义为positional比如top就定义成positional: 2用户参数会按顺序填进去。这个取舍说明了一件事CLI-Anything 不会强行改变目标工具的调用风格它只负责把用户友好的参数形式翻译成目标工具能接受的形式。执行完成后适配器会返回结构化结果默认是这个样子{ command: error-stat, status: ok, duration_ms: 235, data: { total_lines: 1024, top_errors: [ {code: 500, count: 32}, {code: 404, count: 18} ] } }这里其实做了个小魔术脚本输出的是文本统计适配器在包装时加了一个--json参数让脚本内部输出 JSON如果脚本不支持适配器就用正则把文本转成 JSON。能够在注册时配置“输出解析器”对旧脚本特别友好。4.2 包装一个内部服务接口第二个案例是包装一个内部查询接口。这个接口用途是查询订单详情原始调用方式curl -X POST https://api.internal.example.com/order/query \ -H Authorization: Bearer xxxxx \ -H Content-Type: application/json \ -d {order_id: ORD2024001, fields: [status, amount]}使用 CLI-Anything 以后命令变为clia run order-query --order-id ORD2024001 --fields status --fields amount注册文件command: order-query description: 查询订单详情 adapter: http target: method: POST url: https://api.internal.example.com/order/query headers: Content-Type: application/json Authorization: Bearer ${env.ORDER_API_TOKEN} args: - name: order_id required: true http_field: body json_path: order_id - name: fields flag: --fields type: string_list repeated: true http_field: body json_path: fields这里有三个细节值得展开第一fields被定义为可重复参数repeated: true用户每传一次--fields适配器就往数组里追加一个值这与 HTTP 接口期望的 JSON 数组是对应的。第二json_path字段的作用是把参数放到 body 的指定位置。比如fields放在顶层fields键中将来如果接口改了结构只需要调整配置不需要改代码。第三Authorization请求头里的Bearer ${env.ORDER_API_TOKEN}是环境变量引用语法。适配器在执行时会读取当前环境变量ORDER_API_TOKEN的值替换进去这样 Token 不会硬编码在配置文件里避免把密钥提交到 git。执行成功后HTTP 适配器会把响应的 JSON body 原样放进data字段同时记录 HTTP 状态码。如果服务返回 4xx 或 5xx适配器会返回status: error并附带http_status_code和响应内容方便调用方排查。5. 参数规范、输出格式与配置细节5.1 参数描述规范类型、默认值、必填、别名CLI-Anything 既然想当“通用翻译层”参数描述规范就得足够精确。我把参数常用属性归纳成了这几项属性说明示例name参数名命令行用--name传入order_idflag自定义 flag 名称默认是--name的格式--idshort短标志-ipositional第几个位置参数0表示第一个required是否必填truetype参数类型string,int,bool,enum,string_listdefault默认值20repeated是否可重复传入truehttp_field参数要放到 HTTP 请求的哪个位置body,query,headerjson_path在 JSON 结构里的路径item.idhelp帮助说明显示在--help中这里的type是我重点打磨的。很多工具的常见 bug 就是把数字当字符串传或者把 bool 值理解错。CLI-Anything 的参数解析器会做严格的类型转换比如type: int会把100转成数字 100转换失败时直接报错不会把脏参数带进目标工具。enum 类型也很实用例如- name: env type: enum choices: [dev, staging, prod] default: dev如果用户传入一个不在 choices 列表里的值CLI-Anything 会打印支持的值列表并返回错误。这个能力有效避免了因为手滑输错环境名导致的误操作。5.2 输出统一为 JSON 与表格CLI-Anything 默认输出 JSON因为 JSON 方便程序解析。但对人类用户来说有些命令直接看 JSON 并不直观所以我加了一个--output参数支持两个值json和table。table格式适合那些返回列表类数据的命令。例如查询错误码分布输出表格code | count | percentage 500 | 32 | 31.25% 404 | 18 | 17.58%这个功能是在format_output方法中实现的。JSON 是原始数据层表格是人可读层。为了兼容更多场景我还预留了jsonpath过滤器你可以指定--output.fieldcode只取某个字段后续要接监控告警时很方便。5.3 配置文件与命令行补全命令行补全是 CLI-Anything 的隐藏宝藏功能。大部分内部工具没有补全用户得靠记忆力敲参数。CLI-Anything 在注册阶段会扫描所有配置生成一个补全脚本。在 Bash 里启用方式clia completion bash ~/.bash_completion.d/clia启用后用户在终端输入clia run error-stat --file Tab时可以自动补全文件路径输入--level Tab时如果配置里定义了这个参数的类型是 enum还可以自动列出可选值。这个体验一旦有了团队同事基本就回不去手动敲命令的日子了。配置文件的加载顺序也值得说一下。CLI-Anything 按这样查找注册表命令行传入的--registry参数然后是当前目录的.clia-registry/目录最后是用户目录的~/.clia/registry/。这个顺序可以让项目级配置覆盖团队级配置团队级配置覆盖个人配置做到分层管理。6. 常见问题与排查技巧实录6.1 配置文件格式错误YAML 格式说简单也简单说坑也很多。最常见的错误是缩进不一致、字段名拼错、或者忘了给布尔值加引号。我在适配器加载时加入了严格的 schema 校验只要配置里出现未知字段就报configuration error。刚开始团队同事觉得这个校验太严格但后来发现它能提前拦住 90% 的低级错误。如果你手头有多个配置文件可以用一条命令批量校验clia validate --registry ~/.clia/registry/校验通过后才允许注册避免带病上线。6.2 参数类型转换失败另一个高频问题发生在参数类型转换上。比如用户把--top传成了abc适配器会报invalid type: expected int, got abc。这个问题本身好排查但两年前我遇到过一次比较隐蔽的情况Shell 适配器拼接命令时参数转成字符串后没有重新加引号导致包含空格的参数被 shell 拆分成了多个单词。解决办法是在拼接命令时对所有参数值做 shell 转义然后用空格重新拼接。如果你希望参数在传递时保留原始空格可以给参数加一个quote: true的属性。这算是一个小坑但一旦踩到排查起来相当费时间。6.3 依赖缺失与环境隔离Python 函数适配器踩得最多的问题不是代码逻辑而是环境问题。团队里不同项目的依赖经常冲突今天装了这个包明天另一个脚本就跑不起来了。我的做法是给 Python 适配器加一个python_env配置项支持指定虚拟环境路径。例如target: module: ops.sync function: run python_env: /opt/venvs/ops/bin/python执行时适配器会直接使用该虚拟环境中的 Python 解释器避免依赖互相污染。如果你平时用 conda 或 uv也可以把环境路径指到对应位置。6.4 调试利器--debug 与 --dry-runCLI-Anything 内置了两个对排查特别有用的全局参数--debug和--dry-run。--dry-run不会真正执行目标工具只打印适配器拼接出的命令、参数映射结果、请求体等内容。这个功能用来检查“参数是否翻译正确”非常高效。比如怀疑某条命令被拼错先 dry run 看一眼clia run error-stat --file app.log --level ERROR --top 10 --dry-run输出会显示# shell command sh scripts/error_stat.sh app.log ERROR --top 10确认无误后去掉--dry-run再真正执行。--debug会打印更细的执行日志包括配置加载路径、每次解析的原始参数、适配器内部流程耗时等。配合--debug使用基本能解决绝大多数“为什么这个命令没按预期工作”的问题。我个人在实际项目里的体会是CLI-Anything 的价值不在于把某个工具做得更强大而是让团队里所有工具的“入口体验”对齐了。大家不用再记五花八门的命令不管目标工具是什么入口永远是clia run 工具名 --参数这对减少上下文切换成本的帮助非常明显。如果后续有需要你还可以参考同样的思路把定时任务、消息队列消费者也注册进去让整个团队的命令行出入口统一到同一个地方。
网站建设高端定制企业官网