Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,19 @@ export namespace Skill {
export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
const dirs = new Set<string>()
const seen = new Set<string>()

const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => {
const file = Filesystem.resolve(match)
if (seen.has(file)) return
seen.add(file)

const md = await ConfigMarkdown.parse(file).catch((err) => {
Comment on lines 60 to +65
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
: `Failed to parse skill ${file}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
log.error("failed to load skill", { skill: file, err })
return undefined
})

Expand All @@ -76,16 +81,16 @@ export namespace Skill {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: skills[parsed.data.name].location,
duplicate: match,
duplicate: file,
})
}

dirs.add(path.dirname(match))
dirs.add(path.dirname(file))

skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
location: file,
content: md.content,
}
}
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,39 @@ description: A skill in the .opencode/skills directory.
},
})
})

test("deduplicates symlinked skill directories by canonical path", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const agentDir = path.join(dir, ".agents", "skills", "shared-skill")
const claudeDir = path.join(dir, ".claude", "skills", "shared-skill")
await fs.mkdir(agentDir, { recursive: true })
await fs.mkdir(path.dirname(claudeDir), { recursive: true })
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: shared-skill
description: A shared skill.
---

# Shared Skill
`,
)
await fs.symlink(agentDir, claudeDir, process.platform === "win32" ? "junction" : "dir")
return { agentDir }
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
const dirs = await Skill.dirs()
expect(skills.length).toBe(1)
expect(skills[0].location).toBe(path.join(tmp.extra.agentDir, "SKILL.md"))
expect(dirs).toContain(tmp.extra.agentDir)
expect(dirs.length).toBe(1)
},
})
})
Loading