feat(skill-loader): support nested skill directories
Add recursive directory scanning to discover skills in nested directories like superpowers (e.g., skills/superpowers/brainstorming/SKILL.md). Changes: - Add namePrefix, depth, and maxDepth parameters to loadSkillsFromDir - Recurse into subdirectories when no SKILL.md found at current level - Construct hierarchical skill names (e.g., 'superpowers/brainstorming') - Limit recursion depth to 2 levels to prevent infinite loops This enables compatibility with the superpowers plugin which installs skills as: ~/.config/opencode/skills/superpowers/ -> superpowers/skills/ Fixes skill discovery for nested directory structures.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,8 @@ async function loadSkillFromPath(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
scope: SkillScope,
|
||||
namePrefix: string = ""
|
||||
): Promise<LoadedSkill | null> {
|
||||
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<LoadedSkill[]> {
|
||||
async function loadSkillsFromDir(
|
||||
skillsDir: string,
|
||||
scope: SkillScope,
|
||||
namePrefix: string = "",
|
||||
depth: number = 0,
|
||||
maxDepth: number = 2
|
||||
): Promise<LoadedSkill[]> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user