import { realpath } from 'fs/promises' import ignore from 'ignore' import memoize from 'lodash-es/memoize.js' import { basename, dirname, isAbsolute, join, sep as pathSep, relative, } from 'path' import { getAdditionalDirectoriesForClaudeMd, getSessionId, } from '../bootstrap/state.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../services/analytics/index.js' import { roughTokenCountEstimation } from '../services/tokenEstimation.js' import type { Command, PromptCommand } from '../types/command.js' import { parseArgumentNames, substituteArguments, } from '../utils/argumentSubstitution.js' import { logForDebugging } from '../utils/debug.js' import { EFFORT_LEVELS, type EffortValue, parseEffortValue, } from '../utils/effort.js' import { getClaudeConfigHomeDir, isBareMode, isEnvTruthy, } from '../utils/envUtils.js' import { isENOENT, isFsInaccessible } from '../utils/errors.js' import { coerceDescriptionToString, type FrontmatterData, type FrontmatterShell, parseBooleanFrontmatter, parseFrontmatter, parseShellFrontmatter, splitPathInFrontmatter, } from '../utils/frontmatterParser.js' import { getFsImplementation } from '../utils/fsOperations.js' import { isPathGitignored } from '../utils/git/gitignore.js' import { logError } from '../utils/log.js' import { extractDescriptionFromMarkdown, getProjectDirsUpToHome, loadMarkdownFilesForSubdir, type MarkdownFile, parseSlashCommandToolsFromFrontmatter, } from '../utils/markdownConfigLoader.js' import { parseUserSpecifiedModel } from '../utils/model/model.js' import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' import type { SettingSource } from '../utils/settings/constants.js' import { isSettingSourceEnabled } from '../utils/settings/constants.js' import { getManagedFilePath } from '../utils/settings/managedPath.js' import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js' import { HooksSchema, type HooksSettings } from '../utils/settings/types.js' import { createSignal } from '../utils/signal.js' import { registerMCPSkillBuilders } from './mcpSkillBuilders.js' export type LoadedFrom = | 'commands_DEPRECATED' | 'skills' | 'plugin' | 'managed' | 'bundled' | 'mcp' /** * Returns a claude config directory path for a given source. */ export function getSkillsPath( source: SettingSource | 'plugin', dir: 'skills' | 'commands', ): string { switch (source) { case 'policySettings': return join(getManagedFilePath(), '.claude', dir) case 'userSettings': return join(getClaudeConfigHomeDir(), dir) case 'projectSettings': return `.claude/${dir}` case 'plugin': return 'plugin' default: return '' } } /** * Estimates token count for a skill based on frontmatter only * (name, description, whenToUse) since full content is only loaded on invocation. */ export function estimateSkillFrontmatterTokens(skill: Command): number { const frontmatterText = [skill.name, skill.description, skill.whenToUse] .filter(Boolean) .join(' ') return roughTokenCountEstimation(frontmatterText) } /** * Gets a unique identifier for a file by resolving symlinks to a canonical path. * This allows detection of duplicate files accessed through different paths * (e.g., via symlinks or overlapping parent directories). * Returns null if the file doesn't exist or can't be resolved. * * Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids * issues with filesystems that report unreliable inode values (e.g., inode 0 on * some virtual/container/NFS filesystems, or precision loss on ExFAT). * See: https://github.com/anthropics/claude-code/issues/13893 */ async function getFileIdentity(filePath: string): Promise { try { return await realpath(filePath) } catch { return null } } // Internal type to track skill with its file path for deduplication type SkillWithPath = { skill: Command filePath: string } /** * Parse and validate hooks from frontmatter. * Returns undefined if hooks are not defined or invalid. */ function parseHooksFromFrontmatter( frontmatter: FrontmatterData, skillName: string, ): HooksSettings | undefined { if (!frontmatter.hooks) { return undefined } const result = HooksSchema().safeParse(frontmatter.hooks) if (!result.success) { logForDebugging( `Invalid hooks in skill '${skillName}': ${result.error.message}`, ) return undefined } return result.data } /** * Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules. * Returns undefined if no paths are specified or if all patterns are match-all. */ function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined { if (!frontmatter.paths) { return undefined } const patterns = splitPathInFrontmatter(frontmatter.paths) .map(pattern => { // Remove /** suffix - ignore library treats 'path' as matching both // the path itself and everything inside it return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern }) .filter((p: string) => p.length > 0) // If all patterns are ** (match-all), treat as no paths (undefined) if (patterns.length === 0 || patterns.every((p: string) => p === '**')) { return undefined } return patterns } /** * Parses all skill frontmatter fields that are shared between file-based and * MCP skill loading. Caller supplies the resolved skill name and the * source/loadedFrom/baseDir/paths fields separately. */ export function parseSkillFrontmatterFields( frontmatter: FrontmatterData, markdownContent: string, resolvedName: string, descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill', ): { displayName: string | undefined description: string hasUserSpecifiedDescription: boolean allowedTools: string[] argumentHint: string | undefined argumentNames: string[] whenToUse: string | undefined version: string | undefined model: ReturnType | undefined disableModelInvocation: boolean userInvocable: boolean hooks: HooksSettings | undefined executionContext: 'fork' | undefined agent: string | undefined effort: EffortValue | undefined shell: FrontmatterShell | undefined } { const validatedDescription = coerceDescriptionToString( frontmatter.description, resolvedName, ) const description = validatedDescription ?? extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel) const userInvocable = frontmatter['user-invocable'] === undefined ? true : parseBooleanFrontmatter(frontmatter['user-invocable']) const model = frontmatter.model === 'inherit' ? undefined : frontmatter.model ? parseUserSpecifiedModel(frontmatter.model as string) : undefined const effortRaw = frontmatter['effort'] const effort = effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined if (effortRaw !== undefined && effort === undefined) { logForDebugging( `Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, ) } return { displayName: frontmatter.name != null ? String(frontmatter.name) : undefined, description, hasUserSpecifiedDescription: validatedDescription !== null, allowedTools: parseSlashCommandToolsFromFrontmatter( frontmatter['allowed-tools'], ), argumentHint: frontmatter['argument-hint'] != null ? String(frontmatter['argument-hint']) : undefined, argumentNames: parseArgumentNames( frontmatter.arguments as string | string[] | undefined, ), whenToUse: frontmatter.when_to_use as string | undefined, version: frontmatter.version as string | undefined, model, disableModelInvocation: parseBooleanFrontmatter( frontmatter['disable-model-invocation'], ), userInvocable, hooks: parseHooksFromFrontmatter(frontmatter, resolvedName), executionContext: frontmatter.context === 'fork' ? 'fork' : undefined, agent: frontmatter.agent as string | undefined, effort, shell: parseShellFrontmatter(frontmatter.shell, resolvedName), } } /** * Creates a skill command from parsed data */ export function createSkillCommand({ skillName, displayName, description, hasUserSpecifiedDescription, markdownContent, allowedTools, argumentHint, argumentNames, whenToUse, version, model, disableModelInvocation, userInvocable, source, baseDir, loadedFrom, hooks, executionContext, agent, paths, effort, shell, }: { skillName: string displayName: string | undefined description: string hasUserSpecifiedDescription: boolean markdownContent: string allowedTools: string[] argumentHint: string | undefined argumentNames: string[] whenToUse: string | undefined version: string | undefined model: string | undefined disableModelInvocation: boolean userInvocable: boolean source: PromptCommand['source'] baseDir: string | undefined loadedFrom: LoadedFrom hooks: HooksSettings | undefined executionContext: 'inline' | 'fork' | undefined agent: string | undefined paths: string[] | undefined effort: EffortValue | undefined shell: FrontmatterShell | undefined }): Command { return { type: 'prompt', name: skillName, description, hasUserSpecifiedDescription, allowedTools, argumentHint, argNames: argumentNames.length > 0 ? argumentNames : undefined, whenToUse, version, model, disableModelInvocation, userInvocable, context: executionContext, agent, effort, paths, contentLength: markdownContent.length, isHidden: !userInvocable, progressMessage: 'running', userFacingName(): string { return displayName || skillName }, source, loadedFrom, hooks, skillRoot: baseDir, async getPromptForCommand(args, toolUseContext) { let finalContent = baseDir ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}` : markdownContent finalContent = substituteArguments( finalContent, args, true, argumentNames, ) // Replace ${CLAUDE_SKILL_DIR} with the skill's own directory so bash // injection (!`...`) can reference bundled scripts. Normalize backslashes // to forward slashes on Windows so shell commands don't treat them as escapes. if (baseDir) { const skillDir = process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir) } // Replace ${CLAUDE_SESSION_ID} with the current session ID finalContent = finalContent.replace( /\$\{CLAUDE_SESSION_ID\}/g, getSessionId(), ) // Security: MCP skills are remote and untrusted — never execute inline // shell commands (!`…` / ```! … ```) from their markdown body. // ${CLAUDE_SKILL_DIR} is meaningless for MCP skills anyway. if (loadedFrom !== 'mcp') { finalContent = await executeShellCommandsInPrompt( finalContent, { ...toolUseContext, getAppState() { const appState = toolUseContext.getAppState() return { ...appState, toolPermissionContext: { ...appState.toolPermissionContext, alwaysAllowRules: { ...appState.toolPermissionContext.alwaysAllowRules, command: allowedTools, }, }, } }, }, `/${skillName}`, shell, ) } return [{ type: 'text', text: finalContent }] }, } satisfies Command } /** * Loads skills from a /skills/ directory path. * Only supports directory format: skill-name/SKILL.md */ async function loadSkillsFromSkillsDir( basePath: string, source: SettingSource, ): Promise { const fs = getFsImplementation() let entries try { entries = await fs.readdir(basePath) } catch (e: unknown) { if (!isFsInaccessible(e)) logError(e) return [] } const results = await Promise.all( entries.map(async (entry): Promise => { try { // Only support directory format: skill-name/SKILL.md if (!entry.isDirectory() && !entry.isSymbolicLink()) { // Single .md files are NOT supported in /skills/ directory return null } const skillDirPath = join(basePath, entry.name) const skillFilePath = join(skillDirPath, 'SKILL.md') let content: string try { content = await fs.readFile(skillFilePath, { encoding: 'utf-8' }) } catch (e: unknown) { // SKILL.md doesn't exist, skip this entry. Log non-ENOENT errors // (EACCES/EPERM/EIO) so permission/IO problems are diagnosable. if (!isENOENT(e)) { logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, { level: 'warn', }) } return null } const { frontmatter, content: markdownContent } = parseFrontmatter( content, skillFilePath, ) const skillName = entry.name const parsed = parseSkillFrontmatterFields( frontmatter, markdownContent, skillName, ) const paths = parseSkillPaths(frontmatter) return { skill: createSkillCommand({ ...parsed, skillName, markdownContent, source, baseDir: skillDirPath, loadedFrom: 'skills', paths, }), filePath: skillFilePath, } } catch (error) { logError(error) return null } }), ) return results.filter((r): r is SkillWithPath => r !== null) } // --- Legacy /commands/ loader --- function isSkillFile(filePath: string): boolean { return /^skill\.md$/i.test(basename(filePath)) } /** * Transforms markdown files to handle "skill" commands in legacy /commands/ folder. * When a SKILL.md file exists in a directory, only that file is loaded * and it takes the name of its parent directory. */ function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] { const filesByDir = new Map() for (const file of files) { const dir = dirname(file.filePath) const dirFiles = filesByDir.get(dir) ?? [] dirFiles.push(file) filesByDir.set(dir, dirFiles) } const result: MarkdownFile[] = [] for (const [dir, dirFiles] of filesByDir) { const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath)) if (skillFiles.length > 0) { const skillFile = skillFiles[0]! if (skillFiles.length > 1) { logForDebugging( `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`, ) } result.push(skillFile) } else { result.push(...dirFiles) } } return result } function buildNamespace(targetDir: string, baseDir: string): string { const normalizedBaseDir = baseDir.endsWith(pathSep) ? baseDir.slice(0, -1) : baseDir if (targetDir === normalizedBaseDir) { return '' } const relativePath = targetDir.slice(normalizedBaseDir.length + 1) return relativePath ? relativePath.split(pathSep).join(':') : '' } function getSkillCommandName(filePath: string, baseDir: string): string { const skillDirectory = dirname(filePath) const parentOfSkillDir = dirname(skillDirectory) const commandBaseName = basename(skillDirectory) const namespace = buildNamespace(parentOfSkillDir, baseDir) return namespace ? `${namespace}:${commandBaseName}` : commandBaseName } function getRegularCommandName(filePath: string, baseDir: string): string { const fileName = basename(filePath) const fileDirectory = dirname(filePath) const commandBaseName = fileName.replace(/\.md$/, '') const namespace = buildNamespace(fileDirectory, baseDir) return namespace ? `${namespace}:${commandBaseName}` : commandBaseName } function getCommandName(file: MarkdownFile): string { const isSkill = isSkillFile(file.filePath) return isSkill ? getSkillCommandName(file.filePath, file.baseDir) : getRegularCommandName(file.filePath, file.baseDir) } /** * Loads skills from legacy /commands/ directories. * Supports both directory format (SKILL.md) and single .md file format. * Commands from /commands/ default to user-invocable: true */ async function loadSkillsFromCommandsDir( cwd: string, ): Promise { try { const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd) const processedFiles = transformSkillFiles(markdownFiles) const skills: SkillWithPath[] = [] for (const { baseDir, filePath, frontmatter, content, source, } of processedFiles) { try { const isSkillFormat = isSkillFile(filePath) const skillDirectory = isSkillFormat ? dirname(filePath) : undefined const cmdName = getCommandName({ baseDir, filePath, frontmatter, content, source, }) const parsed = parseSkillFrontmatterFields( frontmatter, content, cmdName, 'Custom command', ) skills.push({ skill: createSkillCommand({ ...parsed, skillName: cmdName, displayName: undefined, markdownContent: content, source, baseDir: skillDirectory, loadedFrom: 'commands_DEPRECATED', paths: undefined, }), filePath, }) } catch (error) { logError(error) } } return skills } catch (error) { logError(error) return [] } } /** * Loads all skills from both /skills/ and legacy /commands/ directories. * * Skills from /skills/ directories: * - Only support directory format: skill-name/SKILL.md * - Default to user-invocable: true (can opt-out with user-invocable: false) * * Skills from legacy /commands/ directories: * - Support both directory format (SKILL.md) and single .md file format * - Default to user-invocable: true (user can type /cmd) * * @param cwd Current working directory for project directory traversal */ export const getSkillDirCommands = memoize( async (cwd: string): Promise => { const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills') const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills') const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd) logForDebugging( `Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`, ) // Load from additional directories (--add-dir) const additionalDirs = getAdditionalDirectoriesForClaudeMd() const skillsLocked = isRestrictedToPluginOnly('skills') const projectSettingsEnabled = isSettingSourceEnabled('projectSettings') && !skillsLocked // --bare: skip auto-discovery (managed/user/project dir walks + legacy // commands-dir). Load ONLY explicit --add-dir paths. Bundled skills // register separately. skillsLocked still applies — --bare is not a // policy bypass. if (isBareMode()) { if (additionalDirs.length === 0 || !projectSettingsEnabled) { logForDebugging( `[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`, ) return [] } const additionalSkillsNested = await Promise.all( additionalDirs.map(dir => loadSkillsFromSkillsDir( join(dir, '.claude', 'skills'), 'projectSettings', ), ), ) // No dedup needed — explicit dirs, user controls uniqueness. return additionalSkillsNested.flat().map(s => s.skill) } // Load from /skills/ directories, additional dirs, and legacy /commands/ in parallel // (all independent — different directories, no shared state) const [ managedSkills, userSkills, projectSkillsNested, additionalSkillsNested, legacyCommands, ] = await Promise.all([ isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS) ? Promise.resolve([]) : loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'), isSettingSourceEnabled('userSettings') && !skillsLocked ? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings') : Promise.resolve([]), projectSettingsEnabled ? Promise.all( projectSkillsDirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings'), ), ) : Promise.resolve([]), projectSettingsEnabled ? Promise.all( additionalDirs.map(dir => loadSkillsFromSkillsDir( join(dir, '.claude', 'skills'), 'projectSettings', ), ), ) : Promise.resolve([]), // Legacy commands-as-skills goes through markdownConfigLoader with // subdir='commands', which our agents-only guard there skips. Block // here when skills are locked — these ARE skills, regardless of the // directory they load from. skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd), ]) // Flatten and combine all skills const allSkillsWithPaths = [ ...managedSkills, ...userSkills, ...projectSkillsNested.flat(), ...additionalSkillsNested.flat(), ...legacyCommands, ] // Deduplicate by resolved path (handles symlinks and duplicate parent directories) // Pre-compute file identities in parallel (realpath calls are independent), // then dedup synchronously (order-dependent first-wins) const fileIds = await Promise.all( allSkillsWithPaths.map(({ skill, filePath }) => skill.type === 'prompt' ? getFileIdentity(filePath) : Promise.resolve(null), ), ) const seenFileIds = new Map< string, SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled' >() const deduplicatedSkills: Command[] = [] for (let i = 0; i < allSkillsWithPaths.length; i++) { const entry = allSkillsWithPaths[i] if (entry === undefined || entry.skill.type !== 'prompt') continue const { skill } = entry const fileId = fileIds[i] if (fileId === null || fileId === undefined) { deduplicatedSkills.push(skill) continue } const existingSource = seenFileIds.get(fileId) if (existingSource !== undefined) { logForDebugging( `Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`, ) continue } seenFileIds.set(fileId, skill.source) deduplicatedSkills.push(skill) } const duplicatesRemoved = allSkillsWithPaths.length - deduplicatedSkills.length if (duplicatesRemoved > 0) { logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`) } // Separate conditional skills (with paths frontmatter) from unconditional ones const unconditionalSkills: Command[] = [] const newConditionalSkills: Command[] = [] for (const skill of deduplicatedSkills) { if ( skill.type === 'prompt' && skill.paths && skill.paths.length > 0 && !activatedConditionalSkillNames.has(skill.name) ) { newConditionalSkills.push(skill) } else { unconditionalSkills.push(skill) } } // Store conditional skills for later activation when matching files are touched for (const skill of newConditionalSkills) { conditionalSkills.set(skill.name, skill) } if (newConditionalSkills.length > 0) { logForDebugging( `[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`, ) } logForDebugging( `Loaded ${deduplicatedSkills.length} unique skills (${unconditionalSkills.length} unconditional, ${newConditionalSkills.length} conditional, managed: ${managedSkills.length}, user: ${userSkills.length}, project: ${projectSkillsNested.flat().length}, additional: ${additionalSkillsNested.flat().length}, legacy commands: ${legacyCommands.length})`, ) return unconditionalSkills }, ) export function clearSkillCaches() { getSkillDirCommands.cache?.clear?.() loadMarkdownFilesForSubdir.cache?.clear?.() conditionalSkills.clear() activatedConditionalSkillNames.clear() } // Backwards-compatible aliases for tests export { getSkillDirCommands as getCommandDirCommands } export { clearSkillCaches as clearCommandCaches } export { transformSkillFiles } // --- Dynamic skill discovery --- // State for dynamically discovered skills const dynamicSkillDirs = new Set() const dynamicSkills = new Map() // --- Conditional skills (path-filtered) --- // Skills with paths frontmatter that haven't been activated yet const conditionalSkills = new Map() // Names of skills that have been activated (survives cache clears within a session) const activatedConditionalSkillNames = new Set() // Signal fired when dynamic skills are loaded const skillsLoaded = createSignal() /** * Register a callback to be invoked when dynamic skills are loaded. * Used by other modules to clear caches without creating import cycles. * Returns an unsubscribe function. */ export function onDynamicSkillsLoaded(callback: () => void): () => void { // Wrap at subscribe time so a throwing listener is logged and skipped // rather than aborting skillsLoaded.emit() and breaking skill loading. // Same callSafe pattern as growthbook.ts — createSignal.emit() has no // per-listener try/catch. return skillsLoaded.subscribe(() => { try { callback() } catch (error) { logError(error) } }) } /** * Discovers skill directories by walking up from file paths to cwd. * Only discovers directories below cwd (cwd-level skills are loaded at startup). * * @param filePaths Array of file paths to check * @param cwd Current working directory (upper bound for discovery) * @returns Array of newly discovered skill directories, sorted deepest first */ export async function discoverSkillDirsForPaths( filePaths: string[], cwd: string, ): Promise { const fs = getFsImplementation() const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd const newDirs: string[] = [] for (const filePath of filePaths) { // Start from the file's parent directory let currentDir = dirname(filePath) // Walk up to cwd but NOT including cwd itself // CWD-level skills are already loaded at startup, so we only discover nested ones // Use prefix+separator check to avoid matching /project-backup when cwd is /project while (currentDir.startsWith(resolvedCwd + pathSep)) { const skillDir = join(currentDir, '.claude', 'skills') // Skip if we've already checked this path (hit or miss) — avoids // repeating the same failed stat on every Read/Write/Edit call when // the directory doesn't exist (the common case). if (!dynamicSkillDirs.has(skillDir)) { dynamicSkillDirs.add(skillDir) try { await fs.stat(skillDir) // Skills dir exists. Before loading, check if the containing dir // is gitignored — blocks e.g. node_modules/pkg/.claude/skills from // loading silently. `git check-ignore` handles nested .gitignore, // .git/info/exclude, and global gitignore. Fails open outside a // git repo (exit 128 → false); the invocation-time trust dialog // is the actual security boundary. if (await isPathGitignored(currentDir, resolvedCwd)) { logForDebugging( `[skills] Skipped gitignored skills dir: ${skillDir}`, ) continue } newDirs.push(skillDir) } catch { // Directory doesn't exist — already recorded above, continue } } // Move to parent const parent = dirname(currentDir) if (parent === currentDir) break // Reached root currentDir = parent } } // Sort by path depth (deepest first) so skills closer to the file take precedence return newDirs.sort( (a, b) => b.split(pathSep).length - a.split(pathSep).length, ) } /** * Loads skills from the given directories and merges them into the dynamic skills map. * Skills from directories closer to the file (deeper paths) take precedence. * * @param dirs Array of skill directories to load from (should be sorted deepest first) */ export async function addSkillDirectories(dirs: string[]): Promise { if ( !isSettingSourceEnabled('projectSettings') || isRestrictedToPluginOnly('skills') ) { logForDebugging( '[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy', ) return } if (dirs.length === 0) { return } const previousSkillNamesForLogging = new Set(dynamicSkills.keys()) // Load skills from all directories const loadedSkills = await Promise.all( dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')), ) // Process in reverse order (shallower first) so deeper paths override for (let i = loadedSkills.length - 1; i >= 0; i--) { for (const { skill } of loadedSkills[i] ?? []) { if (skill.type === 'prompt') { dynamicSkills.set(skill.name, skill) } } } const newSkillCount = loadedSkills.flat().length if (newSkillCount > 0) { const addedSkills = [...dynamicSkills.keys()].filter( n => !previousSkillNamesForLogging.has(n), ) logForDebugging( `[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`, ) if (addedSkills.length > 0) { logEvent('tengu_dynamic_skills_changed', { source: 'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, previousCount: previousSkillNamesForLogging.size, newCount: dynamicSkills.size, addedCount: addedSkills.length, directoryCount: dirs.length, }) } } // Notify listeners that skills were loaded (so they can clear caches) skillsLoaded.emit() } /** * Gets all dynamically discovered skills. * These are skills discovered from file paths during the session. */ export function getDynamicSkills(): Command[] { return Array.from(dynamicSkills.values()) } /** * Activates conditional skills (skills with paths frontmatter) whose path * patterns match the given file paths. Activated skills are added to the * dynamic skills map, making them available to the model. * * Uses the `ignore` library (gitignore-style matching), matching the behavior * of CLAUDE.md conditional rules. * * @param filePaths Array of file paths being operated on * @param cwd Current working directory (paths are matched relative to cwd) * @returns Array of newly activated skill names */ export function activateConditionalSkillsForPaths( filePaths: string[], cwd: string, ): string[] { if (conditionalSkills.size === 0) { return [] } const activated: string[] = [] for (const [name, skill] of conditionalSkills) { if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) { continue } const skillIgnore = ignore().add(skill.paths) for (const filePath of filePaths) { const relativePath = isAbsolute(filePath) ? relative(cwd, filePath) : filePath // ignore() throws on empty strings, paths escaping the base (../), // and absolute paths (Windows cross-drive relative() returns absolute). // Files outside cwd can't match cwd-relative patterns anyway. if ( !relativePath || relativePath.startsWith('..') || isAbsolute(relativePath) ) { continue } if (skillIgnore.ignores(relativePath)) { // Activate this skill by moving it to dynamic skills dynamicSkills.set(name, skill) conditionalSkills.delete(name) activatedConditionalSkillNames.add(name) activated.push(name) logForDebugging( `[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`, ) break } } } if (activated.length > 0) { logEvent('tengu_dynamic_skills_changed', { source: 'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, previousCount: dynamicSkills.size - activated.length, newCount: dynamicSkills.size, addedCount: activated.length, directoryCount: 0, }) // Notify listeners that skills were loaded (so they can clear caches) skillsLoaded.emit() } return activated } /** * Gets the number of pending conditional skills (for testing/debugging). */ export function getConditionalSkillCount(): number { return conditionalSkills.size } /** * Clears dynamic skill state (for testing). */ export function clearDynamicSkills(): void { dynamicSkillDirs.clear() dynamicSkills.clear() conditionalSkills.clear() activatedConditionalSkillNames.clear() } // Expose createSkillCommand + parseSkillFrontmatterFields to MCP skill // discovery via a leaf registry module. See mcpSkillBuilders.ts for why this // indirection exists (a literal dynamic import from mcpSkills.ts fans a single // edge out into many cycle violations; a variable-specifier dynamic import // passes dep-cruiser but fails to resolve in Bun-bundled binaries at runtime). // eslint-disable-next-line custom-rules/no-top-level-side-effects -- write-once registration, idempotent registerMCPSkillBuilders({ createSkillCommand, parseSkillFrontmatterFields, })