资讯动态

CLI-Anything:把重复操作封装成一条命令行工具

发布时间:2026/9/28 23:09:33 来源:尧图企业网站定制
你有没有遇到过这种场景负责的服务状态只能靠浏览器一个个打开页面去点某个内部接口每次测试都要拼接一长串 curl 参数团队里的脚本散落在各人的电脑上换台机器就找不到。CLI-Anything 就是冲着这个问题来的——把任意操作、任意接口、任意脚本统一封装成一看就懂、一敲就跑的命令行工具。这两年我折腾过不少命令行工具最大的感受是凡是两周内用过两次以上的操作都值得封装成一条命令。这篇文章就分享一下我基于 CLI-Anything 这种思路从设计到落地的完整过程适合被繁琐操作折磨的开发者、运维和测试同学参考。1. 为什么需要 CLI-Anything从痛点说起1.1 真正让人头疼的不是没有脚本而是脚本无法沉淀我最早做内部工具的时候团队里每个人都有自己的小本本。有人把常用的接口测试命令存在 bash history 里有人写在飞书文档里还有人直接在 IDE 里留了个 scratch 文件。表面上大家都有脚本实际上每个脚本的用法只有作者自己知道参数顺序换一换就报错换台电脑就完全跑不起来。这种状态持续到某个周五下午线上服务告警需要快速查一批用户的数据。同事在群里喊谁有那个查用户的脚本结果三个人发了三个不同的版本参数还都不一样。最后花了十分钟口播教学才把命令凑出来。那一刻我就决定不能再让命令逻辑散落在个人电脑上必须有一个统一的入口把常用的操作全部收编进来。CLI-Anything 这个名字听起来很大其实核心思路非常朴素把操作抽象成命令。用户查询是一个命令、服务健康检查是一个命令、磁盘清理是一个命令。每个命令只需要定义清楚做什么、需要哪些参数、返回什么格式剩下的事情全部交给一个通用框架来处理。就像我给你一张点菜单你只需要勾选想吃什么后厨会自动把菜做出来。1.2 CLI-Anything 的思路配置即命令要做到配置即命令最关键的一步是拆解命令的共性。不管是什么领域的操作一条命令行工具最终都会落到三个环节参数怎么解析、动作怎么执行、结果怎么展示。CLI-Anything 要做的就是把这三点抽象成一套通用机制然后用配置文件来描述每个具体的命令。举一个最典型的例子团队成员最常用的按 ID 查用户这个操作。用 curl 写出来是这样的curl -s http://127.0.0.1:8080/api/v1/users/1024 | python3 -m json.tool看起来不难但实际使用中你会发现几个问题base_url 变了要改哪里返回的 JSON 里有些字段太长能不能只显示关键字段参数从哪来如果这个命令要给别人用总不能让他打开命令行先改一串 URL 吧。用 CLI-Anything 的思路这个问题会变成一份配置文件里的三行内容user-get: help: 按ID查询用户 path: /api/v1/users/{id} method: GET args: - name: --id type: int required: true help: 用户ID output: json然后使用方只需要执行一行命令anything user-get --id 1024URL 是什么、请求头怎么带、返回结果怎么排版全部由框架负责。使用者不需要知道 curl不需要记住 base_url甚至不需要理解 HTTP 方法。他要做的只是选命令、填参数、看结果。2. 整体架构与核心设计思路2.1 三个关键模块加载器、参数解析器、执行器我在设计 CLI-Anything 时把整个框架拆成了三个独立模块各管一段。理解这三个模块基本就理解了这类工具的全部套路。加载器负责读取配置文件并进行校验。配置文件支持 YAML 和 JSON 两种格式。为什么两种都支持YAML 写起来清爽注释友好适合人手工维护JSON 则方便从其他系统生成适合程序自动写入。加载器还有一个重要职责在加载时做基础校验比如命令是否存在、参数类型是否合法、必填参数有没有写全。错误发现得越早使用者的体验越好与其在运行时爆出一堆看不懂的 traceback不如在加载阶段就能给出明确提示。参数解析器是命令行工具的门面。用户敲下命令后首先接触到的就是参数解析逻辑。这块我选择了 argparse 而不是手写解析逻辑原因很简单argparse 已经处理了绝大多数的边界情况比如参数缺失、类型错误、未知选项等生成的错误提示也比较人性化。在 argparse 之上我需要做一层动态映射——根据配置文件里的 args 定义动态创建参数规则这样新增一个命令时不需要修改任何代码只需要改配置文件。执行器是整个框架里最有意思的部分。我把执行类型分成三类HTTP 请求、Shell 命令、Python 函数。HTTP 请求处理的是调接口这一类最普遍的场景Shell 命令处理的是跑一段脚本的场景比如磁盘检查、日志清理Python 函数属于给高级用户留的后门当配置无法满足需求时直接指定一个函数入口让它跑。三种执行器的接口是统一的都接收命令配置和解析后的参数返回一个字符串作为结果。2.2 为什么用配置驱动而不是代码驱动可能有人会问直接用 Click 或者 Typer 写 Python 脚本每个命令写一个函数不就行了为什么非要搞一套配置驱动我在动手之前也有过这个纠结后来对比了一下两种方案在真实团队里的表现。代码驱动的最大问题在于维护门槛。写一个带参数的 Click 命令最少也要七八行代码。团队的诉求是让任何人都能贡献命令包括不太会写 Python 的测试同学和运维同学。如果你告诉他在 commands 目录下新建一个 .py 文件定义一个函数加上装饰器这个心理门槛是很高的。但如果只是让他改一个 YAML 文件加几个缩进和字段那基本属于看一眼就会的范畴。配置驱动还有一个隐藏优势配置文件本身就是文档。命令有哪些参数、每个参数是什么意思、返回什么格式写配置的时候就是写文档的时候。不需要额外维护一份 Markdown 说明因为配置里已经包含了一切。新成员加入团队只需要打开 anything.yaml 扫一遍就知道团队沉淀了哪些可复用的操作。2.3 配置格式设计写起来像点菜配置格式的设计直接决定了这个框架是否容易被接受。我的原则是视觉上要轻语义上要明确。一份配置文件看起来应该像一个点菜单而不是一份技术说明书。定义一条命令你需要回答五个问题这个命令是干什么的要访问哪个路径或执行什么操作有哪些参数参数之间什么关系结果怎么展示对应到配置里就是 help、path或 run、args、method、output 这几个字段。参数的类型设计也很关键。我把类型精简到四种字符串str默认、int、float、bool。这四种覆盖了绝大多数使用场景类型多了反而会让使用者困惑。参数之间偶尔有联动需求比如传了 --file 就不能传 --content这种复杂约束我选择不放进配置层而是交给 Python 执行器去校验保持配置层的简单纯粹。3. 从零实现一个可用的 CLI-Anything3.1 环境准备与项目结构实现一个最小可用版本其实只需要 Python 3.8 和两个第三方库。项目结构我建议分成三个模块加一份配置文件逻辑清晰也方便后续扩展。cli-anything/ ├── pyproject.toml ├── README.md ├── anything.yaml └── cli_anything/ ├── __init__.py ├── main.py ├── loader.py └── executor.py我故意把 formatter 之类的额外功能省略了因为结果展示这块在初期直接打印输出就够了。先把主流程跑通后续再逐步加码。这种先跑通一条完整链路再膨胀的开发方式对于工具类项目特别重要否则很容易陷入框架写了一堆真实命令还没接进来的尴尬境地。依赖方面只用到 PyYAML 和 requests可以在 pyproject.toml 里固定版本[build-system] requires [setuptools61.0] build-backend setuptools.build_meta [project] name cli-anything version 0.1.0 requires-python 3.8 dependencies [ PyYAML6.0, requests2.25, ] [project.scripts] anything cli_anything.main:main这个 pyproject.toml 文件同时解决了依赖管理和全局命令注册pip install 之后 anywhere 就能直接执行 anything 命令。3.2 加载器实现先把配置文件读明白加载器的职责是把配置文件变成内存里的字典对象同时把常见的配置错误挡在门外。我踩过的第一个坑是直接调用yaml.safe_load后不做类型检查结果配置文件写成了列表结构运行时到处报 KeyError。所以加载器里补了一个 isinstance 校验。from pathlib import Path import json import yaml def load_config(path: str) - dict: 加载并解析配置文件。支持 YAML/JSON 两种格式。 p Path(path) if not p.exists(): raise FileNotFoundError(f配置文件不存在: {path}) suffix p.suffix.lower() if suffix in (.yml, .yaml): with p.open(r, encodingutf-8) as f: config yaml.safe_load(f) elif suffix .json: with p.open(r, encodingutf-8) as f: config json.load(f) else: raise ValueError(f不支持的配置文件格式: {suffix}) if not isinstance(config, dict): raise ValueError(配置文件根节点必须是对象 (map)请检查缩进或格式) return config加载器里我还做了一件事把命令配置中可能缺失的默认值补上。比如 method 默认 GET、output 默认 text、type 默认 http。这种兜底逻辑放在加载阶段比放在执行阶段更合适因为在一开始就消除不确定性后续所有模块都能直接假设字段是存在的。3.3 参数解析与子命令分发参数解析是 CLI 工具的核心体验。argparse 提供了 add_subparsers 机制天然适合一级命令 二级子命令的结构。配置里的每个顶层键就是一个二级子命令动态度数取决于配置文件里定义了哪些命令。import argparse def build_parser(config: dict) - argparse.ArgumentParser: commands config.get(commands, {}) parser argparse.ArgumentParser( progconfig.get(name, anything), descriptionconfig.get(description, 将任意操作封装为命令行工具), ) subparsers parser.add_subparsers(destcommand, requiredTrue) for cmd_name, cmd_config in commands.items(): sub subparsers.add_parser( cmd_name, helpcmd_config.get(help, ), descriptioncmd_config.get(description, cmd_config.get(help, )), ) for arg in cmd_config.get(args, []): kwargs {help: arg.get(help, )} arg_type arg.get(type, str) if arg_type int: kwargs[type] int elif arg_type float: kwargs[type] float elif arg_type bool: # 布尔参数用 store_true不需要传值 kwargs[action] store_true kwargs[default] False else: kwargs[default] arg.get(default) flags [arg[name]] if arg.get(short): flags.insert(0, arg[short]) sub.add_argument(*flags, **kwargs) return parser有两个细节值得展开。第一个是requiredTrue在 Python 3.7 之前 argparse 不支持对 subparsers 设置这个参数版本低于 3.7 会报错如果你还在维护老环境需要留意。第二个是布尔参数的表示方式它没有值直接通过是否出现来决定真伪配合store_true最顺手。3.4 HTTP 执行器与 Shell 执行器这两块是命令真正落地的地方。HTTP 执行器接收藏路径模板和参数先做占位符替换再根据 HTTP 方法决定参数是放到 query string 还是 JSON body。import re import requests def execute_http(cmd: dict, args: dict, base_url: str) - str: path cmd[path] query_params {} # 分离路径占位符参数和普通参数 for key, value in list(args.items()): placeholder { key } if placeholder in path: path path.replace(placeholder, str(value)) else: query_params[key] value url base_url.rstrip(/) / path.lstrip(/) method cmd.get(method, GET).upper() resp requests.request( methodmethod, urlurl, paramsquery_params if method in (GET, HEAD, DELETE) else None, jsonquery_params if method in (POST, PUT, PATCH) else None, headerscmd.get(headers, {}), timeoutcmd.get(timeout, 30), ) if resp.status_code 400: raise RuntimeError(f请求失败: HTTP {resp.status_code} - {resp.text[:200]}) return resp.textShell 执行器的核心差异在于环境变量注入。我不能直接把参数拼进命令字符串里那样会留下注入风险。更安全的做法是把参数放进环境变量让脚本通过$VAR的方式读取。import os import subprocess def execute_shell(cmd: dict, args: dict) - str: script cmd[run] env os.environ.copy() for key, value in args.items(): env[key.lstrip(-).upper().replace(-, _)] str(value) result subprocess.run( script, shellTrue, capture_outputTrue, textTrue, timeoutcmd.get(timeout, 60), envenv, ) if result.returncode ! 0: raise RuntimeError( f命令执行失败: exit code {result.returncode}\n fSTDOUT: {result.stdout}\n fSTDERR: {result.stderr} ) return result.stdout比如配置里定义了一条命令参数是--pattern *.log那么脚本内部就可以通过$PATTERN来引用这个值。这样既避免了拼字符串带来的安全风险又让脚本本身保持独立可测试。3.5 主流程拼接与全局命令安装主流程把加载器、参数解析器、执行器串起来同时处理结果展示和错误退出码。这一层不负责具体业务逻辑只做接线工作。import json import os import sys from .executor import execute_http, execute_shell, execute_python from .loader import load_config def main(): config_path os.environ.get(ANYTHING_CONFIG, anything.yaml) config load_config(config_path) parser build_parser(config) args vars(parser.parse_args()) cmd_name args.pop(command) cmd config[commands][cmd_name] try: exec_type cmd.get(type, http) if exec_type http: result execute_http(cmd, args, config.get(base_url, )) elif exec_type shell: result execute_shell(cmd, args) elif exec_type python: result execute_python(cmd, args) else: raise ValueError(f未知的执行类型: {exec_type}) if cmd.get(output, text) json: try: parsed json.loads(result) print(json.dumps(parsed, ensure_asciiFalse, indent2)) except json.JSONDecodeError: print(result) else: print(result) except Exception as e: print(ferror: {e}, filesys.stderr) sys.exit(1)这里有一个小设计点解析后的参数通过vars()变成字典再被 append 到执行器里。这样所有执行器接收的都是一致的 dict而不是持有真正的 Namespace 对象接口之间不会产生耦合。打包安装就更简单了在项目根目录执行pip install -e .-e是开发模式源码改了立即生效不需要反复重新安装。安装完成后直接在任意目录执行anything --help如果能看到帮助信息说明整条链路已经跑通了。4. 配置与命令设计的实操经验4.1 命令命名与参数设计规范框架写好后真正的日常工作是配置命令。命令命名直接决定这个工具好不好用。我踩过几次坑之后总结了一套自己的规则动词在前对象在后用中划线分隔。查用户是 user-get创建用户是 user-create清理日志是 log-clean。不要用驼峰不要在名字里加动词的时态变化保持全小写。参数的命名我用的是双中划线长选项优先短选项只给最高频的参数。比如--id这种参数在多个命令里都会出现就给一个-i的短选项。而那些低频的、含义容易混淆的参数一律不给短选项。这样即使命令多了也不会出现短选项冲突的破事。类型的选择上我的建议是能强类型就强类型。ID 就定义成 int端口就定义成 int年龄就定义成 int。argparse 在参数类型不匹配时会在解析阶段直接报错这比在请求发出后服务器返回 400 再排查要高效得多。4.2 帮助文本的隐藏价值很多人写配置的时候help 字段喜欢随便写或者干脆不写。实际上这个字段的 ROI 极高。sub.add_argument里的 help 不仅会出现在--help输出里更是团队规范的一部分。我给自己定了一个底线每条命令的 help 必须让一个完全没用过的人看懂它再说清楚它的副作用。比如按ID查询用户是合格的查询用户就差一点用户查询接口就是废话。参数的 help 则说明取值范围和边界比如用户ID需大于 0就比用户ID更有信息量。这些看起来不起眼的描述实际使用中能省掉大量沟通成本。新同事第一次用工具先敲anyting user-get --help如果能不看代码就知道怎么用这个配置就算写到位了。4.3 输出格式怎么定输出格式这个点我一开始是完全忽略的反正把请求结果打出来就行。后来加了 JSON 格式化输出体验飙升一个档次。具体做法是当配置里的 output 是 json 时框架会把结果解析成 Python 对象然后用json.dumps加上 indent2 重新打印。if cmd.get(output, text) json: try: parsed json.loads(result) print(json.dumps(parsed, ensure_asciiFalse, indent2)) except json.JSONDecodeError: print(result)这里的ensure_asciiFalse很重要不加的话中文会被转成\uXXXX的转义序列可读性很差。另外如果接口返回的不是合法 JSON也不能直接崩掉而是回退到原文打印。这类防御式处理在工具里非常实用。5. 常见问题与排查技巧实录5.1 子命令参数不生效实话说最常见的报错是anything user-get --id 5执行后发现参数根本没生效请求路径变成了/api/v1/users/{id}。排查下来基本都是同一个原因配置里参数名写的是id但在path里写的是{user_id}占位符名称对不上。这个问题的根源是配置里有两处需要保持一致。我的解决办法是在加载器里加一个校验遍历所有命令的 path检查每个花括号占位符是否都能在 args 里找到同名参数。如果找不到直接抛异常提示路径占位符 {user_id} 未找到对应的参数定义。这个校验把问题从运行时发现提前到了加载时发现。5.2 路径占位符在 YAML 里的坑这是一个非常隐蔽的 YAML 解析问题。一个以{开头的字符串会被 YAML 解析器当成字典。比如你写path: /users/{id}这个没问题因为前面还有/users/前缀。但如果你写default: {id}YAML 会把它解析成一个字典而不是字符串导致后面字符串替换的时候直接类型报错。解决办法很简单凡是可能以特殊字符开头的字符串都用引号包起来比如default: {id}。这个问题我在内部的配置规范里明确写了一条所有字符串值如果是以{、[、*、开头的必须加引号。5.3 请求超时与重试机制requests 的 timeout 参数是接线上服务时最容易忽略的。不设 timeout 意味着请求可能无限期挂起一条命令看起来像是卡死了其实是在等一个永远不会响应的服务器。我在配置里给每个 HTTP 命令都加了默认 timeout 为 30 秒。但对于某些依赖外部服务的接口30 秒可能不够。这时候可以在配置文件里单独覆盖slow-report: help: 生成月度报表 path: /api/v1/reports method: POST timeout: 120重试机制我建议留给更上层的调用方来处理。初学者会把重试逻辑写进执行器导致错误响应和超时混在一起排查问题很痛苦。CLI 工具保持一次性执行的语义失败就失败让用户决定要不要重试这样行为最可预测。5.4 跨平台 Shell 命令执行差异Shell 执行器在 Linux 和 macOS 上表现基本一致但在 Windows 上会遇到几个经典问题。shellTrue在 Windows 上调用的其实是 cmd.exe不是 bash所以配置里写df -h到 Windows 上完全跑不通。我的处理策略是公共命令尽量用 Python 实现不依赖系统命令。比如磁盘检查与其调用df -h不如写一个 Python 执行器用shutil.disk_usage拿到同样的数据。非要用 Shell 命令时配置里明确标注platform: linux加载器在非目标平台上给出友好提示而不是执行到一半才报 command not found。6. 还能怎么玩扩展方向与后续思路CLI-Anything 跑通之后我陆续给它加了一些锦上添花的能力。效果最好的是把配置文件纳入 git 仓库团队成员提交新命令后其他人拉代码就能用。整个过程不需要做任何安装操作只要git pull就能看到新命令出现在--help里。另一个我认为值得探索的方向是和 CI/CD 结合。很多流水线里需要执行一些查询类操作比如部署前检查依赖版本、部署后验证端口连通。把这些检查项写成 CLI-Anything 命令流水线里就是一行anything check-port --name gateway --port 8080效果比在 Jenkins 里维护一堆 shell 脚本直观得多。关于 CMDB、监控系统这类内部平台都可以用类似思路接入。通过配置描述资源类型和查询接口CLI-Anything 就成了内部系统的统一访问入口。新平台接入的成本就是一个配置文件不需要平台方做任何定制开发这个价值在平台多了之后会越来越明显。实际上这类工具最核心的思维是把重复操作建模成一组数据然后让一个通用的引擎去执行。你团队里那些靠人肉记忆的流程只要肯花一个下午梳理清楚都能放进 CLI-Anything 里变成一条命令。我自己的体会是工具不在于大能切实减少大家每天输入的东西就已经赢了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑