From dc64ce8ad4fd354426946e5a37cf0aca207b0635 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Dec 2025 21:31:25 -0600 Subject: [PATCH 1/5] tweak: read global claude skills too --- packages/opencode/src/skill/skill.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 16fa1d08f6..772a0d7427 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -4,6 +4,7 @@ import { Instance } from "../project/instance" import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" +import { Global } from "@/global" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -37,6 +38,9 @@ export namespace Skill { export const state = Instance.state(async () => { const directories = await Config.directories() + // include the global claude skills + directories.push(Global.Path.home) + const skills: Record = {} const addSkill = async (match: string) => { @@ -65,6 +69,16 @@ export namespace Skill { } for (const dir of directories) { + for await (const match of CLAUDE_SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + dot: true, + })) { + await addSkill(match) + } + for await (const match of OPENCODE_SKILL_GLOB.scan({ cwd: dir, absolute: true, @@ -75,16 +89,6 @@ export namespace Skill { } } - for await (const match of CLAUDE_SKILL_GLOB.scan({ - cwd: Instance.worktree, - absolute: true, - onlyFiles: true, - followSymlinks: true, - dot: true, - })) { - await addSkill(match) - } - return skills }) From 65f03894a859e5db2059a80e7757d62bf4584b58 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Dec 2025 23:24:17 -0600 Subject: [PATCH 2/5] tests --- packages/opencode/src/global/index.ts | 3 +- packages/opencode/src/skill/skill.ts | 45 +++++++++++------ packages/opencode/test/preload.ts | 21 ++++++++ packages/opencode/test/skill/skill.test.ts | 59 ++++++++++++++-------- 4 files changed, 90 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 2504a47dc5..9621cadd3a 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -12,7 +12,8 @@ const state = path.join(xdgState!, app) export namespace Global { export const Path = { - home: os.homedir(), + // Allow override via OPENCODE_TEST_HOME for test isolation + home: process.env.OPENCODE_TEST_HOME || os.homedir(), data, bin: path.join(data, "bin"), log: path.join(data, "log"), diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 772a0d7427..33158c4d9f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -5,6 +5,8 @@ import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { exists } from "fs/promises" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -34,13 +36,9 @@ export namespace Skill { ) const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") - const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md") + const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md") export const state = Instance.state(async () => { - const directories = await Config.directories() - // include the global claude skills - directories.push(Global.Path.home) - const skills: Record = {} const addSkill = async (match: string) => { @@ -68,7 +66,33 @@ export namespace Skill { } } - for (const dir of directories) { + // Scan .opencode/skill/ directories + for (const dir of await Config.directories()) { + for await (const match of OPENCODE_SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + })) { + await addSkill(match) + } + } + + // 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 exists(globalClaude)) { + claudeDirs.push(globalClaude) + } + + for (const dir of claudeDirs) { for await (const match of CLAUDE_SKILL_GLOB.scan({ cwd: dir, absolute: true, @@ -78,15 +102,6 @@ export namespace Skill { })) { await addSkill(match) } - - for await (const match of OPENCODE_SKILL_GLOB.scan({ - cwd: dir, - absolute: true, - onlyFiles: true, - followSymlinks: true, - })) { - await addSkill(match) - } } return skills diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index b6b6a66cfc..1161f86616 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -11,6 +11,27 @@ await fs.mkdir(dir, { recursive: true }) afterAll(() => { fsSync.rmSync(dir, { recursive: true, force: true }) }) +// Set test home directory to isolate tests from user's actual home directory +// This prevents tests from picking up real user configs/skills from ~/.claude/skills +const testHome = path.join(dir, "home") +await fs.mkdir(testHome, { recursive: true }) +process.env["OPENCODE_TEST_HOME"] = testHome + +// Create a global skill in ~/.claude/skills/ for testing +const globalSkillDir = path.join(testHome, ".claude", "skills", "global-test-skill") +await fs.mkdir(globalSkillDir, { recursive: true }) +await fs.writeFile( + path.join(globalSkillDir, "SKILL.md"), + `--- +name: global-test-skill +description: A global skill from ~/.claude/skills for testing. +--- + +# Global Test Skill + +This skill is loaded from the global home directory. +`, +) process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 1da8105bd8..5c9f6bc7cb 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -29,10 +29,12 @@ Instructions here. directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("test-skill") - expect(skills[0].description).toBe("A test skill for verification.") - expect(skills[0].location).toContain("skill/test-skill/SKILL.md") + // Should find local skill + global skill from test home + expect(skills.length).toBe(2) + const testSkill = skills.find((s) => s.name === "test-skill") + expect(testSkill).toBeDefined() + expect(testSkill!.description).toBe("A test skill for verification.") + expect(testSkill!.location).toContain("skill/test-skill/SKILL.md") }, }) }) @@ -59,8 +61,10 @@ description: Another test skill. directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("my-skill") + // Should find local skill + global skill from test home + expect(skills.length).toBe(2) + const mySkill = skills.find((s) => s.name === "my-skill") + expect(mySkill).toBeDefined() }, }) }) @@ -84,19 +88,9 @@ Just some content without YAML frontmatter. directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills).toEqual([]) - }, - }) -}) - -test("returns empty array when no skills exist", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills).toEqual([]) + // Should only find the global skill, not the one without frontmatter + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("global-test-skill") }, }) }) @@ -123,9 +117,30 @@ description: A skill in the .claude/skills directory. directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("claude-skill") - expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md") + // Should find both project-local and global skill + expect(skills.length).toBe(2) + const claudeSkill = skills.find((s) => s.name === "claude-skill") + const globalSkill = skills.find((s) => s.name === "global-test-skill") + expect(claudeSkill).toBeDefined() + expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md") + expect(globalSkill).toBeDefined() + expect(globalSkill!.description).toBe("A global skill from ~/.claude/skills for testing.") + }, + }) +}) + +test("discovers global skills from ~/.claude/skills/ directory", async () => { + // Create a project with no local skills - should still find global skill + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("global-test-skill") + expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md") }, }) }) From 83823959e3b8305609c4c63961d2a52dac2c9b38 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Dec 2025 23:29:27 -0600 Subject: [PATCH 3/5] tests --- packages/opencode/src/global/index.ts | 6 ++++-- packages/opencode/test/skill/skill.test.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 9621cadd3a..7be58634e1 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -13,14 +13,16 @@ const state = path.join(xdgState!, app) export namespace Global { export const Path = { // Allow override via OPENCODE_TEST_HOME for test isolation - home: process.env.OPENCODE_TEST_HOME || os.homedir(), + get home() { + return process.env.OPENCODE_TEST_HOME || os.homedir() + }, data, bin: path.join(data, "bin"), log: path.join(data, "log"), cache, config, state, - } as const + } } await Promise.all([ diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 5c9f6bc7cb..a797d28926 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -144,3 +144,23 @@ test("discovers global skills from ~/.claude/skills/ directory", async () => { }, }) }) + +test("returns empty array when no skills exist", async () => { + await using tmp = await tmpdir({ git: true }) + + // Override global home to a directory without any skills + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills).toEqual([]) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } +}) From dc32eda1e5c53886af10f36e14356f3f0823f551 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Dec 2025 23:34:29 -0600 Subject: [PATCH 4/5] tweak: load claude skills first so opencode ones override --- packages/opencode/src/skill/skill.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 33158c4d9f..fa6fd7e43e 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -66,18 +66,6 @@ export namespace Skill { } } - // Scan .opencode/skill/ directories - for (const dir of await Config.directories()) { - for await (const match of OPENCODE_SKILL_GLOB.scan({ - cwd: dir, - absolute: true, - onlyFiles: true, - followSymlinks: true, - })) { - await addSkill(match) - } - } - // Scan .claude/skills/ directories (project-level) const claudeDirs = await Array.fromAsync( Filesystem.up({ @@ -104,6 +92,18 @@ export namespace Skill { } } + // Scan .opencode/skill/ directories + for (const dir of await Config.directories()) { + for await (const match of OPENCODE_SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + })) { + await addSkill(match) + } + } + return skills }) From d3b820d0ae1ffe909e9e82bd6213b964b73ffe5e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Dec 2025 23:45:59 -0600 Subject: [PATCH 5/5] tweak: tests --- packages/opencode/test/preload.ts | 15 ---- packages/opencode/test/skill/skill.test.ts | 93 +++++++++++++--------- 2 files changed, 56 insertions(+), 52 deletions(-) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1161f86616..76d1329f40 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -17,21 +17,6 @@ const testHome = path.join(dir, "home") await fs.mkdir(testHome, { recursive: true }) process.env["OPENCODE_TEST_HOME"] = testHome -// Create a global skill in ~/.claude/skills/ for testing -const globalSkillDir = path.join(testHome, ".claude", "skills", "global-test-skill") -await fs.mkdir(globalSkillDir, { recursive: true }) -await fs.writeFile( - path.join(globalSkillDir, "SKILL.md"), - `--- -name: global-test-skill -description: A global skill from ~/.claude/skills for testing. ---- - -# Global Test Skill - -This skill is loaded from the global home directory. -`, -) process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index a797d28926..72415c1411 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,9 +1,26 @@ import { test, expect } from "bun:test" import { Skill } from "../../src/skill" -import { SystemPrompt } from "../../src/session/system" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" +import fs from "fs/promises" + +async function createGlobalSkill(homeDir: string) { + const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") + await fs.mkdir(skillDir, { recursive: true }) + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: global-test-skill +description: A global skill from ~/.claude/skills for testing. +--- + +# Global Test Skill + +This skill is loaded from the global home directory. +`, + ) +} test("discovers skills from .opencode/skill/ directory", async () => { await using tmp = await tmpdir({ @@ -29,8 +46,7 @@ Instructions here. directory: tmp.path, fn: async () => { const skills = await Skill.all() - // Should find local skill + global skill from test home - expect(skills.length).toBe(2) + expect(skills.length).toBe(1) const testSkill = skills.find((s) => s.name === "test-skill") expect(testSkill).toBeDefined() expect(testSkill!.description).toBe("A test skill for verification.") @@ -43,15 +59,26 @@ test("discovers multiple skills from .opencode/skill/ directory", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "my-skill") + const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one") + const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two") await Bun.write( - path.join(skillDir, "SKILL.md"), + path.join(skillDir1, "SKILL.md"), `--- -name: my-skill -description: Another test skill. +name: skill-one +description: First test skill. --- -# My Skill +# Skill One +`, + ) + await Bun.write( + path.join(skillDir2, "SKILL.md"), + `--- +name: skill-two +description: Second test skill. +--- + +# Skill Two `, ) }, @@ -61,10 +88,9 @@ description: Another test skill. directory: tmp.path, fn: async () => { const skills = await Skill.all() - // Should find local skill + global skill from test home expect(skills.length).toBe(2) - const mySkill = skills.find((s) => s.name === "my-skill") - expect(mySkill).toBeDefined() + expect(skills.find((s) => s.name === "skill-one")).toBeDefined() + expect(skills.find((s) => s.name === "skill-two")).toBeDefined() }, }) }) @@ -88,9 +114,7 @@ Just some content without YAML frontmatter. directory: tmp.path, fn: async () => { const skills = await Skill.all() - // Should only find the global skill, not the one without frontmatter - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("global-test-skill") + expect(skills).toEqual([]) }, }) }) @@ -117,50 +141,45 @@ description: A skill in the .claude/skills directory. directory: tmp.path, fn: async () => { const skills = await Skill.all() - // Should find both project-local and global skill - expect(skills.length).toBe(2) + expect(skills.length).toBe(1) const claudeSkill = skills.find((s) => s.name === "claude-skill") - const globalSkill = skills.find((s) => s.name === "global-test-skill") expect(claudeSkill).toBeDefined() expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md") - expect(globalSkill).toBeDefined() - expect(globalSkill!.description).toBe("A global skill from ~/.claude/skills for testing.") }, }) }) test("discovers global skills from ~/.claude/skills/ directory", async () => { - // Create a project with no local skills - should still find global skill await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("global-test-skill") - expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") - expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md") - }, - }) -}) - -test("returns empty array when no skills exist", async () => { - await using tmp = await tmpdir({ git: true }) - - // Override global home to a directory without any skills const originalHome = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = tmp.path try { + await createGlobalSkill(tmp.path) await Instance.provide({ directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills).toEqual([]) + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("global-test-skill") + expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md") }, }) } finally { process.env.OPENCODE_TEST_HOME = originalHome } }) + +test("returns empty array when no skills exist", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills).toEqual([]) + }, + }) +})