From 238abf4d72e49b6e53fbfc3486bf345df412c480 Mon Sep 17 00:00:00 2001 From: Kevin Wolf Date: Fri, 16 Jan 2026 20:28:45 -0600 Subject: [PATCH 1/2] feat(plugin): add skill support for plugins Allow plugins to package and expose skills by declaring paths in their hooks: export const MyPlugin: Plugin = async () => ({ skill: ['./skills/pdf', './skills/docs/*'] }) Skills are resolved relative to the plugin file and loaded with proper precedence (project skills override plugin skills override global skills). Includes tests for the new functionality. --- packages/opencode/src/plugin/index.ts | 17 ++- packages/opencode/src/skill/registry.ts | 15 +++ packages/opencode/src/skill/skill.ts | 55 ++++++--- .../opencode/test/skill/plugin-skill.test.ts | 112 ++++++++++++++++++ packages/plugin/src/index.ts | 11 ++ 5 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/src/skill/registry.ts create mode 100644 packages/opencode/test/skill/plugin-skill.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d..c42ae081103 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,9 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { SkillRegistry } from "../skill/registry" +import path from "path" +import { fileURLToPath } from "bun" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -76,6 +79,8 @@ export namespace Plugin { }) if (!plugin) continue } + const pluginPath = plugin.startsWith("file://") ? fileURLToPath(plugin) : plugin + const pluginDir = path.dirname(pluginPath) const mod = await import(plugin) // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). @@ -86,6 +91,16 @@ export namespace Plugin { seen.add(fn) const init = await fn(input) hooks.push(init) + if (init.skill && Array.isArray(init.skill)) { + for (const pattern of init.skill) { + const absolutePattern = path.resolve(pluginDir, pattern) + const glob = new Bun.Glob(path.join(absolutePattern, "SKILL.md")) + const matches = await Array.fromAsync(glob.scan({ absolute: true, onlyFiles: true })).catch(() => []) + for (const match of matches) { + SkillRegistry.register(path.dirname(match)) + } + } + } } } @@ -96,7 +111,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "skill">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { diff --git a/packages/opencode/src/skill/registry.ts b/packages/opencode/src/skill/registry.ts new file mode 100644 index 00000000000..75ecce9641e --- /dev/null +++ b/packages/opencode/src/skill/registry.ts @@ -0,0 +1,15 @@ +const skillPaths: string[] = [] + +export namespace SkillRegistry { + export function register(skillPath: string) { + skillPaths.push(skillPath) + } + + export function paths(): readonly string[] { + return skillPaths + } + + export function clear() { + skillPaths.length = 0 + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 6ae0e9fe887..b46536720be 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -11,6 +11,7 @@ import { Flag } from "@/flag/flag" import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" import { Session } from "@/session" +import { SkillRegistry } from "./registry" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -60,12 +61,11 @@ export namespace Skill { const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return - // Warn on duplicate skill names if (skills[parsed.data.name]) { - log.warn("duplicate skill name", { + log.info("skill override", { name: parsed.data.name, - existing: skills[parsed.data.name].location, - duplicate: match, + previous: skills[parsed.data.name].location, + new: match, }) } @@ -76,22 +76,42 @@ 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/ + // Priority 1 (lowest): Global ~/.claude/skills/ const globalClaude = `${Global.Path.home}/.claude` - if (await Filesystem.isDir(globalClaude)) { - claudeDirs.push(globalClaude) + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS && (await Filesystem.isDir(globalClaude))) { + const matches = await Array.fromAsync( + CLAUDE_SKILL_GLOB.scan({ + cwd: globalClaude, + absolute: true, + onlyFiles: true, + followSymlinks: true, + dot: true, + }), + ).catch((error) => { + log.error("failed global .claude directory scan for skills", { dir: globalClaude, error }) + return [] + }) + for (const match of matches) { + await addSkill(match) + } } + // Priority 2: Skills from plugins + for (const skillPath of SkillRegistry.paths()) { + const skillFile = path.join(skillPath, "SKILL.md") + await addSkill(skillFile) + } + + // Priority 3: Project-level .claude/skills/ directories if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) { - for (const dir of claudeDirs) { + const projectClaudeDirs = await Array.fromAsync( + Filesystem.up({ + targets: [".claude"], + start: Instance.directory, + stop: Instance.worktree, + }), + ) + for (const dir of projectClaudeDirs) { const matches = await Array.fromAsync( CLAUDE_SKILL_GLOB.scan({ cwd: dir, @@ -104,14 +124,13 @@ export namespace Skill { log.error("failed .claude directory scan for skills", { dir, error }) return [] }) - for (const match of matches) { await addSkill(match) } } } - // Scan .opencode/skill/ directories + // Priority 4 (highest): Project-level .opencode/skill/ directories for (const dir of await Config.directories()) { for await (const match of OPENCODE_SKILL_GLOB.scan({ cwd: dir, diff --git a/packages/opencode/test/skill/plugin-skill.test.ts b/packages/opencode/test/skill/plugin-skill.test.ts new file mode 100644 index 00000000000..b7401dc9d17 --- /dev/null +++ b/packages/opencode/test/skill/plugin-skill.test.ts @@ -0,0 +1,112 @@ +import { test, expect, beforeAll, afterAll, beforeEach } from "bun:test" +import { Skill } from "../../src/skill" +import { SkillRegistry } from "../../src/skill/registry" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +beforeEach(() => { + SkillRegistry.clear() +}) + +test("loads skills from SkillRegistry", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, "plugin-skills", "pdf") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: pdf +description: Instructions for working with PDF files. +--- + +# PDF Skill + +Instructions for PDF handling. +`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + SkillRegistry.register(path.join(tmp.path, "plugin-skills", "pdf")) + const skills = await Skill.all() + const pdfSkill = skills.find((s) => s.name === "pdf") + expect(pdfSkill).toBeDefined() + expect(pdfSkill!.description).toBe("Instructions for working with PDF files.") + }, + }) +}) + +test("project skills override plugin skills with same name", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const pluginSkillDir = path.join(dir, "plugin-skills", "pdf") + await Bun.write( + path.join(pluginSkillDir, "SKILL.md"), + `--- +name: pdf +description: Plugin PDF skill. +--- +# Plugin PDF +`, + ) + const projectSkillDir = path.join(dir, ".opencode", "skill", "pdf") + await Bun.write( + path.join(projectSkillDir, "SKILL.md"), + `--- +name: pdf +description: Project PDF skill (should win). +--- +# Project PDF +`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + SkillRegistry.register(path.join(tmp.path, "plugin-skills", "pdf")) + const skills = await Skill.all() + const pdfSkill = skills.find((s) => s.name === "pdf") + expect(pdfSkill).toBeDefined() + expect(pdfSkill!.description).toBe("Project PDF skill (should win).") + }, + }) +}) + +test("SkillRegistry stores and retrieves paths correctly", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(SkillRegistry.paths().length).toBe(0) + SkillRegistry.register("/path/to/skill1") + SkillRegistry.register("/path/to/skill2") + expect(SkillRegistry.paths().length).toBe(2) + expect(SkillRegistry.paths()).toContain("/path/to/skill1") + expect(SkillRegistry.paths()).toContain("/path/to/skill2") + }, + }) +}) + +test("SkillRegistry.clear removes all paths", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + SkillRegistry.register("/path/to/skill1") + SkillRegistry.register("/path/to/skill2") + expect(SkillRegistry.paths().length).toBe(2) + SkillRegistry.clear() + expect(SkillRegistry.paths().length).toBe(0) + }, + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 36a4657d74c..ff95608ffe5 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -151,6 +151,17 @@ export interface Hooks { tool?: { [key: string]: ToolDefinition } + /** + * Paths to skill directories, relative to the plugin file. + * Each path can be a direct folder path or a glob pattern. + * Skills must contain a SKILL.md file with frontmatter. + * + * @example + * ```ts + * skill: ["./skills/pdf", "./skills/docs/*"] + * ``` + */ + skill?: string[] auth?: AuthHook /** * Called when a new message is received From 8798865a79fbe5697c0175e773290b17255000b2 Mon Sep 17 00:00:00 2001 From: Kevin Wolf Date: Fri, 16 Jan 2026 20:36:26 -0600 Subject: [PATCH 2/2] docs: document how plugins can bundle skills Users can now distribute reusable instructions (skills) alongside their plugins, making it easier to share complete packages with tools and guidance together. --- packages/web/src/content/docs/plugins.mdx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index 66a1b3cad95..6d9a64ee7b8 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -359,3 +359,19 @@ Format as a structured prompt that a new agent can use to resume work. ``` When `output.prompt` is set, it completely replaces the default compaction prompt. The `output.context` array is ignored in this case. + +--- + +### Package skills + +Plugins can bundle and expose [skills](/docs/skills) for agents to use. This lets you distribute reusable instructions alongside your plugin. + +```ts title=".opencode/plugin/my-plugin.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const MyPlugin: Plugin = async () => ({ + skill: ["./skills/pdf", "./skills/docs/*"], +}) +``` + +Paths are relative to the plugin file and support glob patterns. Skills from plugins have lower precedence than project skills but higher than global skills.