很多人在用 AI 编程工具做自动化的时候,都会卡在一个地方:AI 再聪明,也就是“会说话”。真正要让它干活,离不开一套能动态扩展能力的插件体系。最近我基于 Claude Code 的插件机制,从零搭了一个私有插件市场,解决“能力发散、脚本满天飞、项目交接后别人不会用”的问题。这篇文章完整记录了我的设计思路、分层方案、核心代码和一批实战中踩出来的坑,希望能给正在搞团队级 AI 工具链的同学一些参考。
先说结论:Claude Code 本身提供了开放的插件扩展入口,允许我们注册自定义命令、工具和上下文注入。但默认情况下,插件都是散落在本地目录里的,开发、测试、分发、升级全靠手工,一旦环境变成多机、多团队,立刻失控。插件市场的本质,是给这些能力装上“注册、检索、分发、版本管理”四个环节,让任意一个终端用户能像装软件一样,把能力装进 AI 工具里。
1. 整体架构与设计思路
1.1 为什么一定要有“市场”这一层
刚开始我也觉得,直接把脚本丢进 plugins 目录不就完了?但用了几周之后发现,问题不是“能不能装”,而是“装了什么、在哪装的、怎么改的、怎么收回”。团队里几个人各加各的脚本,目录越来越乱,有些插件过期了也不知道,更别提跨机器同步。这类问题跟传统 IDE 生态是一样的——VS Code 如果只能手动拷贝插件目录,不可能有今天这么大的扩展市场。
市场真正解决的三个问题:
- 发现:新的能力脚本被谁发布了?在哪里能看到?
- 部署:远端机器上没有这些脚本怎么办?能不能一条命令补全?
- 管控:某个插件版本有问题,怎么快速回滚或者撤销?
所以我的方案是:不是把整个市场做成一坨重型平台,而是做“客户端 + 服务端 + 编排层”的三段式结构。服务端负责插件元数据的存储与检索,客户端负责本地安装与调用,编排层负责把 Claude Code 的主进程和我们新的能力注册表桥接起来。
1.2 方案选型与关键取舍
市面上确实有开源的插件管理器,但选型时我做了几个关键取舍。首先,我没有引入重量级容器化方案,因为 Claude Code 本质上是一个进程内工具链,不需要每个插件都跑独立容器。其次,我没有把插件做成可执行二进制,而是统一采用 Python 脚本 + Shell 包装器的方式,因为团队里大多数能力脚本本来就是 Python 写的,直接套 Shell 转发成本最低。第三,服务端我只用了一个轻量的 FastAPI 应用,没有引数据库,数据直接放在 JSON 文件里,配合进程锁实现并发控制。这个方案的适用场景是百级插件以内,再往上再考虑引入真正的数据库。
考虑过用 SQLite 来存插件元数据,但后来放弃了。原因是插件元数据字段结构简单,无非就是名称、版本、作者、描述、入口命令,JSON 文件完全够用。而且 JSON 的好处是可读、可 diff、出了问题可以直接人工编辑。并发写的问题用fcntl.flock文件锁就能解决,实测在几十个插件的规模下没有任何性能瓶颈。
1.3 插件在 Claude Code 中的生命周期
要理解市场,首先得理解插件在 Claude Code 里的完整生命周期。一个插件的生命周期分四个阶段:
- 编写阶段:在项目目录中编写插件脚本,并配好描述文件。
- 注册阶段:通过市场的命令行工具
mkt(Market Place Tool)把插件发布到服务端。 - 分发阶段:其他机器上的 Claude Code 进程启动时,通过
claude_config和自定义注册表拉取插件列表,本地没有的自动下载。 - 运行阶段:Claude Code 通过注册的工具名称匹配到本地脚本,并执行。
在项目里体现为:团队仓库里放一个统一的.claude-plugin/目录,里面每个子目录是一个插件包,包含plugin.json描述文件和可执行入口。Claude Code 启动时,会读取我们注入的动态注册文件,把插件工具暴露给模型。
关键点:Claude Code 的插件机制比较依赖入口脚本的约定。我的建议是每个插件必须提供一个统一格式的
run脚本,接收 JSON 参数,输出 JSON 结果。这样无论底层是 Python、Node 还是纯 Shell,模型侧看到的接口都是完全一致的。
2. 插件市场服务端的实现细节
2.1 描述文件规范
描述文件是整个市场的“身份证”。我先定义了一个严格的 JSON Schema,只允许以下字段。多余字段不影响解析,但发布时会提示非标准,避免团队里混入风格不一的插件导致后续维护困难。
{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": ["name", "version", "description", "author", "commands"], "properties": { "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "description": { "type": "string", "maxLength": 120 }, "author": { "type": "string" }, "commands": { "type": "array", "items": { "type": "object", "required": ["name", "description"], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "arguments": { "type": "object" } } } } } }这里有一个容易忽略的设计点:命令名必须是^[a-z][a-z0-9-]*$这种以小写字母开头的短横线风格。Claude Code 在解析命令名时,对特殊符号非常敏感,如果名字里带下划线或大写字母,某些版本会出现模糊匹配问题,严重时直接不触发。另外,命令名尽量控制在 20 字符以内,太长的命令名在实际对话中很容易被模型截断。
2.2 注册与发布接口
服务端我只暴露了四个接口:注册、列表、详情、删除。这里只贴注册接口的关键实现。
import json import hashlib import threading from pathlib import Path from fastapi import FastAPI, HTTPException, Request PLUGIN_INDEX = {} app = FastAPI() lock = threading.Lock() def load_index(): global PLUGIN_INDEX if Path("plugin_index.json").exists(): PLUGIN_INDEX = json.loads( Path("plugin_index.json").read_text(encoding="utf-8") ) def save_index(): with lock: Path("plugin_index.json").write_text( json.dumps(PLUGIN_INDEX, ensure_ascii=False, indent=2), encoding="utf-8" ) @app.post("/api/plugins") async def register_plugin(req: Request): body = await req.json() name = body.get("name") version = body.get("version") if not name or not version: raise HTTPException(status_code=400, detail="missing name/version") plugin_id = f"{name}@{version}" if plugin_id in PLUGIN_INDEX: raise HTTPException(status_code=409, detail="plugin already exists") # 对插件脚本内容做哈希,确保内容一致 script_content = body.get("script", "") digest = hashlib.sha256(script_content.encode("utf-8")).hexdigest() PLUGIN_INDEX[plugin_id] = { "name": name, "version": version, "author": body.get("author", "unknown"), "description": body.get("description", ""), "commands": body.get("commands", []), "script_digest": digest, "published_at": int(time.time()) } save_index() return {"status": "ok", "plugin_id": plugin_id}我们特意在注册时对脚本内容做了 SHA-256 摘要,这个摘要不是用来做完整性校验的,而是用来做“重复发布检测”。团队里经常有人不小心把同一个插件发布两遍,有了摘要,后端就能精确判断这次发布跟上次是否完全一样,如果一样直接提示无变化,不用再生成一个多余的版本号。
2.3 客户端拉取与缓存策略
客户端也保持简单,用一个sync_plugins.sh脚本从服务端拉取索引,然后逐条对比本地缓存目录。
#!/usr/bin/env bash set -euo pipefail MARKET_URL="${MARKET_URL:-http://127.0.0.1:8000}" CACHE_DIR="${HOME}/.claude-mkt/cache" INDEX_FILE="${CACHE_DIR}/index.json" LOCAL_MANIFEST="${CACHE_DIR}/.manifest.json" mkdir -p "${CACHE_DIR}" if curl -sL "${MARKET_URL}/api/plugins" -o "${INDEX_FILE}.tmp"; then mv "${INDEX_FILE}.tmp" "${INDEX_FILE}" fi # 比对本地 manifest,决定哪些需要更新 if [ ! -f "${LOCAL_MANIFEST}" ]; then python3 - "${CACHE_DIR}" <<'EOF' import json, sys, pathlib, urllib.request cache_dir = pathlib.Path(sys.argv[1]) index = json.loads((cache_dir / "index.json").read_text(encoding="utf-8")) local_manifest = {} for plugin_id, meta in index.items(): plugin_dir = cache_dir / plugin_id script_path = plugin_dir / "run.py" if script_path.exists(): local_manifest[plugin_id] = "local" continue # 实际拉取脚本逻辑省略 ... EOF else echo "No changes detected." fi这里我踩过一个坑:最开始直接把服务端索引里的路径当成文件系统路径用,结果发现服务端路径和客户端路径根本对不上。后来统一约定,插件脚本通过plugin_id(名称@版本)定位,服务端存的是逻辑路径,客户端再把它映射到本地缓存路径,彻底解决了跨机器的路径错位问题。
3. 插件接入 Claude Code 的完整实现
3.1 动态注册文件的生成
Claude Code 通过注册文件来感知外部工具。我们需要在 CLI 配置目录里生成一个 JSON 文件,把市场拉下来的插件命令合并进去。这里利用了 Claude Code 对配置目录内 JSON 的合并机制。
{ "mcpServers": {}, "plugins": { "commands": [ { "name": "network-diagnose", "description": "网络诊断工具;输入目标地址与端口,输出连通性、TCP 握手耗时、路由跳数摘要。", "entry": "python3 ~/.claude-mkt/cache/network-diagnose/run.py" }, { "name": "package-inspector", "description": "依赖包分析工具;输入 requirements.txt 路径或源码目录,输出依赖树、安全公告索引与异常版本列表。", "entry": "python3 ~/.claude-mkt/cache/package-inspector/pak_profile.py" } ] } }这里要特别强调 description 的写法:它会直接影响模型对工具的选择。不要写“这是用来诊断网络的”,而要写“输入目标地址与端口,输出连通性、TCP 握手耗时、路由跳数摘要。”,把输入输出都写清楚。模型看到这个描述后,会自行判断何时调用、传什么参数。同一时间注册了 20 个插件的情况下,描述含糊的插件基本上不会被调用到,因为模型在自然语言规划时无法validly把用户意图映射到工具上。
3.2 统一入口脚本的工程约定
每个插件包的run.py我强制要求实现以下接口:
import json import sys def main(): try: params = json.loads(sys.argv[1]) except IndexError: print(json.dumps({"error": "missing params"}, ensure_ascii=False)) return 1 except json.JSONDecodeError: print(json.dumps({"error": "invalid json"}, ensure_ascii=False)) return 1 try: # 每个插件根据自己的业务逻辑实现这个函数 result = execute(params) print(json.dumps(result, ensure_ascii=False)) return 0 except Exception as e: print(json.dumps({"error": str(e)}, ensure_ascii=False)) return 1 if __name__ == "__main__": sys.exit(main())这样设计的好处有两个:一是所有插件对 Claude Code 来说接口完全一致,模型不需要理解底层实现;二是错误处理统一,插件运行失败时输出标准格式的错误 JSON,Claude Code 可以基于这个 JSON 分析失败原因,甚至自动重试或换参数。这是普通脚本方式做不到的,它把“脚本执行”变成了“模型可感知的状态机”。
4. 从零封装一个可用插件的过程
4.1 场景说明
为了让整个流程看得见摸得着,我做了一个最简单的示例插件——端口扫描工具。它做的事情非常简单:输入 IP 或域名,以及一个端口范围,返回哪些端口是开放的。
4.2 插件包结构
network-diagnose/ ├── plugin.json ├── run.py ├── assets/ │ └── tcp_checker.py └── README.mdplugin.json里这样写:
{ "name": "network-diagnose", "version": "0.1.0", "description": "网络连通性与端口检测工具", "author": "devtools-team", "commands": [ { "name": "net-diagnose", "description": "输入目标地址与端口范围,输出 TCP 连接状态、延迟摘要。", "arguments": { "host": "string", "ports": "string" } } ] }真正执行的代码放到 run.py 里。我特意没有把所有逻辑堆进 run.py,而是拆了一个assets/tcp_checker.py,这样 run.py 只负责解析参数、调用业务函数、返回结果,业务逻辑可以独立单测。
import json import socket import sys import time from concurrent.futures import ThreadPoolExecutor def check_port(host: str, port: int, timeout: float = 3.0) -> dict: start = time.time() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result = sock.connect_ex((host, port)) elapsed = round((time.time() - start) * 1000, 2) if result == 0: return {"port": port, "status": "open", "latency_ms": elapsed} else: return {"port": port, "status": "closed", "latency_ms": elapsed} finally: sock.close() def execute(params: dict) -> dict: host = params.get("host", "") ports_raw = params.get("ports", "") if not host or not ports_raw: raise ValueError("host and ports are required") try: port_parts = ports_raw.split(",") port_numbers = [] for part in port_parts: if "-" in part: a, b = part.split("-") port_numbers.extend(range(int(a), int(b) + 1)) else: port_numbers.append(int(part)) except ValueError: raise ValueError("ports must be numbers or ranges") with ThreadPoolExecutor(max_workers=20) as executor: results = list(executor.map(lambda p: check_port(host, p), port_numbers)) return {"host": host, "results": results} def main(): try: params = json.loads(sys.argv[1]) result = execute(params) print(json.dumps(result, ensure_ascii=False)) return 0 except Exception as e: print(json.dumps({"error": str(e)}, ensure_ascii=False)) return 1 if __name__ == "__main__": sys.exit(main())这里有个实际经验:把端口扫描脚本接入 Claude Code,重点不是扫描逻辑本身,而是参数解析和返回结构。因为模型只会根据你给的工具描述去生成 JSON 参数,如果你的脚本对参数类型要求太严格或者返回结构不一致,后续正确处理率就会很低。所以我在 code 里特意做了宽容解析,不管用户说“80,443”还是“80-100”,最终都统一成列表处理。
4.3 发布进来的完整链路
这个插件写完后,发布的过程只需要三步:
- 执行
mkt publish ./network-diagnose,本地脚本会读取plugin.json,打包 run.py 与 assets 并上传到服务端。 - 服务端收到请求,校验描述格式,计算脚本摘要,写入索引文件,返回
plugin_id: network-diagnose@0.1.0。 - 其他机器执行
mkt sync,拉取最新索引,检测本地没有该插件,自动下载,并更新 Claude Code 的动态注册文件,打印新增命令列表。
最终在 Claude Code 的对话中直接输入“帮我看看 192.168.1.1 的 22 到 30 端口开没开”,模型会自主调用net-diagnose工具,返回标准的 JSON 结果,然后以自然语言告诉你结论。整个链路完全自动化,没有任何手工干预。
5. 插件分发安全性设计与版本控制
5.1 命令注入风险
插件市场最大的安全隐患是恶意脚本。因为插件最终是通过 Claude Code 上下文执行的,理论上如果插件代码里隐藏了读文件、删目录、回传数据的操作,一旦被模型调用,后果很严重。我在这里采取了几层控制:
第一层是发布端校验。服务端接收到脚本内容后,先做静态扫描,检查是否包含subprocess.call、os.system、rm -rf、eval等高风险操作。如果有,直接拒绝注册并告警。这里为了演示,实现了一个简单检查器:
import re BLACKLIST_PATTERNS = [ re.compile(r"os\.system"), re.compile(r"subprocess\.(call|run|Popen)"), re.compile(r"\beval\s*\("), re.compile(r"\bexec\s*\("), ] def security_scan(content: str) -> list[str]: warnings = [] for pattern in BLACKLIST_PATTERNS: if pattern.search(content): warnings.append(pattern.pattern) return warnings第二层是运行沙箱。虽然我们没有用容器,但统一入口脚本会检查当前工作目录,禁止插件在$HOME、/etc等敏感路径下运行。如果插件需要访问项目目录,只能通过环境变量传入路径,不能自行猜测。
第三层是审计日志。每一次工具被调用时,入口脚本都会向本地日志文件追加一条记录,包含工具名、入参摘要、出参摘要、耗时。日志只留最近 7 天的,避免膨胀。
5.2 插件版本的兼容策略
插件发布后不是一劳永逸的。依赖的 Python 版本、底层网络环境变了,插件可能就失灵。我在市场索引里增加了requires_python字段,客户端同步时先检查本机 Python 版本,不满足直接跳过,防止装上去是个炸弹。
版本策略上,我遵循 SemVer。小改动(bugfix)递增 patch 版本,结构性改动(接口变化)递增 minor 版本,大版本只保留前三个。客户端默认锁定 minor 版本,只有显式执行mkt update --major才允许大版本升级。这样即使上游插件有新版本,也不会在团队里瞬间引发破坏性变更。
实践经验:插件市场不适合激进更新,一定要让模型侧的功能保持稳定。我们的教训是,有一次扫号插件升级了 minor 版本,结果新版本把参数名改了,旧会话里模型依然用旧参数名调用,导致连续几次工具执行都返回空结果。后来我们把参数名变更视为 breaking change,必须走 major 版本。
5.3 多环境隔离方案
插件市场服务端支持多环境,通过environment字段区分 prod、staging、dev。开发者在本地调试插件时,发布到 dev 环境;通过了团队的验收脚本后,重发到 staging;在 staging 环境跑一轮回归,最后才升到 prod。
每个环境的索引文件是独立的:
markets/ ├── dev.json ├── staging.json ├── prod.json这样做的好处是,团队内部的插件迭代可以从容进行,不会出现“上午发一个半成品,下午所有同事全部看到”的尴尬局面。我在实际推广中,最受欢迎的就是这个多环境能力,因为很多脚本工具都是先内部验证再推广的。
6. 常见问题与排查技巧实录
6.1 模型没有触发已注册的工具命令
这是最频繁的问题。明明命令已经注册成功,但对话里让模型去“扫描端口”,它偏不调用,反而自己编一个答案给你。
排查路径:
- 查看动态注册文件是否真的写进去了,有时候路径写错,Claude Code 启动时根本读不到。
- 确认工具描述能否被模型规划链路识别。把描述改成“输入目标地址与端口范围,输出 TCP 连接状态、延迟摘要。”,大概率可以解决。之前写的是“网络诊断工具”,模型把它当成闲聊话题,根本没往工具上匹配。
- 检查是否同时注册了多个高相似度命令。如果“net-diagnose”和“network-check”同时存在,模型很容易选错。这种情况要么合并,要么把描述写成完全不同的语义场景。
6.2 脚本执行成功但返回空结果
最常见的原因是 Python 脚本的标准输出被污染了。比如print调试语句没删干净,或者子进程把错误输出打到了stdout。Claude Code 在解析工具返回值时,通常只读取 stdout 的最后一段。如果前面有非 JSON 内容,解析就会失败,或者返回里出现了意想不到的噪音。
我的习惯是:统一入口函数里把业务返回值放到一个专门的result字段中,调试日志一律走stderr,这样即使残留日志也不会污染主输出。
6.3 插件市场服务端并发写入损坏
JSON 文件服务端,最怕的就是两个终端同时执行mkt publish。没有锁的话,大概率会丢数据。我直接在save_index()里加了threading.Lock,实测同时发 20 个请求没有任何问题。但要注意,单机多进程服务(比如用 gunicorn 开了多个 worker)需要额外的文件锁,因为 threading 锁只在单个进程内有效。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 注册工具后模型不调用 | 描述不够具体,工具名相似度高 | 重写描述,输入输出标准化;合并相似命令 |
| 工具返回空 | stdout 被调试输出污染 | 统一入口返回结构,日志走 stderr |
| 插件更新后旧会话调用失败 | 参数名变更被当成非破坏性变更 | 修改参数名必须走 major 版本 |
| 同步后本地不生效 | 动态注册文件未刷新 | 重启 Claude Code 或手动执行 sync 脚本 |
| 重复发布同一插件 | 服务端未做内容摘要比对 | 注册时校验 script_digest |
| 本机 Python 版本太旧 | 插件要求高版本未声明 | 增加 requires_python 字段自动跳过 |
6.5 性能优化的一点心得
随着插件数量增多,同步速度会明显变慢。我后来在客户端增加了一层增量拉取:只有published_at大于本地记录的时间戳才会重新下载,其余直接跳过。这样索引文件即便是几十个插件,同步过程也是毫秒级的。
另一个点是脚本缓存位置。不要把插件缓存放在项目目录里,因为项目会被 Git 提交,缓存文件会被误入库。统一放到~/.claude-mkt/cache里,与项目解耦,才不会有版本混乱的问题。
7. 后续扩展的可能方向
插件市场搭好之后,我又给它加了两层扩展能力:
- 插件依赖管理:现在
plugin.json里支持声明dependencies,客户端同步时自动检测并安装缺失的 Python 包。这样插件的可移植性大大提升,不再要求每台机器预装一堆包。 - 插件组合编排:Claude Code 的工具是单次执行的,但实际业务场景往往需要先扫端口、再查询 IP 归属、再判断是否有已知漏洞。我在市场里提供了“能力链”定义文件,模型可以根据链式描述一次调度多个插件。
这是很值得持续投入的方向。无论是个人开发者还是团队基础设施,拥有一个私有的、可跨机分发的插件市场,才能真正把 AI 工具链变成可积累、可传承的资产。而不是靠某一个人记得所有脚本。这套方案,目前在我们的日常开发里已经稳定跑了两个月,二十几台机器共用同一个市场,没出过大的幺蛾子。
下一步准备把插件的在线编辑器加上,让同事直接在网页上改脚本、提交、看日志,进一步降低上手门槛。如果你也在折腾 Claude Code 的插件能力,希望这篇记录能给你一些可复制的思路。