CLI-Anything:统一所有命令行工具入口的插件化设计
发布时间:2026/9/28 17:17:39来源:尧图网络
说实话我在自己终端里摸爬滚打这些年最烦的就是记参数、切窗口、找入口。我电脑上常年躺着七八个不同形态的工具有的有图形界面有的得开浏览器有的是一段没人敢动的 shell 脚本还有的只能在特定目录下运行。每次发版要同时在三个窗口来回切光是回忆这个工具的日志用哪个参数就要花掉不少时间。后来我花了一个周末写了一个叫CLI-Anything的小工具——它做的事很简单把所有杂七杂八的入口都收编到一个命令后面你只需要记住anything一个词后面跟什么由它帮你分发到对应的工具上。这篇文章就是把这个项目的设计思路、骨架实现和一些踩坑记录完整摊开给想统一开发工具链、给团队搭统一命令入口的读者做个参考。1. 为什么我执意要把所有工具都收编成命令行1.1 我的终端里堆积了十几种工具入口先说我当时的具体困境。日常工作里我需要用到这些入口Docker 容器管理需要docker ps、docker logs、docker compose一系列子命令一堆部署脚本分散在三个项目的scripts/目录里每个都有不同的参数风格SSH 登录测试服务器还要记住几台机器的别名一个内部系统的 HTTP 接口平时用 curl 手敲 JSON 请求数据库客户端用来跑查询和导出数据日志系统要么开网页要么用一段外部脚本配合 grep还有大量一次性运维命令比如清理磁盘、拉取最新镜像、备份数据。真正的问题不仅是数量多而是每个工具的使用方式完全不同。Docker 有自己的子命令体系部署脚本接受--env参数curl 要拼整个 URL数据库客户端又有自己的交互方式。时间一长我把大部分精力都花在了回忆这个工具怎么调上而不是做事本身。更要命的是这些命令的依赖环境也各不相同。有的必须在项目根目录运行否则找不到配置文件有的要设置好几个环境变量才能跑通还有的依赖一个很老的 Python 版本。我自己都经常搞错更别提交给新人。1.2 CLI-Anything 想解决的那几件事做 CLI-Anything 之前我列了一份需求清单其实核心就四条统一入口不管底层是脚本、API、SSH 还是数据库查询用户都从anything命令进入统一帮助所有可用的命令一条命令就能看全不用再翻 README统一日志格式无论哪个工具被调用输出的痕迹、错误格式都一致方便排查统一退出码底层工具成功失败CLI-Anything 必须原样传递退出码这样 CI 脚本里才能正确判断。这个思路跟做一个大而全的工具完全不同。我的目标不是重新实现 Docker、SSH 或者日志系统而是做一层入口封装——让调用方式变成统一的动词 工具名 参数模型。底层的东西还是原来的那些工具只是它们的入口被收拢了。这个定位特别适合两类人一类是像我这样工具散落一地的个人开发者想快速形成自己的命令库另一类是团队负责人想把研发流程沉淀成一个统一 CLI让新人少踩坑。2. 基础设计一个入口接管所有命令背后的三个关键取舍2.1 三种接管的典型做法我为什么选插件化在动手之前我实际上考虑了三种方案。第一种是最直白的把所有调用逻辑硬编码进一个脚本。比如在一个cli.py里写满if command docker: ...。这种做法对小项目很直观但每接一个新工具就要改主程序分支越来越多代码越来越难维护。我上一版工具就是这么写的后来接第十个命令的时候实在受不了了。第二种是配置驱动用一个 YAML 文件声明所有命令入口脚本只负责读取配置并执行。这样做的好处是加工具不用改代码但很快就发现命令的真实逻辑往往比配置能表达的复杂得多。比如有的命令执行前要检查环境变量有的要选择不同的子命令路径。硬塞进配置里YAML 会变得越来越不像配置而是在写一门自定义编程语言。第三种就是插件化每个工具一个目录目录里放一个入口文件暴露统一的注册和执行接口。主程序启动时自动扫描目录把每个插件注册到命令表里。新增工具只需要丢一个文件夹进来不改主程序不影响其他插件。我最终选了插件化。对比一下三个方案方案可维护性扩展难度上手成本适用场景硬编码低低最低命令少于 5 个的临时脚本配置驱动中中中命令逻辑简单、参数固定的场景插件化高高较高工具持续增加、逻辑各有不同的长期项目配置驱动的方案表面看起来最省事但它的瓶颈在于配置只能表达数据不能表达逻辑。而插件化的代价是要先定好一套接口规范最初的开发成本高一些。但后面每接一个新工具成本会稳定降低这正是我需要的。2.2 技术栈对比Python 与 Node.js 的取舍选语言也是个值得说说的决定。CLI 工具用 Python 和 Node.js 都很常见我对比过这两条路维度PythonNode.js标准库对 CLI 的支持argparse、subprocess、importlib全套需要commander或yargs等第三方包插件动态加载importlib原生支持require()本身支持子进程管理subprocess.run很成熟child_process有类似能力环境部署虚拟环境 pip略重npm 安装轻一点跨平台较好较好我最后选了 Python原因有几点第一标准的subprocess接口非常成熟传参数组、环境变量、退出码处理都属于核心能力不需要额外依赖第二importlib让插件加载几乎没有学习成本第三Python 的异常和 traceback 在 CLI 出错排查时更好用至少对我是这样。如果你本来就是 Node 技术栈用commander加require做同样的插件化设计也完全成立。核心结构和设计思路是通用的语言反而是第二位的。2.3 一次命令执行的完整数据流CLI-Anything 能成立的关键在于想清楚一次命令调用的完整链路。用户在某目录打开终端输入anything docker ps -a接下来发生的事情是入口脚本anything被 shell 调用sys.argv拿到[docker, ps, -a]入口把第一个参数docker当作命令名其余[ps, -a]作为透传参数Registry 扫描插件目录找到名为docker的插件入口调用插件的run([ps, -a])方法插件内部决定具体怎么执行通常通过 core.api 调用subprocess.run([docker, ps, -a])子进程继承当前终端的标准输入输出用户看到的结果和直接敲docker ps -a完全一致子进程的退出码被 core.api 捕获原样返回给入口入口再把退出码返回给 shell。这个链路里最容易被忽略的是第 6 步和第 7 步。很多 CLI 封装工具会把子进程的输出捕获后再自己打印一遍这么一来交互式输出会错位退出码也容易丢失。CLI-Anything 的做法是完全透传标准输入、标准输出和标准错误让底层工具感觉不到中间隔了一层。3. 从零落地注册表、插件与参数透传的骨架实现3.1 项目目录结构和入口脚本先看整体目录结构。为了让插件变成丢一个文件夹就能用我把所有约定都放在目录命名和文件命名上cli-anything/ ├── bin/ │ └── anything # 入口脚本 ├── core/ │ ├── __init__.py │ ├── registry.py # 插件注册表 │ ├── api.py # 插件调用的统一API │ └── config.py # 配置加载 ├── plugins/ │ ├── docker/ │ │ └── entry.py │ ├── ssh/ │ │ └── entry.py │ └── log/ │ └── entry.py └── config.yaml入口脚本bin/anything是全项目最薄的一层。它只做三件事加载注册表、扫描插件、把剩余参数交给注册表分发#!/usr/bin/env python3 import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from core.registry import Registry def main(): registry Registry() registry.discover() registry.load_aliases() argv sys.argv[1:] if not argv: registry.print_help() return 0 if argv[0] in (-h, --help, help): registry.print_help() return 0 cmd, rest argv[0], argv[1:] return registry.execute(cmd, rest) if __name__ __main__: sys.exit(main())bin/anything必须加上#!/usr/bin/env python3首行并给执行权限chmod x bin/anything有个细节值得注意这里不是用字符串拼接来拼命令而是把Registry.execute的返回值直接sys.exit出去。这个设计是为了保住退出码它会在后面的踩坑部分体现出价值。config.yaml保持精简主入口用它来定位插件目录和定义别名plugin_dir: ./plugins log_level: INFO aliases: d: docker s: sshcore/config.py负责读取这个文件并正确解析相对于项目根目录的路径。最容易写错的就是这里如果直接用字符串路径去拼接一旦用户从别的目录启动程序就会找不到插件目录。我后来统一用入口文件所在目录的父目录作为项目根目录来解析相对路径。3.2 插件加载器扫描目录、注册命令、延迟执行注册表是 CLI-Anything 的核心。它要做的事是扫描插件目录找到所有包含entry.py的子目录逐个导入并获取注册信息但不执行任何实际逻辑。# core/registry.py from pathlib import Path import importlib.util import sys class Registry: def __init__(self, plugin_dirNone): self.root Path(__file__).resolve().parent.parent self.plugin_dir Path(plugin_dir) if plugin_dir else self.root / plugins self._commands {} self._aliases {} def discover(self): if not self.plugin_dir.is_dir(): raise SystemExit(f插件目录不存在: {self.plugin_dir}) for plugin_path in self.plugin_dir.iterdir(): if not plugin_path.is_dir(): continue entry_file plugin_path / entry.py if not entry_file.exists(): continue plugin_name plugin_path.name.replace(-, _) spec importlib.util.spec_from_file_location( fplugin_{plugin_name}, entry_file ) mod importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) meta mod.register() self._commands[meta[name]] { module: mod, meta: meta, path: plugin_path, } def load_aliases(self): from core.config import load_config cfg load_config(self.root) self._aliases cfg.get(aliases, {}) def execute(self, name, args): real_name self._aliases.get(name, name) plugin self._commands.get(real_name) if plugin is None: return self._fuzzy_match(name, args) return plugin[module].run(args, registryself) def print_help(self): rows [] for name, plugin in sorted(self._commands.items()): rows.append(f {name:12} {plugin[meta].get(description, )}) print(CLI-Anything 可用命令:) print(\n.join(rows))这里有一个设计我花了不少心思插件在discover()阶段只调用register()不调用run()。这样做的好处是启动成本很低任何插件里的初始化逻辑都不会拖慢全局避免一个插件的报错导致整个 CLI 挂掉。_fuzzy_match是找不到命令时的兜底逻辑。我在实际使用中发现输入错误几乎是每天都会发生的事——比如手滑把docker打成dokcer。与其单纯报错不如给出建议import difflib def _fuzzy_match(self, name, args): suggestions difflib.get_close_matches(name, self._commands.keys(), n3) if not suggestions: print(f未知命令: {name}) self.print_help() return 1 print(f没有找到命令: {name}) print(你的意思是不是:) for s in suggestions: print(f {s}) return 1这个模糊匹配实现成本极低但使用体感提升非常明显。对于 CLI-Anything 这种入口越少越好的工具用户记不住确切命令是常态给提示比给报错友好得多。3.3 参数透传边界输入与 shell 转义的实际表现插件系统跑通之后最核心的技术细节就是参数透传。这看起来简单真正踩进去才知道水很深。先说结论永远用数组传参绝对不要拼字符串执行。也就是用subprocess.run([docker, ps, -a])而不是subprocess.run(docker ps -a, shellTrue)。数组传参让 Python 替你把参数转义和分隔都处理好了不会因为参数里含有空格或特殊符号而走样。下面这个表格整理了我实际遇到过的情况用户输入sys.argv实际拿到的内容直接拼字符串的后果正确做法anything docker rm $(docker ps -q)shell 已完成命令替换拿到容器 ID 列表没问题但若混用引号会出错数组透传anything ssh host1 host2两个参数host1、host2作为两个参数传给 ssh行为走样数组透传anything docker rm *shell 已展开为文件列表星号被当成参数本身数组透传anything docker inspect --format{{.Name}}引号已被 shell 去掉拼接后引号丢失数组透传这个表揭示了一个容易混淆的地方shell 只处理一层引号。用户在anything后面的参数里写的引号到了 Python 的sys.argv时已经被 shell 解析掉了。所以 CLI-Anything 内部要做的不是去解析引号而是把拿到的参数数组原封不动交给子进程。core/api.py 把这种透传能力封装成统一入口# core/api.py import os import subprocess import pty import sys def run(cmd, args, env_extraNone, cwdNone): env os.environ.copy() if env_extra: env.update(env_extra) target_cwd cwd or os.environ.get(CLI_ANYTHING_ROOT) or os.getcwd() proc subprocess.run( [cmd] args, cwdtarget_cwd, envenv, stdinNone, # 继承当前终端 stdoutNone, # 直接输出到终端 stderrNone, textTrue, ) return proc.returncode def run_interactive(cmd, args): return pty.spawn([cmd] args) def log(level, *parts): print(f[cli-anything][{level}], *parts, filesys.stderr)这段代码里stdinNone、stdoutNone、stderrNone是关键。subprocess.run默认会继承父进程的文件描述符所以用户能直接看到输出不需要手动捕获再打印。需要说明的是core/api.py是基于常规场景的封装交互式命令的pty.spawn在 Windows 平台上可能受限Linux 和 macOS 上表现稳定。如果你主要维护 Windows 环境需要换用winpty之类的替代方案。4. 让 CLI-Anything 变成团队工程插件协议与安全边界4.1 一份插件协议约定接入新工具只需一个文件夹插件协议是整个项目能不能做成Anything的关键。我最后把协议收敛成两个约定第一每个插件必须是一个独立目录目录下有entry.py 第二entry.py必须暴露register()和run(args, registry)两个函数。register()返回插件元信息def register(): return { name: docker, version: 0.1.0, description: 容器管理命令透传, }run()接收两个参数一个是用户输入中除了命令名之外的所有参数另一个是注册表实例。为什么要传registry因为有些插件可能需要调用其他插件的能力——比如deploy插件可能要用ssh插件来执行远程命令。有了registry插件之间就能互相合作而不必自己重写一套子进程逻辑。标准模板from core.api import run, run_interactive, log def register(): return { name: log, version: 0.1.0, description: 日志查询工具, } def run(args, registry): log(INFO, 执行 log 插件, args) # 交互模式 if --tail in args: return run_interactive(tail, args) # 透传模式 return run(tail, args)这样一套协议对新人来说学习成本极低。不需要理解注册表内部实现只要照模板写register和run就行。4.2 插件的生命周期加载、运行、热更新的取舍插件的生命周期分三个阶段发现、加载、运行。发现阶段发生在入口启动时Registry 扫描plugins/目录找出所有带entry.py的子目录。加载阶段只做模块导入和register()调用不做任何实际业务逻辑。运行阶段才真正调用run()。这里有一个我踩过的设计坑在一开始我在discover()阶段就把插件的run()也调用了一遍想着趁启动把每个插件都验证一遍结果某天一个插件的内部代码报错导致整个 CLI 启动失败连anything --help都没法用。后来改成延迟执行问题立刻消失。热更新是另一个取舍。最开始我写了文件监听用 watchdog 常驻线程监控插件目录变更。后来发现这是个过度设计——CLI 工具本身就是用完即走的短命进程根本不需要常驻监听。更务实的做法是每次运行时检查插件文件的修改时间如果比上次记录的晚就重新加载这个插件。这样既不引入额外依赖又实现了改了插件目录下次运行自动生效。伪代码大致是def execute(self, name, args): plugin self._commands.get(name) if plugin and plugin[mtime] ! self._get_mtime(plugin[path]): self._reload_plugin(plugin[path]) # 继续执行...4.3 本地代码插件的信任边界怎么设这个问题很多教程都不提但我觉得必须讲插件是本地代码它运行时的权限和你的账号权限完全一致。它能读你的文件、改你的配置、访问你的密钥所以你引入一个别人写的插件本质上等同于把账号权限交给了那个作者。在个人工具场景里这个问题还好说因为插件目录里的内容都是自己写的。但放到团队里就得认真定边界。我采用的务实方案是三件事第一插件目录只允许特定的人写通过 Git 仓库权限控制不允许任何人都能往plugins/里提交代码第二给插件目录生成一个签名清单manifest.sha256Registry 在加载插件前比对文件哈希。哈希对不上的插件直接拒绝运行这可以防住意外篡改但防不住恶意提交——因为提交者本身有哈希更新的权限第三约定插件不允许通过subprocess直接执行任意 shell 命令必须经过core.api。这样所有命令都走统一出口日志和审计才有落地的可能。当然了插件开发者真想绕过这条约定也很容易所以它更像一个工程约束而不是安全墙。如果你的团队对安全要求更高可以考虑把插件放到独立用户下运行或者用容器隔离。但那个方案要引入额外的运行时依赖对 CLI 工具的便携性伤害太大。我目前的项目没有做这一步因为信任边界已经通过仓库权限和代码评审基本可控。5. 真刀真枪踩过的坑转义、环境与交互式命令5.1 引号、星号和空参数在透传链路里是如何变形的第一个让我头疼的问题出现在一次用 CLI-Anything 执行 Docker 清理命令的时候。我想清理所有停止的容器于是在终端输入anything docker rm $(docker ps -aq)这个命令在 shell 里会先把$(docker ps -aq)的结果替换成容器 ID 列表所以anything收到的参数已经是展开后的结果subprocess.run直接执行没问题。真正的问题出现在下面这个场景anything docker inspect --format{{.Id}} mycontainer--format{{.Id}}里的双引号会被 shell 剥掉argv拿到的字符串变成--format{{.Id}}。如果我在插件里用字符串拼接的方式构造命令大概率会拼出一个错误的 command line。如果传给subprocess.run([docker, inspect, --format{{.Id}}, mycontainer])那么 Docker 能正确接收。所以我在整个项目里定了一条死规矩任何插件都不得把参数列表重新拼成字符串所有人都用数组传参。这个坑的根源是对shell 引号解析时机的误解。很多人以为subprocess.run()会像 shell 一样再解析一次字符串其实不会。subprocess.run([ls, -la])是把-la作为一个整体参数传出去。而subprocess.run(ls -la, shellTrue)才会让 shell 去解析。CLI-Anything 作为命令入口层绝不能再引入一层不必要的 shell 解析否则用户输入里的每一个特殊字符都可能成为炸弹。5.2 cwd 与环境变量丢失导致构建脚本读错配置第二个坑是我实际踩得最深的。某天我用 CLI-Anything 封装了一个项目的构建脚本def run(args, registry): return run(python, [scripts/build.py, --envstaging])单独在项目目录下跑这个脚本一切正常。但通过anything在任意目录跑脚本就一直报配置文件不存在。我排了半天用 traceback 一层层看才发现问题出在工作目录上。subprocess.run如果不传cwd子进程会继承 CLI-Anything 启动时所在的工作目录。用户在/tmp目录下执行anything build --envstaging那子进程的工作目录也是/tmp脚本里的相对路径./configs/staging.yaml自然就找不到了。修复方案是在core.api.run里增加cwd的解析逻辑优先使用插件传入的cwd否则使用环境变量CLI_ANYTHING_ROOT指向的项目根目录最后才默认当前目录。同时插件内部不要依赖相对路径./尽量通过Path(__file__).resolve().parent来自动定位自己的目录。这样无论从哪个目录调用路径都是确定的。环境变量也有类似的坑。有些构建脚本需要设置JAVA_HOME这样的全局变量但用户当前的 shell 里可能没设。CLI-Anything 作为入口层可以提供一种环境初始化能力在config.yaml里声明需要注入的环境变量执行命令前自动注入。这个设计对团队统一环境很有用省去了每个成员自己配 shell 配置的麻烦。5.3 交互式命令与退出码保真如何做到像直接跑原命令第三个坑和交互式命令有关。CLI-Anything 一开始用subprocess.run处理所有命令我天真地以为ssh host这种命令也能正常跑——结果完全不行。SSH、数据库客户端这类工具需要原始终端设备它们要检查 stdin 是不是 TTY还要申请终端窗口大小。普通的subprocess.run默认把 stdin 接到管道程序检测到不是交互终端之后就会改变行为要么直接报错要么禁用交互输入。解决方法是使用pty.spawn。它会在一个伪终端里运行子进程把当前的终端输入输出桥接过去让子进程以为自己真的连接了一个终端import pty def run_interactive(cmd, args): return pty.spawn([cmd] args)用pty.spawn跑ssh、mysql、python这类交互工具和直接在 shell 里跑几乎没有区别。代价是它的输出捕获能力会变弱因为伪终端会把输出混在一起不好做程序化解析。所以我的设计是对半开默认用subprocess.run做批量任务、日志收集、自动化脚本遇到需要用户交互的工具插件显式调用run_interactive。退出码保真是另一个隐蔽问题。subprocess.run返回的returncode如果没被入口层正确传递就会出现一个让人崩溃的现象anything docker build .构建失败了但echo $?返回 0CI 流水线直接绿了。修复方法就是我在前面入口脚本里写的return registry.execute(cmd, rest)然后sys.exit(main())。任何一层都不能吞掉退出码。这里有个容易被忽略的细节——pty.spawn的返回值并不总是子进程退出码在某些系统上它返回的是waitpid的状态码。所以run_interactive需要对结果做一次解析把真正的 exit code 取出来再返回。我用一张表把这个问题总结一下方便以后排查症状常见原因处理方式交互工具直接退出或禁用交互stdout/stdin 被重定向不是终端改用pty.spawnCI 明明是红的echo $?却是 0入口吞掉了 returncode入口sys.exit(main())原样传递相对路径找不到配置子进程的 cwd 不对显式传cwd或插件用__file__定位6. 实测效果与往后打算6.1 一次发布流程三条命令替代五份笔记CLI-Anything 跑通之后我做的第一件事是把发布流程整个收编进来。之前的流程分散在五份笔记里发布一次要打开三个终端窗口。现在整个流程变成了三条命令anything docker build -t demo:latest . anything test --envstaging anything ssh deploy cd /srv/demo docker compose up -d --build每一步的实际输出和原来单独执行完全一样但好处已经体现出来了命令名统一从anything开始不会把 Docker 的参数和部署脚本的参数记混帮助信息一条命令就能看全新人不需要翻笔记输出格式统一带了插件名前缀日志追溯方便退出码完全透传我可以把这三条命令写进 CI 流水线每一步失败都会准确中断。更让我意外的是CLI-Anything 还逼着我把每个工具的使用方式重新整理了一遍。为了给每个插件写register()里的 description我必须搞明白每个工具平时到底怎么调、参数有哪些、依赖什么环境。很多早该清理的脚本、废弃的参数、重复的命令都在这个过程中被清掉了。6.2 我准备做的两个扩展REST 桥与审计日志用了大半个月后我心里对后续扩展方向也有了更明确的想法。其中一个我确定要做的是给 CLI-Anything 加一个 REST 桥。思路不复杂在入口层挂一个 HTTP 服务把插件的run()暴露成接口。这么一来Web 管理后台、手机上的控制面板甚至其他机器的远程调用都可以通过同一个插件体系来触发命令而不必在 Web 端重复写一套业务逻辑。另一个一定会做的是审计日志。目前所有插件都通过core.api执行命令所以每条命令的执行记录都可以统一写到 SQLite。记录内容至少包括谁在执行、什么时间、执行了哪条命令、参数是什么、退出码是多少。这对团队场景特别有价值——出了事故能追溯平时还能统计分析哪个工具使用频率最高从而决定优化哪个方向。用到现在我最深刻的体会是这种入口统一的思路真正解决的并不只是少记几个参数而是把散乱的工具使用方式沉淀成了一套清晰的协议。你不需要在每次发版前回忆五篇笔记只需要记住anything这个词。如果你也有一堆工具散落各处不妨从这个思路入手做一个类似的命令行订阅层——它带来的收益远远大于第一次搭建时付出的成本。
网站建设高端定制企业官网