This commit is contained in:
Dax Raad 2025-12-22 18:20:19 -05:00
parent cd9eeb7d41
commit 9a5dd18c49
7 changed files with 78 additions and 260 deletions

View file

@ -0,0 +1,6 @@
---
name: test-skill
description: use this when asked to test skill
---
woah this is a test skill

View file

@ -6,6 +6,7 @@ import { FileCommand } from "./file"
import { LSPCommand } from "./lsp" import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep" import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap" import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot" import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({ export const DebugCommand = cmd({
@ -17,6 +18,7 @@ export const DebugCommand = cmd({
.command(RipgrepCommand) .command(RipgrepCommand)
.command(FileCommand) .command(FileCommand)
.command(ScrapCommand) .command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand) .command(SnapshotCommand)
.command(PathsCommand) .command(PathsCommand)
.command({ .command({

View file

@ -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)
})
},
})

View file

@ -40,6 +40,8 @@ export namespace SessionCompaction {
export const PRUNE_MINIMUM = 20_000 export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_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 // 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 // calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant. // tool calls that are no longer relevant.
@ -61,8 +63,7 @@ export namespace SessionCompaction {
const part = msg.parts[partIndex] const part = msg.parts[partIndex]
if (part.type === "tool") if (part.type === "tool")
if (part.state.status === "completed") { if (part.state.status === "completed") {
// Skip skill tool responses - they contain important instructions if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.tool === "skill") continue
if (part.state.time.compacted) break loop if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output) const estimate = Token.estimate(part.state.output)

View file

@ -1,44 +1,16 @@
import path from "path"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
export namespace Skill { export namespace Skill {
const log = Log.create({ service: "skill" }) export const Info = z.object({
name: z.string(),
// Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen description: z.string(),
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ location: z.string(),
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 type Info = z.infer<typeof Info>
export type Frontmatter = z.infer<typeof Frontmatter>
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<string, string>
}
export const InvalidError = NamedError.create( export const InvalidError = NamedError.create(
"SkillInvalidError", "SkillInvalidError",
@ -59,110 +31,41 @@ export namespace Skill {
) )
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
// const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
interface DiscoveredSkill { export const state = Instance.state(async () => {
path: string
baseDir: string // The skill/ or .claude/skills/ directory
}
async function discover(): Promise<DiscoveredSkill[]> {
const directories = await Config.directories() const directories = await Config.directories()
const skills: Record<string, Info> = {}
const results: DiscoveredSkill[] = []
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
for (const dir of directories) { for (const dir of directories) {
const baseDir = path.join(dir, "skill")
for await (const match of SKILL_GLOB.scan({ for await (const match of SKILL_GLOB.scan({
cwd: dir, cwd: dir,
absolute: true, absolute: true,
onlyFiles: true, onlyFiles: true,
followSymlinks: 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<Info> {
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 return skills
}) })
export async function all(): Promise<Info[]> { export async function get(name: string) {
return state() return state().then((x) => x[name])
}
export async function all() {
return state().then((x) => Object.values(x))
} }
} }

View file

@ -8,69 +8,75 @@ import { Wildcard } from "../util/wildcard"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
export const SkillTool = Tool.define("skill", async () => { export const SkillTool = Tool.define("skill", async () => {
const allSkills = await Skill.all() const skills = await Skill.all()
return { return {
description: [ description: [
"Load a skill to get detailed instructions for a specific task.", "Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.", "Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.", "Use this when a task matches an available skill's description.",
"<available_skills>",
...skills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" "), ].join(" "),
parameters: z.object({ 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) { async execute(params, ctx) {
const agent = await Agent.get(ctx.agent) 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) { if (!skill) {
const available = allSkills.map((s) => s.id).join(", ") const available = Skill.all().then((x) => Object.keys(x).join(", "))
throw new Error(`Skill "${params.id}" not found. Available skills: ${available || "none"}`) throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
} }
// Check permission using Wildcard.all on the skill ID // Check permission using Wildcard.all on the skill ID
const permissions = agent.permission.skill const permissions = agent.permission.skill
const action = Wildcard.all(params.id, permissions) const action = Wildcard.all(params.name, permissions)
if (action === "deny") { if (action === "deny") {
throw new Permission.RejectedError( throw new Permission.RejectedError(
ctx.sessionID, ctx.sessionID,
"skill", "skill",
ctx.callID, ctx.callID,
{ skill: params.id }, { skill: params.name },
`Access to skill "${params.id}" is denied for agent "${agent.name}".`, `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
) )
} }
if (action === "ask") { if (action === "ask") {
await Permission.ask({ await Permission.ask({
type: "skill", type: "skill",
pattern: params.id, pattern: params.name,
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
messageID: ctx.messageID, messageID: ctx.messageID,
callID: ctx.callID, callID: ctx.callID,
title: `Load skill: ${skill.name}`, 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 // Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location) 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 // Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${baseDir}`, "", parsed.content.trim()].join( const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
"\n",
)
return { return {
title: `Loaded skill: ${skill.name}`, title: `Loaded skill: ${skill.name}`,
output, output,
metadata: { metadata: {
id: skill.id,
name: skill.name, name: skill.name,
baseDir, dir,
}, },
} }
}, },

View file

@ -65,55 +65,7 @@ description: Another test skill.
}) })
}) })
test("throws error for invalid skill name format", async () => { test("skips skills with missing frontmatter", 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 () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
git: true, git: true,
init: async (dir) => { 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const skills = await Skill.all() const skills = await Skill.all()
expect(skills.length).toBe(1) expect(skills).toEqual([])
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")
}, },
}) })
}) })
@ -252,7 +137,7 @@ description: An example skill for testing XML output.
const result = await SystemPrompt.skills() const result = await SystemPrompt.skills()
expect(result.length).toBe(1) expect(result.length).toBe(1)
expect(result[0]).toContain("<available_skills>") expect(result[0]).toContain("<available_skills>")
expect(result[0]).toContain("<id>example-skill</id>")
expect(result[0]).toContain("<name>example-skill</name>") expect(result[0]).toContain("<name>example-skill</name>")
expect(result[0]).toContain("<description>An example skill for testing XML output.</description>") expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
expect(result[0]).toContain("</available_skills>") expect(result[0]).toContain("</available_skills>")