summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2025-12-21 19:44:56 -0500
committerGitHub <[email protected]>2025-12-21 19:44:56 -0500
commit8fe07159282589d81887e0c5a0a0924fc3649060 (patch)
treeb47d61db46600a0eb8264ec8bbfa10e94fbb6831
parentcb8af962cdf20a4d4e0aff3f0db9e70de14d1c84 (diff)
downloadopencode-8fe07159282589d81887e0c5a0a0924fc3649060.tar.gz
opencode-8fe07159282589d81887e0c5a0a0924fc3649060.zip
feat: add Agent Skills support (#5921)
-rw-r--r--packages/opencode/src/session/prompt.ts6
-rw-r--r--packages/opencode/src/session/system.ts22
-rw-r--r--packages/opencode/src/skill/index.ts1
-rw-r--r--packages/opencode/src/skill/skill.ts154
-rw-r--r--packages/opencode/test/skill/skill.test.ts291
5 files changed, 473 insertions, 1 deletions
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 748851b4f..e393e2fab 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -532,7 +532,11 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
- system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
+ system: [
+ ...(await SystemPrompt.environment()),
+ ...(await SystemPrompt.skills()),
+ ...(await SystemPrompt.custom()),
+ ],
messages: [
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index e15185b38..a9d0586b4 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
+import { Skill } from "../skill"
import { Instance } from "../project/instance"
import path from "path"
@@ -117,4 +118,25 @@ export namespace SystemPrompt {
)
return Promise.all(found).then((result) => result.filter(Boolean))
}
+
+ export async function skills() {
+ const all = await Skill.all()
+ if (all.length === 0) return []
+
+ const lines = [
+ "You have access to skills listed in `<available_skills>`. When a task matches a skill's description, read its SKILL.md file to get detailed instructions.",
+ "",
+ "<available_skills>",
+ ]
+ for (const skill of all) {
+ lines.push(" <skill>")
+ lines.push(` <name>${skill.name}</name>`)
+ lines.push(` <description>${skill.description}</description>`)
+ lines.push(` <location>${skill.location}</location>`)
+ lines.push(" </skill>")
+ }
+ lines.push("</available_skills>")
+
+ return [lines.join("\n")]
+ }
}
diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts
new file mode 100644
index 000000000..67bef3bd3
--- /dev/null
+++ b/packages/opencode/src/skill/index.ts
@@ -0,0 +1 @@
+export * from "./skill"
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
new file mode 100644
index 000000000..88182c5de
--- /dev/null
+++ b/packages/opencode/src/skill/skill.ts
@@ -0,0 +1,154 @@
+import path from "path"
+import z from "zod"
+import { Config } from "../config/config"
+import { Filesystem } from "../util/filesystem"
+import { Instance } from "../project/instance"
+import { NamedError } from "@opencode-ai/util/error"
+import { ConfigMarkdown } from "../config/markdown"
+import { Log } from "../util/log"
+
+export namespace Skill {
+ const log = Log.create({ service: "skill" })
+
+ // Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
+ const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
+
+ export const Frontmatter = z.object({
+ name: z
+ .string()
+ .min(1)
+ .max(64)
+ .refine((val) => NAME_REGEX.test(val), {
+ message:
+ "Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen",
+ }),
+ description: z.string().min(1).max(1024),
+ license: z.string().optional(),
+ compatibility: z.string().max(500).optional(),
+ metadata: z.record(z.string(), z.string()).optional(),
+ })
+
+ export type Frontmatter = z.infer<typeof Frontmatter>
+
+ export interface Info {
+ name: string
+ description: string
+ location: string
+ license?: string
+ compatibility?: string
+ metadata?: Record<string, string>
+ }
+
+ export const InvalidError = NamedError.create(
+ "SkillInvalidError",
+ z.object({
+ path: z.string(),
+ message: z.string().optional(),
+ issues: z.custom<z.core.$ZodIssue[]>().optional(),
+ }),
+ )
+
+ export const NameMismatchError = NamedError.create(
+ "SkillNameMismatchError",
+ z.object({
+ path: z.string(),
+ expected: z.string(),
+ actual: z.string(),
+ }),
+ )
+
+ const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
+ const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
+
+ async function discover(): Promise<string[]> {
+ const directories = await Config.directories()
+
+ const paths: string[] = []
+
+ // Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
+ for (const dir of directories) {
+ for await (const match of SKILL_GLOB.scan({
+ cwd: dir,
+ absolute: true,
+ onlyFiles: true,
+ followSymlinks: true,
+ })) {
+ paths.push(match)
+ }
+ }
+
+ // Also scan .claude/skills/ walking up from cwd to worktree
+ for await (const dir of Filesystem.up({
+ targets: [".claude/skills"],
+ start: Instance.directory,
+ stop: Instance.worktree,
+ })) {
+ for await (const match of CLAUDE_SKILL_GLOB.scan({
+ cwd: dir,
+ absolute: true,
+ onlyFiles: true,
+ followSymlinks: true,
+ })) {
+ paths.push(match)
+ }
+ }
+
+ return paths
+ }
+
+ async function load(skillMdPath: string): Promise<Info> {
+ const md = await ConfigMarkdown.parse(skillMdPath)
+ if (!md.data) {
+ throw new InvalidError({
+ path: skillMdPath,
+ message: "SKILL.md must have YAML frontmatter",
+ })
+ }
+
+ const parsed = Frontmatter.safeParse(md.data)
+ if (!parsed.success) {
+ throw new InvalidError({
+ path: skillMdPath,
+ issues: parsed.error.issues,
+ })
+ }
+
+ const frontmatter = parsed.data
+ const skillDir = path.dirname(skillMdPath)
+ const dirName = path.basename(skillDir)
+
+ if (frontmatter.name !== dirName) {
+ throw new NameMismatchError({
+ path: skillMdPath,
+ expected: dirName,
+ actual: frontmatter.name,
+ })
+ }
+
+ return {
+ name: frontmatter.name,
+ description: frontmatter.description,
+ location: skillMdPath,
+ license: frontmatter.license,
+ compatibility: frontmatter.compatibility,
+ metadata: frontmatter.metadata,
+ }
+ }
+
+ export const state = Instance.state(async () => {
+ const paths = await discover()
+ const skills: Info[] = []
+
+ for (const skillPath of paths) {
+ const info = await load(skillPath)
+ log.info("loaded skill", { name: info.name, location: info.location })
+ skills.push(info)
+ }
+
+ return skills
+ })
+
+ export async function all(): Promise<Info[]> {
+ return state()
+ }
+}
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
new file mode 100644
index 000000000..3d7bc4c23
--- /dev/null
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -0,0 +1,291 @@
+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"
+
+test("discovers skills from .opencode/skill/ directory", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: test-skill
+description: A test skill for verification.
+---
+
+# Test Skill
+
+Instructions here.
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ 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")
+ },
+ })
+})
+
+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")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: my-skill
+description: Another test skill.
+---
+
+# My Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(1)
+ expect(skills[0].name).toBe("my-skill")
+ },
+ })
+})
+
+test("throws error for invalid skill name format", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "InvalidName")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: InvalidName
+description: A skill with invalid name.
+---
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(Skill.all()).rejects.toThrow()
+ },
+ })
+})
+
+test("throws error when name doesn't match directory", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "dir-name")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: different-name
+description: A skill with mismatched name.
+---
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(Skill.all()).rejects.toThrow("SkillNameMismatchError")
+ },
+ })
+})
+
+test("throws error for missing frontmatter", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `# No Frontmatter
+
+Just some content without YAML frontmatter.
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(Skill.all()).rejects.toThrow("SkillInvalidError")
+ },
+ })
+})
+
+test("parses optional fields", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "full-skill")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: full-skill
+description: A skill with all optional fields.
+license: MIT
+compatibility: Requires Node.js 18+
+metadata:
+ author: test-author
+ version: "1.0"
+---
+
+# Full Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(1)
+ expect(skills[0].license).toBe("MIT")
+ expect(skills[0].compatibility).toBe("Requires Node.js 18+")
+ expect(skills[0].metadata).toEqual({
+ author: "test-author",
+ version: "1.0",
+ })
+ },
+ })
+})
+
+test("ignores unknown frontmatter fields", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "extra-fields")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: extra-fields
+description: A skill with extra unknown fields.
+allowed-tools: Bash Read Write
+some-other-field: ignored
+---
+
+# Extra Fields Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const skills = await Skill.all()
+ expect(skills.length).toBe(1)
+ expect(skills[0].name).toBe("extra-fields")
+ },
+ })
+})
+
+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([])
+ },
+ })
+})
+
+test("SystemPrompt.skills() returns empty array when no skills", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await SystemPrompt.skills()
+ expect(result).toEqual([])
+ },
+ })
+})
+
+test("SystemPrompt.skills() returns XML block with skills", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".opencode", "skill", "example-skill")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: example-skill
+description: An example skill for testing XML output.
+---
+
+# Example
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await SystemPrompt.skills()
+ expect(result.length).toBe(1)
+ expect(result[0]).toContain("<available_skills>")
+ expect(result[0]).toContain("<name>example-skill</name>")
+ expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
+ expect(result[0]).toContain("SKILL.md</location>")
+ expect(result[0]).toContain("</available_skills>")
+ expect(result[0]).toContain("When a task matches a skill's description")
+ },
+ })
+})
+
+test("discovers skills from .claude/skills/ directory", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: claude-skill
+description: A skill in the .claude/skills directory.
+---
+
+# Claude Skill
+`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ 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")
+ },
+ })
+})