Merge pull request #2936 from HOYALIM/fix-2696-skill-xml-names
fix: preserve nested async skill names in discovery
This commit is contained in:
@@ -107,6 +107,78 @@ Direct skill.
|
|||||||
expect(skills[0].name).toBe("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 () => {
|
it("skips entries starting with dot", async () => {
|
||||||
// given
|
// given
|
||||||
const validContent = `---
|
const validContent = `---
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export async function loadSkillFromPathAsync(
|
|||||||
skillPath: string,
|
skillPath: string,
|
||||||
resolvedPath: string,
|
resolvedPath: string,
|
||||||
defaultName: string,
|
defaultName: string,
|
||||||
scope: SkillScope
|
scope: SkillScope,
|
||||||
|
namePrefix = ""
|
||||||
): Promise<LoadedSkill | null> {
|
): Promise<LoadedSkill | null> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(skillPath, "utf-8")
|
const content = await readFile(skillPath, "utf-8")
|
||||||
@@ -86,7 +87,8 @@ export async function loadSkillFromPathAsync(
|
|||||||
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
|
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
|
||||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
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 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}`
|
||||||
@@ -142,11 +144,17 @@ function parseAllowedTools(allowedTools: string | string[] | undefined): string[
|
|||||||
return allowedTools.split(/\s+/).filter(Boolean)
|
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 {
|
try {
|
||||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
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
|
if (entry.name.startsWith(".")) return null
|
||||||
|
|
||||||
const entryPath = join(skillsDir, entry.name)
|
const entryPath = join(skillsDir, entry.name)
|
||||||
@@ -158,28 +166,44 @@ export async function discoverSkillsInDirAsync(skillsDir: string): Promise<Loade
|
|||||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||||
try {
|
try {
|
||||||
await readFile(skillMdPath, "utf-8")
|
await readFile(skillMdPath, "utf-8")
|
||||||
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
|
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||||
} catch {
|
} catch {
|
||||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||||
try {
|
try {
|
||||||
await readFile(namedSkillMdPath, "utf-8")
|
await readFile(namedSkillMdPath, "utf-8")
|
||||||
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
|
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||||
} catch {
|
} 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)) {
|
if (isMarkdownFile(entry)) {
|
||||||
const skillName = basename(entry.name, ".md")
|
const skillName = basename(entry.name, ".md")
|
||||||
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
|
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope, namePrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
|
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) {
|
} catch (error: unknown) {
|
||||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user