opencode-froggy 0.10.2 → 0.11.0

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.
package/dist/index.js CHANGED
@@ -5,8 +5,7 @@ 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, formatPluginSkillsAsXmlItems, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
9
- import { injectPluginSkillsIntoSystem } from "./skill-injection";
8
+ import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
10
9
  export { parseFrontmatter, loadAgents, loadCommands } from "./loaders";
11
10
  export { buildSkillActivationBlock } from "./skill-activation";
12
11
  import { buildSkillActivationBlock } from "./skill-activation";
@@ -35,15 +34,6 @@ const SmartfrogPlugin = async (ctx) => {
35
34
  const skillActivationBlock = skillsWithTriggers.length > 0
36
35
  ? buildSkillActivationBlock(skillsWithTriggers)
37
36
  : null;
38
- const skillTool = createSkillTool({
39
- pluginSkills: skills,
40
- pluginDir: PLUGIN_ROOT,
41
- cwd: ctx.directory,
42
- client: ctx.client,
43
- });
44
- const pluginSkillsXmlItems = skills.length > 0
45
- ? formatPluginSkillsAsXmlItems(skills, PLUGIN_ROOT)
46
- : null;
47
37
  log("[init] Plugin loaded", {
48
38
  agents: Object.keys(agents),
49
39
  commands: Object.keys(commands),
@@ -53,7 +43,6 @@ const SmartfrogPlugin = async (ctx) => {
53
43
  tools: [
54
44
  "gitingest",
55
45
  "pdf-to-markdown",
56
- "skill",
57
46
  "agent-promote",
58
47
  "eth-transaction",
59
48
  "eth-address-txs",
@@ -208,11 +197,20 @@ const SmartfrogPlugin = async (ctx) => {
208
197
  if (Object.keys(commands).length > 0) {
209
198
  config.command = { ...(config.command ?? {}), ...commands };
210
199
  }
200
+ if (skills.length > 0) {
201
+ const existingSkills = config.skills ?? {};
202
+ const existingPaths = Array.isArray(existingSkills.paths) ? existingSkills.paths : [];
203
+ config.skills = {
204
+ ...existingSkills,
205
+ paths: existingPaths.includes(SKILL_DIR)
206
+ ? existingPaths
207
+ : [...existingPaths, SKILL_DIR],
208
+ };
209
+ }
211
210
  },
212
211
  tool: {
213
212
  gitingest: gitingestTool,
214
213
  "pdf-to-markdown": pdfToMarkdownTool,
215
- skill: skillTool,
216
214
  "prompt-session": createPromptSessionTool(ctx.client),
217
215
  "list-child-sessions": createListChildSessionsTool(ctx.client),
218
216
  "agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
@@ -289,7 +287,8 @@ const SmartfrogPlugin = async (ctx) => {
289
287
  }
290
288
  },
291
289
  "experimental.chat.system.transform": async (_input, output) => {
292
- injectPluginSkillsIntoSystem(output.system, pluginSkillsXmlItems);
290
+ // The activation block relies on OpenCode's native `skill` tool after we
291
+ // expose the plugin's bundled skills through `config.skills.paths`.
293
292
  if (skillActivationBlock) {
294
293
  output.system.push(skillActivationBlock);
295
294
  }
@@ -4,5 +4,4 @@ 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, discoverAllSkills, formatPluginSkillsAsXmlItems, type CreateSkillToolOptions, type DiscoverAllSkillsOptions, type SkillInfo, type SkillScope, } from "./skill";
8
7
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, type EthTransactionArgs, type EthAddressTxsArgs, type EthAddressBalanceArgs, type EthTokenTransfersArgs, } from "./blockchain";
@@ -4,5 +4,4 @@ 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, discoverAllSkills, formatPluginSkillsAsXmlItems, } from "./skill";
8
7
  export { ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, EtherscanClient, EtherscanClientError, weiToEth, formatTimestamp, shortenAddress, } from "./blockchain";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-froggy",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
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",
@@ -1 +0,0 @@
1
- export declare function injectPluginSkillsIntoSystem(system: string[], pluginSkillsXmlItems: string | null): void;
@@ -1,13 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,60 +0,0 @@
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
- });
@@ -1,34 +0,0 @@
1
- import { type ToolContext } from "@opencode-ai/plugin";
2
- import type { createOpencodeClient } from "@opencode-ai/sdk";
3
- import { type LoadedSkill } from "../loaders";
4
- type Client = ReturnType<typeof createOpencodeClient>;
5
- export type SkillScope = "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project";
6
- export interface SkillInfo {
7
- name: string;
8
- description: string;
9
- location: string;
10
- scope: SkillScope;
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;
19
- export interface CreateSkillToolOptions {
20
- pluginSkills: LoadedSkill[];
21
- pluginDir: string;
22
- cwd: string;
23
- client: Client;
24
- }
25
- export declare function createSkillTool(options: CreateSkillToolOptions): {
26
- description: string;
27
- args: {
28
- name: import("zod").ZodString;
29
- };
30
- execute(args: {
31
- name: string;
32
- }, context: ToolContext): Promise<string>;
33
- };
34
- export {};
@@ -1,137 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join, dirname } from "node:path";
4
- import { homedir } from "node:os";
5
- import { parseFrontmatter } from "../loaders";
6
- import { log } from "../logger";
7
- const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.`;
8
- const TOOL_DESCRIPTION_NO_SKILLS = `${TOOL_DESCRIPTION_PREFIX} No skills are currently available.`;
9
- function discoverSkillsFromDir(skillsDir, scope) {
10
- if (!existsSync(skillsDir))
11
- return [];
12
- const skills = [];
13
- try {
14
- const entries = readdirSync(skillsDir, { withFileTypes: true });
15
- for (const entry of entries) {
16
- if (entry.name.startsWith("."))
17
- continue;
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)
27
- continue;
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
37
- }
38
- }
39
- }
40
- catch {
41
- // Directory not accessible
42
- }
43
- return skills;
44
- }
45
- function pluginSkillsToInfo(skills, pluginDir) {
46
- return skills.map(s => ({
47
- name: s.name,
48
- description: s.description,
49
- location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
50
- scope: "plugin",
51
- }));
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
- }
63
- function formatSkillsXml(skills) {
64
- if (skills.length === 0)
65
- return "";
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));
89
- }
90
- function loadSkillContent(location) {
91
- const content = readFileSync(location, "utf-8");
92
- const { body } = parseFrontmatter(content);
93
- return body.trim();
94
- }
95
- export function createSkillTool(options) {
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)}`;
101
- return tool({
102
- description,
103
- args: {
104
- name: tool.schema
105
- .string()
106
- .describe("The skill identifier from available_skills (e.g., 'tdd', 'openspec-propose')"),
107
- },
108
- async execute(args, _context) {
109
- const skill = skills.find(s => s.name === args.name);
110
- if (!skill) {
111
- const available = skills.map(s => s.name).join(", ");
112
- throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`);
113
- }
114
- const body = loadSkillContent(skill.location);
115
- const dir = dirname(skill.location);
116
- try {
117
- await client.tui.showToast({
118
- body: {
119
- message: `Skill "${skill.name}" loaded`,
120
- variant: "info",
121
- duration: 3000,
122
- },
123
- });
124
- }
125
- catch (error) {
126
- log("[skill] Failed to show toast", { error: String(error) });
127
- }
128
- return [
129
- `## Skill: ${skill.name}`,
130
- "",
131
- `**Base directory**: ${dir}`,
132
- "",
133
- body,
134
- ].join("\n");
135
- },
136
- });
137
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,218 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
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;
12
- }
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", () => {
38
- let testDir;
39
- beforeEach(() => {
40
- testDir = join(tmpdir(), `skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
41
- mkdirSync(testDir, { recursive: true });
42
- });
43
- afterEach(() => {
44
- rmSync(testDir, { recursive: true, force: true });
45
- });
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,
51
- });
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,
62
- });
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
73
- ---
74
- Body`);
75
- const result = discoverAllSkills({
76
- pluginSkills: [],
77
- pluginDir: "/plugin",
78
- cwd: testDir,
79
- });
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
90
- ---
91
- Body`);
92
- const result = discoverAllSkills({
93
- pluginSkills: [],
94
- pluginDir: "/plugin",
95
- cwd: testDir,
96
- });
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
109
- ---
110
- Body`);
111
- const result = discoverAllSkills({
112
- pluginSkills,
113
- pluginDir: "/plugin",
114
- cwd: testDir,
115
- });
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
137
- ---
138
- Body`);
139
- createSkillFile(opencodeDir, "no-desc", `---
140
- name: no-desc
141
- ---
142
- Body`);
143
- const result = discoverAllSkills({
144
- pluginSkills: [],
145
- pluginDir: "/plugin",
146
- cwd: testDir,
147
- });
148
- expect(result).toHaveLength(0);
149
- });
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,
162
- });
163
- expect(result).toHaveLength(0);
164
- });
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,
184
- });
185
- expect(result).toHaveLength(1);
186
- expect(result[0].description).toBe("Opencode version");
187
- expect(result[0].scope).toBe("opencode-project");
188
- });
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,
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");
217
- });
218
- });