Skip to content

Commit 655d2bc

Browse files
Apply PR #11842: feat: add support for reading skills from .agents/skills directories
2 parents cbf2115 + 20d31cd commit 655d2bc

File tree

3 files changed

+142
-30
lines changed

3 files changed

+142
-30
lines changed

packages/opencode/src/flag/flag.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export namespace Flag {
2323
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
2424
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
2525
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
26+
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
27+
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
2628
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
2729
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
2830
export declare const OPENCODE_CLIENT: string

packages/opencode/src/skill/skill.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ export namespace Skill {
4040
}),
4141
)
4242

43+
// External skill directories to search for (project-level and global)
44+
// These follow the directory layout used by Claude Code and other agents.
45+
const EXTERNAL_DIRS = [".claude", ".agents"]
46+
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
47+
4348
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
44-
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
4549
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
4650

4751
export const state = Instance.state(async () => {
@@ -79,38 +83,37 @@ export namespace Skill {
7983
}
8084
}
8185

82-
// Scan .claude/skills/ directories (project-level)
83-
const claudeDirs = await Array.fromAsync(
84-
Filesystem.up({
85-
targets: [".claude"],
86-
start: Instance.directory,
87-
stop: Instance.worktree,
88-
}),
89-
)
90-
// Also include global ~/.claude/skills/
91-
const globalClaude = `${Global.Path.home}/.claude`
92-
if (await Filesystem.isDir(globalClaude)) {
93-
claudeDirs.push(globalClaude)
86+
const scanExternal = async (root: string, scope: "global" | "project") => {
87+
return Array.fromAsync(
88+
EXTERNAL_SKILL_GLOB.scan({
89+
cwd: root,
90+
absolute: true,
91+
onlyFiles: true,
92+
followSymlinks: true,
93+
dot: true,
94+
}),
95+
)
96+
.then((matches) => Promise.all(matches.map(addSkill)))
97+
.catch((error) => {
98+
log.error(`failed to scan ${scope} skills`, { dir: root, error })
99+
})
94100
}
95101

96-
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
97-
for (const dir of claudeDirs) {
98-
const matches = await Array.fromAsync(
99-
CLAUDE_SKILL_GLOB.scan({
100-
cwd: dir,
101-
absolute: true,
102-
onlyFiles: true,
103-
followSymlinks: true,
104-
dot: true,
105-
}),
106-
).catch((error) => {
107-
log.error("failed .claude directory scan for skills", { dir, error })
108-
return []
109-
})
102+
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
103+
// Load global (home) first, then project-level (so project-level overwrites)
104+
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
105+
for (const dir of EXTERNAL_DIRS) {
106+
const root = path.join(Global.Path.home, dir)
107+
if (!(await Filesystem.isDir(root))) continue
108+
await scanExternal(root, "global")
109+
}
110110

111-
for (const match of matches) {
112-
await addSkill(match)
113-
}
111+
for await (const root of Filesystem.up({
112+
targets: EXTERNAL_DIRS,
113+
start: Instance.directory,
114+
stop: Instance.worktree,
115+
})) {
116+
await scanExternal(root, "project")
114117
}
115118
}
116119

packages/opencode/test/skill/skill.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,110 @@ test("returns empty array when no skills exist", async () => {
219219
},
220220
})
221221
})
222+
223+
test("discovers skills from .agents/skills/ directory", async () => {
224+
await using tmp = await tmpdir({
225+
git: true,
226+
init: async (dir) => {
227+
const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
228+
await Bun.write(
229+
path.join(skillDir, "SKILL.md"),
230+
`---
231+
name: agent-skill
232+
description: A skill in the .agents/skills directory.
233+
---
234+
235+
# Agent Skill
236+
`,
237+
)
238+
},
239+
})
240+
241+
await Instance.provide({
242+
directory: tmp.path,
243+
fn: async () => {
244+
const skills = await Skill.all()
245+
expect(skills.length).toBe(1)
246+
const agentSkill = skills.find((s) => s.name === "agent-skill")
247+
expect(agentSkill).toBeDefined()
248+
expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md")
249+
},
250+
})
251+
})
252+
253+
test("discovers global skills from ~/.agents/skills/ directory", async () => {
254+
await using tmp = await tmpdir({ git: true })
255+
256+
const originalHome = process.env.OPENCODE_TEST_HOME
257+
process.env.OPENCODE_TEST_HOME = tmp.path
258+
259+
try {
260+
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
261+
await fs.mkdir(skillDir, { recursive: true })
262+
await Bun.write(
263+
path.join(skillDir, "SKILL.md"),
264+
`---
265+
name: global-agent-skill
266+
description: A global skill from ~/.agents/skills for testing.
267+
---
268+
269+
# Global Agent Skill
270+
271+
This skill is loaded from the global home directory.
272+
`,
273+
)
274+
275+
await Instance.provide({
276+
directory: tmp.path,
277+
fn: async () => {
278+
const skills = await Skill.all()
279+
expect(skills.length).toBe(1)
280+
expect(skills[0].name).toBe("global-agent-skill")
281+
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
282+
expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
283+
},
284+
})
285+
} finally {
286+
process.env.OPENCODE_TEST_HOME = originalHome
287+
}
288+
})
289+
290+
test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
291+
await using tmp = await tmpdir({
292+
git: true,
293+
init: async (dir) => {
294+
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
295+
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
296+
await Bun.write(
297+
path.join(claudeDir, "SKILL.md"),
298+
`---
299+
name: claude-skill
300+
description: A skill in the .claude/skills directory.
301+
---
302+
303+
# Claude Skill
304+
`,
305+
)
306+
await Bun.write(
307+
path.join(agentDir, "SKILL.md"),
308+
`---
309+
name: agent-skill
310+
description: A skill in the .agents/skills directory.
311+
---
312+
313+
# Agent Skill
314+
`,
315+
)
316+
},
317+
})
318+
319+
await Instance.provide({
320+
directory: tmp.path,
321+
fn: async () => {
322+
const skills = await Skill.all()
323+
expect(skills.length).toBe(2)
324+
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
325+
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
326+
},
327+
})
328+
})

0 commit comments

Comments
 (0)