my-pi 0.0.13 → 0.1.1

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,246 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import {
12
+ build_hook_payload,
13
+ create_hooks_resolution_extension,
14
+ load_hooks,
15
+ matches_hook,
16
+ parse_claude_settings_hooks,
17
+ to_claude_tool_name,
18
+ type CommandRunResult,
19
+ type HookState,
20
+ } from './hooks-resolution.js';
21
+
22
+ const dirs: string[] = [];
23
+
24
+ afterEach(() => {
25
+ for (const dir of dirs.splice(0)) {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ function create_temp_dir(): string {
31
+ const dir = mkdtempSync(join(tmpdir(), 'my-pi-hooks-'));
32
+ dirs.push(dir);
33
+ return dir;
34
+ }
35
+
36
+ function create_test_pi() {
37
+ const events = new Map<string, any>();
38
+ const pi = {
39
+ on(name: string, handler: any) {
40
+ events.set(name, handler);
41
+ },
42
+ } as unknown as ExtensionAPI;
43
+ return { pi, events };
44
+ }
45
+
46
+ function create_context(overrides: Partial<any> = {}) {
47
+ const notify = vi.fn();
48
+ return {
49
+ ctx: {
50
+ cwd: '/repo',
51
+ hasUI: true,
52
+ ui: { notify },
53
+ sessionManager: {
54
+ getSessionFile: vi.fn().mockReturnValue('session.jsonl'),
55
+ },
56
+ ...overrides,
57
+ },
58
+ notify,
59
+ };
60
+ }
61
+
62
+ describe('hooks-resolution helpers', () => {
63
+ it('parses Claude settings command hooks', () => {
64
+ const hooks = parse_claude_settings_hooks(
65
+ {
66
+ hooks: {
67
+ PostToolUse: [
68
+ {
69
+ matcher: 'Write|Edit',
70
+ hooks: [
71
+ {
72
+ type: 'command',
73
+ command: 'echo ok',
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ },
79
+ },
80
+ '/repo/.claude/settings.json',
81
+ '/repo',
82
+ );
83
+
84
+ expect(hooks).toHaveLength(1);
85
+ expect(hooks[0].event_name).toBe('PostToolUse');
86
+ expect(hooks[0].command).toBe('echo ok');
87
+ expect(hooks[0].matcher?.test('Write')).toBe(true);
88
+ });
89
+
90
+ it('loads hooks from .claude and .pi config files', () => {
91
+ const dir = create_temp_dir();
92
+ mkdirSync(join(dir, '.git'));
93
+ mkdirSync(join(dir, '.claude'));
94
+ mkdirSync(join(dir, '.pi'));
95
+
96
+ writeFileSync(
97
+ join(dir, '.claude', 'settings.json'),
98
+ JSON.stringify({
99
+ hooks: {
100
+ PostToolUse: [
101
+ {
102
+ matcher: 'Write',
103
+ hooks: [{ type: 'command', command: 'echo claude' }],
104
+ },
105
+ ],
106
+ },
107
+ }),
108
+ );
109
+ writeFileSync(
110
+ join(dir, '.pi', 'hooks.json'),
111
+ JSON.stringify({
112
+ hooks: {
113
+ PostToolUseFailure: [
114
+ { matcher: 'Bash', command: 'echo pi' },
115
+ ],
116
+ },
117
+ }),
118
+ );
119
+
120
+ const state = load_hooks(dir);
121
+ expect(state.project_dir).toBe(dir);
122
+ expect(state.hooks).toHaveLength(2);
123
+ expect(state.hooks.map((hook) => hook.command)).toEqual([
124
+ 'echo claude',
125
+ 'echo pi',
126
+ ]);
127
+ });
128
+
129
+ it('matches Claude-style and Pi-style tool names', () => {
130
+ expect(to_claude_tool_name('ls')).toBe('LS');
131
+ expect(to_claude_tool_name('write')).toBe('Write');
132
+ expect(
133
+ matches_hook(
134
+ {
135
+ event_name: 'PostToolUse',
136
+ matcher: /Write/,
137
+ matcher_text: 'Write',
138
+ command: 'echo ok',
139
+ source: 'test',
140
+ },
141
+ 'write',
142
+ ),
143
+ ).toBe(true);
144
+ });
145
+
146
+ it('builds Claude-compatible hook payloads', () => {
147
+ const { ctx } = create_context();
148
+ const payload = build_hook_payload(
149
+ {
150
+ toolName: 'write',
151
+ toolCallId: 'call-1',
152
+ input: { path: 'src/file.ts', content: 'x' },
153
+ content: [{ type: 'text', text: 'done' }],
154
+ isError: false,
155
+ details: null,
156
+ } as any,
157
+ 'PostToolUse',
158
+ ctx as any,
159
+ '/repo',
160
+ );
161
+
162
+ expect(payload.tool_name).toBe('Write');
163
+ expect(payload.tool_input).toMatchObject({
164
+ path: 'src/file.ts',
165
+ file_path: 'src/file.ts',
166
+ filePath: 'src/file.ts',
167
+ });
168
+ expect(payload.tool_response).toMatchObject({
169
+ is_error: false,
170
+ isError: false,
171
+ text: 'done',
172
+ });
173
+ });
174
+ });
175
+
176
+ describe('hooks-resolution extension', () => {
177
+ it('runs matching hooks once per unique command and notifies on success', async () => {
178
+ const { pi, events } = create_test_pi();
179
+ const run_command_hook = vi
180
+ .fn<
181
+ (
182
+ command: string,
183
+ cwd: string,
184
+ payload: Record<string, unknown>,
185
+ ) => Promise<CommandRunResult>
186
+ >()
187
+ .mockResolvedValue({
188
+ code: 0,
189
+ stdout: '',
190
+ stderr: '',
191
+ elapsed_ms: 12,
192
+ timed_out: false,
193
+ });
194
+ const load_hooks_impl = vi
195
+ .fn<(cwd: string) => HookState>()
196
+ .mockReturnValue({
197
+ project_dir: '/repo',
198
+ hooks: [
199
+ {
200
+ event_name: 'PostToolUse',
201
+ matcher: /Write/,
202
+ matcher_text: 'Write',
203
+ command: 'echo same',
204
+ source: 'a',
205
+ },
206
+ {
207
+ event_name: 'PostToolUse',
208
+ matcher: /Write/,
209
+ matcher_text: 'Write',
210
+ command: 'echo same',
211
+ source: 'b',
212
+ },
213
+ ],
214
+ });
215
+
216
+ await create_hooks_resolution_extension({
217
+ load_hooks: load_hooks_impl,
218
+ run_command_hook,
219
+ })(pi);
220
+
221
+ const start = events.get('session_start');
222
+ const tool_result = events.get('tool_result');
223
+ const { ctx, notify } = create_context();
224
+
225
+ await start({}, ctx);
226
+ await tool_result(
227
+ {
228
+ toolName: 'write',
229
+ toolCallId: 'call-1',
230
+ input: { path: 'src/file.ts' },
231
+ content: [{ type: 'text', text: 'done' }],
232
+ isError: false,
233
+ details: null,
234
+ } as any,
235
+ ctx,
236
+ );
237
+
238
+ expect(load_hooks_impl).toHaveBeenCalledWith('/repo');
239
+ expect(run_command_hook).toHaveBeenCalledTimes(1);
240
+ expect(run_command_hook.mock.calls[0][0]).toBe('echo same');
241
+ expect(notify).toHaveBeenCalledWith(
242
+ 'Hook `echo` ran (12ms)',
243
+ 'info',
244
+ );
245
+ });
246
+ });