opencode-froggy 0.1.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/README.md +440 -0
- package/agent/code-reviewer.md +89 -0
- package/agent/code-simplifier.md +77 -0
- package/agent/doc-writer.md +101 -0
- package/command/commit.md +18 -0
- package/command/review-changes.md +28 -0
- package/command/review-pr.md +29 -0
- package/command/simplify-changes.md +26 -0
- package/command/tests-coverage.md +7 -0
- package/dist/bash-executor.d.ts +15 -0
- package/dist/bash-executor.js +45 -0
- package/dist/code-files.d.ts +3 -0
- package/dist/code-files.js +50 -0
- package/dist/code-files.test.d.ts +1 -0
- package/dist/code-files.test.js +22 -0
- package/dist/config-paths.d.ts +11 -0
- package/dist/config-paths.js +32 -0
- package/dist/config-paths.test.d.ts +1 -0
- package/dist/config-paths.test.js +101 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +288 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +808 -0
- package/dist/loaders.d.ts +80 -0
- package/dist/loaders.js +135 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +15 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { loadAgents, loadSkills, loadCommands, loadHooks, mergeHooks, } from "./loaders";
|
|
5
|
+
import { getGlobalHookDir, getProjectHookDir } from "./config-paths";
|
|
6
|
+
import { hasCodeExtension } from "./code-files";
|
|
7
|
+
import { log } from "./logger";
|
|
8
|
+
import { executeBashAction, DEFAULT_BASH_TIMEOUT, } from "./bash-executor";
|
|
9
|
+
export { parseFrontmatter, loadAgents, loadSkills, loadCommands } from "./loaders";
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// CONSTANTS
|
|
12
|
+
// ============================================================================
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = dirname(__filename);
|
|
15
|
+
const PLUGIN_ROOT = join(__dirname, "..");
|
|
16
|
+
const AGENT_DIR = join(PLUGIN_ROOT, "agent");
|
|
17
|
+
const SKILL_DIR = join(PLUGIN_ROOT, "skill");
|
|
18
|
+
const COMMAND_DIR = join(PLUGIN_ROOT, "command");
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// PLUGIN
|
|
21
|
+
// ============================================================================
|
|
22
|
+
const SmartfrogPlugin = async (ctx) => {
|
|
23
|
+
const agents = loadAgents(AGENT_DIR);
|
|
24
|
+
const skills = loadSkills(SKILL_DIR);
|
|
25
|
+
const commands = loadCommands(COMMAND_DIR);
|
|
26
|
+
const globalHooks = loadHooks(getGlobalHookDir());
|
|
27
|
+
const projectHooks = loadHooks(getProjectHookDir(ctx.directory));
|
|
28
|
+
const hooks = mergeHooks(globalHooks, projectHooks);
|
|
29
|
+
const modifiedCodeFiles = new Map();
|
|
30
|
+
const pendingToolArgs = new Map();
|
|
31
|
+
let mainSessionID;
|
|
32
|
+
log("[init] Plugin loaded", {
|
|
33
|
+
agents: Object.keys(agents),
|
|
34
|
+
commands: Object.keys(commands),
|
|
35
|
+
skills: skills.map(s => s.name),
|
|
36
|
+
hooks: Array.from(hooks.keys()),
|
|
37
|
+
});
|
|
38
|
+
async function executeHookActions(hook, sessionID, extraLog, options) {
|
|
39
|
+
const prefix = `[hook:${hook.event}]`;
|
|
40
|
+
const canBlock = options?.canBlock ?? false;
|
|
41
|
+
const conditions = hook.conditions ?? [];
|
|
42
|
+
for (const condition of conditions) {
|
|
43
|
+
if (condition === "isMainSession" && sessionID !== mainSessionID) {
|
|
44
|
+
log(`${prefix} condition not met, skipping`, { sessionID, condition });
|
|
45
|
+
return { blocked: false };
|
|
46
|
+
}
|
|
47
|
+
if (condition === "hasCodeChange") {
|
|
48
|
+
const files = extraLog?.files;
|
|
49
|
+
if (!files || !files.some(hasCodeExtension)) {
|
|
50
|
+
log(`${prefix} condition not met, skipping`, { sessionID, condition });
|
|
51
|
+
return { blocked: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
log(`${prefix} starting`, {
|
|
56
|
+
sessionID,
|
|
57
|
+
conditions,
|
|
58
|
+
actions: hook.actions.length,
|
|
59
|
+
...extraLog
|
|
60
|
+
});
|
|
61
|
+
for (const action of hook.actions) {
|
|
62
|
+
try {
|
|
63
|
+
if ("command" in action) {
|
|
64
|
+
const { name, args = "" } = typeof action.command === "string"
|
|
65
|
+
? { name: action.command }
|
|
66
|
+
: action.command;
|
|
67
|
+
const { agent, model } = commands[name] ?? {};
|
|
68
|
+
log(`${prefix} executing command`, { command: name, args, agent, model });
|
|
69
|
+
const result = await ctx.client.session.command({
|
|
70
|
+
path: { id: sessionID },
|
|
71
|
+
body: {
|
|
72
|
+
command: name,
|
|
73
|
+
arguments: args,
|
|
74
|
+
agent,
|
|
75
|
+
model,
|
|
76
|
+
},
|
|
77
|
+
query: { directory: ctx.directory },
|
|
78
|
+
});
|
|
79
|
+
log(`${prefix} command result`, { command: name, status: result.response?.status, error: result.error });
|
|
80
|
+
}
|
|
81
|
+
else if ("skill" in action) {
|
|
82
|
+
log(`${prefix} executing skill`, { skill: action.skill });
|
|
83
|
+
const result = await ctx.client.session.prompt({
|
|
84
|
+
path: { id: sessionID },
|
|
85
|
+
body: { parts: [{ type: "text", text: `Use the skill tool to load the "${action.skill}" skill and follow its instructions.` }] },
|
|
86
|
+
query: { directory: ctx.directory },
|
|
87
|
+
});
|
|
88
|
+
log(`${prefix} skill result`, { skill: action.skill, status: result.response?.status, error: result.error });
|
|
89
|
+
}
|
|
90
|
+
else if ("tool" in action) {
|
|
91
|
+
log(`${prefix} executing tool`, { tool: action.tool.name });
|
|
92
|
+
const result = await ctx.client.session.prompt({
|
|
93
|
+
path: { id: sessionID },
|
|
94
|
+
body: { parts: [{ type: "text", text: `Use the ${action.tool.name} tool with these arguments: ${JSON.stringify(action.tool.args)}` }] },
|
|
95
|
+
query: { directory: ctx.directory },
|
|
96
|
+
});
|
|
97
|
+
log(`${prefix} tool result`, { tool: action.tool.name, status: result.response?.status, error: result.error });
|
|
98
|
+
}
|
|
99
|
+
else if ("bash" in action) {
|
|
100
|
+
const { command, timeout } = typeof action.bash === "string"
|
|
101
|
+
? { command: action.bash, timeout: DEFAULT_BASH_TIMEOUT }
|
|
102
|
+
: { command: action.bash.command, timeout: action.bash.timeout ?? DEFAULT_BASH_TIMEOUT };
|
|
103
|
+
const startTime = Date.now();
|
|
104
|
+
log(`${prefix} executing bash`, { command, timeout });
|
|
105
|
+
const bashContext = {
|
|
106
|
+
session_id: sessionID,
|
|
107
|
+
event: hook.event,
|
|
108
|
+
cwd: ctx.directory,
|
|
109
|
+
files: extraLog?.files,
|
|
110
|
+
tool_name: extraLog?.tool_name,
|
|
111
|
+
tool_args: extraLog?.tool_args,
|
|
112
|
+
};
|
|
113
|
+
const result = await executeBashAction(command, timeout, bashContext, ctx.directory);
|
|
114
|
+
const duration = Date.now() - startTime;
|
|
115
|
+
const statusIcon = result.exitCode === 0 ? "✓" : "✗";
|
|
116
|
+
const hookMessage = [
|
|
117
|
+
`[BASH HOOK ${statusIcon}] ${command}`,
|
|
118
|
+
`Exit: ${result.exitCode} | Duration: ${duration}ms`,
|
|
119
|
+
result.stdout.trim() ? `Stdout: ${result.stdout.slice(0, 500).trim()}` : null,
|
|
120
|
+
result.stderr.trim() ? `Stderr: ${result.stderr.slice(0, 500).trim()}` : null,
|
|
121
|
+
].filter(Boolean).join("\n");
|
|
122
|
+
await ctx.client.session.prompt({
|
|
123
|
+
path: { id: sessionID },
|
|
124
|
+
body: {
|
|
125
|
+
noReply: true,
|
|
126
|
+
parts: [{ type: "text", text: hookMessage }],
|
|
127
|
+
},
|
|
128
|
+
query: { directory: ctx.directory },
|
|
129
|
+
}).catch((err) => {
|
|
130
|
+
log(`${prefix} failed to send hook message`, { error: String(err) });
|
|
131
|
+
});
|
|
132
|
+
if (result.exitCode === 2) {
|
|
133
|
+
log(`${prefix} bash exit code 2`, { stderr: result.stderr, canBlock });
|
|
134
|
+
if (canBlock) {
|
|
135
|
+
const blockReason = result.stderr.trim() || "Blocked by hook";
|
|
136
|
+
return { blocked: true, blockReason };
|
|
137
|
+
}
|
|
138
|
+
return { blocked: false };
|
|
139
|
+
}
|
|
140
|
+
if (result.exitCode !== 0) {
|
|
141
|
+
log(`${prefix} bash failed (non-blocking)`, { exitCode: result.exitCode, stderr: result.stderr });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
log(`${prefix} bash completed`, { stdout: result.stdout.slice(0, 200) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
log(`${prefix} action failed, continuing`, { error: String(error) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
log(`${prefix} completed`);
|
|
153
|
+
return { blocked: false };
|
|
154
|
+
}
|
|
155
|
+
async function triggerHooks(event, sessionID, extraLog, options) {
|
|
156
|
+
const eventHooks = hooks.get(event);
|
|
157
|
+
if (!eventHooks)
|
|
158
|
+
return { blocked: false };
|
|
159
|
+
for (const hook of eventHooks) {
|
|
160
|
+
const result = await executeHookActions(hook, sessionID, extraLog, options);
|
|
161
|
+
if (result.blocked)
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
return { blocked: false };
|
|
165
|
+
}
|
|
166
|
+
async function triggerToolHooks(phase, toolName, sessionID, toolArgs) {
|
|
167
|
+
const canBlock = phase === "before";
|
|
168
|
+
const extraLog = { tool_name: toolName, tool_args: toolArgs };
|
|
169
|
+
const wildcardEvent = `tool.${phase}.*`;
|
|
170
|
+
const wildcardResult = await triggerHooks(wildcardEvent, sessionID, extraLog, { canBlock });
|
|
171
|
+
if (wildcardResult.blocked)
|
|
172
|
+
return wildcardResult;
|
|
173
|
+
const specificEvent = `tool.${phase}.${toolName}`;
|
|
174
|
+
const specificResult = await triggerHooks(specificEvent, sessionID, extraLog, { canBlock });
|
|
175
|
+
return specificResult;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
config: async (config) => {
|
|
179
|
+
if (Object.keys(agents).length > 0) {
|
|
180
|
+
config.agent = { ...(config.agent ?? {}), ...agents };
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(commands).length > 0) {
|
|
183
|
+
config.command = { ...(config.command ?? {}), ...commands };
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
tool: {
|
|
187
|
+
skill: tool({
|
|
188
|
+
description: `Load a skill to get detailed instructions for a specific task. Skills provide specialized knowledge and step-by-step guidance. Use this when a task matches an available skill's description. <available_skills>${skills.map((s) => `\n <skill>\n <name>${s.name}</name>\n <description>${s.description}</description>\n </skill>`).join("")}\n</available_skills>`,
|
|
189
|
+
args: {
|
|
190
|
+
name: tool.schema
|
|
191
|
+
.string()
|
|
192
|
+
.describe("The skill identifier from available_skills (e.g., 'post-change-code-simplification')"),
|
|
193
|
+
},
|
|
194
|
+
async execute(args, _context) {
|
|
195
|
+
const skill = skills.find((s) => s.name === args.name);
|
|
196
|
+
if (!skill) {
|
|
197
|
+
const available = skills.map((s) => s.name).join(", ");
|
|
198
|
+
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`);
|
|
199
|
+
}
|
|
200
|
+
return [
|
|
201
|
+
`## Skill: ${skill.name}`,
|
|
202
|
+
"",
|
|
203
|
+
`**Base directory**: ${dirname(skill.path)}`,
|
|
204
|
+
"",
|
|
205
|
+
skill.body,
|
|
206
|
+
].join("\n");
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
"tool.execute.before": async (input, output) => {
|
|
211
|
+
const sessionID = input.sessionID;
|
|
212
|
+
if (!sessionID)
|
|
213
|
+
return;
|
|
214
|
+
const toolArgs = output.args ?? {};
|
|
215
|
+
pendingToolArgs.set(input.callID, toolArgs);
|
|
216
|
+
const result = await triggerToolHooks("before", input.tool, sessionID, toolArgs);
|
|
217
|
+
if (result.blocked) {
|
|
218
|
+
pendingToolArgs.delete(input.callID);
|
|
219
|
+
throw new Error(result.blockReason ?? "Blocked by hook");
|
|
220
|
+
}
|
|
221
|
+
if (["write", "edit"].includes(input.tool)) {
|
|
222
|
+
const filePath = (toolArgs.filePath ?? toolArgs.file_path ?? toolArgs.path);
|
|
223
|
+
if (filePath) {
|
|
224
|
+
log("[tool.execute.before] File modified", { sessionID, filePath, tool: input.tool });
|
|
225
|
+
let files = modifiedCodeFiles.get(sessionID);
|
|
226
|
+
if (!files) {
|
|
227
|
+
files = new Set();
|
|
228
|
+
modifiedCodeFiles.set(sessionID, files);
|
|
229
|
+
}
|
|
230
|
+
files.add(filePath);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
"tool.execute.after": async (input, _output) => {
|
|
235
|
+
const sessionID = input.sessionID;
|
|
236
|
+
if (!sessionID)
|
|
237
|
+
return;
|
|
238
|
+
const toolArgs = pendingToolArgs.get(input.callID) ?? {};
|
|
239
|
+
pendingToolArgs.delete(input.callID);
|
|
240
|
+
await triggerToolHooks("after", input.tool, sessionID, toolArgs);
|
|
241
|
+
},
|
|
242
|
+
event: async ({ event }) => {
|
|
243
|
+
const props = event.properties;
|
|
244
|
+
if (event.type === "session.created") {
|
|
245
|
+
const info = props?.info;
|
|
246
|
+
const sessionID = info?.id;
|
|
247
|
+
if (!sessionID)
|
|
248
|
+
return;
|
|
249
|
+
if (!info.parentID) {
|
|
250
|
+
mainSessionID = sessionID;
|
|
251
|
+
log("[event] session.created - main session", { sessionID });
|
|
252
|
+
}
|
|
253
|
+
await triggerHooks("session.created", sessionID);
|
|
254
|
+
}
|
|
255
|
+
if (event.type === "session.deleted") {
|
|
256
|
+
const info = props?.info;
|
|
257
|
+
const sessionID = info?.id;
|
|
258
|
+
if (!sessionID)
|
|
259
|
+
return;
|
|
260
|
+
log("[event] session.deleted", { sessionID });
|
|
261
|
+
await triggerHooks("session.deleted", sessionID);
|
|
262
|
+
modifiedCodeFiles.delete(sessionID);
|
|
263
|
+
if (sessionID === mainSessionID) {
|
|
264
|
+
mainSessionID = undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (event.type === "session.idle") {
|
|
268
|
+
const sessionID = props?.sessionID;
|
|
269
|
+
log("[event] session.idle", { sessionID, mainSessionID });
|
|
270
|
+
if (!sessionID)
|
|
271
|
+
return;
|
|
272
|
+
if (!mainSessionID) {
|
|
273
|
+
mainSessionID = sessionID;
|
|
274
|
+
log("[event] session.idle - setting mainSessionID from idle event", { sessionID });
|
|
275
|
+
}
|
|
276
|
+
const eventHooks = hooks.get("session.idle");
|
|
277
|
+
if (!eventHooks || eventHooks.length === 0) {
|
|
278
|
+
log("[event] session.idle - no hooks defined, skipping");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const files = modifiedCodeFiles.get(sessionID);
|
|
282
|
+
modifiedCodeFiles.delete(sessionID);
|
|
283
|
+
await triggerHooks("session.idle", sessionID, { files: files ? Array.from(files) : [] });
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
export default SmartfrogPlugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|