diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index ba482bae0..966d16fdd 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -387,4 +387,134 @@ Skill body. } }) }) + + describe("nested skill discovery", () => { + it("discovers skills in nested directories (superpowers pattern)", async () => { + // #given - simulate superpowers structure: skills/superpowers/brainstorming/SKILL.md + const nestedDir = join(SKILLS_DIR, "superpowers", "brainstorming") + mkdirSync(nestedDir, { recursive: true }) + const skillContent = `--- +name: brainstorming +description: A nested skill for brainstorming +--- +This is a nested skill. +` + writeFileSync(join(nestedDir, "SKILL.md"), skillContent) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverSkills({ includeClaudeCodePaths: false }) + const skill = skills.find(s => s.name === "superpowers/brainstorming") + + // #then + expect(skill).toBeDefined() + expect(skill?.name).toBe("superpowers/brainstorming") + expect(skill?.definition.description).toContain("brainstorming") + } finally { + process.chdir(originalCwd) + } + }) + + it("discovers multiple skills in nested directories", async () => { + // #given - multiple nested skills + const skills = ["brainstorming", "debugging", "testing"] + for (const skillName of skills) { + const nestedDir = join(SKILLS_DIR, "superpowers", skillName) + mkdirSync(nestedDir, { recursive: true }) + writeFileSync(join(nestedDir, "SKILL.md"), `--- +name: ${skillName} +description: ${skillName} skill +--- +Content for ${skillName}. +`) + } + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const discoveredSkills = await discoverSkills({ includeClaudeCodePaths: false }) + + // #then + for (const skillName of skills) { + const skill = discoveredSkills.find(s => s.name === `superpowers/${skillName}`) + expect(skill).toBeDefined() + } + } finally { + process.chdir(originalCwd) + } + }) + + it("respects max depth limit", async () => { + // #given - deeply nested skill (3 levels deep, beyond default maxDepth of 2) + const deepDir = join(SKILLS_DIR, "level1", "level2", "level3", "deep-skill") + mkdirSync(deepDir, { recursive: true }) + writeFileSync(join(deepDir, "SKILL.md"), `--- +name: deep-skill +description: A deeply nested skill +--- +Too deep. +`) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverSkills({ includeClaudeCodePaths: false }) + const skill = skills.find(s => s.name.includes("deep-skill")) + + // #then - should not find skill beyond maxDepth + expect(skill).toBeUndefined() + } finally { + process.chdir(originalCwd) + } + }) + + it("flat skills still work alongside nested skills", async () => { + // #given - both flat and nested skills + const flatSkillDir = join(SKILLS_DIR, "flat-skill") + mkdirSync(flatSkillDir, { recursive: true }) + writeFileSync(join(flatSkillDir, "SKILL.md"), `--- +name: flat-skill +description: A flat skill +--- +Flat content. +`) + + const nestedDir = join(SKILLS_DIR, "nested", "nested-skill") + mkdirSync(nestedDir, { recursive: true }) + writeFileSync(join(nestedDir, "SKILL.md"), `--- +name: nested-skill +description: A nested skill +--- +Nested content. +`) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverSkills({ includeClaudeCodePaths: false }) + + // #then - both should be found + const flatSkill = skills.find(s => s.name === "flat-skill") + const nestedSkill = skills.find(s => s.name === "nested/nested-skill") + + expect(flatSkill).toBeDefined() + expect(nestedSkill).toBeDefined() + } finally { + process.chdir(originalCwd) + } + }) + }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 418ed8ddb..181014da0 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -66,7 +66,8 @@ async function loadSkillFromPath( skillPath: string, resolvedPath: string, defaultName: string, - scope: SkillScope + scope: SkillScope, + namePrefix: string = "" ): Promise { try { const content = await fs.readFile(skillPath, "utf-8") @@ -75,7 +76,10 @@ async function loadSkillFromPath( const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) const mcpConfig = mcpJsonMcp || frontmatterMcp - const skillName = data.name || defaultName + // For nested skills, use the full path as the name (e.g., "superpowers/brainstorming") + // For flat skills, use frontmatter name or directory name + const baseName = data.name || defaultName + const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName const originalDescription = data.description || "" const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const formattedDescription = `(${scope} - Skill) ${originalDescription}` @@ -128,7 +132,13 @@ $ARGUMENTS } } -async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise { +async function loadSkillsFromDir( + skillsDir: string, + scope: SkillScope, + namePrefix: string = "", + depth: number = 0, + maxDepth: number = 2 +): Promise { const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const skills: LoadedSkill[] = [] @@ -144,7 +154,7 @@ async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise< const skillMdPath = join(resolvedPath, "SKILL.md") try { await fs.access(skillMdPath) - const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope) + const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix) if (skill) skills.push(skill) continue } catch { @@ -153,18 +163,25 @@ async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise< const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) try { await fs.access(namedSkillMdPath) - const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope) + const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix) if (skill) skills.push(skill) continue } catch { } + // Recurse into subdirectories if no skill found and within depth limit + if (depth < maxDepth) { + const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName + const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth) + skills.push(...nestedSkills) + } + continue } if (isMarkdownFile(entry)) { - const skillName = basename(entry.name, ".md") - const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope) + const baseName = basename(entry.name, ".md") + const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix) if (skill) skills.push(skill) } }