summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-02-03 15:51:54 -0500
committerGitHub <[email protected]>2026-02-03 15:51:54 -0500
commit17e62b050f744adcc9ca30f59ab9ed45ba3184f8 (patch)
treead8675054db35f4a763f0340e903e16d5acb2059
parentee84eb44eef681188a6c5bca53c5b01db1118114 (diff)
downloadopencode-17e62b050f744adcc9ca30f59ab9ed45ba3184f8.tar.gz
opencode-17e62b050f744adcc9ca30f59ab9ed45ba3184f8.zip
feat: add support for reading skills from .agents/skills directories (#11842)
Co-authored-by: Filip <[email protected]>
-rw-r--r--packages/opencode/src/flag/flag.ts2
-rw-r--r--packages/opencode/src/skill/skill.ts63
-rw-r--r--packages/opencode/test/skill/skill.test.ts107
3 files changed, 142 insertions, 30 deletions
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 64ae801d1..b11058b34 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -23,6 +23,8 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
+ export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
+ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 7ffc4d8a1..d8fdd22a4 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -40,8 +40,12 @@ export namespace Skill {
}),
)
+ // External skill directories to search for (project-level and global)
+ // These follow the directory layout used by Claude Code and other agents.
+ const EXTERNAL_DIRS = [".claude", ".agents"]
+ const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
+
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
- const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
export const state = Instance.state(async () => {
@@ -79,38 +83,37 @@ export namespace Skill {
}
}
- // Scan .claude/skills/ directories (project-level)
- const claudeDirs = await Array.fromAsync(
- Filesystem.up({
- targets: [".claude"],
- start: Instance.directory,
- stop: Instance.worktree,
- }),
- )
- // Also include global ~/.claude/skills/
- const globalClaude = `${Global.Path.home}/.claude`
- if (await Filesystem.isDir(globalClaude)) {
- claudeDirs.push(globalClaude)
+ const scanExternal = async (root: string, scope: "global" | "project") => {
+ return Array.fromAsync(
+ EXTERNAL_SKILL_GLOB.scan({
+ cwd: root,
+ absolute: true,
+ onlyFiles: true,
+ followSymlinks: true,
+ dot: true,
+ }),
+ )
+ .then((matches) => Promise.all(matches.map(addSkill)))
+ .catch((error) => {
+ log.error(`failed to scan ${scope} skills`, { dir: root, error })
+ })
}
- if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
- for (const dir of claudeDirs) {
- const matches = await Array.fromAsync(
- CLAUDE_SKILL_GLOB.scan({
- cwd: dir,
- absolute: true,
- onlyFiles: true,
- followSymlinks: true,
- dot: true,
- }),
- ).catch((error) => {
- log.error("failed .claude directory scan for skills", { dir, error })
- return []
- })
+ // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
+ // Load global (home) first, then project-level (so project-level overwrites)
+ if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+ for (const dir of EXTERNAL_DIRS) {
+ const root = path.join(Global.Path.home, dir)
+ if (!(await Filesystem.isDir(root))) continue
+ await scanExternal(root, "global")
+ }
- for (const match of matches) {
- await addSkill(match)
- }
+ for await (const root of Filesystem.up({
+ targets: EXTERNAL_DIRS,
+ start: Instance.directory,
+ stop: Instance.worktree,
+ })) {
+ await scanExternal(root, "project")
}
}
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
index 1d4828580..552cb932e 100644
--- a/packages/opencode/test/skill/skill.test.ts
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -219,3 +219,110 @@ test("returns empty array when no skills exist", async () => {
},
})
})
+
+test("discovers skills from .agents/skills/ directory", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: agent-skill
+description: A skill in the .agents/skills directory.
+---
+
+# Agent Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(1)
+ const agentSkill = skills.find((s) => s.name === "agent-skill")
+ expect(agentSkill).toBeDefined()
+ expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md")
+ },
+ })
+})
+
+test("discovers global skills from ~/.agents/skills/ directory", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const originalHome = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = tmp.path
+
+ try {
+ const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
+ await fs.mkdir(skillDir, { recursive: true })
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: global-agent-skill
+description: A global skill from ~/.agents/skills for testing.
+---
+
+# Global Agent Skill
+
+This skill is loaded from the global home directory.
+`,
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(1)
+ expect(skills[0].name).toBe("global-agent-skill")
+ expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
+ expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
+ },
+ })
+ } finally {
+ process.env.OPENCODE_TEST_HOME = originalHome
+ }
+})
+
+test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
+ const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
+ await Bun.write(
+ path.join(claudeDir, "SKILL.md"),
+ `---
+name: claude-skill
+description: A skill in the .claude/skills directory.
+---
+
+# Claude Skill
+`,
+ )
+ await Bun.write(
+ path.join(agentDir, "SKILL.md"),
+ `---
+name: agent-skill
+description: A skill in the .agents/skills directory.
+---
+
+# Agent Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(2)
+ expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
+ expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
+ },
+ })
+})