资讯详情

CLI-Anything:用声明式配置自动生成命令行工具的工程实践

📅 2026/9/28 16:25:22 | 华诺云谱 👁 阅读
CLI-Anything:用声明式配置自动生成命令行工具的工程实践
CLI-Anything这个名字是我给自己折腾了大半年的一个工具项目起的。起因特别朴素团队里每个人手里都有一堆“只有自己能看懂”的脚本查数据库的、调接口的、跑批处理的、甚至还有定时发群消息的。脚本一多问题就来了——换个人就不会用参数格式全靠猜输出结果五花八门有的打印表格有的吐JSON有的直接往日志文件里怼。我当时就在想能不能反过来把“写脚本”这件事标准化你只需要声明这个任务是什么、收什么参数、去哪里执行、结果怎么输出剩下的由一套统一框架去生成真正的命令行入口。这就是CLI-Anything的雏形一个将任意任务描述文件转换为可用CLI命令的通用工具。它适合谁用适合那些手里攒了一堆脚本、想统一命令入口但又不想重写所有逻辑的人也适合刚接触CLI设计、想搞清楚“命令和参数是怎么被抽象出来”的开发者。这篇文章我会把整个项目的设计思路、核心实现、踩坑过程都摊开讲。1. 从“一堆脚本”到“统一入口”CLI-Anything想解决的问题1.1 每个工具都有自己的脾气先说个真实场景。我们组之前维护着上百个内部脚本有Python写的有Shell写的还有几个是某个同事用Node临时搓出来的。每个脚本的调用方式都完全不同# 查用户信息 python query_user.py --uid 12345 # 查订单参数名却叫 id python query_order.py -i 67890 # 跑数据修复要改配置文件 bash fix_data.sh --env prod --batch 20240601麻烦的不只是记不住参数名。有些脚本读环境变量有些脚本依赖一个“默认在当前目录找config.ini”的隐式约定还有些脚本根本不校验参数——传错了就报一个“KeyError: uid”你完全不知道是参数名错了还是后端接口挂了。每次有人拿着同事的脚本问“这个怎么跑”对方都要现场回忆三分钟。这个问题本质上是接口设计缺失脚本的边界是乱的输入不统一输出不统一错误处理不统一。而CLI工具天然适合承担“统一入口”的角色——它把输入抽象成命令和参数把输出抽象成标准输出和退出码把错误抽象到stderr。只不过大多数人写CLI还是顺着脚本的思路写没有把这层抽象提炼出来。1.2 核心抽象任务描述文件CLI-Anything的核心想法是把“一个任务”描述成一份独立的声明式文件。不管你是要调HTTP接口、查数据库、跑一个本地脚本还是给远端发一条消息这个文件的结构都一致元信息任务名、描述、分组。参数定义每个参数的名字、类型、是否必填、默认值、提示语。执行方式类型http / db / shell / plugin以及对应的连接配置和请求模板。输出约定字段映射、格式json / table / raw、是否隐藏敏感字段。换句话说CLI-Anything不再要求你写一遍命令解析逻辑而是要求你回答四个问题这个命令叫什么它接收什么它去做什么它产出什么这四个问题一旦有了标准答案CLI入口、参数校验、输出格式化这些重复劳动就可以全部自动化。拿“查用户信息”举例以前是一个Python脚本现在是一段YAMLname: query-user description: 查询用户基本信息 type: http method: GET url: https://api.example.internal/users/{id} params: - name: id type: integer required: true help: 用户ID - name: verbose type: boolean default: false help: 是否显示完整字段 output: format: table fields: [id, display_name, email, status]这段配置就是全部“代码”。CLI-Anything读取之后会自动生成一个名为query-user的子命令支持--id和--verbose两个参数自动校验id必须是整数自动调用HTTP接口自动把返回JSON按字段列表输出成表格。1.3 一个能复用的执行模型这套设计背后其实是一个非常简单的执行模型整个运行过程可以用一句话概括命令行入口 → 参数绑定 → 执行器 → 输出格式化。所有任务都跑在这条流水线上差异只体现在“执行器”这一段。HTTP任务走HTTP执行器数据库任务走数据库执行器本地脚本任务走Shell执行器。因为流水线是固定的所以框架可以做大量通用的事情参数校验、缺省值填充、类型转换。执行超时控制、重试、并发限制。统一的退出码0成功1参数错误2执行失败3超时。基于终端类型自动切换输出格式管道输出JSON人看输出表格。这也是我后来选型时反复权衡的依据与其做一个“更聪明的脚本框架”不如做一个“更会下蛋的CLI生成器”。CLI-Anything不需要理解业务逻辑它只需要把业务逻辑藏在配置之后把交互、校验、输出这些烦心事都接管过来。2. 系统边界与核心技术选型2.1 薄入口厚配置第一个技术决策就是“薄入口”。CLI-Anything的入口层面只做三件事加载配置、解析命令、分发给执行器。它不包含任何具体业务代码——连“怎么发HTTP请求”都不写在入口里而是放在执行器模块中。这样做的原因是如果入口代码里堆满了业务分支那它最终还是会变成原来的一百个脚本只不过换了个壳子。薄入口意味着所有任务都是数据而数据可以被管理、被校验、被版本化。你甚至可以给每个任务配上owner和SLA这就不是一般脚本框架能做到的了。实现上入口就是一组标准库调用主流程写成纯函数不依赖任何全局状态# cli_anything/main.py import click from click import Command, Group def main(config_path: str): registry load_registry(config_path) group Group() for task_name, task_config in registry.tasks.items(): group.add_command(build_command(task_name, task_config)) group()2.2 配置格式选型YAML vs JSON配置格式我纠结过很久。JSON的好处是机器可读、解析快、schema工具成熟缺点是手写体验太差注释没法定写错一个逗号整份文件挂掉。YAML的好处是上手成本极低人人都能写嵌套结构看起来更清晰还能用---把多个任务放在一个文件里。但YAML也有坑缩进错误是运行时才发现类型推断规则偶尔反直觉比如version: 1.0会被解析成字符串id: 001还会被当成十进制整数丢掉前导零。最终我选了YAML但配套做了严格的schema校验。任务配置必须经过校验才能注册不能拿“能跑就行”将就。校验用JSON Schema做定义好任务描述文件的完整结构包括参数类型枚举、必填字段、类型约束等。{ type: object, required: [name, type, params], properties: { name: { type: string, pattern: ^[a-z0-9-]$ }, type: { enum: [http, db, shell, plugin] }, params: { type: array, items: { type: object, required: [name, type], properties: { name: { type: string }, type: { enum: [string, integer, float, boolean, list, file] } } } } } }这个schema看起来不起眼但它在项目后期救了我很多次。随着任务配置从十几个涨到上百个手写错误大幅增加——参数名拼错、类型写错、缩进错位。没有校验这些错误要到命令执行时才会炸出来有了校验加载阶段就直接报“任务query-user第3个参数type字段不合法期望值是integer/float/boolean之一”。2.3 执行器与连接器的分离执行器是CLI-Anything里最需要设计清楚的部分。我一开始想得很简单每种任务类型写一个执行器类比如HttpExecutor、DbExecutor、ShellExecutor。但写着写着发现不对HTTP执行器内部还要区分调用内部API和外部API数据库执行器又要区分MySQL、PostgreSQL、ClickHouse。这时候我意识到需要把“执行逻辑”和“连接方式”拆开。执行器只负责一件事拿到参数调用底层能力返回结构化结果。连接器负责具体的协议对接。比如HTTP执行器的核心逻辑是url模板填充、请求头组装、超时与重试、状态码判断。具体怎么发请求交给连接器比如httpx.AsyncClient包装成HTTP连接器。这样新增一个任务类型时大部分情况下你只需要写一个新的连接器执行器能复用。# cli_anything/connectors/http_connector.py class HttpConnector: def __init__(self, base_url: str, timeout: float 5.0): self.base_url base_url.rstrip(/) self.timeout timeout def request(self, method: str, path: str, params: dict, headers: dict | None None): import httpx with httpx.Client(timeoutself.timeout) as client: resp client.request(method, f{self.base_url}/{path.lstrip(/)}, paramsparams, headersheaders) resp.raise_for_status() return resp.json()2.4 为什么最终选了插件化有段时间我琢磨既然CLI-Anything目标是“Anything”那我不可能把所有执行器都内置吧HTTP、DB、Shell、RPC、消息队列、文件操作……每一种都要写、要维护永远追不上需求。于是我把执行器设计成了插件机制。插件是一个Python包暴露一个register接口向框架注册自己支持的连接器类型和参数schema# cli_anything_redis/plugin.py from cli_anything.plugin import register_executor def register(config): register_executor(redis, RedisExecutor, schemaREDIS_CONFIG_SCHEMA)框架启动时扫描配置里声明的插件目录逐个加载。核心只保留HTTP和Shell执行器其他按需安装。这个设计让项目从“我一个人维护的工具”变成了“团队里谁需要谁就扩展”的平台。后来同事加了一个飞书消息执行器总共不到一百行代码连框架代码都没动。插件化的代价是调试变难了一点尤其是插件里的异常容易把主流程的堆栈搞乱。我的处理方式是给插件调用包一层统一的异常边界把插件抛出的任意异常转成PluginExecutionError附带插件名和自定义错误消息这样用户看到的错误不是一坨堆栈而是“插件redis执行出错连接超时”。3. 动手实现一个可用的最小版本3.1 配置加载与校验最小版本的配置加载就三步读YAML、校验schema、构建任务注册表。# cli_anything/loader.py import yaml from pathlib import Path def load_registry(config_path: str): raw yaml.safe_load(Path(config_path).read_text(encodingutf-8)) tasks raw.get(tasks, []) if not isinstance(tasks, list): raise ConfigError(配置文件的tasks必须是列表) registry TaskRegistry() for task in tasks: validate_task(task) registry.register(task[name], task) return registry这里有个容易忽略的细节yaml.safe_load不要换成yaml.load。虽然你自己的配置文件可以信任但一旦配置目录要被团队成员共享保不准有人往里面塞了一个!!python/object标签直接执行任意代码。safe_load能挡掉这类风险。校验我用了jsonschema库上面的schema例子就是实际用的。校验失败的错误信息会把出错路径打印出来定位起来非常快比YAML自己报的“line 12 column 3”好用十倍。3.2 动态生成CLI命令CLI-Anything用的是Python的click库来构建命令行。click支持命令组Group和命令Command关键是它还允许你动态添加参数。每个任务配置里的参数定义都会被转换成对应的click option。import click def build_command(task_name: str, task_config: dict) - click.Command: params [] for p in task_config.get(params, []): option_name f--{p[name]} kwargs { type: map_click_type(p.get(type, string)), required: p.get(required, False), default: p.get(default), help: p.get(help, ), } params.append(click.Option([option_name], **kwargs)) click.pass_context def callback(ctx, **kwargs): executor create_executor(task_config) result executor.execute(kwargs, ctxctx) print_formatted(result, task_config.get(output, {})) cmd click.Command(nametask_name, paramsparams, callbackcallback) cmd.short_help task_config.get(description, ) return cmd这段代码有几个关键点click.Option手动构造解决了动态参数问题。你不需要在函数签名里写死形参而是通过params列表传给click.Command。click.pass_context让回调能拿到全局上下文比如-v全局verbose开关、--config覆盖路径等这些信息放在ctx.meta里传递。类型映射map_click_type需要你自己写把配置里的integer映射到click.INT把boolean映射到click.BOOLlist映射到click.Tuple等。提示click里布尔类型的默认行为是“出现即True”所以别给布尔参数配默认值True然后还想用--no-verbose关闭click不会自动帮你生成反向选项需要手动再注册一个--no-{name}。3.3 执行器实现与错误处理执行器的统一接口是execute(params, ctx) - Any。它接收已经校验过的参数返回一个Python对象这个对象交给输出层去格式化。执行器内部不关心用户看到什么只管把结果拿回来。class ExecutorBase: def execute(self, params: dict, ctx): raise NotImplementedErrorHTTP执行器的逻辑要处理URL模板填充、请求参数、响应包装class HttpExecutor(ExecutorBase): def __init__(self, config): self.connector create_connector(config.get(connector, {})) self.method config.get(method, GET).upper() self.url_template config.get(url) self.header_template config.get(headers, {}) def execute(self, params, ctx): url self.url_template.format(**params) headers {k: v.format(**params) for k, v in self.header_template.items()} if self.method GET: resp self.connector.request(GET, url, paramsparams, headersheaders) else: body {k: v for k, v in params.items() if k in config[body_fields]} resp self.connector.request(self.method, url, jsonbody, headersheaders) return resp错误处理是这里容易忽视的点。一个HTTP调用可能失败在DNS解析、连接超时、读超时、4xx、5xx每种情况的用户提示都应该不同。裸抛异常肯定不行用户看到的会是一行“ConnectError: [Errno -2] Name or service not known”根本不知道是自己参数传错了还是网络问题。我用了一个错误分类器ParamValidationError参数校验失败退出码1。ConnectTimeoutError、ReadTimeoutError网络超时退出码3提示“连接目标服务超时请确认网络或稍后重试”。HTTPStatusError目标接口返回4xx/5xx退出码2带上状态码和响应body前200个字符。其他未预期异常退出码4提示“未预期的异常请附带上方堆栈信息反馈给维护者”。这样一个用户看到退出码马上就知道怎么处理1是自检参数2是找接口owner3是网络问题4才是找框架问题。3.4 输出格式化输出格式是CLI工具易用性的胜负手。CLI-Anything最开始只支持JSON输出结果被同事吐槽“我看命令结果还得拿jq再处理一下”。后来实现了三种输出模式json完整数据适合管道和自动化脚本调用。table人看自动截取配置声明的字段宽度自适应终端。raw原样输出适合文本类接口或shell脚本直接消费。自动探测模式是我最喜欢的如果标准输出是管道不是终端默认json如果是终端默认table。这背后用到了一个技巧sys.stdout.isatty()。def detect_output_mode(terminal_defaulttable, pipe_defaultjson): try: if sys.stdout.isatty(): return terminal_default return pipe_default except Exception: return pipe_default表格输出自己没有造轮子直接用了rich库的Table它能把字段对齐这种事情做到“开了挂”的水平而且终端宽度自适应。关键是自定义fields映射配置一个output.fields列表表头就是字段名值从JSON结果里按路径提取。支持user.name这样的点号路径写法用一个小函数递归取嵌套字段。3.5 一个能跑的完整示例把上面几个部分拼起来就能跑通一个最简单的HTTP任务tasks: - name: query-user description: 查询用户 type: http connector: base_url: https://api.example.internal timeout: 5 method: GET url: /users/{id} params: - name: id type: integer required: true output: format: table fields: [id, display_name, email]然后启动python -m cli_anything --config ./tasks.yaml query-user --id 888成功输出一张表格失败输出一条带退出码的友好错误信息。从配置到可运行前后代码量不超过600行。这个最小版本已经能覆盖掉团队里近一半的“查一下XX信息”类需求。4. 落地过程中的坑与排查链路4.1 参数类型推断看似简单坑最深CLI-Anything的第一个大坑出在最不起眼的参数类型转换上。一开始我把参数类型转换全权交给click的type参数结果遇到两个问题。第一个问题是布尔参数。配置里写verbose是boolean默认false用户命令行敲--verboseclick传进回调的还是False——因为click对布尔参数的处理是“存在即True”不存在就用默认值。这和我预期行为一致但有个反常识的情况如果用户想显式传False他得写--verbosefalse而click默认的BOOL类型根本不认这种写法。我得自定义一个FlexibleBoolType才能同时支持--verbose、--verbosetrue、--verbose false三种写法。第二个问题是整数参数被YAML悄悄转换。配置里写timeout: 5YAML解析出来的是整数5没问题。但要是写timeout: 0.5解析出来是浮点0.5click的INT类型直接拒绝。可这个参数如果定义成float用户传--timeout 5又会被转成5.0下游模板拼接时变成5.0和预期不符。后来我统一走“参数定义里显式声明type加载配置时不信任YAML推断的类型”所有参数值一律以schema里声明的type为准做强制转换。排查这段问题时最有用的工具是给参数绑定过程加了一个--debug-params隐藏参数打印出最终传给执行器的类型、值和来源一眼就能看出是YAML解析问题还是click转换问题。4.2 输出格式的兼容性问题第二个坑比较憋屈——输出格式早期只有JSON被团队里偏爱看表格的人反复喷。后来加了table又引来了新问题table模式下如果字段在结果里不存在rich表会直接空着看着像数据丢了。排查链路是这样的有个任务的配置里写了fields: [id, name, address]结果返回数据里没有address字段。用户跑完说“地址没了”以为数据丢在传输过程中。我一看address根本没从接口返回是上游接口把字段改名成了mailing_address。我给输出层加了两个能力字段解析失败提示当配置的字段路径无法在结果中找到时输出一行警告到stderr但表格照常渲染数据不全时用户能立刻知道“不是工具丢了是上游没给”。字段别名配置里可以写fields: [{name: address, alias: mailing_address}]将接口的mailing_address映射为表格里的address列。所以现在的字段配置支持三种写法纯字符串、点号路径、带alias的对象。这看起来只是个小功能但避免了很多“工具把数据弄丢了”的误解。4.3 超时与并发控制从单线程脚本到并发执行的蜕变CLI-Anything一开始是同步阻塞模型每次命令只做一个任务。但很快有人提出我要一次查一百个用户谁能让我在命令行里发个循环这个需求本质上是并发控制。我考虑了两种方案一种是CLI层直接提供一个--parallel N参数利用asyncio.gather并发执行同一任务多次另一种是任务配置里支持bulk模式一个命令内部循环处理多个参数组。最终两种都做了因为场景不同--parallel适合“任意参数组合各执行一遍”bulk模式适合“一次命令内部循环遍历”。并发带来的坑是超时必须可控。同步模型里一个请求卡住最多卡住当前命令行并发模型里一百个请求卡住可能占满连接池。解决方案是给执行器强制加默认超时HTTP连接器默认5秒Shell执行器默认30秒数据库连接器默认10秒同时限制最大并发数默认10。让--parallel 100真正并发100个连接是会打爆连接池的所以我加了中间件用信号量把实际并发压到配置的上限剩余的任务排队。另一个容易踩的坑是键盘中断。用户在并发执行时按CtrlCasyncio的task会收到CancelledError但如果执行器内部的HTTP调用不响应取消整个进程会挂住。处理方式是捕获KeyboardInterrupt后强制取消所有task再给连接器挂一个asyncio.timeout兜底确保取消逻辑一定能触发。4.4 兼容旧脚本从wrap到替换最后一个大坑是历史包袱。CLI-Anything做得再好团队里还有几百个现存脚本不可能全体一次性迁移。硬推新工具的结果一般是新工具吃灰老脚本继续跑。我给CLI-Anything加了一个wrap模式专门用来过渡。做法是在配置文件里声明一个shell任务把原来的命令原封不动地包进来tasks: - name: legacy-fix-data description: 旧版数据修复脚本兼容模式 type: shell command: bash /opt/legacy/fix_data.sh env: ENV_MODE: production params: - name: batch type: string required: true output: format: rawwrap模式的执行器只是把参数拼进command模板其他什么都不做。这样旧脚本的用户能用一个统一入口去调用命令名、参数风格都逐渐规范起来但底层还是老逻辑风险可控。等某个脚本的迁移条件成熟了再把type从shell改成http或db入口命令不变用户无感。这个兼容层的价值不是技术上的而是组织上的。没有它CLI-Anything就很难在团队里落地因为“迁移有风险”会立在任何技术优势面前。先把入口统一了让用户可以忍受再逐步替换核心逻辑是更实际的上线路径。5. 从个人工具到团队基础设施5.1 把内部API封装成CLICLI-Anything用得最多的地方是把内部API封装成命令行工具。以前前端同事想查一个订单状态得打开Swagger UI找到对应接口填一堆headers还要自己分析返回的嵌套JSON。现在只需要一行cli-anything order-status --order-id SO-20240601-001配置上也只是声明了一个HTTP任务指向内部订单服务的GET接口。后端接口改字段了前端同事根本不用感知——改动都发生在配置文件的output.fields映射里。这类封装的价值在于降低了API的使用门槛。接口文档再完善也要求使用者理解HTTP语义CLI把交互简化到了“记一个命令名和几个参数”即使是不熟悉curl的人也能立刻上手。5.2 定时任务与告警联动CLI工具和cron是天生的搭档。CLI-Anything的命令都是标准CLI可以被crontab直接调用退出码和stderr信息就是天然的监控信号。我们后来在配置里加了一个schedule字段配合一个守护进程自动把任务注册到系统的cron或K8s CronJob上tasks: - name: daily-sync-report type: shell command: python /opt/reports/daily.py schedule: cron: 0 9 * * * notify_on_failure: - type: http url: https://alert.example.internal/api/v1/events这样每天早上9点自动跑日报生成失败时自动触发告警成功时静默。因为所有输出都走了统一的格式化层日志文件格式也一致从命令行手动执行到定时任务切换用户不需要额外写脚本逻辑。5.3 配置仓库化与Code Review当任务配置超过几百个时CLI-Anything实际上已经变成了“配置即代码”的一部分。我们把所有的YAML任务配置放进了Git仓库每次新增或修改走MR评审CI里跑一遍schema校验和样例命令冒烟测试。这里有一个很实际的收益因为任务是声明式的评审者在MR里能直观地看到参数定义和执行方式比评审一段命令式shell脚本容易得多。一个参数类型写错了、一个连接器配置超时太长在代码评审阶段就能抓出来不用等线上出故障。我还写了几个静态检查规则比如所有HTTP任务必须声明timeout不允许默认无限等待。所有数据库任务必须显式声明只读或写写操作需要额外审批字段。所有任务必须有description不允许生成一行“无描述”的命令。这些规则一开始纯靠口头约定执行后来发现完全没约束力——人总是先写能跑的功能再补文档。所以我把规则做进了CI校验里不满足就直接拦截。从那以后新任务的质量稳定了很多。5.4 演进方向CLI-Anything做到这个程度几个明显的演进方向已经浮出来了。第一个是远程执行把本地CLI包装成远程服务通过配置下发和命令代理让团队在本地敲的命令可以安全地在跳板机或容器里执行。第二个是自动补全增强针对动态生成的CLI生成shell补全脚本zsh/bash/fish都全覆盖。还有一个方向是我个人特别看好的基于CLI工具做自动化测试。因为每个任务都有标准化的参数定义和退出码编写自动化测试的成本大大降低——你不需要mock一个CLI进程直接调用任务配置和模拟执行器就行。最后说说我自己的体会。CLI-Anything不是那种“写出来就惊艳所有人”的项目它笨重、简单、甚至有点“笨”——核心思想就是一份配置、一套流水线。但它解决了真实世界里最纠缠的问题把散落各处的信息收拢到一处把重复的交互抽象成一致协议。如果你手里也有上百个脚本和一堆“只有看得懂的人会用”的内部工具我建议你别急着写下一个工具先试试把任务描述清楚让CLI框架替你处理剩下的事情。这个方向大概率不会让你失望。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑