Clipping 微信公众号

Plugins从入门到精通(下)

by 朱昆鹏mm 原文 ↗
Created: 2026-06-14

公众号名称:朱昆鹏AI手记

作者名称:朱昆鹏mm

发布时间:2026-05-26 22:18

一.前言

今天我们要”扒一扒”Claude Code 的 plugin 模块源码 看看一个 plugin 从你输入 /plugin install 那一刻,到真正在你的会话里跑起来,中间到底发生了什么

如果你看过我之前写的《Skills 从入门到精通(下)》,你会发现 Plugin 系统比 Skills 复杂得多—— Skills 主要是”读 .md → 注入 prompt”,而 Plugin 涉及到版本化缓存 / 多源合并 / Hook 注册 / MCP 启动 / 自动更新整套体系

本篇主要参考代码路径:

  • src/utils/plugins/ —— 30+ 个工具文件,构成 Plugin 系统的核心
  • src/services/plugins/ —— 上层 CLI 命令与生命周期管理
  • src/commands/plugin/ —— /plugin 交互界面
  • src/plugins/builtinPlugins.ts —— 内置 plugin 注册器

读起来可能有点烧脑,赶快上车要发车了~


二.三层架构模型

读 plugin 源码之前,要先记住一个核心抽象

根据 src/utils/plugins/refresh.ts 顶部注释,Plugin 系统是三层架构

┌──────────────────────────────────────────────┐
│Layer1:Intent(意图层)                     │
│↓                                            │
│ settings.json 里的 enabledPlugins             │
│"我想用哪些插件"                              │
└──────────────────────────────────────────────┘
↓ reconciler
┌──────────────────────────────────────────────┐
│Layer2:Materialization(物化层)            │
│↓                                            │
│~/.claude/plugins/下的实际文件               │
│"插件实际下载到了哪里"                        │
└──────────────────────────────────────────────┘
↓ refresh
┌──────────────────────────────────────────────┐
│Layer3:ActiveComponents(活动组件层)       │
│↓                                            │
│内存里跑着的 commands / agents / hooks / MCP   │
│"插件真正在会话里激活的部分"                   │
└──────────────────────────────────────────────┘

这三层之间是单向流动的:

  • 用户改 settings.json(Layer 1)
  • reconcileMarketplaces() 把市场克隆到本地(Layer 2)
  • refreshActivePlugins() 把组件注入到运行时(Layer 3)

记住这个模型,下面的源码逻辑全部都可以”对号入座”


三.Plugin 安装的全流程

1.入口:用户执行 /plugin install foo@market

入口函数在 src/services/plugins/pluginCliCommands.ts

exportasyncfunction installPlugin(
plugin:string,
scope:InstallableScope='user',
):Promise{
console.log(`Installing plugin "${plugin}"...`)
const result =await installPluginOp(plugin, scope)
// ...
}

可以看到,CLI 层只是个”包装”,真正的核心逻辑在 installPluginOp(位于 pluginOperations.ts

2.解析 plugin 标识

parsePluginIdentifier('formatter@anthropic-tools')
// → { name: 'formatter', marketplace: 'anthropic-tools' }

源码非常简单,就是 split(’@’),但是有个细节——只用第一个 @ 做分隔符

// 源码片段
if(plugin.includes('@')){
const parts = plugin.split('@')
return{ name: parts[0]||'', marketplace: parts[1]}
}

如果你写了 foo@bar@baz,第三段会被默默丢弃

3.解析依赖闭包

根据 dependencyResolver.ts 里的 resolveDependencyClosure()

// 伪代码
asyncfunction resolveDependencyClosure(rootId, lookup, alreadyEnabled){
const closure =[]
const visited =newSet()
asyncfunction dfs(currentId){
if(visited.has(currentId))return  // 环检测
visited.add(currentId)
const entry =await lookup(currentId)
if(!entry)return{ ok:false, reason:'not-found'}
closure.push(currentId)
for(const dep of entry.dependencies ??[]){
const qualified = qualifyDependency(dep, currentId)
if(alreadyEnabled.has(qualified))continue
// 跨市场检查
if(declaringMarket !== depMarket &&!allowedCrossMarkets.has(depMarket)){
return{ ok:false, reason:'cross-marketplace'}
}
await dfs(qualified)
}
}
await dfs(rootId)
return{ ok:true, closure }
}

这是一个带环检测和跨市场授权检查的 DFS

注意:返回的 closure 中已启用的依赖会被跳过——避免”重装一遍依赖”

4.克隆 / 下载到本地缓存

这一步是最重的,按 plugin source 类型分了 6 种处理:

// pluginLoader.ts 简化版
asyncfunction cachePlugin(source:PluginSource, opts){
switch(source.source){
case'github':
// git clone https://github.com/
return cloneFromGitHub(source.repo, source.ref)
case'git':
// git clone 
return cloneFromGit(source.url, source.ref)
case'git-subdir':
// git clone --filter=tree:0 + sparse-checkout
return cloneSubdir(source.url, source.path, source.ref)
case'npm':
// npm pack 然后解压
return downloadNpmPackage(source.package, source.version, source.registry)
case'pip':
// pip download
return downloadPipPackage(source.package, source.version)
case'url':
// axios get
return downloadFromUrl(source.url)
}
}

最特殊的是 git-subdir——它用 --filter=tree:0部分克隆,只下载需要的子目录,对超大 monorepo 来说节省好几个 GB

5.写入版本化缓存

下载完不会直接用,会按版本号复制一份到版本化目录

~/.claude/plugins/cache/
└──/
└──/
└──/      ←这就是 ${CLAUDE_PLUGIN_ROOT}
├──.claude-plugin/
├── commands/
├── hooks/
└──...

版本号的计算逻辑在 pluginVersioning.tscalculatePluginVersion优先级是:

1. plugin.json 里的 version 字段
2.调用方传入的 providedVersion(来自 marketplace 入口)
3.Git commit SHA 前12位
4.Git SHA +子目录 hash(git-subdir 特殊处理)
5.'unknown'

注意 git-subdir 的版本号特别——它是 -为啥?因为同一个 monorepo 的同一个 commit 下可能有多个 plugin 子目录,光用 SHA 会哈希冲突

6.写入 installed_plugins.json

最后会把安装信息记录到 ~/.claude/plugins/installed_plugins.json

{
"version":2,
"plugins":{
"formatter@anthropic-tools":[
{
"scope":"user",
"installPath":"/Users/me/.claude/plugins/cache/anthropic-tools/formatter/1.0.0",
"version":"1.0.0",
"installedAt":"2026-05-21T10:30:00Z",
"gitCommitSha":"abc123..."
}
]
}
}

这是个 V2 格式——每个 plugin 可以有多个安装条目(不同 scope) 比如同一个插件可以在 user scope 装 1.0.0,在 project scope 装 1.1.0

7.写入 settings.json 的 enabledPlugins

最后激活,要在 settings.json 写入意图:

{
"enabledPlugins":{
"formatter@anthropic-tools":true
}
}

至此,安装流程结束


四.Plugin 启动加载流程

讲完安装,咱们看 plugin 每次启动怎么加载

1.入口:loadAllPlugins / loadAllPluginsCacheOnly

源码 pluginLoader.ts 提供了两个加载入口:

// 完整加载(会做网络克隆)
exportconst loadAllPlugins = memoize(async()=>{
return assemblePluginLoadResult(()=>
loadPluginsFromMarketplaces({ cacheOnly:false}),
)
})
// 仅缓存加载(不联网)
exportconst loadAllPluginsCacheOnly = memoize(async()=>{
return assemblePluginLoadResult(()=>
loadPluginsFromMarketplaces({ cacheOnly:true}),
)
})

两者都用了 lodash 的 memoize 包装,意味着同一进程内只会执行一次

启动时优先用 loadAllPluginsCacheOnly——避免阻塞交互 后台再异步跑 loadAllPlugins 做新鲜下载

2.assemblePluginLoadResult 的三个步骤

源码节选:

asyncfunction assemblePluginLoadResult(marketplaceLoader){
// 步骤 1: 三个来源**并行**加载
const[marketplaceResult, sessionResult]=awaitPromise.all([
marketplaceLoader(),                          // 来自 marketplace
loadSessionOnlyPlugins(getInlinePlugins()),   // --plugin-dir 临时插件
])
const builtinResult = getBuiltinPlugins()        // 内置 plugin
// 步骤 2: 三源合并(处理冲突)
const{ plugins, errors }= mergePluginSources({
session: sessionResult.plugins,
marketplace: marketplaceResult.plugins,
builtin:[...builtinResult.enabled,...builtinResult.disabled],
managedNames: getManagedPluginNames(),
})
// 步骤 3: 校验依赖,降级不满足的插件
const{ demoted }= verifyAndDemote(plugins)
for(const p of plugins){
if(demoted.has(p.source)) p.enabled =false
}
return{
enabled: plugins.filter(p => p.enabled),
disabled: plugins.filter(p =>!p.enabled),
errors,
}
}

冲突规则(在 mergePluginSources 里):

  • 同名时:session > installed(CLI 命令行临时指定的优先)
  • 例外:managed > session(企业策略最高优先)

3.每个 plugin 的内部结构怎么构建?

源码 createPluginFromPath() 是核心:

asyncfunction createPluginFromPath(pluginPath, source, enabled, fallbackName){
// 1. 加载 plugin.json
const manifest =await loadPluginManifest(
join(pluginPath,'.claude-plugin','plugin.json'),
fallbackName,
source,
)
// 2. 并行探测各种子目录是否存在
const[
commandsDirExists,
agentsDirExists,
skillsDirExists,
outputStylesDirExists,
]=awaitPromise.all([
pathExists(join(pluginPath,'commands')),
pathExists(join(pluginPath,'agents')),
pathExists(join(pluginPath,'skills')),
pathExists(join(pluginPath,'output-styles')),
])
// 3. 加载 hooks/hooks.json
const standardHooksPath = join(pluginPath,'hooks','hooks.json')
if(await pathExists(standardHooksPath)){
plugin.hooksConfig =await loadPluginHooks(standardHooksPath, manifest.name)
}
// 4. 加载 plugin settings(白名单过滤)
plugin.settings =await loadPluginSettings(pluginPath, manifest)
return{ plugin, errors }
}

简单总结流程:读 manifest → 探测目录 → 加载 hooks/settings注意所有 IO 都用 Promise.all 并行化,否则启动会肉眼可见地变慢


五.各组件的加载机制

Plugin 加载完成后,每种”能力”都有专门的加载器

1.Commands 加载

文件: src/utils/plugins/loadPluginCommands.ts

function getCommandNameFromFile(filePath, baseDir, pluginName){
// 拼装命令名
// commands/build/deploy.md → /pluginName:build:deploy
// commands/format.md       → /pluginName:format
}

加载完后会被注入到全局 commands 系统,与 Skills 一起被 /skills 列表使用

2.Agents 加载

文件: loadPluginAgents.ts

exportconst loadPluginAgents = memoize(async()=>{
const{ enabled }=await loadAllPluginsCacheOnly()
const agents =[]
for(const plugin of enabled){
// 1. 读取标准 agents/ 目录
if(plugin.agentsPath){
agents.push(...await loadAgentsFromDirectory(...))
}
// 2. 读取 manifest.agents 声明的额外路径
if(plugin.manifest.agents){
// ...
}
}
return agents
})

3.Hooks 加载(最复杂的一个)

文件: loadPluginHooks.ts

exportconst loadPluginHooks = memoize(async()=>{
const{ enabled }=await loadAllPluginsCacheOnly()
const allPluginHooks ={}// 按 event 分组的所有 hook
// 遍历所有启用的 plugin,把 hooks 收集到一起
for(const plugin of enabled){
if(!plugin.hooksConfig)continue
const pluginMatchers = convertPluginHooksToMatchers(plugin)
for(constevent of Object.keys(pluginMatchers)){
allPluginHooks[event].push(...pluginMatchers[event])
}
}
// ⚠️ 关键:原子的清+注册
clearRegisteredPluginHooks()
registerHookCallbacks(allPluginHooks)
})

为什么”原子”很关键?

源码注释里有一个血泪教训(gh-29767 issue):

之前 clear 是单独函数,意味着 clearAllCaches() 调用之后,plugin hooks 会被擦除直到下一次 loadPluginHooks这就导致 Stop hook 在某些情况下永远不触发

现在把 clear+register 合并成原子操作,保证旧 hook 一直有效直到新 hook 注册完成

4.MCP 服务器加载

文件: mcpPluginIntegration.ts

启动 MCP 之前要做变量替换

// 源码节选
function resolvePluginMcpEnvironment(config, plugin, userConfig){
const resolveValue =(value:string)=>{
// 1. 替换 plugin 变量(${CLAUDE_PLUGIN_ROOT})
let resolved = substitutePluginVariables(value, plugin)
// 2. 替换 user_config 变量
if(userConfig) resolved = substituteUserConfigVariables(resolved, userConfig)
// 3. 替换标准环境变量
return expandEnvVarsInString(resolved)
}
// 替换 command、args、env...
config.env ={
CLAUDE_PLUGIN_ROOT: plugin.path,
CLAUDE_PLUGIN_DATA: getPluginDataDir(plugin.source),
...resolvedUserEnv,
}
}

替换完后,调用 MCP SDK 启动子进程

5.LSP 服务器加载

文件: lspPluginIntegration.ts

逻辑和 MCP 类似,区别是:

  • LSP 用的是 stdio/socket 协议
  • 通过 extensionToLanguage 映射文件类型
  • 支持 restartOnCrash 配置自动重启

6.Skills 加载

Skill 文件被当作”特殊的 command”加载 看 walkPluginMarkdown.ts 里的 SKILL_MD_RE

const SKILL_MD_RE =/^skill\.md$/i
// 扫描时如果发现 SKILL.md,stop 当前目录的子目录递归
if(opts.stopAtSkillDir && entries.some(e => SKILL_MD_RE.test(e.name))){
// skill 目录是叶节点
return
}

也就是说,skills 目录是 “叶容器”——遇到 SKILL.md 就停止往下递归


六.refresh:让运行中的会话感知 plugin 变化

最后一个话题:热重载

1.refreshActivePlugins

文件: refresh.ts

这个函数被三个地方调用:

  1. 用户输入 /reload-plugins 命令
  2. 后台安装新市场之后
  3. headless 模式启动前

核心逻辑:

exportasyncfunction refreshActivePlugins(setAppState){
// 1. 清除所有缓存
resetSettingsCache()
clearAllCaches()
// 2. 重新加载所有插件
await loadAllPlugins()
// 3. 重建所有组件
const[commands, agents]=awaitPromise.all([
getPluginCommands(),
getAgentDefinitionsWithOverrides(getOriginalCwd()),
])
await loadPluginHooks()         // 重注册 hooks
await loadPluginMcpServers()    // 重启 MCP
// 4. 重新初始化 LSP(lazy 启动)
reinitializeLspServerManager()
// 5. 更新 React state
setAppState(prev =>({...prev,/* ... */}))
}

2.热重载 vs 重启

不是所有改动都可以热重载:

变更是否需要重启
启用/禁用 plugin❌ 不需要(自动热重载)
添加新市场❌ 不需要
更新 plugin(autoupdate)需要重启
修改 hook 脚本内容❌ 不需要(脚本每次都是磁盘读)
修改 plugin.json⚠️ 需要 /reload-plugins

为啥 autoupdate 后需要重启? 因为 plugin 版本化路径变了,已经在跑的 hook 脚本路径已失效


八.安全机制汇总

最后我们来汇总一下 plugin 系统里的所有安全设计

1.信任目录检查

performStartupChecks.tsx

if(!checkHasTrustDialogAccepted()){
// 当前目录没被信任,跳过自动安装
return
}

新 clone 的仓库默认不会自动装 plugin,必须用户先确认信任

2.官方市场保护

schemas.ts 里的 ALLOWED_OFFICIAL_MARKETPLACE_NAMESisBlockedOfficialName()

  • 保留 8 个官方名称只允许 github.com/anthropics/ 使用
  • 用正则拦截”看着像官方”的山寨名字
  • NON_ASCII_PATTERN 拦截 Unicode 同形字攻击(比如用西里尔字母 а 冒充拉丁字母 a)

3.跨市场依赖默认禁止

dependencyResolver.ts

  • 默认情况下,A 市场的 plugin 不能自动安装 B 市场的依赖
  • 必须根市场显式声明 allowCrossMarketplaceDependenciesOn
  • 这避免了”装一个就被悄悄装一堆”

4.敏感配置走 keychain

pluginOptionsStorage.ts

  • userConfig 里 sensitive:true 的字段进 macOS keychain
  • 普通字段才进 settings.json
  • skill/agent 内容里 ${user_config.} 被替换成占位符,绝不进入模型上下文

5.企业策略锁定

managedPlugins.ts

  • 公司管理员可以在 policySettings 里强制启用或禁用某些 plugin
  • 用户连 disable 命令都改不动

6.MCP/LSP 沙箱化变量

变量替换流程对 $ 模式做了安全防御(NTFS 路径有 $$、$'、$、$&` 等特殊符号):

// 用 function-replacement 形式避免 $ 模式被解释
out= value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g,()=> normalize(plugin.path))

八.写一个最小可读源码版

最后给大家一个学习路径——按这个顺序读源码,最容易看懂:

1. src/utils/plugins/schemas.ts            ←看Zod定义先理解数据结构
2. src/utils/plugins/pluginIdentifier.ts   ←看怎么解析 plugin@market
3. src/plugins/builtinPlugins.ts           ←看最简单的"内置 plugin"是怎么工作的
4. src/utils/plugins/pluginLoader.ts       ←入口加载器,慢慢看
├─ loadPluginManifest                   ←怎么读 plugin.json
├─ createPluginFromPath                 ←怎么构建LoadedPlugin
├─ assemblePluginLoadResult             ←三源合并
└─ loadAllPlugins                       ←顶层入口
5. src/utils/plugins/dependencyResolver.ts ←依赖解析(DFS+环检测)
6. src/utils/plugins/marketplaceManager.ts ←市场克隆和缓存
7. src/utils/plugins/reconciler.ts         ←三层架构的Layer1→2
8. src/utils/plugins/refresh.ts            ←三层架构的Layer2→3
9. src/utils/plugins/loadPluginHooks.ts    ←Hook注册的原子性
10. src/utils/plugins/cacheUtils.ts        ←孤儿清理与7天规则

九.结尾

下篇就到这里了,我们把三篇内容做个最终汇总

三篇总览

篇章核心内容
上篇Plugin 是什么 / 目录结构 / 基本使用 / Marketplace 简介 / 三种 scope
中篇manifest 完整字段 / 模板变量 / Marketplace 详解 / 6 种 plugin source / 三方库推荐
下篇三层架构 / 安装流程 / 启动加载 / 缓存机制 / 7 天孤儿清理 / refresh 热重载 / 安全机制

我个人的几点感受

读完整套 plugin 源码,几个让我印象深刻的设计:

  1. 三层架构清晰 —— intent / materialization / active 三层完全独立,调试任何 plugin 问题都能”对号入座”
  2. 缓存极致谨慎 —— 7 天孤儿规则、双重内存缓存、原子 hook 注册,所有缓存策略都从”避免数据竞争”角度设计
  3. 变量替换有讲究 —— 函数式 replace 避免 $ 模式被误解释,sensitive 字段绝不进模型上下文
  4. 安全防线层层叠加 —— 信任目录 → 官方名保护 → 跨市场禁用 → keychain → 企业策略
  5. 错误优雅降级 —— 一个 plugin 报错不影响其他 plugin 继续加载,符合 graceful degradation 原则

学完之后能干什么

读完《Plugins 从入门到精通》三篇文章,我建议你自己去写一个plugins,体会一下plugins的各个细节~

本篇文章就到此,希望对你有所帮助,欢迎给我点个赞 我会持续输出 Claude Code 相关的技术干货

我们明天再见~

往期推荐

Plugins 从入门到精通(上)

Plugins从入门到精通(中)

skills从入门到精通(上)

Skills 从入门到精通(中)

Skills 从入门到精通(下)


内容效果不满意?点此反馈

输入关键词开始搜索