Claude Code 源码分析第 09 课 · 第 05
第 09 课

插件系统

市场、版本化缓存、依赖项解析、生命周期挂钩 — Claude Code 如何将 Git 存储库转变为一流的功能。

01 Overview

插件系统是 Claude Code 的可扩展性支柱。 它允许第三方作者(和 Anthropic 本身)发布斜杠命令、MCP 服务器、AI 代理、生命周期挂钩、LSP 集成、技能和输出样式 - 所有这些都来自 Git 存储库或 npm 包 - 而无需触及核心二进制文件。

覆盖源文件
utils/plugins/ — schemas.ts·pluginLoader.ts·marketplaceManager.ts·dependencyResolver.ts·pluginVersioning.ts·zipCache.ts·pluginAutoupdate.ts·pluginBlocklist.ts·pluginPolicy.ts·reconciler.ts

services/plugins/ — PluginInstallationManager.ts ·pluginOperations.ts ·pluginCliCommands.ts

五个概念层 你需要了解:

第 1 层

市场来源

GitHub 存储库、git URL、npm 包、本地目录、URL 托管的 JSON — 插件的来源。

第 2 层

清单架构

plugin.json 声明插件导出的内容:命令、挂钩、MCP 服务器、LSP、技能、代理。

第 3 层

版本化缓存

~/.claude/plugins/cache/{mkt}/{plugin}/{version}/ — 具有种子回退功能的不可变的每个版本快照。

第 4 层

依赖解析

安装时 DFS 关闭行走; 加载时定点降级传递。 跨市场部门被封锁。

第 5 层

Lifecycle

后台协调 → 自动更新 → 加载 → 钩子注册 → 命令注册 → MCP 连接。

02 插件生命周期图

该图跟踪了从用户意图到活动功能的完整插件旅程:

flowchart TD subgraph STARTUP["Startup (non-blocking)"] direction TB S1["getDeclaredMarketplaces()\nMerge settings + --add-dir sources"] S2["diffMarketplaces()\nCompare declared vs known_marketplaces.json"] S3{"Missing or\nsource changed?"} S4["reconcileMarketplaces()\ngit clone / http fetch / npm install"] S5["Auto-refresh plugins\nor set needsRefresh flag"] S1 --> S2 --> S3 S3 -->|"Yes"| S4 --> S5 S3 -->|"No"| S5 end subgraph AUTOUPDATE["Background Autoupdate"] direction TB U1["getAutoUpdateEnabledMarketplaces()\nOfficial = true by default"] U2["refreshMarketplace() — git pull / re-fetch"] U3["updatePluginsForMarketplaces()\nupdatePluginOp() per installation"] U4["Notify REPL via onPluginsAutoUpdated()"] U1 --> U2 --> U3 --> U4 end subgraph INSTALL["Plugin Install (user-triggered)"] direction TB I1["parseMarketplaceInput()\nResolve 'name@marketplace'"] I2["isPluginBlockedByPolicy()"] I3["resolveDependencyClosure()\nDFS walk, cycle detect, cross-mkt block"] I4["For each dep in closure:\ngetPluginById() from marketplace"] I5["installPlugin() — clone / fetch / npm"] I6["calculatePluginVersion()\n1. plugin.json 2. provided 3. git SHA 4. 'unknown'"] I7["copyPluginToVersionedCache()\n~/.claude/plugins/cache/{mkt}/{plugin}/{ver}/"] I8["updateSettingsForSource()\nenablePlugins[id] = true"] I1 --> I2 --> I3 --> I4 --> I5 --> I6 --> I7 --> I8 end subgraph LOAD["Load Phase (cache-only, per session)"] direction TB L1["loadAllPluginsCacheOnly()\nRead settings.enabledPlugins"] L2["verifyAndDemote()\nFixed-point dep check, demote broken"] L3["detectAndUninstallDelistedPlugins()\nAuto-uninstall removed marketplace entries"] L4["loadPluginManifest()\nParse plugin.json via PluginManifestSchema"] L5["Resolve paths:\ncommands/, agents/, skills/, hooks/, mcpServers, lspServers"] L1 --> L2 --> L3 --> L4 --> L5 end subgraph REGISTER["Registration"] direction TB R1["getPluginCommands()\nParse .md files → namespaced /plugin:cmd"] R2["getPluginSkills()\nScan skills/ for SKILL.md subdirs"] R3["loadPluginHooks()\nConvert to PluginHookMatcher[]"] R4["loadPluginAgents()\nRegister agent .md files"] R5["mcpPluginIntegration\nConnect MCP servers, inject userConfig"] R6["lspPluginIntegration\nRegister LSP servers"] R1 & R2 & R3 & R4 & R5 & R6 end STARTUP --> AUTOUPDATE AUTOUPDATE --> LOAD INSTALL --> LOAD LOAD --> REGISTER style STARTUP fill:#0f1a2e,stroke:#7d9ab8,color:#b8b0a4 style AUTOUPDATE fill:#0f2a1a,stroke:#6e9468,color:#b8b0a4 style INSTALL fill:#1a0f2e,stroke:#8e82ad,color:#b8b0a4 style LOAD fill:#1a1a0f,stroke:#b8965e,color:#b8b0a4 style REGISTER fill:#2a0f1a,stroke:#c47a50,color:#b8b0a4
03 市场来源

A marketplace 是一个目录——一个 marketplace.json 列出插件的文件。 Claude Code 支持六种源类型来获取该目录:

github

GitHub 存储库

Clones owner/repo 通过 SSH(或远程模式下的 HTTPS)。 官方市场用途 anthropics/claude-plugins-official.

git

任意 Git URL

HTTPS、SSH(git@), 或者 file://。 在克隆之前进行验证以防止注入。

git-subdir

Monorepo子目录

部分克隆(--filter=tree:0) + 稀疏结帐。 版本包含路径哈希以防止冲突。

url

HTTP/HTTPS 网址

直接获取市场 JSON。 官方市场的 GCS 后备通过 officialMarketplaceGcs.ts.

npm

npm 包

安装到共享 npm-cache/node_modules/ 然后复制。 zip 缓存模式不支持。

目录/文件

本地路径

直接指向本地目录或 JSON 文件。 从 zip 缓存中排除(路径位于缓存目录之外)。

官方市场

官方市场(claude-plugins-official) 是 隐式声明 — 如果任何启用的插件引用它,Claude Code 自动克隆 anthropics/claude-plugins-official 首次启动时。 无需用户配置。

保留名称保护

该架构强制执行两层模拟防御。 这 BLOCKED_OFFICIAL_NAME_PATTERN 正则表达式会阻止名称,例如 official-claude-plugins or anthropic-marketplace-v2。 对于允许列表中的保留名称(例如 claude-plugins-official),源组织必须是 anthropics/ 在 GitHub 上。

深入探讨——名称验证代码
// schemas.ts — blocked pattern for impersonation attempts
export const BLOCKED_OFFICIAL_NAME_PATTERN =
  /(?:official[^a-z0-9]*(anthropic|claude)|(?:anthropic|claude)[^a-z0-9]*official|^(?:anthropic|claude)[^a-z0-9]*(marketplace|plugins|official))/i

// Non-ASCII blocks homograph attacks (Cyrillic 'а' ≠ Latin 'a')
const NON_ASCII_PATTERN = /[^\u0020-\u007E]/

export function validateOfficialNameSource(name, source): string | null {
  // Reserved name? Must come from github.com/anthropics/
  if (source.source === 'github') {
    const repo = source.repo || ''
    if (!repo.toLowerCase().startsWith(`anthropics/`)) {
      return `The name '${name}' is reserved for official Anthropic marketplaces.`
    }
  }
  return null
}
04 清单模式 (plugin.json)

每个插件都可以选择提供一个 plugin.json 在其根源。 Claude Code 使用严格的 Zod 模式解析此内容。 未知的顶级键被默默地剥离(对未来字段的弹性); 嵌套对象内的未知键,例如 userConfig or channels 仍然验证失败。

Field Type Notes
name string 烤肉串盒,没有空格。 用于命名空间命令,如 /plugin:cmd.
version 细绳? 塞姆弗。 最高优先级版本源 — 覆盖 git SHA。
description 细绳? 面向用户的描述如图所示 /plugin list.
dependencies 细绳[]? 裸名称继承声明插件的市场。 默认情况下阻止跨市场。
commands 路径| 路径[] | 记录<名称,元数据> Supplement commands/ 目录。 对象形式支持内联内容。
hooks 路径| 钩子配置 | 大批 Supplement hooks/hooks.json。 支持所有 20 多个生命周期事件。
mcpServers 路径| Mcpb路径| 记录| 大批 Supports .mcpb/.dxt 捆绑包或内联服务器配置对象。
lspServers 路径| 记录| 大批 每个服务器: command, extensionToLanguage 地图、传输、环境、超时。
agents 路径| 小路[] 附加代理 .md 文件超出 agents/ directory.
skills 路径| 小路[] 附加技能目录。 每个必须包含 SKILL.md.
outputStyles 路径| 小路[] 用于自定义渲染的输出样式定义。
channels ChannelDecl[] 消息传递渠道(Telegram、Slack 等)。 绑定 MCP 服务器 + userConfig 提示。
userConfig 记录<键、选项> 启用时提示用户可配置的值。 敏感值进入钥匙串。
settings 记录? 启用插件时要合并的设置。 仅保留允许列表中的密钥(当前: agent).
作者/主页/存储库/许可证/关键字 metadata 发现和归因字段。
完整注释的plugin.json示例
{
  "name": "my-plugin",
  "version": "1.2.0",
  "description": "Example plugin showing all capability types",
  "author": { "name": "Acme Corp", "url": "https://acme.example" },
  "license": "MIT",

  // Declare a dependency — bare name inherits this plugin's marketplace
  "dependencies": ["shared-utils"],

  // Commands: directory is auto-scanned + extra file + inline content
  "commands": {
    "hello": { "content": "Say hello to ${CLAUDE_PLUGIN_ROOT}" },
    "deploy": { "source": "./docs/deploy.md", "argumentHint": "[env]" }
  },

  // Hooks: inline or path
  "hooks": "./hooks/extra.json",

  // MCP server via .mcpb bundle (pre-packaged binary)
  "mcpServers": "./server.mcpb",

  // LSP server for TypeScript
  "lspServers": {
    "typescript": {
      "command": "typescript-language-server",
      "args": ["--stdio"],
      "extensionToLanguage": { ".ts": "typescript", ".tsx": "typescriptreact" }
    }
  },

  // User-configurable values (prompted at enable time)
  "userConfig": {
    "API_KEY": {
      "type": "string",
      "title": "API Key",
      "description": "Your service API key",
      "sensitive": true,   // → stored in keychain, not settings.json
      "required": true
    }
  }
}
插件目录约定

没有任何 plugin.json 字段,插件仍然有效。 Claude Code 自动发现 commands/*.md, agents/*.md, skills/*/SKILL.md, hooks/hooks.json, 和 .mcp.json 按照惯例。 仅当您想要选择非常规路径或声明元数据时,清单才需要存在。

05 版本化缓存

插件安装到不可变的内容寻址缓存中 ~/.claude/plugins/cache/。 路径结构为:

# Path format
~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/

# Example
~/.claude/plugins/cache/claude-plugins-official/sql-helper/1.3.2/
~/.claude/plugins/cache/acme-mkt/deploy-tool/a3f8c1d94b12/
~/.claude/plugins/cache/acme-mkt/monorepo-plugin/a3f8c1d94b12-e5a7c3f1/
                                                          ^^ path hash for git-subdir

版本优先顺序

版本字符串的计算方式为 pluginVersioning.ts 以此优先级:

  1. plugin.json version field — 显式 semver,最高权限
  2. 提供版本 从市场进入(例如,固定在 marketplace.json)
  3. 预解析的 git SHA — 在克隆被丢弃之前捕获(git-subdir 情况)
  4. Git 提交 SHA 读自 .git/HEAD 在安装路径中(前 12 个字符)
  5. 'unknown' 作为最后的手段——仍然创建有效的缓存路径
git-subdir 路径哈希

对于 monorepo 插件 (source: "git-subdir"),同一提交的不同子目录中的两个插件将在同一 SHA 路径上发生冲突。 版本变成 {sha12}-{pathHash8} 其中路径哈希是子目录的规范化 SHA-256。 此规范化与 squashfs cron 逐字节匹配:反斜杠→正斜杠,条带前导 ./, 条带尾随 /.

种子缓存回退

企业部署可以预先填充只读种子目录(通过设置 CLAUDE_CODE_PLUGIN_SEED_DIR)。 安装时,加载程序会在执行任何网络获取之前探测种子。 这可以实现首次运行时零网络的场景。

压缩缓存模式

When CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE=1 设置(无头/容器部署),插件存储为 .zip 文件而不是已安装文件存储上的目录。 在会话开始时,它们被提取到 claude-plugin-session-{hex}/ 临时目录并在退出时清理。 Unix 执行位通过 ZIP 中央目录保存 external_attr 字段 - 对于需要的钩子和脚本至关重要 +x.

深入研究——版本化路径计算
// pluginLoader.ts
export function getVersionedCachePathIn(
  baseDir: string,
  pluginId: string,
  version: string,
): string {
  const { name: pluginName, marketplace } = parsePluginIdentifier(pluginId)
  // Sanitize each segment to prevent path traversal
  const sanitizedMarketplace = (marketplace || 'unknown').replace(/[^a-zA-Z0-9\-_]/g, '-')
  const sanitizedPlugin    = (pluginName    || pluginId).replace(/[^a-zA-Z0-9\-_]/g, '-')
  const sanitizedVersion   = version.replace(/[^a-zA-Z0-9\-_.]/g, '-')
  return join(baseDir, 'cache', sanitizedMarketplace, sanitizedPlugin, sanitizedVersion)
}

// pluginVersioning.ts — git-subdir path hash
const normPath = source.path
  .replace(/\\/g, '/')    // backslash → forward slash
  .replace(/^\.\//, '')    // strip leading ./
  .replace(/\/+$/, '')     // strip trailing /
const pathHash = createHash('sha256').update(normPath).digest('hex').substring(0, 8)
const v = `${shortSha}-${pathHash}`
06 依赖解析

插件系统有两个具有不同语义的依赖解析通道,实现于 dependencyResolver.ts:

Install-time: resolveDependencyClosure()

递归 DFS 遍历,计算要安装的插件的完整传递闭包。 它强制执行三个规则:

  • 循环检测 — 如果插件出现在当前 DFS 堆栈上,则返回 { reason: 'cycle' }
  • 跨市场区块 — 来自市场 X 的插件 A 无法自动安装来自市场 Y 的插件 B(除非 Y 在 X 上) allowCrossMarketplaceDependenciesOn allowlist)
  • 已启用跳过 — 已在设置中的 deps 被跳过以避免破坏版本引脚,但根永远不会被跳过(处理“缓存已清除但仍在设置中”的情况)
安装时 DFS walk — 源代码摘录
export async function resolveDependencyClosure(
  rootId: PluginId,
  lookup: (id: PluginId) => Promise<DependencyLookupResult | null>,
  alreadyEnabled: ReadonlySet<PluginId>,
  allowedCrossMarketplaces: ReadonlySet<string> = new Set(),
): Promise<ResolutionResult> {
  const closure: PluginId[] = []
  const visited = new Set<PluginId>()
  const stack: PluginId[] = []  // DFS stack for cycle detection

  async function walk(id, requiredBy) {
    // Skip already-enabled DEPS (not root) to avoid stomping version pins
    if (id !== rootId && alreadyEnabled.has(id)) return null

    // Cross-marketplace security gate
    const idMkt = parsePluginIdentifier(id).marketplace
    if (idMkt !== rootMarketplace && !allowedCrossMarketplaces.has(idMkt)) {
      return { ok: false, reason: 'cross-marketplace', dependency: id, requiredBy }
    }

    if (stack.includes(id)) return { ok: false, reason: 'cycle', chain: [...stack, id] }
    if (visited.has(id)) return null
    visited.add(id)

    const entry = await lookup(id)
    if (!entry) return { ok: false, reason: 'not-found', missing: id, requiredBy }

    stack.push(id)
    for (const rawDep of entry.dependencies ?? []) {
      const dep = qualifyDependency(rawDep, id)  // inherit marketplace if bare name
      const err = await walk(dep, id)
      if (err) return err
    }
    stack.pop()
    closure.push(id)   // post-order: deps come before dependents
    return null
  }

  const err = await walk(rootId, rootId)
  if (err) return err
  return { ok: true, closure }
}

Load-time: verifyAndDemote()

从仅缓存加载集开始在每个会话上运行。 它是一个 定点循环:降级插件 A(因为缺少其 dep)可能会发现插件 B 依赖于 A,因此 B 也必须被降级。 重复该循环,直到没有发生任何变化。

apt风格语义

Claude Code 的依赖模型受到 Debian apt 的启发:依赖关系是 存在保证,而不是模块导入。 插件 B 依赖于插件 A 意味着“当 B 运行时,A 的命名空间 MCP 服务器、命令和代理必须可用”——而不是 B 的代码导入 A 的代码。

flowchart LR subgraph "Dependency Name Resolution" N1["'shared-utils'\n(bare name)"] -->|"declaring plugin is acme@acme-mkt"| N2["'shared-utils@acme-mkt'\n(qualified)"] N3["'shared-utils@acme-mkt'\n(already qualified)"] --> N3 N4["'shared-utils'\n(from --plugin-dir plugin)"] -->|"@inline sentinel → unchanged"| N4 end style N2 fill:#1a2a0f,stroke:#6e9468,color:#b8b0a4 style N3 fill:#0f1a2a,stroke:#7d9ab8,color:#b8b0a4 style N4 fill:#2a1a0f,stroke:#b8965e,color:#b8b0a4
07 命令、技能和钩子加载

命令和技能

命令位于 commands/*.md。 每个文件变成一个名为的斜杠命令 /plugin-name:command-name。 子目录创建命名空间: commands/ci/build.md/my-plugin:ci:build.

技能是包含以下内容的目录 SKILL.md。 当 Claude Code 找到时 SKILL.md 在子目录中 skills/,它将父目录名称注册为技能名称并注入 ${CLAUDE_SKILL_DIR} 所以该技能可以参考自己的支持文件。

命令和技能中的变量替换

在运行时,这些变量在命令/技能内容中被替换:

Variable决心
${CLAUDE_PLUGIN_ROOT}插件安装目录的绝对路径
${CLAUDE_PLUGIN_DATA}插件的可写数据目录
${CLAUDE_SKILL_DIR}该技能的特定子目录(仅限技能模式)
${CLAUDE_SESSION_ID}当前会话标识符
${user_config.KEY}用户配置选项(敏感键替换为占位符)

Hooks

插件挂钩转换为 PluginHookMatcher[] 对象并与全局钩子配置一起注册。 支持每个钩子事件:

// All 20+ hook events a plugin can subscribe to:
PreToolUse | PostToolUse | PostToolUseFailure | PermissionDenied
Notification | UserPromptSubmit | SessionStart | SessionEnd | Stop | StopFailure
SubagentStart | SubagentStop | PreCompact | PostCompact
PermissionRequest | Setup | TeammateIdle
TaskCreated | TaskCompleted | Elicitation | ElicitationResult
ConfigChange | WorktreeCreate | WorktreeRemove
InstructionsLoaded | CwdChanged | FileChanged
热重载

钩子加载订阅 settingsChangeDetector。 什么时候 enabledPlugins 设置更改(例如,用户启用/禁用插件而不重新启动),插件挂钩会自动重新加载。 快照比较使用 JSON 序列化来检测更改。

08 安全与政策

政策封锁(managed-settings.json)

企业管理员可以通过设置强制禁用任何插件 enabledPlugins["name@marketplace"] = false in managed-settings.json。 这 isPluginBlockedByPolicy() 检查在安装阻塞点、启用操作和 UI 中运行 - 无法被用户或项目设置覆盖的单一事实来源。

删除和自动卸载

当市场设定时 forceRemoveDeletedPlugins: true, Claude Code 比较 installed_plugins.json 与启动时当前的市场清单相对应。 不再列出的插件会自动从所有用户控制的范围中卸载,并记录在标记插件文件中,以防止重新安装循环。

安装范围

插件可以安装在四个范围内。 只有前三个是用户可安装的:

user

用户范围

~/.claude/settings.json — 活跃于该用户的所有项目中。

project

项目范围

.claude/settings.json 在当前项目中——致力于回购。

local

本地范围

.claude/settings.local.json ——特定于项目,不承诺。

managed

管理范围

由组织管理员通过设置 managed-settings.json。 对用户只读。

卸载警告:反向依赖检测
// dependencyResolver.ts
export function findReverseDependents(
  pluginId: PluginId,
  plugins: readonly LoadedPlugin[],
): string[] {
  const { name: targetName } = parsePluginIdentifier(pluginId)
  return plugins
    .filter(p =>
      p.enabled &&
      p.source !== pluginId &&
      (p.manifest.dependencies ?? []).some(d => {
        const qualified = qualifyDependency(d, p.source)
        // Bare dep from @inline plugin: match by name only
        return parsePluginIdentifier(qualified).marketplace
          ? qualified === pluginId
          : qualified === targetName
      }),
    )
    .map(p => p.name)
}
// Result shown as: "warning: required by plugin-a, plugin-b"
09 后台自动更新

启动时, autoUpdateMarketplacesAndPluginsInBackground() 静默运行,不会阻塞 REPL。 该流程分为三个阶段:

  1. 确定哪些市场有 autoUpdate: true — 官方市场默认为 true,第三方默认为 false
  2. Run refreshMarketplace() (git pull / re-fetch)每个自动更新市场
  3. Call updatePluginOp() 对于这些市场中每个已安装的插件

更新内容是 non-in-place:新版本缓存在新版本路径中,但运行会话继续使用旧路径。 REPL 通过以下方式通知 onPluginsAutoUpdated() 并显示重启提示。

竞态条件处理

自动更新完成时,可能尚未安装 REPL。 该模块将更新通知存储在 pendingNotification 并立即交付 onPluginsAutoUpdated() 最终由 REPL 调用。 这可以防止“更新在任何人收听之前完成”静默下降。

要点

  • 插件是一个目录(或 ZIP),带有 plugin.json 显现; 清单是可选的——基于约定的自动发现可以处理大多数情况。
  • 市场是目录。 支持六种源类型:github、git、git-subdir、url、npm、目录/文件。 官方 Anthropic 市场是从引用它的任何插件中隐式声明的。
  • 版本化缓存位于 ~/.claude/plugins/cache/{mkt}/{plugin}/{ver}/ 每个版本都是不可变的。 git-subdir 插件在版本中编码路径哈希以防止 monorepo 冲突。
  • 依赖关系解析使用两个通道:安装时的 DFS 闭包遍历(默认情况下阻止跨市场)和加载时的定点降级通道,以在会话开始时捕获损坏的依赖项。
  • 插件是命名空间的:命令变成 /plugin-name:command,钩子标记为 pluginId, MCP 服务器名称带有前缀。 这可以防止插件之间的冲突。
  • 政策封锁(managed-settings.json) 在安装时、启用时和 UI 中强制执行 - 它不能被用户或项目设置覆盖。
  • 自动更新仅在后台进行且非阻塞。 当前会话看到旧代码; 新版本将被缓存以供下次重新启动。
  • Sensitive userConfig 值(标记为 sensitive: true)转到操作系统钥匙串,而不是 settings.json。 它们在 MCP 服务器环境中可用,但从未替换为发送到模型的技能/代理内容。

知识检查

1. 当插件声明时 "dependencies": ["shared-utils"] 并且插件本身来自市场 acme-mkt,解析的依赖 ID 是什么?
2. 插件 A 启用 OK。 插件 B 依赖于 A。后来,A 从市场中删除并自动卸载。 下次会话开始时 B 会发生什么?
3. 一个插件位于 plugins/my-plugin/ 在 monorepo 中位于 git SHA abc123def456 子目录路径是 ./plugins/my-plugin。 版本是什么样的?
4. 以下哪一项是企业预填充插件缓存的正确路径,该缓存可防止首次运行时进行网络获取?
5. 一个插件集 "sensitive": true 在一个 userConfig 字段称为 API_KEY。 数值存储在哪里,能出现在技能内容中吗?
6. 是什么阻止了名为 anthropic-marketplace-v2 免遭第三方注册?
0/6