Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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`).
Expand All @@ -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))
}
}
}
}
}

Expand All @@ -96,7 +111,7 @@ export namespace Plugin {
})

export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "skill">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/skill/registry.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 37 additions & 18 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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,
Expand All @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions packages/opencode/test/skill/plugin-skill.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
11 changes: 11 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/web/src/content/docs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.