ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CLI-Anything:用插件化架构统一脚本入口,打造跨平台命令行工具箱

CLI-Anything:用插件化架构统一脚本入口,打造跨平台命令行工具箱 平时敲命令的时候最烦的就是记那些又长又散的脚本路径。项目里塞了一堆tools/、scripts/目录每个脚本参数还不一样今天用bash deploy.sh --envprod明天用python3 tools/parse_log.py -f app.log时间一长全靠翻历史记录效率低得让人怀疑人生。我之前也试过用shell alias去收拢刚开始还挺爽但alias只解决命令变短解决不了脚本之间怎么配合参数怎么统一换了电脑怎么办这些更实际的问题。这就是我想做CLI-Anything的直接原因把零散的脚本、命令、工作流全部收拢到一个统一的命令行入口里。用一个anything命令后面挂不同的子命令想执行什么就执行什么插件式管理配置驱动跨平台可用。这篇文章就把这个项目从思路到落地的全过程拆开讲清楚包括为什么选这种架构、核心模块怎么实现、实际部署中踩过哪些坑希望对想建自己命令行工具箱的朋友有参考价值。1. 项目定位与设计思路1.1 要解决的痛点脚本散落与心智负担先说说我自己的真实场景。一个中大型项目里常见的操作至少有这些本地启动开发环境、跑单元测试、构建前端产物、拉取测试环境日志、数据库迁移、代码格式检查、Git分支清理、批量重命名资源文件。每一个操作背后都是一个或几个脚本分散在scripts/、bin/、tools/甚至直接写在package.json或Makefile里。这就带来几个很实际的问题每个脚本的参数风格完全不一样。有的用--env有的用-e有的直接读环境变量记起来非常混乱。脚本之间互相调用靠硬编码路径换个目录结构就废。新同事入职要花半天时间才能搞明白哪个脚本是干嘛的。自己的电脑上能用到了CI环境里又得重新配置一套。CLI-Anything的核心思路就是把这些脚本全部插件化然后由一个统一的分发器去加载和调度。使用者只面对一个命令anything task [flags]。至于这个task内部是调用Node脚本、Python脚本还是直接跑Shell命令统统不关使用者的事。1.2 整体架构三个核心组件的分工整个工具分成三块互为配合命令分发器Dispatcher负责解析anything后面跟的子命令和参数找到对应的插件入口并执行。它不关心具体业务逻辑只管路由。插件注册中心Registry负责收集和登记所有可用的任务。每个插件就是一个独立的执行单元声明自己的名称、描述、所需参数注册中心把它们汇总成一个任务清单。配置加载器Config Loader负责读取全局配置和项目级配置把环境变量、路径模板、默认参数注入到插件执行上下文中。这三个组件的设计参考了Web框架里路由-控制器-中间件的分层思想。命令分发器就像是Express里的路由表注册中心是控制器列表配置加载器则有点像中间件链在插件真正跑起来之前先把环境准备好。1.3 为什么不是alias也不是另一个全家桶在动手之前我评估过几个替代方案。第一种是用Shell alias或函数。优点是真的轻量缺点也明显没有统一的参数解析能力alias ppython3 tools/parse.py这种写法遇到带空格的文件路径就得小心处理而且alias只能在交互式Shell里用在脚本里或者CI里经常失效。第二种是直接上成熟的CLI框架比如commander.js、cobra、click。这些框架确实强大但它们是面向编写单一CLI程序的每一个都要单独建项目、写入口、发布。如果我只是想把日常十几个脚本统一管起来给每个脚本都配一个完整的CLI框架成本太高维护负担反而更重。第三种是接受现状用make做统一入口。make确实是个经典方案但它是为构建设计的.PHONY、Makefile语法对于纯任务调度来说有些绕Windows原生也不支持。CLI-Anything的定位是薄壳它不重写你的业务逻辑只是给现有脚本一个统一的入口和一套标准的接入协议。说白了它是一层皮里面还是原来的脚本但这层皮把怎么调用这个问题彻底标准化了。2. 核心模块拆解与实现要点2.1 命令路由动态查找与参数归一化命令分发器的核心逻辑其实不复杂拿到用户输入的第一个参数当作任务名去注册中心查对应的插件描述文件如果找不到就报错并列出相近的任务名。真正麻烦的是参数归一化。不同脚本对参数格式的要求不一样有的接收空格分隔的列表有的接收JSON字符串有的需要分离出开关型参数和值型参数。分发器要做的不是替插件解析所有参数那是插件自己的事而是把来源统一的参数以标准化格式传给插件。我实际采用的方案是分发器只做轻量预解析把参数分成三类命名参数--keyvalue或--key value开关参数--verbose、--force这样不需要值的位置参数剩余的不带--前缀的参数这三类参数会被分别装进三个变量里然后作为一个上下文对象传给插件执行器。插件执行器再根据自己的描述文件决定怎么使用。这样既保留了灵活性又给所有插件一个统一的最小公约数。这里有个值得注意的设计细节命名参数的key会统一转成小写并且支持注册别名。比如插件的描述文件里声明了env参数有个别名是e那么anything run_task -e prod和anything run_task --envprod就都能正确解析。这对改造旧脚本特别友好不需要强迫使用者改记忆习惯也不需要去改原来脚本内部的参数逻辑。2.2 插件机制让Anything成立的关键插件机制是CLI-Anything最有价值的部分也是前期反复推翻重做最多的地方。每个插件本质上是一个遵循约定目录结构的文件夹里面包含三个必要文件plugin.yml描述文件、run.sh执行入口、README.md可选用于anything docs task查看说明。plugin.yml是一个精简的声明式配置大概长这样name: deploy description: 构建并部署到目标环境 args: - name: env alias: e required: true description: 目标环境如 prod/staging - name: tag alias: t required: false default: latest description: 镜像版本号 run: bash run.sh为什么不用Python或Node写插件而是用run.sh作为统一入口主要是考虑到底层脚本可能是任意语言写的。如果强制插件用Python写那原来用Node写的工具就得先包一层。用run.sh当胶水层是最通用的所有主流系统都能执行Shell脚本而且Shell天生擅长调用其他进程。但纯Shell也有它的弱点字符串处理麻烦JSON解析更容易让人抓狂。所以插件执行器里我加了一个约定执行入口不限制必须是Shell也可以是node run.js或python3 run.py只要在plugin.yml的run字段里写明就行。分发器只是把上下文数据放在环境变量里然后去执行这个字段指定的命令。这就是一个非常关键的妥协设计统一入口但不统一语言。使用者完全可以用自己最熟悉的技术栈写插件内部逻辑只需要保证能被命令行拉起来就行。生命周期管理方面我给插件定义了三个阶段pre_run、run、post_run。其中pre_run和post_run默认不开启需要插件自己声明。比如部署插件可以在pre_run阶段检查本地Git状态是否干净在post_run阶段打一个tag推送到远端。这三个阶段在开发调试的时候非常有用比把所有逻辑都塞进一个run.sh要清晰得多。2.3 配置体系全局配置与项目配置的叠加CLI-Anything采用了两层配置叠加的设计全局配置放在用户主目录下的.cli-anything/config.yml存放个人的默认设置比如默认编辑器、默认Shell类型、私有镜像仓库地址。项目配置放在项目根目录的.cli-anything.yml存放跟当前项目绑定的内容比如部署服务器地址、测试命令、路径映射。加载逻辑遵循项目覆盖全局命令行参数覆盖配置文件的优先级。也就是命令行传参 项目配置 全局配置 内置默认值。配置加载器还有一个值得说的功能路径模板变量。很多脚本的问题在于路径硬编码换一台机器就失效。我在配置加载器里内置了几个常用变量{root}CLI-Anything安装的根目录{project}当前项目的根目录自动探测{user}当前用户主目录{date}当前日期格式YYYYMMDD{timestamp}当前时间戳在plugin.yml或者命令行参数里随时可以用这些变量组合出路径。比如--log_dir{project}/logs/{date}分发器会在真正执行前把变量替换成实际值。这个设计的灵感来自IDE里的工作区变量用起来确实比到处拼接绝对路径省心太多。2.4 跨平台兼容被环境逼出来的细节跨平台是这个项目最折磨人的地方没有之一。Shell在macOS和Linux上通常是bash但Windows上可能是cmd、PowerShell或Git Bash而且路径分隔符、环境变量语法、换行符全都不一样。我采取的起点是默认支持bash和zshWindows上要求先装Git for Windows用附带的Git Bash作为执行环境。这不算一个优雅的原生跨平台方案但它是成本最低、成功率最高的路径。具体到代码层面要注意几个细节不要在插件里直接写#!/bin/bash就以为完事了。在Git Bash里bash的路径可能跟Linux不一样最好直接用env bash。Windows上执行外部命令时.exe后缀经常是必需的用command -v做存在性检查时要把匹配逻辑放宽。换行符要统一LF因为Git for Windows的bash有时候会对CRLF发脾气。我在插件的pre_run里加了一个可选的文本规范化钩子让插件自己决定是否需要对文件做换行符转换。解决路径分隔符问题我写了一个小函数normalize_path() { local path$1 # 把反斜杠统一换成斜杠避免混用 echo ${path//\\//} }这个函数被复制到了每个插件的公共库文件里。虽然看起来有点原始但在跨环境场景下真的救命的。3. 实操从零打造自己的CLI-Anything3.1 五分钟完成安装与骨架初始化安装这块没什么花哨的。CLI-Anything本体是一个Python包外加一个bash包装器Python负责解析逻辑bash负责真正把命令从指尖送到执行器。我的安装步骤一般是这样# 克隆项目并安装Python依赖 git clone https://github.com/yourname/cli-anything.git ~/.cli-anything cd ~/.cli-anything python3 -m pip install -r requirements.txt # 在shell配置里添加PATH和补全 echo export PATH$HOME/.cli-anything/bin:$PATH ~/.bashrc echo complete -W $(anything tasks --plain) anything ~/.bashrc source ~/.bashrc初始化一个项目的CLI配置cd /path/to/my_project anything init --namemy_projectanything init会在项目根目录生成一个.cli-anything.yml配置模板并创建默认的cli-anything/tasks目录。此时整个骨架就起来了anything tasks能列出所有已注册的任务刚开始当然是空的但接下来就可以往里填充了。3.2 写第一个插件批量文件重命名技术文章不能没有代码。我拿一个真实的场景做例子批量重命名一批图片文件把IMG_20240101_123456.jpg这样的文件名改成20240101_photo_01.jpg这种规整格式。在cli-anything/tasks下建一个rename_files目录里面写两个文件。plugin.ymlname: rename_files description: 按日期前缀批量重命名图片文件 args: - name: dir alias: d required: true description: 目标目录 - name: ext alias: e required: false default: *.jpg description: 匹配的文件通配符 - name: prefix alias: p required: false default: photo description: 文件名前缀run.sh#!/usr/bin/env bash set -euo pipefail TARGET_DIR$CLI_ARG_DIR EXT_PATTERN$CLI_ARG_EXT PREFIX$CLI_ARG_PREFIX counter1 for file in $TARGET_DIR/$EXT_PATTERN; do if [ ! -f $file ]; then continue fi extension${file##*.} timestamp$(stat -c %y $file | cut -d -f1 | tr -d -) new_name$(printf %s_%s_%02d.%s $timestamp $PREFIX $counter $extension) mv $file $TARGET_DIR/$new_name echo renamed: $(basename $file) - $new_name counter$((counter 1)) done这个插件展示了几个CLI-Anything的约定插件执行入口的环境变量里已经注入了解析好的参数。CLI_ARG_DIR、CLI_ARG_EXT、CLI_ARG_PREFIX都是由分发器自动生成的命名规则是CLI_ARG_加上参数名大写。这省去了每个插件自己解析参数的重复工作。参数默认值在plugin.yml里声明插件代码里不需要再写一套默认逻辑。执行入口完全没有写参数校验因为分发器在pre_run阶段已经根据required字段做过校验了。实际运行anything rename_files --dir/path/to/photos --prefixvacation --ext*.png插件会把/path/to/photos下所有png文件按日期重命名并打印每次改名的过程。3.3 高级编排把多个插件串成流水线单个插件解决了单个任务的问题但实际工作中更多时候是一条流水线。比如发布一个前端项目通常要跑测试、构建、打包镜像、推送、SSH到服务器更新容器。这五个步骤如果用命令行手工敲中间任何一个出问题都可能打断节奏。CLI-Anything在此基础上加了一个轻量的编排模式通过一个特殊的workflow插件类型来实现。准确说我把编排作为内置命令而不是让每个插件自己去调别的插件。anything run workflow工作流定义用YAML描述name: release_web steps: - task: run_tests params: tag: unit - task: build_web params: env: prod - task: docker_build_and_push params: image: registry.example.com/web tag: latest - task: ssh_update params: host: prod-server-01 service: web执行器按顺序逐个调用任何一个步骤失败会立即中断并打印失败步骤和前一步的输出。如果想跳过失败继续跑可以传--stop_on_errorfalse按钮但我绝大多数情况是开着中断的因为早发现早修复总比带着错误往下走好。有一个小细节值得提一下每个step里的params在执行时优先级高于命令行传入的公共参数。也就是说工作流可以给某个步骤定制参数这在同一套流程跑不同环境的时候非常有用。3.4 接入AI辅助命令让Anything更进一步今年大家聊得最多的就是大模型。我顺手把CLI-Anything和本地大模型或云端API做了一层集成。这个功能在项目里叫anything ask用法是anything ask 给nginx配一个反向代理到本地8080端口这个命令做的事很简单把自然语言问题拼进一个预设的提示词模板调API拿回答然后根据回答内容判断是否需要落到文件。如果需要创建配置会生成文件并打印路径。这里的关键不是让AI写命令这么空泛而是把AI当作一个插件来接入。也就是说anything ask本身就是一个插件它的run.sh里调用一个Python脚本脚本负责跟模型API通信。由于它遵循同样的插件协议所以照样能用--config传参、能复用全局配置里的密钥、能在工作流里被当作普通步骤调用。我试过让AI帮我把一段长命令转成plugin.yml的格式效果很不错。还可以让AI在我写完一个插件的run.sh之后帮我检查有没有潜在的路径转义问题。4. 常见问题与排查技巧实录4.1 命令找不到但脚本明明存在这是插件开发时最常遇到的坑。表现为执行anything some_task时提示command not found但在终端里手动跑一下插件里的脚本又是好的。我排查这类问题按照三个方向来先确认插件有没有被注册中心看到。运行anything tasks --verbose查看插件路径是否被正确识别。检查执行入口里有没有用到相对路径。比如run.sh里如果写了python3 tools/a.py而分发器在某个目录下执行时相对路径就失效了。run.sh开头的第一件事就应该是cd $(dirname $0)把工作目录切到脚本自己所在的目录。检查环境变量。分发器会把PATH注入到执行上下文里如果插件要调用某个自定义安装工具得确认它的路径存在于系统中。4.2 参数带空格和特殊字符的转义问题Shell参数转义是个永恒的话题。我在CLI-Anything里遵守一条铁律任何来自用户的参数在组装命令时一律用数组方式传递不拼字符串。什么意思呢比如你要在插件里执行target_filemy new note.md touch $target_file如果文件名有空格直接展开就会裂成两个参数。正确写法target_filemy new note.md touch $target_file在CLI-Anything的插件执行器里我会把用户传过来的参数原封不动放进环境变量插件在读取的时候必须自己加引号。为了少踩这个坑我封装了一个公共库函数arg_quote()每次组装命令时调用它把参数包一层单引号并把参数内的单引号转成\序列。经验之谈绝大多数找不到文件的bug都是参数在传过程中被Shell吃掉了一层引号导致的。遇到这种问题先在插件里加一行echo $CLI_ARG_DIR看看实际收到的值是什么再检查组装命令时的引号。4.3 插件依赖管理与镜像加速当插件数量多了以后依赖管理会变得不可忽视。有的插件需要Python库有的需要Node包如何保证换台电脑anything setup一下就能全部跑起来CLI-Anything的约定是每个插件目录下可以有一个requirements.txtPython和package.jsonNode全局安装命令anything deps会遍历所有插件并安装它们声明的依赖。依赖安装失败的排查主要集中在这几个点网络问题。尤其是安装大型依赖包时超时可以用镜像源替代默认源。版本冲突。不同插件对同一个库的版本要求不一致为了隔离冲突我给每个插件提供了虚拟环境模式设置py_venv: true后执行器会自动创建插件目录下的venv并用它运行插件。Node包则推荐用npx做即时执行或者用npm ci保证锁定版本。4.4 Windows与macOS的行为不一致跨平台的项目一定会在某一天让你怀疑人生。我遇到过一个非常典型的案例同一个Shell脚本在macOS上跑得好好的在Windows Git Bash里却报错mv: cannot stat。后来排查发现是Windows上路径分隔符用的是反斜杠而脚本里对路径做了字符串比较。针对这类跨平台问题我建议在项目里建立一套公共环境探测脚本放到每个插件默认加载的公共库里。这个探测脚本专门做这几件事输出当前系统类型、Shell类型、关键命令的绝对路径。把路径分隔符统一成斜杠。检测文件系统是不是对大小写敏感macOS默认不敏感Linux敏感Windows在Git Bash下通常是敏感的。在debug模式执行任务时这些探测信息会一并打到终端上省去了很多我看代码没问题啊的死循环。4.5 启动速度优化CLI工具的启动速度直接影响使用意愿。如果敲一个命令要等两秒才看到输出那还不如直接用原来的脚本。CLI-Anything的启动路径有两块耗时Python解释器启动以及配置加载。Python启动是大头但改写成Go又没必要毕竟项目要求和迭代速度摆在那里。我做了一个比较实用的优化把配置加载的脏检查缓存到文件里配置没变化时直接读缓存避免每次启动都做一次完整的YAML解析。Python解释器使用python3 -S来跳过某些不必要的模块预加载视觉效果上能快不少。把分发器的核心逻辑做成常驻服务模式anything daemon start启动一个本地Socket服务后续的anything命令都通过Socket通信交给常驻进程处理。这样Python只需要冷启动一次后面的执行时间几乎只受插件本身耗时的限制。采用这个方案后实际体感从开个终端窗口后要先等一下变成秒出响应。不过常驻模式的锁文件管理和端口占用检测需要处理到位不然会出现服务还活着但Socket已死的情况。4.6 问题排查速查表现象优先排查方向建议做法anything xxx提示未知任务任务目录放错位置/命名不一致anything tasks --verbose看注册列表插件执行时找不到路径工作目录不对/环境变量丢失在run.sh开头cd $(dirname $0)参数带空格就报错组装命令时缺引号用公共库arg_quote()包参数Windows跑得动macOS跑不动路径分隔符/大小写/换行符跑debug模式看探测信息依赖安装失败网络源/版本冲突配置镜像源/开启插件虚拟环境启动慢Python冷启动/配置重复解析开启常驻服务模式/启用配置缓存5. 几个值得细细品味的实战心得项目走到这一步说几个我在实际使用中沉淀下来的体会。第一插件目录的命名就是第一份文档。我给项目内每个插件都定了命名规范动词开头、全小写、单词间用下划线。任务列表一打开不用看README就大概知道每个命令是干嘛的。这比在Wiki里写一堆文档有用得多。第二别一开始就想着做完美通用。CLI-Anything的插件协议在V1和V2之间改过两次最初我坚持所有插件必须用同一种语言写结果导致一个用Python写的内部工具根本没法被纳入体系。后来把run字段改成任意可执行命令之后整个系统的接入成本一下子降了下来。设计上的克制有时候比功能丰富更重要。第三统一入口的心智价值被低估了。团队里用这个工具之后新同事入职时不再需要去翻团队的Confluence页面找命令大全只需要敲一个anything tasks所有可用的操作都列在那里。能力地图变成了一等公民这对团队知识管理的贡献比想象中大得多。最后分享一个实用小技巧在anything里加一个suggest命令它会根据当前的Git分支名、最近改动的文件类型、项目里是前端还是后端等上下文信息用规则引擎推荐你现在最可能要跑的任务。比如你正在改一个*.vue文件且分支名里带feature/它就会优先推荐run_tests --tagcomponent和lint --stylestrict。这个功能逻辑不复杂但用起来确实很顺手强烈建议试一下。CLI-Anything这个项目后续我还会继续迭代方向主要是把工作流从纯YAML驱动升级成支持条件分支的迷你流水线但核心的插件协议和分发器架构基本已经稳定了。如果你也受够了那些散落在各处的脚本和记不完的命令不妨用这套思路试着搭一个属于自己的命令行入口反正成本不高收益却很直接。
返回列表