Merge pull request #2936 from HOYALIM/fix-2696-skill-xml-names

fix: preserve nested async skill names in discovery
This commit is contained in:
YeonGyu-Kim
2026-03-29 18:53:35 -07:00
committed by GitHub
2 changed files with 105 additions and 9 deletions
@@ -107,6 +107,78 @@ Direct skill.
expect(skills[0].name).toBe("direct-skill")
})
it("preserves nested skill path names during recursive discovery", async () => {
// given
const nestedSkillDir = join(SKILLS_DIR, "superpowers", "brainstorming")
mkdirSync(nestedSkillDir, { recursive: true })
writeFileSync(
join(nestedSkillDir, "SKILL.md"),
`---
name: brainstorming
description: Nested brainstorming skill
---
Nested skill.
`
)
// when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// then
expect(skills).toHaveLength(1)
expect(skills[0]?.name).toBe("superpowers/brainstorming")
expect(skills[0]?.definition.name).toBe("superpowers/brainstorming")
})
it("preserves nested skill path names for nested {dirName}.md discovery", async () => {
// given
const nestedSkillDir = join(SKILLS_DIR, "superpowers", "brainstorming")
mkdirSync(nestedSkillDir, { recursive: true })
writeFileSync(
join(nestedSkillDir, "brainstorming.md"),
`---
name: brainstorming
description: Nested brainstorming skill
---
Nested skill.
`
)
// when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// then
expect(skills).toHaveLength(1)
expect(skills[0]?.name).toBe("superpowers/brainstorming")
expect(skills[0]?.definition.name).toBe("superpowers/brainstorming")
})
it("preserves nested skill path names for nested direct markdown discovery", async () => {
// given
const nestedSkillDir = join(SKILLS_DIR, "superpowers")
mkdirSync(nestedSkillDir, { recursive: true })
writeFileSync(
join(nestedSkillDir, "brainstorming.md"),
`---
name: brainstorming
description: Nested brainstorming skill
---
Nested skill.
`
)
// when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// then
expect(skills).toHaveLength(1)
expect(skills[0]?.name).toBe("superpowers/brainstorming")
expect(skills[0]?.definition.name).toBe("superpowers/brainstorming")
})
it("skips entries starting with dot", async () => {
// given
const validContent = `---
@@ -75,7 +75,8 @@ export async function loadSkillFromPathAsync(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
scope: SkillScope,
namePrefix = ""
): Promise<LoadedSkill | null> {
try {
const content = await readFile(skillPath, "utf-8")
@@ -86,7 +87,8 @@ export async function loadSkillFromPathAsync(
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
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}`
@@ -142,11 +144,17 @@ function parseAllowedTools(allowedTools: string | string[] | undefined): string[
return allowedTools.split(/\s+/).filter(Boolean)
}
export async function discoverSkillsInDirAsync(skillsDir: string): Promise<LoadedSkill[]> {
export async function discoverSkillsInDirAsync(
skillsDir: string,
scope: SkillScope = "opencode-project",
namePrefix = "",
depth = 0,
maxDepth = 2
): Promise<LoadedSkill[]> {
try {
const entries = await readdir(skillsDir, { withFileTypes: true })
const processEntry = async (entry: Dirent): Promise<LoadedSkill | null> => {
const processEntry = async (entry: Dirent): Promise<LoadedSkill | LoadedSkill[] | null> => {
if (entry.name.startsWith(".")) return null
const entryPath = join(skillsDir, entry.name)
@@ -158,28 +166,44 @@ export async function discoverSkillsInDirAsync(skillsDir: string): Promise<Loade
const skillMdPath = join(resolvedPath, "SKILL.md")
try {
await readFile(skillMdPath, "utf-8")
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope, namePrefix)
} catch {
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
try {
await readFile(namedSkillMdPath, "utf-8")
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
} catch {
return null
if (depth >= maxDepth) {
return null
}
const nestedPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName
const nestedSkills = await discoverSkillsInDirAsync(
resolvedPath,
scope,
nestedPrefix,
depth + 1,
maxDepth
)
return nestedSkills.length > 0 ? nestedSkills : null
}
}
}
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope, namePrefix)
}
return null
}
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
return skillPromises.filter((skill): skill is LoadedSkill => skill !== null)
return skillPromises.flatMap((skill): LoadedSkill[] => {
if (skill === null) return []
return Array.isArray(skill) ? skill : [skill]
})
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return []