@seanmozeik/tripwire 0.6.7 → 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.
- package/LICENSE +21 -0
- package/README.md +152 -141
- package/dist/tripwire +0 -0
- package/dist/tripwire-pi.js +4 -0
- package/package.json +50 -22
- package/scripts/tripwire-cli +12 -0
- package/src/cli.ts +64 -57
- package/src/dispatch.ts +322 -114
- package/src/index.ts +1 -1
- package/src/lib/bash.ts +106 -62
- package/src/lib/config.ts +93 -46
- package/src/lib/cursor.ts +336 -0
- package/src/lib/diff.ts +5 -2
- package/src/lib/event.ts +20 -21
- package/src/lib/install.ts +508 -136
- package/src/lib/log.ts +2 -3
- package/src/lib/secrets.ts +151 -88
- package/src/main.ts +31 -0
- package/src/pi-extension.ts +337 -0
- package/src/rules/bash-deny.ts +13 -3
- package/src/rules/bash-git.ts +32 -45
- package/src/rules/bash-network-install.ts +6 -3
- package/src/rules/bash-redirect.ts +5 -5
- package/src/rules/bash-scoped-rm.ts +1 -1
- package/src/rules/bash-tar-explosion.ts +16 -15
- package/src/rules/config-custom.ts +21 -15
- package/src/rules/lazy-code.ts +1 -1
- package/src/rules/path-protect.ts +73 -10
- package/src/rules/post-secret-scrub.ts +17 -6
- package/src/rules/read-protect.ts +5 -15
- package/src/rules/tool-policy.ts +54 -0
- package/dist/tripwire-cli.js +0 -11
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +0 -96
- package/dist/tripwire.js.jsc +0 -0
- package/src/rules/bash-tool-policy.ts +0 -146
package/src/lib/install.ts
CHANGED
|
@@ -1,46 +1,293 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
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
|
-
|
|
20
|
+
interface CommandHook extends Record<string, unknown> {
|
|
21
|
+
type: string;
|
|
22
|
+
command: string;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
}
|
|
7
25
|
|
|
8
|
-
interface
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
23
|
-
hooks?:
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
77
|
-
const config =
|
|
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
|
|
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
|
-
|
|
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 (
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
456
|
+
const enableCodexHooks = (raw: string): string => {
|
|
457
|
+
if (parseCodexFeatures(raw)?.['hooks'] === true) {
|
|
458
|
+
return raw;
|
|
459
|
+
}
|
|
153
460
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
|
|
572
|
+
const raw = await readFile(configPath, 'utf8');
|
|
573
|
+
const config = parseCursorConfig(raw);
|
|
574
|
+
config.version ??= 1;
|
|
575
|
+
config.hooks ??= {};
|
|
192
576
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 (
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 (
|
|
231
|
-
|
|
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: '
|
|
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 };
|