你有没有遇到过这种场景:负责的服务状态只能靠浏览器一个个打开页面去点,某个内部接口每次测试都要拼接一长串 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-Anything
3.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 = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "cli-anything" version = "0.1.0" requires-python = ">=3.8" dependencies = [ "PyYAML>=6.0", "requests>=2.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", encoding="utf-8") as f: config = yaml.safe_load(f) elif suffix == ".json": with p.open("r", encoding="utf-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( prog=config.get("name", "anything"), description=config.get("description", "将任意操作封装为命令行工具"), ) subparsers = parser.add_subparsers(dest="command", required=True) for cmd_name, cmd_config in commands.items(): sub = subparsers.add_parser( cmd_name, help=cmd_config.get("help", ""), description=cmd_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有两个细节值得展开。第一个是required=True,在 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( method=method, url=url, params=query_params if method in ("GET", "HEAD", "DELETE") else None, json=query_params if method in ("POST", "PUT", "PATCH") else None, headers=cmd.get("headers", {}), timeout=cmd.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, shell=True, capture_output=True, text=True, timeout=cmd.get("timeout", 60), env=env, ) if result.returncode != 0: raise RuntimeError( f"命令执行失败: exit code {result.returncode}\n" f"STDOUT: {result.stdout}\n" f"STDERR: {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_ascii=False, indent=2)) except json.JSONDecodeError: print(result) else: print(result) except Exception as e: print(f"error: {e}", file=sys.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加上 indent=2 重新打印。
if cmd.get("output", "text") == "json": try: parsed = json.loads(result) print(json.dumps(parsed, ensure_ascii=False, indent=2)) except json.JSONDecodeError: print(result)这里的ensure_ascii=False很重要,不加的话中文会被转成\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 上会遇到几个经典问题。shell=True在 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 里变成一条命令。我自己的体会是,工具不在于大,能切实减少大家每天输入的东西,就已经赢了。