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.
@@ -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, createSkillTool, getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, ethTokenTransfersTool, } from "./tools";
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
- if (!skillActivationBlock)
288
- return;
289
- output.system.push(skillActivationBlock);
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
  };
@@ -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";
@@ -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.10.1",
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,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 {};
@@ -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 {};
@@ -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
- });