From a9542e282c4c25970c642c61caddbb120644661b Mon Sep 17 00:00:00 2001 From: "M. Adel Alhashemi" Date: Mon, 22 Dec 2025 12:11:34 +0800 Subject: [PATCH] feat: add native skill tool with permission system - Add skill tool for loading skill content on-demand - Support pattern-based skill permissions (allow/deny/ask) - Enable agent-specific permission overrides - Support nested skills with path-based IDs (e.g., prompter/skill-creator) - Filter available_skills in system prompt by agent permissions - Preserve skill tool responses during compaction pruning --- packages/opencode/src/agent/agent.ts | 32 +++++++++ packages/opencode/src/config/config.ts | 2 + packages/opencode/src/session/compaction.ts | 3 + packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/system.ts | 25 +++++-- packages/opencode/src/skill/skill.ts | 38 ++++++---- packages/opencode/src/tool/registry.ts | 6 ++ packages/opencode/src/tool/skill.ts | 78 +++++++++++++++++++++ packages/opencode/test/skill/skill.test.ts | 4 +- 9 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/src/tool/skill.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 90c8594cd..ad665e5d6 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -30,6 +30,7 @@ export namespace Agent { permission: z.object({ edit: Config.Permission, bash: z.record(z.string(), Config.Permission), + skill: z.record(z.string(), Config.Permission), webfetch: Config.Permission.optional(), doom_loop: Config.Permission.optional(), external_directory: Config.Permission.optional(), @@ -58,6 +59,9 @@ export namespace Agent { bash: { "*": "allow", }, + skill: { + "*": "allow", + }, webfetch: "allow", doom_loop: "ask", external_directory: "ask", @@ -337,6 +341,17 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag "*": overridePermission.bash, } } + + if (typeof basePermission.skill === "string") { + basePermission.skill = { + "*": basePermission.skill, + } + } + if (typeof overridePermission.skill === "string") { + overridePermission.skill = { + "*": overridePermission.skill, + } + } const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any let mergedBash if (merged.bash) { @@ -354,10 +369,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag } } + let mergedSkill + if (merged.skill) { + if (typeof merged.skill === "string") { + mergedSkill = { + "*": merged.skill, + } + } else if (typeof merged.skill === "object") { + mergedSkill = mergeDeep( + { + "*": "allow", + }, + merged.skill, + ) + } + } + const result: Agent.Info["permission"] = { edit: merged.edit ?? "allow", webfetch: merged.webfetch ?? "allow", bash: mergedBash ?? { "*": "allow" }, + skill: mergedSkill ?? { "*": "allow" }, doom_loop: merged.doom_loop, external_directory: merged.external_directory, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 031bdd31b..1557f20f8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -414,6 +414,7 @@ export namespace Config { .object({ edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), + skill: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), @@ -761,6 +762,7 @@ export namespace Config { .object({ edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), + skill: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3e4c8020d..2d069384b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -61,6 +61,9 @@ export namespace SessionCompaction { const part = msg.parts[partIndex] if (part.type === "tool") if (part.state.status === "completed") { + // Skip skill tool responses - they contain important instructions + if (part.tool === "skill") continue + if (part.state.time.compacted) break loop const estimate = Token.estimate(part.state.output) total += estimate diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e393e2fab..e73e1aff9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -534,7 +534,7 @@ export namespace SessionPrompt { sessionID, system: [ ...(await SystemPrompt.environment()), - ...(await SystemPrompt.skills()), + ...(await SystemPrompt.skills(agent.name)), ...(await SystemPrompt.custom()), ], messages: [ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a9d0586b4..8b9d51604 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -119,20 +119,37 @@ export namespace SystemPrompt { return Promise.all(found).then((result) => result.filter(Boolean)) } - export async function skills() { + export async function skills(agentName?: string) { const all = await Skill.all() if (all.length === 0) return [] + // Filter skills by agent permission if agent name provided + let filtered = all + if (agentName) { + const { Agent } = await import("../agent/agent") + const { Wildcard } = await import("../util/wildcard") + const agent = await Agent.get(agentName) + if (agent) { + const permissions = agent.permission.skill + filtered = all.filter((skill) => { + const action = Wildcard.all(skill.id, permissions) + return action !== "deny" + }) + } + } + + if (filtered.length === 0) return [] + const lines = [ - "You have access to skills listed in ``. When a task matches a skill's description, read its SKILL.md file to get detailed instructions.", + "You have access to skills listed in ``. When a task matches a skill's description, use the skill tool to load detailed instructions.", "", "", ] - for (const skill of all) { + for (const skill of filtered) { lines.push(" ") + lines.push(` ${skill.id}`) lines.push(` ${skill.name}`) lines.push(` ${skill.description}`) - lines.push(` ${skill.location}`) lines.push(" ") } lines.push("") diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 88182c5de..d62d8297b 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -31,6 +31,7 @@ export namespace Skill { export type Frontmatter = z.infer export interface Info { + id: string // Path-based identifier (e.g., "code-review" or "docs/api-guide") name: string description: string location: string @@ -57,23 +58,29 @@ export namespace Skill { }), ) - const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md") - const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md") + const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") + const CLAUDE_SKILL_GLOB = new Bun.Glob("**/SKILL.md") - async function discover(): Promise { + interface DiscoveredSkill { + path: string + baseDir: string // The skill/ or .claude/skills/ directory + } + + async function discover(): Promise { const directories = await Config.directories() - const paths: string[] = [] + const results: DiscoveredSkill[] = [] // Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.) for (const dir of directories) { + const baseDir = path.join(dir, "skill") for await (const match of SKILL_GLOB.scan({ cwd: dir, absolute: true, onlyFiles: true, followSymlinks: true, })) { - paths.push(match) + results.push({ path: match, baseDir }) } } @@ -89,14 +96,15 @@ export namespace Skill { onlyFiles: true, followSymlinks: true, })) { - paths.push(match) + results.push({ path: match, baseDir: dir }) } } - return paths + return results } - async function load(skillMdPath: string): Promise { + async function load(discovered: DiscoveredSkill): Promise { + const skillMdPath = discovered.path const md = await ConfigMarkdown.parse(skillMdPath) if (!md.data) { throw new InvalidError({ @@ -125,7 +133,13 @@ export namespace Skill { }) } + // Generate path-based ID from relative path + // e.g., baseDir=/path/skill, skillDir=/path/skill/docs/api-guide → id=docs/api-guide + const relativePath = path.relative(discovered.baseDir, skillDir) + const id = relativePath.split(path.sep).join("/") // Normalize to forward slashes + return { + id, name: frontmatter.name, description: frontmatter.description, location: skillMdPath, @@ -136,12 +150,12 @@ export namespace Skill { } export const state = Instance.state(async () => { - const paths = await discover() + const discovered = 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 }) + for (const item of discovered) { + const info = await load(item) + log.info("loaded skill", { id: info.id, name: info.name, location: info.location }) skills.push(info) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 647c74267..4afb64f72 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" +import { SkillTool } from "./skill" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" @@ -102,6 +103,7 @@ export namespace ToolRegistry { TodoReadTool, WebSearchTool, CodeSearchTool, + SkillTool, ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...custom, ] @@ -148,6 +150,10 @@ export namespace ToolRegistry { result["codesearch"] = false result["websearch"] = false } + // Disable skill tool if all skills are denied + if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) { + result["skill"] = false + } return result } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts new file mode 100644 index 000000000..ec9984e1f --- /dev/null +++ b/packages/opencode/src/tool/skill.ts @@ -0,0 +1,78 @@ +import path from "path" +import z from "zod" +import { Tool } from "./tool" +import { Skill } from "../skill" +import { Agent } from "../agent/agent" +import { Permission } from "../permission" +import { Wildcard } from "../util/wildcard" +import { ConfigMarkdown } from "../config/markdown" + +export const SkillTool = Tool.define("skill", async () => { + const allSkills = await Skill.all() + + return { + description: [ + "Load a skill to get detailed instructions for a specific task.", + "Skills provide specialized knowledge and step-by-step guidance.", + "Use this when a task matches an available skill's description.", + ].join(" "), + parameters: z.object({ + id: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), + }), + async execute(params, ctx) { + const agent = await Agent.get(ctx.agent) + // Look up by id (path-based identifier) + const skill = allSkills.find((s) => s.id === params.id) + + if (!skill) { + const available = allSkills.map((s) => s.id).join(", ") + throw new Error(`Skill "${params.id}" not found. Available skills: ${available || "none"}`) + } + + // Check permission using Wildcard.all on the skill ID + const permissions = agent.permission.skill + const action = Wildcard.all(params.id, permissions) + + if (action === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "skill", + ctx.callID, + { skill: params.id }, + `Access to skill "${params.id}" is denied for agent "${agent.name}".`, + ) + } + + if (action === "ask") { + await Permission.ask({ + type: "skill", + pattern: params.id, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Load skill: ${skill.name}`, + metadata: { id: params.id, name: skill.name, description: skill.description }, + }) + } + + // Load and parse skill content + const parsed = await ConfigMarkdown.parse(skill.location) + const baseDir = path.dirname(skill.location) + + // Format output similar to plugin pattern + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${baseDir}`, "", parsed.content.trim()].join( + "\n", + ) + + return { + title: `Loaded skill: ${skill.name}`, + output, + metadata: { + id: skill.id, + name: skill.name, + baseDir, + }, + } + }, + } +}) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 3d7bc4c23..c1e0d3952 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -252,11 +252,11 @@ description: An example skill for testing XML output. const result = await SystemPrompt.skills() expect(result.length).toBe(1) expect(result[0]).toContain("") + expect(result[0]).toContain("example-skill") expect(result[0]).toContain("example-skill") expect(result[0]).toContain("An example skill for testing XML output.") - expect(result[0]).toContain("SKILL.md") expect(result[0]).toContain("") - expect(result[0]).toContain("When a task matches a skill's description") + expect(result[0]).toContain("use the skill tool") }, }) })