CLI-Anything:打造插件化命令行聚合工具的关键实践 1. 为什么我会执意做一个叫 CLI-Anything 的东西先说个场景我日常一大半时间都泡在终端里但真正处理事情时却要反复跳出跳入浏览器搜资料、微信收文件、Postman 调接口、备忘录记零散想法、系统设置里翻网络配置。窗口来回切换的撕裂感比敲命令本身更消耗注意力。有段时间我甚至数过一个早上能切出去上百次每次切换都会打断心流。于是我开始琢磨一个很朴素的问题能不能把日常这些零碎但高频的操作全部收敛到一个命令行入口里这就是 CLI-Anything 的雏形。CLI-Anything 并不是要做一个无所不能的重型工具它更像一个“杂货铺式”的命令行聚合入口安装完主体后按需注册各种子命令插件搜索、查文档、发请求、记片段、查端口、管文件统统一句话搞定。核心价值有三点一是减少上下文切换不离开终端就能完成绝大多数轻量任务二是所有操作天然可脚本化昨天手点的操作今天写成一行命令就能批量执行三是操作链路留在终端历史里可回看、可复现、可审计。适合谁用如果你经常折腾终端、有一堆重复性操作想自动化或者只是受够了在各窗口之间来回横跳CLI-Anything 这条思路值得你借鉴。需要先说明的是这篇文章里我会把 CLI-Anything 当作一个“自己在维护和使用的开源小项目”来完整复盘包括整体设计思路、关键代码实现、踩过的坑和优化经验。你可以把它理解为一份真实项目总结也可以当一条“如何构建自己专属 CLI 聚合工具”的完整路线图。文章里所有代码和配置都来自实际可用版本照抄之后稍作修改就能跑起来。2. “什么都往里塞”的架构反思约定优于配置刚开始我踩过一个典型弯路把功能一股脑塞进一个 main.py几百行之后自己都不想维护。后来痛定思痛重新设计了插件化架构。核心思路可以总结成四个字约定优先。即只要你按约定放好文件、导出指定的对象CLI-Anything 会自动发现并注册子命令新增功能时完全不需要改动主程序入口。2.1 三个必须坚持的设计目标在动手写代码前我先给自己定了三条硬性约束后面所有决策都围绕它们展开。第一插件之间必须完全隔离。每个子目录都可以被单独禁用或移除不得互相 import共享逻辑以公共库的方式提供。这样即便某个插件质量粗糙甚至报错也只是少一个子命令不会让整个 CLI 崩溃。第二所有子命令的参数要声明式定义。主框架不关心插件内部逻辑只知道“你注册了几个参数、每个参数叫什么、长什么样”。这样做的好处是后续扩展 shell 补全、生成帮助文档、甚至做参数校验时都能统一处理不用每一处各写一套。第三输出格式主体统一、细节可自定义。理想情况是所有子命令默认输出纯文本需要结构化时就输出 JSON这样既能给人看也能给脚本吃典型场景直接作为管道命令使用。2.2 目录结构与插件加载机制的演进目前的目录布局长这样cli-anything/ ├── pyproject.toml ├── cli_anything/ │ ├── __init__.py │ ├── registry.py # 插件注册表 │ ├── core_ui.py # 颜色、表格、缩进等统一输出 │ ├── commands/ # 内置核心命令 │ │ ├── __init__.py │ │ ├── search.py │ │ ├── req.py │ │ ├── clip_memo.py │ │ └── psx.py │ ├── plugins/ # 用户扩展插件目录 │ │ └── example_plugin.py │ └── config_store.py # 统一配置读写 ├── config/ │ └── config.yaml └── tests/插件加载机制经历了三个版本。第一版粗暴扫描 plugins 目录用 importlib 逐个 import结果项目变大后启动时间明显变长因为每个插件都连带加载了一堆第三方库。第二版改成懒加载只在命令实际被调用时才 import启动速度提上去了但带来了热重载时文件句柄占用的问题。第三版就是我一直在用的方案包元数据发现也就是通过 entry_points 做动态发现这样插件可以安装在 site-packages 里也可以放在用户目录的 plugin 路径下主程序只负责收集入口、注册成 click 子命令组。懒加载仍然保留且不会为了“发现”而去执行插件模块。2.3 统一配置宁可戴着镣铐跳舞CLI 聚合工具最怕什么最怕每个插件各写各的配置文件东一个 .json 西一个 .ini最后连自己都忘了配置文件散落在哪。CLI-Anything 把配置集中到一个主目录下默认是~/.config/cli-anything/config.yaml。主体只保留几条通用配置比如默认 region、语言偏好、超时时间各个插件可以在自己的子命名空间里读写键值对。顶层结构很干净# config.yaml app: timeout_seconds: 5 output_style: plain language: zh-CN plugin_config: search: default_engine: bing result_count: 5 req: default_headers: user-agent: Mozilla/5.0 CLI-Anything注意这里有个设计细节插件配置统一挂在plugin_config节点下而不是每个插件自己新建文件。好处是管理集中了坏处是配置文件会随着插件增多而膨胀。我的处理方式是主配置只保存“会被命令行参数覆盖的默认值”不保存任何临时状态临时状态一律放缓存目录。比如搜索历史、请求缓存这些都写到~/.cache/cli-anything/下方便清理。3. 关键实现从最小骨架到第一批高频子命令这章直接上代码。我会按一条完整的实现链路来讲先搭建插件注册表再实现统一入口然后逐个加入几个最具实用价值的子命令。你可以把这一节当成“从零复刻 CLI-Anything 核心底座的实操手册”。3.1 先搭一个能跑的动态注册表注册表是整个工具的“地基”。它要回答的问题很简单用户在终端输入anything search python时程序怎么知道search对应哪个函数我选择了 click 生态来做命令解析因为 click 的装饰器风格特别适合声明式参数定义。核心代码长这样# cli_anything/registry.py from __future__ import annotations import importlib.metadata as metadata from typing import Callable import click _commands: dict[str, Callable] {} def discover_all(): 通过 entry_points 发现所有插件命令注册到全局表。 global _commands eps metadata.entry_points() # select 语法适配不同 Python 版本 if hasattr(eps, select): entries eps.select(groupcli_anything.commands) else: entries eps.get(cli_anything.commands, []) for ep in entries: try: module_path, attr_name ep.value.split(:, 1) module __import__(module_path, fromlist[attr_name]) command getattr(module, attr_name) cmd_name ep.name if cmd_name in _commands: click.echo(f警告: 命令 {cmd_name} 重复已忽略后加载者, errTrue) continue _commands[cmd_name] command except Exception as exc: # noqa: BLE001 click.echo(f加载插件 {ep.name} 失败: {exc}, errTrue) def get_click_group() - click.Group: 构建 click.Group把所有插件命令挂载进去。 click.group() click.version_option() def cli(): CLI-Anything: 一个把日常操作收拢到命令行的小工具。 for name, cmd in _commands.items(): cli.add_command(cmd, namename) return cli注意两点。第一metadata.entry_points()在 Python 3.10 之前和之后的 API 有差异我上面写了兼容分支实际使用时可以先判断版本。第二捕获异常范围很大这是有意为之CLI 工具最忌讳某一个坏插件导致整个工具不可用宁可跳过它也要让主体能启动。3.2 用 pyproject.toml 把插件入口串起来动态注册的前提是安装时要把插件入口声明到包的 metadata 里。假如我把内置命令放在随包发布的模块中就在主项目pyproject.toml里这样声明[project.entry-points.cli_anything.commands] search cli_anything.commands.search:search_cmd req cli_anything.commands.req:req_cmd clip cli_anything.commands.clip_memo:clip_cmd psx cli_anything.commands.psx:psx_cmd对于第三方插件开发者他们只需要在自己的项目里加同样的 entry-points 配置安装后 CLI-Anything 启动扫描时就会自动发现。这种机制比手动往 plugins 目录丢文件要规范很多因为它正确处理了安装、卸载、版本依赖这些事。最开始我用手动复制 plugin.py 到目录的办法结果升级主体时插件路径经常变得乱七八糟现在全部交给包管理器几乎没有再出过问题。3.3 子命令一聚合搜索加结果摘要搜索是所有人最高频的需求。CLI-Anything 的第一个子命令就是search。它做的事情不复杂接受查询词请求某个搜索引擎的 HTML 结果页用正则抽取搜索结果然后紧凑地输出标题、链接和摘要。为什么不用官方搜索 API因为官方 API 大多要注册申请而网页版对于“快速查个资料”的场景已经够用。实现时注意 User-Agent 要伪装成浏览器否则很容易被反爬挡住# cli_anything/commands/search.py import re import httpx import click UA (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36) def fetch_html(query: str, engine: str, count: int) - str: if engine bing: url https://www.bing.com/search elif engine baidu: url https://www.baidu.com/s elif engine duckduckgo: url https://html.duckduckgo.com/html/ else: raise click.UsageError(f不支持的搜索引擎: {engine}) params {q: query, count: str(count)} resp httpx.get(url, paramsparams, headers{User-Agent: UA}, timeout10, follow_redirectsTrue) resp.raise_for_status() return resp.text def parse_results(html: str, engine: str): if engine in (bing, baidu): # 简易版抓取结果标题和链接 pattern re.compile(ra[^]href([^])[^]*(.*?)/a, re.S) raw pattern.findall(html) results [] for link, title_html in raw[:8]: title re.sub(r[^], , title_html).strip() if title and link.startswith(http): results.append({title: title, link: link}) return results # duckduckgo 的 html 版结构更规整可以直接用 result 容器 results [] for item in re.findall(ra[^]classresult__a[^]href([^])[^]*(.*?)/a, html, re.S): link, title_html item results.append({link: link, title: re.sub(r[^], , title_html).strip()}) return results click.command() click.argument(query) click.option(--engine, -e, defaultbing, help搜索引擎) click.option(--count, -n, default5, help结果数量) def search_cmd(query, engine, count): 搜索并返回简明的网页结果列表。 html fetch_html(query, engine, count) results parse_results(html, engine) if not results: click.echo(没有抓到结果可能被反爬拦截了稍后再试。) return for i, r in enumerate(results, 1): click.echo(f{i}. {r[title]}) click.echo(f {r[link]})实测下来bing 的结果结构最稳定duckduckgo 的 html 版结局次之百度偶尔会出现短时段反爬所以我默认引擎用的 bing。你如果拿这个代码直接跑记得先pip install httpx pytest另外别频繁搜同一关键词不然很容易被临时限制。3.4 子命令二通用 HTTP 请求与格式化输出后端调试、接口验证这类操作很多人习惯打开 Postman但命令行其实更顺手。CLI-Anything 内置的req子命令设计目标不是取代 Postman而是满足“在终端里快速发一个 GET/POST看返回头、看 JSON 摘要”的场景。复用 httpx代码量压缩得很小# cli_anything/commands/req.py import json import click import httpx click.command() click.argument(url) click.option(--method, -m, defaultGET, helpHTTP 方法) click.option(--data, -d, defaultNone, helpPOST JSON 数据或 keyvalue) click.option(--header, -H, multipleTrue, help自定义请求头可传多次) click.option(--timeout, default10, help超时秒数) click.option(--json-out, is_flagTrue, help以 JSON 格式输出全部信息) def req_cmd(url, method, data, header, timeout, json_out): 发送 HTTP 请求并格式化展示响应。 headers {} for h in header: k, _, v h.partition(:) if k.strip(): headers[k.strip()] v.strip() body None if data: # 尝试解析成 JSON失败则按表单处理 try: body json.loads(data) except json.JSONDecodeError: body dict(item.split(, 1) for item in data.split()) with httpx.Client(headersheaders, timeouttimeout, follow_redirectsTrue) as client: resp client.request(method.upper(), url, jsonbody) if json_out: out { status: resp.status_code, headers: dict(resp.headers), body: resp.text } click.echo(json.dumps(out, ensure_asciiFalse, indent2)) return click.echo(fHTTP {resp.status_code} {resp.reason_phrase}) for k, v in resp.headers.items(): click.echo(f{k}: {v}) click.echo(---) # 如果响应是 JSON则格式化输出否则截断输出前 500 字符 try: parsed resp.json() click.echo(json.dumps(parsed, ensure_asciiFalse, indent2)) except Exception: click.echo(resp.text[:500])这个命令的最大好处是“可拼接”你可以把它的结果通过--json-out输出再交给 jq 做过滤能完成不少本来要写脚本才能做的事情。比如查一个接口的健康状态一行命令就可以完成anything req https://api.example.com/health -m GET --json-out | jq .status3.5 子命令三与四剪贴板备忘与端口进程定位搜索和请求属于“信息获取”接下来两个命令更偏“日常效率”。clip负责把剪贴板内容存成带时间戳的片段文件也可以反向把某条片段重新复制回剪贴板。最轻量的实现就是利用系统剪贴板命令mac 上用 pbcopy/pbpasteLinux 用 xclipWindows 用 clip/Get-Clipboard# cli_anything/commands/clip_memo.py import subprocess import sys import datetime from pathlib import Path import click CLIP_DIR Path(~/.cache/cli-anything/clips).expanduser() def current_clip(): if sys.platform darwin: return subprocess.run([pbpaste], capture_outputTrue, textTrue, checkTrue).stdout if sys.platform linux: return subprocess.run([xclip, -selection, clipboard, -o], capture_outputTrue, textTrue, checkTrue).stdout # Windows import ctypes return None # 实际使用 PowerShell Get-Clipboard 更可靠 click.group() def clip_cmd(): 剪贴板片段管理。 clip_cmd.command() click.option(--tag, -t, defaultgeneral) def save(tag): 把当前剪贴板内容存为一个片段。 CLIP_DIR.mkdir(parentsTrue, exist_okTrue) text current_clip() if text is None or not text.strip(): click.echo(剪贴板为空不做保存) return fname datetime.datetime.now().strftime(%Y%m%d_%H%M%S) f_{tag}.txt (CLIP_DIR / fname).write_text(text, encodingutf-8) click.echo(f已保存: {CLIP_DIR / fname}) clip_cmd.command() click.option(--latest, is_flagTrue) def list(latest): 列出已保存的片段内容。 files sorted(CLIP_DIR.glob(*.txt), reverseTrue) if latest: files files[:1] for f in files: click.echo(f {f.name} ) click.echo(f.read_text(encodingutf-8)[:200])psx是另一个高频命令查端口占用、反查进程名、杀掉指定进程。这个命令在排查服务器问题时几乎是救命神器。核心逻辑是跨平台调用系统命令并解析输出# cli_anything/commands/psx.py import subprocess import re import os import click click.command() click.argument(port, typeint) click.option(--kill, is_flagTrue, help直接结束占用进程) def psx_cmd(port, kill): 查找占用指定端口的进程。 if os.name nt: cmd fnetstat -ano | findstr :{port} result subprocess.run(cmd, shellTrue, capture_outputTrue, textTrue) lines result.stdout.strip().splitlines() for line in lines: click.echo(line) pids {int(m) for m in re.findall(r\s(\d)\s*$, result.stdout)} for pid in pids: info subprocess.run([tasklist, /FI, fPID eq {pid}], capture_outputTrue, textTrue) click.echo(info.stdout.strip()) if kill: subprocess.run([taskkill, /PID, str(pid), /F]) return cmd [lsof, -i, f:{port}, -P] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: click.echo(f端口 {port} 未被占用) return click.echo(result.stdout.strip()) if kill: pids re.findall(r\s(\d)\s, result.stdout) for pid in pids: click.echo(fkilling {pid}) subprocess.run([kill, -9, pid])这些小命令单看都很简单但正是因为统一收敛到了 CLI-Anything 入口才让“不动鼠标查端口”“顺手存一条灵感”成为肌肉记忆。4. 踩坑实录动态加载、参数冲突与子进程编码写这个项目的过程里真正让我长记性的不是功能本身而是几个潜伏很深的坑。这些坑单独拿出来都很典型值得一条条复盘。4.1 插件重名被静默覆盖的问题第一个坑在我实现注册表早期就踩了。当时发现一个诡异现象明明两个插件都注册了ping子命令但执行时永远只走其中一个另一个毫无提示。排查过程花了一个晚上。我最初怀疑是 entry_points 里同名入口只能存在一个于是打印了 metadata 的数据发现两个入口确实同时存在问题在我的注册表逻辑_commands[cmd_name] command后来的覆盖了先前的没有任何警告。这是我第一版设计的缺陷修复方案也简单注册时判断重名并打印警告然后拒绝覆盖如上文代码所示。但这件事让我意识到另外一个更隐层的问题就是“安装包 A 和安装包 B 同时定义了相同入口名”这个场景靠警告还不够。后来我又加了一层优先级机制内置命令优先级最高用户插件次之高优先级可以覆盖低优先级但必须通过显式配置开启。默认安全显式覆盖。这个设计后来在团队里很受欢迎因为不同插件确实偶尔对同一个动词有不同理解。4.2 click 参数和插件内部 kwargs 的命名冲突第二个坑是参数命名冲突。插件 A 在函数里用ctx作为参数插件 B 也用ctx这本身没问题因为它们是独立的 click command。问题出在我的公共库封装上我把click.argument生成的值和自定义管道对象一起塞给了某个公共处理函数函数签名是def handle(ctx, **kwargs)当插件作者恰好也定义了一个名为ctx的 option 时click 内部会把用户输入值传给函数里的ctx和公共库里的会话对象就撞车了。排查这个问题的链路很典型先是复制了一个最小复现脚本发现只要把 option 改名问题消失再读 click 的源码发现 click 默认把命令行参数绑定到 Python 函数参数名字上它不管你那个名字在函数里原本会被谁使用。解决方案是我们的公共库全面改名任何内部保留参数统一加前缀比如_any_ctx、_any_quiet并且写进 README 的插件开发规范里。这个教训我记到现在做框架/聚合工具时自己的占位参数要第一时间加上独特前缀别抢用户命名空间。4.3 Windows 下子进程输出乱码GBK 与 UTF-8 的拉扯跨平台支持中最折磨人的是编码问题。psx命令在 Windows 下跑netstat -ano拿到的输出经常是 GBK 编码而 Python 的subprocess默认 textTrue 会按系统区域设置解码在中文系统下一般是 GBK这倒还行。真正乱的是如果你用 PowerShell 调用了Get-ClipboardPowerShell 输出的编码取决于控制台代码页可能和 Python 期望不一致于是经常出现“保存下来的片段打开一看中文全变成了锟斤拷”。处理思路很简单但细节很多import sys def _decode_user_text(raw: bytes) - str: for enc in (utf-8, gb18030, latin-1): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace)在实际代码里凡是读取系统命令输出的地方如果没有十足的把握宁可拿capture_outputTrue然后用.stdout字节按候选编码依次尝试解码也不要直接相信 textTrue。这个问题在 Linux/mac 上几乎不会出现但 Windows 用户一多反馈的 issue 大半都集中在编码上。4.4 热重载导致的文件句柄泄漏最后一坑和开发调试相关。早期支持--reload参数用于修改插件后自动重新加载命令。实现方式是扫描 entry_points 后保存模块路径和时间戳发现变化就重新 import并替换_commands表里的命令对象。看着没问题实际跑起来却有一个隐藏泄漏被重新 import 的模块里如果打开了配置文件或网络连接旧模块对象被垃圾回收时文件句柄不一定立刻释放一天重复几十次后文件描述符就爆了。我当时解决得很“土”不再重新 import而是提醒用户重启终端进程。后来有空又复盘发现最稳妥的方案其实是把插件当作独立 Python 进程的入口每次调用子命令时 fork 出子进程执行。但这样一来很多共享内存状态就不能读了复杂度上升一截。最后一版我选择了中间路线开发模式默认不热重载生产模式完全没有热重载需求。这个坑教会我的不是“怎么实现热重载”而是不是所有功能都值得加复杂度守恒你加减在哪里要选清楚。5. 性能调优与把这套工具融入日常的操作细节CLI 工具的价值最终要体现在“顺手”上。如果一个命令启动要 300ms可能还有人忍但超过 800ms大部分人就会退回旧习惯。我做了几项关键调优也总结了一些实际使用中很有用的操作细节。5.1 启动时延的压制延迟导入与索引缓存起初 CLI-Anything 启动很慢原因一目了然入口处 import 了整个 click、httpx、yaml 等一堆库。我用python -X importtime -c from cli_anything.cli import cli统计发现 httpx 一个库就占掉近 200ms。于是做了两件事第一所有重量级第三方库都移到子命令函数内部再导入。比如req子命令真正发请求前才 import httpxsearch同样如此。代价是每个子命令首次调用稍慢但基本只慢一次。第二为“命令名到插件函数对象”建立文件名级缓存。启动时只扫描 entry_points 的字符串信息不触发插件模块的 import只有用户敲了具体命令名才真正加载对应模块。这样启动耗时从约 600ms 降到了约 150ms体感上完全够快了。5.2 shell 集成alias、补全与一键直达光有命令还不够终端工具必须和 shell 生态结合。我在.bashrc/.zshrc里加了几行alias aanything # 帮助信息里的常见用法 compdef _anything anything # zsh 下开启补全 eval $(anything --completion-script-zsh) # 或 bash 类似另外我把最常用的固定套路做成了 shell 函数比如查端口port_find() { any psx $1 }如果说 alias 解决的是“少打几个字”的问题那绑定快捷键解决的就是“打开终端就想用”的问题。我自己在 tmux 里配置了快捷键Ctrl b 后按 c 新建窗口时自动执行anything显示帮助相当于把 CLI-Anything 变成每次进入终端会话后的“首页导航”。这招深受我们团队欢迎因为很多不常敲命令的人至少能从这个导航里发现原来自己有这么多工具可用。5.3 团队分享时最有用的一个技巧自举管理开发到中后期我开始用 CLI-Anything 管理 CLI-Anything 自己。具体做法是把“自定义 alias 清单”“插件开发规范”“允许覆盖的命令列表”都存成普通片段文件归到一个 tag 为anything-meta的目录下。然后写了一个极简的管理子命令专门对这些元文件做版本打点和回看。这样团队新成员加入时不需要我去讲文档直接跑一条anything clip --tag anything-meta list就能看到过去沉淀下来的所有约定和模板。我常说好用的工具不在功能清单上多牛而在“你自己每天都在用”这个事实上。CLI-Anything 现在已经是我打开电脑后第一个敲的命令也是我关电脑前最后一个留在终端里的进程。5.4 后续扩展方向与我的个人取舍这个项目目前还在继续演进。我下一步想做的是统一的通知回调子命令执行完后如果设置了--notify参数就把结果摘要推到系统通知中心这样在跑长任务时可以去干别的事。另一个方向是搜索插件的自定义站点适配框架比如默认支持仓库内文档、公司内部 Wiki、技术博客站点做成可插拔的“源”定义。我特别谨慎的是不做 GUI、不做远程控制、不做复杂的权限模型因为这三个方向会让项目失去“轻量聚合入口”的初心。对自己来说CLI-Anything 的价值从来就不是“功能全”而是“常驻手边、想用就有”。这个取舍标准是我在整个实践过程中最想分享给同样在折腾命令行工具的朋友的一句话。