--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 list、mcp add 这种子命令也需要能够即时识别这些路径。 程序会调用 setInlinePlugins(pluginDir) 将路径写入 src/bootstrap/state.ts 维护的全局内存状态 STATE.inlinePlugins。
2. 加载器中的旁路注入 (Session-only Loader)
在 src/utils/plugins/pluginLoader.ts 中,存在专门的 loadSessionOnlyPlugins(...) 函数。 对于每一个 --plugin-dir 路径:
- 路径转换:将其转换为绝对路径并验证存在性。
- 原地扫描:调用
createPluginFromPath(...)直接读取其内部的 manifest、skills、hooks 等组件。 - 来源标记:将插件 ID 标记为
${plugin.name}@inline。
3. 三层优先级合并 (Priority Merging)
当系统汇总所有可用插件时,mergePluginSources(...) 遵循严格的优先级:
- Session-only Plugins (
--plugin-dir):最高优先级。 - Marketplace Plugins (已安装版本):次之。
- 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% 正确应用,没有任何旧视图残留。
别踩这些坑
- 它不是安装命令:即使你在会话里跑得很完美,下次启动如果你不带
--plugin-dir,这个插件还是“没装过”。 - 它受组织策略 (Policy) 约束:虽然它能盖过已安装版,但它盖不过 Managed Settings。如果公司政策强制禁用或启用了某个特定插件名,你的
--plugin-dir副本可能会被直接丢弃并报错。 - 路径必须是“插件目录”:你指出的目录里必须包含符合要求的插件结构(通常是包含
.claude-plugin目录或 manifest 文件),否则加载器会直接跳过或在plugin list中报错。 - 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.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.ts
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.ts
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.ts
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.ts
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` 为何能实现本地开发时的全量热切。