Skip to content

Commit 8fe0715

Browse files
authored
feat: add Agent Skills support (anomalyco#5921)
1 parent cb8af96 commit 8fe0715

5 files changed

Lines changed: 473 additions & 1 deletion

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,11 @@ export namespace SessionPrompt {
532532
agent,
533533
abort,
534534
sessionID,
535-
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
535+
system: [
536+
...(await SystemPrompt.environment()),
537+
...(await SystemPrompt.skills()),
538+
...(await SystemPrompt.custom()),
539+
],
536540
messages: [
537541
...MessageV2.toModelMessage(sessionMessages),
538542
...(isLastStep

packages/opencode/src/session/system.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
22
import { Global } from "../global"
33
import { Filesystem } from "../util/filesystem"
44
import { Config } from "../config/config"
5+
import { Skill } from "../skill"
56

67
import { Instance } from "../project/instance"
78
import path from "path"
@@ -117,4 +118,25 @@ export namespace SystemPrompt {
117118
)
118119
return Promise.all(found).then((result) => result.filter(Boolean))
119120
}
121+
122+
export async function skills() {
123+
const all = await Skill.all()
124+
if (all.length === 0) return []
125+
126+
const lines = [
127+
"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.",
128+
"",
129+
"<available_skills>",
130+
]
131+
for (const skill of all) {
132+
lines.push(" <skill>")
133+
lines.push(` <name>${skill.name}</name>`)
134+
lines.push(` <description>${skill.description}</description>`)
135+
lines.push(` <location>${skill.location}</location>`)
136+
lines.push(" </skill>")
137+
}
138+
lines.push("</available_skills>")
139+
140+
return [lines.join("\n")]
141+
}
120142
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./skill"
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import path from "path"
2+
import z from "zod"
3+
import { Config } from "../config/config"
4+
import { Filesystem } from "../util/filesystem"
5+
import { Instance } from "../project/instance"
6+
import { NamedError } from "@opencode-ai/util/error"
7+
import { ConfigMarkdown } from "../config/markdown"
8+
import { Log } from "../util/log"
9+
10+
export namespace Skill {
11+
const log = Log.create({ service: "skill" })
12+
13+
// Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
14+
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
15+
16+
export const Frontmatter = z.object({
17+
name: z
18+
.string()
19+
.min(1)
20+
.max(64)
21+
.refine((val) => NAME_REGEX.test(val), {
22+
message:
23+
"Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen",
24+
}),
25+
description: z.string().min(1).max(1024),
26+
license: z.string().optional(),
27+
compatibility: z.string().max(500).optional(),
28+
metadata: z.record(z.string(), z.string()).optional(),
29+
})
30+
31+
export type Frontmatter = z.infer<typeof Frontmatter>
32+
33+
export interface Info {
34+
name: string
35+
description: string
36+
location: string
37+
license?: string
38+
compatibility?: string
39+
metadata?: Record<string, string>
40+
}
41+
42+
export const InvalidError = NamedError.create(
43+
"SkillInvalidError",
44+
z.object({
45+
path: z.string(),
46+
message: z.string().optional(),
47+
issues: z.custom<z.core.$ZodIssue[]>().optional(),
48+
}),
49+
)
50+
51+
export const NameMismatchError = NamedError.create(
52+
"SkillNameMismatchError",
53+
z.object({
54+
path: z.string(),
55+
expected: z.string(),
56+
actual: z.string(),
57+
}),
58+
)
59+
60+
const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
61+
const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
62+
63+
async function discover(): Promise<string[]> {
64+
const directories = await Config.directories()
65+
66+
const paths: string[] = []
67+
68+
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
69+
for (const dir of directories) {
70+
for await (const match of SKILL_GLOB.scan({
71+
cwd: dir,
72+
absolute: true,
73+
onlyFiles: true,
74+
followSymlinks: true,
75+
})) {
76+
paths.push(match)
77+
}
78+
}
79+
80+
// Also scan .claude/skills/ walking up from cwd to worktree
81+
for await (const dir of Filesystem.up({
82+
targets: [".claude/skills"],
83+
start: Instance.directory,
84+
stop: Instance.worktree,
85+
})) {
86+
for await (const match of CLAUDE_SKILL_GLOB.scan({
87+
cwd: dir,
88+
absolute: true,
89+
onlyFiles: true,
90+
followSymlinks: true,
91+
})) {
92+
paths.push(match)
93+
}
94+
}
95+
96+
return paths
97+
}
98+
99+
async function load(skillMdPath: string): Promise<Info> {
100+
const md = await ConfigMarkdown.parse(skillMdPath)
101+
if (!md.data) {
102+
throw new InvalidError({
103+
path: skillMdPath,
104+
message: "SKILL.md must have YAML frontmatter",
105+
})
106+
}
107+
108+
const parsed = Frontmatter.safeParse(md.data)
109+
if (!parsed.success) {
110+
throw new InvalidError({
111+
path: skillMdPath,
112+
issues: parsed.error.issues,
113+
})
114+
}
115+
116+
const frontmatter = parsed.data
117+
const skillDir = path.dirname(skillMdPath)
118+
const dirName = path.basename(skillDir)
119+
120+
if (frontmatter.name !== dirName) {
121+
throw new NameMismatchError({
122+
path: skillMdPath,
123+
expected: dirName,
124+
actual: frontmatter.name,
125+
})
126+
}
127+
128+
return {
129+
name: frontmatter.name,
130+
description: frontmatter.description,
131+
location: skillMdPath,
132+
license: frontmatter.license,
133+
compatibility: frontmatter.compatibility,
134+
metadata: frontmatter.metadata,
135+
}
136+
}
137+
138+
export const state = Instance.state(async () => {
139+
const paths = await discover()
140+
const skills: Info[] = []
141+
142+
for (const skillPath of paths) {
143+
const info = await load(skillPath)
144+
log.info("loaded skill", { name: info.name, location: info.location })
145+
skills.push(info)
146+
}
147+
148+
return skills
149+
})
150+
151+
export async function all(): Promise<Info[]> {
152+
return state()
153+
}
154+
}

0 commit comments

Comments
 (0)