opencode-froggy 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,10 @@ agent: build
10
10
 
11
11
  ## Your task
12
12
 
13
+ **If the current branch is `master`, `main`, `develop`, or `dev`:**
14
+ - Warn the user that committing directly to this branch is discouraged
15
+ - Propose to create a new feature branch with a suggested name based on the changes
16
+
13
17
  1. /commit-push to commit and push all changes
14
18
  2. Once the push is complete, create a PR using `gh pr create`:
15
19
  - Use the commit message as PR title
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import { getGlobalHookDir, getProjectHookDir } from "./config-paths";
5
5
  import { hasCodeExtension } from "./code-files";
6
6
  import { log } from "./logger";
7
7
  import { executeBashAction, DEFAULT_BASH_TIMEOUT, } from "./bash-executor";
8
- import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, createSkillTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
8
+ import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, createSkillTool, formatPluginSkillsAsXmlItems, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
9
+ import { injectPluginSkillsIntoSystem } from "./skill-injection";
9
10
  export { parseFrontmatter, loadAgents, loadCommands } from "./loaders";
10
11
  export { buildSkillActivationBlock } from "./skill-activation";
11
12
  import { buildSkillActivationBlock } from "./skill-activation";
@@ -37,8 +38,12 @@ const SmartfrogPlugin = async (ctx) => {
37
38
  const skillTool = createSkillTool({
38
39
  pluginSkills: skills,
39
40
  pluginDir: PLUGIN_ROOT,
41
+ cwd: ctx.directory,
40
42
  client: ctx.client,
41
43
  });
44
+ const pluginSkillsXmlItems = skills.length > 0
45
+ ? formatPluginSkillsAsXmlItems(skills, PLUGIN_ROOT)
46
+ : null;
42
47
  log("[init] Plugin loaded", {
43
48
  agents: Object.keys(agents),
44
49
  commands: Object.keys(commands),
@@ -284,9 +289,10 @@ const SmartfrogPlugin = async (ctx) => {
284
289
  }
285
290
  },
286
291
  "experimental.chat.system.transform": async (_input, output) => {
287
- if (!skillActivationBlock)
288
- return;
289
- output.system.push(skillActivationBlock);
292
+ injectPluginSkillsIntoSystem(output.system, pluginSkillsXmlItems);
293
+ if (skillActivationBlock) {
294
+ output.system.push(skillActivationBlock);
295
+ }
290
296
  },
291
297
  };
292
298
  };
@@ -0,0 +1 @@
1
+ export declare function injectPluginSkillsIntoSystem(system: string[], pluginSkillsXmlItems: string | null): void;
@@ -0,0 +1,13 @@
1
+ const AVAILABLE_SKILLS_BLOCK_REGEX = /(<available_skills>[\s\S]*?)(<\/available_skills>)/;
2
+ export function injectPluginSkillsIntoSystem(system, pluginSkillsXmlItems) {
3
+ if (!pluginSkillsXmlItems)
4
+ return;
5
+ for (let i = 0; i < system.length; i++) {
6
+ const segment = system[i];
7
+ if (AVAILABLE_SKILLS_BLOCK_REGEX.test(segment)) {
8
+ system[i] = segment.replace(AVAILABLE_SKILLS_BLOCK_REGEX, `$1${pluginSkillsXmlItems}\n$2`);
9
+ return;
10
+ }
11
+ }
12
+ system.push(`<available_skills>\n${pluginSkillsXmlItems}\n</available_skills>`);
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { injectPluginSkillsIntoSystem } from "./skill-injection";
3
+ const SAMPLE_ITEMS = ` <skill>\n <name>tdd</name>\n <description>Apply TDD</description>\n </skill>`;
4
+ describe("injectPluginSkillsIntoSystem", () => {
5
+ it("should do nothing when pluginSkillsXmlItems is null", () => {
6
+ const system = ["some prompt", "<available_skills>\n <skill><name>x</name></skill>\n</available_skills>"];
7
+ const before = [...system];
8
+ injectPluginSkillsIntoSystem(system, null);
9
+ expect(system).toEqual(before);
10
+ });
11
+ it("should inject items inside existing <available_skills> block", () => {
12
+ const system = [
13
+ "header text",
14
+ "<available_skills>\n <skill>\n <name>x</name>\n <description>d</description>\n </skill>\n</available_skills>",
15
+ "footer text",
16
+ ];
17
+ injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
18
+ expect(system[0]).toBe("header text");
19
+ expect(system[2]).toBe("footer text");
20
+ expect(system[1]).toContain("<name>x</name>");
21
+ expect(system[1]).toContain("<name>tdd</name>");
22
+ const openCount = (system[1].match(/<available_skills>/g) ?? []).length;
23
+ const closeCount = (system[1].match(/<\/available_skills>/g) ?? []).length;
24
+ expect(openCount).toBe(1);
25
+ expect(closeCount).toBe(1);
26
+ });
27
+ it("should inject items before </available_skills>", () => {
28
+ const system = [
29
+ "<available_skills>\n <skill>\n <name>native</name>\n <description>n</description>\n </skill>\n</available_skills>",
30
+ ];
31
+ injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
32
+ const nativeIndex = system[0].indexOf("<name>native</name>");
33
+ const tddIndex = system[0].indexOf("<name>tdd</name>");
34
+ const closeIndex = system[0].indexOf("</available_skills>");
35
+ expect(nativeIndex).toBeGreaterThan(-1);
36
+ expect(tddIndex).toBeGreaterThan(nativeIndex);
37
+ expect(closeIndex).toBeGreaterThan(tddIndex);
38
+ });
39
+ it("should push a standalone block when no <available_skills> exists", () => {
40
+ const system = ["plain prompt", "another segment"];
41
+ injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
42
+ expect(system).toHaveLength(3);
43
+ expect(system[2]).toBe(`<available_skills>\n${SAMPLE_ITEMS}\n</available_skills>`);
44
+ });
45
+ it("should only inject into the first matching segment", () => {
46
+ const system = [
47
+ "<available_skills>\n <skill>\n <name>first</name>\n <description>d</description>\n </skill>\n</available_skills>",
48
+ "<available_skills>\n <skill>\n <name>second</name>\n <description>d</description>\n </skill>\n</available_skills>",
49
+ ];
50
+ injectPluginSkillsIntoSystem(system, SAMPLE_ITEMS);
51
+ expect(system[0]).toContain("<name>tdd</name>");
52
+ expect(system[1]).not.toContain("<name>tdd</name>");
53
+ });
54
+ it("should do nothing when pluginSkillsXmlItems is empty string", () => {
55
+ const system = ["<available_skills>\n</available_skills>"];
56
+ const before = [...system];
57
+ injectPluginSkillsIntoSystem(system, "");
58
+ expect(system).toEqual(before);
59
+ });
60
+ });
@@ -4,5 +4,5 @@ export { pdfToMarkdownTool } from "./pdf-to-markdown";
4
4
  export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session";
5
5
  export { createListChildSessionsTool } from "./list-child-sessions";
6
6
  export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote";
7
- export { createSkillTool, type CreateSkillToolOptions, type SkillInfo } from "./skill";
7
+ export { createSkillTool, discoverAllSkills, formatPluginSkillsAsXmlItems, type CreateSkillToolOptions, type DiscoverAllSkillsOptions, type SkillInfo, type SkillScope, } from "./skill";
8
8
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, type EthTransactionArgs, type EthAddressTxsArgs, type EthAddressBalanceArgs, type EthTokenTransfersArgs, } from "./blockchain";
@@ -4,5 +4,5 @@ export { pdfToMarkdownTool } from "./pdf-to-markdown";
4
4
  export { createPromptSessionTool } from "./prompt-session";
5
5
  export { createListChildSessionsTool } from "./list-child-sessions";
6
6
  export { createAgentPromoteTool, getPromotedAgents } from "./agent-promote";
7
- export { createSkillTool } from "./skill";
7
+ export { createSkillTool, discoverAllSkills, formatPluginSkillsAsXmlItems, } from "./skill";
8
8
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, } from "./blockchain";
@@ -2,15 +2,24 @@ import { type ToolContext } from "@opencode-ai/plugin";
2
2
  import type { createOpencodeClient } from "@opencode-ai/sdk";
3
3
  import { type LoadedSkill } from "../loaders";
4
4
  type Client = ReturnType<typeof createOpencodeClient>;
5
+ export type SkillScope = "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project";
5
6
  export interface SkillInfo {
6
7
  name: string;
7
8
  description: string;
8
9
  location: string;
9
- scope: "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project";
10
+ scope: SkillScope;
10
11
  }
12
+ export interface DiscoverAllSkillsOptions {
13
+ pluginSkills: LoadedSkill[];
14
+ pluginDir: string;
15
+ cwd: string;
16
+ }
17
+ export declare function discoverAllSkills(options: DiscoverAllSkillsOptions): SkillInfo[];
18
+ export declare function formatPluginSkillsAsXmlItems(skills: LoadedSkill[], pluginDir: string): string;
11
19
  export interface CreateSkillToolOptions {
12
20
  pluginSkills: LoadedSkill[];
13
21
  pluginDir: string;
22
+ cwd: string;
14
23
  client: Client;
15
24
  }
16
25
  export declare function createSkillTool(options: CreateSkillToolOptions): {
@@ -15,26 +15,25 @@ function discoverSkillsFromDir(skillsDir, scope) {
15
15
  for (const entry of entries) {
16
16
  if (entry.name.startsWith("."))
17
17
  continue;
18
- const entryPath = join(skillsDir, entry.name);
19
- if (entry.isDirectory()) {
20
- const skillMdPath = join(entryPath, "SKILL.md");
21
- if (!existsSync(skillMdPath))
18
+ if (!entry.isDirectory())
19
+ continue;
20
+ const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
21
+ if (!existsSync(skillMdPath))
22
+ continue;
23
+ try {
24
+ const content = readFileSync(skillMdPath, "utf-8");
25
+ const { data } = parseFrontmatter(content);
26
+ if (!data.name || !data.description)
22
27
  continue;
23
- try {
24
- const content = readFileSync(skillMdPath, "utf-8");
25
- const { data } = parseFrontmatter(content);
26
- if (data.name && data.description) {
27
- skills.push({
28
- name: data.name,
29
- description: data.description,
30
- location: skillMdPath,
31
- scope,
32
- });
33
- }
34
- }
35
- catch {
36
- // Skip invalid skill files
37
- }
28
+ skills.push({
29
+ name: data.name,
30
+ description: data.description,
31
+ location: skillMdPath,
32
+ scope,
33
+ });
34
+ }
35
+ catch {
36
+ // Skip invalid skill files
38
37
  }
39
38
  }
40
39
  }
@@ -43,22 +42,6 @@ function discoverSkillsFromDir(skillsDir, scope) {
43
42
  }
44
43
  return skills;
45
44
  }
46
- function discoverOpencodeGlobalSkills() {
47
- const skillsDir = join(homedir(), ".config", "opencode", "skill");
48
- return discoverSkillsFromDir(skillsDir, "opencode");
49
- }
50
- function discoverOpencodeProjectSkills(cwd) {
51
- const skillsDir = join(cwd, ".opencode", "skill");
52
- return discoverSkillsFromDir(skillsDir, "opencode-project");
53
- }
54
- function discoverClaudeGlobalSkills() {
55
- const skillsDir = join(homedir(), ".claude", "skills");
56
- return discoverSkillsFromDir(skillsDir, "claude");
57
- }
58
- function discoverClaudeProjectSkills(cwd) {
59
- const skillsDir = join(cwd, ".claude", "skills");
60
- return discoverSkillsFromDir(skillsDir, "claude-project");
61
- }
62
45
  function pluginSkillsToInfo(skills, pluginDir) {
63
46
  return skills.map(s => ({
64
47
  name: s.name,
@@ -67,20 +50,42 @@ function pluginSkillsToInfo(skills, pluginDir) {
67
50
  scope: "plugin",
68
51
  }));
69
52
  }
53
+ function formatSkillItems(skills) {
54
+ return skills
55
+ .map(skill => [
56
+ " <skill>",
57
+ ` <name>${skill.name}</name>`,
58
+ ` <description>${skill.description}</description>`,
59
+ " </skill>",
60
+ ].join("\n"))
61
+ .join("\n");
62
+ }
70
63
  function formatSkillsXml(skills) {
71
64
  if (skills.length === 0)
72
65
  return "";
73
- const skillsXml = skills
74
- .map(skill => {
75
- return [
76
- " <skill>",
77
- ` <name>${skill.name}</name>`,
78
- ` <description>${skill.description}</description>`,
79
- " </skill>",
80
- ].join("\n");
81
- })
82
- .join("\n");
83
- return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`;
66
+ return `<available_skills>\n${formatSkillItems(skills)}\n</available_skills>`;
67
+ }
68
+ export function discoverAllSkills(options) {
69
+ const { pluginSkills, pluginDir, cwd } = options;
70
+ // Merge order: plugin < claude global < opencode global < claude project < opencode project
71
+ // Later entries override earlier on name collision (project > global > plugin)
72
+ const allSkills = [
73
+ ...pluginSkillsToInfo(pluginSkills, pluginDir),
74
+ ...discoverSkillsFromDir(join(homedir(), ".claude", "skills"), "claude"),
75
+ ...discoverSkillsFromDir(join(homedir(), ".config", "opencode", "skills"), "opencode"),
76
+ ...discoverSkillsFromDir(join(cwd, ".claude", "skills"), "claude-project"),
77
+ ...discoverSkillsFromDir(join(cwd, ".opencode", "skills"), "opencode-project"),
78
+ ];
79
+ const skillMap = new Map();
80
+ for (const skill of allSkills) {
81
+ skillMap.set(skill.name, skill);
82
+ }
83
+ return Array.from(skillMap.values());
84
+ }
85
+ export function formatPluginSkillsAsXmlItems(skills, pluginDir) {
86
+ if (skills.length === 0)
87
+ return "";
88
+ return formatSkillItems(pluginSkillsToInfo(skills, pluginDir));
84
89
  }
85
90
  function loadSkillContent(location) {
86
91
  const content = readFileSync(location, "utf-8");
@@ -88,52 +93,19 @@ function loadSkillContent(location) {
88
93
  return body.trim();
89
94
  }
90
95
  export function createSkillTool(options) {
91
- let cachedSkills = null;
92
- let cachedDescription = null;
93
- const { client, pluginDir, pluginSkills } = options;
94
- const getSkills = (cwd) => {
95
- if (cachedSkills)
96
- return cachedSkills;
97
- // Merge order: plugin defaults < global < project (later entries override earlier on name collision)
98
- const allSkills = [
99
- ...pluginSkillsToInfo(pluginSkills, pluginDir),
100
- ...discoverClaudeGlobalSkills(),
101
- ...discoverOpencodeGlobalSkills(),
102
- ...discoverClaudeProjectSkills(cwd),
103
- ...discoverOpencodeProjectSkills(cwd),
104
- ];
105
- // Deduplicate by name - last definition wins (project > global > plugin)
106
- const skillMap = new Map();
107
- for (const skill of allSkills) {
108
- skillMap.set(skill.name, skill);
109
- }
110
- cachedSkills = Array.from(skillMap.values());
111
- return cachedSkills;
112
- };
113
- const getDescription = (cwd) => {
114
- if (cachedDescription)
115
- return cachedDescription;
116
- const skills = getSkills(cwd);
117
- cachedDescription =
118
- skills.length === 0
119
- ? TOOL_DESCRIPTION_NO_SKILLS
120
- : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skills);
121
- return cachedDescription;
122
- };
123
- // Pre-compute with current working directory
124
- const cwd = process.cwd();
125
- getDescription(cwd);
96
+ const { client, pluginDir, pluginSkills, cwd } = options;
97
+ const skills = discoverAllSkills({ pluginSkills, pluginDir, cwd });
98
+ const description = skills.length === 0
99
+ ? TOOL_DESCRIPTION_NO_SKILLS
100
+ : `${TOOL_DESCRIPTION_PREFIX}\n\n${formatSkillsXml(skills)}`;
126
101
  return tool({
127
- get description() {
128
- return cachedDescription ?? TOOL_DESCRIPTION_PREFIX;
129
- },
102
+ description,
130
103
  args: {
131
104
  name: tool.schema
132
105
  .string()
133
- .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
106
+ .describe("The skill identifier from available_skills (e.g., 'tdd', 'openspec-propose')"),
134
107
  },
135
108
  async execute(args, _context) {
136
- const skills = getSkills(cwd);
137
109
  const skill = skills.find(s => s.name === args.name);
138
110
  if (!skill) {
139
111
  const available = skills.map(s => s.name).join(", ");
@@ -2,230 +2,217 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { parseFrontmatter } from "../loaders";
6
- function discoverSkillsFromDir(skillsDir, scope) {
7
- const { existsSync, readdirSync, readFileSync } = require("node:fs");
8
- if (!existsSync(skillsDir))
9
- return [];
10
- const skills = [];
11
- try {
12
- const entries = readdirSync(skillsDir, { withFileTypes: true });
13
- for (const entry of entries) {
14
- if (entry.name.startsWith("."))
15
- continue;
16
- const entryPath = join(skillsDir, entry.name);
17
- if (entry.isDirectory()) {
18
- const skillMdPath = join(entryPath, "SKILL.md");
19
- if (!existsSync(skillMdPath))
20
- continue;
21
- try {
22
- const content = readFileSync(skillMdPath, "utf-8");
23
- const { data } = parseFrontmatter(content);
24
- if (data.name && data.description) {
25
- skills.push({
26
- name: data.name,
27
- description: data.description,
28
- location: skillMdPath,
29
- scope,
30
- });
31
- }
32
- }
33
- catch {
34
- // Skip invalid skill files
35
- }
36
- }
37
- }
38
- }
39
- catch {
40
- // Directory not accessible
41
- }
42
- return skills;
5
+ import { discoverAllSkills, formatPluginSkillsAsXmlItems, } from "./skill";
6
+ function createSkillFile(dir, name, content) {
7
+ const skillDir = join(dir, name);
8
+ mkdirSync(skillDir, { recursive: true });
9
+ const skillPath = join(skillDir, "SKILL.md");
10
+ writeFileSync(skillPath, content);
11
+ return skillPath;
43
12
  }
44
- function pluginSkillsToInfo(skills, pluginDir) {
45
- return skills.map(s => ({
46
- name: s.name,
47
- description: s.description,
48
- location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
49
- scope: "plugin",
50
- }));
51
- }
52
- function formatSkillsXml(skills) {
53
- if (skills.length === 0)
54
- return "";
55
- const skillsXml = skills
56
- .map(skill => {
57
- return [
58
- " <skill>",
59
- ` <name>${skill.name}</name>`,
60
- ` <description>${skill.description}</description>`,
61
- " </skill>",
62
- ].join("\n");
63
- })
64
- .join("\n");
65
- return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`;
66
- }
67
- describe("skill discovery", () => {
13
+ describe("formatPluginSkillsAsXmlItems", () => {
14
+ it("should return empty string for no skills", () => {
15
+ expect(formatPluginSkillsAsXmlItems([], "/plugin")).toBe("");
16
+ });
17
+ it("should format skills without wrapping <available_skills> tags", () => {
18
+ const skills = [
19
+ { name: "tdd", description: "Apply TDD", path: "/p/SKILL.md", body: "" },
20
+ ];
21
+ const result = formatPluginSkillsAsXmlItems(skills, "/plugin");
22
+ expect(result).toContain("<skill>");
23
+ expect(result).toContain("<name>tdd</name>");
24
+ expect(result).toContain("<description>Apply TDD</description>");
25
+ expect(result).not.toContain("<available_skills>");
26
+ });
27
+ it("should format multiple skills", () => {
28
+ const skills = [
29
+ { name: "a", description: "A", path: "/a", body: "" },
30
+ { name: "b", description: "B", path: "/b", body: "" },
31
+ ];
32
+ const result = formatPluginSkillsAsXmlItems(skills, "/plugin");
33
+ expect(result).toContain("<name>a</name>");
34
+ expect(result).toContain("<name>b</name>");
35
+ });
36
+ });
37
+ describe("discoverAllSkills", () => {
68
38
  let testDir;
69
39
  beforeEach(() => {
70
- testDir = join(tmpdir(), `skill-test-${Date.now()}`);
40
+ testDir = join(tmpdir(), `skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
71
41
  mkdirSync(testDir, { recursive: true });
72
42
  });
73
43
  afterEach(() => {
74
44
  rmSync(testDir, { recursive: true, force: true });
75
45
  });
76
- function createSkillFile(dir, name, content) {
77
- const skillDir = join(dir, name);
78
- mkdirSync(skillDir, { recursive: true });
79
- const skillPath = join(skillDir, "SKILL.md");
80
- writeFileSync(skillPath, content);
81
- return skillPath;
82
- }
83
- describe("discoverSkillsFromDir", () => {
84
- it("should return empty array for non-existent directory", () => {
85
- const result = discoverSkillsFromDir("/non/existent/path", "plugin");
86
- expect(result).toEqual([]);
46
+ it("should return empty array when no skills and no plugin skills", () => {
47
+ const result = discoverAllSkills({
48
+ pluginSkills: [],
49
+ pluginDir: "/plugin",
50
+ cwd: testDir,
87
51
  });
88
- it("should discover valid skill with name and description", () => {
89
- createSkillFile(testDir, "my-skill", `---
90
- name: my-skill
91
- description: A test skill
92
- ---
93
-
94
- Skill content here.`);
95
- const result = discoverSkillsFromDir(testDir, "opencode");
96
- expect(result).toHaveLength(1);
97
- expect(result[0].name).toBe("my-skill");
98
- expect(result[0].description).toBe("A test skill");
99
- expect(result[0].scope).toBe("opencode");
100
- });
101
- it("should ignore directories without SKILL.md", () => {
102
- const skillDir = join(testDir, "no-skill");
103
- mkdirSync(skillDir);
104
- writeFileSync(join(skillDir, "README.md"), "Not a skill");
105
- const result = discoverSkillsFromDir(testDir, "plugin");
106
- expect(result).toHaveLength(0);
52
+ expect(result).toEqual([]);
53
+ });
54
+ it("should include plugin skills with scope=plugin", () => {
55
+ const pluginSkills = [
56
+ { name: "tdd", description: "TDD", path: "/plugin/skill/tdd/SKILL.md", body: "" },
57
+ ];
58
+ const result = discoverAllSkills({
59
+ pluginSkills,
60
+ pluginDir: "/plugin",
61
+ cwd: testDir,
107
62
  });
108
- it("should ignore skills without name", () => {
109
- createSkillFile(testDir, "nameless", `---
110
- description: Has description but no name
63
+ expect(result).toHaveLength(1);
64
+ expect(result[0].name).toBe("tdd");
65
+ expect(result[0].scope).toBe("plugin");
66
+ });
67
+ it("should discover .opencode/skills/ in cwd", () => {
68
+ const opencodeDir = join(testDir, ".opencode", "skills");
69
+ mkdirSync(opencodeDir, { recursive: true });
70
+ createSkillFile(opencodeDir, "project-skill", `---
71
+ name: project-skill
72
+ description: From project
111
73
  ---
112
-
113
- Content`);
114
- const result = discoverSkillsFromDir(testDir, "plugin");
115
- expect(result).toHaveLength(0);
74
+ Body`);
75
+ const result = discoverAllSkills({
76
+ pluginSkills: [],
77
+ pluginDir: "/plugin",
78
+ cwd: testDir,
116
79
  });
117
- it("should ignore skills without description", () => {
118
- createSkillFile(testDir, "no-desc", `---
119
- name: no-desc-skill
80
+ expect(result).toHaveLength(1);
81
+ expect(result[0].name).toBe("project-skill");
82
+ expect(result[0].scope).toBe("opencode-project");
83
+ });
84
+ it("should discover .claude/skills/ in cwd", () => {
85
+ const claudeDir = join(testDir, ".claude", "skills");
86
+ mkdirSync(claudeDir, { recursive: true });
87
+ createSkillFile(claudeDir, "claude-skill", `---
88
+ name: claude-skill
89
+ description: From claude
120
90
  ---
121
-
122
- Content`);
123
- const result = discoverSkillsFromDir(testDir, "plugin");
124
- expect(result).toHaveLength(0);
91
+ Body`);
92
+ const result = discoverAllSkills({
93
+ pluginSkills: [],
94
+ pluginDir: "/plugin",
95
+ cwd: testDir,
125
96
  });
126
- it("should ignore hidden directories", () => {
127
- createSkillFile(testDir, ".hidden-skill", `---
128
- name: hidden
129
- description: Hidden skill
97
+ expect(result).toHaveLength(1);
98
+ expect(result[0].scope).toBe("claude-project");
99
+ });
100
+ it("should let project skills override plugin skills with same name", () => {
101
+ const pluginSkills = [
102
+ { name: "shared", description: "Plugin version", path: "/p", body: "" },
103
+ ];
104
+ const opencodeDir = join(testDir, ".opencode", "skills");
105
+ mkdirSync(opencodeDir, { recursive: true });
106
+ createSkillFile(opencodeDir, "shared", `---
107
+ name: shared
108
+ description: Project version
130
109
  ---
131
-
132
- Content`);
133
- const result = discoverSkillsFromDir(testDir, "plugin");
134
- expect(result).toHaveLength(0);
110
+ Body`);
111
+ const result = discoverAllSkills({
112
+ pluginSkills,
113
+ pluginDir: "/plugin",
114
+ cwd: testDir,
135
115
  });
136
- it("should discover multiple skills", () => {
137
- createSkillFile(testDir, "skill-a", `---
138
- name: skill-a
139
- description: First skill
116
+ expect(result).toHaveLength(1);
117
+ expect(result[0].description).toBe("Project version");
118
+ expect(result[0].scope).toBe("opencode-project");
119
+ });
120
+ it("should ignore directories without SKILL.md", () => {
121
+ const opencodeDir = join(testDir, ".opencode", "skills");
122
+ const incompleteDir = join(opencodeDir, "no-skill");
123
+ mkdirSync(incompleteDir, { recursive: true });
124
+ writeFileSync(join(incompleteDir, "README.md"), "not a skill");
125
+ const result = discoverAllSkills({
126
+ pluginSkills: [],
127
+ pluginDir: "/plugin",
128
+ cwd: testDir,
129
+ });
130
+ expect(result).toHaveLength(0);
131
+ });
132
+ it("should ignore skills missing name or description", () => {
133
+ const opencodeDir = join(testDir, ".opencode", "skills");
134
+ mkdirSync(opencodeDir, { recursive: true });
135
+ createSkillFile(opencodeDir, "no-name", `---
136
+ description: no name
140
137
  ---
141
- Content A`);
142
- createSkillFile(testDir, "skill-b", `---
143
- name: skill-b
144
- description: Second skill
138
+ Body`);
139
+ createSkillFile(opencodeDir, "no-desc", `---
140
+ name: no-desc
145
141
  ---
146
- Content B`);
147
- const result = discoverSkillsFromDir(testDir, "opencode-project");
148
- expect(result).toHaveLength(2);
149
- expect(result.map(s => s.name).sort()).toEqual(["skill-a", "skill-b"]);
142
+ Body`);
143
+ const result = discoverAllSkills({
144
+ pluginSkills: [],
145
+ pluginDir: "/plugin",
146
+ cwd: testDir,
150
147
  });
148
+ expect(result).toHaveLength(0);
151
149
  });
152
- describe("pluginSkillsToInfo", () => {
153
- it("should convert LoadedSkill array to SkillInfo array", () => {
154
- const pluginSkills = [
155
- {
156
- name: "test-skill",
157
- description: "A test skill",
158
- path: "/fake/path/SKILL.md",
159
- body: "Test body",
160
- },
161
- ];
162
- const result = pluginSkillsToInfo(pluginSkills, "/plugin/dir");
163
- expect(result).toHaveLength(1);
164
- expect(result[0].name).toBe("test-skill");
165
- expect(result[0].description).toBe("A test skill");
166
- expect(result[0].location).toBe("/fake/path/SKILL.md");
167
- expect(result[0].scope).toBe("plugin");
168
- });
169
- it("should use default path when path not provided", () => {
170
- const pluginSkills = [
171
- {
172
- name: "no-path-skill",
173
- description: "Skill without path",
174
- path: "",
175
- body: "",
176
- },
177
- ];
178
- const result = pluginSkillsToInfo(pluginSkills, "/my/plugin");
179
- expect(result[0].location).toBe("/my/plugin/skill/no-path-skill/SKILL.md");
150
+ it("should ignore hidden directories", () => {
151
+ const opencodeDir = join(testDir, ".opencode", "skills");
152
+ mkdirSync(opencodeDir, { recursive: true });
153
+ createSkillFile(opencodeDir, ".hidden", `---
154
+ name: hidden
155
+ description: hidden skill
156
+ ---
157
+ Body`);
158
+ const result = discoverAllSkills({
159
+ pluginSkills: [],
160
+ pluginDir: "/plugin",
161
+ cwd: testDir,
180
162
  });
163
+ expect(result).toHaveLength(0);
181
164
  });
182
- describe("formatSkillsXml", () => {
183
- it("should return empty string for no skills", () => {
184
- const result = formatSkillsXml([]);
185
- expect(result).toBe("");
186
- });
187
- it("should format single skill as XML", () => {
188
- const skills = [
189
- {
190
- name: "my-skill",
191
- description: "My description",
192
- location: "/path",
193
- scope: "plugin",
194
- },
195
- ];
196
- const result = formatSkillsXml(skills);
197
- expect(result).toContain("<available_skills>");
198
- expect(result).toContain("<name>my-skill</name>");
199
- expect(result).toContain("<description>My description</description>");
200
- expect(result).toContain("</available_skills>");
201
- });
202
- it("should format multiple skills", () => {
203
- const skills = [
204
- { name: "skill-a", description: "Desc A", location: "/a", scope: "plugin" },
205
- { name: "skill-b", description: "Desc B", location: "/b", scope: "opencode" },
206
- ];
207
- const result = formatSkillsXml(skills);
208
- expect(result).toContain("<name>skill-a</name>");
209
- expect(result).toContain("<name>skill-b</name>");
165
+ it("should let opencode-project override claude-project with same name", () => {
166
+ const claudeDir = join(testDir, ".claude", "skills");
167
+ mkdirSync(claudeDir, { recursive: true });
168
+ createSkillFile(claudeDir, "shared", `---
169
+ name: shared
170
+ description: Claude version
171
+ ---
172
+ Body`);
173
+ const opencodeDir = join(testDir, ".opencode", "skills");
174
+ mkdirSync(opencodeDir, { recursive: true });
175
+ createSkillFile(opencodeDir, "shared", `---
176
+ name: shared
177
+ description: Opencode version
178
+ ---
179
+ Body`);
180
+ const result = discoverAllSkills({
181
+ pluginSkills: [],
182
+ pluginDir: "/plugin",
183
+ cwd: testDir,
210
184
  });
185
+ expect(result).toHaveLength(1);
186
+ expect(result[0].description).toBe("Opencode version");
187
+ expect(result[0].scope).toBe("opencode-project");
211
188
  });
212
- describe("skill deduplication", () => {
213
- it("should demonstrate last-wins deduplication behavior", () => {
214
- // This tests the expected behavior when skills are merged
215
- const allSkills = [
216
- { name: "shared", description: "Plugin version", location: "/plugin", scope: "plugin" },
217
- { name: "shared", description: "Global version", location: "/global", scope: "opencode" },
218
- { name: "shared", description: "Project version", location: "/project", scope: "opencode-project" },
219
- ];
220
- // Simulate deduplication (last wins)
221
- const skillMap = new Map();
222
- for (const skill of allSkills) {
223
- skillMap.set(skill.name, skill);
224
- }
225
- const deduplicated = Array.from(skillMap.values());
226
- expect(deduplicated).toHaveLength(1);
227
- expect(deduplicated[0].description).toBe("Project version");
228
- expect(deduplicated[0].scope).toBe("opencode-project");
189
+ it("should aggregate multiple sources", () => {
190
+ const pluginSkills = [
191
+ { name: "plugin-only", description: "P", path: "/p", body: "" },
192
+ ];
193
+ const opencodeDir = join(testDir, ".opencode", "skills");
194
+ mkdirSync(opencodeDir, { recursive: true });
195
+ createSkillFile(opencodeDir, "opencode-only", `---
196
+ name: opencode-only
197
+ description: O
198
+ ---
199
+ Body`);
200
+ const claudeDir = join(testDir, ".claude", "skills");
201
+ mkdirSync(claudeDir, { recursive: true });
202
+ createSkillFile(claudeDir, "claude-only", `---
203
+ name: claude-only
204
+ description: C
205
+ ---
206
+ Body`);
207
+ const result = discoverAllSkills({
208
+ pluginSkills,
209
+ pluginDir: "/plugin",
210
+ cwd: testDir,
229
211
  });
212
+ expect(result).toHaveLength(3);
213
+ const byName = new Map(result.map((s) => [s.name, s]));
214
+ expect(byName.get("plugin-only")?.scope).toBe("plugin");
215
+ expect(byName.get("opencode-only")?.scope).toBe("opencode-project");
216
+ expect(byName.get("claude-only")?.scope).toBe("claude-project");
230
217
  });
231
218
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-froggy",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "OpenCode plugin with a hook layer (tool.before.*, session.idle...), agents (code-reviewer, doc-writer), and commands (/review-pr, /commit)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",