ARTICLE DETAIL

资讯详情

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

CLI-Anything:用YAML声明式配置打造统一、可复用的命令行任务工具

CLI-Anything:用YAML声明式配置打造统一、可复用的命令行任务工具 命令行爱好者一定都干过这种傻事在笔记本上攒了二三十个脚本有同步配置的、批量改文件名的、调内网接口的、定期备份的。每个脚本都挺好用可一旦换台电脑、或者要在新环境重新部署这些脚本散落在各个目录参数规则各写各的有的还得先改配置再执行。我做了个小工具叫 CLI-Anything思路很简单把任意你能想到的任务统一包装成一条清晰、稳定、可复用的 CLI 命令。这篇文章我不写官腔直接把我设计这个工具的思路、踩过的坑、以及实际使用中的完整配置都摊开讲希望能帮到同样在整理个人工作流的你。这个工具适合谁适合每天要跟终端打交道、手里一堆重复性操作需要沉淀的人。不管你是把外部 API 包成命令行还是想把原来要打开网页才能完成的查询变成一行命令哪怕是批量处理文件、定时执行脚本它在这些场景下都能派上用场。我尽量讲得具体一点相关配置直接给出来你跟着抄作业就行。1. 项目是怎么来的CLI-Anything 到底解决什么问题1.1 终端工作流的真实痛点大部分人的命令行工具都是一次性脚本。今天写一段 curl 调接口明天写一段 python 处理 Excel后天可能又写了一段 bash 批量拉代码。脚本本身没毛病问题是长期积累之后你会发现几个挥之不去的麻烦入口不统一。每个脚本的参数、日志格式、退出码都是随心所欲的用久了根本记不清某个参数是干嘛的。复用成本高。想在另一台机器上跑通一套脚本往往得重新装依赖、重新配环境变量、甚至改代码里的绝对路径。组合能力差。脚本 A 的输出没法方便地喂给脚本 B想串成一条流水线得写一堆胶水代码。隐蔽的高门槛。很多操作其实是面对非技术用户的比如让同事跑一个数据同步任务如果只丢给他一个几十行的 python 文件这门槛就太高了。CLI-Anything 最早的雏形就是为解决最后这个问题而写的。有个版本上线之前我需要让运营同学每天手动去更新一批接口配置但我不能在所有人的电脑上搭一套 Python 环境。于是我把这个动作做成了一个命令叫ca run sync-config运营双击终端输入这一条命令按提示选几个参数剩下的交给程序跑。后来我发现这个思路可以抽象到更多场景干脆把它做成了开源项目。1.2 同类工具的空白与切入点市面上其实有一些配置驱动的命令行工具比如 Makefile、npm scripts、Taskfile还有各类 CI 工具。它们都很优秀但总觉得缺少点什么。Makefile 本质是 recipe 的集合写法比较偏 Unix 传统Taskfile 更年轻一点可是它对跨平台参数和交互式输入的支持还需要自己补npm scripts 只能服务 Node 生态离“任意任务”有点远。我想要的东西是这样的第一任务描述是声明式的也就是用配置文件告诉工具“做什么”而不是写一堆过程式代码第二内置通用执行器比如 shell、http、file、venv 这些高频操作直接开箱即用第三插件机制要轻用户可以自由添加自己的执行器第四命令入口高度统一所有任务都是ca run task-name没有第二个特殊的入口语法。CLI-Anything 的定位不是替代 Makefile 或 Taskfile而是在这些工具和个人脚本之间补一层“任务胶水层”。1.3 核心设计目标在设计初始我给自己定了四条硬性标准后来证明这几条标准确实让项目少走了很多弯路单一入口。不管任务多复杂用户只需要记得ca run这一个子命令。配置可读。一个任务长什么样、依赖哪些参数、失败时怎么办打开 YAML 文件就能看明白。执行可观测。每一步的耗时、输出、退出码都要记录出问题能一眼定位。跨机器可迁移。项目目录里不写死绝对路径环境差异通过变量覆盖来解决。这四条标准奠定了整个项目的骨架后面的所有设计都是围绕它们展开的。比如为了做到“配置可读”我放弃了让用户在 YAML 里写 Python 表达式的做法因为表达式一多文件就变成了另一种编程语言可读性反而下降。改成通过参数和过滤器来完成数据变换虽然灵活度小一点但 90% 的日常任务其实用不上那么强大的表达式。2. 整体设计与核心思路拆解2.1 三层架构任务、执行器、CLI 壳层CLI-Anything 从结构上可以拆成三个层次。最上层是 CLI 壳层负责解析参数、加载配置、生成帮助文档中间层是任务层也就是描述“做什么”的 YAML 文件最下层是执行器层每个执行器只负责一类动作shell 执行器负责跑命令http 执行器负责发请求file 执行器负责文件操作python 执行器负责执行代码片段。这个分层解决了一个很关键的维护性问题新增一种能力的时候用户不需要改动 CLI 壳层只需要新增一个执行器类。而且每个执行器可以单独测试、单独复用。比如 http 执行器不只是用来发一次请求它还能用于健康检查、Webhook 通知只要在任务配置里指定using: http就行。这里有个很容易犯的设计错误为了让配置文件短一些把所有执行器的选项都揉进一个 schema 里。结果就是一个任务里可能出现大量根本用不到的字段源码里还得维护一坨 if else。我采用的方案是每个执行器有自己独立的 schema配置的时候用with子节点包裹该执行器的专属参数这样互相之间不会污染。2.2 为什么用 YAML 声明任务而不是直接写 Python有人会质疑既然底层是 Python那直接在 Python 里定义任务对象不是更灵活这个质疑有道理但实际操作中我体验下来YAML 声明式有它不可替代的优势。拿一个发送 HTTP 请求的任务举例。用 Python 写代码大概是这样的import requests data {name: foo, age: 18} resp requests.post(https://api.example.com/users, jsondata) resp.raise_for_status() print(resp.json())这段代码很清晰没问题。但一旦你打算把它变成“可以由非技术同事安全运行”的命令就得处理参数校验、环境变量、日志格式、超时控制、错误提示……代码量会翻好几倍。而用 YAML 声明任务核心内容被压缩为- id: create-user name: 创建用户 using: http with: url: https://api.example.com/users method: POST json: name: {args.name} age: {args.age} timeout: 10 expect: 201这个 YAML 文件几乎任何人都能看懂而且它天然就是配置不是代码。这意味着你可以把任务定义交给团队成员评审也可以直接从外部系统动态生成这些配置。对多数场景来说灵活性的损失换来了可读性和可审查性这笔账是划算的。2.3 插件化执行器的取舍插件系统的设计我前后推翻过三次。最开始是用setuptools entry_points每一次添加执行器都要重新安装包很麻烦后来改成扫描指定目录下的.py文件实时加载这个方案体验好了不少但也有隐忧——任何人都能改写执行器逻辑风险不可控。最终采用的方案是分两类激活官方执行器随主包安装内置在项目源码里用户自定义执行器放在~/.cli-anything/plugins/目录通过约定好的目录结构被自动扫描。每个执行器只需要实现一个功能class HttpExecutor: def run(self, task_ctx, params): # task_ctx 保存日志、变量、状态 # params 是 YAML 里 with 节点解析后的字典 pass这个接口故意设计得非常简单甚至没有把 step 对象直接塞给执行器只传入上下文和参数。原因是很多第三方执行器只需要处理自己的领域逻辑如果给它整个 step 对象反而容易写出依赖执行顺序的脏代码后续维护就是噩梦。2.4 一个任务的完整配置结构CLI-Anything 中一个任务文件是一个 YAML 列表列表里的每个元素就是一个 step。下面是最常见的一个多步骤任务name: release-pipeline description: 发布打包并推送 vars: version: 1.2.0 steps: - id: build name: 构建 using: shell with: run: | python -m build ls -la dist/ cwd: {env.PROJECT_ROOT} - id: healthcheck name: 健康检查 using: http with: url: http://localhost:8000/healthz method: GET - id: notify name: 通知 using: shell with: run: | curl -X POST -H Content-Type: application/json \ -d {ok: true} \ ${{ env.NOTIFY_URL }} - id: cleanup name: 清理旧产物 using: file with: action: delete_old pattern: dist/*.tar.gz keep: 3注意到这里有两个变量作用域vars是任务级变量用{vars.version}引用env是环境变量用{env.PROJECT_ROOT}引用。所有引用在任务开始前统一解析一次解析失败直接中止避免跑到一半才发现变量是空的。3. 专项能力拆解5 个高频使用场景详解3.1 把 Shell 命令包装成统一入口最常见的用法就是把一坨复杂命令变成一个带参数的 CLI 命令。以前清理临时文件我得记住“不要删掉那个 config 目录”现在只需要一个cleanup-cache任务。配置如下- id: cleanup-cache name: 清理用户目录缓存 using: shell params: - name: target label: 缓存目录名 default: ~/.cache with: run: | TEMP_DIR{args.target} if [ -d $TEMP_DIR ]; then rm -rf $TEMP_DIR/* echo cleaned: $TEMP_DIR else echo directory not found: $TEMP_DIR exit 200 fi这里我推荐一个自己的习惯在 shell 脚本里尽量别小看退出码。CLI-Anything 里退出码可以直接用英文名称定义比如exit 200表示“目录不存在”而不是“失败”。因为对于定时任务你往往需要区分“真的出错了”和“无事发生”用不同的退出码去触发不同的报警策略。3.2 声明式 HTTP 请求任务从生涩到顺手HTTP 执行器是我个人使用频率最高的执行器。它解决了现实中一个烦人的痛点团队内部接口文档更新很快而 curl 命令在 Windows 和 Linux 上行为不一致。用声明式配置可以固定住请求方法、请求头、超时避免各种终端差异。下面这个例子是从内部服务拉取一份用户数据并保存为 JSON 文件- id: fetch-users name: 拉取用户列表 using: http with: url: {env.API_BASE}/users method: GET headers: Authorization: Bearer {env.API_TOKEN} query: page: {args.page} limit: 50 capture: - path: $.data assign: users - id: save-users name: 保存到本地 using: file with: action: write_json path: output/users.json content: {users}注意到capture这个字段没有它用于从 HTTP 响应体里提取数据。格式是 JSONPath 或 JSON Pointer提取出来的值会注入变量池供后面的步骤使用。这个设计帮我省掉了大量“用 Python 解析响应再写文件”的胶水代码。在这一步还要特别留意响应体大小。有一次我拉一个分页接口忘了加 limit响应体直接拉回 18 万条数据capture 又做了全量解析整个任务卡了 40 多秒。后来我加了两个保护HTTP 执行器默认限制响应体 20MB超过部分直接报错同时建议在脚本里对大型响应启用 streaming 模式只提取需要的路径避免整包载入内存。3.3 文件操作任务清理、归档、批量重命名文件执行器通常不单独使用而是和其他执行器配合成流水线。比如“每天备份一次数据保留最近 7 份”这个需求以前的脚本得用 cron 加一堆 find 参数现在写成- id: backup name: 备份数据目录 using: shell with: run: | NOW$(date %Y%m%d-%H%M%S) tar -czf backups/backup-$NOW.tar.gz -C data . - id: rotate name: 轮转旧备份 using: file with: action: keep_only pattern: backups/backup-*.tar.gz keep: 7keep_only是我后来补充的一个动作它会列出匹配pattern的所有文件按修改时间排序保留最新keep份超出部分归档到.trash/目录而不是直接删除。为什么不直接删因为误删是最不可逆的归档目录相当于一个缓冲我发现备份内容有问题还能拽回来。文件操作类任务一定要预留这个缓冲成本极低收益很高。还有一个高频操作批量重命名。假设要统一把产品图片从IMG_2034.JPG改成product-2034.jpg可以这样写- id: rename-images using: file with: action: rename pattern: images/IMG_*.JPG transform: - { replace: IMG_, with: product- } - { lower: true } extension: jpgtransform是重命名时的规则链按顺序处理每一个匹配文件名。注意文件名大小写问题我默认用字符串化的小写规则你在真实项目里一定要先跑一次dry_run: true看看效果再落到具体文件上。3.4 带交互提示的任务让命令行工具像表单一样跟用户交互是提升可用性的关键一步。CLI-Anything 支持在任务开头收集输入参数支持类型校验和默认值。举个例子发布版本时追问版本号和发布渠道- id: release name: 发布正式版 description: 指定版本和渠道执行发布 params: - name: version label: 版本号 (x.y.z) type: string required: true pattern: ^\\d\\.\\d\\.\\d$ - name: channel label: 发布渠道 type: choice choices: [stable, beta, alpha] default: stable steps: - id: deploy using: shell with: run: deploy.sh --version {args.version} --channel {args.channel}如果你希望某些步骤跳过交互任务还支持非交互模式ca run release --non-interactive -p version1.2.0 -p channelstable。这样在 CI 里自动发布也不会被交互卡住。这个--non-interactive参数我强烈建议所有任务都要做兼容否则一到自动化环境就是一颗定时炸弹。3.5 任务编排并行与依赖多个 step 默认是顺序执行的这是最安全的模式。顺序执行的好处是任意一步失败后续步骤都不会被误执行。但有些步骤相互独立顺序执行就白白浪费时间。我在 CLI-Anything 里增加了一个简单的编排描述给每个 step 加depends_on字段没有依赖关系的步骤会被自动并行调度。steps: - id: lint using: shell with: run: ruff check . - id: typecheck using: shell with: run: mypy src - id: test using: shell with: run: pytest depends_on: [lint, typecheck]这个配置下lint和typecheck会并行执行都成功后才跑test。并行调度通常能让总耗时下降 30% 到 50%。需要注意的是并行步骤之间尽量别共享可变文件否则容易竞态。我在文档里也明确写了默认每个 step 的工作目录是独立的临时副本只有声明shared_workspace: true的任务才共享目录。4. 项目实施细节与开发记录4.1 目录结构与入口设计CLI-Anything 的代码结构其实是很多人会忽略但至关重要的一部分。项目根目录如下cli-anything/ ├── pyproject.toml ├── cli_anything/ │ ├── __init__.py │ ├── __main__.py │ ├── cli.py # 入口负责 argparse 和 dispatch │ ├── loader.py # 加载 YAML 任务文件 │ ├── runtime.py # step 执行上下文、变量池 │ ├── executor/ │ │ ├── __init__.py │ │ ├── shell.py │ │ ├── http.py │ │ ├── file.py │ │ ├── python.py │ │ └── plugin.py │ └── utils/ │ ├── logging.py │ └── fs.py ├── tasks/ │ └── example.yml └── tests/__main__.py的存在让工具支持python -m cli_anything这是很多开源项目都忽略的小细节。对于没有安装成全局命令的临时环境支持模块方式运行能省不少事。命令入口cli.py不做任何具体业务逻辑它只负责三件事解析参数、加载配置、把控制权转交给 runtime。如果你要在这个项目上做二次开发请务必保持这层“薄入口”别把业务逻辑塞进 CLI 解析里。4.2 参数解析与校验的实现参数校验这部分踩坑最多。最开始的版本里我把所有参数都写在 YAML 的params节点下然后用 argparse 处理。但很快发现一个矛盾argparseadd_argument是按 Python 方法参数签名的而 YAML 配置是用户提供的字符串两者之间需要一层系统性的映射否则代码全是破绽。我的做法是设计一个ParamSpecdataclass字段包括name、label、type、required、default、choices、pattern。通过统一的parse_param_spec()函数把 YAML dict 转成ParamSpec再由ParamsManager负责三件事类型转换、format 校验、交互提示。其中类型转换看起来简单真做起来还是有不少细节比如整数要允许1_000这种写法布尔值得接受yes/no/true/false/0/1字符串要去掉首尾空格。这些细节不做好任务参数就会成为各种神秘的报错来源。信不信由你最让我费神的反而是“字符串里的花括号”。因为任务配置里到处都有变量引用{args.page}但如果参数值本身包含 JSON 里的大括号比如pattern: ^\\d\\.\\d\\.\\d$解析器就必须判断哪些花括号是变量、哪些是字面量。我的方案是采用双花括号转义{{代表字面量{解析变量引用时只处理{var}格式。这个规则写进文档以后类似问题少了一大半。4.3 子进程管理和信号处理Shell 执行器是最基础的执行器也是隐藏问题最多的执行器。调用系统命令不等于os.system()完事至少要处理 5 个维度环境变量注入、工作目录、输入输出流、超时控制、退出码。我最先实现的是最粗糙的subprocess.run()后来在实际运行中遇到两件很尴尬的事。第一件命令输出带颜色转义码在日志文件里变成一堆[32m影响阅读。解决办法是给子进程设置env把NO_COLOR和CLICOLOR0加进去并判断输出目标是 TTY 还是文件不是 TTY 就强制禁用颜色。第二件长时间运行的任务在超时后会留下僵尸子进程。如果你在 shell 脚本里又启了后台进程subprocess.run超时杀掉的是父进程后台子进程可能仍在运行。所以我额外维护了一个进程组在 Unix 平台用start_new_sessionTrue超时后用os.killpg杀掉整个进程组。这个细节直接关系到一个自动化任务会不会“假死但杀不死”值得每个做 CLI 工具的人记在心里。proc subprocess.Popen( cmd, shellTrue, textTrue, envfull_env, start_new_sessionTrue, stdoutsubprocess.PIPE, stderrsubprocess.STDOUT, ) try: out, _ proc.communicate(timeouttimeout_seconds) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGTERM) out, _ proc.communicate() raise RuntimeError(f命令执行超时已清理进程组: {cmd})4.4 日志与退出码约定日志是 CLI 工具最容易做烂的部分。我的习惯是分三层step开始和结束时各打一条结构化日志包含任务 id、耗时step内部输出的每一行都打上# 前缀方便区分是工具日志还是命令输出任务失败时输出一个带上下文的错误摘要比如“第 2 步失败用了 http 执行器URL 为 xxx”。退出码约定直接影响自动化运维的可靠性。我定义了这样一套内部约定退出码含义处理方式0成功无1常规错误配置错误、参数错误需要修复后重试2执行器未找到或插件加载失败检查安装包3依赖步骤失败导致的中止查看前置步骤日志200 ~ 210业务自定义的略过状态例如“无事可做”211 ~ 255用户脚本自定义错误由任务脚本自行定义语义这个表格不是凭空定的是从实际运维中反推出来的。如果所有异常都混成一个退出码半夜收到报警还得一个个查日志有了语义化退出码报警脚本可以直接根据退出码决定是短信通知还是仅记一条日志。4.5 安装与打包项目自带pyproject.toml用标准构建工具打包即可。一个值得强调的点是不要把tasks/示例目录直接打进 Python 包里。因为任务配置文件跟本机工作区强相关属于“用户数据”而不是“代码数据”。安装时使用[project.scripts]注册ca命令具体可以写成[project.scripts] ca cli_anything.cli:main [tool.setuptools] packages [cli_anything, cli_anything.executor, cli_anything.utils]另外要顺手把 Python 版本的最低要求写清楚。这个项目我要求在3.9因为用到了一些内置泛型和较新的 typing 特性。如果目标环境是 Ubuntu 20.04 那种老版本系统建议提前降级语法否则用户第一眼看到SyntaxError就会直接放弃。5. 常见问题与排查技巧实录5.1 交互式命令卡死最经典的问题在 shell 任务里执行docker login、ssh roothost这种交互式命令时任务的输出像死了一样什么都不显示也不结束。原因其实很简单PIPE把标准输入、标准输出都改成了管道命令检测到不是 TTY要么进入纯非交互模式要么等待输入但输入流从未提供。我总结了三条路。第一如果能提供密码优先改用非交互式的参数形式比如sshpass或docker login -p但注意这种方式不适合生产环境。第二用stdin字段显式指定输入比如with.stdin: y\n来回答关键确认。第三实在无法非交互化任务设计上直接砍掉它把这步留给用户手动执行。这里我给大家一个更稳的建议任何交互式命令都不要硬塞进自动化任务里。CLI 工具的价值在于确定性和可重复性交互式步骤刚好是确定性的大敌。与其跟交互命令搏斗不如换一个 API 或者换一个支持--yes的替代工具。5.2 YAML 配置里最容易踩的坑YAML 有个著名的陷阱叫“Norway 问题”字符串true、false、null、200会被自动转换成布尔或整数。比如default: true被解析成布尔之后参数拼接时很可能变成字符串 “True”大小写都对不上。我的解决方案是所有 Params 相关字段在加载时都做一次“类型弱化处理”——如果 schema 声明的是 string就强制把布尔、整数重新转回字符串。还有一个常见问题YAML 中的自定义标签和注释。比如%YAML 1.2或者!!python/object这类标签如果用户从别处复制配置没注意解析器就可能报错。加载器我用了safe_load而不是load确保非安全标签直接被拒绝避免配置被恶意利用。5.3 权限与安全边界CLI-Anything 的本质是“按配置执行本地命令”这就意味着配置文件的权限要谨慎对待。我的项目里有一个 20 秒超时的很吓人的例子用户从网上复制了一段 YAML里面包含curl ... | bash这在 Linux 下等于直接执行远程代码。所以我做了两个保护机制任务定义默认只允许加载~/.cli-anything/tasks/目录下的文件除非用户在命令后加--allow-external-tasks否则不允许加载其他任意路径的任务文件。环境变量也很敏感。如果配置里使用{env.API_TOKEN}在并行任务中打印变量值前要自动脱敏。具体实现是维护一个secret_keys集合日志输出时把匹配到的值替换成***。这属于典型的事后补救但在团队协作中很实用尤其是你们还没有统一密钥管理系统的时候。5.4 常见问题速查我把实际操作中遇到的十几个典型问题整理成了下表方便你直接检索定位。现象可能原因排查思路解决办法命令执行超时后进程仍在未启用进程组检查ps -ef开启start_new_sessionTrue变量显示为原样{args.x}花括号转义或作用域不对查看解析日志检查双花括号和 vars 层级命令行反馈“任务不存在”任务目录没扫到运行ca list检查 YAML 文件名和 id 命名HTTP 响应中文乱码编码识别错查看 Content-Type统一配置charset: utf-8并行任务写入同一文件冲突竞态检查文件锁日志加shared_workspace: true或拆分目录非交互模式下仍弹提示param 没有默认值看 required指定默认值或-p传入这张表帮我节省了大量“自己坑自己”的时间建议你在实际使用中持续补充。6. 性能优化与下一步计划6.1 并行执行的真实收益前面说了并行任务能让耗时下降 30% 到 50%但前提是各步骤之间的资源不冲突。我在一套 12 核机器上做过基准三个纯 CPU 计算步骤并行时总耗时从 6.8 秒降到了 2.7 秒但三个都要读同一个大文件的步骤并行时不仅没加速还因 I/O 抖动导致偶发超时。我的结论是并行不是免费的它更适合网络等待密集型任务比如并发请求多个接口、并发检查多台机器状态。如果任务要并发很大千万别无脑开 50 个线程去发 HTTP 请求。我把并发数上限默认设为 CPU 核心数加 2防止瞬时打爆连接池。这个参数暴露为with.max_parallel实测下来比较稳。6.2 缓存与幂等性另一个优化方向是任务级缓存。对于耗时较长的数据获取型 step比如拉取远端数据、解析大文件可以声明- id: fetch-remote using: http with: url: ... cache: ttl: 3600 key: {env.API_BASE}/users.{args.page}缓存命中时直接跳过整个 step并且不执行 capture 吗不对capture 照常执行只是数据源替换为缓存中的响应体。这样后面 step 的代码完全无感。缓存目录放在本地临时目录为了稳定我推荐开启ttl过期否则改完接口参数后发现结果一直还是旧的反而会增加排查成本。幂等性方面我支持 step 级幂等标记idempotent: true的步骤如果上次是成功结束的本次运行时可以选择“跳过已成功步骤”。这个功能对大型任务尤其有用因为某一步失败后修个 bug 再跑前几步不用重新执行。当然前提是你充分信任步骤结果的稳定性看门狗类任务不能盲目开启。6.3 下一步计划与我的一点体会现在的版本距离“Anything”还有距离我打算接下来重点扩充三块一是远程执行让它能通过 SSH 协议把任务推送到远端机器执行统一管理多节点定时任务二是交互式调试模式能逐条执行 step 并在断点处修改变量三是把 HTTP 执行器升级成完整的 OpenAPI 客户端生成器用户只需要给一个 OpenAPI 文档就能自动生成一套类型安全的命令。从实际运行体验来看把任务都收敛成ca run这样的统一入口最大的收益其实不是效率提升而是心智负担下降。以前我有一堆半废弃的脚本每次看到都觉得陌生现在任务清单里列出了几十个 id每个都有描述、参数和日志看一眼就明白它能干什么。如果你也有类似的需求无论是自己使用还是团队协作希望这篇文章能帮你少踩几个坑快速沉淀出自己的“Anything”。
返回列表