diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 748851b4f8c..fabe3fa5128 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -583,7 +583,7 @@ export namespace SessionPrompt { mergeDeep(await ToolRegistry.enabled(input.agent)), mergeDeep(input.tools ?? {}), ) - for (const item of await ToolRegistry.tools(input.model.providerID)) { + for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { if (Wildcard.all(item.id, enabledTools) === false) continue const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 888460e84fb..41df88f8b6a 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -3,8 +3,10 @@ import { Config } from "../config/config" 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" }) export const Info = z.object({ name: z.string(), description: z.string(), @@ -50,6 +52,16 @@ export namespace Skill { const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) continue + + // Warn on duplicate skill names + if (skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: skills[parsed.data.name].location, + duplicate: match, + }) + } + skills[parsed.data.name] = { name: parsed.data.name, description: parsed.data.description, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 52d8e2cbb09..69a45432dc5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -115,7 +115,7 @@ export namespace ToolRegistry { return all().then((x) => x.map((t) => t.id)) } - export async function tools(providerID: string) { + export async function tools(providerID: string, agent?: Agent.Info) { const tools = await all() const result = await Promise.all( tools @@ -130,7 +130,7 @@ export namespace ToolRegistry { using _ = log.time(t.id) return { id: t.id, - ...(await t.init()), + ...(await t.init({ agent })), } }), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index ad5001da985..2503f76398b 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -7,78 +7,94 @@ import { Permission } from "../permission" import { Wildcard } from "../util/wildcard" import { ConfigMarkdown } from "../config/markdown" -export const SkillTool = Tool.define("skill", async () => { - const skills = 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.", - "", - ...skills.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` `, - ]), - "", - ].join(" "), - parameters: z.object({ - name: 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) +const parameters = z.object({ + name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), +}) - const skill = await Skill.get(params.name) +export const SkillTool: Tool.Info = { + id: "skill", + async init(ctx) { + const skills = await Skill.all() - if (!skill) { - const available = Skill.all().then((x) => Object.keys(x).join(", ")) - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } + // Filter skills by agent permissions if agent provided + let accessibleSkills = skills + if (ctx?.agent) { + const permissions = ctx.agent.permission.skill + accessibleSkills = skills.filter((skill) => { + const action = Wildcard.all(skill.name, permissions) + return action !== "deny" + }) + } - // Check permission using Wildcard.all on the skill ID - const permissions = agent.permission.skill - const action = Wildcard.all(params.name, permissions) + 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.", + "", + ...accessibleSkills.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` `, + ]), + "", + ].join(" "), + parameters, + async execute(params, ctx) { + const agent = await Agent.get(ctx.agent) - if (action === "deny") { - throw new Permission.RejectedError( - ctx.sessionID, - "skill", - ctx.callID, - { skill: params.name }, - `Access to skill "${params.name}" is denied for agent "${agent.name}".`, - ) - } + const skill = await Skill.get(params.name) - if (action === "ask") { - await Permission.ask({ - type: "skill", - pattern: params.name, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: `Load skill: ${skill.name}`, - metadata: { id: params.name, name: skill.name, description: skill.description }, - }) - } + if (!skill) { + const available = await Skill.all().then((x) => x.map((s) => s.name).join(", ")) + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } - // Load and parse skill content - const parsed = await ConfigMarkdown.parse(skill.location) - const dir = path.dirname(skill.location) + // Check permission using Wildcard.all on the skill name + const permissions = agent.permission.skill + const action = Wildcard.all(params.name, permissions) - // Format output similar to plugin pattern - const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n") + if (action === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "skill", + ctx.callID, + { skill: params.name }, + `Access to skill "${params.name}" is denied for agent "${agent.name}".`, + ) + } - return { - title: `Loaded skill: ${skill.name}`, - output, - metadata: { - name: skill.name, - dir, - }, - } - }, - } -}) + if (action === "ask") { + await Permission.ask({ + type: "skill", + pattern: params.name, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Load skill: ${skill.name}`, + metadata: { name: skill.name, description: skill.description }, + }) + } + + // Load and parse skill content + const parsed = await ConfigMarkdown.parse(skill.location) + const dir = path.dirname(skill.location) + + // Format output similar to plugin pattern + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join( + "\n", + ) + + return { + title: `Loaded skill: ${skill.name}`, + output, + metadata: { + name: skill.name, + dir, + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 80b6abe8c74..acee24902c1 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,11 +1,16 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" +import type { Agent } from "../agent/agent" export namespace Tool { interface Metadata { [key: string]: any } + export interface InitContext { + agent?: Agent.Info + } + export type Context = { sessionID: string messageID: string @@ -17,7 +22,7 @@ export namespace Tool { } export interface Info { id: string - init: () => Promise<{ + init: (ctx?: InitContext) => Promise<{ description: string parameters: Parameters execute( @@ -42,8 +47,8 @@ export namespace Tool { ): Info { return { id, - init: async () => { - const toolInfo = init instanceof Function ? await init() : init + init: async (ctx) => { + const toolInfo = init instanceof Function ? await init(ctx) : init const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { try { diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 217d4b3d25e..c1d433a839f 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -4,7 +4,7 @@ description: "Define reusable behavior via SKILL.md definitions" --- Agent skills let OpenCode discover reusable instructions from your repo or home directory. -When a conversation matches a skill, the agent is prompted to read its `SKILL.md`. +Skills are loaded on-demand via the native `skill` tool—agents see available skills and can load the full content when needed. --- @@ -97,24 +97,123 @@ Ask clarifying questions if the target versioning scheme is unclear. --- -## Recognize prompt injection +## Recognize tool description -OpenCode adds an `` XML block to the system prompt. -Each entry includes the skill name, description, and its discovered location. +OpenCode lists available skills in the `skill` tool description. +Each entry includes the skill name and description: ```xml git-release Create consistent releases and changelogs - .opencode/skill/git-release/SKILL.md ``` +The agent loads a skill by calling the tool: + +``` +skill({ name: "git-release" }) +``` + +--- + +## Configure permissions + +Control which skills agents can access using pattern-based permissions in `opencode.json`: + +```json +{ + "permission": { + "skill": { + "pr-review": "allow", + "internal-*": "deny", + "experimental-*": "ask", + "*": "allow" + } + } +} +``` + +| Permission | Behavior | +| ---------- | ----------------------------------------- | +| `allow` | Skill loads immediately | +| `deny` | Skill hidden from agent, access rejected | +| `ask` | User prompted for approval before loading | + +Patterns support wildcards: `internal-*` matches `internal-docs`, `internal-tools`, etc. + +--- + +## Override per agent + +Give specific agents different permissions than the global defaults. + +**For custom agents** (in agent frontmatter): + +```yaml +--- +permission: + skill: + "documents-*": "allow" +--- +``` + +**For built-in agents** (in `opencode.json`): + +```json +{ + "agent": { + "plan": { + "permission": { + "skill": { + "internal-*": "allow" + } + } + } + } +} +``` + +--- + +## Disable the skill tool + +Completely disable skills for agents that shouldn't use them: + +**For custom agents**: + +```yaml +--- +tools: + skill: false +--- +``` + +**For built-in agents**: + +```json +{ + "agent": { + "plan": { + "tools": { + "skill": false + } + } + } +} +``` + +When disabled, the `` section is omitted entirely. + --- ## Troubleshoot loading -If a skill does not show up, verify the folder name matches `name` exactly. -Also check that `SKILL.md` is spelled in all caps and includes frontmatter. +If a skill does not show up: + +1. Verify `SKILL.md` is spelled in all caps +2. Check that frontmatter includes `name` and `description` +3. Ensure skill names are unique across all locations +4. Check permissions—skills with `deny` are hidden from agents