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,584 @@
1
+ // Hooks resolution — Claude Code style hook compatibility
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ ExtensionFactory,
7
+ ToolResultEvent,
8
+ } from '@mariozechner/pi-coding-agent';
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, readFileSync, statSync } from 'node:fs';
11
+ import { basename, dirname, join, resolve } from 'node:path';
12
+
13
+ const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
14
+
15
+ type JsonValue =
16
+ | string
17
+ | number
18
+ | boolean
19
+ | null
20
+ | JsonValue[]
21
+ | { [key: string]: JsonValue };
22
+
23
+ export type HookEventName = 'PostToolUse' | 'PostToolUseFailure';
24
+
25
+ export interface ResolvedCommandHook {
26
+ event_name: HookEventName;
27
+ matcher?: RegExp;
28
+ matcher_text?: string;
29
+ command: string;
30
+ source: string;
31
+ }
32
+
33
+ export interface HookState {
34
+ project_dir: string;
35
+ hooks: ResolvedCommandHook[];
36
+ }
37
+
38
+ export interface CommandRunResult {
39
+ code: number;
40
+ stdout: string;
41
+ stderr: string;
42
+ elapsed_ms: number;
43
+ timed_out: boolean;
44
+ }
45
+
46
+ export function is_file(path: string): boolean {
47
+ try {
48
+ return statSync(path).isFile();
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function as_record(
55
+ value: unknown,
56
+ ): Record<string, unknown> | undefined {
57
+ if (typeof value !== 'object' || value === null) return undefined;
58
+ return value as Record<string, unknown>;
59
+ }
60
+
61
+ export function walk_up_directories(
62
+ start_dir: string,
63
+ stop_dir?: string,
64
+ ): string[] {
65
+ const directories: string[] = [];
66
+ const has_stop_dir = stop_dir !== undefined;
67
+ let current = resolve(start_dir);
68
+ let parent = dirname(current);
69
+ let reached_stop_dir = has_stop_dir && current === stop_dir;
70
+ let reached_filesystem_root = parent === current;
71
+
72
+ directories.push(current);
73
+ while (!reached_stop_dir && !reached_filesystem_root) {
74
+ current = parent;
75
+ parent = dirname(current);
76
+ reached_stop_dir = has_stop_dir && current === stop_dir;
77
+ reached_filesystem_root = parent === current;
78
+ directories.push(current);
79
+ }
80
+
81
+ return directories;
82
+ }
83
+
84
+ export function find_nearest_git_root(
85
+ start_dir: string,
86
+ ): string | undefined {
87
+ for (const directory of walk_up_directories(start_dir)) {
88
+ if (existsSync(join(directory, '.git'))) {
89
+ return directory;
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export function has_hooks_config(directory: string): boolean {
96
+ return (
97
+ is_file(join(directory, '.claude', 'settings.json')) ||
98
+ is_file(join(directory, '.rulesync', 'hooks.json')) ||
99
+ is_file(join(directory, '.pi', 'hooks.json'))
100
+ );
101
+ }
102
+
103
+ export function find_project_dir(cwd: string): string {
104
+ const git_root = find_nearest_git_root(cwd);
105
+ for (const directory of walk_up_directories(cwd, git_root)) {
106
+ if (has_hooks_config(directory)) {
107
+ return directory;
108
+ }
109
+ }
110
+ return git_root ?? resolve(cwd);
111
+ }
112
+
113
+ export function read_json_file(path: string): JsonValue | undefined {
114
+ if (!is_file(path)) return undefined;
115
+ try {
116
+ return JSON.parse(readFileSync(path, 'utf8')) as JsonValue;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ export function resolve_hook_command(
123
+ command: string,
124
+ project_dir: string,
125
+ ): string {
126
+ return command.replace(/\$CLAUDE_PROJECT_DIR\b/g, project_dir);
127
+ }
128
+
129
+ export function compile_matcher(
130
+ matcher_text: string | undefined,
131
+ ): RegExp | undefined {
132
+ if (matcher_text === undefined) return undefined;
133
+ try {
134
+ return new RegExp(matcher_text);
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ export function create_hook(
141
+ event_name: HookEventName,
142
+ matcher_text: string | undefined,
143
+ command: string,
144
+ source: string,
145
+ project_dir: string,
146
+ ): ResolvedCommandHook | undefined {
147
+ const matcher = compile_matcher(matcher_text);
148
+ if (matcher_text !== undefined && matcher === undefined)
149
+ return undefined;
150
+ return {
151
+ event_name,
152
+ matcher,
153
+ matcher_text,
154
+ command: resolve_hook_command(command, project_dir),
155
+ source,
156
+ };
157
+ }
158
+
159
+ export function get_hook_entries(
160
+ hooks_record: Record<string, unknown>,
161
+ event_name: HookEventName,
162
+ ): unknown[] {
163
+ const keys =
164
+ event_name === 'PostToolUse'
165
+ ? ['PostToolUse', 'postToolUse']
166
+ : ['PostToolUseFailure', 'postToolUseFailure'];
167
+
168
+ for (const key of keys) {
169
+ const value = hooks_record[key];
170
+ if (Array.isArray(value)) return value;
171
+ }
172
+ return [];
173
+ }
174
+
175
+ export function parse_claude_settings_hooks(
176
+ config: unknown,
177
+ source: string,
178
+ project_dir: string,
179
+ ): ResolvedCommandHook[] {
180
+ const root = as_record(config);
181
+ const hooks_root = root ? as_record(root.hooks) : undefined;
182
+ if (!hooks_root) return [];
183
+
184
+ const hooks: ResolvedCommandHook[] = [];
185
+ const events: HookEventName[] = [
186
+ 'PostToolUse',
187
+ 'PostToolUseFailure',
188
+ ];
189
+
190
+ for (const event_name of events) {
191
+ const entries = get_hook_entries(hooks_root, event_name);
192
+ for (const entry of entries) {
193
+ const entry_record = as_record(entry);
194
+ if (!entry_record || !Array.isArray(entry_record.hooks))
195
+ continue;
196
+
197
+ const matcher_text =
198
+ typeof entry_record.matcher === 'string'
199
+ ? entry_record.matcher
200
+ : undefined;
201
+ for (const nested_hook of entry_record.hooks) {
202
+ const nested_record = as_record(nested_hook);
203
+ if (!nested_record) continue;
204
+ if (nested_record.type !== 'command') continue;
205
+ if (typeof nested_record.command !== 'string') continue;
206
+
207
+ const hook = create_hook(
208
+ event_name,
209
+ matcher_text,
210
+ nested_record.command,
211
+ source,
212
+ project_dir,
213
+ );
214
+ if (hook) hooks.push(hook);
215
+ }
216
+ }
217
+ }
218
+
219
+ return hooks;
220
+ }
221
+
222
+ export function parse_simple_hooks_file(
223
+ config: unknown,
224
+ source: string,
225
+ project_dir: string,
226
+ ): ResolvedCommandHook[] {
227
+ const root = as_record(config);
228
+ const hooks_root = root ? as_record(root.hooks) : undefined;
229
+ if (!hooks_root) return [];
230
+
231
+ const hooks: ResolvedCommandHook[] = [];
232
+ const events: HookEventName[] = [
233
+ 'PostToolUse',
234
+ 'PostToolUseFailure',
235
+ ];
236
+
237
+ for (const event_name of events) {
238
+ const entries = get_hook_entries(hooks_root, event_name);
239
+ for (const entry of entries) {
240
+ const entry_record = as_record(entry);
241
+ if (!entry_record || typeof entry_record.command !== 'string') {
242
+ continue;
243
+ }
244
+
245
+ const matcher_text =
246
+ typeof entry_record.matcher === 'string'
247
+ ? entry_record.matcher
248
+ : undefined;
249
+ const hook = create_hook(
250
+ event_name,
251
+ matcher_text,
252
+ entry_record.command,
253
+ source,
254
+ project_dir,
255
+ );
256
+ if (hook) hooks.push(hook);
257
+ }
258
+ }
259
+
260
+ return hooks;
261
+ }
262
+
263
+ export function load_hooks(cwd: string): HookState {
264
+ const project_dir = find_project_dir(cwd);
265
+ const hooks: ResolvedCommandHook[] = [];
266
+
267
+ const claude_settings_path = join(
268
+ project_dir,
269
+ '.claude',
270
+ 'settings.json',
271
+ );
272
+ const rulesync_hooks_path = join(
273
+ project_dir,
274
+ '.rulesync',
275
+ 'hooks.json',
276
+ );
277
+ const pi_hooks_path = join(project_dir, '.pi', 'hooks.json');
278
+
279
+ const claude_settings = read_json_file(claude_settings_path);
280
+ if (claude_settings !== undefined) {
281
+ hooks.push(
282
+ ...parse_claude_settings_hooks(
283
+ claude_settings,
284
+ claude_settings_path,
285
+ project_dir,
286
+ ),
287
+ );
288
+ }
289
+
290
+ const rulesync_hooks = read_json_file(rulesync_hooks_path);
291
+ if (rulesync_hooks !== undefined) {
292
+ hooks.push(
293
+ ...parse_simple_hooks_file(
294
+ rulesync_hooks,
295
+ rulesync_hooks_path,
296
+ project_dir,
297
+ ),
298
+ );
299
+ }
300
+
301
+ const pi_hooks = read_json_file(pi_hooks_path);
302
+ if (pi_hooks !== undefined) {
303
+ hooks.push(
304
+ ...parse_simple_hooks_file(
305
+ pi_hooks,
306
+ pi_hooks_path,
307
+ project_dir,
308
+ ),
309
+ );
310
+ }
311
+
312
+ return { project_dir, hooks };
313
+ }
314
+
315
+ export function to_claude_tool_name(tool_name: string): string {
316
+ if (tool_name === 'ls') return 'LS';
317
+ if (tool_name.length === 0) return tool_name;
318
+ return tool_name[0].toUpperCase() + tool_name.slice(1);
319
+ }
320
+
321
+ export function matches_hook(
322
+ hook: ResolvedCommandHook,
323
+ tool_name: string,
324
+ ): boolean {
325
+ if (!hook.matcher) return true;
326
+
327
+ const claude_tool_name = to_claude_tool_name(tool_name);
328
+ hook.matcher.lastIndex = 0;
329
+ if (hook.matcher.test(tool_name)) return true;
330
+
331
+ hook.matcher.lastIndex = 0;
332
+ return hook.matcher.test(claude_tool_name);
333
+ }
334
+
335
+ export function extract_text_content(content: unknown): string {
336
+ if (!Array.isArray(content)) return '';
337
+
338
+ const parts: string[] = [];
339
+ for (const item of content) {
340
+ if (!item || typeof item !== 'object') continue;
341
+ const item_record = item as Record<string, unknown>;
342
+ if (
343
+ item_record.type === 'text' &&
344
+ typeof item_record.text === 'string'
345
+ ) {
346
+ parts.push(item_record.text);
347
+ }
348
+ }
349
+
350
+ return parts.join('\n');
351
+ }
352
+
353
+ export function normalize_tool_input(
354
+ input: Record<string, unknown>,
355
+ ): Record<string, unknown> {
356
+ const normalized: Record<string, unknown> = { ...input };
357
+ const path_value =
358
+ typeof input.path === 'string' ? input.path : undefined;
359
+ if (path_value !== undefined) {
360
+ normalized.file_path = path_value;
361
+ normalized.filePath = path_value;
362
+ }
363
+ return normalized;
364
+ }
365
+
366
+ export function build_tool_response(
367
+ event: ToolResultEvent,
368
+ normalized_input: Record<string, unknown>,
369
+ ): Record<string, unknown> {
370
+ const response: Record<string, unknown> = {
371
+ is_error: event.isError,
372
+ isError: event.isError,
373
+ content: event.content,
374
+ text: extract_text_content(event.content),
375
+ details: event.details ?? null,
376
+ };
377
+
378
+ const file_path =
379
+ typeof normalized_input.file_path === 'string'
380
+ ? normalized_input.file_path
381
+ : undefined;
382
+ if (file_path !== undefined) {
383
+ response.file_path = file_path;
384
+ response.filePath = file_path;
385
+ }
386
+
387
+ return response;
388
+ }
389
+
390
+ export function build_hook_payload(
391
+ event: ToolResultEvent,
392
+ event_name: HookEventName,
393
+ ctx: ExtensionContext,
394
+ project_dir: string,
395
+ ): Record<string, unknown> {
396
+ const normalized_input = normalize_tool_input(
397
+ event.input as Record<string, unknown>,
398
+ );
399
+ const session_id =
400
+ ctx.sessionManager.getSessionFile() ?? 'ephemeral';
401
+
402
+ return {
403
+ session_id,
404
+ cwd: ctx.cwd,
405
+ claude_project_dir: project_dir,
406
+ hook_event_name: event_name,
407
+ tool_name: to_claude_tool_name(event.toolName),
408
+ tool_call_id: event.toolCallId,
409
+ tool_input: normalized_input,
410
+ tool_response: build_tool_response(event, normalized_input),
411
+ };
412
+ }
413
+
414
+ export async function run_command_hook(
415
+ command: string,
416
+ cwd: string,
417
+ payload: Record<string, unknown>,
418
+ ): Promise<CommandRunResult> {
419
+ return await new Promise((resolve) => {
420
+ const started_at = Date.now();
421
+ const child = spawn('bash', ['-lc', command], {
422
+ cwd,
423
+ env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
424
+ stdio: ['pipe', 'pipe', 'pipe'],
425
+ });
426
+
427
+ let stdout = '';
428
+ let stderr = '';
429
+ let timed_out = false;
430
+ let resolved = false;
431
+
432
+ const finish = (code: number) => {
433
+ if (resolved) return;
434
+ resolved = true;
435
+ resolve({
436
+ code,
437
+ stdout,
438
+ stderr,
439
+ elapsed_ms: Date.now() - started_at,
440
+ timed_out,
441
+ });
442
+ };
443
+
444
+ const timeout = setTimeout(() => {
445
+ timed_out = true;
446
+ child.kill('SIGTERM');
447
+ const kill_timer = setTimeout(() => {
448
+ child.kill('SIGKILL');
449
+ }, 1000);
450
+ (
451
+ kill_timer as NodeJS.Timeout & { unref?: () => void }
452
+ ).unref?.();
453
+ }, HOOK_TIMEOUT_MS);
454
+ (timeout as NodeJS.Timeout & { unref?: () => void }).unref?.();
455
+
456
+ child.stdout.on('data', (chunk: Buffer) => {
457
+ stdout += chunk.toString('utf8');
458
+ });
459
+ child.stderr.on('data', (chunk: Buffer) => {
460
+ stderr += chunk.toString('utf8');
461
+ });
462
+
463
+ child.on('error', (error) => {
464
+ clearTimeout(timeout);
465
+ stderr += `${error.message}\n`;
466
+ finish(-1);
467
+ });
468
+
469
+ child.on('close', (code) => {
470
+ clearTimeout(timeout);
471
+ finish(code ?? -1);
472
+ });
473
+
474
+ try {
475
+ child.stdin.write(JSON.stringify(payload));
476
+ child.stdin.end();
477
+ } catch (error) {
478
+ stderr += `${error instanceof Error ? error.message : String(error)}\n`;
479
+ }
480
+ });
481
+ }
482
+
483
+ export function hook_event_name_for_result(
484
+ event: ToolResultEvent,
485
+ ): HookEventName {
486
+ return event.isError ? 'PostToolUseFailure' : 'PostToolUse';
487
+ }
488
+
489
+ export function format_duration(elapsed_ms: number): string {
490
+ if (elapsed_ms < 1000) return `${elapsed_ms}ms`;
491
+ return `${(elapsed_ms / 1000).toFixed(1)}s`;
492
+ }
493
+
494
+ export function hook_name(command: string): string {
495
+ const sh_path_match = command.match(/[^\s|;&]+\.sh\b/);
496
+ if (sh_path_match) return basename(sh_path_match[0]);
497
+ const first_token = command.trim().split(/\s+/)[0] ?? 'hook';
498
+ return basename(first_token);
499
+ }
500
+
501
+ export interface HooksResolutionOptions {
502
+ load_hooks?: (cwd: string) => HookState;
503
+ run_command_hook?: (
504
+ command: string,
505
+ cwd: string,
506
+ payload: Record<string, unknown>,
507
+ ) => Promise<CommandRunResult>;
508
+ }
509
+
510
+ export function create_hooks_resolution_extension(
511
+ options: HooksResolutionOptions = {},
512
+ ): ExtensionFactory {
513
+ const load_hooks_impl = options.load_hooks ?? load_hooks;
514
+ const run_command_hook_impl =
515
+ options.run_command_hook ?? run_command_hook;
516
+
517
+ return async function hooks_resolution(pi: ExtensionAPI) {
518
+ let state: HookState = {
519
+ project_dir: process.cwd(),
520
+ hooks: [],
521
+ };
522
+
523
+ const refresh_hooks = (cwd: string) => {
524
+ state = load_hooks_impl(cwd);
525
+ };
526
+
527
+ pi.on('session_start', (_event, ctx) => {
528
+ refresh_hooks(ctx.cwd);
529
+ });
530
+
531
+ pi.on('tool_result', async (event, ctx) => {
532
+ if (state.hooks.length === 0) return;
533
+
534
+ const event_name = hook_event_name_for_result(event);
535
+ const matching_hooks = state.hooks.filter(
536
+ (hook) =>
537
+ hook.event_name === event_name &&
538
+ matches_hook(hook, event.toolName),
539
+ );
540
+ if (matching_hooks.length === 0) return;
541
+
542
+ const payload = build_hook_payload(
543
+ event,
544
+ event_name,
545
+ ctx,
546
+ state.project_dir,
547
+ );
548
+ const executed_commands = new Set<string>();
549
+
550
+ for (const hook of matching_hooks) {
551
+ if (executed_commands.has(hook.command)) continue;
552
+ executed_commands.add(hook.command);
553
+
554
+ const result = await run_command_hook_impl(
555
+ hook.command,
556
+ state.project_dir,
557
+ payload,
558
+ );
559
+ const name = hook_name(hook.command);
560
+ const duration = format_duration(result.elapsed_ms);
561
+
562
+ if (ctx.hasUI) {
563
+ if (result.code === 0) {
564
+ ctx.ui.notify(
565
+ `Hook \`${name}\` ran (${duration})`,
566
+ 'info',
567
+ );
568
+ } else {
569
+ const error_line =
570
+ result.stderr.trim() ||
571
+ result.stdout.trim() ||
572
+ `exit code ${result.code}`;
573
+ ctx.ui.notify(
574
+ `Hook \`${name}\` failed (${duration}): ${error_line}`,
575
+ 'warning',
576
+ );
577
+ }
578
+ }
579
+ }
580
+ });
581
+ };
582
+ }
583
+
584
+ export default create_hooks_resolution_extension();