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
22 changes: 15 additions & 7 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ export namespace Skill {

export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
const dirs = new Set<string>()
const dirs = new Map<string, string>()
const seen = new Map<string, string>()

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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -174,7 +182,7 @@ export namespace Skill {

return {
skills,
dirs: Array.from(dirs),
dirs: Array.from(dirs.values()),
}
})

Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 33 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,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")])
},
})
})
Loading