ARTICLE DETAIL

资讯详情

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

CLI-Anything:用声明式配置统一命令行工具入口

CLI-Anything:用声明式配置统一命令行工具入口 CLI-Anything 是我去年年底开始动手做的一个开源小项目起因其实是自己手底下的脚本太多太乱了。当时我维护着十几个 Python 和 Shell 脚本有做数据清洗的、有调内部 API 的、有查数据库的每个脚本的参数风格都不一样有的用--input有的用-i有的干脆直接读环境变量。新同事入职想跑一个任务得先读一遍 README 才知道怎么下手。那时候我就想能不能做一个工具让我用一份配置就能把这些乱七八糟的入口全部统一成标准、好看、带帮助信息的命令行工具。于是就有了 CLI-Anything一句话概括就是通过声明式配置把任何可执行的东西快速包装成一套标准 CLI。这个项目最核心的价值在于把参数解析、帮助文本、输出格式化这些重复劳动全部收敛到一层通用引擎里你只需要关心业务逻辑。它适合后端开发、运维工程师、数据分析师以及任何需要频繁跟命令行打交道的人。如果你也面临脚本入口混乱、参数规则不统一、命令输出很难看这类问题这篇文章会把我的设计思路、实现细节、踩过的坑都摊开讲清楚。1. 项目定位与整体设计思路1.1 痛点脚本多、入口乱、参数全靠记在动手写 CLI-Anything 之前我先梳理了一下自己日常工作中的实际场景。最典型的场景是数据分析。比如每周要跑渠道数据报表我原来的做法是一堆 Python 文件躺着weekly_report.py --channelapp --formatexcel另一个monthly_report.py --channelapp --formatexcel看起来差不多但参数细节完全不同。weekly_report.py里日期参数叫--datemonthly_report.py里却叫--month传错了就直接抛异常。到了运维那边更常见的是一堆 Shell 脚本散落在各处有的挂在 crontab 里有的手动执行日志格式五花八门退出码也不规范。这些问题的本质不是写代码难而是接口不统一。用户包括未来的自己需要去记忆每一个脚本的私有参数这种心智负担完全没有必要。CLI 工具本该是看一眼帮助信息就会用的东西但我们平时脚本里的 argparse 代码往往写得敷衍帮助文本缺失、参数校验不严格、输出没有高亮结果就是工具链越长越难用。CLI-Anything 就是冲着这个痛点去的。我希望它做到三件事第一把参数定义、校验规则、帮助信息抽成像 JSON 一样的纯数据第二让同一套配置能驱动任意语言写成的底层命令第三输出统一走格式化管线命令执行完是成功还是失败、结果长什么样一眼就能看出来。1.2 目标定位一份配置生成一套专业 CLI项目名字里有个Anything听起来口气很大但我给它划定的实际边界是很清楚的。CLI-Anything 要解决的第一个目标是统一入口。你有一个 Python 函数、一个 Node 脚本、一个 REST API 调用甚至一段 SQL 查询都可以通过一份配置文件把它们暴露成tool command [options]这种统一格式的命令。用户不用关心底层是 Bash 还是 Python只需要知道tool run pipeline --envprod这一种用法。第二个目标是标准体验。每个命令都要有自动生成的--help参数要有默认值、有类型说明、有必填校验命令执行完要有标准输出格式出错要有清晰的错误信息和正确的退出码。这些体验如果是手写 argparse每个脚本要写几十行才有好效果而用 CLI-Anything配置里十几个字段就搞定了。第三个目标是可插拔能力。CLI-Anything 本身不内置所有场景的执行器而是提供一套插件机制。有人需要对接 Kafka有人需要调 Kubernetes API有人需要执行本地脚本这些都可以各自以插件的形式扩展。主项目只要做好配置解析、参数校验、进程调度、输出格式化这层基础底座就够了。1.3 技术选型为什么用 Python 而不是 Go/Rust关于语言选型一开始我确实纠结过。CLI 工具用 Go 写很流行编译出来单个二进制文件启动速度快部署也干净。Rust 更不用说性能强、分发方便。但最终我还是选了 Python原因有两个。第一个原因是插件生态。Python 的 import 机制让它天然适合做插件系统只要约定好入口函数任何人都能用pip install装一个包进来自动扩展 CLI。Go 的插件系统相对麻烦Rust 的动态加载更是复杂对于这种Anything定位的项目Python 的灵活性是最大的优势。第二个原因是目标用户的画像。我想覆盖的用户里有大量数据分析师、测试工程师、SRE他们最熟悉的语言就是 Python。如果他们想给 CLI-Anything 写一个新的执行器插件用 Python 几乎零门槛。反过来如果主项目是 Go很多潜在贡献者可能连编译环境都懒得搭。当然Python 的缺点是启动慢、分发麻烦。我采取的补偿措施是核心代码保持极简只依赖typer和pyyaml两个库提供cli-anything命令行入口装完后直接用打包成 wheel配合 pipx 也能实现类似单一二进制的体验。后面我会专门讲性能优化时再展开。2. 核心细节解析与实操要点2.1 三个核心模块配置解析、命令绑定、输出管线CLI-Anything 内部实际上拆成了三个相对独立的模块这也是我在重构两版之后定下来的结构。第一个模块是配置解析器。它负责读取你的 YAML也支持 JSON配置文件把命令名、参数列表、执行器类型、输出模式等声明转换成内部的命令对象。这一层最关键的是 schema 校验也就是说配置文件本身还套着一层格式规范写错了会直接给出哪个字段不对、期望什么格式的提示。第二个模块是命令绑定器。它拿到命令对象之后会根据配置里的executor字段来决定怎么执行。目前内置了三类执行器python执行器负责内联一个 Python 表达式或函数shell执行器负责调用本地命令http执行器负责去请求一个 REST API。插件机制也挂在这一层你可以注册新的执行器比如docker、k8s、sql等等。第三个模块是输出管线。这个模块我之前做得比较简单就是打印结果字符串。后来觉得不行因为真实场景里用户既需要给人看的表格输出也需要给机器解析的 JSON 输出。所以现在输出管线支持三种格式text、json、table分别适配终端阅读、程序消费和快速概览。执行器返回的数据结构要保持统一输出管线才能灵活格式化。再说配置结构设计。一份最小配置大致长这样tools: greeting: description: Say hello to someone executor: type: python call: main:greet args: - name: name required: true help: Your name这里tools下面每个键代表一个命令description用于生成帮助文本executor声明执行方式args定义参数列表。命令绑定时引擎会把这些args转换成对应参数解析器的定义然后自动生成--name这样的选项。2.2 参数类型推断与校验从配置到可执行参数的转换参数定义这块是最容易出问题的我一开始偷懒只在配置里声明参数名和是否必填其他全靠底层 argparse 去猜。结果就是字符串参数还好整数参数经常有人传入abc然后报一个诡异的类型错误。后来我增加了显式的type字段支持str、int、float、bool、list几种基础类型并加入自动推断作为辅助。自动推断的逻辑是如果配置里没写type但写了default就按default的 Python 类型推断如果没写default就统一按str处理。这个逻辑简单但很实用因为大部分参数声明了默认值之后类型意图就很明显了。校验方面做了一件很有价值的事把 argparse 的报错信息翻译成更友好的提示。比如当你传了一个不在枚举范围内的值默认报错可能是invalid choice: devCLI-Anything 会结合配置里choices字段生成完整提示Invalid value for --env: dev is not one of prod, staging, test这个体验提升非常大尤其对于非技术背景的使用者。还有一个容易忽略的细节是布尔开关参数。CLI 里常见的需求是--verbose这种标志位不需要传值。配置文件里我会这样声明- name: verbose type: bool default: false help: Enable verbose logging内部实现时bool 类型默认生成--verbose/--no-verbose两个变体这样用户既能显式开启也能显式关闭避免了很多脚本里设置了就是 True想关掉只能不写的尴尬。2.3 输出格式化与退出码约定让脚本结果可读、可管很多个人脚本根本不考虑输出格式和退出码但一旦要集成到 CI 或做成团队共享工具这两件事就是刚需。CLI-Anything 里我把输出和退出码做成了两个正交的维度。先说输出。执行器本身只负责返回结构化数据比如一个 dict、一个 list、一段字符串然后由输出管线统一渲染。默认是text模式直接把内容打印出来json模式会做json.dumps(..., ensure_asciiFalse, indent2)输出table模式适合 list of dict自动把 key 当作表头。这里有个实用技巧如果执行器返回的是一个生成器比如懒加载的大日志文件输出管线会流式处理而不是一次性全部加载到内存。这个设计让我在处理几百 MB 日志时完全没压力。退出码约定也很简单分三层执行成功返回 0执行器内部抛出异常返回 2参数解析失败返回 3。为什么留出 1 不用因为很多底层命令自己就用 1 表示逻辑上的失败比如搜索无结果、文件不存在。我保留 1 作为业务层错误码的扩展空间避免和底层语义冲突。这个约定在实际使用中非常顺排查问题的时候先看退出码就能缩小一半范围。2.4 插件机制如何把自己的工具注入 CLI-Anything插件机制是组织好这个项目之后逐步完善的。原理不复杂就是约定一个入口函数然后在配置里用字符串路径引用它。比如我想把公司内部的发版工具接进来只需要写一个小包裹函数# mydeploy.py from cli_anything import register_executor register_executor(deploy) def execute(command, ctx): # command 是解析后的命令对象 # ctx 里包含参数值、环境变量、工作目录等上下文 ...然后在配置文件里executor: type: deploy param: ...插件的核心接口是execute(command, ctx)返回什么结构都行输出管线会统一处理。为了方便写插件我在项目里提供了一个基类BaseExecutor里面预置了参数访问的语法糖。比如命令里定义了一个参数--env在插件里用ctx.args[env]就能拿到。此外插件还可以声明钩子函数。比如before_execute(command, ctx)、after_execute(result, ctx)可以用来做登录态检查、耗时统计、审计日志等等。这个钩子机制让我在接内部系统时省了很多事很多通用逻辑都从插件逻辑里挪到了钩子里。3. 实操过程与核心环节实现3.1 安装与最小配置先把第一个命令跑起来安装本身不复杂pip install cli-anything # 或者用 pipx 安装隔离环境更干净 pipx install cli-anything装好之后命令行入口叫canyCLI-Anything 的缩写。初始化的流程我特意做得快你在任意目录建一个cany.yaml然后运行cany --help它会自动加载当前目录下的配置文件为你展示所有已注册的命令。如果从零开始最简单的体验是这样先建一个配置文件cany.yamlversion: 1 tools: hello: description: Print a greeting message executor: type: shell command: echo hello from cli-anything然后运行cany hello它会执行echo命令输出结果。虽然这只是一个最小示例但整个链路已经通了配置文件加载、命令解析、shell 执行器、输出打印。我更推荐从绑定 Python 函数开始用。比如你有一个模块demo.pydef add(a: int, b: int) - int: return a b在cany.yaml里这样写tools: add: description: Add two numbers executor: type: python call: demo:add args: - name: a type: int required: true - name: b type: int required: true然后执行cany add --a 3 --b 4终端输出7。这个例子已经把配置驱动函数调用的核心流程走通了。Python 执行器的工作方式是把demo:add解析成模块路径和函数名import 进来把参数值按名字映射到函数形参拼成add(a3, b4)然后调用。3.2 进阶配置参数默认值、枚举、帮助信息与输出格式等你理解了最小流程就可以开始加配置项了。我建议按这四步逐步丰富你的配置第一步加默认值。这样同一个参数在不同的需求里可以有不同的默认行为而不是每次都要显式传。比如- name: retries type: int default: 3 help: Number of retries第二步加枚举约束。如果你希望某个参数只能从给定范围里选用choices- name: log_level type: str default: info choices: [debug, info, warning, error]第三步完善帮助信息。所有命令的description和每个参数的help我都会写全。这不仅仅是给用户看的CLI-Anything 生成的--help会严格按这些字段渲染写清楚之后甚至可以当作接口文档用。第四步指定输出格式。在命令里加一个顶层字段output_format或允许用户通过执行时加--format json覆盖。我个人更喜欢让用户能覆盖所以引擎会先把顶层配置解析成默认值然后注册一个隐藏参数--format给用户动态调整。这段配置下来命令的帮助信息已经非常好看了。我实测过最复杂的配置生成出来的cany run-pipeline --help长这样Usage: cany run-pipeline [OPTIONS] Run the ETL pipeline Options: --env [prod|staging|test] Environment to run against [default: test] --start-date DATE Start date of data window [default: 2025-01-01] --end-date DATE End date of data window [default: 2025-01-31] --format [text|json|table] Output format [default: text] --help Show this message and exit.这个界面足以让一个完全没接触过底层脚本的人直接上手。3.3 实战场景把数据库查询包装成可视化命令行绑定本地函数、执行 Shell 命令终究是简单的真正让我觉得 CLI-Anything 有价值的是把一些复杂的交互流程变成一条命令。这里分享一个真实的例子把数据库查询包装成 CLI。之前公司的数据分析师经常拿着 SQL 片段来找运维帮忙跑运维要在数据库客户端里切来切去还要处理结果导出。后来我用 CLI-Anything 注册了一个query命令tools: query: description: Run SQL query against analytics warehouse executor: type: python call: db_plugin:run_query args: - name: sql type: str required: true - name: limit type: int default: 50 - name: format type: str default: table choices: [table, json, csv]对应的db_plugin.py大概长这样import sqlite3 from cli_anything import BaseExecutor class DbExecutor(BaseExecutor): def execute(self, command, ctx): sql ctx.args[sql] limit ctx.args[limit] conn sqlite3.connect(analytics.db) cur conn.execute(sql f LIMIT {limit}) columns [desc[0] for desc in cur.description] rows cur.fetchall() return {columns: columns, rows: rows}只要这个执行器返回的 dict 里有columns和rows内置的表格式输出管线就会自动绘制一个对齐的终端表格。这是输出管线给插件作者提供的隐藏协议不用额外声明返回结构对上就能用。实际跑起来的效果就是一条命令cany query --sql SELECT channel, COUNT(*) FROM orders GROUP BY channel --limit 10输出---------------- | channel | COUNT | ---------------- | app | 4201 | | web | 3988 | | offline | 1024 | ----------------这个场景对团队生产力和协作的改善非常明显。SQL 脚本不再散落在聊天记录和本地文件里而是固化在配置中以命令形式存在谁都能查、谁都能看得懂结果。3.4 部署与分发团队共享 CLI 的实现方式如果只是个人用项目停在配置文件加上本地 Python 脚本完全没问题。但要做成团队共享工具就得考虑分发的问题。我目前最推荐的方式是配合 pipx 私有仓库。第一步把自己的执行器插件做成一个 Python 包比如team-cany-plugins。包结构大概是team-cany-plugins/ team_cany_plugins/ __init__.py db_plugin.py k8s_plugin.py setup.py第二步在setup.py里声明这个包和 CLI-Anything 的依赖关系setup( nameteam-cany-plugins, install_requires[cli-anything], entry_points{ cli_anything.plugins: [ db team_cany_plugins.db_plugin, k8s team_cany_plugins.k8s_plugin, ] }, )第三步团队成员通过pipx install team-cany-plugins安装配置文件cany.yaml放在一个约定的共享目录里也可以做成通过cany fetch-config --url http://...拉取。这套流程跑通以后团队里的 CLI 体验就完全统一了。新成员入职装一个 pipx 包、拉一份配置马上能用全部工具。配置迭代也能热更新不需要重新安装代码。分发时还需要注意插件依赖的版本管理。Python 生态的依赖解析很敏感如果team-cany-plugins里用了某个 pandas 版本而用户环境有冲突pipx 的隔离环境能挡掉大部分这类问题。这个点我在给团队做培训时一定会强调不要用系统 Python 直接装一定要走 pipx 或 venv。4. 常见问题与排查技巧实录4.1 配置文件报错排查从入门到崩溃的常见坑CLI-Anything 用的人多了之后我积累了一些出现频率很高的配置问题这里整理成一个速查表。现象原因处理方式cany命令找不到使用量太小或安装不完整检查 pipx list确认cli-anything已安装命令存在但执行时报错unknown commandcany.yaml不在当前目录使用cany --config /path/to/cany.yaml指定配置参数传了值但函数收不到配置里参数名和函数形参不一致检查args里的name是否和函数签名严格一致中文输出乱码或方框对齐错乱终端编码或中文字符宽度的老问题代码里统一用sys.stdout.reconfigure(encodingutf-8)表格式输出使用wcwidth计算显示宽度第二个坑尤其值得多说一句。我一开始默认只在当前目录找配置文件这导致在子目录下执行命令经常失败。后来改成向上递归查找也就是从当前目录逐级向上找直到根目录。这样你在项目的子文件夹里执行cany deploy也能正常工作体验顺畅很多。4.2 参数传递中的常见陷阱引号、Shell 转义与类型转换用命令行工具最绕不开的问题是 Shell 引号与转义。CLI-Anything 在执行shell类型命令时默认不做额外的 Shell 解析直接交给subprocess.run的参数列表形式这样能避免很多注入类问题。但代价是如果你真的需要管道、通配符这类 Shell 特性需要用shell: true显式开启。开启方式在配置里executor: type: shell command: cat {input} | grep {pattern} shell: true这里我还做了一个小引擎命令字符串里的{input}占位符会用实际的参数值去替换。最初我直接用 f-string 拼接后来发现如果参数值里带引号或空格分分钟把命令搞坏。后来改成先用shlex.quote()对参数值做安全性转义再替换占位符。这个细节救了很多场景尤其是--name这种参数可能被传成带空格的值。类型转换的坑也有一个很经典的配置文件里default: 003这种带前导零的会被 YAML 解析成整数 3最终打印出来是 3而不是 003。解决的办法是显式加上引号把它变成字符串或者用type: str强制覆盖。但这种隐式转换很容易让人困惑我在文档里直接写了规则请永远给非字符串默认值加引号避免 YAML 的自动类型推断惹事。4.3 跨平台与性能问题我在 Windows 和旧电脑上踩过的坑开发过程里我在 macOS 上写代码很顺畅结果一放到 Windows 就出了各种问题这里分享几个有代表性的。第一个是颜色输出。Linux 和 macOS 的终端都支持 ANSI 颜色Windows 的 cmd 和 PowerShell 在老版本里默认不支持。后来我统一在输出管线里做了颜色检测用一个标准的函数判断当前 stdout 是否支持 ANSI不支持就直接退化为纯文本。同时也建议 Windows 用户优先用 Windows Terminal 而不是老版 cmd能省掉大部分终端兼容问题。第二个是路径分隔符。Shell 执行器如果接到一个 Windows 路径直接传给 subprocess 可能会因为反斜杠转义出问题。我在实现里统一把路径参数用Path对象包装输出时再根据平台自动转换成对应格式。第三个是启动性能。Python 写的 CLI 一直被人诟病按一下要等一秒。我实测 CLI-Anything 的最小命令从按下回车到输出结果在普通机器上大概是 350ms 到 500ms其中大部分时间花在 import 依赖和加载配置上。优化手段主要是两个把核心依赖的 import 从模块顶部挪到函数内部以及配置文件的解析结果做一个缓存。实测优化后启动时间能降到 200ms 以内对于一个工具型 CLI 来说完全够用了。4.4 调试技巧加日志、dry-run 和配置 lint真正到了开发插件或者排查复杂问题的时候几个辅助功能非常关键。CLI-Anything 内置了一个--debug全局参数开启后会把整个执行链路的关键信息打印出来包括加载了哪个配置文件、匹配了哪条命令、参数值是什么、执行器返回了什么。我调试插件时基本离不开它。另一个好用的功能是cany preview command它只做配置解析和参数校验但不真正执行。类似于--dry-run对于确认参数映射是否正确非常有用。比如你改了配置文件不确定args名字是否正确运行cany preview add --a 3它会打印出实际会传给函数的参数字典一目了然。第三个是配置文件的 lint 工具cany lint。它会检查配置里的常见错误比如参数名重复、help 字段缺失、choices 类型不一致等。这个工具我是从实际维护中获得的灵感因为配置文件越来越多之后手写检查和逐个命令测试的成本太高了lint 至少能拦截一半的低级错误。调试技巧归纳为一条原则先看--debug输出再确认配置解析最后调试插件本身。按照这个顺序排查大部分问题都能十几分钟内定位到根因。5. 个人用法与项目后续规划写到这儿已经把自己的核心设计和实操经验交代得差不多了最后聊一点私人的使用习惯和后续方向。我在自己的开发环境里CLI-Anything 已经不只是脚本包装器了它成了我所有重复性工作的总入口。本地起服务、跑测试、同步代码库、部署到测试环境全都通过cany暴露成统一的命令。配置文件本身也纳入 Git 管理换新电脑时直接拉下来装个 pipx 包就够了环境迁移成本几乎为零。项目后续的方向我给自己列了两条主线。一条是让命令定义支持交互式参数提示也就是用户不带参数执行时动态向用户询问缺失的必填项。这在内部工具场景中很实用因为很多人记不住参数名。另一条是做一个可视化的配置生成器用网页勾选的方式生成cany.yaml让更不爱看文档的人也能上手。如果你在自己的项目里也遇到了脚本入口混乱、命令行体验差的问题我个人建议是先别急着复制我的工具而是先想清楚你想统一的到底是什么。统一入口本身的价值远大于工具实现哪怕你只是把所有的命令写进一个 Makefile 或者 npm scripts 里也能收获同样的体验提升。CLI-Anything 只是把这件事做成了一套可扩展、跨语言的通用方案希望能给你的工具链带来一点新的灵感。
返回列表