问题引入
当你在 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 类型。理解这个类型是理解整个系统的基础:
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/)
Plugin Skills
(Marketplace)
加载器
loadSkillsFromSkillsDir()
loadSkillsFromCommandsDir()
getBuiltinPluginSkillCommands()
统一注册
getSkillDirCommands()
memoized
加载优先级按此顺序执行,遇到同名 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:
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}
注意几个设计要点:
- 只支持目录格式。单个
.md 文件在 /skills/ 目录下会被忽略,这确保每个 Skill 可以携带辅助文件(脚本、模板等)。
- 符号链接支持。
isSymbolicLink() 检查使得 Skill 可以通过 symlink 共享。
- 并行加载。使用
Promise.all 同时读取所有 Skill 文件。
- 优雅降级。单个 Skill 的加载失败不影响其他 Skill。
Frontmatter 元数据解析
SKILL.md 文件的 frontmatter 是 Skill 行为的完整声明。parseSkillFrontmatterFields 函数处理所有可能的字段:
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 示例:
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 字段的语义值得逐一解释:
| 字段 | 类型 | 用途 |
|---|
name | string | 显示名(不影响命令名) |
description | string | 简短描述,用于 Skill 列表 |
when_to_use | string | 告诉模型何时应该调用此 Skill |
allowed-tools | string[] | Skill 执行期间额外允许的工具 |
arguments | string/string[] | 命名参数列表 |
model | string | 模型覆盖(如 sonnet、opus、inherit) |
effort | EffortValue | 推理努力级别 |
context | 'fork' | 是否在子 Agent 中执行 |
paths | string[] | 条件激活的文件路径模式 |
hooks | HooksSettings | Skill 级别的 Hook 配置 |
user-invocable | boolean | 用户是否可以通过 /name 调用 |
disable-model-invocation | boolean | 禁止模型主动调用 |
参数替换机制
Skill 的 prompt 内容支持多种参数替换:
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}
这里有三层替换:
- 用户参数:
$ARGUMENTS 被替换为完整的参数字符串,$1/$2 被替换为位置参数,命名参数 ${branch} 被替换为对应的值。
- 内置变量:
${CLAUDE_SKILL_DIR} 指向 Skill 所在目录,${CLAUDE_SESSION_ID} 是当前会话 ID。
- Shell 命令:Skill 内容中的
!`command` 和 ```! command ``` 语法会被实际执行,输出替换到 prompt 中。这是 Skill 与环境交互的关键能力——但出于安全考虑,MCP 来源的 Skill 禁止执行 shell 命令。
多级来源聚合与去重
getSkillDirCommands 是文件 Skill 的聚合入口。它使用 memoize 缓存结果,从五个来源并行加载 Skill:
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 解析到规范路径来去重:
1async function getFileIdentity(filePath: string): Promise<string | null> {
2 try {
3 return await realpath(filePath)
4 } catch {
5 return null
6 }
7}
去重过程先并行计算所有文件的 identity,然后同步扫描去除重复:
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 声明自己只在特定文件路径下生效:
1---
2paths:
3 - "src/components/**"
4 - "src/styles/**"
5---
当 Skill 带有 paths 字段时,它不会立即加载到可用列表,而是存储在 conditionalSkills Map 中。只有当模型操作匹配路径的文件时,Skill 才被激活:
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 目录:
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 控制模型的推理努力级别:
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 的定义中:
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 函数在启动时注册:
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,不会重复提取。
安全的文件写入
提取文件到磁盘时,系统使用了多层安全措施:
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:目录权限仅限所有者
路径验证也很严格:
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 中统一注册:
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}
有两种注册模式:
- 无条件注册:
registerSimplifySkill() 等始终可用的 Skill 在模块顶层 import
- Feature-gated 注册:通过
feature() 检查 feature flag,使用 require() 延迟加载
使用 require() 而非 import() 是因为 Bun 打包后动态 import() 的路径解析会指向 /$bunfs/root/...,而 require() 可以正常工作。
实战示例:simplify Skill
让我们看一个实际的 Bundled Skill 如何工作:
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,指示模型:
- 运行
git diff 识别变更
- 启动三个并行 Agent 分别审查代码复用、代码质量和效率
- 汇总发现并直接修复问题
这展示了 Bundled Skill 的核心价值:将专家级的多步骤工作流封装为一条命令。
Plugin 系统
两级 Plugin 架构
Marketplace Plugins
commands/ skills/ agents/
Plugin 管理
getBuiltinPluginSkillCommands()
Built-in Plugin
Built-in Plugin 与 Bundled Skill 的核心区别在于:用户可以启用/禁用 Built-in Plugin。
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} 区分。
启用/禁用状态管理
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() → userSetting → defaultEnabled → true。如果 isAvailable() 返回 false,Plugin 完全不可见;否则用户在 /plugin UI 中的设置优先,没有用户设置则看 Plugin 的默认值,最终兜底为启用。
Skill 从 Plugin 到 Command 的转换
Plugin 的 Skill 通过 skillDefinitionToCommand 转换为标准 Command:
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 类型表示,拥有更丰富的结构:
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 提供了 help 和 cancel-ralph 两个 Skill,它们的完整名称是 ralph-loop:help 和 ralph-loop:cancel-ralph。
MCP Skill 桥接
mcpSkillBuilders 注册
MCP Server 可以通过 Prompt 原语暴露 Skill。当 MCP Prompt 的 frontmatter 包含特定字段时,它会被转化为 Skill 而不是普通的 Prompt:
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 代码需要调用 createSkillCommand 和 parseSkillFrontmatterFields,但直接导入 loadSkillsDir.ts 会引入巨大的传递依赖树,在 dependency-cruiser 检查中产生大量循环警告。
解决方案是运行时注册:mcpSkillBuilders.ts 只导入类型(typeof),不产生运行时依赖。loadSkillsDir.ts 在模块初始化时注册实际函数,这发生在任何 MCP Server 连接之前。
MCP Skill 与文件 Skill 的差异
MCP 来源的 Skill 有两个重要限制:
- 禁止 Shell 命令执行:
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}
${CLAUDE_SKILL_DIR} 无意义:MCP Skill 没有本地目录,因此 Skill 目录变量不做替换。
MCP Skill 在 SkillTool.call() 中通过 getAllCommands 获取,它从 AppState.mcp.commands 中筛选 loadedFrom === 'mcp' 的命令:
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 内容展开到当前对话上下文中:
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 中执行:
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 的权限检查区分了多种场景:
- Deny 规则优先检查:如果用户或策略配置了拒绝规则(如
Skill(deploy)),直接拒绝
- 安全属性自动放行:如果 Skill 只有安全属性(无
allowedTools、无 hooks、无 context: fork),自动允许
- Allow 规则匹配:匹配用户配置的允许规则,支持前缀通配(
review:* 匹配所有 review: 开头的 Skill)
- Ask 兜底:默认询问用户,同时提供"允许此 Skill"和"允许此前缀"两种快捷建议
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 中实现了一套预算管理机制:
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 总描述超出预算时,系统采用分级截断策略:
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() 函数统一:
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:
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 在加载时就生效:
1// Skill hooks - 仅在 Skill 被调用时激活
2command.hooks = parseHooksFromFrontmatter(frontmatter, skillName)
3
4// Plugin hooks - Plugin 启用后始终生效
5plugin.hooksConfig = definition.hooks
两者都使用相同的 HooksSettings Schema,可以配置 PreToolUse、PostToolUse、PreCompact 等生命周期钩子。
缓存与失效
文件 Skill 的加载结果被 memoize 缓存,但动态 Skill 发现和条件 Skill 激活需要触发缓存失效:
1export function clearSkillCaches() {
2 getSkillDirCommands.cache?.clear?.()
3 loadMarkdownFilesForSubdir.cache?.clear?.()
4 conditionalSkills.clear()
5 activatedConditionalSkillNames.clear()
6}
动态 Skill 加载完成后通过信号通知订阅者:
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:
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:
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)作为配置载体:
1---
2description: "..."
3allowed-tools: [...]
4paths: ["src/**"]
5---
6
7实际的 prompt 内容...
这个模式的优势:
- 人类可读:非开发者也能编写和维护 Skill
- 版本控制友好:Markdown diff 清晰直观
- 自文档化:frontmatter 是配置,body 是文档
- IDE 支持:标准 Markdown 格式,有丰富的编辑器支持
2. 多层级目录合并模式
从多个目录层级加载配置,按优先级合并去重:
1Managed (策略) > User (全局) > Project (项目) > Dynamic (运行时)
这个模式适用于任何需要"全局默认 + 项目覆盖"语义的系统。去重使用 realpath 而非路径字符串比较,正确处理了 symlink 场景。
3. 条件激活模式
资源(Skill、规则等)不立即加载,而是声明激活条件,在运行时匹配后才生效:
流程
声明 → 存储到 conditionalMap → 运行时触发 → 移到 activeMap → 通知订阅者
这个模式在大型项目中特别有价值。它将 O(N) 的 Skill 列表压缩到 O(active) 级别,同时保持了按需发现的能力。
4. 注册-而非-导入模式
Bundled Skill 和 MCP Skill Builder 都使用了"在模块初始化时注册到全局 Map,而非直接导入"的模式:
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 权限 → 路径遍历检查
四层防御:
- 随机目录名防止预创建攻击
O_EXCL 防止覆盖已有文件
O_NOFOLLOW 防止符号链接攻击
- 路径规范化 +
.. 检查防止目录遍历
小结
Claude Code 的三层可扩展性架构——Skill、Plugin、MCP——看似复杂,但底层逻辑清晰:
- 统一表示:所有可扩展能力最终都是
Command 类型
- 统一入口:
SkillTool 是模型调用 Skill 的唯一接口
- 统一调度:无论来源如何,Skill 执行都经过相同的验证、权限检查和 inline/fork 决策
- 差异化安全:不同来源有不同的信任级别(MCP 禁止 shell 命令,Plugin 需要权限确认,Bundled 自动放行)
这套架构的设计哲学是:让用户通过 Markdown 文件就能定义新能力,让第三方通过 Plugin 和 MCP 扩展生态,同时在每一层都有明确的安全边界。文件 Skill 的路径条件激活和动态发现保证了大型项目中的性能,Bundled Skill 的延迟提取和安全写入保证了二进制分发的可靠性,Plugin 的两级架构(Built-in + Marketplace)兼顾了开箱即用和自由扩展。
下一篇文章我们将深入 OAuth 与认证系统,探索 Claude Code 如何安全地管理 API Key、OAuth Token 和 Session 凭证。