( filePaths: string[], cwd: string, )
| 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. |
no test coverage detected