插件与技能:三层可扩展性架构

深入 Claude Code 的可扩展性设计——Skill 的 Markdown frontmatter 配置、Plugin 两级注册、MCP Server 动态加载,三层如何统一管理

问题引入

当你在 Claude Code 中输入 /simplify,它会自动对你最近修改的代码进行三维度审查——代码复用、质量和效率。当你安装一个 Plugin,它提供的 Skill 会自动出现在可用列表中。当 MCP Server 暴露 Prompt 并附带特定的 frontmatter 标记时,这些 Prompt 也会被转化为 Skill 供模型调用。

这背后是一套三层可扩展性架构:

  • Skill 层:以 Markdown 文件为载体的技能定义,通过 frontmatter 声明元数据,支持多来源加载、参数替换、条件激活
  • Plugin 层:将多个 Skill、Hooks、MCP Server 打包为可安装的扩展单元,分为 Built-in 和 Marketplace 两级
  • MCP 层:通过 MCP 协议从远程 Server 动态发现并加载 Skill

三层各有独立的注册机制,但最终统一汇聚到一个 Command[] 数组中,由 SkillTool 作为唯一入口向模型暴露。本文将从 Skill 系统的文件加载开始,逐层深入这套可扩展性架构的设计与实现。

Skill 系统全景

核心数据结构:Command

所有 Skill 最终都被表示为 Command 类型。理解这个类型是理解整个系统的基础:

src/types/command.ts
TypeScript
1export type PromptCommand = {
2 type: 'prompt'
3 progressMessage: string
4 contentLength: number
5 argNames?: string[]
6 allowedTools?: string[]
7 model?: string
8 source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
9 pluginInfo?: {
10 pluginManifest: PluginManifest
11 repository: string
12 }
13 hooks?: HooksSettings
14 skillRoot?: string
15 context?: 'inline' | 'fork'
16 agent?: string
17 effort?: EffortValue
18 paths?: string[]
19 getPromptForCommand(
20 args: string,
21 context: ToolUseContext,
22 ): Promise<ContentBlockParam[]>
23}
24
25export type Command = CommandBase &
26 (PromptCommand | LocalCommand | LocalJSXCommand)

source 字段标记了 Skill 的来源——'bundled' 表示编译进 CLI 的内置技能,'plugin' 来自 Plugin,'mcp' 来自 MCP Server,而 SettingSource'userSettings''projectSettings''policySettings')对应不同目录加载的文件 Skill。

getPromptForCommand 是核心方法:它接收用户参数和工具上下文,返回注入到对话中的 prompt 内容。不同来源的 Skill 在这个方法中实现各自的参数替换、shell 命令执行和安全策略。

多来源加载架构

Skill 来源
Managed Skills
(Policy 目录)
User Skills
(~/.claude/skills/)
Project Skills
(.claude/skills/)
Legacy Commands
(.claude/commands/)
Bundled Skills
(编译内置)
Plugin Skills
(Marketplace)
MCP Skills
(远程 Server)
Dynamic Skills
(运行时发现)
加载器
loadSkillsFromSkillsDir()
loadSkillsFromCommandsDir()
registerBundledSkill()
getBuiltinPluginSkillCommands()
mcpSkillBuilders
统一注册
getSkillDirCommands()
memoized
getCommands()
合并所有来源
SkillTool
模型唯一入口

加载优先级按此顺序执行,遇到同名 Skill 时先加载的胜出。

文件 Skill 的加载:loadSkillsDir.ts

文件 Skill 的核心加载逻辑在 loadSkillsDir.ts 中。这个文件超过 1000 行,是整个 Skill 系统最复杂的模块。

目录结构约定

Skills 目录只支持目录格式:每个 Skill 是一个目录,内含 SKILL.md 文件。

流程
.claude/skills/
├── review-code/
│ └── SKILL.md # Skill 定义
├── deploy/
│ ├── SKILL.md # Skill 定义
│ └── scripts/
│ └── deploy.sh # 辅助文件
└── frontend:lint/ # 命名空间 "frontend:lint"
└── SKILL.md

加载函数 loadSkillsFromSkillsDir 遍历目录,逐个读取 SKILL.md

src/skills/loadSkillsDir.ts
TypeScript
1async function loadSkillsFromSkillsDir(
2 basePath: string,
3 source: SettingSource,
4): Promise<SkillWithPath[]> {
5 const fs = getFsImplementation()
6
7 let entries
8 try {
9 entries = await fs.readdir(basePath)
10 } catch (e: unknown) {
11 if (!isFsInaccessible(e)) logError(e)
12 return []
13 }
14
15 const results = await Promise.all(
16 entries.map(async (entry): Promise<SkillWithPath | null> => {
17 try {
18 // 只支持目录格式:skill-name/SKILL.md
19 if (!entry.isDirectory() && !entry.isSymbolicLink()) {
20 return null
21 }
22
23 const skillDirPath = join(basePath, entry.name)
24 const skillFilePath = join(skillDirPath, 'SKILL.md')
25
26 let content: string
27 try {
28 content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
29 } catch (e: unknown) {
30 if (!isENOENT(e)) {
31 logForDebugging(
32 `[skills] failed to read ${skillFilePath}: ${e}`,
33 { level: 'warn' },
34 )
35 }
36 return null
37 }
38
39 const { frontmatter, content: markdownContent } = parseFrontmatter(
40 content, skillFilePath,
41 )
42
43 const skillName = entry.name
44 const parsed = parseSkillFrontmatterFields(
45 frontmatter, markdownContent, skillName,
46 )
47 const paths = parseSkillPaths(frontmatter)
48
49 return {
50 skill: createSkillCommand({
51 ...parsed,
52 skillName,
53 markdownContent,
54 source,
55 baseDir: skillDirPath,
56 loadedFrom: 'skills',
57 paths,
58 }),
59 filePath: skillFilePath,
60 }
61 } catch (error) {
62 logError(error)
63 return null
64 }
65 }),
66 )
67
68 return results.filter((r): r is SkillWithPath => r !== null)
69}

注意几个设计要点:

  1. 只支持目录格式。单个 .md 文件在 /skills/ 目录下会被忽略,这确保每个 Skill 可以携带辅助文件(脚本、模板等)。
  2. 符号链接支持isSymbolicLink() 检查使得 Skill 可以通过 symlink 共享。
  3. 并行加载。使用 Promise.all 同时读取所有 Skill 文件。
  4. 优雅降级。单个 Skill 的加载失败不影响其他 Skill。

Frontmatter 元数据解析

SKILL.md 文件的 frontmatter 是 Skill 行为的完整声明。parseSkillFrontmatterFields 函数处理所有可能的字段:

src/skills/loadSkillsDir.ts
TypeScript
1export function parseSkillFrontmatterFields(
2 frontmatter: FrontmatterData,
3 markdownContent: string,
4 resolvedName: string,
5 descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
6): {
7 displayName: string | undefined
8 description: string
9 hasUserSpecifiedDescription: boolean
10 allowedTools: string[]
11 argumentHint: string | undefined
12 argumentNames: string[]
13 whenToUse: string | undefined
14 version: string | undefined
15 model: ReturnType<typeof parseUserSpecifiedModel> | undefined
16 disableModelInvocation: boolean
17 userInvocable: boolean
18 hooks: HooksSettings | undefined
19 executionContext: 'fork' | undefined
20 agent: string | undefined
21 effort: EffortValue | undefined
22 shell: FrontmatterShell | undefined
23} {
24 // ...解析逻辑
25}

一个完整的 SKILL.md frontmatter 示例:

MARKDOWN
1---
2name: "代码审查助手"
3description: "对 Git 变更进行多维度代码审查"
4when_to_use: "当用户要求审查代码或提交 PR 时触发"
5allowed-tools:
6 - Bash(git:*)
7 - Read
8 - Grep
9arguments:
10 - branch
11 - focus_area
12argument-hint: "<branch> [focus_area]"
13model: sonnet
14effort: high
15context: fork
16agent: general-purpose
17user-invocable: true
18disable-model-invocation: false
19paths:
20 - "src/**"
21 - "lib/**"
22hooks:
23 PreToolUse:
24 - matcher: Bash
25 hooks:
26 - type: command
27 command: echo "Reviewing..."
28shell:
29 type: bash
30 command: /bin/bash
31---
32
33## 审查流程
34
35对 $ARGUMENTS 分支的变更进行以下审查...
36
37当前 Session ID: ${CLAUDE_SESSION_ID}
38Skill 目录: ${CLAUDE_SKILL_DIR}

这些 frontmatter 字段的语义值得逐一解释:

字段类型用途
namestring显示名(不影响命令名)
descriptionstring简短描述,用于 Skill 列表
when_to_usestring告诉模型何时应该调用此 Skill
allowed-toolsstring[]Skill 执行期间额外允许的工具
argumentsstring/string[]命名参数列表
modelstring模型覆盖(如 sonnetopusinherit
effortEffortValue推理努力级别
context'fork'是否在子 Agent 中执行
pathsstring[]条件激活的文件路径模式
hooksHooksSettingsSkill 级别的 Hook 配置
user-invocableboolean用户是否可以通过 /name 调用
disable-model-invocationboolean禁止模型主动调用

参数替换机制

Skill 的 prompt 内容支持多种参数替换:

src/skills/loadSkillsDir.ts
TypeScript
1async getPromptForCommand(args, toolUseContext) {
2 let finalContent = baseDir
3 ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
4 : markdownContent
5
6 // 1. 用户参数替换:$ARGUMENTS, $1, $2, 或命名参数
7 finalContent = substituteArguments(
8 finalContent, args, true, argumentNames,
9 )
10
11 // 2. 内置变量替换
12 if (baseDir) {
13 const skillDir =
14 process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir
15 finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
16 }
17
18 finalContent = finalContent.replace(
19 /\$\{CLAUDE_SESSION_ID\}/g,
20 getSessionId(),
21 )
22
23 // 3. Shell 命令执行(仅非 MCP Skill)
24 if (loadedFrom !== 'mcp') {
25 finalContent = await executeShellCommandsInPrompt(
26 finalContent, toolUseContext, `/${skillName}`, shell,
27 )
28 }
29
30 return [{ type: 'text', text: finalContent }]
31}

这里有三层替换:

  1. 用户参数$ARGUMENTS 被替换为完整的参数字符串,$1/$2 被替换为位置参数,命名参数 ${branch} 被替换为对应的值。
  2. 内置变量${CLAUDE_SKILL_DIR} 指向 Skill 所在目录,${CLAUDE_SESSION_ID} 是当前会话 ID。
  3. Shell 命令:Skill 内容中的 !`command````! command ``` 语法会被实际执行,输出替换到 prompt 中。这是 Skill 与环境交互的关键能力——但出于安全考虑,MCP 来源的 Skill 禁止执行 shell 命令。

多级来源聚合与去重

getSkillDirCommands 是文件 Skill 的聚合入口。它使用 memoize 缓存结果,从五个来源并行加载 Skill:

src/skills/loadSkillsDir.ts
TypeScript
1export const getSkillDirCommands = memoize(
2 async (cwd: string): Promise<Command[]> => {
3 const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
4 const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
5 const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
6
7 const [
8 managedSkills,
9 userSkills,
10 projectSkillsNested,
11 additionalSkillsNested,
12 legacyCommands,
13 ] = await Promise.all([
14 loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
15 loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
16 Promise.all(
17 projectSkillsDirs.map(dir =>
18 loadSkillsFromSkillsDir(dir, 'projectSettings'),
19 ),
20 ),
21 Promise.all(
22 additionalDirs.map(dir =>
23 loadSkillsFromSkillsDir(
24 join(dir, '.claude', 'skills'), 'projectSettings',
25 ),
26 ),
27 ),
28 loadSkillsFromCommandsDir(cwd),
29 ])
30
31 // 合并所有来源
32 const allSkillsWithPaths = [
33 ...managedSkills,
34 ...userSkills,
35 ...projectSkillsNested.flat(),
36 ...additionalSkillsNested.flat(),
37 ...legacyCommands,
38 ]
39
40 // ... 去重和条件 Skill 分离
41 },
42)

注意来源的优先级顺序:Managed(企业策略)> User(用户全局)> Project(项目级)> Additional(--add-dir)> Legacy(旧版 /commands/)。

基于 realpath 的去重

多个来源可能通过不同路径指向同一个文件(如 symlink)。系统使用 realpath 解析到规范路径来去重:

src/skills/loadSkillsDir.ts
TypeScript
1async function getFileIdentity(filePath: string): Promise<string | null> {
2 try {
3 return await realpath(filePath)
4 } catch {
5 return null
6 }
7}

去重过程先并行计算所有文件的 identity,然后同步扫描去除重复:

src/skills/loadSkillsDir.ts
TypeScript
1const fileIds = await Promise.all(
2 allSkillsWithPaths.map(({ skill, filePath }) =>
3 skill.type === 'prompt'
4 ? getFileIdentity(filePath)
5 : Promise.resolve(null),
6 ),
7)
8
9const seenFileIds = new Map<string, SettingSource | ...>()
10const deduplicatedSkills: Command[] = []
11
12for (let i = 0; i < allSkillsWithPaths.length; i++) {
13 const entry = allSkillsWithPaths[i]
14 if (entry === undefined || entry.skill.type !== 'prompt') continue
15 const { skill } = entry
16
17 const fileId = fileIds[i]
18 if (fileId === null || fileId === undefined) {
19 deduplicatedSkills.push(skill)
20 continue
21 }
22
23 const existingSource = seenFileIds.get(fileId)
24 if (existingSource !== undefined) {
25 logForDebugging(
26 `Skipping duplicate skill '${skill.name}' from ${skill.source}`,
27 )
28 continue
29 }
30
31 seenFileIds.set(fileId, skill.source)
32 deduplicatedSkills.push(skill)
33}

这个设计选择了 realpath 而非 inode 比较,因为某些文件系统(NFS、ExFAT、容器内虚拟 FS)报告的 inode 值不可靠。先并行做 IO(Promise.all 获取 identity),再同步做逻辑判断(遍历去重),是性能和正确性兼顾的典型模式。

Paths 条件激活

Skill 可以通过 paths frontmatter 声明自己只在特定文件路径下生效:

YAML
1---
2paths:
3 - "src/components/**"
4 - "src/styles/**"
5---

当 Skill 带有 paths 字段时,它不会立即加载到可用列表,而是存储在 conditionalSkills Map 中。只有当模型操作匹配路径的文件时,Skill 才被激活:

src/skills/loadSkillsDir.ts
TypeScript
1export function activateConditionalSkillsForPaths(
2 filePaths: string[],
3 cwd: string,
4): string[] {
5 if (conditionalSkills.size === 0) {
6 return []
7 }
8
9 const activated: string[] = []
10
11 for (const [name, skill] of conditionalSkills) {
12 if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) {
13 continue
14 }
15
16 const skillIgnore = ignore().add(skill.paths)
17 for (const filePath of filePaths) {
18 const relativePath = isAbsolute(filePath)
19 ? relative(cwd, filePath)
20 : filePath
21
22 if (
23 !relativePath ||
24 relativePath.startsWith('..') ||
25 isAbsolute(relativePath)
26 ) {
27 continue
28 }
29
30 if (skillIgnore.ignores(relativePath)) {
31 dynamicSkills.set(name, skill)
32 conditionalSkills.delete(name)
33 activatedConditionalSkillNames.add(name)
34 activated.push(name)
35 break
36 }
37 }
38 }
39
40 if (activated.length > 0) {
41 skillsLoaded.emit()
42 }
43
44 return activated
45}

这里使用了 ignore 库(与 .gitignore 相同的 glob 匹配规则),匹配后将 Skill 从 conditionalSkills 移到 dynamicSkills,并通过信号通知其他模块清除缓存。activatedConditionalSkillNames Set 确保重新加载 Skill(缓存清除后)时已激活的 Skill 不会被重新降级为条件 Skill。

这种"懒激活"设计有明确的性能目的:一个项目可能有大量 Skill,但不是所有 Skill 都与当前工作相关。通过路径过滤,Skill 列表保持精简,减少系统提示词中的 token 消耗。

动态 Skill 发现

除了条件激活,系统还支持从文件操作中动态发现新的 Skill 目录:

src/skills/loadSkillsDir.ts
TypeScript
1export async function discoverSkillDirsForPaths(
2 filePaths: string[],
3 cwd: string,
4): Promise<string[]> {
5 const fs = getFsImplementation()
6 const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd
7 const newDirs: string[] = []
8
9 for (const filePath of filePaths) {
10 let currentDir = dirname(filePath)
11
12 // 从文件位置向上遍历到 cwd,检查每级是否存在 .claude/skills/
13 while (currentDir.startsWith(resolvedCwd + pathSep)) {
14 const skillDir = join(currentDir, '.claude', 'skills')
15
16 if (!dynamicSkillDirs.has(skillDir)) {
17 dynamicSkillDirs.add(skillDir)
18 try {
19 await fs.stat(skillDir)
20 // 检查是否被 gitignore
21 if (await isPathGitignored(currentDir, resolvedCwd)) {
22 continue
23 }
24 newDirs.push(skillDir)
25 } catch {
26 // 目录不存在,继续
27 }
28 }
29
30 const parent = dirname(currentDir)
31 if (parent === currentDir) break
32 currentDir = parent
33 }
34 }
35
36 // 按深度排序,越深的目录优先级越高
37 return newDirs.sort(
38 (a, b) => b.split(pathSep).length - a.split(pathSep).length,
39 )
40}

当模型 Read 或 Edit 一个 src/modules/payments/handler.ts 文件时,系统会向上检查 src/modules/payments/.claude/skills/src/modules/.claude/skills/ 等目录是否存在。如果发现了新的 Skill 目录,它们会被加载并合并到可用 Skill 中。

值得注意的是 dynamicSkillDirs Set 同时记录了已检查的目录(无论成功或失败),避免对同一目录的重复 stat 调用。此外,位于 .gitignore 路径下的 Skill 目录会被跳过,防止 node_modules 中的恶意 Skill 被加载。

Effort Level

Skill 可以通过 effort frontmatter 控制模型的推理努力级别:

src/skills/loadSkillsDir.ts
TypeScript
1const effortRaw = frontmatter['effort']
2const effort =
3 effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
4if (effortRaw !== undefined && effort === undefined) {
5 logForDebugging(
6 `Skill ${resolvedName} has invalid effort '${effortRaw}'.` +
7 ` Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
8 )
9}

当 Skill 以 context: fork 方式运行时,effort 会被注入到子 Agent 的定义中:

src/tools/SkillTool/SkillTool.ts
TypeScript
1const agentDefinition =
2 command.effort !== undefined
3 ? { ...baseAgent, effort: command.effort }
4 : baseAgent

这允许特定 Skill 要求更高或更低的推理强度——例如代码审查 Skill 设置 effort: high,而简单的格式化 Skill 设置 effort: low 以加速执行。

Bundled Skill 系统

registerBundledSkill 注册模式

Bundled Skill 是编译进 CLI 二进制的技能,不依赖文件系统。它们通过 registerBundledSkill 函数在启动时注册:

src/skills/bundledSkills.ts
TypeScript
1export function registerBundledSkill(definition: BundledSkillDefinition): void {
2 const { files } = definition
3
4 let skillRoot: string | undefined
5 let getPromptForCommand = definition.getPromptForCommand
6
7 if (files && Object.keys(files).length > 0) {
8 skillRoot = getBundledSkillExtractDir(definition.name)
9 let extractionPromise: Promise<string | null> | undefined
10 const inner = definition.getPromptForCommand
11 getPromptForCommand = async (args, ctx) => {
12 extractionPromise ??= extractBundledSkillFiles(definition.name, files)
13 const extractedDir = await extractionPromise
14 const blocks = await inner(args, ctx)
15 if (extractedDir === null) return blocks
16 return prependBaseDir(blocks, extractedDir)
17 }
18 }
19
20 const command: Command = {
21 type: 'prompt',
22 name: definition.name,
23 description: definition.description,
24 aliases: definition.aliases,
25 hasUserSpecifiedDescription: true,
26 allowedTools: definition.allowedTools ?? [],
27 // ...其他字段
28 source: 'bundled',
29 loadedFrom: 'bundled',
30 getPromptForCommand,
31 }
32 bundledSkills.push(command)
33}

这里有一个精巧的 延迟文件提取 机制:当 Bundled Skill 声明了 files(参考文件),这些文件不会在注册时写入磁盘,而是在第一次调用时才提取。extractionPromise ??= ... 使用了 nullish 赋值操作符来实现 memoize——多个并发调用共享同一个 Promise,不会重复提取。

安全的文件写入

提取文件到磁盘时,系统使用了多层安全措施:

src/skills/bundledSkills.ts
TypeScript
1const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
2const SAFE_WRITE_FLAGS =
3 process.platform === 'win32'
4 ? 'wx'
5 : fsConstants.O_WRONLY |
6 fsConstants.O_CREAT |
7 fsConstants.O_EXCL |
8 O_NOFOLLOW
9
10async function safeWriteFile(p: string, content: string): Promise<void> {
11 const fh = await open(p, SAFE_WRITE_FLAGS, 0o600)
12 try {
13 await fh.writeFile(content, 'utf8')
14 } finally {
15 await fh.close()
16 }
17}
  • O_EXCL:如果文件已存在则失败,防止覆盖预创建的恶意文件
  • O_NOFOLLOW:不跟随符号链接,防止 symlink 攻击
  • 0o600:文件权限仅限所有者读写
  • 0o700:目录权限仅限所有者

路径验证也很严格:

src/skills/bundledSkills.ts
TypeScript
1function resolveSkillFilePath(baseDir: string, relPath: string): string {
2 const normalized = normalize(relPath)
3 if (
4 isAbsolute(normalized) ||
5 normalized.split(pathSep).includes('..') ||
6 normalized.split('/').includes('..')
7 ) {
8 throw new Error(`bundled skill file path escapes skill dir: ${relPath}`)
9 }
10 return join(baseDir, normalized)
11}

Bundled Skill 注册流程

所有 Bundled Skill 在 initBundledSkills 中统一注册:

src/skills/bundled/index.ts
TypeScript
1export function initBundledSkills(): void {
2 registerUpdateConfigSkill()
3 registerKeybindingsSkill()
4 registerVerifySkill()
5 registerDebugSkill()
6 registerLoremIpsumSkill()
7 registerSkillifySkill()
8 registerRememberSkill()
9 registerSimplifySkill()
10 registerBatchSkill()
11 registerStuckSkill()
12
13 // Feature-gated skills
14 if (feature('KAIROS') || feature('KAIROS_DREAM')) {
15 const { registerDreamSkill } = require('./dream.js')
16 registerDreamSkill()
17 }
18 if (feature('AGENT_TRIGGERS')) {
19 const { registerLoopSkill } = require('./loop.js')
20 registerLoopSkill()
21 }
22 // ...更多 feature-gated skills
23}

有两种注册模式:

  1. 无条件注册registerSimplifySkill() 等始终可用的 Skill 在模块顶层 import
  2. Feature-gated 注册:通过 feature() 检查 feature flag,使用 require() 延迟加载

使用 require() 而非 import() 是因为 Bun 打包后动态 import() 的路径解析会指向 /$bunfs/root/...,而 require() 可以正常工作。

实战示例:simplify Skill

让我们看一个实际的 Bundled Skill 如何工作:

src/skills/bundled/simplify.ts
TypeScript
1export function registerSimplifySkill(): void {
2 registerBundledSkill({
3 name: 'simplify',
4 description:
5 'Review changed code for reuse, quality, and efficiency, ' +
6 'then fix any issues found.',
7 userInvocable: true,
8 async getPromptForCommand(args) {
9 let prompt = SIMPLIFY_PROMPT
10 if (args) {
11 prompt += `\n\n## Additional Focus\n\n${args}`
12 }
13 return [{ type: 'text', text: prompt }]
14 },
15 })
16}

SIMPLIFY_PROMPT 是一个精心设计的多阶段 prompt,指示模型:

  1. 运行 git diff 识别变更
  2. 启动三个并行 Agent 分别审查代码复用、代码质量和效率
  3. 汇总发现并直接修复问题

这展示了 Bundled Skill 的核心价值:将专家级的多步骤工作流封装为一条命令。

Plugin 系统

两级 Plugin 架构

Built-in Plugins
registerBuiltinPlugin()
BuiltinPluginDefinition
BUILTIN_PLUGINS Map
Marketplace Plugins
git clone / install
Plugin Manifest
commands/ skills/ agents/
Plugin 管理
/plugin UI
settings.enabledPlugins
getBuiltinPlugins()
getBuiltinPluginSkillCommands()
输出
LoadedPlugin[]
Command[]

Built-in Plugin

Built-in Plugin 与 Bundled Skill 的核心区别在于:用户可以启用/禁用 Built-in Plugin

src/types/plugin.ts
TypeScript
1export type BuiltinPluginDefinition = {
2 name: string
3 description: string
4 version?: string
5 skills?: BundledSkillDefinition[]
6 hooks?: HooksSettings
7 mcpServers?: Record<string, McpServerConfig>
8 isAvailable?: () => boolean
9 defaultEnabled?: boolean
10}

一个 Built-in Plugin 可以包含多个组件:

  • skills:通过 BundledSkillDefinition 定义的技能列表
  • hooks:生命周期钩子配置
  • mcpServers:MCP Server 配置

Plugin ID 使用 {name}@builtin 格式,与 Marketplace Plugin 的 {name}@{marketplace} 区分。

启用/禁用状态管理

src/plugins/builtinPlugins.ts
TypeScript
1export function getBuiltinPlugins(): {
2 enabled: LoadedPlugin[]
3 disabled: LoadedPlugin[]
4} {
5 const settings = getSettings_DEPRECATED()
6 const enabled: LoadedPlugin[] = []
7 const disabled: LoadedPlugin[] = []
8
9 for (const [name, definition] of BUILTIN_PLUGINS) {
10 // 可用性检查(如平台限制)
11 if (definition.isAvailable && !definition.isAvailable()) {
12 continue
13 }
14
15 const pluginId = `${name}@${BUILTIN_MARKETPLACE_NAME}`
16 const userSetting = settings?.enabledPlugins?.[pluginId]
17 // 优先级:用户设置 > Plugin 默认值 > true
18 const isEnabled =
19 userSetting !== undefined
20 ? userSetting === true
21 : (definition.defaultEnabled ?? true)
22
23 const plugin: LoadedPlugin = {
24 name,
25 manifest: {
26 name,
27 description: definition.description,
28 version: definition.version,
29 },
30 path: BUILTIN_MARKETPLACE_NAME,
31 source: pluginId,
32 repository: pluginId,
33 enabled: isEnabled,
34 isBuiltin: true,
35 hooksConfig: definition.hooks,
36 mcpServers: definition.mcpServers,
37 }
38
39 if (isEnabled) {
40 enabled.push(plugin)
41 } else {
42 disabled.push(plugin)
43 }
44 }
45
46 return { enabled, disabled }
47}

状态判断链:isAvailable()userSettingdefaultEnabledtrue。如果 isAvailable() 返回 false,Plugin 完全不可见;否则用户在 /plugin UI 中的设置优先,没有用户设置则看 Plugin 的默认值,最终兜底为启用。

Skill 从 Plugin 到 Command 的转换

Plugin 的 Skill 通过 skillDefinitionToCommand 转换为标准 Command

src/plugins/builtinPlugins.ts
TypeScript
1function skillDefinitionToCommand(definition: BundledSkillDefinition): Command {
2 return {
3 type: 'prompt',
4 name: definition.name,
5 description: definition.description,
6 hasUserSpecifiedDescription: true,
7 allowedTools: definition.allowedTools ?? [],
8 // 关键点:source 设为 'bundled' 而非 'builtin'
9 // 'builtin' 在 Command.source 中表示硬编码的 /help, /clear 等
10 // 使用 'bundled' 确保 Plugin Skill 出现在 SkillTool 列表中
11 source: 'bundled',
12 loadedFrom: 'bundled',
13 hooks: definition.hooks,
14 context: definition.context,
15 agent: definition.agent,
16 isEnabled: definition.isEnabled ?? (() => true),
17 isHidden: !(definition.userInvocable ?? true),
18 progressMessage: 'running',
19 getPromptForCommand: definition.getPromptForCommand,
20 }
21}

这里 source: 'bundled' 的选择是刻意的——代码注释解释了原因:'builtin'Command.source 的语义中表示硬编码的 CLI 命令(/help/clear),如果 Plugin Skill 使用 'builtin',它们会从 SkillTool 列表中消失。

Marketplace Plugin

Marketplace Plugin 通过 LoadedPlugin 类型表示,拥有更丰富的结构:

src/types/plugin.ts
TypeScript
1export type LoadedPlugin = {
2 name: string
3 manifest: PluginManifest
4 path: string
5 source: string
6 repository: string
7 enabled?: boolean
8 isBuiltin?: boolean
9 sha?: string // Git commit SHA 版本锁定
10 commandsPath?: string
11 commandsPaths?: string[] // manifest 中的额外命令路径
12 agentsPath?: string
13 agentsPaths?: string[]
14 skillsPath?: string
15 skillsPaths?: string[]
16 outputStylesPath?: string
17 outputStylesPaths?: string[]
18 hooksConfig?: HooksSettings
19 mcpServers?: Record<string, McpServerConfig>
20 lspServers?: Record<string, LspServerConfig>
21 settings?: Record<string, unknown>
22}

Marketplace Plugin 通过 Git 仓库分发。安装过程 clone 仓库到本地,读取 manifest 文件,然后根据 manifest 中声明的路径加载各种组件。Skill 文件使用与项目级 Skill 相同的目录格式(skill-name/SKILL.md),由同一套 loadSkillsFromSkillsDir 加载器处理。

Plugin 的命名空间使用冒号分隔:如果一个名为 ralph-loop 的 Plugin 提供了 helpcancel-ralph 两个 Skill,它们的完整名称是 ralph-loop:helpralph-loop:cancel-ralph

MCP Skill 桥接

mcpSkillBuilders 注册

MCP Server 可以通过 Prompt 原语暴露 Skill。当 MCP Prompt 的 frontmatter 包含特定字段时,它会被转化为 Skill 而不是普通的 Prompt:

src/skills/mcpSkillBuilders.ts
TypeScript
1export type MCPSkillBuilders = {
2 createSkillCommand: typeof createSkillCommand
3 parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields
4}
5
6let builders: MCPSkillBuilders | null = null
7
8export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
9 builders = b
10}
11
12export function getMCPSkillBuilders(): MCPSkillBuilders {
13 if (!builders) {
14 throw new Error(
15 'MCP skill builders not registered — ' +
16 'loadSkillsDir.ts has not been evaluated yet',
17 )
18 }
19 return builders
20}

这个模块解决了一个微妙的循环依赖问题。MCP 代码需要调用 createSkillCommandparseSkillFrontmatterFields,但直接导入 loadSkillsDir.ts 会引入巨大的传递依赖树,在 dependency-cruiser 检查中产生大量循环警告。

解决方案是运行时注册mcpSkillBuilders.ts 只导入类型(typeof),不产生运行时依赖。loadSkillsDir.ts 在模块初始化时注册实际函数,这发生在任何 MCP Server 连接之前。

MCP Skill 与文件 Skill 的差异

MCP 来源的 Skill 有两个重要限制:

  1. 禁止 Shell 命令执行
src/skills/loadSkillsDir.ts
TypeScript
1// Security: MCP skills are remote and untrusted — never execute inline
2// shell commands (!`…` / ```! … ```) from their markdown body.
3if (loadedFrom !== 'mcp') {
4 finalContent = await executeShellCommandsInPrompt(
5 finalContent, toolUseContext, `/${skillName}`, shell,
6 )
7}
  1. ${CLAUDE_SKILL_DIR} 无意义:MCP Skill 没有本地目录,因此 Skill 目录变量不做替换。

MCP Skill 在 SkillTool.call() 中通过 getAllCommands 获取,它从 AppState.mcp.commands 中筛选 loadedFrom === 'mcp' 的命令:

src/tools/SkillTool/SkillTool.ts
TypeScript
1async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
2 const mcpSkills = context
3 .getAppState()
4 .mcp.commands.filter(
5 cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
6 )
7 if (mcpSkills.length === 0) return getCommands(getProjectRoot())
8 const localCommands = await getCommands(getProjectRoot())
9 return uniqBy([...localCommands, ...mcpSkills], 'name')
10}

uniqBy 按 name 去重,本地命令优先(出现在数组前面)。

SkillTool 执行流程

执行模式

SkillTool 是模型调用 Skill 的唯一工具接口。它支持两种执行模式:

sequenceDiagram
    participant M as 模型
    participant ST as SkillTool
    participant V as validateInput
    participant P as checkPermissions
    participant C as call()

    M->>ST: { skill: "simplify", args: "" }
    ST->>V: 验证 Skill 存在
    V-->>ST: result: true

    ST->>P: 权限检查
    alt 安全属性 Skill
        P-->>ST: allow (auto)
    else 有 allow 规则
        P-->>ST: allow (rule)
    else 需要用户确认
        P-->>ST: ask
    end

    ST->>C: 执行 Skill

    alt context: 'fork'
        C->>C: executeForkedSkill()
        Note over C: 在子 Agent 中运行<br/>独立的 token 预算
        C-->>ST: { status: 'forked', result: "..." }
    else context: 'inline'(默认)
        C->>C: processPromptSlashCommand()
        Note over C: 展开 prompt 到<br/>当前对话上下文
        C-->>ST: { status: 'inline', newMessages: [...] }
    end

    ST-->>M: ToolResult

    style M fill:#1e3a5f,color:#e0e0e0
    style ST fill:#2d1f3d,color:#e0e0e0
    style V fill:#1a3d2e,color:#e0e0e0
    style P fill:#3d2e1a,color:#e0e0e0
    style C fill:#3d1a1a,color:#e0e0e0

Inline 模式(默认)

Inline 模式将 Skill 的 prompt 内容展开到当前对话上下文中:

src/tools/SkillTool/SkillTool.ts
TypeScript
1const processedCommand = await processPromptSlashCommand(
2 commandName,
3 args || '',
4 commands,
5 context,
6)
7
8if (!processedCommand.shouldQuery) {
9 throw new Error('Command processing failed')
10}

结果以 newMessages 的形式返回,这些消息被插入到当前对话流中。同时通过 contextModifier 修改上下文——添加 allowedTools 和传递 effort 级别。

Fork 模式

当 Skill 声明 context: 'fork' 时,它在独立的子 Agent 中执行:

src/tools/SkillTool/SkillTool.ts
TypeScript
1async function executeForkedSkill(
2 command: Command & { type: 'prompt' },
3 commandName: string,
4 args: string | undefined,
5 context: ToolUseContext,
6 canUseTool: CanUseToolFn,
7 parentMessage: AssistantMessage,
8 onProgress?: ToolCallProgress<Progress>,
9): Promise<ToolResult<Output>> {
10 const agentId = createAgentId()
11
12 const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
13 await prepareForkedCommandContext(command, args || '', context)
14
15 const agentDefinition =
16 command.effort !== undefined
17 ? { ...baseAgent, effort: command.effort }
18 : baseAgent
19
20 const agentMessages: Message[] = []
21
22 for await (const message of runAgent({
23 agentDefinition,
24 promptMessages,
25 toolUseContext: {
26 ...context,
27 getAppState: modifiedGetAppState,
28 },
29 canUseTool,
30 isAsync: false,
31 querySource: 'agent:custom',
32 model: command.model as ModelAlias | undefined,
33 availableTools: context.options.tools,
34 override: { agentId },
35 })) {
36 agentMessages.push(message)
37 // 向父级报告工具调用进度
38 }
39
40 const resultText = extractResultText(
41 agentMessages,
42 'Skill execution completed',
43 )
44 agentMessages.length = 0 // 释放消息内存
45
46 return {
47 data: {
48 success: true,
49 commandName,
50 status: 'forked',
51 agentId,
52 result: resultText,
53 },
54 }
55}

Fork 模式的优势:

  • 独立 token 预算:子 Agent 有自己的上下文窗口,不消耗主对话的 token
  • 隔离性:Skill 执行期间的工具调用和中间结果不污染主对话
  • 内存管理:执行完成后 agentMessages.length = 0 主动释放消息内存,clearInvokedSkillsForAgent(agentId) 清理 Skill 状态

权限检查

SkillTool 的权限检查区分了多种场景:

  1. Deny 规则优先检查:如果用户或策略配置了拒绝规则(如 Skill(deploy)),直接拒绝
  2. 安全属性自动放行:如果 Skill 只有安全属性(无 allowedTools、无 hooks、无 context: fork),自动允许
  3. Allow 规则匹配:匹配用户配置的允许规则,支持前缀通配(review:* 匹配所有 review: 开头的 Skill)
  4. Ask 兜底:默认询问用户,同时提供"允许此 Skill"和"允许此前缀"两种快捷建议
src/tools/SkillTool/SkillTool.ts
TypeScript
1const suggestions = [
2 {
3 type: 'addRules' as const,
4 rules: [{ toolName: SKILL_TOOL_NAME, ruleContent: commandName }],
5 behavior: 'allow' as const,
6 destination: 'localSettings' as const,
7 },
8 {
9 type: 'addRules' as const,
10 rules: [{ toolName: SKILL_TOOL_NAME, ruleContent: `${commandName}:*` }],
11 behavior: 'allow' as const,
12 destination: 'localSettings' as const,
13 },
14]

Skill 列表与 Token 预算管理

Skill 的发现信息通过 system-reminder 消息暴露给模型。但 Skill 数量可能很多,直接列出所有 Skill 的完整描述会浪费宝贵的 context window token。prompt.ts 中实现了一套预算管理机制:

src/tools/SkillTool/prompt.ts
TypeScript
1export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 上下文窗口的 1%
2export const CHARS_PER_TOKEN = 4
3export const DEFAULT_CHAR_BUDGET = 8_000 // 兜底:200K × 4 × 1%

当 Skill 总描述超出预算时,系统采用分级截断策略:

src/tools/SkillTool/prompt.ts
TypeScript
1export function formatCommandsWithinBudget(
2 commands: Command[],
3 contextWindowTokens?: number,
4): string {
5 if (commands.length === 0) return ''
6
7 const budget = getCharBudget(contextWindowTokens)
8
9 // 尝试完整描述
10 const fullTotal = fullEntries.reduce(
11 (sum, e) => sum + stringWidth(e.full), 0,
12 )
13 if (fullTotal <= budget) {
14 return fullEntries.map(e => e.full).join('\n')
15 }
16
17 // 分区:bundled(永不截断)vs 其他
18 // Bundled Skill 始终保留完整描述
19 // 其他 Skill 按比例截断描述
20
21 if (maxDescLen < MIN_DESC_LENGTH) {
22 // 极端情况:其他 Skill 只显示名字
23 return commands.map((cmd, i) =>
24 bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
25 ).join('\n')
26 }
27
28 // 正常截断:非 bundled 的描述截断到 maxDescLen
29 return commands.map((cmd, i) => {
30 if (bundledIndices.has(i)) return fullEntries[i]!.full
31 const description = getCommandDescription(cmd)
32 return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
33 }).join('\n')
34}

设计原则:

  • Bundled Skill 永不截断:它们是核心能力,描述质量直接影响模型的调用决策
  • 渐进降级:先截断描述长度,极端情况下只保留名字
  • 每条描述上限 250 字符MAX_LISTING_DESC_CHARS 硬限制,即使预算充足也不浪费

三层的统一与交互

统一 Command 注册

三层最终通过 getCommands() 函数统一:

TypeScript
1// 伪代码,展示合并逻辑
2async function getCommands(cwd: string): Promise<Command[]> {
3 const fileSkills = await getSkillDirCommands(cwd)
4 const bundledSkills = getBundledSkills()
5 const pluginSkills = getBuiltinPluginSkillCommands()
6 const dynamicSkills = getDynamicSkills()
7
8 return [...fileSkills, ...bundledSkills, ...pluginSkills, ...dynamicSkills]
9}

SkillTool.getAllCommands() 进一步加入 MCP Skill:

TypeScript
1const localCommands = await getCommands(getProjectRoot())
2return uniqBy([...localCommands, ...mcpSkills], 'name')

名称冲突解决

各层之间可能出现同名 Skill。解决策略是:先注册者胜出

  • 文件 Skill 内部:Managed > User > Project(由 getSkillDirCommands 中的 flatten 顺序决定)
  • 文件 vs Bundled:getCommands 中文件 Skill 在前
  • 本地 vs MCP:uniqBy([...localCommands, ...mcpSkills], 'name') 本地优先

Plugin Skill 通过命名空间(plugin-name:skill-name)避免与其他来源的冲突。

Hooks 统一

Skill 和 Plugin 都可以声明 Hooks。Skill 的 Hooks 在调用时注册,Plugin 的 Hooks 在加载时就生效:

TypeScript
1// Skill hooks - 仅在 Skill 被调用时激活
2command.hooks = parseHooksFromFrontmatter(frontmatter, skillName)
3
4// Plugin hooks - Plugin 启用后始终生效
5plugin.hooksConfig = definition.hooks

两者都使用相同的 HooksSettings Schema,可以配置 PreToolUsePostToolUsePreCompact 等生命周期钩子。

缓存与失效

文件 Skill 的加载结果被 memoize 缓存,但动态 Skill 发现和条件 Skill 激活需要触发缓存失效:

src/skills/loadSkillsDir.ts
TypeScript
1export function clearSkillCaches() {
2 getSkillDirCommands.cache?.clear?.()
3 loadMarkdownFilesForSubdir.cache?.clear?.()
4 conditionalSkills.clear()
5 activatedConditionalSkillNames.clear()
6}

动态 Skill 加载完成后通过信号通知订阅者:

src/skills/loadSkillsDir.ts
TypeScript
1export function onDynamicSkillsLoaded(callback: () => void): () => void {
2 return skillsLoaded.subscribe(() => {
3 try {
4 callback()
5 } catch (error) {
6 logError(error)
7 }
8 })
9}

这个信号机制使用了与 GrowthBook 特性标志相同的 createSignal 工具——一个轻量的观察者模式实现。订阅者的错误被捕获并记录,不会中断信号传播。

Bare Mode 与策略锁定

Bare Mode

--bare 模式跳过所有自动发现逻辑,只加载通过 --add-dir 显式指定的 Skill:

src/skills/loadSkillsDir.ts
TypeScript
1if (isBareMode()) {
2 if (additionalDirs.length === 0 || !projectSettingsEnabled) {
3 return []
4 }
5 const additionalSkillsNested = await Promise.all(
6 additionalDirs.map(dir =>
7 loadSkillsFromSkillsDir(
8 join(dir, '.claude', 'skills'), 'projectSettings',
9 ),
10 ),
11 )
12 return additionalSkillsNested.flat().map(s => s.skill)
13}

这对 CI/CD 环境很有用:确保只运行预定义的 Skill,不受项目目录结构影响。

Plugin-Only 策略

企业可以通过 isRestrictedToPluginOnly('skills') 锁定 Skill 来源为仅限 Plugin:

src/skills/loadSkillsDir.ts
TypeScript
1const skillsLocked = isRestrictedToPluginOnly('skills')
2const projectSettingsEnabled =
3 isSettingSourceEnabled('projectSettings') && !skillsLocked

skillsLocked 为 true 时:

  • 项目级 Skill(.claude/skills/)不加载
  • 用户级 Skill(~/.claude/skills/)不加载
  • 旧版 commands 目录不加载
  • 只有 Managed(策略)级 Skill 和 Bundled/Plugin Skill 可用

可迁移模式

Claude Code 的三层可扩展性架构蕴含了几个可复用的设计模式:

1. Markdown-as-Config 模式

使用 Markdown 文件(带 YAML frontmatter)作为配置载体:

MARKDOWN
1---
2description: "..."
3allowed-tools: [...]
4paths: ["src/**"]
5---
6
7实际的 prompt 内容...

这个模式的优势:

  • 人类可读:非开发者也能编写和维护 Skill
  • 版本控制友好:Markdown diff 清晰直观
  • 自文档化:frontmatter 是配置,body 是文档
  • IDE 支持:标准 Markdown 格式,有丰富的编辑器支持

2. 多层级目录合并模式

从多个目录层级加载配置,按优先级合并去重:

Text
1Managed (策略) > User (全局) > Project (项目) > Dynamic (运行时)

这个模式适用于任何需要"全局默认 + 项目覆盖"语义的系统。去重使用 realpath 而非路径字符串比较,正确处理了 symlink 场景。

3. 条件激活模式

资源(Skill、规则等)不立即加载,而是声明激活条件,在运行时匹配后才生效:

流程
声明 存储到 conditionalMap 运行时触发 移到 activeMap 通知订阅者

这个模式在大型项目中特别有价值。它将 O(N) 的 Skill 列表压缩到 O(active) 级别,同时保持了按需发现的能力。

4. 注册-而非-导入模式

Bundled Skill 和 MCP Skill Builder 都使用了"在模块初始化时注册到全局 Map,而非直接导入"的模式:

TypeScript
1// 注册
2export function registerBundledSkill(def: BundledSkillDefinition): void {
3 bundledSkills.push(skillDefToCommand(def))
4}
5
6// 获取
7export function getBundledSkills(): Command[] {
8 return [...bundledSkills]
9}

这个模式的好处:

  • 打破循环依赖:注册模块只导入类型,不导入实现
  • 支持延迟加载:feature-gated Skill 使用 require() 按需加载
  • 测试友好clearBundledSkills() 可以重置状态

5. 安全写入模式

Bundled Skill 的文件提取展示了安全写入的最佳实践:

流程
Per-process nonce dir O_EXCL | O_NOFOLLOW 0o600 权限 路径遍历检查

四层防御:

  1. 随机目录名防止预创建攻击
  2. O_EXCL 防止覆盖已有文件
  3. O_NOFOLLOW 防止符号链接攻击
  4. 路径规范化 + .. 检查防止目录遍历

小结

Claude Code 的三层可扩展性架构——Skill、Plugin、MCP——看似复杂,但底层逻辑清晰:

  1. 统一表示:所有可扩展能力最终都是 Command 类型
  2. 统一入口SkillTool 是模型调用 Skill 的唯一接口
  3. 统一调度:无论来源如何,Skill 执行都经过相同的验证、权限检查和 inline/fork 决策
  4. 差异化安全:不同来源有不同的信任级别(MCP 禁止 shell 命令,Plugin 需要权限确认,Bundled 自动放行)

这套架构的设计哲学是:让用户通过 Markdown 文件就能定义新能力,让第三方通过 Plugin 和 MCP 扩展生态,同时在每一层都有明确的安全边界。文件 Skill 的路径条件激活和动态发现保证了大型项目中的性能,Bundled Skill 的延迟提取和安全写入保证了二进制分发的可靠性,Plugin 的两级架构(Built-in + Marketplace)兼顾了开箱即用和自由扩展。

下一篇文章我们将深入 OAuth 与认证系统,探索 Claude Code 如何安全地管理 API Key、OAuth Token 和 Session 凭证。