本章总览
loadSkillsDir.ts(约 1087 行)扫描 .claude/skills、managed、plugin 路径下的 SKILL.md,解析 frontmatter 为 PromptCommand。bundledSkills.ts 提供 registerBundledSkill 编程式注册,供 bundled/*.ts 在 init 时写入。本章讲解 LoadedFrom 来源、createSkillCommand、files 提取与 registerMCPSkillBuilders 循环打破。loadSkillsDir 是 commands.ts 与 SkillTool 的共同上游。
学完本章你应该能
- 说明 getSkillsPath(source, dir) 路径规则
- 解释 createSkillCommand frontmatter 字段映射
- 描述 registerBundledSkill 的 files 提取与 prependBaseDir
- 理解 registerMCPSkillBuilders 在 loadSkillsDir 模块 init 注册
- 掌握 clearDynamicSkills 与 /clear caches 衔接
核心概念(先读懂这些)
LoadedFrom 五源 + deprecated commands
LoadedFrom = commands_DEPRECATED | skills | plugin | managed | bundled | mcp。disk skill 与旧 commands 目录并存;frontmatter 决定 model、allowed-tools、context:fork 等。plugin-only policy 可限制非 plugin skill。
mcpSkillBuilders 打破循环依赖
loadSkillsDir 静态 import mcpSkillBuilders 并在文件末尾 registerMCPSkillBuilders({ createSkillCommand, parseSkillFrontmatterFields })。mcpSkills.ts 通过 getMCPSkillBuilders() 延迟获取,避免 client.ts → mcpSkills → loadSkillsDir → client 环。
建议学习步骤
- 阅读 LoadedFrom 与 getSkillsPath(源码块 A)
- 阅读 createSkillCommand 入口(源码块 B)
- 阅读 registerBundledSkill 与 files 提取(源码块 C、D)
- 阅读 registerMCPSkillBuilders 末尾注册(源码块 E)
- 阅读 frontmatter 与 HooksSchema(源码块 F)
常见误区
注意
executeShellCommandsInPrompt 在 load 时执行 shell 片段——安全敏感
注意
isBareMode 可能跳过部分目录扫描
注意
disk IO 失败 isENOENT / isFsInaccessible 区分 skip vs error log
注意
plugin-only policy 下非 plugin skill 静默 skip
注意
memoize cache 需在 plugin install 后 clearDynamicSkills
skills 加载在架构中的位置
main.tsx initBundledSkills()
commands.ts getCommands(projectRoot)
├─ builtIn slash commands
├─ loadSkillsFromDir (loadSkillsDir.ts)
├─ getBundledSkills() (bundledSkills.ts 内部数组)
└─ MCP commands (AppState.mcp.commands, loadedFrom==='mcp')
REPL useSkillsChange(projectRoot, setLocalCommands)
→ skill 文件变更 → getCommands 全量 refresh
SkillsMenu.tsx 用 getSkillsPath、estimateSkillFrontmatterTokens 展示菜单。
FileRead/Write/EditTool import loadSkillsDir 辅助函数解析 skill 路径边界。
源码引用: src/commands.ts · 第 160–160 行(共 755 行)
160| } from './skills/loadSkillsDir.js'
源码引用: src/screens/REPL.tsx · 第 683–684 行(共 7050 行)
683| <Box flexGrow={1} />
684| <Text dimColor>
LoadedFrom 与 getSkillsPath
export type LoadedFrom = 'commands_DEPRECATED' | 'skills' | 'plugin' | 'managed' | 'bundled' | 'mcp'
getSkillsPath(source, dir) 返回 claude config 下 skills 或 commands 目录:
- user / project / local / managed / plugin 源
- 与 settings constants isSettingSourceEnabled 联动
- managed 路径经 getManagedFilePath
扫描使用 loadMarkdownFilesForSubdir、parseFrontmatter、parseSlashCommandToolsFromFrontmatter。
isPathGitignored / ignore 包排除 gitignore 目录内 skill。
源码引用: src/skills/loadSkillsDir.ts · 第 67–80 行(共 1087 行)
67| export type LoadedFrom =
68| | 'commands_DEPRECATED'
69| | 'skills'
70| | 'plugin'
71| | 'managed'
72| | 'bundled'
73| | 'mcp'
74|
75| /**
76| * Returns a claude config directory path for a given source.
77| */
78| export function getSkillsPath(
79| source: SettingSource | 'plugin',
80| dir: 'skills' | 'commands',
源码引用: src/skills/loadSkillsDir.ts · 第 78–95 行(共 1087 行)
78| export function getSkillsPath(
79| source: SettingSource | 'plugin',
80| dir: 'skills' | 'commands',
81| ): string {
82| switch (source) {
83| case 'policySettings':
84| return join(getManagedFilePath(), '.claude', dir)
85| case 'userSettings':
86| return join(getClaudeConfigHomeDir(), dir)
87| case 'projectSettings':
88| return `.claude/${dir}`
89| case 'plugin':
90| return 'plugin'
91| default:
92| return ''
93| }
94| }
95|
createSkillCommand
createSkillCommand({...}) 把 MarkdownFile + frontmatter 转为 PromptCommand:
关键 frontmatter 字段:
- description / argument-hint / allowed-tools
- model / effort / disable-model-invocation
- context: inline | fork → SkillTool executeForkedSkill
- agent → 指定 subagent type
- hooks → HooksSchema 校验
getPromptForCommand 闭包:
- substituteArguments 替换 $ARGUMENTS
- executeShellCommandsInPrompt 执行 inline shell
- roughTokenCountEstimation 记录 analytics
parseSkillFrontmatterFields 供 MCP skill builders 复用同一解析逻辑。
源码引用: src/skills/loadSkillsDir.ts · 第 270–310 行(共 1087 行)
270| export function createSkillCommand({
271| skillName,
272| displayName,
273| description,
274| hasUserSpecifiedDescription,
275| markdownContent,
276| allowedTools,
277| argumentHint,
278| argumentNames,
279| whenToUse,
280| version,
281| model,
282| disableModelInvocation,
283| userInvocable,
284| source,
285| baseDir,
286| loadedFrom,
287| hooks,
288| executionContext,
289| agent,
290| paths,
291| effort,
292| shell,
293| }: {
294| skillName: string
295| displayName: string | undefined
296| description: string
297| hasUserSpecifiedDescription: boolean
298| markdownContent: string
299| allowedTools: string[]
300| argumentHint: string | undefined
301| argumentNames: string[]
302| whenToUse: string | undefined
303| version: string | undefined
304| model: string | undefined
305| disableModelInvocation: boolean
306| userInvocable: boolean
307| source: PromptCommand['source']
308| baseDir: string | undefined
309| loadedFrom: LoadedFrom
310| hooks: HooksSettings | undefined
源码引用: src/utils/frontmatterParser.ts · 第 36–36 行(共 371 行)
36| // Validated by HooksSchema in loadSkillsDir.ts
registerBundledSkill 注册表
BundledSkillDefinition 字段:
- name, description, aliases, whenToUse
- allowedTools, model, disableModelInvocation, userInvocable
- isEnabled() 动态 gate(feature / env)
- hooks, context, agent
- files — 首次调用时 extract 到磁盘,prepend "Base directory for this skill"
内部 bundledSkills: Command[] 数组;getBundledSkills() 返回副本供 getCommands merge。
registerBundledSkill 包装 getPromptForCommand:files 存在时 memoize extractionPromise 防并发 race。
源码引用: src/skills/bundledSkills.ts · 第 15–41 行(共 221 行)
15| export type BundledSkillDefinition = {
16| name: string
17| description: string
18| aliases?: string[]
19| whenToUse?: string
20| argumentHint?: string
21| allowedTools?: string[]
22| model?: string
23| disableModelInvocation?: boolean
24| userInvocable?: boolean
25| isEnabled?: () => boolean
26| hooks?: HooksSettings
27| context?: 'inline' | 'fork'
28| agent?: string
29| /**
30| * Additional reference files to extract to disk on first invocation.
31| * Keys are relative paths (forward slashes, no `..`), values are content.
32| * When set, the skill prompt is prefixed with a "Base directory for this
33| * skill: <dir>" line so the model can Read/Grep these files on demand —
34| * same contract as disk-based skills.
35| */
36| files?: Record<string, string>
37| getPromptForCommand: (
38| args: string,
39| context: ToolUseContext,
40| ) => Promise<ContentBlockParam[]>
41| }
源码引用: src/skills/bundledSkills.ts · 第 53–80 行(共 221 行)
53| export function registerBundledSkill(definition: BundledSkillDefinition): void {
54| const { files } = definition
55|
56| let skillRoot: string | undefined
57| let getPromptForCommand = definition.getPromptForCommand
58|
59| if (files && Object.keys(files).length > 0) {
60| skillRoot = getBundledSkillExtractDir(definition.name)
61| // Closure-local memoization: extract once per process.
62| // Memoize the promise (not the result) so concurrent callers await
63| // the same extraction instead of racing into separate writes.
64| let extractionPromise: Promise<string | null> | undefined
65| const inner = definition.getPromptForCommand
66| getPromptForCommand = async (args, ctx) => {
67| extractionPromise ??= extractBundledSkillFiles(definition.name, files)
68| const extractedDir = await extractionPromise
69| const blocks = await inner(args, ctx)
70| if (extractedDir === null) return blocks
71| return prependBaseDir(blocks, extractedDir)
72| }
73| }
74|
75| const command: Command = {
76| type: 'prompt',
77| name: definition.name,
78| description: definition.description,
79| aliases: definition.aliases,
80| hasUserSpecifiedDescription: true,
files 提取与 prependBaseDir
当 definition.files 非空:
skillRoot = getBundledSkillExtractDir(name)extractBundledSkillFiles(name, files)写入 forward-slash 相对路径- getPromptForCommand wrapper prepend base dir 行到 ContentBlockParam[]
模型收到 prompt 后可 Read/Grep 提取目录内 reference md——与磁盘 skill 相同契约。
getBundledSkillsRoot 来自 utils/permissions/filesystem.js,权限系统知悉 extract 根路径。
源码引用: src/skills/bundledSkills.ts · 第 59–73 行(共 221 行)
59| if (files && Object.keys(files).length > 0) {
60| skillRoot = getBundledSkillExtractDir(definition.name)
61| // Closure-local memoization: extract once per process.
62| // Memoize the promise (not the result) so concurrent callers await
63| // the same extraction instead of racing into separate writes.
64| let extractionPromise: Promise<string | null> | undefined
65| const inner = definition.getPromptForCommand
66| getPromptForCommand = async (args, ctx) => {
67| extractionPromise ??= extractBundledSkillFiles(definition.name, files)
68| const extractedDir = await extractionPromise
69| const blocks = await inner(args, ctx)
70| if (extractedDir === null) return blocks
71| return prependBaseDir(blocks, extractedDir)
72| }
73| }
源码引用: src/skills/bundledSkills.ts · 第 43–52 行(共 221 行)
43| // Internal registry for bundled skills
44| const bundledSkills: Command[] = []
45|
46| /**
47| * Register a bundled skill that will be available to the model.
48| * Call this at module initialization or in an init function.
49| *
50| * Bundled skills are compiled into the CLI binary and available to all users.
51| * They follow the same pattern as registerPostSamplingHook() for internal features.
52| */
registerMCPSkillBuilders 模块 init
loadSkillsDir.ts 文件末尾:
registerMCPSkillBuilders({
createSkillCommand,
parseSkillFrontmatterFields,
})
mcpSkillBuilders.ts 注释解释:
- 动态 import 非字面量在 Bun bunfs 失败
- 静态 import 导致 dependency-cruiser 环
- write-once registry 在 loadSkillsDir eval 时注册(commands.ts 静态 import 保证 startup 前完成)
getMCPSkillBuilders() 未注册时 throw,表明 load order bug。
源码引用: src/skills/mcpSkillBuilders.ts · 第 6–44 行(共 45 行)
6| /**
7| * Write-once registry for the two loadSkillsDir functions that MCP skill
8| * discovery needs. This module is a dependency-graph leaf: it imports nothing
9| * but types, so both mcpSkills.ts and loadSkillsDir.ts can depend on it
10| * without forming a cycle (client.ts → mcpSkills.ts → loadSkillsDir.ts → …
11| * → client.ts).
12| *
13| * The non-literal dynamic-import approach ("await import(variable)") fails at
14| * runtime in Bun-bundled binaries — the specifier is resolved against the
15| * chunk's /$bunfs/root/… path, not the original source tree, yielding "Cannot
16| * find module './loadSkillsDir.js'". A literal dynamic import works in bunfs
17| * but dependency-cruiser tracks it, and because loadSkillsDir transitively
18| * reaches almost everything, the single new edge fans out into many new cycle
19| * violations in the diff check.
20| *
21| * Registration happens at loadSkillsDir.ts module init, which is eagerly
22| * evaluated at startup via the static import from commands.ts — long before
23| * any MCP server connects.
24| */
25|
26| export type MCPSkillBuilders = {
27| createSkillCommand: typeof createSkillCommand
28| parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields
29| }
30|
31| let builders: MCPSkillBuilders | null = null
32|
33| export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
34| builders = b
35| }
36|
37| export function getMCPSkillBuilders(): MCPSkillBuilders {
38| if (!builders) {
39| throw new Error(
40| 'MCP skill builders not registered — loadSkillsDir.ts has not been evaluated yet',
41| )
42| }
43| return builders
44| }
源码引用: src/skills/loadSkillsDir.ts · 第 65–65 行(共 1087 行)
65| import { registerMCPSkillBuilders } from './mcpSkillBuilders.js'
frontmatter 解析与 HooksSchema
loadSkillsDir 使用 parseFrontmatter、parseBooleanFrontmatter、parseShellFrontmatter、splitPathInFrontmatter 处理 SKILL.md YAML。
HooksSchema(utils/settings/types.js)校验 skill hooks 块——与 settings hooks 同 schema,避免两套规则。
parseSlashCommandToolsFromFrontmatter 限制 skill 可调用 tool 子集;与 allowed-tools 字段协同。
isRestrictedToPluginOnly 企业策略下拒绝非 plugin skill 加载,log 并 skip。
源码引用: src/skills/loadSkillsDir.ts · 第 38–56 行(共 1087 行)
38| import {
39| coerceDescriptionToString,
40| type FrontmatterData,
41| type FrontmatterShell,
42| parseBooleanFrontmatter,
43| parseFrontmatter,
44| parseShellFrontmatter,
45| splitPathInFrontmatter,
46| } from '../utils/frontmatterParser.js'
47| import { getFsImplementation } from '../utils/fsOperations.js'
48| import { isPathGitignored } from '../utils/git/gitignore.js'
49| import { logError } from '../utils/log.js'
50| import {
51| extractDescriptionFromMarkdown,
52| getProjectDirsUpToHome,
53| loadMarkdownFilesForSubdir,
54| type MarkdownFile,
55| parseSlashCommandToolsFromFrontmatter,
56| } from '../utils/markdownConfigLoader.js'
memoize 与并发加载
loadSkillsDir 使用 lodash memoize 缓存目录扫描结果;plugin 安装或 /clear 时 clearDynamicSkills bust cache。
createSignal 与 skillChangeDetector 配合 REPL useSkillsChange 热重载。
logEvent skill 加载带 LoadedFrom 维度 analytics;失败文件收集到 warnings 而非 crash 全表。
getProjectDirsUpToHome 限定扫描深度,避免 HOME 以上越界。
缓存失效与 telemetry
clearDynamicSkills(loadSkillsDir export)在 /clear caches 命令调用,丢弃 memoized 扫描结果。
skillChangeDetector / skillLoadedEvent telemetry 在 skill 加载时 logEvent。
estimateSkillFrontmatterTokens 供 SkillsMenu、analyzeContext 估算 skill 占用 context。
disk skill 变更 → useSkillsChange → REPL setLocalCommands → 无需重启进程。
源码引用: src/commands/clear/caches.ts · 第 26–26 行(共 145 行)
26| import { clearDynamicSkills } from '../../skills/loadSkillsDir.js'
源码引用: src/components/skills/SkillsMenu.tsx · 第 7–7 行(共 206 行)
7| type CommandResultDisplay,
本章小结与延伸
loadSkillsDir = 磁盘 skill 扫描器;bundledSkills = 内存注册表。MCP 构建见 mcp-skills 章。扫描路径含 user/project/managed/plugin 四层 settings 源。 继续学习: