CLI-Anything这个项目名字听起来有点狂,但真做起来你会发现,所谓“任何东西”都有共性。我去年因为同时在几个项目里来回切换工具,今天用curl敲API,明天写Python脚本,后天还得手动跑cron清理日志,烦到极致之后,就搭了一个统一命令行入口——CLI-Anything。它不把世界上的软件都重写一遍,而是把那些杂乱的shell命令、Python函数、HTTP接口统一收编成一套带参数校验、帮助文档、配置文件和友好输出的CLI工具。
这个项目非常适合后端开发、运维、数据工程这类每天跟命令行打交道的人。你不需要会特别高深的技术,只要了解基础的Python和Shell,就能照着思路自己搭一套。它的核心价值不是让你写出多酷的框架,而是把日常那些零散的“一次性脚本”收拢成有规矩、可复用、能交接的工具。说白了,就是给你的命令行工作流装一个统一的前台。
1. 项目概览:CLI-Anything到底在解决什么问题
1.1 一句话说清楚:CLI-Anything是什么
CLI-Anything本质上是一个“命令收编层”。它不自己去实现某个业务功能,而是给现有的脚本、函数、HTTP接口一个统一的命令行入口。
举个例子。我日常工作的机器上,有一堆散落各处的工具:有写好的Python数据清洗脚本,有负责重启服务的Shell脚本,还有几个需要带鉴权token调用的内部API。过去我用它们的方式完全不同,有的要进到指定目录去跑,有的要记参数顺序,有的还要先生成token再拼接URL。这就像家里钥匙一大堆,每把钥匙对应一扇门,但门上没写名字,全凭记忆。
CLI-Anything的思路就是给这些“门”统一换锁芯,最后你只需要记住一把钥匙。所有任务都通过同一个命令入口触发,比如cli-anything run script --name clean_data,或者cli-anything call api --name list_users。参数怎么传、是否需要鉴权、输出什么格式,都由CLI-Anything替你操心。
这个项目适合的受众其实很广。如果你是那种经常需要在终端里敲各种命令的开发者,或者团队里总有人把脚本写成“只有自己能看懂”的江湖手艺,CLI-Anything就能帮上忙。它不需要团队全员学习新框架,只要求每个人了解“通过统一命令入口调用任务”这一个概念。
1.2 它到底解决了什么痛点
第一个痛点是工具碎片化。真实工作环境里,同一个操作经常分散在不同工具里。查数据库要去连MySQL客户端,看服务日志要去翻文件,部署一次要执行一串Shell命令。每次切换到不同工具,都要重新回忆它的参数和语法,效率极低。CLI-Anything把这些操作全部收编为一个入口,对应的子命令平铺在--help里,找起来一目了然。
第二个痛点是脚本参数规范缺失。我自己写过太多“裸脚本”,参数全靠sys.argv[1]硬取,位置记错就报错,而且毫无帮助提示。时间一长,我自己都忘了第三个参数到底是端口号还是超时时间。CLI-Anything通过参数解析层统一解决这个问题,每个任务声明自己的参数类型、默认值和帮助说明,调用错误时直接给出清晰报错。
第三个痛点是交接成本高。团队里总会有人把自己的知识锁在本地脚本里,人一走,脚本就报废。但如果你把脚本收编进CLI-Anything,每个命令都有注册信息和帮助文档,新人看一眼--help就知道怎么调用。这一点在实际协作中价值非常大,少了很多“这个脚本怎么跑”的追问。
2. 整体设计与技术选型
2.1 技术栈选型:为什么是Python+Typer
我在技术选型上比较了几种方案,最终定的组合是Python + Typer + Rich。
第一是生态。团队里本来就用Python写各种自动化脚本,收编过来的成本最低。一个已有脚本只要包一层函数,就能被CLI-Anything调用,不需要重写。如果选Go或Rust,性能虽然好,但移植存量脚本的工作量太大了。
第二是参数解析和帮助文档生成。Python标准库的argparse能用,但写起来啰嗦,且帮助文本不太好维护。Typer这个库特别适合做CLI外壳,它基于类型注解自动生成参数解析、校验和--help文档。你只需要定义函数参数,标注类型和默认值,Typer就帮你搞定一切。
第三是输出体验。Rich能让终端输出带颜色、表格和进度条,调试和演示效果都很好。CLI工具最怕黑漆漆一团文字看不清,Rich帮我把结构化输出直接打在终端上。
我也对比过直接用Shell脚本做总入口的方案,发现后期维护很痛苦。Shell没有类型概念,参数校验全靠手工,分支一多脚本就变成一坨难以阅读的代码。而CLI-Anything作为一个Python项目,可以单元测试、可以加日志、可以按模块拆分,明显更扛得住项目体量增长。
技术栈确定后,我做了一个小验证原型,把三个不同来源的脚本收编进来,测试从写注册信息到实际调用大概花了多久。结果我第一次跑通只用了两个小时,这个成本完全值得投入。
2.2 三个核心设计原则
CLI-Anything能比较稳定地支撑我日常使用,靠的是三条设计原则。
原则一:注册表驱动。所有的收编对象都通过“注册”的方式挂到CLI上,而不是在代码里写一堆 if 分支去分发。注册表是一个数据字典,记录了每一个命令的名字、帮助文本、适配器类型、目标对象和参数定义。新增一个命令只需要往注册表里加一条记录,主分发逻辑完全不用改。这个设计让CLI-Anything保持开闭原则,对新增开放,对修改关闭。
原则二:统一输入输出。无论背后是Shell脚本还是HTTP接口,用户的输入都只有“命令名+参数”。后端则统一返回结构化结果,由CLI-Anything负责渲染成表格、JSON或普通文本。这样做的好处是,你换掉实现方式时,用户侧完全无感。比如原来一个备份脚本用的是Shell实现,后来改成Python函数,调用命令一条都不用改。
原则三:适配器隔离。这是最关键的一点。CLI-Anything不直接执行目标命令,而是通过适配器(Adapter)来间接调用。Shell适配器负责执行子进程,Python适配器负责调用函数,API适配器负责发HTTP请求。每种适配器各自封装一种执行方式,新增一种执行方式时,不需要动主程序逻辑。
2.3 目录结构与模块划分
项目结构是我反复调整过的版本,看起来清晰,扩展也方便。
cli_anything/ ├── app.py # CLI入口,负责实例化Typer应用 ├── registry.py # 注册表管理,存放全部命令元信息 ├── config.py # 配置文件加载,全局YAML解析 ├── adapters/ │ ├── __init__.py # 适配器工厂 │ ├── shell.py # Shell脚本适配器 │ ├── python_func.py # Python函数适配器 │ └── http_api.py # HTTP API适配器 ├── jobs/ │ ├── backup.py # 备份任务 │ ├── deploy.py # 部署编排 │ └── health_check.py # 服务检查 ├── config.yaml # 全局配置,含鉴权信息、超时等 ├── requirements.txt └── pyproject.toml我把注册表放在registry.py里,每个被收编的任务放在jobs/下,适配器独立成包。这种分层的目录结构有一个好处:当你需要排查某个任务的执行链路时,路径非常清晰——入口app.py找命令,registry.py查元信息,适配器负责执行,任务函数放在jobs里。整个流程不绕弯子。
3. 核心功能与实现细节
3.1 注册表设计:让每个子命令都有户口
注册表是整个CLI-Anything的大脑。我设计的数据结构不复杂,核心是一个列表,每个元素是一个命令的元信息。
# registry.py from typing import List, Dict _REGISTRY: List[Dict] = [] def register(name: str, help_text: str, adapter_type: str, target: str, params: List[Dict] = None): """注册一个命令到CLI-Anything。""" _REGISTRY.append({ "name": name, "help": help_text, "adapter_type": adapter_type, "target": target, "params": params or [], }) def get_all_commands() -> List[Dict]: """返回全部注册命令。""" return _REGISTRY为什么用注册表而不是硬编码路由?最开始我写过一个版本,在app.py里用一堆if command == "backup": backup.run()的代码,结果是每加一个命令就要改主文件,而且主文件越来越大,阅读和测试都很痛苦。改成注册表后,新增任务只做两件事:写任务函数、调用一次register。主分发逻辑始终保持稳定。
注册表还有一个隐藏收益:因为所有命令元信息都集中在一个数据结构里,我可以在外层生成帮助文档、命令列表甚至Zsh补全脚本。这些都是数据驱动的,不用为每个命令单独写一份说明。
3.2 三种适配器:脚本/函数/API一网打尽
适配器是CLI-Anything的执行引擎。我目前实现了三种,覆盖了日常九成以上的需求。
Shell适配器是基础款。它通过subprocess.run执行外部命令,支持shell=True的管道写法,也支持纯参数数组传递。
# adapters/shell.py import subprocess from typing import List def run_shell(target: str, args: List[str]) -> int: """执行Shell命令,返回退出码。""" cmd = [target] + args proc = subprocess.run(cmd, capture_output=True, text=True, shell=False) if proc.stdout: print(proc.stdout) if proc.stderr: print(proc.stderr, file=__import__("sys").stderr) return proc.returncodePython函数适配器更简单,本质上就是“调一个函数”。我把函数名存储在target里,运行时通过动态导入找到并调用它。这样做的好处是,任务函数可以享受Python生态的库,比如pandas处理数据、requests调API,业务逻辑不用被CLI层污染。
HTTP API适配器是重头戏,专门用来把内外部接口封装成命令。它会自动读取配置文件里的base_url和token,你只需要指定路径和方法。这样我就可以用cli-anything call api --name create_user --payload '{"name":"tom"}'来代替一长串curl命令。
三种适配器的选择逻辑封装在适配器工厂里,CLI层不需要关心对象到底是什么类型,只要传入adapter_type即可。新增第四种适配器,比如docker容器的远程执行,只需要新增一个类并在工厂里注册,就能被CLI-Anything使用。
3.3 参数解析与动态帮助文档
CLI-Anything的参数定义是声明式的,存在注册信息里。每个参数包含名称、类型、是否必填、默认值和帮助文本。
params = [ {"name": "name", "type": str, "required": True, "help": "用户名"}, {"name": "timeout", "type": int, "required": False, "default": 30, "help": "超时时间"}, ]Typer支持通过注解生成参数解析,但这里的难点是运行时才能确定有哪些参数。我采用动态生成子命令的方式,遍历注册表,为每个命令创建一个Typer命令函数,并把参数定义为可选参数或必需参数。这样用户输入cli-anything run script --help时,就能看到这个命令自己的帮助文档。
动态帮助文档这个功能极大提升了CLI的可用性。以前脚本参数记不住,还要翻源码;现在直接看帮助就行。而且因为帮助文档来源于注册信息,写注册表时顺手填好help字段,就能自动获得完善的文档。
3.4 统一输出与退出码规范
CLI工具最容易犯的毛病是输出格式混乱。有的脚本print一堆文本,有的直接没输出。我在CLI-Anything里统一了输出层:普通结果用Rich表格打印,需要机器读取时加--output json参数切换到JSON格式。
退出码也做了规范,这是自动化脚本非常依赖的一点。
| 退出码 | 含义 | 场景 |
|---|---|---|
| 0 | 成功 | 任务正常完成 |
| 1 | 业务失败 | 任务执行时目标对象返回错误 |
| 2 | 参数错误 | 用户传入了非法参数 |
| 3 | 目标不存在 | 注册表里找不到对应命令 |
| 4 | 适配器异常 | 比如网络超时、依赖缺失 |
我之前踩过一个大坑:Shell适配器处理完命令后,没有把子进程的退出码传出去,导致目标脚本抛错了,但上层看起来还是成功退出。现在适配器严格执行退出码传递,自动化流水线跑出来的结果是可信的。
4. 从零构建一个最小可用的CLI-Anything
4.1 初始化工程结构
先把基础环境搭起来。我建议用venv隔离,避免污染系统Python。
mkdir cli-anything && cd cli-anything python -m venv venv source venv/bin/activate pip install typer rich pyyaml requests然后创建目录结构和上面的Python文件。工程初始化阶段不用写很多代码,把pyproject.toml配好,让CLI可以被pip以开发模式安装。
# pyproject.toml [project] name = "cli-anything" version = "0.1.0" requires-python = ">=3.10" [project.scripts] cli-anything = "cli_anything.app:main" [tool.setuptools] packages = ["cli_anything", "cli_anything.adapters", "cli_anything.jobs"]配置好之后运行pip install -e .,终端里就能识别cli-anything命令了。这一步也就是文章开头说的“统一入口”基础。
4.2 注册表+子命令分发实现
接下来实现入口和注册表逻辑。app.py需要动态读取注册表,为每个命令生成一个独立的Typer子命令。
# app.py import typer from cli_anything.registry import get_all_commands from cli_anything.adapters import run_adapter app = typer.Typer() def _make_command(item): """为注册表里的每个命令动态生成一个执行函数。""" def command(**kwargs): result = run_adapter(item["adapter_type"], item["target"], kwargs, item.get("params", [])) typer.echo(result) return command @app.command() def run(name: str, **kwargs): """运行指定命令。""" for item in get_all_commands(): if item["name"] == name: fn = _make_command(item) fn(**kwargs) return typer.echo(f"命令 {name} 不存在,请检查 --help", err=True) raise typer.Exit(code=3) def main(): app()为了让代码清晰,我把动态参数生成放到一个辅助函数里,用户实际触发时用cli-anything run backup --target /data即可。初次实现时可以只保留run命令,后续再扩展其他入口。
4.3 HttpAPI适配器实现
HTTP API适配器是实用性最强的部分,我重点说一下。它的核心目标是让用户用CLI而不是curl去调用API。
# adapters/http_api.py import requests def call_http(base_url: str, path: str, method: str = "GET", token: str = None, params: dict = None): """统一HTTP API调用。""" headers = {} if token: headers["Authorization"] = f"Bearer {token}" url = f"{base_url}{path}" if method.upper() == "GET": resp = requests.get(url, headers=headers, params=params, timeout=30) else: resp = requests.post(url, headers=headers, json=params, timeout=30) return resp.status_code, resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text我用这个适配器注册了一个查询GitHub用户信息的命令,调用方式很直观。
cli-anything call api --name gh_user --param "username:octocat" # 返回 # 用户名: octocat # 公开仓库数: 8 # 粉丝数: 5823实际操作时要注意base_url和token统一从配置文件读取,避免每个API命令都重复写。我把它放在config.yaml里,运行时加载到全局配置对象。
4.4 任务编排与定时触发
CLI-Anything还有一个隐藏玩法:任务编排。我最初只想做收编,后来发现很多任务是“一串动作”的组合。比如备份任务,要先检查目录,再压缩文件,最后上传归档。与其写一个巨大的Shell脚本,不如拆成几个小命令,再用一个编排命令串联。
# jobs/backup.py from cli_anything.adapters.shell import run_shell def run_backup(source: str, dest: str): run_shell("mkdir -p", [dest]) run_shell("tar", ["-czf", f"{dest}/backup.tar.gz", source]) run_shell("mv", [f"{dest}/backup.tar.gz", f"{dest}/backup-final.tar.gz"])这样注册到CLI后,用户只需要执行cli-anything run script --name backup --param "source:/data --param dest:/backup",内部三个步骤自动完成。中间的失败检查点在编排函数里处理,某一步失败就直接抛异常,不会继续执行。
定时触发我接入了APScheduler,允许把某些命令挂到cron式时间表上。比如每天凌晨两点执行备份,就执行cli-anything schedule add --cron "0 2 * * *" --command "backup"。这部分适合放在后台服务里运行,权当给CLI扩展了定时能力。
4.5 一条命令拉起整套服务
最后演示一个综合场景。我经常需要本地启动三个服务:后端API、前端静态服务、数据库。过去要开三个终端,现在我把它们编排成一个dev group。
# jobs/dev.py def start_dev(): from cli_anything.adapters.shell import run_shell import subprocess procs = [] procs.append(subprocess.Popen(["python", "api.py"])) procs.append(subprocess.Popen(["npm", "run", "dev"])) procs.append(subprocess.Popen(["docker", "compose", "up"])) for p in procs: p.wait()注册后,一个命令搞定整套环境启动。这里我没有并行捕获日志,日志直接打到终端,开发时看实时输出反而更直观。这样的编排场景,是CLI-Anything真正体现“Anything”价值的地方——它不关心你背后是什么技术栈,只要能执行,就能被收编。
5. 常见问题与排查技巧实录
5.1 命令找不到与环境变量
我在另一台服务器装上CLI-Anything后发现,新开的终端窗口执行cli-anything却报command not found。排查了一下,原因是开发模式下pip安装路径没有加入PATH。
解决方式有两种。一种是确认venv激活,再执行pip install -e .,安装时输出会显示脚本安装路径;另一种是给命令加一个Python模块入口兜底,永远可以用python -m cli_anything触发。
# pyproject.toml 增加 [tool.setuptools] scripts = [] [project.scripts] cli-anything = "cli_anything.app:main" cli-anything-py = "cli_anything.app:main"更稳妥的方案是把cli-anything做成了软链接到本地bin目录,同时在文档里写明“如果找不到命令,先执行python -m cli_anything --help”。团队里有人装完忘了激活venv,这类问题大概率很快就能定位。
5.2 启动太慢:懒加载才是本体
CLI-Anything跑了一段时间后,我发现简单的--help也要等一秒多。原因很简单:app.py里导入了所有adapters,而adapters里又import了requests、rich、subprocess等一堆模块。实际上帮助命令根本不需要加载这些重型依赖。
修复方法是把适配器的导入时机从“启动时”改成“执行时”。注册表里只存字符串标识,运行时再动态导入对应模块。
# adapters/__init__.py import importlib _ADAPTERS = { "shell": "cli_anything.adapters.shell", "python_func": "cli_anything.adapters.python_func", "http_api": "cli_anything.adapters.http_api", } def run_adapter(adapter_type: str, target: str, kwargs: dict, params: list): module = importlib.import_module(_ADAPTERS[adapter_type]) func = getattr(module, "run") return func(target, kwargs, params)改完之后,启动时间从大约1.2秒降到0.18秒。实测感受非常明显,频繁敲命令时不再有卡顿感。懒加载对CLI工具来说不是优化项,而是基本项。
5.3 API请求超时与重试
HTTP API适配器上线后,我发现偶尔会碰到一次性超时的情况。内网服务偶尔抖动,直接报错用户会觉得不好用。我给API调用加了重试机制,但重试不是无脑加,只在GET和幂等POST上启用。
import tenacity @tenacity.retry( stop=tenacity.stop_after_attempt(3), wait=tenacity.wait_exponential(multiplier=1, min=1, max=10), retry=tenacity.retry_if_exception_type(requests.Timeout), ) def _http_get(url, headers, params): resp = requests.get(url, headers=headers, params=params, timeout=20) resp.raise_for_status() return resp十秒钟的重试成本我觉得可以接受。但要用这功能,一定要想清楚接口是否幂等,否则重复提交订单这类操作会出大问题。我只在查询类命令里开了重试,写入类命令明确不重试只报错。
5.4 退出码被吞的坑
退出码被吞是我很早就遇到过的问题,但值得反复强调。ShellAdapter如果用subprocess.run(cmd)后不检查returncode,目标命令失败时CLI可能仍然返回0,上层自动化流程完全不知道失败。
这个问题让一次备份任务在日志里显示“成功”,但实际备份文件没生成。排查到最后才发现是退出码传递缺失。现在我的ShellAdapter强制结束:
sys.exit(proc.returncode)同时把stdout和stderr分开打印,错误信息不会被Rich美化得看不出重点。CLI工具对退出码的敬畏,应该排在输出美化之前。
6. 我的一些个人心得
6.1 我踩过最大的坑:一开始想设计成插件平台
第一版CLI-Anything我用了大量抽象,想做成一个插件平台,让每个人都能写插件。结果架子搭得很漂亮,功能却迟迟没落地,最后真正能用的命令没有几条。后来我把需求收敛成“先收编我自己最常用的五个命令”,只用了半天就把工具跑了起来。
这个教训很深刻:先把真实任务跑通,再去想抽象和扩展。CLI-Anything现在虽然有很多适配器,但我前期是靠“接一个真实需求、沉淀一个适配器”的方式慢慢长出来的。任何工具一开始就追求大而全,基本都活不过第一周。
6.2 分享一个非常管用的小技巧
给CLI-Anything加一个全局--dry-run参数,执行任何命令时都只打印“将要执行什么”,不真正执行。
cli-anything run backup --source /data --dest /backup --dry-run # 输出: # [DRY-RUN] tar -czf /backup/backup.tar.gz /data # [DRY-RUN] mv /backup/backup.tar.gz /backup/backup-final.tar.gz这个参数在调试编排任务时简直救命。以前我改完备份流程要真跑一遍才知道顺序对不对,现在dry-run一眼就能看出命令参数有没有传错。我也建议在注册表里增加一个dry_run_safe字段,让单个命令自己声明是否支持试运行,有些写了就很难回滚的操作,比如删除命令,默认就禁止dry-run模拟。
6.3 后续还能怎么扩展
CLI-Anything的下一步,我想把注册表导出成JSON,这样可以让团队里其他工具消费同一个命令清单;同时计划支持远程适配器,让CLI转发到另一台机器执行。不过以我现在的经验来看,扩展点不在多,而是把当前这套收编逻辑做扎实。
如果你也想搭一个CLI-Anything,我的建议是:别从框架开始,从你明天就要用到的三条命令开始。把最痛苦的操作收编进来,用着顺手了再慢慢加。命令行这东西,真正实用的不是复杂的设计,而是每次敲命令时减少的那几秒等待。