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
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { parseFrontmatter, loadAgents, loadSkills, loadCommands, loadHooks, mergeHooks, } from "./loaders";
|
|
6
|
+
import { executeBashAction } from "./bash-executor";
|
|
7
|
+
describe("parseFrontmatter", () => {
|
|
8
|
+
it("should parse valid frontmatter", () => {
|
|
9
|
+
const content = `---
|
|
10
|
+
name: test
|
|
11
|
+
description: A test skill
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Content here`;
|
|
15
|
+
const result = parseFrontmatter(content);
|
|
16
|
+
expect(result.data.name).toBe("test");
|
|
17
|
+
expect(result.data.description).toBe("A test skill");
|
|
18
|
+
expect(result.body.trim()).toBe("# Content here");
|
|
19
|
+
});
|
|
20
|
+
it("should handle content without frontmatter", () => {
|
|
21
|
+
const content = "# Just content\n\nNo frontmatter here";
|
|
22
|
+
const result = parseFrontmatter(content);
|
|
23
|
+
expect(result.data).toEqual({});
|
|
24
|
+
expect(result.body).toBe(content);
|
|
25
|
+
});
|
|
26
|
+
it("should handle empty frontmatter", () => {
|
|
27
|
+
const content = `---
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
Content after empty frontmatter`;
|
|
31
|
+
const result = parseFrontmatter(content);
|
|
32
|
+
// Empty frontmatter returns empty object (yaml.load returns null, we cast to T)
|
|
33
|
+
expect(result.body.trim()).toBe("Content after empty frontmatter");
|
|
34
|
+
});
|
|
35
|
+
it("should handle invalid YAML gracefully", () => {
|
|
36
|
+
const content = `---
|
|
37
|
+
invalid: yaml: content: here
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
Body content`;
|
|
41
|
+
const result = parseFrontmatter(content);
|
|
42
|
+
expect(result.data).toEqual({});
|
|
43
|
+
expect(result.body).toBe(content);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("loadAgents", () => {
|
|
47
|
+
let testDir;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
testDir = join(tmpdir(), `opencode-test-${Date.now()}`);
|
|
50
|
+
mkdirSync(testDir, { recursive: true });
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
it("should return empty object for non-existent directory", () => {
|
|
56
|
+
const result = loadAgents("/non/existent/path");
|
|
57
|
+
expect(result).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
it("should load agent from markdown file", () => {
|
|
60
|
+
const agentContent = `---
|
|
61
|
+
description: Test agent
|
|
62
|
+
mode: subagent
|
|
63
|
+
temperature: 0.5
|
|
64
|
+
tools:
|
|
65
|
+
write: false
|
|
66
|
+
edit: true
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
# Test Agent
|
|
70
|
+
|
|
71
|
+
You are a test agent.`;
|
|
72
|
+
writeFileSync(join(testDir, "test-agent.md"), agentContent);
|
|
73
|
+
const result = loadAgents(testDir);
|
|
74
|
+
expect(result["test-agent"]).toBeDefined();
|
|
75
|
+
expect(result["test-agent"].description).toBe("Test agent");
|
|
76
|
+
expect(result["test-agent"].mode).toBe("subagent");
|
|
77
|
+
expect(result["test-agent"].temperature).toBe(0.5);
|
|
78
|
+
expect(result["test-agent"].tools).toEqual({ write: false, edit: true });
|
|
79
|
+
expect(result["test-agent"].prompt).toContain("You are a test agent.");
|
|
80
|
+
});
|
|
81
|
+
it("should load agent with permissions and singular permission key", () => {
|
|
82
|
+
const agentContent = `---
|
|
83
|
+
description: Permission agent
|
|
84
|
+
permission:
|
|
85
|
+
bash: allow
|
|
86
|
+
---
|
|
87
|
+
Content`;
|
|
88
|
+
writeFileSync(join(testDir, "perm.md"), agentContent);
|
|
89
|
+
const result = loadAgents(testDir);
|
|
90
|
+
expect(result["perm"].permissions).toEqual({ bash: "allow" });
|
|
91
|
+
});
|
|
92
|
+
it("should prioritize permissions (plural) over permission (singular)", () => {
|
|
93
|
+
const agentContent = `---
|
|
94
|
+
description: Dual permission agent
|
|
95
|
+
permission:
|
|
96
|
+
bash: deny
|
|
97
|
+
permissions:
|
|
98
|
+
bash: allow
|
|
99
|
+
---
|
|
100
|
+
Content`;
|
|
101
|
+
writeFileSync(join(testDir, "dual.md"), agentContent);
|
|
102
|
+
const result = loadAgents(testDir);
|
|
103
|
+
expect(result["dual"].permissions).toEqual({ bash: "allow" });
|
|
104
|
+
});
|
|
105
|
+
it("should not include undefined optional fields", () => {
|
|
106
|
+
const agentContent = `---
|
|
107
|
+
description: Minimal agent
|
|
108
|
+
---
|
|
109
|
+
Content`;
|
|
110
|
+
writeFileSync(join(testDir, "minimal.md"), agentContent);
|
|
111
|
+
const result = loadAgents(testDir);
|
|
112
|
+
expect(result["minimal"]).not.toHaveProperty("temperature");
|
|
113
|
+
expect(result["minimal"]).not.toHaveProperty("tools");
|
|
114
|
+
expect(result["minimal"]).not.toHaveProperty("permissions");
|
|
115
|
+
});
|
|
116
|
+
it("should convert agent mode to primary", () => {
|
|
117
|
+
const agentContent = `---
|
|
118
|
+
description: Primary agent
|
|
119
|
+
mode: agent
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
Content`;
|
|
123
|
+
writeFileSync(join(testDir, "primary.md"), agentContent);
|
|
124
|
+
const result = loadAgents(testDir);
|
|
125
|
+
expect(result["primary"].mode).toBe("primary");
|
|
126
|
+
});
|
|
127
|
+
it("should ignore non-markdown files", () => {
|
|
128
|
+
writeFileSync(join(testDir, "not-agent.txt"), "some content");
|
|
129
|
+
writeFileSync(join(testDir, "agent.md"), "---\ndescription: Agent\n---\nContent");
|
|
130
|
+
const result = loadAgents(testDir);
|
|
131
|
+
expect(Object.keys(result)).toEqual(["agent"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("loadSkills", () => {
|
|
135
|
+
let testDir;
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
testDir = join(tmpdir(), `opencode-test-${Date.now()}`);
|
|
138
|
+
mkdirSync(testDir, { recursive: true });
|
|
139
|
+
});
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
it("should return empty array for non-existent directory", () => {
|
|
144
|
+
const result = loadSkills("/non/existent/path");
|
|
145
|
+
expect(result).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
it("should load skill from SKILL.md in subdirectory", () => {
|
|
148
|
+
const skillDir = join(testDir, "my-skill");
|
|
149
|
+
mkdirSync(skillDir);
|
|
150
|
+
const skillContent = `---
|
|
151
|
+
name: my-skill
|
|
152
|
+
description: A test skill
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
# Skill Instructions
|
|
156
|
+
|
|
157
|
+
Do something useful.`;
|
|
158
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillContent);
|
|
159
|
+
const result = loadSkills(testDir);
|
|
160
|
+
expect(result).toHaveLength(1);
|
|
161
|
+
expect(result[0].name).toBe("my-skill");
|
|
162
|
+
expect(result[0].description).toBe("A test skill");
|
|
163
|
+
expect(result[0].body).toContain("Do something useful.");
|
|
164
|
+
expect(result[0].path).toBe(join(skillDir, "SKILL.md"));
|
|
165
|
+
});
|
|
166
|
+
it("should use directory name if name not in frontmatter", () => {
|
|
167
|
+
const skillDir = join(testDir, "fallback-name");
|
|
168
|
+
mkdirSync(skillDir);
|
|
169
|
+
const skillContent = `---
|
|
170
|
+
description: No name provided
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
Content`;
|
|
174
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillContent);
|
|
175
|
+
const result = loadSkills(testDir);
|
|
176
|
+
expect(result[0].name).toBe("fallback-name");
|
|
177
|
+
});
|
|
178
|
+
it("should ignore directories without SKILL.md", () => {
|
|
179
|
+
const skillDir1 = join(testDir, "valid-skill");
|
|
180
|
+
const skillDir2 = join(testDir, "invalid-skill");
|
|
181
|
+
mkdirSync(skillDir1);
|
|
182
|
+
mkdirSync(skillDir2);
|
|
183
|
+
writeFileSync(join(skillDir1, "SKILL.md"), "---\nname: valid\n---\nContent");
|
|
184
|
+
writeFileSync(join(skillDir2, "README.md"), "Not a skill");
|
|
185
|
+
const result = loadSkills(testDir);
|
|
186
|
+
expect(result).toHaveLength(1);
|
|
187
|
+
expect(result[0].name).toBe("valid");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe("loadCommands", () => {
|
|
191
|
+
let testDir;
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
testDir = join(tmpdir(), `opencode-test-${Date.now()}`);
|
|
194
|
+
mkdirSync(testDir, { recursive: true });
|
|
195
|
+
});
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
198
|
+
});
|
|
199
|
+
it("should return empty object for non-existent directory", () => {
|
|
200
|
+
const result = loadCommands("/non/existent/path");
|
|
201
|
+
expect(result).toEqual({});
|
|
202
|
+
});
|
|
203
|
+
it("should load command from markdown file", () => {
|
|
204
|
+
const commandContent = `---
|
|
205
|
+
description: Test command
|
|
206
|
+
agent: code-reviewer
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Context
|
|
210
|
+
|
|
211
|
+
Run this command to test.`;
|
|
212
|
+
writeFileSync(join(testDir, "test-cmd.md"), commandContent);
|
|
213
|
+
const result = loadCommands(testDir);
|
|
214
|
+
expect(result["test-cmd"]).toBeDefined();
|
|
215
|
+
expect(result["test-cmd"].description).toBe("Test command");
|
|
216
|
+
expect(result["test-cmd"].agent).toBe("code-reviewer");
|
|
217
|
+
expect(result["test-cmd"].template).toContain("Run this command to test.");
|
|
218
|
+
});
|
|
219
|
+
it("should handle command without agent", () => {
|
|
220
|
+
const commandContent = `---
|
|
221
|
+
description: Simple command
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
Do something`;
|
|
225
|
+
writeFileSync(join(testDir, "simple.md"), commandContent);
|
|
226
|
+
const result = loadCommands(testDir);
|
|
227
|
+
expect(result["simple"].agent).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe("loadHooks", () => {
|
|
231
|
+
let testDir;
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
testDir = join(tmpdir(), `opencode-test-${Date.now()}`);
|
|
234
|
+
mkdirSync(testDir, { recursive: true });
|
|
235
|
+
});
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
238
|
+
});
|
|
239
|
+
it("should return empty map for non-existent directory", () => {
|
|
240
|
+
const result = loadHooks("/non/existent/path");
|
|
241
|
+
expect(result.size).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
it("should return empty map when hooks.md does not exist", () => {
|
|
244
|
+
const result = loadHooks(testDir);
|
|
245
|
+
expect(result.size).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
it("should load hook from hooks.md with command action", () => {
|
|
248
|
+
const hookContent = `---
|
|
249
|
+
hooks:
|
|
250
|
+
- event: session.idle
|
|
251
|
+
conditions: [isMainSession]
|
|
252
|
+
actions:
|
|
253
|
+
- command: simplify-changes
|
|
254
|
+
---`;
|
|
255
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
256
|
+
const result = loadHooks(testDir);
|
|
257
|
+
expect(result.size).toBe(1);
|
|
258
|
+
expect(result.has("session.idle")).toBe(true);
|
|
259
|
+
const hooks = result.get("session.idle");
|
|
260
|
+
expect(hooks).toHaveLength(1);
|
|
261
|
+
expect(hooks[0].event).toBe("session.idle");
|
|
262
|
+
expect(hooks[0].conditions).toEqual(["isMainSession"]);
|
|
263
|
+
expect(hooks[0].actions).toHaveLength(1);
|
|
264
|
+
expect(hooks[0].actions[0]).toEqual({ command: "simplify-changes" });
|
|
265
|
+
});
|
|
266
|
+
it("should load hook with hasCodeChange condition", () => {
|
|
267
|
+
const hookContent = `---
|
|
268
|
+
hooks:
|
|
269
|
+
- event: session.idle
|
|
270
|
+
conditions: [hasCodeChange]
|
|
271
|
+
actions:
|
|
272
|
+
- command: simplify-changes
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
---`;
|
|
276
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
277
|
+
const result = loadHooks(testDir);
|
|
278
|
+
const hooks = result.get("session.idle");
|
|
279
|
+
expect(hooks).toHaveLength(1);
|
|
280
|
+
expect(hooks[0].conditions).toEqual(["hasCodeChange"]);
|
|
281
|
+
});
|
|
282
|
+
it("should load hook with multiple actions", () => {
|
|
283
|
+
const hookContent = `---
|
|
284
|
+
hooks:
|
|
285
|
+
- event: session.idle
|
|
286
|
+
conditions: [isMainSession]
|
|
287
|
+
actions:
|
|
288
|
+
- command: simplify-changes
|
|
289
|
+
- skill: post-change-code-simplification
|
|
290
|
+
- tool:
|
|
291
|
+
name: bash
|
|
292
|
+
args:
|
|
293
|
+
command: "echo done"
|
|
294
|
+
---`;
|
|
295
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
296
|
+
const result = loadHooks(testDir);
|
|
297
|
+
const hooks = result.get("session.idle");
|
|
298
|
+
expect(hooks).toHaveLength(1);
|
|
299
|
+
expect(hooks[0].actions).toHaveLength(3);
|
|
300
|
+
expect(hooks[0].actions[0]).toEqual({ command: "simplify-changes" });
|
|
301
|
+
expect(hooks[0].actions[1]).toEqual({ skill: "post-change-code-simplification" });
|
|
302
|
+
expect(hooks[0].actions[2]).toEqual({
|
|
303
|
+
tool: { name: "bash", args: { command: "echo done" } }
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
it("should load hook with bash action (short form)", () => {
|
|
307
|
+
const hookContent = `---
|
|
308
|
+
hooks:
|
|
309
|
+
- event: session.idle
|
|
310
|
+
actions:
|
|
311
|
+
- bash: "npm run lint"
|
|
312
|
+
---`;
|
|
313
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
314
|
+
const result = loadHooks(testDir);
|
|
315
|
+
const hooks = result.get("session.idle");
|
|
316
|
+
expect(hooks).toHaveLength(1);
|
|
317
|
+
expect(hooks[0].actions).toHaveLength(1);
|
|
318
|
+
expect(hooks[0].actions[0]).toEqual({ bash: "npm run lint" });
|
|
319
|
+
});
|
|
320
|
+
it("should load hook with bash action (long form with timeout)", () => {
|
|
321
|
+
const hookContent = `---
|
|
322
|
+
hooks:
|
|
323
|
+
- event: session.created
|
|
324
|
+
actions:
|
|
325
|
+
- bash:
|
|
326
|
+
command: "$OPENCODE_PROJECT_DIR/.opencode/hooks/init.sh"
|
|
327
|
+
timeout: 30000
|
|
328
|
+
---`;
|
|
329
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
330
|
+
const result = loadHooks(testDir);
|
|
331
|
+
const hooks = result.get("session.created");
|
|
332
|
+
expect(hooks).toHaveLength(1);
|
|
333
|
+
expect(hooks[0].actions[0]).toEqual({
|
|
334
|
+
bash: {
|
|
335
|
+
command: "$OPENCODE_PROJECT_DIR/.opencode/hooks/init.sh",
|
|
336
|
+
timeout: 30000,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
it("should load hook with mixed actions including bash", () => {
|
|
341
|
+
const hookContent = `---
|
|
342
|
+
hooks:
|
|
343
|
+
- event: session.idle
|
|
344
|
+
conditions: [hasCodeChange]
|
|
345
|
+
actions:
|
|
346
|
+
- bash: "npm run lint"
|
|
347
|
+
- command: simplify-changes
|
|
348
|
+
- bash:
|
|
349
|
+
command: "npm run format"
|
|
350
|
+
timeout: 10000
|
|
351
|
+
---`;
|
|
352
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
353
|
+
const result = loadHooks(testDir);
|
|
354
|
+
const hooks = result.get("session.idle");
|
|
355
|
+
expect(hooks).toHaveLength(1);
|
|
356
|
+
expect(hooks[0].actions).toHaveLength(3);
|
|
357
|
+
expect(hooks[0].actions[0]).toEqual({ bash: "npm run lint" });
|
|
358
|
+
expect(hooks[0].actions[1]).toEqual({ command: "simplify-changes" });
|
|
359
|
+
expect(hooks[0].actions[2]).toEqual({
|
|
360
|
+
bash: { command: "npm run format", timeout: 10000 },
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
it("should load hook with command with args", () => {
|
|
364
|
+
const hookContent = `---
|
|
365
|
+
hooks:
|
|
366
|
+
- event: session.created
|
|
367
|
+
actions:
|
|
368
|
+
- command:
|
|
369
|
+
name: review-pr
|
|
370
|
+
args: "main feature"
|
|
371
|
+
---`;
|
|
372
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
373
|
+
const result = loadHooks(testDir);
|
|
374
|
+
const hooks = result.get("session.created");
|
|
375
|
+
expect(hooks).toHaveLength(1);
|
|
376
|
+
expect(hooks[0].actions[0]).toEqual({
|
|
377
|
+
command: { name: "review-pr", args: "main feature" }
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
it("should load hook without conditions", () => {
|
|
381
|
+
const hookContent = `---
|
|
382
|
+
hooks:
|
|
383
|
+
- event: session.deleted
|
|
384
|
+
actions:
|
|
385
|
+
- command: test-cmd
|
|
386
|
+
---`;
|
|
387
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
388
|
+
const result = loadHooks(testDir);
|
|
389
|
+
const hooks = result.get("session.deleted");
|
|
390
|
+
expect(hooks).toHaveLength(1);
|
|
391
|
+
expect(hooks[0].conditions).toBeUndefined();
|
|
392
|
+
});
|
|
393
|
+
it("should load multiple hooks for different events", () => {
|
|
394
|
+
const hookContent = `---
|
|
395
|
+
hooks:
|
|
396
|
+
- event: session.idle
|
|
397
|
+
actions:
|
|
398
|
+
- command: simplify-changes
|
|
399
|
+
- event: session.created
|
|
400
|
+
actions:
|
|
401
|
+
- command: init-cmd
|
|
402
|
+
- event: tool.after.write
|
|
403
|
+
actions:
|
|
404
|
+
- command: after-write
|
|
405
|
+
---`;
|
|
406
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
407
|
+
const result = loadHooks(testDir);
|
|
408
|
+
expect(result.size).toBe(3);
|
|
409
|
+
expect(result.has("session.idle")).toBe(true);
|
|
410
|
+
expect(result.has("session.created")).toBe(true);
|
|
411
|
+
expect(result.has("tool.after.write")).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
it("should load multiple hooks for same event in declaration order", () => {
|
|
414
|
+
const hookContent = `---
|
|
415
|
+
hooks:
|
|
416
|
+
- event: session.idle
|
|
417
|
+
conditions: [isMainSession]
|
|
418
|
+
actions:
|
|
419
|
+
- command: first-cmd
|
|
420
|
+
- event: session.idle
|
|
421
|
+
actions:
|
|
422
|
+
- command: second-cmd
|
|
423
|
+
---`;
|
|
424
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
425
|
+
const result = loadHooks(testDir);
|
|
426
|
+
expect(result.size).toBe(1);
|
|
427
|
+
const hooks = result.get("session.idle");
|
|
428
|
+
expect(hooks).toHaveLength(2);
|
|
429
|
+
expect(hooks[0].conditions).toEqual(["isMainSession"]);
|
|
430
|
+
expect(hooks[0].actions[0]).toEqual({ command: "first-cmd" });
|
|
431
|
+
expect(hooks[1].conditions).toBeUndefined();
|
|
432
|
+
expect(hooks[1].actions[0]).toEqual({ command: "second-cmd" });
|
|
433
|
+
});
|
|
434
|
+
it("should ignore hooks with invalid event names", () => {
|
|
435
|
+
const hookContent = `---
|
|
436
|
+
hooks:
|
|
437
|
+
- event: invalid.event
|
|
438
|
+
actions:
|
|
439
|
+
- command: test
|
|
440
|
+
- event: session.idle
|
|
441
|
+
actions:
|
|
442
|
+
- command: valid-cmd
|
|
443
|
+
---`;
|
|
444
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
445
|
+
const result = loadHooks(testDir);
|
|
446
|
+
expect(result.size).toBe(1);
|
|
447
|
+
expect(result.has("session.idle")).toBe(true);
|
|
448
|
+
});
|
|
449
|
+
it("should return empty map with invalid YAML frontmatter", () => {
|
|
450
|
+
const hookContent = `---
|
|
451
|
+
not valid yaml: : :
|
|
452
|
+
---`;
|
|
453
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
454
|
+
const result = loadHooks(testDir);
|
|
455
|
+
expect(result.size).toBe(0);
|
|
456
|
+
});
|
|
457
|
+
it("should return empty map without hooks array", () => {
|
|
458
|
+
const hookContent = `---
|
|
459
|
+
something: else
|
|
460
|
+
---`;
|
|
461
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
462
|
+
const result = loadHooks(testDir);
|
|
463
|
+
expect(result.size).toBe(0);
|
|
464
|
+
});
|
|
465
|
+
it("should ignore hooks without actions array", () => {
|
|
466
|
+
const hookContent = `---
|
|
467
|
+
hooks:
|
|
468
|
+
- event: session.idle
|
|
469
|
+
- event: session.created
|
|
470
|
+
actions:
|
|
471
|
+
- command: valid-cmd
|
|
472
|
+
---`;
|
|
473
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
474
|
+
const result = loadHooks(testDir);
|
|
475
|
+
expect(result.size).toBe(1);
|
|
476
|
+
expect(result.has("session.created")).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
it("should support all valid event types", () => {
|
|
479
|
+
const hookContent = `---
|
|
480
|
+
hooks:
|
|
481
|
+
- event: session.idle
|
|
482
|
+
actions:
|
|
483
|
+
- command: cmd1
|
|
484
|
+
- event: session.created
|
|
485
|
+
actions:
|
|
486
|
+
- command: cmd2
|
|
487
|
+
- event: session.deleted
|
|
488
|
+
actions:
|
|
489
|
+
- command: cmd3
|
|
490
|
+
- event: tool.after.write
|
|
491
|
+
actions:
|
|
492
|
+
- command: cmd4
|
|
493
|
+
- event: tool.after.edit
|
|
494
|
+
actions:
|
|
495
|
+
- command: cmd5
|
|
496
|
+
---`;
|
|
497
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
498
|
+
const result = loadHooks(testDir);
|
|
499
|
+
expect(result.size).toBe(5);
|
|
500
|
+
expect(result.has("session.idle")).toBe(true);
|
|
501
|
+
expect(result.has("session.created")).toBe(true);
|
|
502
|
+
expect(result.has("session.deleted")).toBe(true);
|
|
503
|
+
expect(result.has("tool.after.write")).toBe(true);
|
|
504
|
+
expect(result.has("tool.after.edit")).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
it("should support tool.before.* wildcard event", () => {
|
|
507
|
+
const hookContent = `---
|
|
508
|
+
hooks:
|
|
509
|
+
- event: tool.before.*
|
|
510
|
+
actions:
|
|
511
|
+
- bash: "echo before all tools"
|
|
512
|
+
---`;
|
|
513
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
514
|
+
const result = loadHooks(testDir);
|
|
515
|
+
expect(result.size).toBe(1);
|
|
516
|
+
expect(result.has("tool.before.*")).toBe(true);
|
|
517
|
+
expect(result.get("tool.before.*")[0].actions[0]).toEqual({ bash: "echo before all tools" });
|
|
518
|
+
});
|
|
519
|
+
it("should support tool.after.* wildcard event", () => {
|
|
520
|
+
const hookContent = `---
|
|
521
|
+
hooks:
|
|
522
|
+
- event: tool.after.*
|
|
523
|
+
actions:
|
|
524
|
+
- bash: "echo after all tools"
|
|
525
|
+
---`;
|
|
526
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
527
|
+
const result = loadHooks(testDir);
|
|
528
|
+
expect(result.size).toBe(1);
|
|
529
|
+
expect(result.has("tool.after.*")).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
it("should support tool.before.<name> specific events", () => {
|
|
532
|
+
const hookContent = `---
|
|
533
|
+
hooks:
|
|
534
|
+
- event: tool.before.write
|
|
535
|
+
actions:
|
|
536
|
+
- bash: "echo before write"
|
|
537
|
+
- event: tool.before.edit
|
|
538
|
+
actions:
|
|
539
|
+
- bash: "echo before edit"
|
|
540
|
+
- event: tool.before.bash
|
|
541
|
+
actions:
|
|
542
|
+
- bash: "echo before bash"
|
|
543
|
+
---`;
|
|
544
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
545
|
+
const result = loadHooks(testDir);
|
|
546
|
+
expect(result.size).toBe(3);
|
|
547
|
+
expect(result.has("tool.before.write")).toBe(true);
|
|
548
|
+
expect(result.has("tool.before.edit")).toBe(true);
|
|
549
|
+
expect(result.has("tool.before.bash")).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
it("should support mixed wildcard and specific tool events", () => {
|
|
552
|
+
const hookContent = `---
|
|
553
|
+
hooks:
|
|
554
|
+
- event: tool.before.*
|
|
555
|
+
actions:
|
|
556
|
+
- bash: "echo before all"
|
|
557
|
+
- event: tool.before.write
|
|
558
|
+
actions:
|
|
559
|
+
- bash: "echo before write specifically"
|
|
560
|
+
- event: tool.after.*
|
|
561
|
+
actions:
|
|
562
|
+
- bash: "echo after all"
|
|
563
|
+
- event: tool.after.write
|
|
564
|
+
actions:
|
|
565
|
+
- bash: "echo after write specifically"
|
|
566
|
+
---`;
|
|
567
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
568
|
+
const result = loadHooks(testDir);
|
|
569
|
+
expect(result.size).toBe(4);
|
|
570
|
+
expect(result.has("tool.before.*")).toBe(true);
|
|
571
|
+
expect(result.has("tool.before.write")).toBe(true);
|
|
572
|
+
expect(result.has("tool.after.*")).toBe(true);
|
|
573
|
+
expect(result.has("tool.after.write")).toBe(true);
|
|
574
|
+
});
|
|
575
|
+
it("should reject tool.before without suffix", () => {
|
|
576
|
+
const hookContent = `---
|
|
577
|
+
hooks:
|
|
578
|
+
- event: tool.before
|
|
579
|
+
actions:
|
|
580
|
+
- bash: "echo invalid"
|
|
581
|
+
- event: tool.before.write
|
|
582
|
+
actions:
|
|
583
|
+
- bash: "echo valid"
|
|
584
|
+
---`;
|
|
585
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
586
|
+
const result = loadHooks(testDir);
|
|
587
|
+
expect(result.size).toBe(1);
|
|
588
|
+
expect(result.has("tool.before")).toBe(false);
|
|
589
|
+
expect(result.has("tool.before.write")).toBe(true);
|
|
590
|
+
});
|
|
591
|
+
it("should reject tool.after without suffix", () => {
|
|
592
|
+
const hookContent = `---
|
|
593
|
+
hooks:
|
|
594
|
+
- event: tool.after
|
|
595
|
+
actions:
|
|
596
|
+
- bash: "echo invalid"
|
|
597
|
+
- event: session.idle
|
|
598
|
+
actions:
|
|
599
|
+
- bash: "echo valid"
|
|
600
|
+
---`;
|
|
601
|
+
writeFileSync(join(testDir, "hooks.md"), hookContent);
|
|
602
|
+
const result = loadHooks(testDir);
|
|
603
|
+
expect(result.size).toBe(1);
|
|
604
|
+
expect(result.has("tool.after")).toBe(false);
|
|
605
|
+
expect(result.has("session.idle")).toBe(true);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
describe("executeBashAction", () => {
|
|
609
|
+
let testDir;
|
|
610
|
+
beforeEach(() => {
|
|
611
|
+
testDir = join(tmpdir(), `opencode-bash-test-${Date.now()}`);
|
|
612
|
+
mkdirSync(testDir, { recursive: true });
|
|
613
|
+
});
|
|
614
|
+
afterEach(() => {
|
|
615
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
616
|
+
});
|
|
617
|
+
it("should execute simple command and return stdout", async () => {
|
|
618
|
+
const context = {
|
|
619
|
+
session_id: "test-session",
|
|
620
|
+
event: "session.idle",
|
|
621
|
+
cwd: testDir,
|
|
622
|
+
};
|
|
623
|
+
const result = await executeBashAction("echo hello", 5000, context, testDir);
|
|
624
|
+
expect(result.exitCode).toBe(0);
|
|
625
|
+
expect(result.stdout.trim()).toBe("hello");
|
|
626
|
+
expect(result.stderr).toBe("");
|
|
627
|
+
});
|
|
628
|
+
it("should return exit code 1 for failing command", async () => {
|
|
629
|
+
const context = {
|
|
630
|
+
session_id: "test-session",
|
|
631
|
+
event: "session.idle",
|
|
632
|
+
cwd: testDir,
|
|
633
|
+
};
|
|
634
|
+
const result = await executeBashAction("exit 1", 5000, context, testDir);
|
|
635
|
+
expect(result.exitCode).toBe(1);
|
|
636
|
+
});
|
|
637
|
+
it("should return exit code 2 for blocking command", async () => {
|
|
638
|
+
const context = {
|
|
639
|
+
session_id: "test-session",
|
|
640
|
+
event: "session.idle",
|
|
641
|
+
cwd: testDir,
|
|
642
|
+
};
|
|
643
|
+
const result = await executeBashAction("echo 'blocked' >&2 && exit 2", 5000, context, testDir);
|
|
644
|
+
expect(result.exitCode).toBe(2);
|
|
645
|
+
expect(result.stderr.trim()).toBe("blocked");
|
|
646
|
+
});
|
|
647
|
+
it("should capture stderr", async () => {
|
|
648
|
+
const context = {
|
|
649
|
+
session_id: "test-session",
|
|
650
|
+
event: "session.idle",
|
|
651
|
+
cwd: testDir,
|
|
652
|
+
};
|
|
653
|
+
const result = await executeBashAction("echo 'error message' >&2", 5000, context, testDir);
|
|
654
|
+
expect(result.exitCode).toBe(0);
|
|
655
|
+
expect(result.stderr.trim()).toBe("error message");
|
|
656
|
+
});
|
|
657
|
+
it("should set OPENCODE_PROJECT_DIR environment variable", async () => {
|
|
658
|
+
const context = {
|
|
659
|
+
session_id: "test-session",
|
|
660
|
+
event: "session.idle",
|
|
661
|
+
cwd: testDir,
|
|
662
|
+
};
|
|
663
|
+
const result = await executeBashAction("echo $OPENCODE_PROJECT_DIR", 5000, context, testDir);
|
|
664
|
+
expect(result.exitCode).toBe(0);
|
|
665
|
+
expect(result.stdout.trim()).toBe(testDir);
|
|
666
|
+
});
|
|
667
|
+
it("should set OPENCODE_SESSION_ID environment variable", async () => {
|
|
668
|
+
const context = {
|
|
669
|
+
session_id: "my-session-123",
|
|
670
|
+
event: "session.idle",
|
|
671
|
+
cwd: testDir,
|
|
672
|
+
};
|
|
673
|
+
const result = await executeBashAction("echo $OPENCODE_SESSION_ID", 5000, context, testDir);
|
|
674
|
+
expect(result.exitCode).toBe(0);
|
|
675
|
+
expect(result.stdout.trim()).toBe("my-session-123");
|
|
676
|
+
});
|
|
677
|
+
it("should pass context as JSON via stdin", async () => {
|
|
678
|
+
const context = {
|
|
679
|
+
session_id: "test-session",
|
|
680
|
+
event: "session.idle",
|
|
681
|
+
cwd: testDir,
|
|
682
|
+
files: ["file1.ts", "file2.ts"],
|
|
683
|
+
};
|
|
684
|
+
const result = await executeBashAction("cat", 5000, context, testDir);
|
|
685
|
+
expect(result.exitCode).toBe(0);
|
|
686
|
+
const parsed = JSON.parse(result.stdout);
|
|
687
|
+
expect(parsed.session_id).toBe("test-session");
|
|
688
|
+
expect(parsed.event).toBe("session.idle");
|
|
689
|
+
expect(parsed.files).toEqual(["file1.ts", "file2.ts"]);
|
|
690
|
+
});
|
|
691
|
+
it("should timeout long-running commands", async () => {
|
|
692
|
+
const context = {
|
|
693
|
+
session_id: "test-session",
|
|
694
|
+
event: "session.idle",
|
|
695
|
+
cwd: testDir,
|
|
696
|
+
};
|
|
697
|
+
const result = await executeBashAction("sleep 10", 100, context, testDir);
|
|
698
|
+
expect(result.exitCode).toBe(1);
|
|
699
|
+
expect(result.stderr).toContain("timed out");
|
|
700
|
+
});
|
|
701
|
+
it("should run command in specified cwd", async () => {
|
|
702
|
+
const subDir = join(testDir, "subdir");
|
|
703
|
+
mkdirSync(subDir);
|
|
704
|
+
const context = {
|
|
705
|
+
session_id: "test-session",
|
|
706
|
+
event: "session.idle",
|
|
707
|
+
cwd: subDir,
|
|
708
|
+
};
|
|
709
|
+
const result = await executeBashAction("pwd", 5000, context, testDir);
|
|
710
|
+
expect(result.exitCode).toBe(0);
|
|
711
|
+
// macOS resolves /var to /private/var, so we check if the path ends with the subdir
|
|
712
|
+
expect(result.stdout.trim()).toContain("subdir");
|
|
713
|
+
});
|
|
714
|
+
it("should handle command with special characters", async () => {
|
|
715
|
+
const context = {
|
|
716
|
+
session_id: "test-session",
|
|
717
|
+
event: "session.idle",
|
|
718
|
+
cwd: testDir,
|
|
719
|
+
};
|
|
720
|
+
const result = await executeBashAction("echo 'hello world' && echo \"test\"", 5000, context, testDir);
|
|
721
|
+
expect(result.exitCode).toBe(0);
|
|
722
|
+
expect(result.stdout).toContain("hello world");
|
|
723
|
+
expect(result.stdout).toContain("test");
|
|
724
|
+
});
|
|
725
|
+
it("should pass tool_name and tool_args via stdin for tool hooks", async () => {
|
|
726
|
+
const context = {
|
|
727
|
+
session_id: "test-session",
|
|
728
|
+
event: "tool.before.write",
|
|
729
|
+
cwd: testDir,
|
|
730
|
+
tool_name: "write",
|
|
731
|
+
tool_args: { filePath: "/path/to/file.ts", content: "hello" },
|
|
732
|
+
};
|
|
733
|
+
const result = await executeBashAction("cat", 5000, context, testDir);
|
|
734
|
+
expect(result.exitCode).toBe(0);
|
|
735
|
+
const parsed = JSON.parse(result.stdout);
|
|
736
|
+
expect(parsed.tool_name).toBe("write");
|
|
737
|
+
expect(parsed.tool_args).toEqual({ filePath: "/path/to/file.ts", content: "hello" });
|
|
738
|
+
expect(parsed.event).toBe("tool.before.write");
|
|
739
|
+
});
|
|
740
|
+
it("should not include tool fields when not provided", async () => {
|
|
741
|
+
const context = {
|
|
742
|
+
session_id: "test-session",
|
|
743
|
+
event: "session.idle",
|
|
744
|
+
cwd: testDir,
|
|
745
|
+
};
|
|
746
|
+
const result = await executeBashAction("cat", 5000, context, testDir);
|
|
747
|
+
expect(result.exitCode).toBe(0);
|
|
748
|
+
const parsed = JSON.parse(result.stdout);
|
|
749
|
+
expect(parsed.tool_name).toBeUndefined();
|
|
750
|
+
expect(parsed.tool_args).toBeUndefined();
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
describe("mergeHooks", () => {
|
|
754
|
+
it("should return empty map when merging empty maps", () => {
|
|
755
|
+
const result = mergeHooks(new Map(), new Map());
|
|
756
|
+
expect(result.size).toBe(0);
|
|
757
|
+
});
|
|
758
|
+
it("should merge maps with non-overlapping events", () => {
|
|
759
|
+
const hook1 = { event: "session.idle", actions: [{ command: "cmd1" }] };
|
|
760
|
+
const hook2 = { event: "session.created", actions: [{ command: "cmd2" }] };
|
|
761
|
+
const map1 = new Map([["session.idle", [hook1]]]);
|
|
762
|
+
const map2 = new Map([["session.created", [hook2]]]);
|
|
763
|
+
const result = mergeHooks(map1, map2);
|
|
764
|
+
expect(result.size).toBe(2);
|
|
765
|
+
expect(result.has("session.idle")).toBe(true);
|
|
766
|
+
expect(result.has("session.created")).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
it("should concatenate hooks for overlapping events in order", () => {
|
|
769
|
+
const hook1 = { event: "session.idle", actions: [{ command: "cmd1" }] };
|
|
770
|
+
const hook2 = { event: "session.idle", actions: [{ command: "cmd2" }] };
|
|
771
|
+
const map1 = new Map([["session.idle", [hook1]]]);
|
|
772
|
+
const map2 = new Map([["session.idle", [hook2]]]);
|
|
773
|
+
const result = mergeHooks(map1, map2);
|
|
774
|
+
expect(result.size).toBe(1);
|
|
775
|
+
const hooks = result.get("session.idle");
|
|
776
|
+
expect(hooks).toHaveLength(2);
|
|
777
|
+
expect(hooks[0]).toBe(hook1);
|
|
778
|
+
expect(hooks[1]).toBe(hook2);
|
|
779
|
+
});
|
|
780
|
+
it("should handle single map input", () => {
|
|
781
|
+
const hook = { event: "session.idle", actions: [{ command: "cmd" }] };
|
|
782
|
+
const map = new Map([["session.idle", [hook]]]);
|
|
783
|
+
const result = mergeHooks(map);
|
|
784
|
+
expect(result.size).toBe(1);
|
|
785
|
+
expect(result.get("session.idle")).toHaveLength(1);
|
|
786
|
+
});
|
|
787
|
+
it("should handle multiple maps with multiple events each", () => {
|
|
788
|
+
const globalHook1 = { event: "session.idle", actions: [{ command: "global-idle" }] };
|
|
789
|
+
const globalHook2 = { event: "session.created", actions: [{ command: "global-created" }] };
|
|
790
|
+
const projectHook1 = { event: "session.idle", actions: [{ command: "project-idle" }] };
|
|
791
|
+
const projectHook2 = { event: "session.deleted", actions: [{ command: "project-deleted" }] };
|
|
792
|
+
const globalMap = new Map([
|
|
793
|
+
["session.idle", [globalHook1]],
|
|
794
|
+
["session.created", [globalHook2]],
|
|
795
|
+
]);
|
|
796
|
+
const projectMap = new Map([
|
|
797
|
+
["session.idle", [projectHook1]],
|
|
798
|
+
["session.deleted", [projectHook2]],
|
|
799
|
+
]);
|
|
800
|
+
const result = mergeHooks(globalMap, projectMap);
|
|
801
|
+
expect(result.size).toBe(3);
|
|
802
|
+
expect(result.get("session.idle")).toHaveLength(2);
|
|
803
|
+
expect(result.get("session.idle")[0]).toBe(globalHook1);
|
|
804
|
+
expect(result.get("session.idle")[1]).toBe(projectHook1);
|
|
805
|
+
expect(result.get("session.created")).toHaveLength(1);
|
|
806
|
+
expect(result.get("session.deleted")).toHaveLength(1);
|
|
807
|
+
});
|
|
808
|
+
});
|