@seanmozeik/tripwire 0.6.6 → 0.7.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.
@@ -1,46 +1,293 @@
1
- // Config installation module for tripwire hooks.
2
- // Parses and upserts hook configurations for Claude Code, Codex, and pi-guardrails.
3
-
1
+ // Agent installation module for Tripwire hooks and the native Pi extension.
2
+
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readlink,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ symlink,
14
+ type FileHandle,
15
+ } from 'node:fs/promises';
4
16
  import { homedir } from 'node:os';
17
+ import pathModule from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
5
19
 
6
- import { file } from 'bun';
20
+ interface CommandHook extends Record<string, unknown> {
21
+ type: string;
22
+ command: string;
23
+ timeout?: number;
24
+ }
7
25
 
8
- interface ClaudeConfig {
9
- hooks?: {
10
- PreToolUse?: { hooks: { type: string; command: string }[] }[];
11
- PostToolUse?: { hooks: { type: string; command: string }[] }[];
12
- };
26
+ interface HookGroup extends Record<string, unknown> {
27
+ hooks: CommandHook[];
13
28
  }
14
29
 
15
- interface PiConfig {
16
- hooks?: {
17
- PreToolUse?: { hooks: { type: string; command: string }[] }[];
18
- PostToolUse?: { hooks: { type: string; command: string }[] }[];
19
- };
30
+ interface AgentHooks extends Record<string, unknown> {
31
+ PreToolUse?: HookGroup[];
32
+ PostToolUse?: HookGroup[];
20
33
  }
21
34
 
22
- interface CodexHooksConfig {
23
- hooks?: {
24
- PreToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
25
- PostToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
26
- };
35
+ interface AgentHooksConfig extends Record<string, unknown> {
36
+ hooks?: AgentHooks;
37
+ }
38
+
39
+ const piExtensionSourceCandidates = [
40
+ pathModule.join(pathModule.dirname(process.execPath), 'tripwire-pi.js'),
41
+ fileURLToPath(new URL('../../dist/tripwire-pi.js', import.meta.url)),
42
+ fileURLToPath(new URL('tripwire-pi.js', import.meta.url)),
43
+ ];
44
+
45
+ export interface InstallOptions {
46
+ readonly extensionSource?: string;
47
+ readonly homeDirectory?: string;
48
+ }
49
+
50
+ interface AtomicTextReplaceOptions {
51
+ readonly beforeRename?: (pendingPath: string, targetPath: string) => Promise<void> | void;
27
52
  }
28
53
 
54
+ interface ExtensionLinkResult {
55
+ readonly action: 'already' | 'installed' | 'updated';
56
+ readonly success: boolean;
57
+ }
58
+
59
+ interface CursorHook extends Record<string, unknown> {
60
+ command: string;
61
+ type?: string;
62
+ timeout?: number;
63
+ failClosed?: boolean;
64
+ }
65
+
66
+ interface CursorConfig extends Record<string, unknown> {
67
+ version?: number;
68
+ hooks?: Record<string, CursorHook[]>;
69
+ }
70
+
71
+ const isJsonRecord = (value: unknown): value is Record<string, unknown> =>
72
+ typeof value === 'object' && value !== null && !Array.isArray(value);
73
+
74
+ const isErrno = (error: unknown, code: string): boolean =>
75
+ error instanceof Error && 'code' in error && error.code === code;
76
+
77
+ export const replaceTextAtomically = async (
78
+ targetPath: string,
79
+ text: string,
80
+ options: AtomicTextReplaceOptions = {},
81
+ ): Promise<void> => {
82
+ let publicationPath = targetPath;
83
+ let targetIsSymlink = false;
84
+ try {
85
+ const targetStatus = await lstat(targetPath);
86
+ targetIsSymlink = targetStatus.isSymbolicLink();
87
+ } catch (error) {
88
+ if (!isErrno(error, 'ENOENT')) {
89
+ throw error;
90
+ }
91
+ }
92
+ if (targetIsSymlink) {
93
+ // A dangling link fails here instead of being replaced.
94
+ publicationPath = await realpath(targetPath);
95
+ }
96
+
97
+ const pendingPath = `${publicationPath}.${process.pid}.next`;
98
+ let handle: FileHandle | undefined;
99
+ let mode = 0o600;
100
+
101
+ try {
102
+ const targetStatus = await stat(publicationPath);
103
+ mode = targetStatus.mode & 0o7777;
104
+ } catch (error) {
105
+ if (!isErrno(error, 'ENOENT')) {
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ await rm(pendingPath, { force: true });
111
+ try {
112
+ handle = await open(pendingPath, 'wx', mode);
113
+ await handle.writeFile(text, 'utf8');
114
+ // Chmod is explicit after writing because open applies the umask and writes can clear mode bits.
115
+ await handle.chmod(mode);
116
+ await handle.sync();
117
+ await handle.close();
118
+ handle = undefined;
119
+ await options.beforeRename?.(pendingPath, publicationPath);
120
+ await rename(pendingPath, publicationPath);
121
+ } finally {
122
+ try {
123
+ if (handle !== undefined) {
124
+ await handle.close();
125
+ }
126
+ } finally {
127
+ await rm(pendingPath, { force: true });
128
+ }
129
+ }
130
+ };
131
+
132
+ const isCommandHook = (value: unknown): value is CommandHook =>
133
+ isJsonRecord(value) &&
134
+ typeof value['type'] === 'string' &&
135
+ typeof value['command'] === 'string' &&
136
+ (value['timeout'] === undefined || typeof value['timeout'] === 'number');
137
+
138
+ const isHookGroups = (value: unknown): value is HookGroup[] =>
139
+ Array.isArray(value) &&
140
+ value.every(
141
+ (group) =>
142
+ isJsonRecord(group) && Array.isArray(group['hooks']) && group['hooks'].every(isCommandHook),
143
+ );
144
+
145
+ const isAgentHooksConfig = (value: unknown): value is AgentHooksConfig => {
146
+ if (!isJsonRecord(value)) {
147
+ return false;
148
+ }
149
+ const { hooks } = value;
150
+ if (hooks === undefined) {
151
+ return true;
152
+ }
153
+ return (
154
+ isJsonRecord(hooks) &&
155
+ (hooks['PreToolUse'] === undefined || isHookGroups(hooks['PreToolUse'])) &&
156
+ (hooks['PostToolUse'] === undefined || isHookGroups(hooks['PostToolUse']))
157
+ );
158
+ };
159
+
160
+ const parseAgentHooksConfig = (raw: string, label: string): AgentHooksConfig => {
161
+ const value: unknown = JSON.parse(raw);
162
+ if (!isAgentHooksConfig(value)) {
163
+ throw new Error(`${label} hook config has an unsupported shape`);
164
+ }
165
+ return value;
166
+ };
167
+
168
+ const parseCursorConfig = (raw: string): CursorConfig => {
169
+ const value: unknown = JSON.parse(raw);
170
+ if (!isJsonRecord(value)) {
171
+ throw new Error('Cursor hooks.json must contain a JSON object');
172
+ }
173
+ const { hooks } = value;
174
+ if (hooks === undefined) {
175
+ return value;
176
+ }
177
+ if (!isJsonRecord(hooks)) {
178
+ throw new Error('Cursor hooks.json `hooks` must be an object');
179
+ }
180
+ for (const [eventName, eventHooks] of Object.entries(hooks)) {
181
+ if (
182
+ !Array.isArray(eventHooks) ||
183
+ eventHooks.some((hook) => !isJsonRecord(hook) || typeof hook['command'] !== 'string')
184
+ ) {
185
+ throw new Error(`Cursor hooks.json event ${eventName} must contain command hooks`);
186
+ }
187
+ }
188
+ return value;
189
+ };
190
+
29
191
  const TRIPWIRE_HOOK = 'tripwire-hook';
30
192
 
31
- const addHookIfMissing = (
32
- hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
33
- ): [{ hooks: { type: string; command: string; timeout?: number }[] }[], boolean] => {
193
+ const installExtensionLink = async (
194
+ source: string,
195
+ extensionPath: string,
196
+ ): Promise<ExtensionLinkResult> => {
197
+ await mkdir(pathModule.dirname(extensionPath), { recursive: true });
198
+ try {
199
+ const status = await lstat(extensionPath);
200
+ if (!status.isSymbolicLink()) {
201
+ return { action: 'already', success: false };
202
+ }
203
+ const currentTarget = await readlink(extensionPath);
204
+ if (currentTarget === source) {
205
+ return { action: 'already', success: true };
206
+ }
207
+ const resolvedTarget = pathModule.resolve(pathModule.dirname(extensionPath), currentTarget);
208
+ if (pathModule.basename(resolvedTarget) !== 'tripwire-pi.js') {
209
+ return { action: 'already', success: false };
210
+ }
211
+
212
+ const pendingPath = `${extensionPath}.${process.pid}.next`;
213
+ await rm(pendingPath, { force: true });
214
+ try {
215
+ await symlink(source, pendingPath);
216
+ await rename(pendingPath, extensionPath);
217
+ } finally {
218
+ await rm(pendingPath, { force: true });
219
+ }
220
+ return { action: 'updated', success: true };
221
+ } catch (error) {
222
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
223
+ throw error;
224
+ }
225
+ await symlink(source, extensionPath);
226
+ return { action: 'installed', success: true };
227
+ }
228
+ };
229
+
230
+ const CURSOR_HOOK_EVENTS = [
231
+ 'preToolUse',
232
+ 'postToolUse',
233
+ 'beforeShellExecution',
234
+ 'afterShellExecution',
235
+ 'beforeReadFile',
236
+ 'afterFileEdit',
237
+ ] as const;
238
+
239
+ const CURSOR_FAIL_CLOSED_EVENTS = new Set(['preToolUse', 'beforeShellExecution', 'beforeReadFile']);
240
+
241
+ const isTripwireCommand = (command: string): boolean =>
242
+ command === TRIPWIRE_HOOK ||
243
+ command.startsWith(`${TRIPWIRE_HOOK} `) ||
244
+ command.includes('/tripwire-hook') ||
245
+ command.includes('/tripwire.js') ||
246
+ command.includes('/dist/tripwire');
247
+
248
+ const addCursorHook = (
249
+ hooks: CursorHook[] | undefined,
250
+ eventName: string,
251
+ ): [CursorHook[], boolean] => {
252
+ const failClosed = CURSOR_FAIL_CLOSED_EVENTS.has(eventName);
253
+ const newHook = {
254
+ command: `${TRIPWIRE_HOOK} --cursor-event ${eventName}`,
255
+ ...(failClosed && { failClosed: true }),
256
+ };
257
+ if (hooks === undefined) {
258
+ return [[newHook], true];
259
+ }
260
+
261
+ let changed = false;
262
+ const hasTripwire = hooks.some((hook) => isTripwireCommand(hook.command));
263
+ const desiredCommand = `${TRIPWIRE_HOOK} --cursor-event ${eventName}`;
264
+ const normalized = hooks.map((hook) => {
265
+ if (!isTripwireCommand(hook.command)) {
266
+ return hook;
267
+ }
268
+ if (hook.command === desiredCommand && (!failClosed || hook.failClosed === true)) {
269
+ return hook;
270
+ }
271
+ changed = true;
272
+ return { ...hook, command: desiredCommand, ...(failClosed && { failClosed: true }) };
273
+ });
274
+
275
+ if (hasTripwire) {
276
+ return [normalized, changed];
277
+ }
278
+ return [[...normalized, newHook], true];
279
+ };
280
+
281
+ const addHookIfMissing = (hooks: HookGroup[] | undefined): [HookGroup[], boolean] => {
34
282
  if (!hooks) {
35
- const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
36
- { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
37
- ];
283
+ const newHooks: HookGroup[] = [{ hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] }];
38
284
  return [newHooks, false];
39
285
  }
40
286
 
41
287
  let needsNormalization = false;
42
288
 
43
289
  const normalizedHooks = hooks.map((h) => ({
290
+ ...h,
44
291
  hooks: h.hooks.map((hook) => {
45
292
  if (hook.command === TRIPWIRE_HOOK || hook.command.endsWith('/tripwire-hook')) {
46
293
  if (hook.command !== TRIPWIRE_HOOK) {
@@ -61,20 +308,21 @@ const addHookIfMissing = (
61
308
  return [normalizedHooks, !needsNormalization];
62
309
  }
63
310
 
64
- const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
311
+ const newHooks: HookGroup[] = [
65
312
  ...normalizedHooks,
66
313
  { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
67
314
  ];
68
315
  return [newHooks, false];
69
316
  };
70
317
 
71
- export const installClaude = async (): Promise<{ success: boolean; message: string }> => {
72
- const configPath = `${homedir()}/.claude/settings.json`;
73
- const configFile = file(configPath);
74
-
318
+ export const installClaude = async (
319
+ options: InstallOptions = {},
320
+ ): Promise<{ success: boolean; message: string }> => {
321
+ const homeDirectory = options.homeDirectory ?? homedir();
322
+ const configPath = `${homeDirectory}/.claude/settings.json`;
75
323
  try {
76
- const raw = await configFile.text();
77
- const config = JSON.parse(raw) as ClaudeConfig;
324
+ const raw = await readFile(configPath, 'utf8');
325
+ const config = parseAgentHooksConfig(raw, 'Claude');
78
326
 
79
327
  config.hooks ??= {};
80
328
  const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
@@ -87,152 +335,276 @@ export const installClaude = async (): Promise<{ success: boolean; message: stri
87
335
  return { success: true, message: `Already configured: ${configPath}` };
88
336
  }
89
337
 
90
- await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
338
+ await replaceTextAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
91
339
 
92
340
  return { success: true, message: `Updated ${configPath}` };
93
341
  } catch (error) {
94
- const message = error instanceof Error ? error.message : String(error);
95
- if (message.includes('No such file')) {
342
+ if (isErrno(error, 'ENOENT')) {
96
343
  return { success: false, message: `Config file not found: ${configPath}` };
97
344
  }
345
+ const message = error instanceof Error ? error.message : String(error);
98
346
  return { success: false, message: `Failed to update Claude config: ${message}` };
99
347
  }
100
348
  };
101
349
 
102
- export const installPi = async (): Promise<{ success: boolean; message: string }> => {
103
- const configPath = `${homedir()}/.pi/agent/settings.json`;
104
- const configFile = file(configPath);
350
+ export const installPi = async (
351
+ options: InstallOptions = {},
352
+ ): Promise<{ success: boolean; message: string }> => {
353
+ const homeDirectory = options.homeDirectory ?? homedir();
354
+ const configPath = `${homeDirectory}/.pi/agent/settings.json`;
355
+ const extensionPath = `${homeDirectory}/.pi/agent/extensions/tripwire.js`;
105
356
 
106
357
  try {
107
- const raw = await configFile.text();
108
- const config = JSON.parse(raw) as PiConfig;
109
-
110
- config.hooks ??= {};
111
- const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
112
- const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
113
-
114
- config.hooks.PreToolUse = preToolUse;
115
- config.hooks.PostToolUse = postToolUse;
116
-
117
- if (preSkipped && postSkipped) {
118
- return { success: true, message: `Already configured: ${configPath}` };
358
+ const source =
359
+ options.extensionSource ??
360
+ piExtensionSourceCandidates.find((candidate) => Bun.file(candidate).size > 0);
361
+ if (source === undefined) {
362
+ return { success: false, message: 'Built Pi extension not found; run `bun run build` first' };
119
363
  }
120
-
121
- await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
122
-
123
- return { success: true, message: `Updated ${configPath}` };
364
+ const raw = await readFile(configPath, 'utf8');
365
+ let config = parseAgentHooksConfig(raw, 'Pi');
366
+ if (config.hooks !== undefined) {
367
+ const { PreToolUse, PostToolUse, ...otherHooks } = config.hooks;
368
+ const cleanGroups = (groups: HookGroup[] | undefined) =>
369
+ groups
370
+ ?.map((group) => ({
371
+ ...group,
372
+ hooks: group.hooks.filter((hook) => !isTripwireCommand(hook.command)),
373
+ }))
374
+ .filter((group) => group.hooks.length > 0) ?? [];
375
+ const cleanPreToolUse = cleanGroups(PreToolUse);
376
+ const cleanPostToolUse = cleanGroups(PostToolUse);
377
+ const nextHooks = {
378
+ ...otherHooks,
379
+ ...(cleanPreToolUse.length > 0 && { PreToolUse: cleanPreToolUse }),
380
+ ...(cleanPostToolUse.length > 0 && { PostToolUse: cleanPostToolUse }),
381
+ };
382
+ if (Object.keys(nextHooks).length === 0) {
383
+ const { hooks: _removedHooks, ...configWithoutHooks } = config;
384
+ config = configWithoutHooks;
385
+ } else {
386
+ config.hooks = nextHooks;
387
+ }
388
+ }
389
+ const nextRaw = `${JSON.stringify(config, null, 2)}\n`;
390
+ const settingsChanged = nextRaw !== raw;
391
+
392
+ // The extension must be available before old hook settings are removed.
393
+ const link = await installExtensionLink(source, extensionPath);
394
+ if (!link.success) {
395
+ return {
396
+ success: false,
397
+ message: `Refusing to replace existing Pi extension: ${extensionPath}`,
398
+ };
399
+ }
400
+ if (settingsChanged) {
401
+ await replaceTextAtomically(configPath, nextRaw);
402
+ }
403
+ if (link.action === 'already' && !settingsChanged) {
404
+ return { success: true, message: `Already configured: ${extensionPath}` };
405
+ }
406
+ const verb = link.action === 'updated' ? 'Updated' : 'Installed';
407
+ return { success: true, message: `${verb} ${extensionPath}` };
124
408
  } catch (error) {
125
- const message = error instanceof Error ? error.message : String(error);
126
- if (message.includes('No such file')) {
409
+ if (isErrno(error, 'ENOENT')) {
127
410
  return { success: false, message: `Config file not found: ${configPath}` };
128
411
  }
412
+ const message = error instanceof Error ? error.message : String(error);
129
413
  return { success: false, message: `Failed to update pi config: ${message}` };
130
414
  }
131
415
  };
132
416
 
133
- export const installCodex = async (): Promise<{ success: boolean; message: string }> => {
134
- const configTomlPath = `${homedir()}/.codex/config.toml`;
135
- const hooksJsonPath = `${homedir()}/.codex/hooks.json`;
136
- const hooksJsonFile = file(hooksJsonPath);
137
- const configTomlFile = file(configTomlPath);
138
-
139
- let hooksUpdated = false;
140
- let tomlUpdated = false;
141
-
142
- // First, update hooks.json
417
+ export const installOhMyPi = async (
418
+ options: InstallOptions = {},
419
+ ): Promise<{ success: boolean; message: string }> => {
420
+ const homeDirectory = options.homeDirectory ?? homedir();
421
+ const extensionPath = `${homeDirectory}/.omp/agent/extensions/tripwire.js`;
143
422
  try {
144
- const raw = await hooksJsonFile.text();
145
- const config = JSON.parse(raw) as CodexHooksConfig;
423
+ const source =
424
+ options.extensionSource ??
425
+ piExtensionSourceCandidates.find((candidate) => Bun.file(candidate).size > 0);
426
+ if (source === undefined) {
427
+ return { success: false, message: 'Built Pi extension not found; run `bun run build` first' };
428
+ }
429
+ const link = await installExtensionLink(source, extensionPath);
430
+ if (!link.success) {
431
+ return {
432
+ success: false,
433
+ message: `Refusing to replace existing oh-my-pi extension: ${extensionPath}`,
434
+ };
435
+ }
436
+ if (link.action === 'already') {
437
+ return { success: true, message: `Already configured: ${extensionPath}` };
438
+ }
439
+ const verb = link.action === 'updated' ? 'Updated' : 'Installed';
440
+ return { success: true, message: `${verb} ${extensionPath}` };
441
+ } catch (error) {
442
+ const message = error instanceof Error ? error.message : String(error);
443
+ return { success: false, message: `Failed to install oh-my-pi extension: ${message}` };
444
+ }
445
+ };
146
446
 
147
- config.hooks ??= {};
148
- const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
149
- const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
447
+ const parseCodexFeatures = (raw: string): Record<string, unknown> | undefined => {
448
+ const parsed: unknown = Bun.TOML.parse(raw);
449
+ const features = isJsonRecord(parsed) ? parsed['features'] : undefined;
450
+ if (features !== undefined && !isJsonRecord(features)) {
451
+ throw new Error('Codex config.toml `features` must be a table');
452
+ }
453
+ return features;
454
+ };
150
455
 
151
- config.hooks.PreToolUse = preToolUse;
152
- config.hooks.PostToolUse = postToolUse;
456
+ const enableCodexHooks = (raw: string): string => {
457
+ if (parseCodexFeatures(raw)?.['hooks'] === true) {
458
+ return raw;
459
+ }
153
460
 
154
- if (!preSkipped || !postSkipped) {
155
- hooksUpdated = true;
156
- }
157
-
158
- // Add timeout to tripwire-hook if not present
159
- const addTimeout = (
160
- hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
161
- ): { hooks: { type: string; command: string; timeout?: number }[] }[] => {
162
- return (
163
- hooks?.map((h) => ({
164
- hooks: h.hooks.map((hook) => {
165
- if (hook.command === TRIPWIRE_HOOK && hook.timeout === undefined) {
166
- return { ...hook, timeout: 10 };
167
- }
168
- return hook;
169
- }),
170
- })) ?? []
461
+ const newline = raw.includes('\r\n') ? '\r\n' : '\n';
462
+ const featuresHeader = /^[\t ]*\[features\][\t ]*(?:#.*)?(?:\r?\n|$)/m.exec(raw);
463
+ let next: string;
464
+ if (featuresHeader === null) {
465
+ const separator = raw.length === 0 || raw.endsWith('\n') ? '' : newline;
466
+ const finalNewline = raw.length === 0 || raw.endsWith('\n') ? newline : '';
467
+ next = `${raw}${separator}[features]${newline}hooks = true${finalNewline}`;
468
+ } else {
469
+ const sectionStart = featuresHeader.index + featuresHeader[0].length;
470
+ const nextHeaderPattern = /^[\t ]*\[/gm;
471
+ nextHeaderPattern.lastIndex = sectionStart;
472
+ const nextHeader = nextHeaderPattern.exec(raw);
473
+ const sectionEnd = nextHeader?.index ?? raw.length;
474
+ const section = raw.slice(sectionStart, sectionEnd);
475
+ const assignment =
476
+ /^(?<prefix>[\t ]*hooks[\t ]*=[\t ]*)(?<value>[^#\r\n]*?)(?<suffix>[\t ]*(?:#.*)?)$/m.exec(
477
+ section,
171
478
  );
172
- };
173
479
 
174
- config.hooks.PreToolUse = addTimeout(config.hooks.PreToolUse);
175
- config.hooks.PostToolUse = addTimeout(config.hooks.PostToolUse);
480
+ if (assignment === null) {
481
+ const headerHasNewline = featuresHeader[0].endsWith('\n');
482
+ const insertion = headerHasNewline ? `hooks = true${newline}` : `${newline}hooks = true`;
483
+ next = `${raw.slice(0, sectionStart)}${insertion}${raw.slice(sectionStart)}`;
484
+ } else {
485
+ const prefix = assignment.groups?.['prefix'];
486
+ const value = assignment.groups?.['value'];
487
+ if (prefix === undefined || value === undefined) {
488
+ throw new Error('Could not locate Codex hooks value in config.toml');
489
+ }
490
+ const valueStart = sectionStart + assignment.index + prefix.length;
491
+ const valueEnd = valueStart + value.length;
492
+ next = `${raw.slice(0, valueStart)}true${raw.slice(valueEnd)}`;
493
+ }
494
+ }
176
495
 
496
+ if (parseCodexFeatures(next)?.['hooks'] !== true) {
497
+ throw new Error('Could not enable Codex hooks in config.toml');
498
+ }
499
+ return next;
500
+ };
501
+
502
+ export const installCodex = async (
503
+ options: InstallOptions = {},
504
+ ): Promise<{ success: boolean; message: string }> => {
505
+ const homeDirectory = options.homeDirectory ?? homedir();
506
+ const configTomlPath = `${homeDirectory}/.codex/config.toml`;
507
+ const hooksJsonPath = `${homeDirectory}/.codex/hooks.json`;
508
+
509
+ try {
510
+ // Read and validate both files before the first write.
511
+ const [hooksRaw, tomlRaw] = await Promise.all([
512
+ readFile(hooksJsonPath, 'utf8'),
513
+ readFile(configTomlPath, 'utf8'),
514
+ ]);
515
+ const config = parseAgentHooksConfig(hooksRaw, 'Codex');
516
+ config.hooks ??= {};
517
+ const [preToolUse] = addHookIfMissing(config.hooks.PreToolUse);
518
+ const [postToolUse] = addHookIfMissing(config.hooks.PostToolUse);
519
+
520
+ const addTimeout = (hooks: HookGroup[]): HookGroup[] =>
521
+ hooks.map((group) => ({
522
+ ...group,
523
+ hooks: group.hooks.map((hook) =>
524
+ hook.command === TRIPWIRE_HOOK && hook.timeout === undefined
525
+ ? { ...hook, timeout: 10 }
526
+ : hook,
527
+ ),
528
+ }));
529
+
530
+ config.hooks.PreToolUse = addTimeout(preToolUse);
531
+ config.hooks.PostToolUse = addTimeout(postToolUse);
532
+ const nextHooksRaw = `${JSON.stringify(config, null, 2)}\n`;
533
+ const nextTomlRaw = enableCodexHooks(tomlRaw);
534
+ const hooksUpdated = nextHooksRaw !== hooksRaw;
535
+ const tomlUpdated = nextTomlRaw !== tomlRaw;
536
+
537
+ // Publish hooks.json first. It is inert while the feature is disabled, and a retry repairs
538
+ // a first-file-only partial update without changing the already published bytes.
177
539
  if (hooksUpdated) {
178
- await hooksJsonFile.write(`${JSON.stringify(config, null, 2)}\n`);
540
+ await replaceTextAtomically(hooksJsonPath, nextHooksRaw);
541
+ }
542
+ if (tomlUpdated) {
543
+ await replaceTextAtomically(configTomlPath, nextTomlRaw);
544
+ }
545
+
546
+ if (!hooksUpdated && !tomlUpdated) {
547
+ return {
548
+ success: true,
549
+ message: `Already configured: ${configTomlPath} and ${hooksJsonPath}`,
550
+ };
179
551
  }
552
+ return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
180
553
  } catch (error) {
181
- const message = error instanceof Error ? error.message : String(error);
182
- if (message.includes('No such file')) {
183
- return { success: false, message: `Config file not found: ${hooksJsonPath}` };
554
+ if (isErrno(error, 'ENOENT')) {
555
+ const missingPath =
556
+ isJsonRecord(error) && typeof error['path'] === 'string'
557
+ ? error['path']
558
+ : `${configTomlPath} or ${hooksJsonPath}`;
559
+ return { success: false, message: `Config file not found: ${missingPath}` };
184
560
  }
185
- return { success: false, message: `Failed to update Codex hooks.json: ${message}` };
561
+ const message = error instanceof Error ? error.message : String(error);
562
+ return { success: false, message: `Failed to update Codex config: ${message}` };
186
563
  }
564
+ };
187
565
 
188
- // Then, update config.toml to enable hooks
566
+ export const installCursor = async (
567
+ options: InstallOptions = {},
568
+ ): Promise<{ success: boolean; message: string }> => {
569
+ const homeDirectory = options.homeDirectory ?? homedir();
570
+ const configPath = `${homeDirectory}/.cursor/hooks.json`;
189
571
  try {
190
- const raw = await configTomlFile.text();
191
- let toml = raw;
572
+ const raw = await readFile(configPath, 'utf8');
573
+ const config = parseCursorConfig(raw);
574
+ config.version ??= 1;
575
+ config.hooks ??= {};
192
576
 
193
- // Enable hooks in [features] section
194
- if (toml.includes('hooks = true')) {
195
- // Already enabled, nothing to do
196
- } else {
197
- tomlUpdated = true;
198
- if (toml.includes('[features]')) {
199
- // Find [features] section and add hooks = true
200
- const featuresIndex = toml.indexOf('[features]');
201
- const nextSectionIndex = toml.indexOf('\n[', featuresIndex + 1);
202
- if (nextSectionIndex === -1) {
203
- toml += '\nhooks = true';
204
- } else {
205
- toml = `${toml.slice(0, nextSectionIndex)}\nhooks = true${toml.slice(nextSectionIndex)}`;
206
- }
207
- } else {
208
- toml += '\n[features]\nhooks = true';
209
- }
577
+ let updated = false;
578
+ for (const eventName of CURSOR_HOOK_EVENTS) {
579
+ const [hooks, changed] = addCursorHook(config.hooks[eventName], eventName);
580
+ config.hooks[eventName] = hooks;
581
+ updated ||= changed;
210
582
  }
211
583
 
212
- if (tomlUpdated) {
213
- await configTomlFile.write(toml);
584
+ if (!updated) {
585
+ return { success: true, message: `Already configured: ${configPath}` };
214
586
  }
587
+ await replaceTextAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
588
+ return { success: true, message: `Updated ${configPath}` };
215
589
  } catch (error) {
216
- const message = error instanceof Error ? error.message : String(error);
217
- if (message.includes('No such file')) {
218
- return { success: false, message: `Config file not found: ${configTomlPath}` };
590
+ if (isErrno(error, 'ENOENT')) {
591
+ return { success: false, message: `Config file not found: ${configPath}` };
219
592
  }
220
- return { success: false, message: `Failed to update Codex config.toml: ${message}` };
221
- }
222
-
223
- if (!hooksUpdated && !tomlUpdated) {
224
- return { success: true, message: `Already configured: ${configTomlPath} and ${hooksJsonPath}` };
593
+ const message = error instanceof Error ? error.message : String(error);
594
+ return { success: false, message: `Failed to update Cursor hooks.json: ${message}` };
225
595
  }
226
-
227
- return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
228
596
  };
229
597
 
230
- export const installAll = async (): Promise<
231
- { target: string; success: boolean; message: string }[]
232
- > => {
598
+ export const installAll = async (
599
+ options: InstallOptions = {},
600
+ ): Promise<{ target: string; success: boolean; message: string }[]> => {
233
601
  return [
234
- { target: 'claude', ...(await installClaude()) },
235
- { target: 'codex', ...(await installCodex()) },
236
- { target: 'pi', ...(await installPi()) },
602
+ { target: 'claude', ...(await installClaude(options)) },
603
+ { target: 'codex', ...(await installCodex(options)) },
604
+ { target: 'cursor', ...(await installCursor(options)) },
605
+ { target: 'pi', ...(await installPi(options)) },
606
+ { target: 'oh-my-pi', ...(await installOhMyPi(options)) },
237
607
  ];
238
608
  };
609
+
610
+ export { addCursorHook, parseCursorConfig, type CursorHook };