diff --git a/src/skills/loadSkillsDir.test.ts b/src/skills/loadSkillsDir.test.ts new file mode 100644 index 00000000..8bf8e8e8 --- /dev/null +++ b/src/skills/loadSkillsDir.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { getSkillDirCommands, clearSkillCaches } from './loadSkillsDir.ts' + +function writeSkill(rootDir: string, skillPath: string): void { + const skillDir = join(rootDir, '.claude', 'skills', ...skillPath.split('/')) + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\ndescription: ${skillPath}\n---\n# ${skillPath}\n`, + 'utf8', + ) +} + +test('loads flat and nested skills with colon namespaces', async () => { + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-')) + const cwd = join(configDir, 'workspace') + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + + try { + mkdirSync(cwd, { recursive: true }) + writeSkill(configDir, 'flat-skill') + writeSkill(configDir, 'git/commit') + writeSkill(configDir, 'frontend/react/form') + + process.env.CLAUDE_CONFIG_DIR = configDir + clearSkillCaches() + + const skills = await getSkillDirCommands(cwd) + const promptSkills = skills.filter(skill => skill.type === 'prompt') + const skillNames = promptSkills.map(skill => skill.name).sort() + + assert.deepEqual(skillNames, [ + 'flat-skill', + 'frontend:react:form', + 'git:commit', + ]) + + const nestedSkill = promptSkills.find(skill => skill.name === 'git:commit') + assert.ok(nestedSkill) + assert.equal(nestedSkill.skillRoot, join(configDir, '.claude', 'skills', 'git', 'commit')) + + const deepSkill = promptSkills.find( + skill => skill.name === 'frontend:react:form', + ) + assert.ok(deepSkill) + assert.equal( + deepSkill.skillRoot, + join(configDir, '.claude', 'skills', 'frontend', 'react', 'form'), + ) + } finally { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + clearSkillCaches() + rmSync(configDir, { recursive: true, force: true }) + } +}) diff --git a/src/skills/loadSkillsDir.ts b/src/skills/loadSkillsDir.ts index b1138eed..de9bf7bf 100644 --- a/src/skills/loadSkillsDir.ts +++ b/src/skills/loadSkillsDir.ts @@ -401,14 +401,66 @@ export function createSkillCommand({ } /** - * Loads skills from a /skills/ directory path. - * Only supports directory format: skill-name/SKILL.md + * Recursively finds nested SKILL.md files under a /skills/ directory. + * Ignores markdown files placed directly in the root /skills/ directory. */ -async function loadSkillsFromSkillsDir( - basePath: string, - source: SettingSource, -): Promise { +async function findSkillMarkdownFiles(basePath: string): Promise { const fs = getFsImplementation() + const visitedDirs = new Set() + const skillFiles: string[] = [] + + async function walk(skillDirPath: string): Promise { + const dirId = await getFileIdentity(skillDirPath) + if (dirId && visitedDirs.has(dirId)) { + return + } + if (dirId) { + visitedDirs.add(dirId) + } + + let entries + try { + entries = await fs.readdir(skillDirPath) + } catch (e: unknown) { + if (!isFsInaccessible(e)) { + logForDebugging(`[skills] failed to read directory ${skillDirPath}: ${e}`, { + level: 'warn', + }) + } + return + } + + const childDirs: string[] = [] + for (const entry of entries) { + const entryPath = join(skillDirPath, entry.name) + + if (isSkillFile(entryPath)) { + skillFiles.push(entryPath) + continue + } + + if (entry.isDirectory()) { + childDirs.push(entryPath) + continue + } + + if (entry.isSymbolicLink()) { + try { + if ((await fs.stat(entryPath)).isDirectory()) { + childDirs.push(entryPath) + } + } catch (e: unknown) { + if (!isENOENT(e) && !isFsInaccessible(e)) { + logForDebugging(`[skills] failed to stat ${entryPath}: ${e}`, { + level: 'warn', + }) + } + } + } + } + + await Promise.all(childDirs.map(walk)) + } let entries try { @@ -418,17 +470,50 @@ async function loadSkillsFromSkillsDir( 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 topLevelDirs: string[] = [] + for (const entry of entries) { + const entryPath = join(basePath, entry.name) - const skillDirPath = join(basePath, entry.name) - const skillFilePath = join(skillDirPath, 'SKILL.md') + if (entry.isDirectory()) { + topLevelDirs.push(entryPath) + continue + } + + if (entry.isSymbolicLink()) { + try { + if ((await fs.stat(entryPath)).isDirectory()) { + topLevelDirs.push(entryPath) + } + } catch (e: unknown) { + if (!isENOENT(e) && !isFsInaccessible(e)) { + logForDebugging(`[skills] failed to stat ${entryPath}: ${e}`, { + level: 'warn', + }) + } + } + } + } + + await Promise.all(topLevelDirs.map(walk)) + skillFiles.sort() + return skillFiles +} + +/** + * Loads skills from a /skills/ directory path. + * Supports nested directory format: category/skill/SKILL.md + */ +async function loadSkillsFromSkillsDir( + basePath: string, + source: SettingSource, +): Promise { + const fs = getFsImplementation() + const skillFiles = await findSkillMarkdownFiles(basePath) + + const results = await Promise.all( + skillFiles.map(async (skillFilePath): Promise => { + try { + const skillDirPath = dirname(skillFilePath) let content: string try { @@ -449,7 +534,7 @@ async function loadSkillsFromSkillsDir( skillFilePath, ) - const skillName = entry.name + const skillName = getSkillCommandName(skillFilePath, basePath) const parsed = parseSkillFrontmatterFields( frontmatter, markdownContent,