在前面的文章中,我们已经分析了 Claude Code 的 Agent Loop、Tool System、Permission System 等核心子系统。然而,真正让 Claude Code 从一个"通用对话助手"进化为"领域专家 Agent"的,是其 Skills 系统——一套高度工程化的可复用能力扩展框架。Skills 允许开发者将特定领域的工作流、编码规范、调试策略封装为可插拔的能力单元,在运行时按需注入到 Agent 的上下文中。
本文将从源码层面深入解析 Claude Code Skills 系统的完整架构,涵盖目录结构、Skill 定义格式、加载流程、执行机制以及 MCP-based Skills 的动态生成能力。
一、Skills 系统整体架构
Claude Code 的 Skills 系统并非简单的"提示词模板仓库",而是一个具备完整生命周期管理的能力注册中心。它支持多来源、多优先级、条件激活和懒加载等高级特性。
1.1 核心目录结构
Skills 系统的源码集中在 src/skills/ 目录下:
src/skills/
├── loadSkillsDir.ts # Skill 加载器(~1100 行,核心引擎)
├── bundledSkills.ts # 内置 Skill 注册工厂
├── mcpSkillBuilders.ts # MCP Skill 构建器注册表
├── bundled/ # 内置 Skill 定义
│ ├── index.ts # 初始化所有 bundled skills
│ ├── verify.ts # /verify skill
│ ├── debug.ts # /debug skill
│ ├── remember.ts # /remember skill
│ ├── simplify.ts # /simplify skill
│ ├── stuck.ts # /stuck skill
│ ├── batch.ts # /batch skill
│ └── ... # 其他内置技能在运行时,Skills 的来源分为四个层级(按优先级从高到低):
| 来源 | 路径 | 说明 |
|---|---|---|
| Managed | /etc/claude-code/.claude/skills/ | 组织级策略强制部署 |
| User | ~/.claude/skills/ | 用户个人技能库 |
| Project | .claude/skills/ | 项目级技能(随仓库版本控制) |
| Bundled | 编译进二进制 | Claude Code 内置技能 |
这种分层设计体现了权限和可信度的递减关系——Managed 技能具有最高优先级,可以覆盖用户和项目级别的同名技能,适合企业统一规范的场景。
1.2 两类 Skill 的本质区别
Claude Code 的 Skill 分为两大类:
Bundled Skills(内置技能):在编译时打包进 CLI 二进制,通过 registerBundledSkill() 工厂函数注册。src/skills/bundled/index.ts(约 79 行)是注册入口,启动时把所有内置技能逐个注册进去。这些技能的 getPromptForCommand 是一个函数,可以动态生成提示内容。
Disk-based Skills(磁盘技能):用户自建技能,放在 .claude/skills/ 目录下。src/skills/loadSkillsDir.ts(约 1100 行)负责加载。这些技能以 SKILL.md 文件为载体,内容在调用时从磁盘读取。
两者的核心差异在于内容存储位置和加载时机:
flowchart TD
A[Skill 来源] --> B[Bundled Skills]
A --> C[Disk-based Skills]
A --> D[MCP-based Skills]
B --> B1[编译进二进制]
B --> B2[registerBundledSkill 注册]
B --> B3[首次调用时懒提取文件]
C --> C1[.claude/skills/ 目录]
C --> C2[启动时扫描 frontmatter]
C --> C3[调用时读取完整 markdown]
D --> D1[MCP Server 提供]
D --> D2[运行时动态发现]
D --> D3[Prompt 转换为 Skill]二、Skill 定义格式:SKILL.md 规范
Skill 最常见的载体就是 SKILL.md 文件。Claude Code 采用"前端元数据 + 正文内容"的两段式结构,与 Hexo 等静态站点的 frontmatter 设计如出一辙。
2.1 Frontmatter 字段全景
parseSkillFrontmatterFields() 函数(src/skills/loadSkillsDir.ts,第 185~260 行)解析了 15+ 个 frontmatter 字段:
// src/skills/loadSkillsDir.ts ~185-260
export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
): {
displayName: string | undefined
description: string
hasUserSpecifiedDescription: boolean
allowedTools: string[]
argumentHint: string | undefined
argumentNames: string[]
whenToUse: string | undefined
version: string | undefined
model: ReturnType<typeof parseUserSpecifiedModel> | undefined
disableModelInvocation: boolean
userInvocable: boolean
hooks: HooksSettings | undefined
executionContext: 'fork' | undefined
agent: string | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
} { ... }关键字段的含义如下:
name:Skill 的显示名称,可选,默认使用目录名description:一句话描述,出现在 Skill 列表中供 LLM 决策when_to_use:更详细的使用时机说明,帮助模型判断何时调用allowed-tools:该 Skill 被允许使用的工具白名单(如["Read", "Edit", "Bash"])arguments:参数名列表,支持数组或逗号分隔字符串argument-hint:参数提示文本model:覆盖模型,如haiku、sonnet、opus,或inherit继承当前模型disable-model-invocation:设为true时禁止通过 SkillTool 调用(仅作为斜杠命令)user-invocable:设为false时仅模型内部调用,不出现在 REPL 命令列表context:执行上下文,inline(默认,在同一会话中执行)或fork(在子 Agent 中隔离执行)agent:绑定到指定 agent 类型paths:条件触发路径(glob pattern),匹配文件时自动激活effort:任务复杂度估计:low、medium、high或整数hooks:Skill 级 Hook 配置shell:指定 Shell 类型,bash或powershell
2.2 一个完整的 SKILL.md 示例
---
name: systematic-debugging
description: Systematically debug any bug or test failure
when_to_use: Use when encountering any bug, error, or test failure
allowed-tools: ["Read", "Grep", "Bash", "Edit"]
arguments: [target, error_message]
argument-hint: The failing test name or error to investigate
model: sonnet
context: fork
paths: ["**/*.test.ts", "**/*.spec.ts"]
---
# Systematic Debugging Protocol
When investigating `${ARGUMENTS}`:
1. Reproduce the failure exactly as reported
2. Identify the minimal code path that triggers it
3. Formulate a hypothesis about the root cause
4. Add targeted logging or use debugger to verify
5. Fix the root cause, not the symptom
6. Verify the fix and run the full test suite
7. Summarize findings and prevent recurrence
Base directory for this skill: ${CLAUDE_SKILL_DIR}注意几个内置变量:
${ARGUMENTS}:用户传入的参数,会被substituteArguments()替换${CLAUDE_SKILL_DIR}:Skill 所在目录路径,用于引用同目录下的脚本${CLAUDE_SESSION_ID}:当前会话 ID
2.3 条件激活:paths 字段的魔法
paths 字段是 Skills 系统中最精妙的设计之一。声明了 paths 的技能是条件技能(Conditional Skill)——当用户操作或修改了匹配该 glob pattern 的文件时,技能自动激活并注入上下文。
parseSkillPaths() 函数(src/skills/loadSkillsDir.ts,第 95~115 行)处理路径模式:
// src/skills/loadSkillsDir.ts ~95-115
function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined {
if (!frontmatter.paths) {
return undefined
}
const patterns = splitPathInFrontmatter(frontmatter.paths)
.map(pattern => {
// Remove /** suffix - ignore library treats 'path' as matching both
// the path itself and everything inside it
return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern
})
.filter((p: string) => p.length > 0)
if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
return undefined
}
return patterns
}在 activateConditionalSkillsForPaths() 中(src/skills/loadSkillsDir.ts,约第 1030~1080 行),系统使用 ignore 库(gitignore 风格的匹配引擎)来检测路径匹配:
// src/skills/loadSkillsDir.ts ~1030-1080
export function activateConditionalSkillsForPaths(
filePaths: string[],
cwd: string,
): string[] {
const activated: string[] = []
for (const [name, skill] of conditionalSkills) {
const skillIgnore = ignore().add(skill.paths)
for (const filePath of filePaths) {
const relativePath = isAbsolute(filePath)
? relative(cwd, filePath)
: filePath
if (skillIgnore.ignores(relativePath)) {
dynamicSkills.set(name, skill)
conditionalSkills.delete(name)
activated.push(name)
break
}
}
}
return activated
}这比 Cursor Rules 的"始终应用"模式精细得多——一个只对 Python 项目生效的技能可以设置 paths: ["**/*.py"],只有操作 Python 文件时才触发。
三、Skill 加载流程:从目录扫描到工具池注册
Skill 的加载是一个多阶段、高并发的流水线。loadSkillsDir.ts 中的 getSkillDirCommands() 是主入口。
3.1 加载链路总览
sequenceDiagram
participant Startup as 启动流程
participant GSDC as getSkillDirCommands()
participant LSSD as loadSkillsFromSkillsDir()
participant LSCD as loadSkillsFromCommandsDir()
participant PSFF as parseSkillFrontmatterFields()
participant CSC as createSkillCommand()
participant ToolPool as 工具池/Command Registry
Startup->>GSDC: 传入 cwd,记忆化调用
GSDC->>GSDC: 计算 5 个来源目录
par 并行加载
GSDC->>LSSD: managed + user + project + additional
GSDC->>LSCD: legacy /commands/ 目录
end
LSSD->>LSSD: readdir 遍历子目录
LSSD->>LSSD: 读取 skill-name/SKILL.md
LSSD->>PSFF: 解析 frontmatter
PSFF-->>LSSD: 返回解析后的字段
LSSD->>CSC: 构建 Command 对象
CSC-->>LSSD: 返回 prompt 类型 Command
LSSD-->>GSDC: SkillWithPath[]
GSDC->>GSDC: realpath 去重
GSDC->>GSDC: 分离条件/无条件技能
GSDC->>ToolPool: 返回无条件技能列表3.2 多来源并行加载
getSkillDirCommands() 使用 Promise.all 并行从 5 个来源加载:
// src/skills/loadSkillsDir.ts ~550-620
const [
managedSkills,
userSkills,
projectSkillsNested,
additionalSkillsNested,
legacyCommands,
] = await Promise.all([
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS)
? Promise.resolve([])
: loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
isSettingSourceEnabled('userSettings') && !skillsLocked
? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings')
: Promise.resolve([]),
projectSettingsEnabled
? Promise.all(
projectSkillsDirs.map(dir =>
loadSkillsFromSkillsDir(dir, 'projectSettings'),
),
)
: Promise.resolve([]),
projectSettingsEnabled
? Promise.all(
additionalDirs.map(dir =>
loadSkillsFromSkillsDir(join(dir, '.claude', 'skills'), 'projectSettings'),
),
)
: Promise.resolve([]),
skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd),
])值得注意的是:
--bare模式会跳过自动发现,仅加载--add-dir指定的路径skillsLocked(plugin-only策略)会阻止加载用户和项目技能
3.3 去重策略:realpath 解决符号链接问题
去重是加载流程中的关键步骤。Claude Code 没有使用 inode(因为某些虚拟/容器/NFS 文件系统报告不可靠的 inode 值),而是使用 realpath() 解析符号链接到规范路径:
// src/skills/loadSkillsDir.ts ~70-85
async function getFileIdentity(filePath: string): Promise<string | null> {
try {
return await realpath(filePath)
} catch {
return null
}
}去重逻辑采用"先加载者获胜"的策略——Managed 技能优先于 User 技能,User 优先于 Project:
// src/skills/loadSkillsDir.ts ~650-720
const fileIds = await Promise.all(
allSkillsWithPaths.map(({ skill, filePath }) =>
skill.type === 'prompt' ? getFileIdentity(filePath) : Promise.resolve(null),
),
)
const seenFileIds = new Map<string, SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'>()
const deduplicatedSkills: Command[] = []
for (let i = 0; i < allSkillsWithPaths.length; i++) {
const entry = allSkillsWithPaths[i]
if (entry === undefined || entry.skill.type !== 'prompt') continue
const fileId = fileIds[i]
if (fileId === null || fileId === undefined) {
deduplicatedSkills.push(entry.skill)
continue
}
const existingSource = seenFileIds.get(fileId)
if (existingSource !== undefined) {
logForDebugging(`Skipping duplicate skill '${entry.skill.name}'...`)
continue
}
seenFileIds.set(fileId, entry.skill.source)
deduplicatedSkills.push(entry.skill)
}3.4 createSkillCommand:从 Markdown 到 Command 对象
createSkillCommand()(src/skills/loadSkillsDir.ts,第 280~360 行)是将解析后的 frontmatter 和 markdown 正文转换为内部 Command 对象的核心工厂函数。生成的 Command 是一个 PromptCommand,其 getPromptForCommand 方法负责在调用时动态构建提示内容。
// src/skills/loadSkillsDir.ts ~280-360(简化)
export function createSkillCommand({
skillName, displayName, description, markdownContent,
allowedTools, argumentHint, argumentNames, whenToUse,
model, disableModelInvocation, userInvocable, source,
baseDir, loadedFrom, hooks, executionContext, agent,
paths, effort, shell,
}): Command {
return {
type: 'prompt',
name: skillName,
description,
allowedTools,
argNames: argumentNames.length > 0 ? argumentNames : undefined,
whenToUse,
model,
disableModelInvocation,
userInvocable,
context: executionContext,
agent,
effort,
paths,
contentLength: markdownContent.length,
isHidden: !userInvocable,
progressMessage: 'running',
userFacingName(): string { return displayName || skillName },
source,
loadedFrom,
hooks,
skillRoot: baseDir,
async getPromptForCommand(args, toolUseContext) {
let finalContent = baseDir
? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
: markdownContent
finalContent = substituteArguments(finalContent, args, true, argumentNames)
if (baseDir) {
const skillDir = process.platform === 'win32'
? baseDir.replace(/\\/g, '/')
: baseDir
finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
}
finalContent = finalContent.replace(/\$\{CLAUDE_SESSION_ID\}/g, getSessionId())
// MCP skills 禁止执行内联 shell 命令(安全限制)
if (loadedFrom !== 'mcp') {
finalContent = await executeShellCommandsInPrompt(
finalContent, toolUseContext, `/${skillName}`, shell,
)
}
return [{ type: 'text', text: finalContent }]
},
} satisfies Command
}这里有几个工程细节值得注意:
- 参数替换:
substituteArguments()支持$ARGUMENTS、$1、$2等多种参数引用语法 - 变量注入:
${CLAUDE_SKILL_DIR}和${CLAUDE_SESSION_ID}在调用时动态替换 - 安全沙箱:MCP skills(远程/不可信)禁止执行内联 Shell 命令(
!command` 语法),这是防止远程代码执行的关键防线
3.5 动态 Skill 发现
除了启动时加载的静态技能,loadSkillsDir.ts 还支持动态 Skill 发现。当用户操作某个深层目录下的文件时,系统会沿着路径向上遍历,发现 .claude/skills/ 目录并加载其中的技能:
// src/skills/loadSkillsDir.ts ~930-980
discoverSkillDirsForPaths(filePaths, cwd):
for filePath in filePaths:
currentDir = dirname(filePath)
while currentDir starts with cwd + sep:
skillDir = join(currentDir, '.claude', 'skills')
if skillDir not in dynamicSkillDirs:
if isPathGitignored(currentDir, cwd): skip
if exists(skillDir): newDirs.push(skillDir)
currentDir = parent(currentDir)
return newDirs sorted by depth (deepest first)更深的目录具有更高优先级,这与 JavaScript 模块解析的"最近匹配"原则一致。
四、Skill 执行:与 SkillTool 的深度配合
Skill 被加载后,并不会立即影响 Agent 的行为。只有当 LLM 通过 SkillTool 调用时,Skill 的完整内容才会被注入到对话上下文中。
4.1 SkillTool 的核心架构
SkillTool 位于 src/tools/SkillTool/SkillTool.ts,是一个标准的 Claude Code Tool 实现,遵循 buildTool() 工厂模式:
// src/tools/SkillTool/SkillTool.ts
export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
name: SKILL_TOOL_NAME, // "Skill"
searchHint: 'invoke a slash-command skill',
maxResultSizeChars: 100_000,
inputSchema: z.object({ skill: z.string(), args: z.string().optional() }),
outputSchema: z.union([inlineOutputSchema, forkedOutputSchema]),
description: async ({ skill }) => `Execute skill: ${skill}`,
prompt: async () => getPrompt(getProjectRoot()),
validateInput,
checkPermissions,
call,
mapToolResultToToolResultBlockParam,
// ... render functions
})SkillTool 的输入非常简单——只有 skill(名称)和可选的 args(参数)。真正的复杂性在于 call() 方法的执行逻辑。
4.2 执行流程:从调用到上下文注入
模型调用 Skill tool { skill: "systematic-debugging", args: "login failure" }
│
├─ validateInput()
│ ├─ 检查 skill 格式非空
│ ├─ 去除前导斜杠(兼容 /skill-name 写法)
│ ├─ 在命令表中查找(包括 MCP skills)
│ ├─ 检查 disableModelInvocation
│ └─ 确认 type === 'prompt'
│
├─ checkPermissions()
│ ├─ 匹配 allow/deny 规则
│ ├─ 检查 skillHasOnlySafeProperties()
│ └─ 默认行为:ask 用户确认
│
└─ call()
├─ 如果是 fork context → executeForkedSkill()
└─ 否则 → processPromptSlashCommand()
├─ 调用 command.getPromptForCommand(args, context)
├─ 生成新消息(用户消息注入 skill 内容)
├─ 注册 skill hooks
├─ 返回 newMessages + contextModifier
│ ├─ allowedTools 扩展
│ ├─ model 覆盖
│ └─ effort 覆盖
└─ 继续 Agent Loop4.3 上下文修改:Context Modifier 机制
Skill 执行不仅仅是"插入一段提示词",它还可以通过 contextModifier 修改 Agent 的运行时环境。这是 Skills 系统最强大的设计之一:
// src/tools/SkillTool/SkillTool.ts ~700-800
call({ skill, args }, context, canUseTool, parentMessage):
// ... 前面流程省略 ...
return {
data: { success: true, commandName, allowedTools, model },
newMessages, // Skill 内容作为用户消息注入
contextModifier(ctx) {
let modifiedContext = ctx
// 1. 扩展允许的工具列表
if (allowedTools.length > 0) {
modifiedContext = {
...modifiedContext,
getAppState() {
const appState = previousGetAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: [...new Set([...existing, ...allowedTools])],
},
},
}
},
}
}
// 2. 覆盖模型
if (model) {
modifiedContext = {
...modifiedContext,
options: {
...modifiedContext.options,
mainLoopModel: resolveSkillModelOverride(model, ctx.options.mainLoopModel),
},
}
}
// 3. 覆盖 effort 级别
if (effort !== undefined) {
modifiedContext = {
...modifiedContext,
getAppState() {
const appState = previousGetAppState()
return { ...appState, effortValue: effort }
},
}
}
return modifiedContext
},
}这意味着一个 Skill 可以:
- 临时提升工具权限:比如
deploySkill 可以自动获得Bash和Write的免确认权限 - 切换模型:复杂分析 Skill 可以强制使用
opus,而简单 Skill 可以用haiku - 调整 effort 预算:长时间运行的 Skill 可以申请更高的 token 预算
4.4 Fork 模式:隔离执行的子 Agent
当 Skill 的 context: 'fork' 时,executeForkedSkill() 会创建一个完全隔离的子 Agent 来执行 Skill 的内容。这带来了几个关键特性:
- 上下文隔离:子 Agent 拥有独立的 message history 和 token budget
- 副作用 containment:Skill 执行中的文件修改、工具调用不会影响主对话
- 结果摘要:子 Agent 完成后只返回结果文本,主 Agent 获得干净的摘要
// src/tools/SkillTool/SkillTool.ts ~200-280
async function executeForkedSkill(
command: Command & { type: 'prompt' },
commandName: string,
args: string | undefined,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
): Promise<ToolResult<Output>> {
const agentId = createAgentId()
const forkedSanitizedName = isBuiltIn || isBundled || isOfficialSkill
? commandName
: 'custom'
// ... 构建 forked context ...
const result = await runAgent(forkedContext, forkedMessages, {
agentId,
maxTurns: FORKED_SKILL_MAX_TURNS,
parentToolUseContext: context,
})
// 提取结果文本返回主 Agent
return {
data: {
success: result.success,
commandName,
status: 'forked',
agentId,
result: extractResultText(result.messages),
},
}
}这是 Context Engineering 在 Skill 系统中的高级应用——通过执行上下文的分叉,实现复杂工作流的可控组合。
4.5 Token 预算与技能列表注入
Claude Code 不会在每次对话开始时就把所有 Skill 的完整内容塞给模型(那样会瞬间耗尽上下文窗口)。相反,它只注入 Skill 的元数据列表(名称 + 描述),由 LLM 自主决定是否调用。
getSkillListingAttachments()(src/utils/attachments.ts,第 2660~2750 行)负责这个注入过程:
// src/utils/attachments.ts ~2660-2750
async function getSkillListingAttachments(toolUseContext): Promise<Attachment[]> {
let allCommands = await getCommands(getProjectRoot())
const mcpSkills = context.getAppState().mcp.commands.filter(
cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp'
)
allCommands = uniqBy([...localCommands, ...mcpSkills], 'name')
// 在 skill-search 启用时,只注入 bundled + MCP skills
if (feature('EXPERIMENTAL_SKILL_SEARCH') && isSkillSearchEnabled()) {
allCommands = filterToBundledAndMcp(allCommands)
}
const content = formatCommandsWithinBudget(newSkills, contextWindowTokens)
return [{ type: 'skill_listing', content, skillCount: newSkills.length, isInitial }]
}formatCommandsWithinBudget() 使用 token 预算控制(上下文窗口的 1%,每个描述最多 250 字符),以 <system-reminder> 的形式注入:
The following skills are available for use with the Skill tool:
- test-driven-development: Use when implementing any feature or bugfix
- systematic-debugging: Use when encountering any bug or test failure
- verify: Verify a code change does what it should by running the app这种设计使得 LLM 知道"有什么技能可用",但只在真正需要时才支付"读取完整技能内容"的 token 成本。
五、MCP-based Skills:跨协议的能力融合
MCP(Model Context Protocol)是 Claude Code 与外部系统交互的主要通道。而 mcpSkillBuilders.ts 则解决了 MCP Prompt 如何转换为 Claude Code Skill 的问题。
5.1 mcpSkillBuilders.ts:打破循环依赖的注册表
mcpSkillBuilders.ts 的代码非常简洁(仅约 50 行),但其设计意图极其精妙——它解决了模块间的循环依赖问题:
// src/skills/mcpSkillBuilders.ts
export type MCPSkillBuilders = {
createSkillCommand: typeof createSkillCommand
parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields
}
let builders: MCPSkillBuilders | null = null
export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
builders = b
}
export function getMCPSkillBuilders(): MCPSkillBuilders {
if (!builders) {
throw new Error(
'MCP skill builders not registered — loadSkillsDir.ts has not been evaluated yet',
)
}
return builders
}循环依赖的来源是:
client.ts(MCP 客户端)→mcpSkills.ts(MCP Skill 发现)→loadSkillsDir.ts(Skill 构建器)→ … →client.ts
如果 mcpSkills.ts 直接动态导入 loadSkillsDir.ts,在 Bun-bundled 二进制中会因为路径解析失败(/$bunfs/root/...)而崩溃。mcpSkillBuilders.ts 作为依赖图叶子节点(只导入类型,不导入值),在 loadSkillsDir.ts 模块初始化时通过 registerMCPSkillBuilders() 注册构建器,然后 mcpSkills.ts 通过 getMCPSkillBuilders() 安全地获取它们。
在 loadSkillsDir.ts 末尾(约第 1100 行),注册发生在模块顶层:
// src/skills/loadSkillsDir.ts ~1100
registerMCPSkillBuilders({
createSkillCommand,
parseSkillFrontmatterFields,
})5.2 MCP Prompt 到 Skill 的转换
当 MCP Server 连接后,Claude Code 会调用 list_prompts 获取该 Server 提供的 Prompt 列表。这些 Prompt 需要被转换为内部的 Command 对象,才能被 SkillTool 识别和调用。
转换流程(基于 src/services/mcp/client.ts 中的相关逻辑):
- 发现阶段:
fetchMcpSkillsForClient()从 MCP Client 获取 Prompt 列表 - Frontmatter 提取:尝试从 Prompt 描述中解析 YAML frontmatter(name、description、allowed-tools 等)
- Command 构建:使用
getMCPSkillBuilders().createSkillCommand()构建Command对象 - 注册到 AppState:将生成的 Command 放入
appState.mcp.commands,与本地技能统一展示
MCP Skill 的一个重要限制是安全边界:由于 MCP Server 是远程/不可信的,其提供的 Skill 禁止执行内联 Shell 命令(!command语法)。这是createSkillCommand中loadedFrom !== 'mcp'` 检查的直接体现。
5.3 Bundled Skills 的懒提取机制
内置技能的资源文件(如 SKILL.md 模板、辅助脚本)是压缩存储在二进制中的。bundledSkills.ts 实现了一个优雅的**懒提取(Lazy Extraction)**模式:
// src/skills/bundledSkills.ts ~35-80
export function registerBundledSkill(definition: BundledSkillDefinition): void {
const { files } = definition
let skillRoot: string | undefined
let getPromptForCommand = definition.getPromptForCommand
if (files && Object.keys(files).length > 0) {
skillRoot = getBundledSkillExtractDir(definition.name)
// Closure-local memoization: extract once per process
let extractionPromise: Promise<string | null> | undefined
const inner = definition.getPromptForCommand
getPromptForCommand = async (args, ctx) => {
extractionPromise ??= extractBundledSkillFiles(definition.name, files)
const extractedDir = await extractionPromise
const blocks = await inner(args, ctx)
if (extractedDir === null) return blocks
return prependBaseDir(blocks, extractedDir)
}
}
// ... 构建 Command 对象并注册 ...
}关键设计点:
- 提取延迟到首次调用:启动时不需要解压任何文件
- Promise 级别缓存:
extractionPromise ??=确保并发调用不会重复解压 - 安全写入:
safeWriteFile()使用O_CREAT | O_EXCL | O_NOFOLLOW防止符号链接劫持 - 路径遍历防护:
resolveSkillFilePath()拒绝包含..的相对路径
5.4 Skill 文件的提取与写入安全
extractBundledSkillFiles() 的实现体现了安全工程的纵深防御思想:
// src/skills/bundledSkills.ts ~120-180
async function extractBundledSkillFiles(
skillName: string,
files: Record<string, string>,
): Promise<string | null> {
const dir = getBundledSkillExtractDir(skillName)
try {
await writeSkillFiles(dir, files)
return dir
} catch (e) {
logForDebugging(`Failed to extract bundled skill '${skillName}'...`)
return null
}
}
async function writeSkillFiles(dir: string, files: Record<string, string>): Promise<void> {
const byParent = new Map<string, [string, string][]>()
for (const [relPath, content] of Object.entries(files)) {
const target = resolveSkillFilePath(dir, relPath)
const parent = dirname(target)
const group = byParent.get(parent)
if (group) group.push([target, content])
else byParent.set(parent, [[target, content]])
}
await Promise.all(
[...byParent].map(async ([parent, entries]) => {
await mkdir(parent, { recursive: true, mode: 0o700 })
await Promise.all(entries.map(([p, c]) => safeWriteFile(p, c)))
}),
)
}
async function safeWriteFile(p: string, content: string): Promise<void> {
const fh = await open(p, SAFE_WRITE_FLAGS, 0o600)
try {
await fh.writeFile(content, 'utf8')
} finally {
await fh.close()
}
}安全防护措施层层递进:
- 进程级随机 nonce:
getBundledSkillsRoot()包含随机值,防止攻击者预创建目录 - 严格权限:
mkdir(..., 0o700)和open(..., 0o600)确保仅所有者可读写 - 原子写入:
O_EXCL保证文件不存在时才创建,防止覆盖攻击 - 禁止跟随符号链接:
O_NOFOLLOW防止符号链接劫持
六、总结:Skills 作为 Agent 能力边界扩展器
Claude Code 的 Skills 系统是一套经过深度工程化的能力注册与调度框架。它的设计哲学可以概括为:
- 延迟加载:只在需要时支付 token 和 I/O 成本
- 分层覆盖:Managed → User → Project → Bundled 的优先级体系
- 条件激活:
paths字段实现精准触发的 Context Engineering - 上下文隔离:
fork模式支持复杂工作流的安全组合 - 权限最小化:
allowed-tools白名单 +skillHasOnlySafeProperties自动授权 - 安全纵深:从路径遍历防护到符号链接防御的多层安全设计
对于 AI Agent 开发者而言,Skills 系统的最大启示在于:提示词工程正在从"一次性编写"演进为"可组合、可版本化、可动态发现的能力单元"。Claude Code 通过将 Skill 定义为 first-class citizen(一等公民),让 LLM 能够自主决策何时、以何种方式调用领域专家能力——这正是从"聊天机器人"到"自主 Agent"的关键跃迁。
在下一篇文章中,我们将继续深入 Claude Code 的 Hooks 系统,分析它如何在不修改核心代码的情况下,通过事件订阅机制扩展 Agent 的行为边界。