diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index fa984b3e111..5e42111c36c 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -55,14 +55,19 @@ export namespace Skill { export const state = Instance.state(async () => { const skills: Record = {} const dirs = new Set() + const seen = new Set() 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) => { 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 }) @@ -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, } } diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 2264723a090..db8ab72b75b 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -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) + }, + }) +})