Skip to content

Commit abc7eed

Browse files
authored
tweak: read global claude skills too (anomalyco#6420)
1 parent 1670d22 commit abc7eed

File tree

4 files changed

+121
-39
lines changed

4 files changed

+121
-39
lines changed

packages/opencode/src/global/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ const state = path.join(xdgState!, app)
1212

1313
export namespace Global {
1414
export const Path = {
15-
home: os.homedir(),
15+
// Allow override via OPENCODE_TEST_HOME for test isolation
16+
get home() {
17+
return process.env.OPENCODE_TEST_HOME || os.homedir()
18+
},
1619
data,
1720
bin: path.join(data, "bin"),
1821
log: path.join(data, "log"),
1922
cache,
2023
config,
2124
state,
22-
} as const
25+
}
2326
}
2427

2528
await Promise.all([

packages/opencode/src/skill/skill.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { Instance } from "../project/instance"
44
import { NamedError } from "@opencode-ai/util/error"
55
import { ConfigMarkdown } from "../config/markdown"
66
import { Log } from "../util/log"
7+
import { Global } from "@/global"
8+
import { Filesystem } from "@/util/filesystem"
9+
import { exists } from "fs/promises"
710

811
export namespace Skill {
912
const log = Log.create({ service: "skill" })
@@ -33,10 +36,9 @@ export namespace Skill {
3336
)
3437

3538
const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
36-
const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md")
39+
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
3740

3841
export const state = Instance.state(async () => {
39-
const directories = await Config.directories()
4042
const skills: Record<string, Info> = {}
4143

4244
const addSkill = async (match: string) => {
@@ -64,25 +66,42 @@ export namespace Skill {
6466
}
6567
}
6668

67-
for (const dir of directories) {
68-
for await (const match of OPENCODE_SKILL_GLOB.scan({
69+
// Scan .claude/skills/ directories (project-level)
70+
const claudeDirs = await Array.fromAsync(
71+
Filesystem.up({
72+
targets: [".claude"],
73+
start: Instance.directory,
74+
stop: Instance.worktree,
75+
}),
76+
)
77+
// Also include global ~/.claude/skills/
78+
const globalClaude = `${Global.Path.home}/.claude`
79+
if (await exists(globalClaude)) {
80+
claudeDirs.push(globalClaude)
81+
}
82+
83+
for (const dir of claudeDirs) {
84+
for await (const match of CLAUDE_SKILL_GLOB.scan({
6985
cwd: dir,
7086
absolute: true,
7187
onlyFiles: true,
7288
followSymlinks: true,
89+
dot: true,
7390
})) {
7491
await addSkill(match)
7592
}
7693
}
7794

78-
for await (const match of CLAUDE_SKILL_GLOB.scan({
79-
cwd: Instance.worktree,
80-
absolute: true,
81-
onlyFiles: true,
82-
followSymlinks: true,
83-
dot: true,
84-
})) {
85-
await addSkill(match)
95+
// Scan .opencode/skill/ directories
96+
for (const dir of await Config.directories()) {
97+
for await (const match of OPENCODE_SKILL_GLOB.scan({
98+
cwd: dir,
99+
absolute: true,
100+
onlyFiles: true,
101+
followSymlinks: true,
102+
})) {
103+
await addSkill(match)
104+
}
86105
}
87106

88107
return skills

packages/opencode/test/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ await fs.mkdir(dir, { recursive: true })
1111
afterAll(() => {
1212
fsSync.rmSync(dir, { recursive: true, force: true })
1313
})
14+
// Set test home directory to isolate tests from user's actual home directory
15+
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
16+
const testHome = path.join(dir, "home")
17+
await fs.mkdir(testHome, { recursive: true })
18+
process.env["OPENCODE_TEST_HOME"] = testHome
19+
1420
process.env["XDG_DATA_HOME"] = path.join(dir, "share")
1521
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
1622
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")

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

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import { test, expect } from "bun:test"
22
import { Skill } from "../../src/skill"
3-
import { SystemPrompt } from "../../src/session/system"
43
import { Instance } from "../../src/project/instance"
54
import { tmpdir } from "../fixture/fixture"
65
import path from "path"
6+
import fs from "fs/promises"
7+
8+
async function createGlobalSkill(homeDir: string) {
9+
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
10+
await fs.mkdir(skillDir, { recursive: true })
11+
await Bun.write(
12+
path.join(skillDir, "SKILL.md"),
13+
`---
14+
name: global-test-skill
15+
description: A global skill from ~/.claude/skills for testing.
16+
---
17+
18+
# Global Test Skill
19+
20+
This skill is loaded from the global home directory.
21+
`,
22+
)
23+
}
724

825
test("discovers skills from .opencode/skill/ directory", async () => {
926
await using tmp = await tmpdir({
@@ -30,9 +47,10 @@ Instructions here.
3047
fn: async () => {
3148
const skills = await Skill.all()
3249
expect(skills.length).toBe(1)
33-
expect(skills[0].name).toBe("test-skill")
34-
expect(skills[0].description).toBe("A test skill for verification.")
35-
expect(skills[0].location).toContain("skill/test-skill/SKILL.md")
50+
const testSkill = skills.find((s) => s.name === "test-skill")
51+
expect(testSkill).toBeDefined()
52+
expect(testSkill!.description).toBe("A test skill for verification.")
53+
expect(testSkill!.location).toContain("skill/test-skill/SKILL.md")
3654
},
3755
})
3856
})
@@ -41,15 +59,26 @@ test("discovers multiple skills from .opencode/skill/ directory", async () => {
4159
await using tmp = await tmpdir({
4260
git: true,
4361
init: async (dir) => {
44-
const skillDir = path.join(dir, ".opencode", "skill", "my-skill")
62+
const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one")
63+
const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two")
4564
await Bun.write(
46-
path.join(skillDir, "SKILL.md"),
65+
path.join(skillDir1, "SKILL.md"),
4766
`---
48-
name: my-skill
49-
description: Another test skill.
67+
name: skill-one
68+
description: First test skill.
5069
---
5170
52-
# My Skill
71+
# Skill One
72+
`,
73+
)
74+
await Bun.write(
75+
path.join(skillDir2, "SKILL.md"),
76+
`---
77+
name: skill-two
78+
description: Second test skill.
79+
---
80+
81+
# Skill Two
5382
`,
5483
)
5584
},
@@ -59,8 +88,9 @@ description: Another test skill.
5988
directory: tmp.path,
6089
fn: async () => {
6190
const skills = await Skill.all()
62-
expect(skills.length).toBe(1)
63-
expect(skills[0].name).toBe("my-skill")
91+
expect(skills.length).toBe(2)
92+
expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
93+
expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
6494
},
6595
})
6696
})
@@ -89,18 +119,6 @@ Just some content without YAML frontmatter.
89119
})
90120
})
91121

92-
test("returns empty array when no skills exist", async () => {
93-
await using tmp = await tmpdir({ git: true })
94-
95-
await Instance.provide({
96-
directory: tmp.path,
97-
fn: async () => {
98-
const skills = await Skill.all()
99-
expect(skills).toEqual([])
100-
},
101-
})
102-
})
103-
104122
test("discovers skills from .claude/skills/ directory", async () => {
105123
await using tmp = await tmpdir({
106124
git: true,
@@ -124,8 +142,44 @@ description: A skill in the .claude/skills directory.
124142
fn: async () => {
125143
const skills = await Skill.all()
126144
expect(skills.length).toBe(1)
127-
expect(skills[0].name).toBe("claude-skill")
128-
expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
145+
const claudeSkill = skills.find((s) => s.name === "claude-skill")
146+
expect(claudeSkill).toBeDefined()
147+
expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md")
148+
},
149+
})
150+
})
151+
152+
test("discovers global skills from ~/.claude/skills/ directory", async () => {
153+
await using tmp = await tmpdir({ git: true })
154+
155+
const originalHome = process.env.OPENCODE_TEST_HOME
156+
process.env.OPENCODE_TEST_HOME = tmp.path
157+
158+
try {
159+
await createGlobalSkill(tmp.path)
160+
await Instance.provide({
161+
directory: tmp.path,
162+
fn: async () => {
163+
const skills = await Skill.all()
164+
expect(skills.length).toBe(1)
165+
expect(skills[0].name).toBe("global-test-skill")
166+
expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
167+
expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md")
168+
},
169+
})
170+
} finally {
171+
process.env.OPENCODE_TEST_HOME = originalHome
172+
}
173+
})
174+
175+
test("returns empty array when no skills exist", async () => {
176+
await using tmp = await tmpdir({ git: true })
177+
178+
await Instance.provide({
179+
directory: tmp.path,
180+
fn: async () => {
181+
const skills = await Skill.all()
182+
expect(skills).toEqual([])
129183
},
130184
})
131185
})

0 commit comments

Comments
 (0)