cc-haha/docs/en/skills/02-implementation.md
程序员阿江(Relakkes) 072e5bcc48 fix: unify anchor IDs between VitePress and GitHub with custom slugify
Add GitHub-compatible slugify (github-slugger algorithm) to VitePress
config so heading anchor IDs are consistent across both platforms.
Update all inline anchor links in 22 doc files accordingly.
2026-04-05 10:13:57 +08:00

31 KiB
Raw Blame History

Claude Code Skills System -- Implementation Details

A deep dive into how Skills are discovered, loaded, injected, executed, and managed.

Architecture · Discovery & Loading · Frontmatter Parsing · Injection · Execution Engine · Fork Execution · Conditional Activation · Hook Integration · Permission System · Complete Lifecycle · Source Index

Skills Architecture Overview


1. Overall Architecture

The Skills system consists of 5 core modules working together:

┌─────────────────────────────────────────────────────┐
│                   Skills System                      │
│                                                     │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────┐ │
│  │  Discovery   │  │   Prompt     │  │  SkillTool │ │
│  │  & Loading   │→│  Injection   │→│  Execution  │ │
│  └─────────────┘  └──────────────┘  └────────────┘ │
│         ↑                                    ↓      │
│  ┌─────────────┐                     ┌────────────┐ │
│  │  Activation  │                     │  Context   │ │
│  │  Conditional │←────────────────────│  Modifier  │ │
│  └─────────────┘                     └────────────┘ │
└─────────────────────────────────────────────────────┘

Module Responsibilities

Module Core File Responsibility
Discovery loadSkillsDir.ts Discover and load Skills from 6 sources
Prompt prompt.ts + attachments.ts Inject Skill listing into system-reminder
SkillTool SkillTool.ts Validation, permission checks, Skill execution
Activation loadSkillsDir.ts (second half) Conditional activation and dynamic discovery
Context forkedAgent.ts Context preparation and modification

2. Skill Discovery and Loading

Loading Entry Point

Skills loading begins with the getSkills() function in commands.ts:

// src/commands.ts:351-396
async function getSkills(cwd: string) {
  const [skillDirCommands, pluginSkills] = await Promise.all([
    getSkillDirCommands(cwd),    // Directory Skills (managed/user/project)
    getPluginSkills(),            // Plugin Skills
  ])
  const bundledSkills = getBundledSkills()          // Built-in Skills
  const builtinPluginSkills = getBuiltinPluginSkillCommands()  // Built-in plugin Skills
  return { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills }
}

Aggregation and Ordering

All Skills from all sources are aggregated in loadAllCommands(), ordered by priority:

// src/commands.ts:447-467
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  return [
    ...bundledSkills,        // 1. Built-in Skills (highest priority)
    ...builtinPluginSkills,  // 2. Built-in plugin Skills
    ...skillDirCommands,     // 3. Directory Skills (managed → user → project)
    ...workflowCommands,     // 4. Workflow commands
    ...pluginCommands,       // 5. Plugin commands
    ...pluginSkills,         // 6. Plugin Skills
    ...COMMANDS(),           // 7. Built-in commands (lowest priority)
  ]
})

Key feature: Uses memoize for caching to avoid redundant disk I/O.

Directory Skill Loading Flow

Skill Loading Flow

getSkillDirCommands() is the core loading function for directory Skills:

getSkillDirCommands(cwd)
├─ Determine loading paths
│  ├─ managed: ${MANAGED_PATH}/.claude/skills/
│  ├─ user:    ~/.claude/skills/
│  ├─ project: .claude/skills/ (traverse upward to HOME)
│  └─ additional: paths specified via --add-dir
│
├─ Parallel loading (Promise.all)
│  ├─ loadSkillsFromSkillsDir(managedDir, 'policySettings')
│  ├─ loadSkillsFromSkillsDir(userDir, 'userSettings')
│  ├─ loadSkillsFromSkillsDir(projectDirs, 'projectSettings')
│  ├─ loadSkillsFromSkillsDir(additionalDirs, 'projectSettings')
│  └─ loadSkillsFromCommandsDir(cwd)  ← Legacy /commands/ format compatibility
│
├─ Deduplication (by realpath)
│  └─ getFileIdentity(filePath) → realpath resolves symlinks
│     └─ seenFileIds Map, first occurrence wins
│
└─ Separate conditional Skills
   ├─ No paths → unconditionalSkills (immediately available)
   └─ Has paths → conditionalSkills Map (awaiting activation)

Deduplication Mechanism

// src/skills/loadSkillsDir.ts:725-763
const fileIds = await Promise.all(
  allSkillsWithPaths.map(({ skill, filePath }) =>
    skill.type === 'prompt'
      ? getFileIdentity(filePath)  // realpath() resolves symlinks
      : Promise.resolve(null),
  ),
)

const seenFileIds = new Map<string, SettingSource>()
for (const entry of allSkillsWithPaths) {
  const fileId = fileIds[i]
  const existingSource = seenFileIds.get(fileId)
  if (existingSource !== undefined) continue  // Skip duplicates
  seenFileIds.set(fileId, skill.source)
  deduplicatedSkills.push(skill)
}

Bundled Skill Registration

Built-in Skills use an entirely different registration path:

// src/skills/bundledSkills.ts:53-100
export function registerBundledSkill(definition: BundledSkillDefinition): void {
  // If files are present, create extraction directory and lazy extraction logic
  if (files && Object.keys(files).length > 0) {
    skillRoot = getBundledSkillExtractDir(definition.name)
    // Extract files to disk on first invocation
    getPromptForCommand = async (args, ctx) => {
      extractionPromise ??= extractBundledSkillFiles(name, files)
      const extractedDir = await extractionPromise
      const blocks = await inner(args, ctx)
      return prependBaseDir(blocks, extractedDir)
    }
  }

  const command: Command = {
    type: 'prompt',
    source: 'bundled',
    loadedFrom: 'bundled',
    // ...other fields
  }
  bundledSkills.push(command)
}

File extraction: Bundled Skills can include a files: Record<string, string> field. On first invocation, these files are extracted to disk (getBundledSkillExtractDir()), allowing the model to access them via Read/Grep.

Startup Registration

// src/skills/bundled/index.ts:13-58
export function initBundledSkills(): void {
  require('./verify.js').registerVerifySkill()
  require('./debug.js').registerDebugSkill()
  require('./remember.js').registerRememberSkill()
  // ...
  if (feature('AGENT_TRIGGERS')) {
    require('./loop.js').registerLoopSkill()     // Feature-gated
  }
  if (feature('KAIROS')) {
    require('./dream.js').registerDreamSkill()    // Feature-gated
  }
}

Plugin Skill Loading

Plugin System
├─ loadAllPluginsCacheOnly()
│  └─ Get all enabled plugins
│
├─ For each plugin:
│  ├─ Read manifest.skillsPath → default Skills directory
│  ├─ Read manifest.skillsPaths[] → additional Skills directories
│  └─ loadSkillsFromDirectory() to load SKILL.md
│
├─ Namespacing:
│  └─ {pluginName}:{namespace}:{skillName}
│     e.g.: superpowers:code-reviewer
│
└─ Variable substitution:
   ├─ ${CLAUDE_PLUGIN_ROOT} → plugin root directory
   ├─ ${CLAUDE_PLUGIN_DATA} → plugin data directory
   ├─ ${CLAUDE_SKILL_DIR}   → skill directory
   └─ ${user_config.X}      → user configuration values

MCP Skill Loading

// src/services/mcp/client.ts:2030-2102
// MCP prompts are converted to Command objects
async function fetchCommandsForClient(client) {
  const prompts = await client.listPrompts()
  return prompts.map(prompt => ({
    type: 'prompt',
    name: `mcp__${normalizeNameForMCP(serverName)}__${prompt.name}`,
    source: 'mcp',
    loadedFrom: 'mcp',
    // getPromptForCommand calls the MCP server to fetch content
  }))
}

Feature gate: feature('MCP_SKILLS') controls whether MCP Skills are available.


3. Frontmatter Parsing

Parsing Flow

SKILL.md file
    ↓
parseFrontmatter()              ← frontmatterParser.ts
    ├─ Separate YAML frontmatter from Markdown content
    ├─ quoteProblematicValues()  ← Handle special characters (glob patterns, etc.)
    └─ parseYaml()               ← Parse YAML
    ↓
parseSkillFrontmatterFields()   ← loadSkillsDir.ts:185-265
    ├─ description extraction priority:
    │  1. frontmatter.description field
    │  2. First # heading in Markdown
    │  3. Skill name as fallback
    ├─ parseUserSpecifiedModel()     ← Model alias resolution
    ├─ parseEffortValue()            ← Effort level parsing
    ├─ parseHooksFromFrontmatter()   ← Hook configuration validation
    ├─ parseBooleanFrontmatter()     ← Boolean field parsing
    └─ parseSlashCommandToolsFromFrontmatter() ← Tool list parsing
    ↓
createSkillCommand()            ← loadSkillsDir.ts:270-401
    └─ Generate Command object (with getPromptForCommand closure)

FrontmatterData Type Definition

// src/utils/frontmatterParser.ts:10-59
export type FrontmatterData = {
  'allowed-tools'?: string | string[] | null
  description?: string | null
  'argument-hint'?: string | null
  when_to_use?: string | null
  version?: string | null
  model?: string | null          // haiku, sonnet, opus, inherit
  'user-invocable'?: string | null
  'disable-model-invocation'?: string | null
  hooks?: HooksSettings | null
  effort?: string | null         // low, medium, high, max, or numeric
  context?: 'inline' | 'fork' | null
  agent?: string | null
  paths?: string | string[] | null
  shell?: string | null          // bash, powershell
  [key: string]: unknown
}

The getPromptForCommand Closure

Each Command object contains a getPromptForCommand closure that executes upon invocation:

// src/skills/loadSkillsDir.ts:344-399
async getPromptForCommand(args, toolUseContext) {
  // 1. Add base directory header
  let finalContent = baseDir
    ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
    : markdownContent

  // 2. Argument substitution
  finalContent = substituteArguments(finalContent, args, true, argumentNames)

  // 3. Skill directory variable substitution
  if (baseDir) {
    finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
  }

  // 4. Session ID substitution
  finalContent = finalContent.replace(/\$\{CLAUDE_SESSION_ID\}/g, getSessionId())

  // 5. Execute inline shell commands (skipped for MCP Skills — security restriction)
  if (loadedFrom !== 'mcp') {
    finalContent = await executeShellCommandsInPrompt(finalContent, context)
  }

  return [{ type: 'text', text: finalContent }]
}

4. Skill Injection into Conversations

Injection Flow

Skill Listing Injection

Skills are injected into conversations via system-reminder messages:

Start of each conversation turn
    ↓
getSkillListingAttachments()          ← attachments.ts:2600-2747
    ├─ getSkillToolCommands(cwd)      ← Get all model-invocable Skills
    ├─ getMcpSkillCommands()          ← Get MCP Skills
    ├─ sentSkillNames Map tracking    ← Avoid duplicate sends (per-agent)
    └─ formatCommandsWithinBudget()   ← Truncate to context budget
    ↓
Returns Attachment:
  { type: 'skill_listing', content, skillCount, isInitial }
    ↓
normalizeAttachmentForAPI()           ← messages.ts:3732-3737
    ↓
Wrapped as <system-reminder> user message:
  "The following skills are available for use with the Skill tool:
   - commit: Create a git commit...
   - review-pr: Review a pull request...
   - superpowers:code-reviewer: Expert code review..."

Context Budget Control

// src/tools/SkillTool/prompt.ts:21-29
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01  // 1% of context window
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000           // 200k × 4 × 1% fallback
export const MAX_LISTING_DESC_CHARS = 250          // Max characters per description

Truncation strategy:

formatCommandsWithinBudget(commands, contextWindowTokens)
    ├─ Calculate total budget = contextWindowTokens × 4 × 1%
    ├─ Try full descriptions
    │  └─ Total chars ≤ budget → output all
    │
    ├─ Partition: Bundled (never truncated) + rest
    │  ├─ Bundled Skills always retain full descriptions
    │  └─ Remaining Skills split the leftover budget evenly
    │
    ├─ Truncate descriptions → maxDescLen characters
    │  └─ maxDescLen < 20 → extreme case: non-Bundled show name only
    │
    └─ Output format: "- skill-name: description..."

SkillTool Prompt

The tool prompt definition seen by the model:

// src/tools/SkillTool/prompt.ts:173-196
export const getPrompt = memoize(async (_cwd: string): Promise<string> => {
  return `Execute a skill within the main conversation

When users ask you to perform tasks, check if any of the available skills match.

How to invoke:
- Use this tool with the skill name and optional arguments
- Examples:
  - skill: "pdf"
  - skill: "commit", args: "-m 'Fix bug'"

Important:
- Available skills are listed in system-reminder messages
- When a skill matches, invoke BEFORE generating any other response
- NEVER mention a skill without calling this tool
- Do not invoke a skill that is already running
`
})

5. SkillTool Execution Engine

Tool Definition

// src/tools/SkillTool/SkillTool.ts:331-340
export const SkillTool = buildTool({
  name: 'Skill',
  inputSchema: z.object({
    skill: z.string(),    // Skill name
    args: z.string().optional(),  // Optional arguments
  }),
  outputSchema: z.union([inlineOutputSchema, forkedOutputSchema]),
})

Execution Flow

SkillTool Execution Flow

SkillTool.call({ skill, args })
    │
    ├─ 1. Normalize input
    │  └─ Strip leading /, trim whitespace
    │
    ├─ 2. Remote Skill check (experimental)
    │  └─ _canonical_<slug> prefix → executeRemoteSkill()
    │
    ├─ 3. Find Command object
    │  └─ getAllCommands(context) → findCommand(name, commands)
    │
    ├─ 4. Record usage frequency
    │  └─ recordSkillUsage(commandName) → influences sorting recommendations
    │
    ├─ 5. Determine execution path
    │  ├─ command.context === 'fork'
    │  │  └─ → executeForkedSkill()  [see Section 6]
    │  │
    │  └─ Default: inline
    │     ├─ processPromptSlashCommand()
    │     │  └─ getMessagesForPromptSlashCommand()
    │     │     ├─ command.getPromptForCommand(args, context)
    │     │     ├─ registerSkillHooks()      ← Register hooks
    │     │     ├─ addInvokedSkill()         ← Record (restored after compression)
    │     │     ├─ formatCommandLoadingMetadata()
    │     │     │  └─ <command-name>/skillName</command-name>
    │     │     └─ Extract attachments → create messages
    │     │
    │     ├─ Extract metadata: allowedTools, model, effort
    │     ├─ tagMessagesWithToolUseID() ← Associate with tool_use
    │     └─ Return { newMessages, contextModifier }
    │
    └─ 6. contextModifier() closure
       ├─ Update allowedTools
       │  └─ appState.toolPermissionContext.alwaysAllowRules.command
       ├─ Update model
       │  └─ resolveSkillModelOverride() preserves [1m] suffix
       └─ Update effort
          └─ appState.effortValue

Validation Logic

// src/tools/SkillTool/SkillTool.ts:354-430
async validateInput({ skill }, context) {
  // 1. Format check — non-empty
  // 2. Normalize — strip leading /
  // 3. Remote Skill check — _canonical_ prefix
  // 4. Lookup — findCommand() within getAllCommands()
  // 5. Disabled check — disableModelInvocation
  // 6. Type check — must be 'prompt' type
}

Error code definitions:

errorCode Meaning
1 Invalid format (empty skill name)
2 Unknown skill
4 Model invocation disabled
5 Not a prompt type
6 Remote skill not found

getAllCommands -- MCP Skill Merging

// src/tools/SkillTool/SkillTool.ts:81-94
async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
  // Get MCP Skills from AppState (loadedFrom === 'mcp')
  const mcpSkills = context.getAppState()
    .mcp.commands.filter(
      cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
    )
  if (mcpSkills.length === 0) return getCommands(getProjectRoot())
  const localCommands = await getCommands(getProjectRoot())
  return uniqBy([...localCommands, ...mcpSkills], 'name')
}

6. Fork Sub-Agent Execution

Execution Flow

executeForkedSkill(command, commandName, args, context, ...)
    │
    ├─ 1. Create sub-agent ID
    │  └─ agentId = createAgentId()
    │
    ├─ 2. Telemetry recording
    │  └─ logEvent('tengu_skill_tool_invocation', { execution_context: 'fork' })
    │
    ├─ 3. Prepare fork context
    │  └─ prepareForkedCommandContext(command, args, context)
    │     ├─ command.getPromptForCommand(args, context)  ← Get skill content
    │     ├─ parseToolListFromCLI(allowedTools)           ← Parse tool whitelist
    │     ├─ createGetAppStateWithAllowedTools()          ← Modify AppState
    │     ├─ Select agent: command.agent ?? 'general-purpose'
    │     └─ promptMessages = [createUserMessage(skillContent)]
    │
    ├─ 4. Merge effort
    │  └─ command.effort → inject into agentDefinition
    │
    ├─ 5. Run sub-agent
    │  └─ for await (message of runAgent({
    │       agentDefinition,
    │       promptMessages,
    │       toolUseContext: { ...context, getAppState: modifiedGetAppState },
    │       model: command.model,
    │       override: { agentId },
    │     }))
    │     └─ Collect messages + report progress (onProgress)
    │
    ├─ 6. Extract results
    │  └─ extractResultText(agentMessages)
    │     └─ Get text from the last assistant message
    │
    └─ 7. Cleanup
       └─ clearInvokedSkillsForAgent(agentId)

prepareForkedCommandContext

// src/utils/forkedAgent.ts:191-232
export async function prepareForkedCommandContext(
  command: PromptCommand,
  args: string,
  context: ToolUseContext,
): Promise<PreparedForkedContext> {
  // Get skill content (with argument substitution and shell execution)
  const skillPrompt = await command.getPromptForCommand(args, context)
  const skillContent = skillPrompt.map(b => b.type === 'text' ? b.text : '').join('\n')

  // Build tool whitelist
  const allowedTools = parseToolListFromCLI(command.allowedTools ?? [])
  const modifiedGetAppState = createGetAppStateWithAllowedTools(
    context.getAppState, allowedTools,
  )

  // Select agent type
  const agentTypeName = command.agent ?? 'general-purpose'
  const baseAgent = agents.find(a => a.agentType === agentTypeName)

  // Build prompt messages
  const promptMessages = [createUserMessage({ content: skillContent })]

  return { skillContent, modifiedGetAppState, baseAgent, promptMessages }
}

Inline vs Fork Return Differences

Inline return:

{
  data: {
    success: true,
    commandName: 'commit',
    allowedTools: ['Bash', 'Read'],
    model: 'sonnet',
    status: 'inline',
  },
  newMessages: [...],        // Injected into conversation
  contextModifier: (ctx) => { ... },  // Modifies context
}

Fork return:

{
  data: {
    success: true,
    commandName: 'verify',
    status: 'forked',
    agentId: 'agent_abc123',
    result: 'Verification passed, all tests have run...',
  },
  // No newMessages — result is embedded in the tool_result block
}

7. Conditional Activation and Dynamic Discovery

Conditional Skills

Conditional Activation Mechanism

Skills with paths frontmatter are not immediately exposed to the model:

At startup
├─ Load all Skills
├─ Those with paths → conditionalSkills Map
└─ Those without paths → immediately available

At runtime (triggered by file operations)
├─ activateConditionalSkillsForPaths(filePaths, cwd)
│  ├─ Iterate over conditionalSkills Map
│  ├─ Match paths patterns using the ignore library
│  │  └─ filePath converted to cwd-relative path before matching
│  ├─ On match:
│  │  ├─ Move to dynamicSkills Map
│  │  ├─ Remove from conditionalSkills
│  │  ├─ Add to activatedConditionalSkillNames Set
│  │  └─ Log telemetry: tengu_dynamic_skills_changed
│  └─ Once activated, remains active for the session
└─ Notify cache invalidation → skillsLoaded.emit()

Dynamic Discovery

When operating on files in deeply nested directories, the system automatically discovers new Skills:

// src/skills/loadSkillsDir.ts:861-915
export async function discoverSkillDirsForPaths(
  filePaths: string[],
  cwd: string,
): Promise<string[]> {
  for (const filePath of filePaths) {
    let currentDir = dirname(filePath)
    // Traverse upward from the file's directory to cwd (excluding cwd itself)
    while (currentDir.startsWith(resolvedCwd + pathSep)) {
      const skillDir = join(currentDir, '.claude', 'skills')
      if (!dynamicSkillDirs.has(skillDir)) {
        dynamicSkillDirs.add(skillDir)
        await fs.stat(skillDir)  // Check if it exists
        // Check if ignored by .gitignore
        if (await isPathGitignored(currentDir, resolvedCwd)) continue
        newDirs.push(skillDir)
      }
      currentDir = dirname(currentDir)
    }
  }
  // Sort by depth (deepest first), ensuring nearest Skills have higher priority
  return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)
}

Cache Invalidation Chain

Dynamic Skill change
    ↓
skillsLoaded.emit()
    ↓
clearCommandMemoizationCaches()
    ├─ loadAllCommands.cache.clear()
    ├─ getSkillToolCommands.cache.clear()
    ├─ getSlashCommandToolSkills.cache.clear()
    └─ clearSkillIndexCache()
    ↓
Next conversation turn loads the updated Skill list

8. Hook Integration

Hook Registration

Skills can declare hooks via frontmatter, which are automatically registered as session-level hooks upon invocation:

// src/utils/hooks/registerSkillHooks.ts:20-64
export function registerSkillHooks(
  setAppState, sessionId, hooks, skillName, skillRoot?,
): void {
  for (const eventName of HOOK_EVENTS) {
    const matchers = hooks[eventName]
    if (!matchers) continue
    for (const matcher of matchers) {
      for (const hook of matcher.hooks) {
        // once: true → automatically removed after one execution
        const onHookSuccess = hook.once
          ? () => removeSessionHook(setAppState, sessionId, eventName, hook)
          : undefined

        addSessionHook(
          setAppState, sessionId, eventName,
          matcher.matcher || '',
          hook, onHookSuccess, skillRoot,
        )
      }
    }
  }
}

Hook Lifecycle

Skill invocation
    ↓
processPromptSlashCommand()
    ├─ Check command.hooks
    └─ registerSkillHooks(setAppState, sessionId, hooks, skillName, skillRoot)
         ├─ Iterate over HOOK_EVENTS (PreToolUse, PostToolUse, Stop, ...)
         ├─ Register addSessionHook() for each matcher
         ├─ skillRoot → CLAUDE_PLUGIN_ROOT environment variable
         └─ once: true → removeSessionHook() after first execution
    ↓
During session
    ├─ Tool calls trigger PreToolUse/PostToolUse
    ├─ Matcher matches → execute hook command
    └─ Hooks with once: true are automatically removed after first execution

9. Permission System

Check Flow

checkPermissions({ skill, args }, context)
    │
    ├─ 1. Deny rule check (highest priority)
    │  └─ getRuleByContentsForTool(context, SkillTool, 'deny')
    │     ├─ Exact match: "commit" === commandName
    │     └─ Prefix match: "review:*" → commandName.startsWith("review")
    │
    ├─ 2. Remote Skill auto-allow
    │  └─ _canonical_<slug> → auto-allow (Ant-specific experimental)
    │
    ├─ 3. Allow rule check
    │  └─ getRuleByContentsForTool(context, SkillTool, 'allow')
    │
    ├─ 4. Safe property auto-allow
    │  └─ skillHasOnlySafeProperties(command)
    │     └─ SAFE_SKILL_PROPERTIES whitelist check
    │
    └─ 5. Default: ask user
       └─ Provide suggestions: exact allow + prefix allow

Safe Property Whitelist

If a Skill contains only the following properties (no hooks, no allowedTools, no fork), it is automatically allowed:

SAFE_SKILL_PROPERTIES = {
  type, name, description, contentLength, source,
  loadedFrom, progressMessage, userInvocable,
  disableModelInvocation, hasUserSpecifiedDescription,
  getPromptForCommand, userFacingName, ...
}

Core principle: Newly added frontmatter fields require permission by default, unless explicitly added to the whitelist.


10. Complete Lifecycle

Data Flow Overview

Complete Lifecycle

Phase 1: Discovery and Registration
──────────────────────────────────
CLI startup
    ├─ initBundledSkills()        → bundledSkills[]
    ├─ getPluginSkills()          → pluginSkills[]
    ├─ getSkillDirCommands(cwd)   → skillDirCommands[]
    │  └─ Conditional Skills → conditionalSkills Map
    └─ loadAllCommands()          → aggregate & memoize

Phase 2: Injection into Conversation
────────────────────────────────────
Each conversation turn
    ├─ getSkillListingAttachments()
    │  ├─ getSkillToolCommands()  → filter model-invocable Skills
    │  ├─ getMcpSkillCommands()   → MCP Skills
    │  └─ formatCommandsWithinBudget()  → truncate to budget
    └─ Wrap as <system-reminder> message and inject

Phase 3: Invocation and Execution
─────────────────────────────────
Model/user trigger
    ├─ SkillTool.validateInput()  → validation
    ├─ SkillTool.checkPermissions()  → permissions
    └─ SkillTool.call()
       ├─ Inline → processPromptSlashCommand()
       │  ├─ getPromptForCommand()  → content expansion
       │  ├─ registerSkillHooks()   → hook registration
       │  ├─ addInvokedSkill()      → record (restored after compression)
       │  └─ contextModifier()      → update tools/model/effort
       └─ Fork → executeForkedSkill()
          ├─ prepareForkedCommandContext()
          ├─ runAgent()             → sub-agent execution
          └─ extractResultText()    → extract results

Phase 4: Runtime Discovery
─────────────────────────
File operation trigger
    ├─ discoverSkillDirsForPaths()   → new Skills directories
    ├─ addSkillDirectories()          → load & register
    ├─ activateConditionalSkillsForPaths()  → conditional activation
    └─ clearCommandMemoizationCaches()      → cache invalidation

Skill Content Persistence

Inline Skill content is recorded to session state via addInvokedSkill(), ensuring it can be restored after context compression:

addInvokedSkill(name, path, content, agentId)
    ↓
Stored in session state
    ↓
On compression → buildPostCompactMessages()
    ↓
Restored scoped by agentId (prevents cross-agent leakage)

11. Source Code Index

Core Files

File Responsibility
src/tools/SkillTool/SkillTool.ts SkillTool definition, validation, permissions, execution
src/tools/SkillTool/prompt.ts Tool prompt, Skill listing formatting, budget control
src/skills/loadSkillsDir.ts Directory Skill discovery, loading, deduplication, conditional activation
src/skills/bundledSkills.ts Built-in Skill registration system
src/skills/bundled/index.ts Built-in Skills initialization entry point
src/commands.ts Command aggregation, ordering, filtering, cache management

Type Definitions

File Key Types
src/types/command.ts PromptCommand, Command, LocalCommandResult
src/utils/frontmatterParser.ts FrontmatterData, ParsedMarkdown
src/skills/bundledSkills.ts BundledSkillDefinition

Utility Modules

File Responsibility
src/utils/forkedAgent.ts Fork context preparation, result extraction
src/utils/hooks/registerSkillHooks.ts Skill hook registration
src/utils/argumentSubstitution.ts Argument substitution ($ARGUMENTS, ${ARG1})
src/utils/promptShellExecution.ts Inline shell command execution
src/utils/attachments.ts Skill listing attachment generation
src/utils/messages.ts system-reminder wrapping
src/utils/plugins/loadPluginCommands.ts Plugin Skill loading
src/services/mcp/client.ts MCP Skill conversion
src/skills/mcpSkillBuilders.ts MCP Skill builder registration

Key Constants

Constant Value Location
SKILL_BUDGET_CONTEXT_PERCENT 0.01 prompt.ts:21
DEFAULT_CHAR_BUDGET 8,000 prompt.ts:23
MAX_LISTING_DESC_CHARS 250 prompt.ts:29
SKILL_TOOL_NAME 'Skill' constants.ts

Telemetry Events

Event Description
tengu_skill_tool_invocation Skill invocation (includes execution_context, invocation_trigger)
tengu_skill_tool_slash_prefix Model used the / prefix
tengu_dynamic_skills_changed Dynamic Skills changed (conditional activation/directory discovery)
tengu_skill_descriptions_truncated Skill descriptions were truncated