/ src / skills / loadSkillsDir.ts
loadSkillsDir.ts
   1  import { realpath } from 'fs/promises'
   2  import ignore from 'ignore'
   3  import memoize from 'lodash-es/memoize.js'
   4  import {
   5    basename,
   6    dirname,
   7    isAbsolute,
   8    join,
   9    sep as pathSep,
  10    relative,
  11  } from 'path'
  12  import {
  13    getAdditionalDirectoriesForClaudeMd,
  14    getSessionId,
  15  } from '../bootstrap/state.js'
  16  import {
  17    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  18    logEvent,
  19  } from '../services/analytics/index.js'
  20  import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
  21  import type { Command, PromptCommand } from '../types/command.js'
  22  import {
  23    parseArgumentNames,
  24    substituteArguments,
  25  } from '../utils/argumentSubstitution.js'
  26  import { logForDebugging } from '../utils/debug.js'
  27  import {
  28    EFFORT_LEVELS,
  29    type EffortValue,
  30    parseEffortValue,
  31  } from '../utils/effort.js'
  32  import {
  33    getClaudeConfigHomeDir,
  34    isBareMode,
  35    isEnvTruthy,
  36  } from '../utils/envUtils.js'
  37  import { isENOENT, isFsInaccessible } from '../utils/errors.js'
  38  import {
  39    coerceDescriptionToString,
  40    type FrontmatterData,
  41    type FrontmatterShell,
  42    parseBooleanFrontmatter,
  43    parseFrontmatter,
  44    parseShellFrontmatter,
  45    splitPathInFrontmatter,
  46  } from '../utils/frontmatterParser.js'
  47  import { getFsImplementation } from '../utils/fsOperations.js'
  48  import { isPathGitignored } from '../utils/git/gitignore.js'
  49  import { logError } from '../utils/log.js'
  50  import {
  51    extractDescriptionFromMarkdown,
  52    getProjectDirsUpToHome,
  53    loadMarkdownFilesForSubdir,
  54    type MarkdownFile,
  55    parseSlashCommandToolsFromFrontmatter,
  56  } from '../utils/markdownConfigLoader.js'
  57  import { parseUserSpecifiedModel } from '../utils/model/model.js'
  58  import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
  59  import type { SettingSource } from '../utils/settings/constants.js'
  60  import { isSettingSourceEnabled } from '../utils/settings/constants.js'
  61  import { getManagedFilePath } from '../utils/settings/managedPath.js'
  62  import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js'
  63  import { HooksSchema, type HooksSettings } from '../utils/settings/types.js'
  64  import { createSignal } from '../utils/signal.js'
  65  import { registerMCPSkillBuilders } from './mcpSkillBuilders.js'
  66  
  67  export 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   */
  78  export 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   */
 100  export 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   */
 118  async 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
 127  type 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   */
 136  function 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   */
 159  function 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   */
 185  export 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   */
 270  export 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   */
 407  async 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  
 484  function 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   */
 493  function 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  
 523  function 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  
 536  function 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  
 545  function 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  
 554  function 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   */
 566  async 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   */
 638  export 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  
 806  export function clearSkillCaches() {
 807    getSkillDirCommands.cache?.clear?.()
 808    loadMarkdownFilesForSubdir.cache?.clear?.()
 809    conditionalSkills.clear()
 810    activatedConditionalSkillNames.clear()
 811  }
 812  
 813  // Backwards-compatible aliases for tests
 814  export { getSkillDirCommands as getCommandDirCommands }
 815  export { clearSkillCaches as clearCommandCaches }
 816  export { transformSkillFiles }
 817  
 818  // --- Dynamic skill discovery ---
 819  
 820  // State for dynamically discovered skills
 821  const dynamicSkillDirs = new Set<string>()
 822  const dynamicSkills = new Map<string, Command>()
 823  
 824  // --- Conditional skills (path-filtered) ---
 825  
 826  // Skills with paths frontmatter that haven't been activated yet
 827  const conditionalSkills = new Map<string, Command>()
 828  // Names of skills that have been activated (survives cache clears within a session)
 829  const activatedConditionalSkillNames = new Set<string>()
 830  
 831  // Signal fired when dynamic skills are loaded
 832  const 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   */
 839  export 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   */
 861  export 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   */
 923  export 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   */
 981  export 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   */
 997  export 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   */
1063  export function getConditionalSkillCount(): number {
1064    return conditionalSkills.size
1065  }
1066  
1067  /**
1068   * Clears dynamic skill state (for testing).
1069   */
1070  export 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
1083  registerMCPSkillBuilders({
1084    createSkillCommand,
1085    parseSkillFrontmatterFields,
1086  })