Why this matters
Skills begin as markdown files with frontmatter. The loader reads that frontmatter, fills in the missing pieces, and turns the file into a Command object the runtime can list and invoke later.
Before the loader excerpt
The loader needs three helper shapes before it can read the full skill block. HooksSettings is the validated hooks map, EffortValue is either a named effort level or a number, and FrontmatterShell is the shell selector for ! blocks.
HooksSettings
The validated hooks map, keyed by hook event.
export type HooksSettings = Partial<Record<HookEvent, HookMatcher[]>>HooksSettings is the typed version of the hooks map after the YAML is checked.
Each hook event can point to a list of hook matchers, so the loader knows
exactly which hook commands to register.
EffortValue
The effort level the loader can carry forward for a skill.
export type EffortValue = EffortLevel | numberEffortValue is either a named effort level such as low, medium, high,
or max, or a plain number. The loader keeps that value around so later code
can decide how much thinking budget to use.
FrontmatterShell
The shell choice used when the skill runs `!` blocks.
export type FrontmatterShell = 'bash' | 'powershell'FrontmatterShell is the author’s choice of shell for markdown ! blocks.
It keeps shell-specific command snippets predictable across platforms.
From frontmatter to command
The runtime does not use the markdown file directly. It first discovers the file on disk, then parses the frontmatter fields that describe how the skill should behave, and only then builds the command-shaped object the rest of the app can work with.
Here FrontmatterData is the parsed YAML block, SettingSource tells the loader where the skill came from, and Command is the runtime object the rest of the app can list and invoke later.
Skill loader excerpt
The loader contract that discovers a skill, reads its frontmatter, and turns it into a `Command` the runtime can keep and invoke later.
export type LoadedFrom =
| 'commands_DEPRECATED'
| 'skills'
| 'plugin'
| 'managed'
| 'bundled'
| 'mcp'
/**
* Returns a claude config directory path for a given source.
*/
export function getSkillsPath(
source: SettingSource | 'plugin',
dir: 'skills' | 'commands',
): string {
switch (source) {
case 'policySettings':
return join(getManagedFilePath(), '.claude', dir)
case 'userSettings':
return join(getClaudeConfigHomeDir(), dir)
case 'projectSettings':
return `.claude/${dir}`
case 'plugin':
return 'plugin'
default:
return ''
}
}
/**
* Estimates token count for a skill based on frontmatter only
* (name, description, whenToUse) since full content is only loaded on invocation.
*/
export function estimateSkillFrontmatterTokens(skill: Command): number {
const frontmatterText = [skill.name, skill.description, skill.whenToUse]
.filter(Boolean)
.join(' ')
return roughTokenCountEstimation(frontmatterText)
}
/**
* Gets a unique identifier for a file by resolving symlinks to a canonical path.
* This allows detection of duplicate files accessed through different paths
* (e.g., via symlinks or overlapping parent directories).
* Returns null if the file doesn't exist or can't be resolved.
*
* Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids
* issues with filesystems that report unreliable inode values (e.g., inode 0 on
* some virtual/container/NFS filesystems, or precision loss on ExFAT).
* See: https://github.com/anthropics/claude-code/issues/13893
*/
async function getFileIdentity(filePath: string): Promise<string | null> {
try {
return await realpath(filePath)
} catch {
return null
}
}
// Internal type to track skill with its file path for deduplication
type SkillWithPath = {
skill: Command
filePath: string
}
/**
* Parse and validate hooks from frontmatter.
* Returns undefined if hooks are not defined or invalid.
*/
function parseHooksFromFrontmatter(
frontmatter: FrontmatterData,
skillName: string,
): HooksSettings | undefined {
if (!frontmatter.hooks) {
return undefined
}
const result = HooksSchema().safeParse(frontmatter.hooks)
if (!result.success) {
logForDebugging(
`Invalid hooks in skill '${skillName}': ${result.error.message}`,
)
return undefined
}
return result.data
}
/**
* Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules.
* Returns undefined if no paths are specified or if all patterns are match-all.
*/
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 all patterns are ** (match-all), treat as no paths (undefined)
if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
return undefined
}
return patterns
}
/**
* Parses all skill frontmatter fields that are shared between file-based and
* MCP skill loading. Caller supplies the resolved skill name and the
* source/loadedFrom/baseDir/paths fields separately.
*/
export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
): {
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
} {
const validatedDescription = coerceDescriptionToString(
frontmatter.description,
resolvedName,
)
const description =
validatedDescription ??
extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel)
const userInvocable =
frontmatter['user-invocable'] === undefined
? true
: parseBooleanFrontmatter(frontmatter['user-invocable'])
const model =
frontmatter.model === 'inherit'
? undefined
: frontmatter.model
? parseUserSpecifiedModel(frontmatter.model as string)
: undefined
const effortRaw = frontmatter['effort']
const effort =
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
if (effortRaw !== undefined && effort === undefined) {
logForDebugging(
`Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
)
}
return {
displayName:
frontmatter.name != null ? String(frontmatter.name) : undefined,
description,
hasUserSpecifiedDescription: validatedDescription !== null,
allowedTools: parseSlashCommandToolsFromFrontmatter(
frontmatter['allowed-tools'],
),
argumentHint:
frontmatter['argument-hint'] != null
? String(frontmatter['argument-hint'])
: undefined,
argumentNames: parseArgumentNames(
frontmatter.arguments as string | string[] | undefined,
),
whenToUse: frontmatter.when_to_use as string | undefined,
version: frontmatter.version as string | undefined,
model,
disableModelInvocation: parseBooleanFrontmatter(
frontmatter['disable-model-invocation'],
),
userInvocable,
hooks: parseHooksFromFrontmatter(frontmatter, resolvedName),
executionContext: frontmatter.context === 'fork' ? 'fork' : undefined,
agent: frontmatter.agent as string | undefined,
effort,
shell: parseShellFrontmatter(frontmatter.shell, resolvedName),
}
} The important part is the shape change: a skill is not just text after the load step. It becomes a command-shaped object with a name, description, tools, hooks, and execution mode, so the runtime can decide whether to show it, hide it, or invoke it later.
This is the handoff: the parsed fields are packed into the Command object and
the runtime keeps that object around for listing and invocation later.
Command handoff
The exact place where parsed frontmatter becomes a `Command` object.
export function createSkillCommand({
skillName,
displayName,
description,
hasUserSpecifiedDescription,
markdownContent,
allowedTools,
argumentHint,
argumentNames,
whenToUse,
version,
model,
disableModelInvocation,
userInvocable,
source,
baseDir,
loadedFrom,
hooks,
executionContext,
agent,
paths,
effort,
shell,
}: {
skillName: string
displayName: string | undefined
description: string
hasUserSpecifiedDescription: boolean
markdownContent: string
allowedTools: string[]
argumentHint: string | undefined
argumentNames: string[]
whenToUse: string | undefined
version: string | undefined
model: string | undefined
disableModelInvocation: boolean
userInvocable: boolean
source: PromptCommand['source']
baseDir: string | undefined
loadedFrom: LoadedFrom
hooks: HooksSettings | undefined
executionContext: 'inline' | 'fork' | undefined
agent: string | undefined
paths: string[] | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
}): Command {
return {
type: 'prompt',
name: skillName,
description,
hasUserSpecifiedDescription,
allowedTools,
argumentHint,
argNames: argumentNames.length > 0 ? argumentNames : undefined,
whenToUse,
version,
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) {