From 6e1f9db52ab1ea067ef11aecb3463b337daf7498 Mon Sep 17 00:00:00 2001 From: Igor Warzocha Date: Mon, 22 Dec 2025 20:11:03 +0000 Subject: [PATCH] feat(agent): add per-agent skills filtering Add ability to filter which skills are visible to each agent via the 'skills' field in agent configuration (opencode.json or markdown frontmatter). Supports two modes: - Whitelist mode: when any true values present, only matching skills shown - Blacklist mode: when only false values, exclude matching skills Examples: # Only show spreadsheets skill to excel agent skills: spreadsheets: true # Hide git skills from read-only agent skills: "git-*": false Uses same wildcard pattern matching as tools filtering. --- packages/opencode/src/agent/agent.ts | 7 ++ packages/opencode/src/config/config.ts | 6 ++ packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/system.ts | 4 +- packages/opencode/src/skill/skill.ts | 41 +++++++++ packages/opencode/test/agent/agent.test.ts | 34 ++++++++ packages/opencode/test/skill/skill.test.ts | 99 ++++++++++++++++++++++ 7 files changed, 190 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 90c8594cd77..02c40b6a6f6 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -42,6 +42,7 @@ export namespace Agent { .optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()), + skills: z.record(z.string(), z.boolean()).optional(), options: z.record(z.string(), z.any()), maxSteps: z.number().int().positive().optional(), }) @@ -212,6 +213,7 @@ export namespace Agent { model, prompt, tools, + skills, description, temperature, top_p, @@ -236,6 +238,11 @@ export namespace Agent { ...defaultTools, ...item.tools, } + if (skills) + item.skills = { + ...item.skills, + ...skills, + } if (description) item.description = description if (temperature != undefined) item.temperature = temperature if (top_p != undefined) item.topP = top_p diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index daf81f43461..e8479caa51d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -396,6 +396,12 @@ export namespace Config { top_p: z.number().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + skills: z + .record(z.string(), z.boolean()) + .optional() + .describe( + "Skills filtering: true to include, false to exclude. Supports wildcards (e.g., 'git-*': false). Default is all skills enabled.", + ), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e393e2fab9f..e5df04e6d78 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.skills)), ...(await SystemPrompt.custom()), ], messages: [ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a9d0586b40c..3b9d2d016a3 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -119,8 +119,8 @@ export namespace SystemPrompt { return Promise.all(found).then((result) => result.filter(Boolean)) } - export async function skills() { - const all = await Skill.all() + export async function skills(agentSkills?: Record) { + const all = agentSkills ? await Skill.forAgent(agentSkills) : await Skill.all() if (all.length === 0) return [] const lines = [ diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 88182c5de42..997101bcdca 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -6,6 +6,7 @@ import { Instance } from "../project/instance" import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" +import { Wildcard } from "../util/wildcard" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -151,4 +152,44 @@ export namespace Skill { export async function all(): Promise { return state() } + + /** + * Filter skills based on a pattern map. + * Uses the same wildcard logic as tools filtering. + * + * Behavior: + * - If patterns contain any `true` values, operates in whitelist mode: + * skills must explicitly match a `true` pattern to be included. + * - If patterns contain only `false` values, operates in blacklist mode: + * skills are included unless they match a `false` pattern. + * + * @param skills List of skills to filter + * @param patterns Record of patterns to boolean (true = include, false = exclude) + * @returns Filtered list of skills + */ + export function filter(skills: Info[], patterns: Record | undefined): Info[] { + if (!patterns || Object.keys(patterns).length === 0) return skills + + const hasIncludes = Object.values(patterns).some((v) => v === true) + + return skills.filter((skill) => { + const match = Wildcard.all(skill.name, patterns) + if (hasIncludes) { + // Whitelist mode: must explicitly match true + return match === true + } + // Blacklist mode: include unless explicitly false + return match !== false + }) + } + + /** + * Get skills filtered for a specific agent. + * @param agentSkills The agent's skills filter configuration + * @returns Filtered list of skills + */ + export async function forAgent(agentSkills: Record | undefined): Promise { + const skills = await all() + return filter(skills, agentSkills) + } } diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 222bf8367e6..22e11b192aa 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -144,3 +144,37 @@ Custom primary agent`, }, }) }) + +test("agent markdown frontmatter supports skills filtering", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const agentDir = path.join(opencodeDir, "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Bun.write( + path.join(agentDir, "restricted.md"), + `--- +mode: subagent +skills: + "*": false + "git-*": true +--- +Restricted agent with skills filter`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await Agent.list() + const restricted = agents.find((a) => a.name === "restricted") + expect(restricted).toBeDefined() + expect(restricted?.skills).toEqual({ + "*": false, + "git-*": true, + }) + }, + }) +}) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 3d7bc4c2361..627c242512a 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -289,3 +289,102 @@ description: A skill in the .claude/skills directory. }, }) }) + +test("Skill.filter() with undefined returns all skills", async () => { + const skills = [ + { name: "git-commit", description: "Git commit", location: "/path" }, + { name: "git-push", description: "Git push", location: "/path" }, + { name: "code-review", description: "Code review", location: "/path" }, + ] as Skill.Info[] + + const result = Skill.filter(skills, undefined) + expect(result).toEqual(skills) +}) + +test("Skill.filter() with empty object returns all skills", async () => { + const skills = [ + { name: "git-commit", description: "Git commit", location: "/path" }, + { name: "git-push", description: "Git push", location: "/path" }, + ] as Skill.Info[] + + const result = Skill.filter(skills, {}) + expect(result).toEqual(skills) +}) + +test("Skill.filter() excludes skills with false values (blacklist mode)", async () => { + const skills = [ + { name: "git-commit", description: "Git commit", location: "/path" }, + { name: "git-push", description: "Git push", location: "/path" }, + { name: "code-review", description: "Code review", location: "/path" }, + ] as Skill.Info[] + + const result = Skill.filter(skills, { "git-push": false }) + expect(result.length).toBe(2) + expect(result.map((s) => s.name)).toEqual(["git-commit", "code-review"]) +}) + +test("Skill.filter() supports wildcard patterns in blacklist mode", async () => { + const skills = [ + { name: "git-commit", description: "Git commit", location: "/path" }, + { name: "git-push", description: "Git push", location: "/path" }, + { name: "code-review", description: "Code review", location: "/path" }, + ] as Skill.Info[] + + const result = Skill.filter(skills, { "git-*": false }) + expect(result.length).toBe(1) + expect(result[0].name).toBe("code-review") +}) + +test("Skill.filter() with true values operates in whitelist mode", async () => { + const skills = [ + { name: "git-commit", description: "Git commit", location: "/path" }, + { name: "git-push", description: "Git push", location: "/path" }, + { name: "code-review", description: "Code review", location: "/path" }, + ] as Skill.Info[] + + // Only spreadsheets: true means ONLY show skills matching true patterns + const result = Skill.filter(skills, { "git-commit": true }) + expect(result.length).toBe(1) + expect(result[0].name).toBe("git-commit") +}) + +test("SystemPrompt.skills() filters skills when agent pattern provided", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir1 = path.join(dir, ".opencode", "skill", "git-commit") + await Bun.write( + path.join(skillDir1, "SKILL.md"), + `--- +name: git-commit +description: Git commit helper. +--- +`, + ) + const skillDir2 = path.join(dir, ".opencode", "skill", "code-review") + await Bun.write( + path.join(skillDir2, "SKILL.md"), + `--- +name: code-review +description: Code review helper. +--- +`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Without filter + const allResult = await SystemPrompt.skills() + expect(allResult[0]).toContain("git-commit") + expect(allResult[0]).toContain("code-review") + + // With filter excluding git-* + const filteredResult = await SystemPrompt.skills({ "git-*": false }) + expect(filteredResult[0]).not.toContain("git-commit") + expect(filteredResult[0]).toContain("code-review") + }, + }) +})