Merge pull request #1254 from LeekJay/fix/nested-skill-discovery
feat(skill-loader): support nested skill directories
This commit is contained in:
commit
8098e48658
@ -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,
|
skillPath: string,
|
||||||
resolvedPath: string,
|
resolvedPath: string,
|
||||||
defaultName: string,
|
defaultName: string,
|
||||||
scope: SkillScope
|
scope: SkillScope,
|
||||||
|
namePrefix: string = ""
|
||||||
): Promise<LoadedSkill | null> {
|
): Promise<LoadedSkill | null> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(skillPath, "utf-8")
|
const content = await fs.readFile(skillPath, "utf-8")
|
||||||
@ -75,7 +76,10 @@ async function loadSkillFromPath(
|
|||||||
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
|
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
|
||||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
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 originalDescription = data.description || ""
|
||||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
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 entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||||
const skills: LoadedSkill[] = []
|
const skills: LoadedSkill[] = []
|
||||||
|
|
||||||
@ -144,7 +154,7 @@ async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<
|
|||||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||||
try {
|
try {
|
||||||
await fs.access(skillMdPath)
|
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)
|
if (skill) skills.push(skill)
|
||||||
continue
|
continue
|
||||||
} catch {
|
} catch {
|
||||||
@ -153,18 +163,25 @@ async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<
|
|||||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||||
try {
|
try {
|
||||||
await fs.access(namedSkillMdPath)
|
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)
|
if (skill) skills.push(skill)
|
||||||
continue
|
continue
|
||||||
} catch {
|
} 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMarkdownFile(entry)) {
|
if (isMarkdownFile(entry)) {
|
||||||
const skillName = basename(entry.name, ".md")
|
const baseName = basename(entry.name, ".md")
|
||||||
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix)
|
||||||
if (skill) skills.push(skill)
|
if (skill) skills.push(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user