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.
@@ -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
+ });