Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down Expand Up @@ -212,6 +213,7 @@ export namespace Agent {
model,
prompt,
tools,
skills,
description,
temperature,
top_p,
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ export namespace SessionPrompt {
sessionID,
system: [
...(await SystemPrompt.environment()),
...(await SystemPrompt.skills()),
...(await SystemPrompt.skills(agent.skills)),
...(await SystemPrompt.custom()),
],
messages: [
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>) {
const all = agentSkills ? await Skill.forAgent(agentSkills) : await Skill.all()
if (all.length === 0) return []

const lines = [
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -151,4 +152,44 @@ export namespace Skill {
export async function all(): Promise<Info[]> {
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<string, boolean> | 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<string, boolean> | undefined): Promise<Info[]> {
const skills = await all()
return filter(skills, agentSkills)
}
}
34 changes: 34 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
},
})
})
99 changes: 99 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
},
})
})