Compare commits

...

1 Commits

Author SHA1 Message Date
Dax Raad
77f463e4e5 feat: add Agent Skills support
Implement the Agent Skills standard (agentskills.io) for extending agent capabilities with procedural knowledge packages.

- Add Skill module with discovery, parsing, and validation
- Load skills from .opencode/skill/ (via Config.directories)
- Load skills from .claude/skills/ (via Filesystem.up)
- Inject available skills into system prompt as XML block
- Add 11 tests covering discovery, validation, and error cases
2025-12-21 19:40:51 -05:00
5 changed files with 473 additions and 1 deletions

View File

@@ -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

View File

@@ -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")]
}
}

View File

@@ -0,0 +1 @@
export * from "./skill"

View File

@@ -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()
}
}

View File

@@ -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")
},
})
})