ARTICLE DETAIL

资讯详情

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

CLI-Anything:用蓝图把任意脚本封装成标准命令行工具

CLI-Anything:用蓝图把任意脚本封装成标准命令行工具 去年年底我在整理自己电脑上积攒多年的散装脚本时又被同一个问题烦到失眠有的脚本用 argparse 写参数有的直接用 sys.argv 裸解析有的干脆把输入写在文件顶部每次要改都得开编辑器。更要命的是这些脚本的调用方式完全不一样时间一长我自己都记不住哪个命令该带什么参数。当时我脑子里冒出的念头很朴素能不能有一个工具让我只写一份描述文件命令行入口、参数校验、帮助文档、自动补全全部自动生成这个念头后来就长成了 CLI-Anything——一个把任意脚本、API 甚至内部服务统一封装成标准命令行工具的开源项目。如果你也在维护一堆脚本或者经常要给团队里的非技术同事提供可用的命令行入口这篇文章大概能帮你少走不少弯路。1. 为什么我会写一个把一切变成命令行的工具先交代一下背景。我有几个长期维护的内部工具一个批量图片压缩脚本一个读取数据库报表的 Python 模块还有一个调用内部 API 做数据同步的守护进程。这三个东西的使用方式完全不同脚本要改文件填参数模块要用python -m package.module --config xxx的方式调用API 则要用 curl 拼 URL。团队里的运营同学每次想跑个报表都得在群里喊我帮忙敲命令。我知道问题在哪里不是工具不好用而是工具的入口太散没有一个统一、安全、可自服务的命令行界面。CLI-Anything 最早的设计目标就三条第一用一个轻量的描述文件定义命令第二所有命令最终都以ca your-command --param value这种方式运行第三底层内容可以是 Python 函数、Shell 脚本、HTTP 请求甚至是一段 Docker 容器命令。说白了它就是给散装工具套上一层统一的门面让使用者不需要关心背后是什么技术栈。这个思路和我之前用过的很多任务管理器不太一样。常见的做法是写个 Makefile或者用 npm script / Taskfile 这类工具去定义任务。它们当然也能统一入口但问题是参数校验、帮助信息、数据校验基本都是缺失的。你运行make build的时候谁也没法保证传进去的--envprod是不是合法值。CLI-Anything 把重点放在了命令行体验本身参数怎么解析、类型怎么校验、帮助怎么展示、报错怎么更有人味。它不是跟 Makefile 抢饭碗而是补上它们一直没认真做的事情。那为什么不直接用 Click / Typer / argparse 写个标准 CLI这也就是 CLI-Anything 存在的第二个理由当我面对的是二十个不同技术栈的脚本时为每个脚本单独写一套 CLI 封装工作量太大了而且每多一个入口就多一份要维护的代码。描述驱动的方式把命令的定义和命令的实现拆开——定义部分是声明式的配置实现部分还留在原项目里。这样我只需要维护一份蓝图文件就能得到一套标准化的命令行入口。2. 核心设计拆解蓝图、运行时和 handler 这三块怎么拼在一起2.1 第一步用什么格式描述命令这件事CLI-Anything 的核心是一个叫命令蓝图Blueprint的概念。你每定义一个命令就要附上一份描述这个命令行为的文件。我最初选的格式是 TOML原因很简单它支持注释支持多行字符串嵌套结构也够用而且对非程序员来说比 YAML 缩进报错友好得多。一个最简单的蓝图长这样[command] name greet description 跟指定的人打个招呼 [command.handler] type python target my_app.handlers:greet [[command.arguments]] name name type string required true description 打招呼的对象名字 [[command.arguments]] name times type int default 1 description 重复输出次数 [command.options] loud { type bool, default false, description 是否用大写输出 }这份文件的信息量其实很大handler告诉运行时最终该执行什么arguments定义了位置参数options定义了--loud这种可选参数。我在设计的时候特意把参数定义和处理逻辑彻底分离因为绝大多数脚本无法直接用最大的原因就是参数定义藏在代码里使用前必须翻源码。TOML 的注释能力也派上了大用场。我习惯在每段配置下面用注释写清楚这个参数是怎么来的、有没有什么坑这些注释将来都会原样出现在--help里。等于说一份文件同时承担了接口定义、用户文档、实现映射三个职能这也是我愿意把入口统一到 CLI-Anything 的原因。2.2 运行时是怎么把描述变成真正的命令行蓝图只是一堆静态描述真正把它变成命令行体验的是运行时。CLI-Anything 的运行时本质上是一个两层结构最外层是参数解析器最内层是执行器。参数解析器负责把用户在终端敲的一长串字符串解析成结构化数据。这个过程包括处理缩写参数--re对应--retry-times、类型转换把3转成int、必填校验、默认值填充。我这里直接复用了 Python 生态里非常成熟的argparse但做了一层封装——根据蓝图里的type字段自动生成解析逻辑。你不需要在蓝图里去描述dest、metavar这些底层概念运行时会把name转成dest把description转成help。执行器负责的是另一件事拿到解析好的参数找到 handler把参数传给目标拿到返回值翻译成退出码。这个过程也被抽象成了几个阶段# 伪代码运行时主流程 def run(blueprint, argv): parser build_parser(blueprint) args parser.parse_args(argv) handler resolve_handler(blueprint.handler) result execute(handler, args) return render(result)其中resolve_handler做的事情可以类比成路径查找它拿到python:my_app.handlers:greet这种字符串加载模块找到函数返回一个可调用对象。执行阶段则负责把参数对象转成函数签名比如函数是greet(name: str, times: int 1, loud: bool False)运行时就把name、times、loud这三个解析好的值按名字传进去。2.3 选择映射方式的逻辑为什么允许三种 handler 类型我最初只打算支持 Python handler但后来发现一个尴尬的情况很多运行得很好的工具是用 Shell 或者 Go 写的如果只支持 Python又得强迫别人做技术栈迁移这就违背了收编的初衷。所以最后我做了三种 handler 类型python、shell、http。python直接加载模块函数适合你手头就有可导入的 Python 代码库。shell原样拼装命令字符串并交给系统 Shell 执行适合不打算改造的老旧脚本。http向指定的 URL 发请求适合请求体干脆就是 JSON、正式接口已经定义好的服务。三种 handler 的存在是有代价的后面我会专门聊跨平台执行器差异的问题。但从设计角度这种多一点灵活性的决定让 CLI-Anything 能在真实环境里活下来。因为真实世界里的工具从来不是同构的你要做的不是让所有工具都变成一个语言而是把它们的入口收敛到同一个界面。3. 30分钟把第一个脚本变成有模有样的命令行工具3.1 安装与初始化开发和测试环境是 macOS Python 3.10不过 CLI-Anything 本身兼容 3.9 以上版本。安装很简单pip install cli-anything你可以在任意目录里直接初始化一个命令集ca init my-tools这会在当前目录下生成一个.ca/文件夹里面默认放一个cli.toml和一个commands/目录。cli.toml是全局配置commands/下放各个命令的蓝图文件。我习惯按业务模块建子目录比如commands/greeting.toml、commands/report.toml目录多的时候一目了然。这里有个小建议最好把.ca/目录纳入版本控制它是命令行的说明书团队其他人拉下来之后直接可以跑不需要额外交代任何东西。3.2 从零定义一个可执行的命令为了看得见摸得着我拿一个常见的批量重命名文件场景来演示。假设你有一个脚本rename_files.py它接收目录路径和前缀两个参数把所有匹配文件的前缀替换掉。以前你想用这个脚本得先记住脚本路径、参数顺序还得小心别传错格式。现在用 CLI-Anything 把它变成命令[command] name rename-files description 批量重命名指定目录下的文件 [command.handler] type shell command python /opt/scripts/rename_files.py {directory} --prefix {prefix} [[command.arguments]] name directory type path required true description 要处理的目录 [[command.options]] prefix { type string, default new_, description 新文件名前缀 }等一下这里有个很重要的问题shellhandler 下直接把参数拼进命令字符串如果是--prefix里带了;rm -rf /这种内容会不会出事这里必须做转义。CLI-Anything 在处理 shell handler 时会用shlex.quote()对每个注入参数的字符串值做一层安全转义防止命令拼接注入。这是我在所有 handler 里最在意的一条安全底线。定义完之后跑一下ca rename-files /tmp/uploads --prefix holiday_终端会输出/Bins size reduction ... 处理完成你会看到 shell 里该有的--help也有ca rename-files --help输出会自动生成一段说明包含每个参数的描述和默认值。这看起来很普通但对于团队里第一次接触该脚本的人来说--help就是最直接的使用文档。3.3 路径参数的隐藏细节上面的例子里我把directory的类型写成了path这背后有一个专门的校验器。它不只是检查参数是不是字符串还会做三件事展开~、把相对路径转成绝对路径、检测目录是否存在并给出友好报错。你传一个不存在的目录时不会看到 Python 抛出的FileNotFoundError堆栈而是一句错误目录 /tmp/not_exists 不存在请检查后重试我当初做这个校验器是因为团队同事在 Windows 上跑 Linux 路径脚本后一次次报错。与其让大家去猜不如在入口处就拦住。这一层防御性设计对待所有非技术的使用者都很有价值。3.4 自动补全与调试开关CLI-Anything 另外两个让我坚持用下去的功能是自动补全和--debug开关。在初始化之后运行ca completion install会生成一份 Shell 补全脚本比如 zsh 或 bash 的补全函数。效果就是你在终端敲ca retab时能自动补出rename-files再敲一个空格后按tab还能列出directory和--prefix。补全的逻辑是根据蓝图文件名和参数的 name 字段生成的不需要额外维护一份清单。--debug开关是内置的运行任何命令时加一个--debug终端会打印出内部解析后的参数结构、实际执行的 handler 字符串以及退出码。这个开关在我排查为什么本地没问题、在服务器上就报错这种问题时帮了大忙因为大部分问题都出在参数没有按预期传进去。4. 进阶实战把 API 调用、数据库查询和定时任务统一收编4.1 场景一内部系统 API 的查询入口我维护了一个内部订单查询 API以前运营同学想查订单都是从浏览器里开 Swagger 页面手工填参数特别费劲。用 CLI-Anything 的httphandler定义这样一个命令[command] name order-get description 通过订单号查询订单状态 [command.handler] type http method GET url https://api.internal.example.com/orders/{order_id} headers { Authorization Bearer ${API_TOKEN} } [[command.arguments]] name order_id type string required true description 完整的订单编号这里我利用了一个环境变量模板${API_TOKEN}让 token 不出现在蓝图文件里避免把敏感信息提交到版本库。运行时会在执行请求前从环境变量读取并替换。这个模式特别适合内部工具的命令入口不暴露密钥需求。执行效果是用户在终端敲ca order-get SO123456运行时拼出完整 URL 并带认证头发请求最后把 JSON 响应格式化打印出来。如果响应里带了业务错误码运行时还能把它转换成对应退出码。这对脚本化的场景极其有用——我可以直接在 shell 脚本里判断命令执行成功与否。4.2 场景二日常 SQL 查询变成带参数校验的命令团队的数据分析师经常要查某个时间段内的新增用户数过去要在数据库客户端里手写 SQL很容易出现少写一个引号、日期格式不对的问题。我给他们做了一个daily-active-users命令蓝图如下[command] name daily-active-users description 统计指定日期区间的活跃用户数 [command.handler] type python target internal_queries.daily_active_users:query [[command.arguments]] name start_date type date required true description 开始日期格式 YYYY-MM-DD [[command.arguments]] name end_date type date required true description 结束日期格式 YYYY-MM-DDdate类型是我专门加上的解析器之一。它在内部会把字符串转成datetime.date如果格式不对会直接提示正确的预期格式。这样就把一个 SQL 查询命令变成了一个类型安全的日期接口使用者不需要关心数据库连接细节只需要知道命令接受两个日期参数。实际执行结果会以表格形式输出整条命令的退出码也和数据条数挂钩。4.3 场景三批处理任务的执行和状态追踪最后一个高频场景是批处理任务。我有一些每天凌晨跑的数据清洗任务以前靠 cron 加日志文件来跟踪状态排错时要翻日志找半天。现在把任务包装成命令后跑任务变成了ca run-export --source-db prod --target-db warehouse --date 2025-03-01CLI-Anything 在执行批处理任务时如果返回码不为 0会自动在 stderr 里打印错误摘要并把退出码原样透传给外层调用者。这样在 cron 里配置时系统就能用退出码判断是否发送告警。出错时加上--debug能看到完整 traceback再也不用去翻几十 MB 的日志文件了。这三个场景的共性在于它们本质上都是往一个操作里传参数、执行、看结果。CLI-Anything 做的事情不是消灭脚本而是把脚本的接口标准化让每一个入口都能被记忆、被补全、被校验、被自动化。5. 三个差点让我放弃的坑5.1 参数类型推导不是所有默认值都适合当类型模板第一个坑出现在做类型推导的时候。早期的版本里为了省事我允许用户不写type直接从default里猜类型默认值是1就推断为int是abc就推断为string。这看起来很方便实际一用就出事有人写了default false我把它推断成布尔类型但有人想要一个默认值是false的字符串结果也被转成了布尔型的False整个命令行为直接变了。后来我做了个决定type字段必须有显式声明不允许默认值推断类型。虽然写蓝图的时候会多敲几个字但能避免大量类型混淆的问题。如果你希望参数同时支持1和1这种宽松输入那就在 handler 里做容错不要指望着在 CLI 层把类型揉成一团浆糊。这个经验是经过实际痛苦换来的类型系统的价值就在严格一宽松错误就会被推迟到执行阶段排查成本翻倍。5.2 错误透传CLI 的报错和后台真实报错之间的翻译问题第二个大坑是错误透传。我最初设计的时候把底层异常的堆栈直接抛给用户想着反正你能看到完整 traceback。结果第一次给团队同事试用时对方看到一屏红字直接懵了。说到底普通使用者需要的不是堆栈而是发生了什么、该怎么办。我后来对错误处理做了分级错误层级输出位置展示形式参数校验错误如目录不存在、日期格式错误stderr简洁中文提示 退出码 2handler 内部业务错误如 API 返回 4xxstderr错误摘要 建议命令如加--debughandler 内部异常stderr完整 traceback且提示可加--debug运行时自身错误如蓝图找不到stderr定位到具体 blueprint 文件名和行号退出码的设计也沿用了很多系统工具的习惯0 表示成功1 表示 handler 返回了失败2 表示参数解析错误。这样做的好处是使用方不需要解析终端文本只看退出码就能判断命令是否成功。5.3 跨平台路径与执行器差异第三个坑是跨平台。我自己主要在 macOS 上开发但有不少同事用 Windows。最开始写shellhandler 时我直接拼接命令Windows 上跑rm -rf自然就崩了。后来我放弃了完全兼容所有 shell的想法改成判断系统后在 Windows 上优先使用cmd /C包装命令并推荐用户用 PowerShell 风格的命令。同时在路径处理上统一用pathlib.Path保证传给 handler 的是绝对路径不依赖当前工作目录。这里有个诚实的建议如果你想做一个团队级内部工具最好只锁定一到两个目标系统比如 Linux macOS把 Windows 作为支持但需要额外测试的次级平台。不要试图一开始就在三平台上做完全等效否则维护成本会把你拖垮。6. 用了一段时间以后什么活该交给它什么活别硬塞6.1 它做得最好的地方CLI-Anything 用了三个月后的最大感受就是新命令的接入成本低到可以忽略。我现在接到一个新工具需求会在脑子里自动拆成两步先写一个 handler 函数再写一份 blueprint。整个过程通常不到十分钟剩下的参数校验、帮助文档、补全脚本都是自动生成的。团队里已经有不少非技术同学开始通过它跑查询任务他们不需要理解 Python 模块和 API 鉴权只需要看ca --help和按 Tab 补全就够了。它对日常自动化的价值也是无意中发现的因为所有命令都收口到了统一入口我写 cron 任务时再也不用满世界找脚本路径只要记住ca task-xxx --date today这个固定格式配合环境变量注入令牌就能在 CI 里稳定运行。6.2 不要无脑硬塞的边界但也有些情况我不建议用它。比如一个交互性非常强的工具用户需要在运行过程中持续输入内容并观察实时反馈那就不适合用预定义参数的方式去描述。再比如前端构建工具那种参数极多、互相依赖的场景如果全塞进 blueprint描述文件的复杂度会失控。还有一个很容易被忽略的点如果某个脚本本身极其轻量只有一个参数直接python script.py 5就够了那完全没必要为它做一个命令封装。工具的价值在于统一入口和降低认知负担而不是制造另一层概念负担。6.3 还可以往哪些方向继续玩从我个人的使用经验出发我觉得这个项目最值得扩展的方向有三个第一是自动生成 shell 补全之外的命令速查表。蓝图里其实已经有全部信息可以生成一篇 Markdown 文档帮助团队内部 wiki 自动同步。第二是插件化的校验器。我现在内置了int、string、path、date、bool等基础类型但真实业务里经常要校验 email、手机号、IP 段。如果未来允许开发者注册自定义类型解析器那么 CLI-Anything 的适用范围会大很多。第三是命令执行历史可视化。因为所有命令都走同一个运行时所以可以在运行时里埋点把每次执行的指令、参数、耗时、退出码都记录下来。这对运维排查问题来说价值很大。写到这我想起自己最开始那个失眠的晚上本意只是想把桌面上一堆乱七八糟的脚本收拾干净最后却花了大半年做了一个开源项目。回头看CLI-Anything 最核心的经验可以浓缩成一句话所有让人记不住入口的工具本质上都是没认真设计入口。如果你也正被几十个散装命令困扰不妨也试试用描述文件把它们统一收编起来——先把一个最常用的脚本变成命令跑通了再逐步扩大范围这个起步方式比追求一步到位稳妥得多。
返回列表