From db1b812fe9da207b5ec61b82f8233c0d15b0e3d6 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Wed, 14 Jan 2026 19:22:58 +0100 Subject: [PATCH 1/3] feat: add skill activation system and switch to OpenRouter model - Add use_when field for skills to trigger automatic activation - Create skill tool for loading skills dynamically - Inject skill-activation-rules block into system prompt - Switch GitHub Actions model to openrouter/mistralai/devstral-2512:free --- .github/workflows/opencode-review.yml | 8 +- .github/workflows/opencode.yml | 2 +- skill/code-simplify/SKILL.md | 8 +- src/index.ts | 44 ++++++ src/loaders.ts | 3 + src/tools/index.ts | 1 + src/tools/skill.ts | 194 ++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 src/tools/skill.ts diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index c79c006..a9ea1ab 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: @@ -20,9 +20,11 @@ jobs: env: OPENCODE_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..3fb4485 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,4 +30,4 @@ jobs: env: OPENCODE_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..ee732fb 100644 --- a/skill/code-simplify/SKILL.md +++ b/skill/code-simplify/SKILL.md @@ -1,6 +1,12 @@ --- 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: > + REQUIRED: After completing a coding task (feature, bug fix, refactor) where code files were modified, + call skill({ name: "code-simplify" }) as a final quality check BEFORE marking the task complete. + This includes: multi-file changes, substantial modifications (>30 lines), or any new feature implementation. --- Run the `/simplify-changes` command. diff --git a/src/index.ts b/src/index.ts index 4633137..dc2f2b3 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, @@ -72,6 +75,23 @@ 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") + +// ============================================================================ +// SKILL ACTIVATION +// ============================================================================ + +function buildSkillActivationBlock(skills: LoadedSkill[]): string { + const rules = skills + .map(s => ` `) + .join("\n") + + return ` +MANDATORY: Call skill({ name }) BEFORE responding when trigger matches. + +${rules} +` +} // ============================================================================ // PLUGIN @@ -80,6 +100,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 +109,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 +317,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 +414,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/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.ts b/src/tools/skill.ts new file mode 100644 index 0000000..65911b5 --- /dev/null +++ b/src/tools/skill.ts @@ -0,0 +1,194 @@ +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 + + // Priority order (lowest to highest): plugin < global < project + const allSkills = [ + ...pluginSkillsToInfo(options.pluginSkills, options.pluginDir), + ...discoverClaudeGlobalSkills(), + ...discoverOpencodeGlobalSkills(), + ...discoverClaudeProjectSkills(cwd), + ...discoverOpencodeProjectSkills(cwd), + ] + + 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") + }, + }) +} From 7031a99338467a673a83a1761f293eef09bf8071 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Wed, 14 Jan 2026 19:31:15 +0100 Subject: [PATCH 2/3] fix: use OPENROUTER_API_KEY env var for OpenRouter provider --- .github/workflows/opencode-review.yml | 2 +- .github/workflows/opencode.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index a9ea1ab..15747a8 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -18,7 +18,7 @@ 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: openrouter/mistralai/devstral-2512:free prompt: | diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 3fb4485..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: openrouter/mistralai/devstral-2512:free \ No newline at end of file From 78708ef083543b29c77d92c72f3a62451db7393a Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Wed, 14 Jan 2026 20:00:13 +0100 Subject: [PATCH 3/3] fix: address code review feedback and add comprehensive skill tests - Fix unsafe non-null assertion on useWhen field - Clarify skill merge priority comments (project > global > plugin) - Extract buildSkillActivationBlock to separate module for testability - Add escapeTrigger helper function - Add 20 new tests for skill system (147 total) - Strengthen code-simplify skill activation trigger --- skill/code-simplify/SKILL.md | 8 +- src/index.test.ts | 128 +++++++++++++++ src/index.ts | 21 +-- src/skill-activation.ts | 17 ++ src/tools/skill.test.ts | 297 +++++++++++++++++++++++++++++++++++ src/tools/skill.ts | 3 +- 6 files changed, 453 insertions(+), 21 deletions(-) create mode 100644 src/skill-activation.ts create mode 100644 src/tools/skill.test.ts diff --git a/skill/code-simplify/SKILL.md b/skill/code-simplify/SKILL.md index ee732fb..8850c5a 100644 --- a/skill/code-simplify/SKILL.md +++ b/skill/code-simplify/SKILL.md @@ -4,9 +4,11 @@ description: > Simplify code you just wrote or modified. Load after completing a feature, fix, or refactor to improve clarity while preserving behavior. use_when: > - REQUIRED: After completing a coding task (feature, bug fix, refactor) where code files were modified, - call skill({ name: "code-simplify" }) as a final quality check BEFORE marking the task complete. - This includes: multi-file changes, substantial modifications (>30 lines), or any new feature implementation. + 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 dc2f2b3..437df97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,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 @@ -77,22 +80,6 @@ const AGENT_DIR = join(PLUGIN_ROOT, "agent") const COMMAND_DIR = join(PLUGIN_ROOT, "command") const SKILL_DIR = join(PLUGIN_ROOT, "skill") -// ============================================================================ -// SKILL ACTIVATION -// ============================================================================ - -function buildSkillActivationBlock(skills: LoadedSkill[]): string { - const rules = skills - .map(s => ` `) - .join("\n") - - return ` -MANDATORY: Call skill({ name }) BEFORE responding when trigger matches. - -${rules} -` -} - // ============================================================================ // PLUGIN // ============================================================================ 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/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 index 65911b5..3f8121f 100644 --- a/src/tools/skill.ts +++ b/src/tools/skill.ts @@ -127,7 +127,7 @@ export function createSkillTool(options: CreateSkillToolOptions) { const getSkills = (cwd: string): SkillInfo[] => { if (cachedSkills) return cachedSkills - // Priority order (lowest to highest): plugin < global < project + // Merge order: plugin defaults < global < project (later entries override earlier on name collision) const allSkills = [ ...pluginSkillsToInfo(options.pluginSkills, options.pluginDir), ...discoverClaudeGlobalSkills(), @@ -136,6 +136,7 @@ export function createSkillTool(options: CreateSkillToolOptions) { ...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)