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.
- package/command/gh-create-pr.md +4 -0
- package/dist/index.js +10 -4
- package/dist/skill-injection.d.ts +1 -0
- package/dist/skill-injection.js +13 -0
- package/dist/skill-injection.test.d.ts +1 -0
- package/dist/skill-injection.test.js +60 -0
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/skill.d.ts +10 -1
- package/dist/tools/skill.js +58 -86
- package/dist/tools/skill.test.js +187 -200
- package/package.json +1 -1
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,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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
});
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/tools/index.js
CHANGED
|
@@ -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";
|
package/dist/tools/skill.d.ts
CHANGED
|
@@ -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:
|
|
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): {
|
package/dist/tools/skill.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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., '
|
|
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(", ");
|
package/dist/tools/skill.test.js
CHANGED
|
@@ -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 {
|
|
6
|
-
function
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
45
|
-
return skills
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
]
|
|
63
|
-
|
|
64
|
-
.
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
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
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
74
|
+
Body`);
|
|
75
|
+
const result = discoverAllSkills({
|
|
76
|
+
pluginSkills: [],
|
|
77
|
+
pluginDir: "/plugin",
|
|
78
|
+
cwd: testDir,
|
|
116
79
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
91
|
+
Body`);
|
|
92
|
+
const result = discoverAllSkills({
|
|
93
|
+
pluginSkills: [],
|
|
94
|
+
pluginDir: "/plugin",
|
|
95
|
+
cwd: testDir,
|
|
125
96
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
110
|
+
Body`);
|
|
111
|
+
const result = discoverAllSkills({
|
|
112
|
+
pluginSkills,
|
|
113
|
+
pluginDir: "/plugin",
|
|
114
|
+
cwd: testDir,
|
|
135
115
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
name:
|
|
144
|
-
description: Second skill
|
|
138
|
+
Body`);
|
|
139
|
+
createSkillFile(opencodeDir, "no-desc", `---
|
|
140
|
+
name: no-desc
|
|
145
141
|
---
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
142
|
+
Body`);
|
|
143
|
+
const result = discoverAllSkills({
|
|
144
|
+
pluginSkills: [],
|
|
145
|
+
pluginDir: "/plugin",
|
|
146
|
+
cwd: testDir,
|
|
150
147
|
});
|
|
148
|
+
expect(result).toHaveLength(0);
|
|
151
149
|
});
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
]
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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",
|