diff --git a/.opencode/skill/test-skill/SKILL.md b/.opencode/skill/test-skill/SKILL.md new file mode 100644 index 000000000..3fef059f2 --- /dev/null +++ b/.opencode/skill/test-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: test-skill +description: use this when asked to test skill +--- + +woah this is a test skill diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 172987875..3b0aefa28 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -6,6 +6,7 @@ import { FileCommand } from "./file" import { LSPCommand } from "./lsp" import { RipgrepCommand } from "./ripgrep" import { ScrapCommand } from "./scrap" +import { SkillCommand } from "./skill" import { SnapshotCommand } from "./snapshot" export const DebugCommand = cmd({ @@ -17,6 +18,7 @@ export const DebugCommand = cmd({ .command(RipgrepCommand) .command(FileCommand) .command(ScrapCommand) + .command(SkillCommand) .command(SnapshotCommand) .command(PathsCommand) .command({ diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts new file mode 100644 index 000000000..8079b688e --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -0,0 +1,15 @@ +import { EOL } from "os" +import { Skill } from "../../../skill" +import { bootstrap } from "../../bootstrap" +import { cmd } from "../cmd" + +export const SkillCommand = cmd({ + command: "skill", + builder: (yargs) => yargs, + async handler() { + await bootstrap(process.cwd(), async () => { + const skills = await Skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) + }) + }, +}) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 2d069384b..e1edfdf51 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -40,6 +40,8 @@ export namespace SessionCompaction { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 + const PRUNE_PROTECTED_TOOLS = ["skill"] + // goes backwards through parts until there are 40_000 tokens worth of tool // calls. then erases output of previous tool calls. idea is to throw away old // tool calls that are no longer relevant. @@ -61,8 +63,7 @@ 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 (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue if (part.state.time.compacted) break loop const estimate = Token.estimate(part.state.output) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index c23df3cd9..888460e84 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,44 +1,16 @@ -import path from "path" import z from "zod" import { Config } from "../config/config" -import { Filesystem } from "../util/filesystem" 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" }) - - // Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen - const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ - - export const Frontmatter = z.object({ - name: z - .string() - .min(1) - .max(64) - .refine((val) => NAME_REGEX.test(val), { - message: - "Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen", - }), - description: z.string().min(1).max(1024), - license: z.string().optional(), - compatibility: z.string().max(500).optional(), - metadata: z.record(z.string(), z.string()).optional(), + export const Info = z.object({ + name: z.string(), + description: z.string(), + location: z.string(), }) - - 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 - license?: string - compatibility?: string - metadata?: Record - } + export type Info = z.infer export const InvalidError = NamedError.create( "SkillInvalidError", @@ -59,110 +31,41 @@ export namespace Skill { ) const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") - // const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md") - interface DiscoveredSkill { - path: string - baseDir: string // The skill/ or .claude/skills/ directory - } - - async function discover(): Promise { + export const state = Instance.state(async () => { const directories = await Config.directories() + const skills: Record = {} - 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, })) { - results.push({ path: match, baseDir }) + const md = await ConfigMarkdown.parse(match) + if (!md) { + continue + } + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) continue + skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + } } } - // Also scan .claude/skills/ walking up from cwd to worktree - // for await (const dir of Filesystem.up({ - // targets: [".claude/skills"], - // start: Instance.directory, - // stop: Instance.worktree, - // })) { - // for await (const match of CLAUDE_SKILL_GLOB.scan({ - // cwd: dir, - // absolute: true, - // onlyFiles: true, - // followSymlinks: true, - // })) { - // paths.push(match) - // } - // } - - return results - } - - async function load(discovered: DiscoveredSkill): Promise { - const skillMdPath = discovered.path - const md = await ConfigMarkdown.parse(skillMdPath) - if (!md.data) { - throw new InvalidError({ - path: skillMdPath, - message: "SKILL.md must have YAML frontmatter", - }) - } - - const parsed = Frontmatter.safeParse(md.data) - if (!parsed.success) { - throw new InvalidError({ - path: skillMdPath, - issues: parsed.error.issues, - }) - } - - const frontmatter = parsed.data - const skillDir = path.dirname(skillMdPath) - const dirName = path.basename(skillDir) - - if (frontmatter.name !== dirName) { - throw new NameMismatchError({ - path: skillMdPath, - expected: dirName, - actual: frontmatter.name, - }) - } - - // 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, - license: frontmatter.license, - compatibility: frontmatter.compatibility, - metadata: frontmatter.metadata, - } - } - - export const state = Instance.state(async () => { - const discovered = await discover() - const skills: Info[] = [] - - 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) - } - return skills }) - export async function all(): Promise { - return state() + export async function get(name: string) { + return state().then((x) => x[name]) + } + + export async function all() { + return state().then((x) => Object.values(x)) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index ec9984e1f..ad5001da9 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -8,69 +8,75 @@ import { Wildcard } from "../util/wildcard" import { ConfigMarkdown } from "../config/markdown" export const SkillTool = Tool.define("skill", async () => { - const allSkills = await Skill.all() - + 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({ - id: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), + 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) - // Look up by id (path-based identifier) - const skill = allSkills.find((s) => s.id === params.id) + + const skill = await Skill.get(params.name) if (!skill) { - const available = allSkills.map((s) => s.id).join(", ") - throw new Error(`Skill "${params.id}" not found. Available skills: ${available || "none"}`) + const available = Skill.all().then((x) => Object.keys(x).join(", ")) + throw new Error(`Skill "${params.name}" 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) + const action = Wildcard.all(params.name, 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}".`, + { skill: params.name }, + `Access to skill "${params.name}" is denied for agent "${agent.name}".`, ) } if (action === "ask") { await Permission.ask({ type: "skill", - pattern: params.id, + pattern: params.name, sessionID: ctx.sessionID, messageID: ctx.messageID, callID: ctx.callID, title: `Load skill: ${skill.name}`, - metadata: { id: params.id, name: skill.name, description: skill.description }, + metadata: { id: params.name, name: skill.name, description: skill.description }, }) } // Load and parse skill content const parsed = await ConfigMarkdown.parse(skill.location) - const baseDir = path.dirname(skill.location) + const dir = path.dirname(skill.location) // Format output similar to plugin pattern - const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${baseDir}`, "", parsed.content.trim()].join( - "\n", - ) + const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n") return { title: `Loaded skill: ${skill.name}`, output, metadata: { - id: skill.id, name: skill.name, - baseDir, + dir, }, } }, diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 7b816e984..859e45880 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -65,55 +65,7 @@ description: Another test skill. }) }) -test("throws error for invalid skill name format", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "InvalidName") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: InvalidName -description: A skill with invalid name. ---- -`, - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(Skill.all()).rejects.toThrow() - }, - }) -}) - -test("throws error when name doesn't match directory", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "dir-name") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: different-name -description: A skill with mismatched name. ---- -`, - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(Skill.all()).rejects.toThrow("SkillNameMismatchError") - }, - }) -}) - -test("throws error for missing frontmatter", async () => { +test("skips skills with missing frontmatter", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { @@ -128,78 +80,11 @@ Just some content without YAML frontmatter. }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(Skill.all()).rejects.toThrow("SkillInvalidError") - }, - }) -}) - -test("parses optional fields", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "full-skill") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: full-skill -description: A skill with all optional fields. -license: MIT -compatibility: Requires Node.js 18+ -metadata: - author: test-author - version: "1.0" ---- - -# Full Skill -`, - ) - }, - }) - await Instance.provide({ directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].license).toBe("MIT") - expect(skills[0].compatibility).toBe("Requires Node.js 18+") - expect(skills[0].metadata).toEqual({ - author: "test-author", - version: "1.0", - }) - }, - }) -}) - -test("ignores unknown frontmatter fields", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "extra-fields") - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: extra-fields -description: A skill with extra unknown fields. -allowed-tools: Bash Read Write -some-other-field: ignored ---- - -# Extra Fields Skill -`, - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("extra-fields") + expect(skills).toEqual([]) }, }) }) @@ -252,7 +137,7 @@ 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("")