diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml
index c79c006..15747a8 100644
--- a/.github/workflows/opencode-review.yml
+++ b/.github/workflows/opencode-review.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write
- contents: write
+ contents: read
pull-requests: write
issues: write
steps:
@@ -18,11 +18,13 @@ jobs:
persist-credentials: false
- uses: anomalyco/opencode/github@latest
env:
- OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+ OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
- model: opencode/claude-opus-4-5
+ model: openrouter/mistralai/devstral-2512:free
prompt: |
Review this pull request:
- Check for code quality issues
- Look for potential bugs
- - Suggest improvements
\ No newline at end of file
+ - Suggest improvements
+
+ Do NOT make any commits or modify files. Only post review comments.
\ No newline at end of file
diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml
index 8f881ee..7b3ec4c 100644
--- a/.github/workflows/opencode.yml
+++ b/.github/workflows/opencode.yml
@@ -28,6 +28,6 @@ jobs:
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
- OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+ OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
- model: opencode/claude-opus-4-5
\ No newline at end of file
+ model: openrouter/mistralai/devstral-2512:free
\ No newline at end of file
diff --git a/skill/code-simplify/SKILL.md b/skill/code-simplify/SKILL.md
index 63790a9..8850c5a 100644
--- a/skill/code-simplify/SKILL.md
+++ b/skill/code-simplify/SKILL.md
@@ -1,6 +1,14 @@
---
name: code-simplify
-description: Simplify code you just wrote or modified. Load after completing a feature, fix, or refactor to improve clarity while preserving behavior.
+description: >
+ Simplify code you just wrote or modified. Load after completing a feature, fix, or refactor
+ to improve clarity while preserving behavior.
+use_when: >
+ CRITICAL REMINDER - DO NOT SKIP: You MUST call skill({ name: "code-simplify" }) IMMEDIATELY
+ after ANY code modification (new files, edits, refactors), BEFORE responding to the user or
+ marking todos complete. This is NOT optional. If you just wrote or modified code and are about
+ to say "done" or ask "shall I commit?", STOP and call this skill FIRST. Failure to do so is
+ a protocol violation.
---
Run the `/simplify-changes` command.
diff --git a/src/index.test.ts b/src/index.test.ts
index bae183f..f6bc5cc 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -11,8 +11,10 @@ import {
mergeHooks,
type HookConfig,
type HookEvent,
+ type LoadedSkill,
} from "./loaders"
import { executeBashAction, type BashContext } from "./bash-executor"
+import { buildSkillActivationBlock } from "./skill-activation"
describe("parseFrontmatter", () => {
it("should parse valid frontmatter", () => {
@@ -278,6 +280,45 @@ Content`
expect(result).toHaveLength(1)
expect(result[0].name).toBe("valid")
})
+
+ it("should load use_when field from frontmatter", () => {
+ const skillDir = join(testDir, "auto-skill")
+ mkdirSync(skillDir)
+
+ const skillContent = `---
+name: auto-skill
+description: An auto-triggered skill
+use_when: After completing a task, call this skill
+---
+
+Skill instructions here.`
+
+ writeFileSync(join(skillDir, "SKILL.md"), skillContent)
+
+ const result = loadSkills(testDir)
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe("auto-skill")
+ expect(result[0].useWhen).toBe("After completing a task, call this skill")
+ })
+
+ it("should have undefined useWhen when not provided", () => {
+ const skillDir = join(testDir, "manual-skill")
+ mkdirSync(skillDir)
+
+ const skillContent = `---
+name: manual-skill
+description: A manual skill
+---
+
+Content`
+
+ writeFileSync(join(skillDir, "SKILL.md"), skillContent)
+
+ const result = loadSkills(testDir)
+
+ expect(result[0].useWhen).toBeUndefined()
+ })
})
describe("loadCommands", () => {
@@ -984,6 +1025,93 @@ describe("executeBashAction", () => {
})
})
+describe("buildSkillActivationBlock", () => {
+ it("should generate XML block with skill rules", () => {
+ const skills: LoadedSkill[] = [
+ {
+ name: "code-review",
+ description: "Review code",
+ useWhen: "After writing code",
+ path: "/path/to/skill",
+ body: "",
+ },
+ ]
+
+ const result = buildSkillActivationBlock(skills)
+
+ expect(result).toContain("")
+ expect(result).toContain("")
+ expect(result).toContain('skill="code-review"')
+ expect(result).toContain('trigger="After writing code"')
+ expect(result).toContain("MANDATORY")
+ })
+
+ it("should escape quotes in trigger text", () => {
+ const skills: LoadedSkill[] = [
+ {
+ name: "test-skill",
+ description: "Test",
+ useWhen: 'Call when user says "help"',
+ path: "/path",
+ body: "",
+ },
+ ]
+
+ const result = buildSkillActivationBlock(skills)
+
+ expect(result).toContain(""help"")
+ expect(result).not.toContain('"help"')
+ })
+
+ it("should replace newlines with spaces in trigger text", () => {
+ const skills: LoadedSkill[] = [
+ {
+ name: "multi-line",
+ description: "Test",
+ useWhen: "First line\nSecond line\nThird line",
+ path: "/path",
+ body: "",
+ },
+ ]
+
+ const result = buildSkillActivationBlock(skills)
+
+ expect(result).toContain("First line Second line Third line")
+ // The trigger attribute should not contain newlines (though the XML block itself does)
+ expect(result).toMatch(/trigger="[^"]*First line Second line Third line[^"]*"/)
+ })
+
+ it("should trim whitespace from trigger text", () => {
+ const skills: LoadedSkill[] = [
+ {
+ name: "whitespace",
+ description: "Test",
+ useWhen: " padded trigger ",
+ path: "/path",
+ body: "",
+ },
+ ]
+
+ const result = buildSkillActivationBlock(skills)
+
+ expect(result).toContain('trigger="padded trigger"')
+ })
+
+ it("should generate multiple rules for multiple skills", () => {
+ const skills: LoadedSkill[] = [
+ { name: "skill-a", description: "A", useWhen: "Trigger A", path: "/a", body: "" },
+ { name: "skill-b", description: "B", useWhen: "Trigger B", path: "/b", body: "" },
+ ]
+
+ const result = buildSkillActivationBlock(skills)
+
+ expect(result).toContain('skill="skill-a"')
+ expect(result).toContain('skill="skill-b"')
+ expect(result).toContain('trigger="Trigger A"')
+ expect(result).toContain('trigger="Trigger B"')
+ })
+})
+
describe("mergeHooks", () => {
it("should return empty map when merging empty maps", () => {
const result = mergeHooks(
diff --git a/src/index.ts b/src/index.ts
index 4633137..437df97 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,9 +5,11 @@ import {
loadAgents,
loadCommands,
loadHooks,
+ loadSkills,
mergeHooks,
type HookConfig,
type HookEvent,
+ type LoadedSkill,
} from "./loaders"
import { getGlobalHookDir, getProjectHookDir } from "./config-paths"
import { hasCodeExtension } from "./code-files"
@@ -22,6 +24,7 @@ import {
createPromptSessionTool,
createListChildSessionsTool,
createAgentPromoteTool,
+ createSkillTool,
getPromotedAgents,
ethTransactionTool,
ethAddressTxsTool,
@@ -29,7 +32,10 @@ import {
ethTokenTransfersTool,
} from "./tools"
-export { parseFrontmatter, loadAgents, loadCommands } from "./loaders"
+export { parseFrontmatter, loadAgents, loadCommands, type LoadedSkill } from "./loaders"
+export { buildSkillActivationBlock } from "./skill-activation"
+
+import { buildSkillActivationBlock } from "./skill-activation"
// ============================================================================
// TYPES
@@ -72,6 +78,7 @@ const __dirname = dirname(__filename)
const PLUGIN_ROOT = join(__dirname, "..")
const AGENT_DIR = join(PLUGIN_ROOT, "agent")
const COMMAND_DIR = join(PLUGIN_ROOT, "command")
+const SKILL_DIR = join(PLUGIN_ROOT, "skill")
// ============================================================================
// PLUGIN
@@ -80,6 +87,7 @@ const COMMAND_DIR = join(PLUGIN_ROOT, "command")
const SmartfrogPlugin: Plugin = async (ctx) => {
const agents = loadAgents(AGENT_DIR)
const commands = loadCommands(COMMAND_DIR)
+ const skills = loadSkills(SKILL_DIR)
const globalHooks = loadHooks(getGlobalHookDir())
const projectHooks = loadHooks(getProjectHookDir(ctx.directory))
@@ -88,12 +96,25 @@ const SmartfrogPlugin: Plugin = async (ctx) => {
const modifiedCodeFiles = new Map>()
const pendingToolArgs = new Map>()
+ const skillsWithTriggers = skills.filter(s => s.useWhen)
+ const skillActivationBlock = skillsWithTriggers.length > 0
+ ? buildSkillActivationBlock(skillsWithTriggers)
+ : null
+
+ const skillTool = createSkillTool({
+ pluginSkills: skills,
+ pluginDir: PLUGIN_ROOT,
+ })
+
log("[init] Plugin loaded", {
agents: Object.keys(agents),
commands: Object.keys(commands),
+ skills: skills.map(s => s.name),
+ skillsWithTriggers: skillsWithTriggers.map(s => s.name),
hooks: Array.from(hooks.keys()),
tools: [
"gitingest",
+ "skill",
"agent-promote",
"eth-transaction",
"eth-address-txs",
@@ -283,6 +304,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => {
tool: {
gitingest: gitingestTool,
+ skill: skillTool,
"prompt-session": createPromptSessionTool(ctx.client),
"list-child-sessions": createListChildSessionsTool(ctx.client),
"agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
@@ -379,6 +401,15 @@ const SmartfrogPlugin: Plugin = async (ctx) => {
await triggerHooks("session.idle", sessionID, { files: files ? Array.from(files) : [] })
}
},
+
+ "experimental.chat.system.transform": async (
+ _input: Record,
+ output: { system: string[] }
+ ): Promise => {
+ if (!skillActivationBlock) return
+ output.system.push(skillActivationBlock)
+ },
+
}
}
diff --git a/src/loaders.ts b/src/loaders.ts
index ad58ea6..23aad8d 100644
--- a/src/loaders.ts
+++ b/src/loaders.ts
@@ -21,6 +21,7 @@ export interface AgentFrontmatter {
export interface SkillFrontmatter {
name: string
description: string
+ use_when?: string
license?: string
compatibility?: string
metadata?: Record
@@ -44,6 +45,7 @@ export interface CommandConfig {
export interface LoadedSkill {
name: string
description: string
+ useWhen?: string
path: string
body: string
}
@@ -176,6 +178,7 @@ export function loadSkills(skillDir: string): LoadedSkill[] {
skills.push({
name: data.name || entry.name,
description: data.description || "",
+ useWhen: data.use_when,
path: skillPath,
body: body.trim(),
})
diff --git a/src/skill-activation.ts b/src/skill-activation.ts
new file mode 100644
index 0000000..a43d11d
--- /dev/null
+++ b/src/skill-activation.ts
@@ -0,0 +1,17 @@
+import { type LoadedSkill } from "./loaders"
+
+function escapeTrigger(text: string): string {
+ return text.replace(/"/g, """).replace(/\n/g, " ").trim()
+}
+
+export function buildSkillActivationBlock(skills: LoadedSkill[]): string {
+ const rules = skills
+ .map(s => ` `)
+ .join("\n")
+
+ return `
+MANDATORY: Call skill({ name }) BEFORE responding when trigger matches.
+
+${rules}
+`
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 694e281..fd3cf76 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -2,6 +2,7 @@ export { gitingestTool, fetchGitingest, type GitingestArgs } from "./gitingest"
export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session"
export { createListChildSessionsTool } from "./list-child-sessions"
export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote"
+export { createSkillTool, type CreateSkillToolOptions, type SkillInfo } from "./skill"
export {
ethTransactionTool,
diff --git a/src/tools/skill.test.ts b/src/tools/skill.test.ts
new file mode 100644
index 0000000..93a8839
--- /dev/null
+++ b/src/tools/skill.test.ts
@@ -0,0 +1,297 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest"
+import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import { parseFrontmatter, type LoadedSkill } from "../loaders"
+
+/**
+ * These tests verify the skill discovery and loading logic.
+ *
+ * Note: We cannot directly test createSkillTool because it imports
+ * @opencode-ai/plugin which is not available in the test environment.
+ * Instead, we test the underlying helper functions and data structures.
+ */
+
+interface SkillInfo {
+ name: string
+ description: string
+ location: string
+ scope: "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project"
+}
+
+function discoverSkillsFromDir(
+ skillsDir: string,
+ scope: SkillInfo["scope"]
+): SkillInfo[] {
+ const { existsSync, readdirSync, readFileSync } = require("node:fs")
+
+ if (!existsSync(skillsDir)) return []
+
+ const skills: SkillInfo[] = []
+
+ try {
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) continue
+
+ const entryPath = join(skillsDir, entry.name)
+
+ if (entry.isDirectory()) {
+ const skillMdPath = join(entryPath, "SKILL.md")
+ if (!existsSync(skillMdPath)) continue
+
+ try {
+ const content = readFileSync(skillMdPath, "utf-8")
+ const { data } = parseFrontmatter<{ name?: string; description?: string }>(content)
+
+ if (data.name && data.description) {
+ skills.push({
+ name: data.name,
+ description: data.description,
+ location: skillMdPath,
+ scope,
+ })
+ }
+ } catch {
+ // Skip invalid skill files
+ }
+ }
+ }
+ } catch {
+ // Directory not accessible
+ }
+
+ return skills
+}
+
+function pluginSkillsToInfo(skills: LoadedSkill[], pluginDir: string): SkillInfo[] {
+ return skills.map(s => ({
+ name: s.name,
+ description: s.description,
+ location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
+ scope: "plugin" as const,
+ }))
+}
+
+function formatSkillsXml(skills: SkillInfo[]): string {
+ if (skills.length === 0) return ""
+
+ const skillsXml = skills
+ .map(skill => {
+ return [
+ " ",
+ ` ${skill.name}`,
+ ` ${skill.description}`,
+ " ",
+ ].join("\n")
+ })
+ .join("\n")
+
+ return `\n\n\n${skillsXml}\n`
+}
+
+describe("skill discovery", () => {
+ let testDir: string
+
+ beforeEach(() => {
+ testDir = join(tmpdir(), `skill-test-${Date.now()}`)
+ mkdirSync(testDir, { recursive: true })
+ })
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true })
+ })
+
+ function createSkillFile(dir: string, name: string, content: string): string {
+ const skillDir = join(dir, name)
+ mkdirSync(skillDir, { recursive: true })
+ const skillPath = join(skillDir, "SKILL.md")
+ writeFileSync(skillPath, content)
+ return skillPath
+ }
+
+ describe("discoverSkillsFromDir", () => {
+ it("should return empty array for non-existent directory", () => {
+ const result = discoverSkillsFromDir("/non/existent/path", "plugin")
+ expect(result).toEqual([])
+ })
+
+ it("should discover valid skill with name and description", () => {
+ createSkillFile(testDir, "my-skill", `---
+name: my-skill
+description: A test skill
+---
+
+Skill content here.`)
+
+ const result = discoverSkillsFromDir(testDir, "opencode")
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe("my-skill")
+ expect(result[0].description).toBe("A test skill")
+ expect(result[0].scope).toBe("opencode")
+ })
+
+ it("should ignore directories without SKILL.md", () => {
+ const skillDir = join(testDir, "no-skill")
+ mkdirSync(skillDir)
+ writeFileSync(join(skillDir, "README.md"), "Not a skill")
+
+ const result = discoverSkillsFromDir(testDir, "plugin")
+
+ expect(result).toHaveLength(0)
+ })
+
+ it("should ignore skills without name", () => {
+ createSkillFile(testDir, "nameless", `---
+description: Has description but no name
+---
+
+Content`)
+
+ const result = discoverSkillsFromDir(testDir, "plugin")
+
+ expect(result).toHaveLength(0)
+ })
+
+ it("should ignore skills without description", () => {
+ createSkillFile(testDir, "no-desc", `---
+name: no-desc-skill
+---
+
+Content`)
+
+ const result = discoverSkillsFromDir(testDir, "plugin")
+
+ expect(result).toHaveLength(0)
+ })
+
+ it("should ignore hidden directories", () => {
+ createSkillFile(testDir, ".hidden-skill", `---
+name: hidden
+description: Hidden skill
+---
+
+Content`)
+
+ const result = discoverSkillsFromDir(testDir, "plugin")
+
+ expect(result).toHaveLength(0)
+ })
+
+ it("should discover multiple skills", () => {
+ createSkillFile(testDir, "skill-a", `---
+name: skill-a
+description: First skill
+---
+Content A`)
+
+ createSkillFile(testDir, "skill-b", `---
+name: skill-b
+description: Second skill
+---
+Content B`)
+
+ const result = discoverSkillsFromDir(testDir, "opencode-project")
+
+ expect(result).toHaveLength(2)
+ expect(result.map(s => s.name).sort()).toEqual(["skill-a", "skill-b"])
+ })
+ })
+
+ describe("pluginSkillsToInfo", () => {
+ it("should convert LoadedSkill array to SkillInfo array", () => {
+ const pluginSkills: LoadedSkill[] = [
+ {
+ name: "test-skill",
+ description: "A test skill",
+ path: "/fake/path/SKILL.md",
+ body: "Test body",
+ },
+ ]
+
+ const result = pluginSkillsToInfo(pluginSkills, "/plugin/dir")
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe("test-skill")
+ expect(result[0].description).toBe("A test skill")
+ expect(result[0].location).toBe("/fake/path/SKILL.md")
+ expect(result[0].scope).toBe("plugin")
+ })
+
+ it("should use default path when path not provided", () => {
+ const pluginSkills: LoadedSkill[] = [
+ {
+ name: "no-path-skill",
+ description: "Skill without path",
+ path: "",
+ body: "",
+ },
+ ]
+
+ const result = pluginSkillsToInfo(pluginSkills, "/my/plugin")
+
+ expect(result[0].location).toBe("/my/plugin/skill/no-path-skill/SKILL.md")
+ })
+ })
+
+ describe("formatSkillsXml", () => {
+ it("should return empty string for no skills", () => {
+ const result = formatSkillsXml([])
+ expect(result).toBe("")
+ })
+
+ it("should format single skill as XML", () => {
+ const skills: SkillInfo[] = [
+ {
+ name: "my-skill",
+ description: "My description",
+ location: "/path",
+ scope: "plugin",
+ },
+ ]
+
+ const result = formatSkillsXml(skills)
+
+ expect(result).toContain("")
+ expect(result).toContain("my-skill")
+ expect(result).toContain("My description")
+ expect(result).toContain("")
+ })
+
+ it("should format multiple skills", () => {
+ const skills: SkillInfo[] = [
+ { name: "skill-a", description: "Desc A", location: "/a", scope: "plugin" },
+ { name: "skill-b", description: "Desc B", location: "/b", scope: "opencode" },
+ ]
+
+ const result = formatSkillsXml(skills)
+
+ expect(result).toContain("skill-a")
+ expect(result).toContain("skill-b")
+ })
+ })
+
+ describe("skill deduplication", () => {
+ it("should demonstrate last-wins deduplication behavior", () => {
+ // This tests the expected behavior when skills are merged
+ const allSkills: SkillInfo[] = [
+ { name: "shared", description: "Plugin version", location: "/plugin", scope: "plugin" },
+ { name: "shared", description: "Global version", location: "/global", scope: "opencode" },
+ { name: "shared", description: "Project version", location: "/project", scope: "opencode-project" },
+ ]
+
+ // Simulate deduplication (last wins)
+ const skillMap = new Map()
+ for (const skill of allSkills) {
+ skillMap.set(skill.name, skill)
+ }
+ const deduplicated = Array.from(skillMap.values())
+
+ expect(deduplicated).toHaveLength(1)
+ expect(deduplicated[0].description).toBe("Project version")
+ expect(deduplicated[0].scope).toBe("opencode-project")
+ })
+ })
+})
diff --git a/src/tools/skill.ts b/src/tools/skill.ts
new file mode 100644
index 0000000..3f8121f
--- /dev/null
+++ b/src/tools/skill.ts
@@ -0,0 +1,195 @@
+import { tool, type ToolContext } from "@opencode-ai/plugin"
+import { existsSync, readdirSync, readFileSync } from "node:fs"
+import { join, dirname } from "node:path"
+import { homedir } from "node:os"
+import { parseFrontmatter, type LoadedSkill } from "../loaders"
+
+export interface SkillInfo {
+ name: string
+ description: string
+ location: string
+ scope: "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project"
+}
+
+interface SkillFrontmatter {
+ name?: string
+ description?: string
+}
+
+const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.`
+const TOOL_DESCRIPTION_NO_SKILLS = `${TOOL_DESCRIPTION_PREFIX} No skills are currently available.`
+
+function discoverSkillsFromDir(
+ skillsDir: string,
+ scope: SkillInfo["scope"]
+): SkillInfo[] {
+ if (!existsSync(skillsDir)) return []
+
+ const skills: SkillInfo[] = []
+
+ try {
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) continue
+
+ const entryPath = join(skillsDir, entry.name)
+
+ if (entry.isDirectory()) {
+ const skillMdPath = join(entryPath, "SKILL.md")
+ if (!existsSync(skillMdPath)) continue
+
+ try {
+ const content = readFileSync(skillMdPath, "utf-8")
+ const { data } = parseFrontmatter(content)
+
+ if (data.name && data.description) {
+ skills.push({
+ name: data.name,
+ description: data.description,
+ location: skillMdPath,
+ scope,
+ })
+ }
+ } catch {
+ // Skip invalid skill files
+ }
+ }
+ }
+ } catch {
+ // Directory not accessible
+ }
+
+ return skills
+}
+
+function discoverOpencodeGlobalSkills(): SkillInfo[] {
+ const skillsDir = join(homedir(), ".config", "opencode", "skill")
+ return discoverSkillsFromDir(skillsDir, "opencode")
+}
+
+function discoverOpencodeProjectSkills(cwd: string): SkillInfo[] {
+ const skillsDir = join(cwd, ".opencode", "skill")
+ return discoverSkillsFromDir(skillsDir, "opencode-project")
+}
+
+function discoverClaudeGlobalSkills(): SkillInfo[] {
+ const skillsDir = join(homedir(), ".claude", "skills")
+ return discoverSkillsFromDir(skillsDir, "claude")
+}
+
+function discoverClaudeProjectSkills(cwd: string): SkillInfo[] {
+ const skillsDir = join(cwd, ".claude", "skills")
+ return discoverSkillsFromDir(skillsDir, "claude-project")
+}
+
+function pluginSkillsToInfo(skills: LoadedSkill[], pluginDir: string): SkillInfo[] {
+ return skills.map(s => ({
+ name: s.name,
+ description: s.description,
+ location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
+ scope: "plugin" as const,
+ }))
+}
+
+function formatSkillsXml(skills: SkillInfo[]): string {
+ if (skills.length === 0) return ""
+
+ const skillsXml = skills
+ .map(skill => {
+ return [
+ " ",
+ ` ${skill.name}`,
+ ` ${skill.description}`,
+ " ",
+ ].join("\n")
+ })
+ .join("\n")
+
+ return `\n\n\n${skillsXml}\n`
+}
+
+function loadSkillContent(location: string): string {
+ const content = readFileSync(location, "utf-8")
+ const { body } = parseFrontmatter(content)
+ return body.trim()
+}
+
+export interface CreateSkillToolOptions {
+ pluginSkills: LoadedSkill[]
+ pluginDir: string
+}
+
+export function createSkillTool(options: CreateSkillToolOptions) {
+ let cachedSkills: SkillInfo[] | null = null
+ let cachedDescription: string | null = null
+
+ const getSkills = (cwd: string): SkillInfo[] => {
+ if (cachedSkills) return cachedSkills
+
+ // Merge order: plugin defaults < global < project (later entries override earlier on name collision)
+ const allSkills = [
+ ...pluginSkillsToInfo(options.pluginSkills, options.pluginDir),
+ ...discoverClaudeGlobalSkills(),
+ ...discoverOpencodeGlobalSkills(),
+ ...discoverClaudeProjectSkills(cwd),
+ ...discoverOpencodeProjectSkills(cwd),
+ ]
+
+ // Deduplicate by name - last definition wins (project > global > plugin)
+ const skillMap = new Map()
+ for (const skill of allSkills) {
+ skillMap.set(skill.name, skill)
+ }
+
+ cachedSkills = Array.from(skillMap.values())
+ return cachedSkills
+ }
+
+ const getDescription = (cwd: string): string => {
+ if (cachedDescription) return cachedDescription
+
+ const skills = getSkills(cwd)
+ cachedDescription =
+ skills.length === 0
+ ? TOOL_DESCRIPTION_NO_SKILLS
+ : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skills)
+
+ return cachedDescription
+ }
+
+ // Pre-compute with current working directory
+ const cwd = process.cwd()
+ getDescription(cwd)
+
+ return tool({
+ get description() {
+ return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
+ },
+ args: {
+ name: tool.schema
+ .string()
+ .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
+ },
+ async execute(args: { name: string }, _context: ToolContext) {
+ const skills = getSkills(cwd)
+ const skill = skills.find(s => s.name === args.name)
+
+ if (!skill) {
+ const available = skills.map(s => s.name).join(", ")
+ throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
+ }
+
+ const body = loadSkillContent(skill.location)
+ const dir = dirname(skill.location)
+
+ return [
+ `## Skill: ${skill.name}`,
+ "",
+ `**Base directory**: ${dir}`,
+ "",
+ body,
+ ].join("\n")
+ },
+ })
+}