opencode-froggy 0.10.1 → 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/command/gh-create-pr.md +4 -0
- package/dist/index.js +16 -11
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.js +0 -1
- package/package.json +1 -1
- package/dist/tools/skill.d.ts +0 -25
- package/dist/tools/skill.js +0 -165
- package/dist/tools/skill.test.d.ts +0 -1
- package/dist/tools/skill.test.js +0 -231
package/command/gh-create-pr.md
CHANGED
|
@@ -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,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,
|
|
8
|
+
import { gitingestTool, pdfToMarkdownTool, createPromptSessionTool, createListChildSessionsTool, createAgentPromoteTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
|
|
9
9
|
export { parseFrontmatter, loadAgents, loadCommands } from "./loaders";
|
|
10
10
|
export { buildSkillActivationBlock } from "./skill-activation";
|
|
11
11
|
import { buildSkillActivationBlock } from "./skill-activation";
|
|
@@ -34,11 +34,6 @@ const SmartfrogPlugin = async (ctx) => {
|
|
|
34
34
|
const skillActivationBlock = skillsWithTriggers.length > 0
|
|
35
35
|
? buildSkillActivationBlock(skillsWithTriggers)
|
|
36
36
|
: null;
|
|
37
|
-
const skillTool = createSkillTool({
|
|
38
|
-
pluginSkills: skills,
|
|
39
|
-
pluginDir: PLUGIN_ROOT,
|
|
40
|
-
client: ctx.client,
|
|
41
|
-
});
|
|
42
37
|
log("[init] Plugin loaded", {
|
|
43
38
|
agents: Object.keys(agents),
|
|
44
39
|
commands: Object.keys(commands),
|
|
@@ -48,7 +43,6 @@ const SmartfrogPlugin = async (ctx) => {
|
|
|
48
43
|
tools: [
|
|
49
44
|
"gitingest",
|
|
50
45
|
"pdf-to-markdown",
|
|
51
|
-
"skill",
|
|
52
46
|
"agent-promote",
|
|
53
47
|
"eth-transaction",
|
|
54
48
|
"eth-address-txs",
|
|
@@ -203,11 +197,20 @@ const SmartfrogPlugin = async (ctx) => {
|
|
|
203
197
|
if (Object.keys(commands).length > 0) {
|
|
204
198
|
config.command = { ...(config.command ?? {}), ...commands };
|
|
205
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
|
+
}
|
|
206
210
|
},
|
|
207
211
|
tool: {
|
|
208
212
|
gitingest: gitingestTool,
|
|
209
213
|
"pdf-to-markdown": pdfToMarkdownTool,
|
|
210
|
-
skill: skillTool,
|
|
211
214
|
"prompt-session": createPromptSessionTool(ctx.client),
|
|
212
215
|
"list-child-sessions": createListChildSessionsTool(ctx.client),
|
|
213
216
|
"agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
|
|
@@ -284,9 +287,11 @@ const SmartfrogPlugin = async (ctx) => {
|
|
|
284
287
|
}
|
|
285
288
|
},
|
|
286
289
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
+
// The activation block relies on OpenCode's native `skill` tool after we
|
|
291
|
+
// expose the plugin's bundled skills through `config.skills.paths`.
|
|
292
|
+
if (skillActivationBlock) {
|
|
293
|
+
output.system.push(skillActivationBlock);
|
|
294
|
+
}
|
|
290
295
|
},
|
|
291
296
|
};
|
|
292
297
|
};
|
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, type CreateSkillToolOptions, type SkillInfo } 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 } 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",
|
package/dist/tools/skill.d.ts
DELETED
|
@@ -1,25 +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 interface SkillInfo {
|
|
6
|
-
name: string;
|
|
7
|
-
description: string;
|
|
8
|
-
location: string;
|
|
9
|
-
scope: "plugin" | "opencode" | "opencode-project" | "claude" | "claude-project";
|
|
10
|
-
}
|
|
11
|
-
export interface CreateSkillToolOptions {
|
|
12
|
-
pluginSkills: LoadedSkill[];
|
|
13
|
-
pluginDir: string;
|
|
14
|
-
client: Client;
|
|
15
|
-
}
|
|
16
|
-
export declare function createSkillTool(options: CreateSkillToolOptions): {
|
|
17
|
-
description: string;
|
|
18
|
-
args: {
|
|
19
|
-
name: import("zod").ZodString;
|
|
20
|
-
};
|
|
21
|
-
execute(args: {
|
|
22
|
-
name: string;
|
|
23
|
-
}, context: ToolContext): Promise<string>;
|
|
24
|
-
};
|
|
25
|
-
export {};
|
package/dist/tools/skill.js
DELETED
|
@@ -1,165 +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
|
-
const entryPath = join(skillsDir, entry.name);
|
|
19
|
-
if (entry.isDirectory()) {
|
|
20
|
-
const skillMdPath = join(entryPath, "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
|
-
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
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
// Directory not accessible
|
|
43
|
-
}
|
|
44
|
-
return skills;
|
|
45
|
-
}
|
|
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
|
-
function pluginSkillsToInfo(skills, pluginDir) {
|
|
63
|
-
return skills.map(s => ({
|
|
64
|
-
name: s.name,
|
|
65
|
-
description: s.description,
|
|
66
|
-
location: s.path || join(pluginDir, "skill", s.name, "SKILL.md"),
|
|
67
|
-
scope: "plugin",
|
|
68
|
-
}));
|
|
69
|
-
}
|
|
70
|
-
function formatSkillsXml(skills) {
|
|
71
|
-
if (skills.length === 0)
|
|
72
|
-
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>`;
|
|
84
|
-
}
|
|
85
|
-
function loadSkillContent(location) {
|
|
86
|
-
const content = readFileSync(location, "utf-8");
|
|
87
|
-
const { body } = parseFrontmatter(content);
|
|
88
|
-
return body.trim();
|
|
89
|
-
}
|
|
90
|
-
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);
|
|
126
|
-
return tool({
|
|
127
|
-
get description() {
|
|
128
|
-
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX;
|
|
129
|
-
},
|
|
130
|
-
args: {
|
|
131
|
-
name: tool.schema
|
|
132
|
-
.string()
|
|
133
|
-
.describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
|
|
134
|
-
},
|
|
135
|
-
async execute(args, _context) {
|
|
136
|
-
const skills = getSkills(cwd);
|
|
137
|
-
const skill = skills.find(s => s.name === args.name);
|
|
138
|
-
if (!skill) {
|
|
139
|
-
const available = skills.map(s => s.name).join(", ");
|
|
140
|
-
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`);
|
|
141
|
-
}
|
|
142
|
-
const body = loadSkillContent(skill.location);
|
|
143
|
-
const dir = dirname(skill.location);
|
|
144
|
-
try {
|
|
145
|
-
await client.tui.showToast({
|
|
146
|
-
body: {
|
|
147
|
-
message: `Skill "${skill.name}" loaded`,
|
|
148
|
-
variant: "info",
|
|
149
|
-
duration: 3000,
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
catch (error) {
|
|
154
|
-
log("[skill] Failed to show toast", { error: String(error) });
|
|
155
|
-
}
|
|
156
|
-
return [
|
|
157
|
-
`## Skill: ${skill.name}`,
|
|
158
|
-
"",
|
|
159
|
-
`**Base directory**: ${dir}`,
|
|
160
|
-
"",
|
|
161
|
-
body,
|
|
162
|
-
].join("\n");
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tools/skill.test.js
DELETED
|
@@ -1,231 +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 { 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;
|
|
43
|
-
}
|
|
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", () => {
|
|
68
|
-
let testDir;
|
|
69
|
-
beforeEach(() => {
|
|
70
|
-
testDir = join(tmpdir(), `skill-test-${Date.now()}`);
|
|
71
|
-
mkdirSync(testDir, { recursive: true });
|
|
72
|
-
});
|
|
73
|
-
afterEach(() => {
|
|
74
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
75
|
-
});
|
|
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([]);
|
|
87
|
-
});
|
|
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);
|
|
107
|
-
});
|
|
108
|
-
it("should ignore skills without name", () => {
|
|
109
|
-
createSkillFile(testDir, "nameless", `---
|
|
110
|
-
description: Has description but no name
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
Content`);
|
|
114
|
-
const result = discoverSkillsFromDir(testDir, "plugin");
|
|
115
|
-
expect(result).toHaveLength(0);
|
|
116
|
-
});
|
|
117
|
-
it("should ignore skills without description", () => {
|
|
118
|
-
createSkillFile(testDir, "no-desc", `---
|
|
119
|
-
name: no-desc-skill
|
|
120
|
-
---
|
|
121
|
-
|
|
122
|
-
Content`);
|
|
123
|
-
const result = discoverSkillsFromDir(testDir, "plugin");
|
|
124
|
-
expect(result).toHaveLength(0);
|
|
125
|
-
});
|
|
126
|
-
it("should ignore hidden directories", () => {
|
|
127
|
-
createSkillFile(testDir, ".hidden-skill", `---
|
|
128
|
-
name: hidden
|
|
129
|
-
description: Hidden skill
|
|
130
|
-
---
|
|
131
|
-
|
|
132
|
-
Content`);
|
|
133
|
-
const result = discoverSkillsFromDir(testDir, "plugin");
|
|
134
|
-
expect(result).toHaveLength(0);
|
|
135
|
-
});
|
|
136
|
-
it("should discover multiple skills", () => {
|
|
137
|
-
createSkillFile(testDir, "skill-a", `---
|
|
138
|
-
name: skill-a
|
|
139
|
-
description: First skill
|
|
140
|
-
---
|
|
141
|
-
Content A`);
|
|
142
|
-
createSkillFile(testDir, "skill-b", `---
|
|
143
|
-
name: skill-b
|
|
144
|
-
description: Second skill
|
|
145
|
-
---
|
|
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"]);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
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");
|
|
180
|
-
});
|
|
181
|
-
});
|
|
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>");
|
|
210
|
-
});
|
|
211
|
-
});
|
|
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");
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
});
|