mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
- Set up VitePress with bilingual support (Chinese root + English /en/) - Create unified sidebar navigation with all doc sections - Add custom Anthropic brand theme (#D97757) - Create Quick Start pages (zh/en) from README content - Migrate 7 .en.md files to docs/en/ directory structure - Translate memory/agent/skills docs to English (9 files) - Generate English-only architecture diagrams (11 images via PicTactic) - Copy English-ready images for agent/skills sections (21 images) - Configure custom domain (claudecodehaha.relakkesyang.org) - Localize Chinese UI labels (sidebar, outline, footer) - Add documentation site badge to READMEs - Fix dead links and update cross-references for VitePress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
898 lines
31 KiB
Markdown
898 lines
31 KiB
Markdown
# Claude Code Skills System -- Implementation Details
|
||
|
||
> A deep dive into how Skills are discovered, loaded, injected, executed, and managed.
|
||
|
||
<p align="center">
|
||
<a href="#1-overall-architecture">Architecture</a> · <a href="#2-skill-discovery-and-loading">Discovery & Loading</a> · <a href="#3-frontmatter-parsing">Frontmatter Parsing</a> · <a href="#4-skill-injection-into-conversations">Injection</a> · <a href="#5-skilltool-execution-engine">Execution Engine</a> · <a href="#6-fork-sub-agent-execution">Fork Execution</a> · <a href="#7-conditional-activation-and-dynamic-discovery">Conditional Activation</a> · <a href="#8-hook-integration">Hook Integration</a> · <a href="#9-permission-system">Permission System</a> · <a href="#10-complete-lifecycle">Complete Lifecycle</a> · <a href="#11-source-code-index">Source Index</a>
|
||
</p>
|
||
|
||

|
||
|
||
---
|
||
|
||
## 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`:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||

|
||
|
||
`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
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||

|
||
|
||
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
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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.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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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:**
|
||
```typescript
|
||
{
|
||
data: {
|
||
success: true,
|
||
commandName: 'commit',
|
||
allowedTools: ['Bash', 'Read'],
|
||
model: 'sonnet',
|
||
status: 'inline',
|
||
},
|
||
newMessages: [...], // Injected into conversation
|
||
contextModifier: (ctx) => { ... }, // Modifies context
|
||
}
|
||
```
|
||
|
||
**Fork return:**
|
||
```typescript
|
||
{
|
||
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
|
||
|
||

|
||
|
||
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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||

|
||
|
||
```
|
||
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 |
|