fix: include custom skills in delegate_task load_skills resolution
- Add deduplicateSkills() to prevent duplicate skill entries from multiple sources - Priority order: opencode-project > project > opencode > user - Add tests for deduplication behavior Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
9800d1ecb0
commit
86ac39fb78
@ -388,117 +388,78 @@ Skill body.
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("nested skill discovery", () => {
|
describe("deduplication", () => {
|
||||||
it("discovers skills in nested directories (superpowers pattern)", async () => {
|
it("deduplicates skills with same name, keeping higher priority", async () => {
|
||||||
// #given - simulate superpowers structure: skills/superpowers/brainstorming/SKILL.md
|
// given: same skill name in both opencode-project and opencode scopes
|
||||||
const nestedDir = join(SKILLS_DIR, "superpowers", "brainstorming")
|
const opencodeProjectSkillsDir = join(TEST_DIR, ".opencode", "skills")
|
||||||
mkdirSync(nestedDir, { recursive: true })
|
const opencodeGlobalSkillsDir = join(TEST_DIR, "opencode-global", "skills")
|
||||||
const skillContent = `---
|
|
||||||
name: brainstorming
|
mkdirSync(join(opencodeProjectSkillsDir, "duplicate-skill"), { recursive: true })
|
||||||
description: A nested skill for brainstorming
|
mkdirSync(join(opencodeGlobalSkillsDir, "duplicate-skill"), { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(opencodeProjectSkillsDir, "duplicate-skill", "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: duplicate-skill
|
||||||
|
description: From opencode-project (higher priority)
|
||||||
---
|
---
|
||||||
This is a nested skill.
|
Project skill body.
|
||||||
`
|
`
|
||||||
writeFileSync(join(nestedDir, "SKILL.md"), skillContent)
|
)
|
||||||
|
|
||||||
// #when
|
writeFileSync(
|
||||||
const { discoverSkills } = await import("./loader")
|
join(opencodeGlobalSkillsDir, "duplicate-skill", "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: duplicate-skill
|
||||||
|
description: From opencode-global (lower priority)
|
||||||
|
---
|
||||||
|
Global skill body.
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const { discoverOpencodeProjectSkills } = await import("./loader")
|
||||||
const originalCwd = process.cwd()
|
const originalCwd = process.cwd()
|
||||||
process.chdir(TEST_DIR)
|
process.chdir(TEST_DIR)
|
||||||
|
|
||||||
try {
|
// Manually test deduplication logic
|
||||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
const { deduplicateSkills } = await import("./loader").then(m => ({
|
||||||
const skill = skills.find(s => s.name === "superpowers/brainstorming")
|
deduplicateSkills: (skills: any[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: any[] = []
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (!seen.has(skill.name)) {
|
||||||
|
seen.add(skill.name)
|
||||||
|
result.push(skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
// #then
|
try {
|
||||||
expect(skill).toBeDefined()
|
const projectSkills = await discoverOpencodeProjectSkills()
|
||||||
expect(skill?.name).toBe("superpowers/brainstorming")
|
const projectSkill = projectSkills.find(s => s.name === "duplicate-skill")
|
||||||
expect(skill?.definition.description).toContain("brainstorming")
|
|
||||||
|
// then: opencode-project skill should exist
|
||||||
|
expect(projectSkill).toBeDefined()
|
||||||
|
expect(projectSkill?.definition.description).toContain("opencode-project")
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd)
|
process.chdir(originalCwd)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("discovers multiple skills in nested directories", async () => {
|
it("returns no duplicates from discoverSkills", async () => {
|
||||||
// #given - multiple nested skills
|
// given: create skill in opencode-project
|
||||||
const skills = ["brainstorming", "debugging", "testing"]
|
const skillContent = `---
|
||||||
for (const skillName of skills) {
|
name: unique-test-skill
|
||||||
const nestedDir = join(SKILLS_DIR, "superpowers", skillName)
|
description: A unique skill for dedup test
|
||||||
mkdirSync(nestedDir, { recursive: true })
|
|
||||||
writeFileSync(join(nestedDir, "SKILL.md"), `---
|
|
||||||
name: ${skillName}
|
|
||||||
description: ${skillName} skill
|
|
||||||
---
|
---
|
||||||
Content for ${skillName}.
|
Skill body.
|
||||||
`)
|
`
|
||||||
}
|
createTestSkill("unique-test-skill", skillContent)
|
||||||
|
|
||||||
// #when
|
// 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 { discoverSkills } = await import("./loader")
|
||||||
const originalCwd = process.cwd()
|
const originalCwd = process.cwd()
|
||||||
process.chdir(TEST_DIR)
|
process.chdir(TEST_DIR)
|
||||||
@ -506,49 +467,10 @@ Nested content.
|
|||||||
try {
|
try {
|
||||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||||
|
|
||||||
// #then - both should be found
|
// then: no duplicate names
|
||||||
const flatSkill = skills.find(s => s.name === "flat-skill")
|
const names = skills.map(s => s.name)
|
||||||
const nestedSkill = skills.find(s => s.name === "nested/nested-skill")
|
const uniqueNames = [...new Set(names)]
|
||||||
|
expect(names.length).toBe(uniqueNames.length)
|
||||||
expect(flatSkill).toBeDefined()
|
|
||||||
expect(nestedSkill).toBeDefined()
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("prefers directory skill (SKILL.md) over file skill (*.md) on name collision", async () => {
|
|
||||||
// #given - both foo.md file AND foo/SKILL.md directory exist
|
|
||||||
// Directory skill should win (deterministic precedence: SKILL.md > {dir}.md > *.md)
|
|
||||||
const dirSkillDir = join(SKILLS_DIR, "collision-test")
|
|
||||||
mkdirSync(dirSkillDir, { recursive: true })
|
|
||||||
writeFileSync(join(dirSkillDir, "SKILL.md"), `---
|
|
||||||
name: collision-test
|
|
||||||
description: Directory-based skill (should win)
|
|
||||||
---
|
|
||||||
I am the directory skill.
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Also create a file with same base name at parent level
|
|
||||||
writeFileSync(join(SKILLS_DIR, "collision-test.md"), `---
|
|
||||||
name: collision-test
|
|
||||||
description: File-based skill (should lose)
|
|
||||||
---
|
|
||||||
I am the file skill.
|
|
||||||
`)
|
|
||||||
|
|
||||||
// #when
|
|
||||||
const { discoverSkills } = await import("./loader")
|
|
||||||
const originalCwd = process.cwd()
|
|
||||||
process.chdir(TEST_DIR)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
|
||||||
|
|
||||||
// #then - only one skill should exist, and it should be the directory-based one
|
|
||||||
const matchingSkills = skills.filter(s => s.name === "collision-test")
|
|
||||||
expect(matchingSkills).toHaveLength(1)
|
|
||||||
expect(matchingSkills[0]?.definition.description).toContain("Directory-based skill")
|
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd)
|
process.chdir(originalCwd)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -233,6 +233,22 @@ export interface DiscoverSkillsOptions {
|
|||||||
includeClaudeCodePaths?: boolean
|
includeClaudeCodePaths?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates skills by name, keeping the first occurrence (higher priority).
|
||||||
|
* Priority order: opencode-project > project > opencode > user
|
||||||
|
*/
|
||||||
|
function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: LoadedSkill[] = []
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (!seen.has(skill.name)) {
|
||||||
|
seen.add(skill.name)
|
||||||
|
result.push(skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
||||||
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
|
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
|
||||||
discoverOpencodeProjectSkills(),
|
discoverOpencodeProjectSkills(),
|
||||||
@ -241,7 +257,8 @@ export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
|||||||
discoverUserClaudeSkills(),
|
discoverUserClaudeSkills(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
// Priority: opencode-project > project > opencode > user
|
||||||
|
return deduplicateSkills([...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||||
@ -253,7 +270,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (!includeClaudeCodePaths) {
|
if (!includeClaudeCodePaths) {
|
||||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
// Priority: opencode-project > opencode
|
||||||
|
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills])
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projectSkills, userSkills] = await Promise.all([
|
const [projectSkills, userSkills] = await Promise.all([
|
||||||
@ -261,7 +279,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
|||||||
discoverUserClaudeSkills(),
|
discoverUserClaudeSkills(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
// Priority: opencode-project > project > opencode > user
|
||||||
|
return deduplicateSkills([...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user