2
0
Dax Raad 4 сар өмнө
parent
commit
9a5dd18c49

+ 6 - 0
.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

+ 2 - 0
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({

+ 15 - 0
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)
+    })
+  },
+})

+ 3 - 2
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)

+ 25 - 122
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<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 type Info = z.infer<typeof Info>
 
   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<DiscoveredSkill[]> {
+  export const state = Instance.state(async () => {
     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) {
-      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<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
   })
 
-  export async function all(): Promise<Info[]> {
-    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))
   }
 }

+ 24 - 18
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.",
+      "<available_skills>",
+      ...skills.flatMap((skill) => [
+        `  <skill>`,
+        `    <name>${skill.name}</name>`,
+        `    <description>${skill.description}</description>`,
+        `  </skill>`,
+      ]),
+      "</available_skills>",
     ].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,
         },
       }
     },

+ 3 - 118
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("<available_skills>")
-      expect(result[0]).toContain("<id>example-skill</id>")
+
       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("</available_skills>")