source dump of claude code
at main 1086 lines 34 kB view raw
1import { realpath } from 'fs/promises' 2import ignore from 'ignore' 3import memoize from 'lodash-es/memoize.js' 4import { 5 basename, 6 dirname, 7 isAbsolute, 8 join, 9 sep as pathSep, 10 relative, 11} from 'path' 12import { 13 getAdditionalDirectoriesForClaudeMd, 14 getSessionId, 15} from '../bootstrap/state.js' 16import { 17 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 18 logEvent, 19} from '../services/analytics/index.js' 20import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 21import type { Command, PromptCommand } from '../types/command.js' 22import { 23 parseArgumentNames, 24 substituteArguments, 25} from '../utils/argumentSubstitution.js' 26import { logForDebugging } from '../utils/debug.js' 27import { 28 EFFORT_LEVELS, 29 type EffortValue, 30 parseEffortValue, 31} from '../utils/effort.js' 32import { 33 getClaudeConfigHomeDir, 34 isBareMode, 35 isEnvTruthy, 36} from '../utils/envUtils.js' 37import { isENOENT, isFsInaccessible } from '../utils/errors.js' 38import { 39 coerceDescriptionToString, 40 type FrontmatterData, 41 type FrontmatterShell, 42 parseBooleanFrontmatter, 43 parseFrontmatter, 44 parseShellFrontmatter, 45 splitPathInFrontmatter, 46} from '../utils/frontmatterParser.js' 47import { getFsImplementation } from '../utils/fsOperations.js' 48import { isPathGitignored } from '../utils/git/gitignore.js' 49import { logError } from '../utils/log.js' 50import { 51 extractDescriptionFromMarkdown, 52 getProjectDirsUpToHome, 53 loadMarkdownFilesForSubdir, 54 type MarkdownFile, 55 parseSlashCommandToolsFromFrontmatter, 56} from '../utils/markdownConfigLoader.js' 57import { parseUserSpecifiedModel } from '../utils/model/model.js' 58import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js' 59import type { SettingSource } from '../utils/settings/constants.js' 60import { isSettingSourceEnabled } from '../utils/settings/constants.js' 61import { getManagedFilePath } from '../utils/settings/managedPath.js' 62import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js' 63import { HooksSchema, type HooksSettings } from '../utils/settings/types.js' 64import { createSignal } from '../utils/signal.js' 65import { registerMCPSkillBuilders } from './mcpSkillBuilders.js' 66 67export type LoadedFrom = 68 | 'commands_DEPRECATED' 69 | 'skills' 70 | 'plugin' 71 | 'managed' 72 | 'bundled' 73 | 'mcp' 74 75/** 76 * Returns a claude config directory path for a given source. 77 */ 78export function getSkillsPath( 79 source: SettingSource | 'plugin', 80 dir: 'skills' | 'commands', 81): string { 82 switch (source) { 83 case 'policySettings': 84 return join(getManagedFilePath(), '.claude', dir) 85 case 'userSettings': 86 return join(getClaudeConfigHomeDir(), dir) 87 case 'projectSettings': 88 return `.claude/${dir}` 89 case 'plugin': 90 return 'plugin' 91 default: 92 return '' 93 } 94} 95 96/** 97 * Estimates token count for a skill based on frontmatter only 98 * (name, description, whenToUse) since full content is only loaded on invocation. 99 */ 100export function estimateSkillFrontmatterTokens(skill: Command): number { 101 const frontmatterText = [skill.name, skill.description, skill.whenToUse] 102 .filter(Boolean) 103 .join(' ') 104 return roughTokenCountEstimation(frontmatterText) 105} 106 107/** 108 * Gets a unique identifier for a file by resolving symlinks to a canonical path. 109 * This allows detection of duplicate files accessed through different paths 110 * (e.g., via symlinks or overlapping parent directories). 111 * Returns null if the file doesn't exist or can't be resolved. 112 * 113 * Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids 114 * issues with filesystems that report unreliable inode values (e.g., inode 0 on 115 * some virtual/container/NFS filesystems, or precision loss on ExFAT). 116 * See: https://github.com/anthropics/claude-code/issues/13893 117 */ 118async function getFileIdentity(filePath: string): Promise<string | null> { 119 try { 120 return await realpath(filePath) 121 } catch { 122 return null 123 } 124} 125 126// Internal type to track skill with its file path for deduplication 127type SkillWithPath = { 128 skill: Command 129 filePath: string 130} 131 132/** 133 * Parse and validate hooks from frontmatter. 134 * Returns undefined if hooks are not defined or invalid. 135 */ 136function parseHooksFromFrontmatter( 137 frontmatter: FrontmatterData, 138 skillName: string, 139): HooksSettings | undefined { 140 if (!frontmatter.hooks) { 141 return undefined 142 } 143 144 const result = HooksSchema().safeParse(frontmatter.hooks) 145 if (!result.success) { 146 logForDebugging( 147 `Invalid hooks in skill '${skillName}': ${result.error.message}`, 148 ) 149 return undefined 150 } 151 152 return result.data 153} 154 155/** 156 * Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules. 157 * Returns undefined if no paths are specified or if all patterns are match-all. 158 */ 159function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined { 160 if (!frontmatter.paths) { 161 return undefined 162 } 163 164 const patterns = splitPathInFrontmatter(frontmatter.paths) 165 .map(pattern => { 166 // Remove /** suffix - ignore library treats 'path' as matching both 167 // the path itself and everything inside it 168 return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern 169 }) 170 .filter((p: string) => p.length > 0) 171 172 // If all patterns are ** (match-all), treat as no paths (undefined) 173 if (patterns.length === 0 || patterns.every((p: string) => p === '**')) { 174 return undefined 175 } 176 177 return patterns 178} 179 180/** 181 * Parses all skill frontmatter fields that are shared between file-based and 182 * MCP skill loading. Caller supplies the resolved skill name and the 183 * source/loadedFrom/baseDir/paths fields separately. 184 */ 185export function parseSkillFrontmatterFields( 186 frontmatter: FrontmatterData, 187 markdownContent: string, 188 resolvedName: string, 189 descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill', 190): { 191 displayName: string | undefined 192 description: string 193 hasUserSpecifiedDescription: boolean 194 allowedTools: string[] 195 argumentHint: string | undefined 196 argumentNames: string[] 197 whenToUse: string | undefined 198 version: string | undefined 199 model: ReturnType<typeof parseUserSpecifiedModel> | undefined 200 disableModelInvocation: boolean 201 userInvocable: boolean 202 hooks: HooksSettings | undefined 203 executionContext: 'fork' | undefined 204 agent: string | undefined 205 effort: EffortValue | undefined 206 shell: FrontmatterShell | undefined 207} { 208 const validatedDescription = coerceDescriptionToString( 209 frontmatter.description, 210 resolvedName, 211 ) 212 const description = 213 validatedDescription ?? 214 extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel) 215 216 const userInvocable = 217 frontmatter['user-invocable'] === undefined 218 ? true 219 : parseBooleanFrontmatter(frontmatter['user-invocable']) 220 221 const model = 222 frontmatter.model === 'inherit' 223 ? undefined 224 : frontmatter.model 225 ? parseUserSpecifiedModel(frontmatter.model as string) 226 : undefined 227 228 const effortRaw = frontmatter['effort'] 229 const effort = 230 effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined 231 if (effortRaw !== undefined && effort === undefined) { 232 logForDebugging( 233 `Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, 234 ) 235 } 236 237 return { 238 displayName: 239 frontmatter.name != null ? String(frontmatter.name) : undefined, 240 description, 241 hasUserSpecifiedDescription: validatedDescription !== null, 242 allowedTools: parseSlashCommandToolsFromFrontmatter( 243 frontmatter['allowed-tools'], 244 ), 245 argumentHint: 246 frontmatter['argument-hint'] != null 247 ? String(frontmatter['argument-hint']) 248 : undefined, 249 argumentNames: parseArgumentNames( 250 frontmatter.arguments as string | string[] | undefined, 251 ), 252 whenToUse: frontmatter.when_to_use as string | undefined, 253 version: frontmatter.version as string | undefined, 254 model, 255 disableModelInvocation: parseBooleanFrontmatter( 256 frontmatter['disable-model-invocation'], 257 ), 258 userInvocable, 259 hooks: parseHooksFromFrontmatter(frontmatter, resolvedName), 260 executionContext: frontmatter.context === 'fork' ? 'fork' : undefined, 261 agent: frontmatter.agent as string | undefined, 262 effort, 263 shell: parseShellFrontmatter(frontmatter.shell, resolvedName), 264 } 265} 266 267/** 268 * Creates a skill command from parsed data 269 */ 270export function createSkillCommand({ 271 skillName, 272 displayName, 273 description, 274 hasUserSpecifiedDescription, 275 markdownContent, 276 allowedTools, 277 argumentHint, 278 argumentNames, 279 whenToUse, 280 version, 281 model, 282 disableModelInvocation, 283 userInvocable, 284 source, 285 baseDir, 286 loadedFrom, 287 hooks, 288 executionContext, 289 agent, 290 paths, 291 effort, 292 shell, 293}: { 294 skillName: string 295 displayName: string | undefined 296 description: string 297 hasUserSpecifiedDescription: boolean 298 markdownContent: string 299 allowedTools: string[] 300 argumentHint: string | undefined 301 argumentNames: string[] 302 whenToUse: string | undefined 303 version: string | undefined 304 model: string | undefined 305 disableModelInvocation: boolean 306 userInvocable: boolean 307 source: PromptCommand['source'] 308 baseDir: string | undefined 309 loadedFrom: LoadedFrom 310 hooks: HooksSettings | undefined 311 executionContext: 'inline' | 'fork' | undefined 312 agent: string | undefined 313 paths: string[] | undefined 314 effort: EffortValue | undefined 315 shell: FrontmatterShell | undefined 316}): Command { 317 return { 318 type: 'prompt', 319 name: skillName, 320 description, 321 hasUserSpecifiedDescription, 322 allowedTools, 323 argumentHint, 324 argNames: argumentNames.length > 0 ? argumentNames : undefined, 325 whenToUse, 326 version, 327 model, 328 disableModelInvocation, 329 userInvocable, 330 context: executionContext, 331 agent, 332 effort, 333 paths, 334 contentLength: markdownContent.length, 335 isHidden: !userInvocable, 336 progressMessage: 'running', 337 userFacingName(): string { 338 return displayName || skillName 339 }, 340 source, 341 loadedFrom, 342 hooks, 343 skillRoot: baseDir, 344 async getPromptForCommand(args, toolUseContext) { 345 let finalContent = baseDir 346 ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}` 347 : markdownContent 348 349 finalContent = substituteArguments( 350 finalContent, 351 args, 352 true, 353 argumentNames, 354 ) 355 356 // Replace ${CLAUDE_SKILL_DIR} with the skill's own directory so bash 357 // injection (!`...`) can reference bundled scripts. Normalize backslashes 358 // to forward slashes on Windows so shell commands don't treat them as escapes. 359 if (baseDir) { 360 const skillDir = 361 process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir 362 finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir) 363 } 364 365 // Replace ${CLAUDE_SESSION_ID} with the current session ID 366 finalContent = finalContent.replace( 367 /\$\{CLAUDE_SESSION_ID\}/g, 368 getSessionId(), 369 ) 370 371 // Security: MCP skills are remote and untrusted — never execute inline 372 // shell commands (!`…` / ```! … ```) from their markdown body. 373 // ${CLAUDE_SKILL_DIR} is meaningless for MCP skills anyway. 374 if (loadedFrom !== 'mcp') { 375 finalContent = await executeShellCommandsInPrompt( 376 finalContent, 377 { 378 ...toolUseContext, 379 getAppState() { 380 const appState = toolUseContext.getAppState() 381 return { 382 ...appState, 383 toolPermissionContext: { 384 ...appState.toolPermissionContext, 385 alwaysAllowRules: { 386 ...appState.toolPermissionContext.alwaysAllowRules, 387 command: allowedTools, 388 }, 389 }, 390 } 391 }, 392 }, 393 `/${skillName}`, 394 shell, 395 ) 396 } 397 398 return [{ type: 'text', text: finalContent }] 399 }, 400 } satisfies Command 401} 402 403/** 404 * Loads skills from a /skills/ directory path. 405 * Only supports directory format: skill-name/SKILL.md 406 */ 407async function loadSkillsFromSkillsDir( 408 basePath: string, 409 source: SettingSource, 410): Promise<SkillWithPath[]> { 411 const fs = getFsImplementation() 412 413 let entries 414 try { 415 entries = await fs.readdir(basePath) 416 } catch (e: unknown) { 417 if (!isFsInaccessible(e)) logError(e) 418 return [] 419 } 420 421 const results = await Promise.all( 422 entries.map(async (entry): Promise<SkillWithPath | null> => { 423 try { 424 // Only support directory format: skill-name/SKILL.md 425 if (!entry.isDirectory() && !entry.isSymbolicLink()) { 426 // Single .md files are NOT supported in /skills/ directory 427 return null 428 } 429 430 const skillDirPath = join(basePath, entry.name) 431 const skillFilePath = join(skillDirPath, 'SKILL.md') 432 433 let content: string 434 try { 435 content = await fs.readFile(skillFilePath, { encoding: 'utf-8' }) 436 } catch (e: unknown) { 437 // SKILL.md doesn't exist, skip this entry. Log non-ENOENT errors 438 // (EACCES/EPERM/EIO) so permission/IO problems are diagnosable. 439 if (!isENOENT(e)) { 440 logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, { 441 level: 'warn', 442 }) 443 } 444 return null 445 } 446 447 const { frontmatter, content: markdownContent } = parseFrontmatter( 448 content, 449 skillFilePath, 450 ) 451 452 const skillName = entry.name 453 const parsed = parseSkillFrontmatterFields( 454 frontmatter, 455 markdownContent, 456 skillName, 457 ) 458 const paths = parseSkillPaths(frontmatter) 459 460 return { 461 skill: createSkillCommand({ 462 ...parsed, 463 skillName, 464 markdownContent, 465 source, 466 baseDir: skillDirPath, 467 loadedFrom: 'skills', 468 paths, 469 }), 470 filePath: skillFilePath, 471 } 472 } catch (error) { 473 logError(error) 474 return null 475 } 476 }), 477 ) 478 479 return results.filter((r): r is SkillWithPath => r !== null) 480} 481 482// --- Legacy /commands/ loader --- 483 484function isSkillFile(filePath: string): boolean { 485 return /^skill\.md$/i.test(basename(filePath)) 486} 487 488/** 489 * Transforms markdown files to handle "skill" commands in legacy /commands/ folder. 490 * When a SKILL.md file exists in a directory, only that file is loaded 491 * and it takes the name of its parent directory. 492 */ 493function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] { 494 const filesByDir = new Map<string, MarkdownFile[]>() 495 496 for (const file of files) { 497 const dir = dirname(file.filePath) 498 const dirFiles = filesByDir.get(dir) ?? [] 499 dirFiles.push(file) 500 filesByDir.set(dir, dirFiles) 501 } 502 503 const result: MarkdownFile[] = [] 504 505 for (const [dir, dirFiles] of filesByDir) { 506 const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath)) 507 if (skillFiles.length > 0) { 508 const skillFile = skillFiles[0]! 509 if (skillFiles.length > 1) { 510 logForDebugging( 511 `Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`, 512 ) 513 } 514 result.push(skillFile) 515 } else { 516 result.push(...dirFiles) 517 } 518 } 519 520 return result 521} 522 523function buildNamespace(targetDir: string, baseDir: string): string { 524 const normalizedBaseDir = baseDir.endsWith(pathSep) 525 ? baseDir.slice(0, -1) 526 : baseDir 527 528 if (targetDir === normalizedBaseDir) { 529 return '' 530 } 531 532 const relativePath = targetDir.slice(normalizedBaseDir.length + 1) 533 return relativePath ? relativePath.split(pathSep).join(':') : '' 534} 535 536function getSkillCommandName(filePath: string, baseDir: string): string { 537 const skillDirectory = dirname(filePath) 538 const parentOfSkillDir = dirname(skillDirectory) 539 const commandBaseName = basename(skillDirectory) 540 541 const namespace = buildNamespace(parentOfSkillDir, baseDir) 542 return namespace ? `${namespace}:${commandBaseName}` : commandBaseName 543} 544 545function getRegularCommandName(filePath: string, baseDir: string): string { 546 const fileName = basename(filePath) 547 const fileDirectory = dirname(filePath) 548 const commandBaseName = fileName.replace(/\.md$/, '') 549 550 const namespace = buildNamespace(fileDirectory, baseDir) 551 return namespace ? `${namespace}:${commandBaseName}` : commandBaseName 552} 553 554function getCommandName(file: MarkdownFile): string { 555 const isSkill = isSkillFile(file.filePath) 556 return isSkill 557 ? getSkillCommandName(file.filePath, file.baseDir) 558 : getRegularCommandName(file.filePath, file.baseDir) 559} 560 561/** 562 * Loads skills from legacy /commands/ directories. 563 * Supports both directory format (SKILL.md) and single .md file format. 564 * Commands from /commands/ default to user-invocable: true 565 */ 566async function loadSkillsFromCommandsDir( 567 cwd: string, 568): Promise<SkillWithPath[]> { 569 try { 570 const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd) 571 const processedFiles = transformSkillFiles(markdownFiles) 572 573 const skills: SkillWithPath[] = [] 574 575 for (const { 576 baseDir, 577 filePath, 578 frontmatter, 579 content, 580 source, 581 } of processedFiles) { 582 try { 583 const isSkillFormat = isSkillFile(filePath) 584 const skillDirectory = isSkillFormat ? dirname(filePath) : undefined 585 const cmdName = getCommandName({ 586 baseDir, 587 filePath, 588 frontmatter, 589 content, 590 source, 591 }) 592 593 const parsed = parseSkillFrontmatterFields( 594 frontmatter, 595 content, 596 cmdName, 597 'Custom command', 598 ) 599 600 skills.push({ 601 skill: createSkillCommand({ 602 ...parsed, 603 skillName: cmdName, 604 displayName: undefined, 605 markdownContent: content, 606 source, 607 baseDir: skillDirectory, 608 loadedFrom: 'commands_DEPRECATED', 609 paths: undefined, 610 }), 611 filePath, 612 }) 613 } catch (error) { 614 logError(error) 615 } 616 } 617 618 return skills 619 } catch (error) { 620 logError(error) 621 return [] 622 } 623} 624 625/** 626 * Loads all skills from both /skills/ and legacy /commands/ directories. 627 * 628 * Skills from /skills/ directories: 629 * - Only support directory format: skill-name/SKILL.md 630 * - Default to user-invocable: true (can opt-out with user-invocable: false) 631 * 632 * Skills from legacy /commands/ directories: 633 * - Support both directory format (SKILL.md) and single .md file format 634 * - Default to user-invocable: true (user can type /cmd) 635 * 636 * @param cwd Current working directory for project directory traversal 637 */ 638export const getSkillDirCommands = memoize( 639 async (cwd: string): Promise<Command[]> => { 640 const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills') 641 const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills') 642 const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd) 643 644 logForDebugging( 645 `Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`, 646 ) 647 648 // Load from additional directories (--add-dir) 649 const additionalDirs = getAdditionalDirectoriesForClaudeMd() 650 const skillsLocked = isRestrictedToPluginOnly('skills') 651 const projectSettingsEnabled = 652 isSettingSourceEnabled('projectSettings') && !skillsLocked 653 654 // --bare: skip auto-discovery (managed/user/project dir walks + legacy 655 // commands-dir). Load ONLY explicit --add-dir paths. Bundled skills 656 // register separately. skillsLocked still applies — --bare is not a 657 // policy bypass. 658 if (isBareMode()) { 659 if (additionalDirs.length === 0 || !projectSettingsEnabled) { 660 logForDebugging( 661 `[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`, 662 ) 663 return [] 664 } 665 const additionalSkillsNested = await Promise.all( 666 additionalDirs.map(dir => 667 loadSkillsFromSkillsDir( 668 join(dir, '.claude', 'skills'), 669 'projectSettings', 670 ), 671 ), 672 ) 673 // No dedup needed — explicit dirs, user controls uniqueness. 674 return additionalSkillsNested.flat().map(s => s.skill) 675 } 676 677 // Load from /skills/ directories, additional dirs, and legacy /commands/ in parallel 678 // (all independent — different directories, no shared state) 679 const [ 680 managedSkills, 681 userSkills, 682 projectSkillsNested, 683 additionalSkillsNested, 684 legacyCommands, 685 ] = await Promise.all([ 686 isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS) 687 ? Promise.resolve([]) 688 : loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'), 689 isSettingSourceEnabled('userSettings') && !skillsLocked 690 ? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings') 691 : Promise.resolve([]), 692 projectSettingsEnabled 693 ? Promise.all( 694 projectSkillsDirs.map(dir => 695 loadSkillsFromSkillsDir(dir, 'projectSettings'), 696 ), 697 ) 698 : Promise.resolve([]), 699 projectSettingsEnabled 700 ? Promise.all( 701 additionalDirs.map(dir => 702 loadSkillsFromSkillsDir( 703 join(dir, '.claude', 'skills'), 704 'projectSettings', 705 ), 706 ), 707 ) 708 : Promise.resolve([]), 709 // Legacy commands-as-skills goes through markdownConfigLoader with 710 // subdir='commands', which our agents-only guard there skips. Block 711 // here when skills are locked — these ARE skills, regardless of the 712 // directory they load from. 713 skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd), 714 ]) 715 716 // Flatten and combine all skills 717 const allSkillsWithPaths = [ 718 ...managedSkills, 719 ...userSkills, 720 ...projectSkillsNested.flat(), 721 ...additionalSkillsNested.flat(), 722 ...legacyCommands, 723 ] 724 725 // Deduplicate by resolved path (handles symlinks and duplicate parent directories) 726 // Pre-compute file identities in parallel (realpath calls are independent), 727 // then dedup synchronously (order-dependent first-wins) 728 const fileIds = await Promise.all( 729 allSkillsWithPaths.map(({ skill, filePath }) => 730 skill.type === 'prompt' 731 ? getFileIdentity(filePath) 732 : Promise.resolve(null), 733 ), 734 ) 735 736 const seenFileIds = new Map< 737 string, 738 SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled' 739 >() 740 const deduplicatedSkills: Command[] = [] 741 742 for (let i = 0; i < allSkillsWithPaths.length; i++) { 743 const entry = allSkillsWithPaths[i] 744 if (entry === undefined || entry.skill.type !== 'prompt') continue 745 const { skill } = entry 746 747 const fileId = fileIds[i] 748 if (fileId === null || fileId === undefined) { 749 deduplicatedSkills.push(skill) 750 continue 751 } 752 753 const existingSource = seenFileIds.get(fileId) 754 if (existingSource !== undefined) { 755 logForDebugging( 756 `Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`, 757 ) 758 continue 759 } 760 761 seenFileIds.set(fileId, skill.source) 762 deduplicatedSkills.push(skill) 763 } 764 765 const duplicatesRemoved = 766 allSkillsWithPaths.length - deduplicatedSkills.length 767 if (duplicatesRemoved > 0) { 768 logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`) 769 } 770 771 // Separate conditional skills (with paths frontmatter) from unconditional ones 772 const unconditionalSkills: Command[] = [] 773 const newConditionalSkills: Command[] = [] 774 for (const skill of deduplicatedSkills) { 775 if ( 776 skill.type === 'prompt' && 777 skill.paths && 778 skill.paths.length > 0 && 779 !activatedConditionalSkillNames.has(skill.name) 780 ) { 781 newConditionalSkills.push(skill) 782 } else { 783 unconditionalSkills.push(skill) 784 } 785 } 786 787 // Store conditional skills for later activation when matching files are touched 788 for (const skill of newConditionalSkills) { 789 conditionalSkills.set(skill.name, skill) 790 } 791 792 if (newConditionalSkills.length > 0) { 793 logForDebugging( 794 `[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`, 795 ) 796 } 797 798 logForDebugging( 799 `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})`, 800 ) 801 802 return unconditionalSkills 803 }, 804) 805 806export function clearSkillCaches() { 807 getSkillDirCommands.cache?.clear?.() 808 loadMarkdownFilesForSubdir.cache?.clear?.() 809 conditionalSkills.clear() 810 activatedConditionalSkillNames.clear() 811} 812 813// Backwards-compatible aliases for tests 814export { getSkillDirCommands as getCommandDirCommands } 815export { clearSkillCaches as clearCommandCaches } 816export { transformSkillFiles } 817 818// --- Dynamic skill discovery --- 819 820// State for dynamically discovered skills 821const dynamicSkillDirs = new Set<string>() 822const dynamicSkills = new Map<string, Command>() 823 824// --- Conditional skills (path-filtered) --- 825 826// Skills with paths frontmatter that haven't been activated yet 827const conditionalSkills = new Map<string, Command>() 828// Names of skills that have been activated (survives cache clears within a session) 829const activatedConditionalSkillNames = new Set<string>() 830 831// Signal fired when dynamic skills are loaded 832const skillsLoaded = createSignal() 833 834/** 835 * Register a callback to be invoked when dynamic skills are loaded. 836 * Used by other modules to clear caches without creating import cycles. 837 * Returns an unsubscribe function. 838 */ 839export function onDynamicSkillsLoaded(callback: () => void): () => void { 840 // Wrap at subscribe time so a throwing listener is logged and skipped 841 // rather than aborting skillsLoaded.emit() and breaking skill loading. 842 // Same callSafe pattern as growthbook.ts — createSignal.emit() has no 843 // per-listener try/catch. 844 return skillsLoaded.subscribe(() => { 845 try { 846 callback() 847 } catch (error) { 848 logError(error) 849 } 850 }) 851} 852 853/** 854 * Discovers skill directories by walking up from file paths to cwd. 855 * Only discovers directories below cwd (cwd-level skills are loaded at startup). 856 * 857 * @param filePaths Array of file paths to check 858 * @param cwd Current working directory (upper bound for discovery) 859 * @returns Array of newly discovered skill directories, sorted deepest first 860 */ 861export async function discoverSkillDirsForPaths( 862 filePaths: string[], 863 cwd: string, 864): Promise<string[]> { 865 const fs = getFsImplementation() 866 const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd 867 const newDirs: string[] = [] 868 869 for (const filePath of filePaths) { 870 // Start from the file's parent directory 871 let currentDir = dirname(filePath) 872 873 // Walk up to cwd but NOT including cwd itself 874 // CWD-level skills are already loaded at startup, so we only discover nested ones 875 // Use prefix+separator check to avoid matching /project-backup when cwd is /project 876 while (currentDir.startsWith(resolvedCwd + pathSep)) { 877 const skillDir = join(currentDir, '.claude', 'skills') 878 879 // Skip if we've already checked this path (hit or miss) — avoids 880 // repeating the same failed stat on every Read/Write/Edit call when 881 // the directory doesn't exist (the common case). 882 if (!dynamicSkillDirs.has(skillDir)) { 883 dynamicSkillDirs.add(skillDir) 884 try { 885 await fs.stat(skillDir) 886 // Skills dir exists. Before loading, check if the containing dir 887 // is gitignored — blocks e.g. node_modules/pkg/.claude/skills from 888 // loading silently. `git check-ignore` handles nested .gitignore, 889 // .git/info/exclude, and global gitignore. Fails open outside a 890 // git repo (exit 128 → false); the invocation-time trust dialog 891 // is the actual security boundary. 892 if (await isPathGitignored(currentDir, resolvedCwd)) { 893 logForDebugging( 894 `[skills] Skipped gitignored skills dir: ${skillDir}`, 895 ) 896 continue 897 } 898 newDirs.push(skillDir) 899 } catch { 900 // Directory doesn't exist — already recorded above, continue 901 } 902 } 903 904 // Move to parent 905 const parent = dirname(currentDir) 906 if (parent === currentDir) break // Reached root 907 currentDir = parent 908 } 909 } 910 911 // Sort by path depth (deepest first) so skills closer to the file take precedence 912 return newDirs.sort( 913 (a, b) => b.split(pathSep).length - a.split(pathSep).length, 914 ) 915} 916 917/** 918 * Loads skills from the given directories and merges them into the dynamic skills map. 919 * Skills from directories closer to the file (deeper paths) take precedence. 920 * 921 * @param dirs Array of skill directories to load from (should be sorted deepest first) 922 */ 923export async function addSkillDirectories(dirs: string[]): Promise<void> { 924 if ( 925 !isSettingSourceEnabled('projectSettings') || 926 isRestrictedToPluginOnly('skills') 927 ) { 928 logForDebugging( 929 '[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy', 930 ) 931 return 932 } 933 if (dirs.length === 0) { 934 return 935 } 936 937 const previousSkillNamesForLogging = new Set(dynamicSkills.keys()) 938 939 // Load skills from all directories 940 const loadedSkills = await Promise.all( 941 dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')), 942 ) 943 944 // Process in reverse order (shallower first) so deeper paths override 945 for (let i = loadedSkills.length - 1; i >= 0; i--) { 946 for (const { skill } of loadedSkills[i] ?? []) { 947 if (skill.type === 'prompt') { 948 dynamicSkills.set(skill.name, skill) 949 } 950 } 951 } 952 953 const newSkillCount = loadedSkills.flat().length 954 if (newSkillCount > 0) { 955 const addedSkills = [...dynamicSkills.keys()].filter( 956 n => !previousSkillNamesForLogging.has(n), 957 ) 958 logForDebugging( 959 `[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`, 960 ) 961 if (addedSkills.length > 0) { 962 logEvent('tengu_dynamic_skills_changed', { 963 source: 964 'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 965 previousCount: previousSkillNamesForLogging.size, 966 newCount: dynamicSkills.size, 967 addedCount: addedSkills.length, 968 directoryCount: dirs.length, 969 }) 970 } 971 } 972 973 // Notify listeners that skills were loaded (so they can clear caches) 974 skillsLoaded.emit() 975} 976 977/** 978 * Gets all dynamically discovered skills. 979 * These are skills discovered from file paths during the session. 980 */ 981export function getDynamicSkills(): Command[] { 982 return Array.from(dynamicSkills.values()) 983} 984 985/** 986 * Activates conditional skills (skills with paths frontmatter) whose path 987 * patterns match the given file paths. Activated skills are added to the 988 * dynamic skills map, making them available to the model. 989 * 990 * Uses the `ignore` library (gitignore-style matching), matching the behavior 991 * of CLAUDE.md conditional rules. 992 * 993 * @param filePaths Array of file paths being operated on 994 * @param cwd Current working directory (paths are matched relative to cwd) 995 * @returns Array of newly activated skill names 996 */ 997export function activateConditionalSkillsForPaths( 998 filePaths: string[], 999 cwd: string, 1000): string[] { 1001 if (conditionalSkills.size === 0) { 1002 return [] 1003 } 1004 1005 const activated: string[] = [] 1006 1007 for (const [name, skill] of conditionalSkills) { 1008 if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) { 1009 continue 1010 } 1011 1012 const skillIgnore = ignore().add(skill.paths) 1013 for (const filePath of filePaths) { 1014 const relativePath = isAbsolute(filePath) 1015 ? relative(cwd, filePath) 1016 : filePath 1017 1018 // ignore() throws on empty strings, paths escaping the base (../), 1019 // and absolute paths (Windows cross-drive relative() returns absolute). 1020 // Files outside cwd can't match cwd-relative patterns anyway. 1021 if ( 1022 !relativePath || 1023 relativePath.startsWith('..') || 1024 isAbsolute(relativePath) 1025 ) { 1026 continue 1027 } 1028 1029 if (skillIgnore.ignores(relativePath)) { 1030 // Activate this skill by moving it to dynamic skills 1031 dynamicSkills.set(name, skill) 1032 conditionalSkills.delete(name) 1033 activatedConditionalSkillNames.add(name) 1034 activated.push(name) 1035 logForDebugging( 1036 `[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`, 1037 ) 1038 break 1039 } 1040 } 1041 } 1042 1043 if (activated.length > 0) { 1044 logEvent('tengu_dynamic_skills_changed', { 1045 source: 1046 'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1047 previousCount: dynamicSkills.size - activated.length, 1048 newCount: dynamicSkills.size, 1049 addedCount: activated.length, 1050 directoryCount: 0, 1051 }) 1052 1053 // Notify listeners that skills were loaded (so they can clear caches) 1054 skillsLoaded.emit() 1055 } 1056 1057 return activated 1058} 1059 1060/** 1061 * Gets the number of pending conditional skills (for testing/debugging). 1062 */ 1063export function getConditionalSkillCount(): number { 1064 return conditionalSkills.size 1065} 1066 1067/** 1068 * Clears dynamic skill state (for testing). 1069 */ 1070export function clearDynamicSkills(): void { 1071 dynamicSkillDirs.clear() 1072 dynamicSkills.clear() 1073 conditionalSkills.clear() 1074 activatedConditionalSkillNames.clear() 1075} 1076 1077// Expose createSkillCommand + parseSkillFrontmatterFields to MCP skill 1078// discovery via a leaf registry module. See mcpSkillBuilders.ts for why this 1079// indirection exists (a literal dynamic import from mcpSkills.ts fans a single 1080// edge out into many cycle violations; a variable-specifier dynamic import 1081// passes dep-cruiser but fails to resolve in Bun-bundled binaries at runtime). 1082// eslint-disable-next-line custom-rules/no-top-level-side-effects -- write-once registration, idempotent 1083registerMCPSkillBuilders({ 1084 createSkillCommand, 1085 parseSkillFrontmatterFields, 1086})