Skip to content

Commit 046e351

Browse files
malhashemithdxr
andauthored
feat: add native skill tool with permission system (anomalyco#5930)
Co-authored-by: Dax Raad <d@ironbay.co>
1 parent b9029af commit 046e351

File tree

12 files changed

+180
-298
lines changed

12 files changed

+180
-298
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: test-skill
3+
description: use this when asked to test skill
4+
---
5+
6+
woah this is a test skill

packages/opencode/src/agent/agent.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export namespace Agent {
3030
permission: z.object({
3131
edit: Config.Permission,
3232
bash: z.record(z.string(), Config.Permission),
33+
skill: z.record(z.string(), Config.Permission),
3334
webfetch: Config.Permission.optional(),
3435
doom_loop: Config.Permission.optional(),
3536
external_directory: Config.Permission.optional(),
@@ -58,6 +59,9 @@ export namespace Agent {
5859
bash: {
5960
"*": "allow",
6061
},
62+
skill: {
63+
"*": "allow",
64+
},
6165
webfetch: "allow",
6266
doom_loop: "ask",
6367
external_directory: "ask",
@@ -337,6 +341,17 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
337341
"*": overridePermission.bash,
338342
}
339343
}
344+
345+
if (typeof basePermission.skill === "string") {
346+
basePermission.skill = {
347+
"*": basePermission.skill,
348+
}
349+
}
350+
if (typeof overridePermission.skill === "string") {
351+
overridePermission.skill = {
352+
"*": overridePermission.skill,
353+
}
354+
}
340355
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
341356
let mergedBash
342357
if (merged.bash) {
@@ -354,10 +369,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
354369
}
355370
}
356371

372+
let mergedSkill
373+
if (merged.skill) {
374+
if (typeof merged.skill === "string") {
375+
mergedSkill = {
376+
"*": merged.skill,
377+
}
378+
} else if (typeof merged.skill === "object") {
379+
mergedSkill = mergeDeep(
380+
{
381+
"*": "allow",
382+
},
383+
merged.skill,
384+
)
385+
}
386+
}
387+
357388
const result: Agent.Info["permission"] = {
358389
edit: merged.edit ?? "allow",
359390
webfetch: merged.webfetch ?? "allow",
360391
bash: mergedBash ?? { "*": "allow" },
392+
skill: mergedSkill ?? { "*": "allow" },
361393
doom_loop: merged.doom_loop,
362394
external_directory: merged.external_directory,
363395
}

packages/opencode/src/cli/cmd/debug/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FileCommand } from "./file"
66
import { LSPCommand } from "./lsp"
77
import { RipgrepCommand } from "./ripgrep"
88
import { ScrapCommand } from "./scrap"
9+
import { SkillCommand } from "./skill"
910
import { SnapshotCommand } from "./snapshot"
1011

1112
export const DebugCommand = cmd({
@@ -17,6 +18,7 @@ export const DebugCommand = cmd({
1718
.command(RipgrepCommand)
1819
.command(FileCommand)
1920
.command(ScrapCommand)
21+
.command(SkillCommand)
2022
.command(SnapshotCommand)
2123
.command(PathsCommand)
2224
.command({
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { EOL } from "os"
2+
import { Skill } from "../../../skill"
3+
import { bootstrap } from "../../bootstrap"
4+
import { cmd } from "../cmd"
5+
6+
export const SkillCommand = cmd({
7+
command: "skill",
8+
builder: (yargs) => yargs,
9+
async handler() {
10+
await bootstrap(process.cwd(), async () => {
11+
const skills = await Skill.all()
12+
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
13+
})
14+
},
15+
})

packages/opencode/src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ export namespace Config {
414414
.object({
415415
edit: Permission.optional(),
416416
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
417+
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
417418
webfetch: Permission.optional(),
418419
doom_loop: Permission.optional(),
419420
external_directory: Permission.optional(),
@@ -764,6 +765,7 @@ export namespace Config {
764765
.object({
765766
edit: Permission.optional(),
766767
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
768+
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
767769
webfetch: Permission.optional(),
768770
doom_loop: Permission.optional(),
769771
external_directory: Permission.optional(),

packages/opencode/src/session/compaction.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export namespace SessionCompaction {
4040
export const PRUNE_MINIMUM = 20_000
4141
export const PRUNE_PROTECT = 40_000
4242

43+
const PRUNE_PROTECTED_TOOLS = ["skill"]
44+
4345
// goes backwards through parts until there are 40_000 tokens worth of tool
4446
// calls. then erases output of previous tool calls. idea is to throw away old
4547
// tool calls that are no longer relevant.
@@ -61,6 +63,8 @@ export namespace SessionCompaction {
6163
const part = msg.parts[partIndex]
6264
if (part.type === "tool")
6365
if (part.state.status === "completed") {
66+
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
67+
6468
if (part.state.time.compacted) break loop
6569
const estimate = Token.estimate(part.state.output)
6670
total += estimate

packages/opencode/src/session/prompt.ts

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

packages/opencode/src/session/system.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import PROMPT_POLARIS from "./prompt/polaris.txt"
1414
import PROMPT_BEAST from "./prompt/beast.txt"
1515
import PROMPT_GEMINI from "./prompt/gemini.txt"
1616
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
17-
import PROMPT_COMPACTION from "./prompt/compaction.txt"
1817

1918
import PROMPT_CODEX from "./prompt/codex.txt"
2019
import type { Provider } from "@/provider/provider"
@@ -118,25 +117,4 @@ export namespace SystemPrompt {
118117
)
119118
return Promise.all(found).then((result) => result.filter(Boolean))
120119
}
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-
}
142120
}
Lines changed: 26 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,16 @@
1-
import path from "path"
21
import z from "zod"
32
import { Config } from "../config/config"
4-
import { Filesystem } from "../util/filesystem"
53
import { Instance } from "../project/instance"
64
import { NamedError } from "@opencode-ai/util/error"
75
import { ConfigMarkdown } from "../config/markdown"
8-
import { Log } from "../util/log"
96

107
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(),
8+
export const Info = z.object({
9+
name: z.string(),
10+
description: z.string(),
11+
location: z.string(),
2912
})
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-
}
13+
export type Info = z.infer<typeof Info>
4114

4215
export const InvalidError = NamedError.create(
4316
"SkillInvalidError",
@@ -57,98 +30,42 @@ export namespace Skill {
5730
}),
5831
)
5932

60-
const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
61-
// const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
33+
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
6234

63-
async function discover(): Promise<string[]> {
35+
export const state = Instance.state(async () => {
6436
const directories = await Config.directories()
37+
const skills: Record<string, Info> = {}
6538

66-
const paths: string[] = []
67-
68-
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
6939
for (const dir of directories) {
7040
for await (const match of SKILL_GLOB.scan({
7141
cwd: dir,
7242
absolute: true,
7343
onlyFiles: true,
7444
followSymlinks: true,
7545
})) {
76-
paths.push(match)
46+
const md = await ConfigMarkdown.parse(match)
47+
if (!md) {
48+
continue
49+
}
50+
51+
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
52+
if (!parsed.success) continue
53+
skills[parsed.data.name] = {
54+
name: parsed.data.name,
55+
description: parsed.data.description,
56+
location: match,
57+
}
7758
}
7859
}
7960

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-
14861
return skills
14962
})
15063

151-
export async function all(): Promise<Info[]> {
152-
return state()
64+
export async function get(name: string) {
65+
return state().then((x) => x[name])
66+
}
67+
68+
export async function all() {
69+
return state().then((x) => Object.values(x))
15370
}
15471
}

packages/opencode/src/tool/registry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
1010
import { WebFetchTool } from "./webfetch"
1111
import { WriteTool } from "./write"
1212
import { InvalidTool } from "./invalid"
13+
import { SkillTool } from "./skill"
1314
import type { Agent } from "../agent/agent"
1415
import { Tool } from "./tool"
1516
import { Instance } from "../project/instance"
@@ -103,6 +104,7 @@ export namespace ToolRegistry {
103104
TodoReadTool,
104105
WebSearchTool,
105106
CodeSearchTool,
107+
SkillTool,
106108
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
107109
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
108110
...custom,
@@ -150,6 +152,10 @@ export namespace ToolRegistry {
150152
result["codesearch"] = false
151153
result["websearch"] = false
152154
}
155+
// Disable skill tool if all skills are denied
156+
if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
157+
result["skill"] = false
158+
}
153159

154160
return result
155161
}

0 commit comments

Comments
 (0)