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(group="cli_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} 重复,已忽略后加载者", err=True) continue _commands[cmd_name] = command except Exception as exc: # noqa: BLE001 click.echo(f"加载插件 {ep.name} 失败: {exc}", err=True) 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, name=name) 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, params=params, headers={"User-Agent": UA}, timeout=10, follow_redirects=True) resp.raise_for_status() return resp.text def parse_results(html: str, engine: str): if engine in ("bing", "baidu"): # 简易版:抓取结果标题和链接 pattern = re.compile(r'<a[^>]+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(r'<a[^>]+class="result__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", default="bing", help="搜索引擎") @click.option("--count", "-n", default=5, 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", default="GET", help="HTTP 方法") @click.option("--data", "-d", default=None, help="POST JSON 数据或 key=value") @click.option("--header", "-H", multiple=True, help="自定义请求头,可传多次") @click.option("--timeout", default=10, help="超时秒数") @click.option("--json-out", is_flag=True, 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(headers=headers, timeout=timeout, follow_redirects=True) as client: resp = client.request(method.upper(), url, json=body) if json_out: out = { "status": resp.status_code, "headers": dict(resp.headers), "body": resp.text } click.echo(json.dumps(out, ensure_ascii=False, indent=2)) return click.echo(f"HTTP {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_ascii=False, indent=2)) 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/pbpaste,Linux 用 xclip,Windows 用 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_output=True, text=True, check=True).stdout if sys.platform == "linux": return subprocess.run(["xclip", "-selection", "clipboard", "-o"], capture_output=True, text=True, check=True).stdout # Windows import ctypes return None # 实际使用 PowerShell Get-Clipboard 更可靠 @click.group() def clip_cmd(): """剪贴板片段管理。""" @clip_cmd.command() @click.option("--tag", "-t", default="general") def save(tag): """把当前剪贴板内容存为一个片段。""" CLIP_DIR.mkdir(parents=True, exist_ok=True) 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, encoding="utf-8") click.echo(f"已保存: {CLIP_DIR / fname}") @clip_cmd.command() @click.option("--latest", is_flag=True) def list(latest): """列出已保存的片段内容。""" files = sorted(CLIP_DIR.glob("*.txt"), reverse=True) if latest: files = files[:1] for f in files: click.echo(f"=== {f.name} ===") click.echo(f.read_text(encoding="utf-8")[:200])psx是另一个高频命令:查端口占用、反查进程名、杀掉指定进程。这个命令在排查服务器问题时几乎是救命神器。核心逻辑是跨平台调用系统命令并解析输出:
# cli_anything/commands/psx.py import subprocess import re import os import click @click.command() @click.argument("port", type=int) @click.option("--kill", is_flag=True, help="直接结束占用进程") def psx_cmd(port, kill): """查找占用指定端口的进程。""" if os.name == "nt": cmd = f'netstat -ano | findstr :{port}' result = subprocess.run(cmd, shell=True, capture_output=True, text=True) 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", f"PID eq {pid}"], capture_output=True, text=True) 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_output=True, text=True) 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(f"killing {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默认 text=True 会按系统区域设置解码,在中文系统下一般是 GBK,这倒还行。真正乱的是:如果你用 PowerShell 调用了Get-Clipboard,PowerShell 输出的编码取决于控制台代码页,可能和 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", errors="replace")在实际代码里,凡是读取系统命令输出的地方,如果没有十足的把握,宁可拿capture_output=True然后用.stdout字节按候选编码依次尝试解码,也不要直接相信 text=True。这个问题在 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 httpx,search同样如此。代价是每个子命令首次调用稍慢,但基本只慢一次。第二,为“命令名到插件函数对象”建立文件名级缓存。启动时只扫描 entry_points 的字符串信息,不触发插件模块的 import;只有用户敲了具体命令名,才真正加载对应模块。这样启动耗时从约 600ms 降到了约 150ms,体感上完全够快了。
5.2 shell 集成:alias、补全与一键直达
光有命令还不够,终端工具必须和 shell 生态结合。我在.bashrc/.zshrc里加了几行:
alias a=anything # 帮助信息里的常见用法 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 的价值从来就不是“功能全”,而是“常驻手边、想用就有”。这个取舍标准,是我在整个实践过程中最想分享给同样在折腾命令行工具的朋友的一句话。