The Problem
When you type /simplify in Claude Code, it automatically performs a three-dimensional review of your recently modified code — code reuse, quality, and efficiency. When you install a Plugin, its Skills automatically appear in the available list. When an MCP Server exposes Prompts with specific frontmatter markers, those Prompts are also transformed into Skills for the model to invoke.
Behind all this is a three-layer extensibility architecture:
- Skill layer: Skill definitions carried in Markdown files, with metadata declared via frontmatter, supporting multi-source loading, argument substitution, and conditional activation
- Plugin layer: Bundles multiple Skills, Hooks, and MCP Servers into installable extension units, split into Built-in and Marketplace tiers
- MCP layer: Dynamically discovers and loads Skills from remote Servers via the MCP protocol
Each layer has its own independent registration mechanism, but they all ultimately converge into a single Command[] array, with SkillTool serving as the sole entry point to expose them to the model. This article starts from Skill system file loading and progressively dives into the design and implementation of this extensibility architecture.
Skill System Overview
Core Data Structure: Command
All Skills are ultimately represented as the Command type. Understanding this type is foundational to understanding the entire system:
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)
The source field marks where the Skill comes from — 'bundled' indicates a skill compiled into the CLI binary, 'plugin' comes from a Plugin, 'mcp' from an MCP Server, and SettingSource values ('userSettings', 'projectSettings', 'policySettings') correspond to file Skills loaded from different directories.
getPromptForCommand is the core method: it accepts user arguments and tool context, returning prompt content to inject into the conversation. Skills from different sources implement their own argument substitution, shell command execution, and security policies within this method.
Multi-Source Loading Architecture
Skill Sources
Managed Skills
(Policy directory)
User Skills
(~/.claude/skills/)
Project Skills
(.claude/skills/)
Legacy Commands
(.claude/commands/)
Bundled Skills
(Compiled in)
Plugin Skills
(Marketplace)
MCP Skills
(Remote Server)
Dynamic Skills
(Runtime discovery)
Loaders
loadSkillsFromSkillsDir()
loadSkillsFromCommandsDir()
getBuiltinPluginSkillCommands()
Unified Registration
getSkillDirCommands()
memoized
getCommands()
merge all sources
SkillTool
Model's sole entry point
Loading priority follows this order, with the first-loaded Skill winning when names collide.
File Skill Loading: loadSkillsDir.ts
The core loading logic for file Skills lives in loadSkillsDir.ts. This file exceeds 1000 lines and is the most complex module in the entire Skill system.
Directory Structure Convention
The Skills directory only supports the directory format: each Skill is a directory containing a SKILL.md file.
1.claude/skills/
2├── review-code/
3│ └── SKILL.md # Skill definition
4├── deploy/
5│ ├── SKILL.md # Skill definition
6│ └── scripts/
7│ └── deploy.sh # Supporting files
8└── frontend:lint/ # Namespace -> "frontend:lint"
9 └── SKILL.md
The loading function loadSkillsFromSkillsDir traverses the directory, reading each 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 // Only directory format supported: 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}
Note several design points:
- Directory format only. Single
.md files in the /skills/ directory are ignored, ensuring each Skill can carry supporting files (scripts, templates, etc.).
- Symbolic link support. The
isSymbolicLink() check enables Skills to be shared via symlinks.
- Parallel loading.
Promise.all reads all Skill files concurrently.
- Graceful degradation. A loading failure for one Skill doesn't affect the others.
Frontmatter Metadata Parsing
The SKILL.md file's frontmatter is the complete declaration of a Skill's behavior. The parseSkillFrontmatterFields function handles all possible fields:
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 // ...parsing logic
25}
A complete SKILL.md frontmatter example:
1---
2name: "Code Review Assistant"
3description: "Perform multi-dimensional code review on Git changes"
4when_to_use: "Trigger when user asks to review code or submit a 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## Review Process
34
35Review the changes on the $ARGUMENTS branch across the following dimensions...
36
37Current Session ID: ${CLAUDE_SESSION_ID}
38Skill directory: ${CLAUDE_SKILL_DIR}
The semantics of these frontmatter fields are worth explaining one by one:
| Field | Type | Purpose |
|---|
name | string | Display name (doesn't affect command name) |
description | string | Brief description, used in Skill listings |
when_to_use | string | Tells the model when it should invoke this Skill |
allowed-tools | string[] | Additional tools allowed during Skill execution |
arguments | string/string[] | Named argument list |
model | string | Model override (e.g., sonnet, opus, inherit) |
effort | EffortValue | Reasoning effort level |
context | 'fork' | Whether to execute in a sub-Agent |
paths | string[] | File path patterns for conditional activation |
hooks | HooksSettings | Skill-level Hook configuration |
user-invocable | boolean | Whether users can invoke via /name |
disable-model-invocation | boolean | Prevent model from proactively invoking |
Argument Substitution Mechanism
Skill prompt content supports multiple argument substitution patterns:
1async getPromptForCommand(args, toolUseContext) {
2 let finalContent = baseDir
3 ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
4 : markdownContent
5
6 // 1. User argument substitution: $ARGUMENTS, $1, $2, or named arguments
7 finalContent = substituteArguments(
8 finalContent, args, true, argumentNames,
9 )
10
11 // 2. Built-in variable substitution
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 command execution (non-MCP Skills only)
24 if (loadedFrom !== 'mcp') {
25 finalContent = await executeShellCommandsInPrompt(
26 finalContent, toolUseContext, `/${skillName}`, shell,
27 )
28 }
29
30 return [{ type: 'text', text: finalContent }]
31}
There are three layers of substitution:
- User arguments:
$ARGUMENTS is replaced with the full argument string, $1/$2 with positional arguments, and named arguments like ${branch} with their corresponding values.
- Built-in variables:
${CLAUDE_SKILL_DIR} points to the Skill's directory, and ${CLAUDE_SESSION_ID} is the current session ID.
- Shell commands: The
!`command` and ```! command ``` syntax within Skill content is actually executed, with output substituted into the prompt. This is a key capability for Skills to interact with the environment — but for security reasons, MCP-sourced Skills are prohibited from executing shell commands.
Multi-Level Source Aggregation and Deduplication
getSkillDirCommands is the aggregation entry point for file Skills. It uses memoize to cache results and loads Skills from five sources in parallel:
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 // Merge all sources
32 const allSkillsWithPaths = [
33 ...managedSkills,
34 ...userSkills,
35 ...projectSkillsNested.flat(),
36 ...additionalSkillsNested.flat(),
37 ...legacyCommands,
38 ]
39
40 // ... deduplication and conditional Skill separation
41 },
42)
Note the source priority order: Managed (enterprise policy) > User (user global) > Project (project-level) > Additional (--add-dir) > Legacy (old /commands/).
Realpath-Based Deduplication
Multiple sources may point to the same file through different paths (e.g., via symlink). The system uses realpath to resolve to canonical paths for deduplication:
1async function getFileIdentity(filePath: string): Promise<string | null> {
2 try {
3 return await realpath(filePath)
4 } catch {
5 return null
6 }
7}
The deduplication process first computes identities for all files in parallel, then synchronously scans to remove duplicates:
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}
This design chose realpath over inode comparison because some filesystems (NFS, ExFAT, container virtual FS) report unreliable inode values. The pattern of doing IO in parallel first (Promise.all for identities), then synchronous logic (iterative deduplication), is a typical approach that balances performance and correctness.
Paths Conditional Activation
Skills can declare via paths frontmatter that they should only be active under specific file paths:
1---
2paths:
3 - "src/components/**"
4 - "src/styles/**"
5---
When a Skill has a paths field, it's not immediately loaded into the available list but stored in a conditionalSkills Map. Only when the model operates on files matching the paths is the Skill activated:
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}
This uses the ignore library (the same glob matching rules as .gitignore). After matching, the Skill is moved from conditionalSkills to dynamicSkills, and other modules are notified via a signal to clear caches. The activatedConditionalSkillNames Set ensures that when Skills are reloaded (after cache clearing), already-activated Skills won't be demoted back to conditional.
This "lazy activation" design has a clear performance purpose: a project may have many Skills, but not all are relevant to the current work. Through path filtering, the Skill list stays lean, reducing token consumption in the system prompt.
Dynamic Skill Discovery
Beyond conditional activation, the system also supports dynamically discovering new Skill directories from file operations:
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 // Walk up from file location to cwd, checking each level for .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 // Check if gitignored
21 if (await isPathGitignored(currentDir, resolvedCwd)) {
22 continue
23 }
24 newDirs.push(skillDir)
25 } catch {
26 // Directory doesn't exist, continue
27 }
28 }
29
30 const parent = dirname(currentDir)
31 if (parent === currentDir) break
32 currentDir = parent
33 }
34 }
35
36 // Sort by depth, deeper directories have higher priority
37 return newDirs.sort(
38 (a, b) => b.split(pathSep).length - a.split(pathSep).length,
39 )
40}
When the model Reads or Edits a file like src/modules/payments/handler.ts, the system checks upward for src/modules/payments/.claude/skills/, src/modules/.claude/skills/, and so on. If new Skill directories are found, they're loaded and merged into the available Skills.
Worth noting is that the dynamicSkillDirs Set also records directories that have been checked (whether successfully or not), avoiding duplicate stat calls to the same directory. Additionally, Skill directories under .gitignore paths are skipped, preventing malicious Skills in node_modules from being loaded.
Effort Level
Skills can control the model's reasoning effort level via the 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}
When a Skill runs with context: fork, effort is injected into the sub-Agent's definition:
1const agentDefinition =
2 command.effort !== undefined
3 ? { ...baseAgent, effort: command.effort }
4 : baseAgent
This allows specific Skills to demand higher or lower reasoning intensity — for example, a code review Skill might set effort: high, while a simple formatting Skill sets effort: low for faster execution.
Bundled Skill System
The registerBundledSkill Registration Pattern
Bundled Skills are compiled into the CLI binary and don't depend on the filesystem. They're registered at startup via the registerBundledSkill function:
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 // ...other fields
28 source: 'bundled',
29 loadedFrom: 'bundled',
30 getPromptForCommand,
31 }
32 bundledSkills.push(command)
33}
There's an elegant lazy file extraction mechanism here: when a Bundled Skill declares files (reference files), these files aren't written to disk at registration time but extracted on first invocation. extractionPromise ??= ... uses the nullish assignment operator for memoization — multiple concurrent invocations share the same Promise without duplicate extraction.
Secure File Writing
When extracting files to disk, the system employs multiple layers of security measures:
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: Fails if the file already exists, preventing overwrite of pre-created malicious files
O_NOFOLLOW: Doesn't follow symbolic links, preventing symlink attacks
0o600: File permissions restricted to owner read/write
0o700: Directory permissions restricted to owner only
Path validation is equally strict:
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 Registration Flow
All Bundled Skills are registered in 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 // ...more feature-gated skills
23}
There are two registration patterns:
- Unconditional registration: Always-available Skills like
registerSimplifySkill() are imported at the module top level
- Feature-gated registration: Uses
feature() to check feature flags, with require() for lazy loading
require() is used instead of import() because after Bun bundling, dynamic import() path resolution points to /$bunfs/root/..., while require() works correctly.
Practical Example: The simplify Skill
Let's see how an actual Bundled Skill works:
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 is a carefully designed multi-stage prompt that instructs the model to:
- Run
git diff to identify changes
- Launch three parallel Agents to review code reuse, code quality, and efficiency respectively
- Summarize findings and directly fix issues
This demonstrates the core value of Bundled Skills: encapsulating expert-level multi-step workflows into a single command.
Plugin System
Two-Tier Plugin Architecture
Marketplace Plugins
commands/ skills/ agents/
Plugin Management
getBuiltinPluginSkillCommands()
Built-in Plugin
The key difference between Built-in Plugins and Bundled Skills is: users can enable/disable Built-in Plugins.
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}
A Built-in Plugin can contain multiple components:
- skills: A list of skills defined via
BundledSkillDefinition
- hooks: Lifecycle hook configuration
- mcpServers: MCP Server configuration
Plugin IDs use the {name}@builtin format, distinguishing them from Marketplace Plugins' {name}@{marketplace}.
Enable/Disable State Management
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 // Availability check (e.g., platform restrictions)
11 if (definition.isAvailable && !definition.isAvailable()) {
12 continue
13 }
14
15 const pluginId = `${name}@${BUILTIN_MARKETPLACE_NAME}`
16 const userSetting = settings?.enabledPlugins?.[pluginId]
17 // Priority: user setting > Plugin default > 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}
The state determination chain: isAvailable() -> userSetting -> defaultEnabled -> true. If isAvailable() returns false, the Plugin is completely invisible; otherwise the user's setting in the /plugin UI takes priority, falling back to the Plugin's default value, with a final fallback to enabled.
Skill Conversion from Plugin to Command
Plugin Skills are converted to standard Command objects via skillDefinitionToCommand:
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 // Key point: source is set to 'bundled' not 'builtin'
9 // 'builtin' in Command.source represents hardcoded /help, /clear, etc.
10 // Using 'bundled' ensures Plugin Skills appear in the SkillTool list
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}
The choice of source: 'bundled' here is deliberate — a code comment explains why: 'builtin' in Command.source semantics means hardcoded CLI commands (/help, /clear); if Plugin Skills used 'builtin', they would disappear from the SkillTool list.
Marketplace Plugin
Marketplace Plugins are represented by the LoadedPlugin type, with a richer structure:
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 version lock
10 commandsPath?: string
11 commandsPaths?: string[] // Additional command paths from 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 Plugins are distributed via Git repositories. The installation process clones the repository locally, reads the manifest file, then loads various components based on paths declared in the manifest. Skill files use the same directory format as project-level Skills (skill-name/SKILL.md), handled by the same loadSkillsFromSkillsDir loader.
Plugin namespacing uses colon separation: if a Plugin named ralph-loop provides help and cancel-ralph Skills, their full names are ralph-loop:help and ralph-loop:cancel-ralph.
MCP Skill Bridging
mcpSkillBuilders Registration
MCP Servers can expose Skills through the Prompt primitive. When an MCP Prompt's frontmatter contains specific fields, it gets transformed into a Skill rather than a regular 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}
This module solves a subtle circular dependency problem. The MCP code needs to call createSkillCommand and parseSkillFrontmatterFields, but directly importing loadSkillsDir.ts would bring in a massive transitive dependency tree, producing numerous circular warnings in dependency-cruiser checks.
The solution is runtime registration: mcpSkillBuilders.ts only imports types (typeof), creating no runtime dependency. loadSkillsDir.ts registers the actual functions during module initialization, which happens before any MCP Server connects.
Differences Between MCP Skills and File Skills
MCP-sourced Skills have two important restrictions:
- Shell command execution is prohibited:
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} is meaningless: MCP Skills have no local directory, so the Skill directory variable is not substituted.
MCP Skills are retrieved in SkillTool.call() via getAllCommands, which filters loadedFrom === 'mcp' commands from AppState.mcp.commands:
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 deduplicates by name, with local commands taking priority (appearing first in the array).
SkillTool Execution Flow
Execution Modes
SkillTool is the sole tool interface through which the model invokes Skills. It supports two execution modes:
sequenceDiagram
participant M as Model
participant ST as SkillTool
participant V as validateInput
participant P as checkPermissions
participant C as call()
M->>ST: { skill: "simplify", args: "" }
ST->>V: Verify Skill exists
V-->>ST: result: true
ST->>P: Permission check
alt Safe-attribute Skill
P-->>ST: allow (auto)
else Has allow rule
P-->>ST: allow (rule)
else Needs user confirmation
P-->>ST: ask
end
ST->>C: Execute Skill
alt context: 'fork'
C->>C: executeForkedSkill()
Note over C: Runs in sub-Agent<br/>Independent token budget
C-->>ST: { status: 'forked', result: "..." }
else context: 'inline' (default)
C->>C: processPromptSlashCommand()
Note over C: Expands prompt into<br/>current conversation context
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 Mode (Default)
Inline mode expands the Skill's prompt content into the current conversation context:
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}
Results are returned as newMessages, which are inserted into the current conversation flow. The context is simultaneously modified via contextModifier — adding allowedTools and passing through the effort level.
Fork Mode
When a Skill declares context: 'fork', it executes in an independent sub-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 // Report tool call progress to parent
38 }
39
40 const resultText = extractResultText(
41 agentMessages,
42 'Skill execution completed',
43 )
44 agentMessages.length = 0 // Release message memory
45
46 return {
47 data: {
48 success: true,
49 commandName,
50 status: 'forked',
51 agentId,
52 result: resultText,
53 },
54 }
55}
Advantages of Fork mode:
- Independent token budget: The sub-Agent has its own context window, not consuming the main conversation's tokens
- Isolation: Tool calls and intermediate results during Skill execution don't pollute the main conversation
- Memory management: After completion,
agentMessages.length = 0 proactively releases message memory, and clearInvokedSkillsForAgent(agentId) cleans up Skill state
Permission Checking
SkillTool's permission checking distinguishes several scenarios:
- Deny rules checked first: If the user or policy has configured a deny rule (e.g.,
Skill(deploy)), reject immediately
- Safe attributes auto-allow: If the Skill has only safe attributes (no
allowedTools, no hooks, no context: fork), allow automatically
- Allow rule matching: Match user-configured allow rules, supporting prefix wildcards (
review:* matches all Skills starting with review:)
- Ask as fallback: Default to asking the user, providing both "allow this Skill" and "allow this prefix" as shortcut suggestions
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 Listing and Token Budget Management
Skill discovery information is exposed to the model through system-reminder messages. However, there may be many Skills, and listing full descriptions for all of them would waste precious context window tokens. prompt.ts implements a budget management mechanism:
1export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 1% of context window
2export const CHARS_PER_TOKEN = 4
3export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 200K x 4 x 1%
When total Skill descriptions exceed the budget, the system employs a tiered truncation strategy:
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 // Try full descriptions
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 // Partition: bundled (never truncated) vs others
18 // Bundled Skills always keep full descriptions
19 // Other Skills have descriptions truncated proportionally
20
21 if (maxDescLen < MIN_DESC_LENGTH) {
22 // Extreme case: other Skills show name only
23 return commands.map((cmd, i) =>
24 bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
25 ).join('\n')
26 }
27
28 // Normal truncation: non-bundled descriptions truncated to 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}
Design principles:
- Bundled Skills are never truncated: They are core capabilities, and description quality directly affects the model's invocation decisions
- Progressive degradation: First truncate description length, in extreme cases keep only names
- Per-description limit of 250 characters:
MAX_LISTING_DESC_CHARS hard limit, no waste even when budget is ample
Unification and Interaction of the Three Layers
Unified Command Registration
The three layers ultimately unify through the getCommands() function:
1// Pseudocode showing the merge logic
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() further adds MCP Skills:
1const localCommands = await getCommands(getProjectRoot())
2return uniqBy([...localCommands, ...mcpSkills], 'name')
Name Collision Resolution
Same-named Skills may appear across layers. The resolution strategy is: first registered wins.
- Within file Skills: Managed > User > Project (determined by flatten order in
getSkillDirCommands)
- File vs Bundled: File Skills come first in
getCommands
- Local vs MCP:
uniqBy([...localCommands, ...mcpSkills], 'name') gives local priority
Plugin Skills avoid conflicts with other sources through namespacing (plugin-name:skill-name).
Hooks Unification
Both Skills and Plugins can declare Hooks. Skill Hooks are registered when invoked, while Plugin Hooks take effect as soon as they're loaded:
1// Skill hooks - only active when the Skill is invoked
2command.hooks = parseHooksFromFrontmatter(frontmatter, skillName)
3
4// Plugin hooks - always active once the Plugin is enabled
5plugin.hooksConfig = definition.hooks
Both use the same HooksSettings Schema and can configure PreToolUse, PostToolUse, PreCompact, and other lifecycle hooks.
Caching and Invalidation
File Skill loading results are cached via memoize, but dynamic Skill discovery and conditional Skill activation need to trigger cache invalidation:
1export function clearSkillCaches() {
2 getSkillDirCommands.cache?.clear?.()
3 loadMarkdownFilesForSubdir.cache?.clear?.()
4 conditionalSkills.clear()
5 activatedConditionalSkillNames.clear()
6}
After dynamic Skill loading completes, subscribers are notified via a signal:
1export function onDynamicSkillsLoaded(callback: () => void): () => void {
2 return skillsLoaded.subscribe(() => {
3 try {
4 callback()
5 } catch (error) {
6 logError(error)
7 }
8 })
9}
This signal mechanism uses the same createSignal utility as GrowthBook feature flags — a lightweight observer pattern implementation. Subscriber errors are caught and logged without interrupting signal propagation.
Bare Mode and Policy Lockdown
Bare Mode
--bare mode skips all auto-discovery logic, only loading Skills explicitly specified via --add-dir:
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}
This is useful for CI/CD environments: ensuring only predefined Skills run, unaffected by project directory structure.
Plugin-Only Policy
Enterprises can lock Skill sources to Plugin-only via isRestrictedToPluginOnly('skills'):
1const skillsLocked = isRestrictedToPluginOnly('skills')
2const projectSettingsEnabled =
3 isSettingSourceEnabled('projectSettings') && !skillsLocked
When skillsLocked is true:
- Project-level Skills (
.claude/skills/) are not loaded
- User-level Skills (
~/.claude/skills/) are not loaded
- Legacy commands directories are not loaded
- Only Managed (policy) level Skills and Bundled/Plugin Skills are available
Portable Patterns
Claude Code's three-layer extensibility architecture contains several reusable design patterns:
1. Markdown-as-Config Pattern
Using Markdown files (with YAML frontmatter) as a configuration carrier:
1---
2description: "..."
3allowed-tools: [...]
4paths: ["src/**"]
5---
6
7Actual prompt content...
Advantages of this pattern:
- Human-readable: Non-developers can write and maintain Skills
- Version control friendly: Markdown diffs are clear and intuitive
- Self-documenting: Frontmatter is configuration, body is documentation
- IDE support: Standard Markdown format with rich editor support
2. Multi-Level Directory Merge Pattern
Loading configuration from multiple directory levels, merging with priority-based deduplication:
1Managed (Policy) > User (Global) > Project (Project) > Dynamic (Runtime)
This pattern is applicable to any system needing "global defaults + project overrides" semantics. Deduplication uses realpath rather than path string comparison, correctly handling symlink scenarios.
3. Conditional Activation Pattern
Resources (Skills, rules, etc.) aren't loaded immediately but declare activation conditions that only take effect when matched at runtime:
1Declare -> store in conditionalMap -> runtime trigger -> move to activeMap -> notify subscribers
This pattern is particularly valuable in large projects. It compresses the Skill list from O(N) to O(active) level while maintaining the ability for on-demand discovery.
4. Register-Rather-Than-Import Pattern
Both Bundled Skills and MCP Skill Builders use the pattern of "registering to a global Map during module initialization rather than importing directly":
1// Register
2export function registerBundledSkill(def: BundledSkillDefinition): void {
3 bundledSkills.push(skillDefToCommand(def))
4}
5
6// Retrieve
7export function getBundledSkills(): Command[] {
8 return [...bundledSkills]
9}
Benefits of this pattern:
- Breaks circular dependencies: The registration module only imports types, not implementations
- Supports lazy loading: Feature-gated Skills use
require() for on-demand loading
- Test-friendly:
clearBundledSkills() can reset state
5. Secure Write Pattern
Bundled Skill file extraction demonstrates secure writing best practices:
1Per-process nonce dir -> O_EXCL | O_NOFOLLOW -> 0o600 permissions -> path traversal checks
Four layers of defense:
- Random directory names prevent pre-creation attacks
O_EXCL prevents overwriting existing files
O_NOFOLLOW prevents symbolic link attacks
- Path normalization +
.. checking prevents directory traversal
Summary
Claude Code's three-layer extensibility architecture — Skills, Plugins, MCP — may seem complex, but the underlying logic is clear:
- Unified representation: All extensible capabilities ultimately become the
Command type
- Unified entry point:
SkillTool is the sole interface through which the model invokes Skills
- Unified dispatch: Regardless of source, Skill execution goes through the same validation, permission checking, and inline/fork decisions
- Differentiated security: Different sources have different trust levels (MCP prohibits shell commands, Plugins require permission confirmation, Bundled auto-allow)
The design philosophy of this architecture is: let users define new capabilities through Markdown files, let third parties extend the ecosystem through Plugins and MCP, while maintaining clear security boundaries at every layer. File Skill path conditional activation and dynamic discovery ensure performance in large projects, Bundled Skill lazy extraction and secure writing ensure reliability for binary distribution, and the Plugin two-tier architecture (Built-in + Marketplace) balances out-of-the-box experience with extensibility freedom.
The next article will dive into the OAuth and authentication system, exploring how Claude Code securely manages API keys, OAuth tokens, and session credentials.