diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index fa984b3e111..f52723a3a7f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -54,9 +54,11 @@ export namespace Skill { export const state = Instance.state(async () => { const skills: Record = {} - const dirs = new Set() + const dirs = new Map() + const seen = new Map() const addSkill = async (match: string) => { + const real = Filesystem.resolve(match) const md = await ConfigMarkdown.parse(match).catch((err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message @@ -71,16 +73,23 @@ export namespace Skill { const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return - // Warn on duplicate skill names - if (skills[parsed.data.name]) { + const prev = skills[parsed.data.name] + if (prev && Filesystem.resolve(prev.location) !== real) { log.warn("duplicate skill name", { name: parsed.data.name, - existing: skills[parsed.data.name].location, + existing: prev.location, duplicate: match, }) } - dirs.add(path.dirname(match)) + const old = seen.get(real) + if (old && old !== parsed.data.name) { + const skill = skills[old] + if (skill && Filesystem.resolve(skill.location) === real) delete skills[old] + } + + dirs.set(path.dirname(real), path.dirname(match)) + seen.set(real, parsed.data.name) skills[parsed.data.name] = { name: parsed.data.name, @@ -159,7 +168,6 @@ export namespace Skill { for (const url of config.skills?.urls ?? []) { const list = await Discovery.pull(url) for (const dir of list) { - dirs.add(dir) const matches = await Glob.scan(SKILL_PATTERN, { cwd: dir, absolute: true, @@ -174,7 +182,7 @@ export namespace Skill { return { skills, - dirs: Array.from(dirs), + dirs: Array.from(dirs.values()), } }) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 497b6019d3e..acaf1bb3f3d 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test" import path from "path" +import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" @@ -564,6 +565,45 @@ description: Permission skill. } }) +test("symlinked skill aliases add one external_directory allow rule", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const claude = path.join(dir, ".claude", "skills", "shared-skill") + const agents = path.join(dir, ".agents", "skills") + await Bun.write( + path.join(claude, "SKILL.md"), + `--- +name: shared-skill +description: Shared skill. +--- + +# Shared Skill +`, + ) + await fs.mkdir(agents, { recursive: true }) + await fs.symlink(claude, path.join(agents, "shared-skill"), "dir") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const rules = build!.permission.filter( + (rule) => + rule.permission === "external_directory" && + rule.action === "allow" && + rule.pattern.includes("shared-skill") && + rule.pattern.endsWith("*"), + ) + + expect(rules).toHaveLength(1) + expect(rules[0].pattern).toBe(path.join(tmp.path, ".agents", "skills", "shared-skill", "*")) + }, + }) +}) + test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 2264723a090..366754c8a48 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -386,3 +386,36 @@ description: A skill in the .opencode/skills directory. }, }) }) + +test("dedupes symlinked skill aliases and keeps the later path", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const claude = path.join(dir, ".claude", "skills", "shared-skill") + const agents = path.join(dir, ".agents", "skills") + await Bun.write( + path.join(claude, "SKILL.md"), + `--- +name: shared-skill +description: Shared skill. +--- + +# Shared Skill +`, + ) + await fs.mkdir(agents, { recursive: true }) + await fs.symlink(claude, path.join(agents, "shared-skill"), "dir") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + const dirs = await Skill.dirs() + expect(skills).toHaveLength(1) + expect(skills[0].location).toContain(path.join(".agents", "skills", "shared-skill", "SKILL.md")) + expect(dirs).toEqual([path.join(tmp.path, ".agents", "skills", "shared-skill")]) + }, + }) +})