Skip to content
源码分析手册

--plugin-dir:不是安装插件,而是把本地插件目录临时插进当前 session 的加载优先级

对应官方文档:claude-code-docs/docs/plugins.md 里的 Test your plugins locally

先搞清楚这是什么

官方文档将 --plugin-dir 描述为“本地测试插件”的开发便利开关。但在源码实现中,它并非一种简单的插件加载方式,而是一个会话级旁路源 (Session-only Source Injection)

与正式安装的插件不同,通过 --plugin-dir 加载的插件:

  • 不持久化:不写 installed_plugins.json,会话结束即消失。
  • 不物化:直接从源代码目录读取,不走版本化缓存。
  • 高优先级:同名冲突时,本地目录强制覆盖已安装版。

它的核心意义不在于“安装”,而在于在不改变全局配置的前提下,为当前会话注入一份临时的、未物化的插件视图

实现细节

--plugin-dir 的加载链从 CLI 启动一直贯穿到插件刷新,分为四个关键步骤:

1. CLI 启动时的抢跑式收集 (Pre-action Collection)

src/main.tsx 中,--plugin-dir 被定义为一个可重复的 Flag。它在 preAction 阶段被优先读取,原因很直白:像 plugin listmcp add 这种子命令也需要能够即时识别这些路径。 程序会调用 setInlinePlugins(pluginDir) 将路径写入 src/bootstrap/state.ts 维护的全局内存状态 STATE.inlinePlugins

2. 加载器中的旁路注入 (Session-only Loader)

src/utils/plugins/pluginLoader.ts 中,存在专门的 loadSessionOnlyPlugins(...) 函数。 对于每一个 --plugin-dir 路径:

  1. 路径转换:将其转换为绝对路径并验证存在性。
  2. 原地扫描:调用 createPluginFromPath(...) 直接读取其内部的 manifest、skills、hooks 等组件。
  3. 来源标记:将插件 ID 标记为 ${plugin.name}@inline

3. 三层优先级合并 (Priority Merging)

当系统汇总所有可用插件时,mergePluginSources(...) 遵循严格的优先级:

  1. Session-only Plugins (--plugin-dir):最高优先级。
  2. Marketplace Plugins (已安装版本):次之。
  3. Built-in Plugins (内置版本):最低。 这种“覆盖式合并”确保了你当前正在开发的本地版本总是能盖过磁盘上已安装的旧版,非常方便迭代验证。

4. 彻底的热交换 (Layer-3 Refresh)

当你修改了本地插件目录中的代码并执行 /reload-plugins 时,Claude Code 不是简单地重扫一下文件。 在 src/utils/plugins/refresh.ts 中,这被定义为一次“全量刷新”:

  • 清空所有组件缓存(Memoize)。
  • 重新扫描 inline 路径和 marketplace 路径。
  • 完全卸载旧的 Command/Agent/Hook 实例,重新挂载新实例。
  • 重启 Plugin MCP Server。 这种整套切换保证了本地开发时的修改能被 100% 正确应用,没有任何旧视图残留。

别踩这些坑

  1. 它不是安装命令:即使你在会话里跑得很完美,下次启动如果你不带 --plugin-dir,这个插件还是“没装过”。
  2. 它受组织策略 (Policy) 约束:虽然它能盖过已安装版,但它盖不过 Managed Settings。如果公司政策强制禁用或启用了某个特定插件名,你的 --plugin-dir 副本可能会被直接丢弃并报错。
  3. 路径必须是“插件目录”:你指出的目录里必须包含符合要求的插件结构(通常是包含 .claude-plugin 目录或 manifest 文件),否则加载器会直接跳过或在 plugin list 中报错。
  4. REPL 热更新依赖 reload:改了文件不 reload,当前会话的 AI 可能还在引用旧的上下文快照。

接下来看什么

  • 如果你想深入研究这些组件(Command/Agent/Hook)是如何被挂载到 AI 身上的,请看 loader-and-skills.md(待迁移)。
  • 如果你更关心正式版插件的缓存路径和物化逻辑,请看 plugin-caching-and-resolution.md

源码锚点

  • claude-code-opensource/src/main.tsx
📄 src/main.tsxL70-82 of 4684
tsx
const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js');
const getTeammatePromptAddendum = () => require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js');
const getTeammateModeSnapshot = () => require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js');
/* eslint-enable @typescript-eslint/no-require-imports */
// Dead code elimination: conditional import for COORDINATOR_MODE
/* eslint-disable @typescript-eslint/no-require-imports */
const coordinatorModeModule = feature('COORDINATOR_MODE') ? require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js') : null;
/* eslint-enable @typescript-eslint/no-require-imports */
// Dead code elimination: conditional import for KAIROS (assistant mode)
/* eslint-disable @typescript-eslint/no-require-imports */
const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null;
const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null;
import { relative, resolve } from 'path';
`--plugin-dir` 的 CLI 解析入口,以及为什么要在 `preAction` 阶段就把路径锁死在 session state 里。
  • claude-code-opensource/src/bootstrap/state.ts
📄 src/bootstrap/state.tsL27-29 of 1759
typescript
type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher

import type { SessionId } from 'src/types/ids.js'
维护 `STATE.inlinePlugins` 内存数组,作为会话内唯一的临时插件路径来源。
  • claude-code-opensource/src/utils/plugins/pluginLoader.ts
📄 src/utils/plugins/pluginLoader.tsL109-116 of 3303
typescript
  type CommandMetadata,
  PluginHooksSchema,
  PluginIdSchema,
  PluginManifestSchema,
  type PluginMarketplaceEntry,
  type PluginSource,
} from './schemas.js'
import {
`loadSessionOnlyPlugins(...)` 的核心实现,以及三层优先级合并的逻辑。
  • claude-code-opensource/src/cli/handlers/plugins.ts
📄 src/cli/handlers/plugins.tsL54-58 of 879
typescript
  type ValidationResult,
  validateManifest,
  validatePluginContents,
} from '../../utils/plugins/validatePlugin.js'
import { jsonStringify } from '../../utils/slowOperations.js'
`plugin list` 是如何专门区分并展示 `Session-only plugins (@inline)` 的。
  • claude-code-opensource/src/utils/plugins/refresh.ts
📄 src/utils/plugins/refresh.tsL38-57 of 216
typescript
type SetAppState = (updater: (prev: AppState) => AppState) => void

export type RefreshActivePluginsResult = {
  enabled_count: number
  disabled_count: number
  command_count: number
  agent_count: number
  hook_count: number
  mcp_count: number
  /** LSP servers provided by enabled plugins. reinitializeLspServerManager()
   * is called unconditionally so the manager picks these up (no-op if
   * manager was never initialized). */
  lsp_count: number
  error_count: number
  /** The refreshed agent definitions, for callers (e.g. print.ts) that also
   * maintain a local mutable reference outside AppState. */
  agentDefinitions: AgentDefinitionsResult
  /** The refreshed plugin commands, same rationale as agentDefinitions. */
  pluginCommands: Command[]
}
定义了 Layer-3 刷新机制,揭示了 `/reload-plugins` 为何能实现本地开发时的全量热切。

基于 Claude Code v2.1.88 开源快照的深度分析