Skip to content

Commit db2739f

Browse files
committed
feat(skill): add support for custom skill directories via config
Allows users to specify additional directories to search for SKILL.md files using the `skill.paths` config option. Paths can be relative, absolute, or use ~ for home directory expansion. Closes #8533
1 parent 6b6d6e9 commit db2739f

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed

packages/opencode/src/config/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export namespace Config {
3232
if (target.instructions && source.instructions) {
3333
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
3434
}
35+
if (target.skill?.paths && source.skill?.paths) {
36+
merged.skill = merged.skill || {}
37+
merged.skill.paths = Array.from(new Set([...target.skill.paths, ...source.skill.paths]))
38+
}
3539
return merged
3640
}
3741

@@ -855,6 +859,12 @@ export namespace Config {
855859
})
856860
.optional(),
857861
plugin: z.string().array().optional(),
862+
skill: z
863+
.object({
864+
paths: z.string().array().optional().describe("Additional directories to search for SKILL.md files"),
865+
})
866+
.optional()
867+
.describe("Skill configuration"),
858868
snapshot: z.boolean().optional(),
859869
share: z
860870
.enum(["manual", "auto", "disabled"])

packages/opencode/src/skill/skill.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import z from "zod"
2+
import path from "path"
23
import { Config } from "../config/config"
34
import { Instance } from "../project/instance"
45
import { NamedError } from "@opencode-ai/util/error"
@@ -113,6 +114,34 @@ export namespace Skill {
113114
}
114115
}
115116

117+
// Scan custom skill paths from config
118+
const cfg = await Config.get()
119+
for (const raw of cfg.skill?.paths ?? []) {
120+
const resolved = raw.startsWith("~")
121+
? path.join(Global.Path.home, raw.slice(1))
122+
: path.isAbsolute(raw)
123+
? raw
124+
: path.resolve(Instance.directory, raw)
125+
126+
if (!(await Filesystem.isDir(resolved))) continue
127+
128+
const matches = await Array.fromAsync(
129+
OPENCODE_SKILL_GLOB.scan({
130+
cwd: resolved,
131+
absolute: true,
132+
onlyFiles: true,
133+
followSymlinks: true,
134+
}),
135+
).catch((error) => {
136+
log.error("failed custom skill directory scan", { dir: resolved, error })
137+
return []
138+
})
139+
140+
for (const match of matches) {
141+
await addSkill(match)
142+
}
143+
}
144+
116145
return skills
117146
})
118147

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

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,248 @@ test("returns empty array when no skills exist", async () => {
183183
},
184184
})
185185
})
186+
187+
test("discovers skills from custom paths in config", async () => {
188+
await using tmp = await tmpdir({
189+
git: true,
190+
init: async (dir) => {
191+
const customDir = path.join(dir, "my-custom-skills", "skill", "custom-skill")
192+
await Bun.write(
193+
path.join(customDir, "SKILL.md"),
194+
`---
195+
name: custom-skill
196+
description: A skill from a custom directory.
197+
---
198+
199+
# Custom Skill
200+
`,
201+
)
202+
await Bun.write(
203+
path.join(dir, "opencode.json"),
204+
JSON.stringify({
205+
$schema: "https://opencode.ai/config.json",
206+
skill: {
207+
paths: ["my-custom-skills"],
208+
},
209+
}),
210+
)
211+
},
212+
})
213+
214+
await Instance.provide({
215+
directory: tmp.path,
216+
fn: async () => {
217+
const skills = await Skill.all()
218+
expect(skills.length).toBe(1)
219+
const skill = skills.find((s) => s.name === "custom-skill")
220+
expect(skill).toBeDefined()
221+
expect(skill!.description).toBe("A skill from a custom directory.")
222+
},
223+
})
224+
})
225+
226+
test("resolves relative custom skill paths from project directory", async () => {
227+
await using tmp = await tmpdir({
228+
git: true,
229+
init: async (dir) => {
230+
const customDir = path.join(dir, "custom-skills", "skills", "relative-skill")
231+
await Bun.write(
232+
path.join(customDir, "SKILL.md"),
233+
`---
234+
name: relative-skill
235+
description: A skill from a relative path.
236+
---
237+
238+
# Relative Skill
239+
`,
240+
)
241+
await Bun.write(
242+
path.join(dir, "opencode.json"),
243+
JSON.stringify({
244+
$schema: "https://opencode.ai/config.json",
245+
skill: {
246+
paths: ["./custom-skills"],
247+
},
248+
}),
249+
)
250+
},
251+
})
252+
253+
await Instance.provide({
254+
directory: tmp.path,
255+
fn: async () => {
256+
const skills = await Skill.all()
257+
expect(skills.length).toBe(1)
258+
const skill = skills.find((s) => s.name === "relative-skill")
259+
expect(skill).toBeDefined()
260+
expect(skill!.location).toContain("custom-skills/skills/relative-skill/SKILL.md")
261+
},
262+
})
263+
})
264+
265+
test("expands ~ in custom skill paths", async () => {
266+
await using tmp = await tmpdir({ git: true })
267+
268+
const original = process.env.OPENCODE_TEST_HOME
269+
process.env.OPENCODE_TEST_HOME = tmp.path
270+
271+
try {
272+
const homeSkillDir = path.join(tmp.path, "test-skills", "skill", "home-skill")
273+
await fs.mkdir(homeSkillDir, { recursive: true })
274+
await Bun.write(
275+
path.join(homeSkillDir, "SKILL.md"),
276+
`---
277+
name: home-skill
278+
description: A skill from home directory expansion.
279+
---
280+
281+
# Home Skill
282+
`,
283+
)
284+
await Bun.write(
285+
path.join(tmp.path, "opencode.json"),
286+
JSON.stringify({
287+
$schema: "https://opencode.ai/config.json",
288+
skill: {
289+
paths: ["~/test-skills"],
290+
},
291+
}),
292+
)
293+
294+
await Instance.provide({
295+
directory: tmp.path,
296+
fn: async () => {
297+
const skills = await Skill.all()
298+
expect(skills.length).toBe(1)
299+
const skill = skills.find((s) => s.name === "home-skill")
300+
expect(skill).toBeDefined()
301+
expect(skill!.description).toBe("A skill from home directory expansion.")
302+
},
303+
})
304+
} finally {
305+
process.env.OPENCODE_TEST_HOME = original
306+
}
307+
})
308+
309+
test("handles absolute custom skill paths", async () => {
310+
await using tmp = await tmpdir({ git: true })
311+
312+
const absoluteDir = path.join(tmp.path, "absolute-skills", "skill", "abs-skill")
313+
await fs.mkdir(absoluteDir, { recursive: true })
314+
await Bun.write(
315+
path.join(absoluteDir, "SKILL.md"),
316+
`---
317+
name: abs-skill
318+
description: A skill from an absolute path.
319+
---
320+
321+
# Absolute Skill
322+
`,
323+
)
324+
await Bun.write(
325+
path.join(tmp.path, "opencode.json"),
326+
JSON.stringify({
327+
$schema: "https://opencode.ai/config.json",
328+
skill: {
329+
paths: [path.join(tmp.path, "absolute-skills")],
330+
},
331+
}),
332+
)
333+
334+
await Instance.provide({
335+
directory: tmp.path,
336+
fn: async () => {
337+
const skills = await Skill.all()
338+
expect(skills.length).toBe(1)
339+
const skill = skills.find((s) => s.name === "abs-skill")
340+
expect(skill).toBeDefined()
341+
expect(skill!.location).toContain("absolute-skills/skill/abs-skill/SKILL.md")
342+
},
343+
})
344+
})
345+
346+
test("gracefully skips non-existent custom paths", async () => {
347+
await using tmp = await tmpdir({
348+
git: true,
349+
init: async (dir) => {
350+
const skillDir = path.join(dir, ".opencode", "skill", "existing-skill")
351+
await Bun.write(
352+
path.join(skillDir, "SKILL.md"),
353+
`---
354+
name: existing-skill
355+
description: An existing skill.
356+
---
357+
358+
# Existing Skill
359+
`,
360+
)
361+
await Bun.write(
362+
path.join(dir, "opencode.json"),
363+
JSON.stringify({
364+
$schema: "https://opencode.ai/config.json",
365+
skill: {
366+
paths: ["non-existent-path", "another-missing-dir"],
367+
},
368+
}),
369+
)
370+
},
371+
})
372+
373+
await Instance.provide({
374+
directory: tmp.path,
375+
fn: async () => {
376+
const skills = await Skill.all()
377+
expect(skills.length).toBe(1)
378+
expect(skills[0].name).toBe("existing-skill")
379+
},
380+
})
381+
})
382+
383+
test("merges custom paths with default paths", async () => {
384+
await using tmp = await tmpdir({
385+
git: true,
386+
init: async (dir) => {
387+
const defaultDir = path.join(dir, ".opencode", "skill", "default-skill")
388+
await Bun.write(
389+
path.join(defaultDir, "SKILL.md"),
390+
`---
391+
name: default-skill
392+
description: A skill from default path.
393+
---
394+
395+
# Default Skill
396+
`,
397+
)
398+
const customDir = path.join(dir, "custom-skills", "skill", "custom-skill")
399+
await Bun.write(
400+
path.join(customDir, "SKILL.md"),
401+
`---
402+
name: custom-skill
403+
description: A skill from custom path.
404+
---
405+
406+
# Custom Skill
407+
`,
408+
)
409+
await Bun.write(
410+
path.join(dir, "opencode.json"),
411+
JSON.stringify({
412+
$schema: "https://opencode.ai/config.json",
413+
skill: {
414+
paths: ["custom-skills"],
415+
},
416+
}),
417+
)
418+
},
419+
})
420+
421+
await Instance.provide({
422+
directory: tmp.path,
423+
fn: async () => {
424+
const skills = await Skill.all()
425+
expect(skills.length).toBe(2)
426+
expect(skills.find((s) => s.name === "default-skill")).toBeDefined()
427+
expect(skills.find((s) => s.name === "custom-skill")).toBeDefined()
428+
},
429+
})
430+
})

0 commit comments

Comments
 (0)