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 +13 -14
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.js +0 -1
- package/package.json +1 -1
- package/dist/skill-injection.d.ts +0 -1
- package/dist/skill-injection.js +0 -13
- package/dist/skill-injection.test.d.ts +0 -1
- package/dist/skill-injection.test.js +0 -60
- package/dist/tools/skill.d.ts +0 -34
- package/dist/tools/skill.js +0 -137
- package/dist/tools/skill.test.d.ts +0 -1
- package/dist/tools/skill.test.js +0 -218
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,
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/tools/index.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/skill-injection.js
DELETED
|
@@ -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
|
-
});
|
package/dist/tools/skill.d.ts
DELETED
|
@@ -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 {};
|
package/dist/tools/skill.js
DELETED
|
@@ -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 {};
|
package/dist/tools/skill.test.js
DELETED
|
@@ -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
|
-
});
|