@coworker-jp/aidr 0.0.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,247 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+
6
+ // Backup path: `<target>.backup-<ISO-timestamp>`.
7
+ // Millisecond timestamp is enough for human-facing uniqueness; parallel-install
8
+ // races within the same ms are out of scope (single-user install tool).
9
+ export function backupPathFor(target) {
10
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
11
+ return `${target}.backup-${stamp}`;
12
+ }
13
+
14
+ // Strip UTF-8 BOM and parse JSON. Prototype-pollution-safe: removes
15
+ // "__proto__" / "constructor" / "prototype" keys at every level of the parsed
16
+ // object so a hand-edited user config can't inject into Object.prototype when
17
+ // later spread/merged.
18
+ const POISON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
19
+ function sanitize(o) {
20
+ if (o === null || typeof o !== "object") return o;
21
+ if (Array.isArray(o)) { for (let i = 0; i < o.length; i++) o[i] = sanitize(o[i]); return o; }
22
+ for (const k of Object.keys(o)) {
23
+ if (POISON_KEYS.has(k)) { delete o[k]; continue; }
24
+ o[k] = sanitize(o[k]);
25
+ }
26
+ return o;
27
+ }
28
+
29
+ function parseJsonSafe(text, target) {
30
+ const stripped = text.replace(/^\uFEFF/, "");
31
+ if (stripped.trim() === "") return null;
32
+ if (/^\s*(?:\/\/|\/\*)/m.test(stripped)) {
33
+ throw new Error(
34
+ `${target} contains JS-style comments (JSONC). ` +
35
+ `Standard JSON doesn't allow comments — remove them and re-run.`
36
+ );
37
+ }
38
+ let parsed;
39
+ try { parsed = JSON.parse(stripped); }
40
+ catch (e) {
41
+ throw new Error(
42
+ `${target} is not valid JSON (${e.message}). ` +
43
+ `aidr refuses to overwrite a malformed config. ` +
44
+ `Please fix or delete the file and re-run.`
45
+ );
46
+ }
47
+ return sanitize(parsed);
48
+ }
49
+
50
+ // Idempotent-aware write with backup contract:
51
+ // - missing file → write
52
+ // - same content present → noop (no backup, no write)
53
+ // - different content + force → backup existing then write (no user data lost)
54
+ // - different content + !force → throw (refuse to overwrite without --force)
55
+ // - symlink → throw (don't follow user's intentional indirection)
56
+ //
57
+ // Pass `backup: false` to skip backup creation entirely — use this for
58
+ // deterministic, installer-regenerated files (shell scripts, templated
59
+ // wrappers) that don't need to be preserved. User-owned JSON/TOML configs
60
+ // should keep the default (backup: true).
61
+ export async function writeFileSafe(target, content, {
62
+ dryRun = false,
63
+ force = false,
64
+ backup = true,
65
+ mode = 0o644,
66
+ stderr = process.stderr,
67
+ } = {}) {
68
+ let stat = null;
69
+ try { stat = await fsp.lstat(target); }
70
+ catch (e) { if (e.code !== "ENOENT") throw e; }
71
+
72
+ if (stat && stat.isSymbolicLink()) {
73
+ throw new Error(
74
+ `${target} is a symlink. Refusing to overwrite. ` +
75
+ `Remove the symlink and re-run, or edit the target file manually.`
76
+ );
77
+ }
78
+
79
+ let backupPath = null;
80
+ if (stat) {
81
+ const existing = await fsp.readFile(target, "utf8");
82
+ if (existing === content) {
83
+ return { written: false, path: target, backupPath: null, unchanged: true };
84
+ }
85
+ if (!force) {
86
+ throw new Error(`refusing to overwrite ${target} (use --force)`);
87
+ }
88
+ if (backup && !dryRun) {
89
+ backupPath = backupPathFor(target);
90
+ await fsp.copyFile(target, backupPath);
91
+ }
92
+ if (backup) {
93
+ stderr.write(
94
+ `WARNING: ${target} already exists. ` +
95
+ `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n`
96
+ );
97
+ }
98
+ }
99
+
100
+ if (dryRun) {
101
+ process.stdout.write(`\n--- [dry-run] ${target} (${content.length} bytes) ---\n${content}\n--- end ---\n`);
102
+ return { written: false, path: target, backupPath: null };
103
+ }
104
+ await fsp.mkdir(path.dirname(target), { recursive: true });
105
+ await fsp.writeFile(target, content, { mode });
106
+ await fsp.chmod(target, mode);
107
+ return { written: true, path: target, backupPath };
108
+ }
109
+
110
+ export async function ensureDir(p) {
111
+ await fsp.mkdir(p, { recursive: true });
112
+ }
113
+
114
+ export function existsSync(p) {
115
+ try { fs.statSync(p); return true; } catch { return false; }
116
+ }
117
+
118
+ export async function removeIfExists(p) {
119
+ try { await fsp.rm(p, { recursive: true, force: true }); return true; } catch { return false; }
120
+ }
121
+
122
+ // Merge ourObject into existing JSON at `target` using `mergeFn(existing, ours)`.
123
+ // - Missing file: writes ourObject (merged with {}).
124
+ // - Existing file: backs up to target.backup-<ISO>, then writes merged result.
125
+ // - Symlink: refuses (would break the user's intentional indirection).
126
+ // - Malformed: refuses (don't silently discard user data).
127
+ export async function writeJsonMerge(target, ourObject, mergeFn, {
128
+ dryRun = false,
129
+ mode = 0o644,
130
+ stdout = process.stdout,
131
+ stderr = process.stderr,
132
+ } = {}) {
133
+ let existing = null;
134
+ let backupPath = null;
135
+
136
+ let stat = null;
137
+ try { stat = await fsp.lstat(target); }
138
+ catch (e) { if (e.code !== "ENOENT") throw e; }
139
+
140
+ let existingText = null;
141
+ if (stat) {
142
+ if (stat.isSymbolicLink()) {
143
+ throw new Error(
144
+ `${target} is a symlink. Refusing to overwrite. ` +
145
+ `Remove the symlink and re-run, or edit the target file manually.`
146
+ );
147
+ }
148
+ existingText = await fsp.readFile(target, "utf8");
149
+ existing = parseJsonSafe(existingText, target) ?? {};
150
+ }
151
+
152
+ const merged = mergeFn(existing, ourObject);
153
+ const content = JSON.stringify(merged, null, 2) + "\n";
154
+
155
+ // Idempotent short-circuit: if the merge output matches the existing file
156
+ // byte-for-byte, skip both the backup and the write. Without this guard
157
+ // every rerun drops a new settings.json.backup-* file even though nothing
158
+ // changed, polluting the user's config dir on repeated installs.
159
+ if (existingText !== null && existingText === content) {
160
+ return { written: false, path: target, backupPath: null, unchanged: true };
161
+ }
162
+
163
+ if (stat && !dryRun) {
164
+ backupPath = backupPathFor(target);
165
+ await fsp.copyFile(target, backupPath);
166
+ }
167
+ if (stat) {
168
+ stderr.write(
169
+ `WARNING: ${target} already exists. ` +
170
+ `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n` +
171
+ `Merging ai-scanner env/hooks; your other settings are preserved.\n`
172
+ );
173
+ }
174
+
175
+ if (dryRun) {
176
+ stdout.write(
177
+ `\n--- [dry-run] ${target} (merged, ${content.length} bytes) ---\n${content}\n--- end ---\n`
178
+ );
179
+ return { written: false, path: target, backupPath: null };
180
+ }
181
+
182
+ await fsp.mkdir(path.dirname(target), { recursive: true });
183
+ const tmp = `${target}.aidr-tmp-${crypto.randomUUID()}`;
184
+ await fsp.writeFile(tmp, content, { mode });
185
+ await fsp.chmod(tmp, mode);
186
+ await fsp.rename(tmp, target);
187
+ return { written: true, path: target, backupPath };
188
+ }
189
+
190
+ // Reverse of writeJsonMerge: strip ai-scanner entries from existing JSON using
191
+ // unmergeFn(existing) -> { result, empty }.
192
+ // - File missing: no-op, returns { action: "none" }.
193
+ // - Malformed JSON or symlink: throws (same fail-closed policy as writeJsonMerge).
194
+ // - empty=true after unmerge: delete the file.
195
+ // - empty=false: back up then write the stripped result.
196
+ export async function unmergeJsonFile(target, unmergeFn, {
197
+ dryRun = false,
198
+ mode = 0o644,
199
+ } = {}) {
200
+ let stat = null;
201
+ try { stat = await fsp.lstat(target); }
202
+ catch (e) { if (e.code !== "ENOENT") throw e; }
203
+ if (!stat) return { action: "none", path: target };
204
+
205
+ if (stat.isSymbolicLink()) {
206
+ throw new Error(
207
+ `${target} is a symlink. Refusing to modify. ` +
208
+ `Remove the symlink and re-run, or edit the target file manually.`
209
+ );
210
+ }
211
+ const text = await fsp.readFile(target, "utf8");
212
+ const existing = parseJsonSafe(text, target);
213
+
214
+ const { result, empty } = unmergeFn(existing);
215
+
216
+ if (dryRun) {
217
+ return {
218
+ action: empty ? "delete" : "modify",
219
+ path: target,
220
+ preview: empty ? null : (JSON.stringify(result, null, 2) + "\n"),
221
+ };
222
+ }
223
+
224
+ const backupPath = backupPathFor(target);
225
+ await fsp.copyFile(target, backupPath);
226
+
227
+ if (empty) {
228
+ await fsp.rm(target, { force: true });
229
+ return { action: "delete", path: target, backupPath };
230
+ }
231
+ const content = JSON.stringify(result, null, 2) + "\n";
232
+ const tmp = `${target}.aidr-tmp-${crypto.randomUUID()}`;
233
+ await fsp.writeFile(tmp, content, { mode });
234
+ await fsp.chmod(tmp, mode);
235
+ await fsp.rename(tmp, target);
236
+ return { action: "modify", path: target, backupPath };
237
+ }
238
+
239
+ export async function pathExists(p) {
240
+ try { await fsp.access(p); return true; } catch { return false; }
241
+ }
242
+
243
+ // Remove a directory only if it has become empty (rmdir errors otherwise).
244
+ export async function rmdirIfEmpty(p) {
245
+ try { await fsp.rmdir(p); return true; }
246
+ catch { return false; }
247
+ }
package/src/merge.mjs ADDED
@@ -0,0 +1,412 @@
1
+ // Build-and-merge for agent settings/hooks JSON files.
2
+ // Keeps ai-scanner's env/hooks fresh while preserving every other user key.
3
+ //
4
+ // Agents that merge into shared settings.json:
5
+ // - Claude Code (~/.claude/settings.json)
6
+ // - Gemini CLI (~/.gemini/settings.json)
7
+ //
8
+ // Agents that merge into shared hooks.json (event → hook-entry array):
9
+ // - Cursor (~/.cursor/hooks.json or <repo>/.cursor/hooks.json)
10
+ // - Windsurf (~/.windsurf/hooks.json or <repo>/.windsurf/hooks.json)
11
+ // - Codex CLI (~/.codex/hooks.json or <repo>/.codex/hooks.json)
12
+ //
13
+ // Kiro writes to a dedicated file (agents/ai-scanner.json) owned entirely by
14
+ // aidr, so no merge is needed.
15
+
16
+ // Our hook commands are identified by command-string regex rather than a
17
+ // "_managedBy" marker field, because those marker fields aren't part of the
18
+ // agent's official schema and could be stripped by future agent upgrades.
19
+
20
+ // Anchored to the path prefix we generate in templates.mjs — either
21
+ // `"$CLAUDE_PROJECT_DIR"`, `$CLAUDE_PROJECT_DIR`, `"$HOME"`, or `$HOME` —
22
+ // so a user's unrelated hook at /home/u/project/.claude/coworker-ai/scan_foo.sh
23
+ // does NOT get clobbered on uninstall.
24
+ export const CLAUDE_OUR_COMMAND_RE =
25
+ /^(?:"\$(?:CLAUDE_PROJECT_DIR|HOME)"|\$(?:CLAUDE_PROJECT_DIR|HOME))\/\.claude\/coworker-ai\/(?:start_scanner|stop_scanner|scan_[a-z_]+|log_web_urls)\.sh(?:\s|$|"|')/;
26
+
27
+ export const GEMINI_OUR_COMMAND_RE =
28
+ /ai-scanner["']?\s+scan\s+hook-gemini\b/;
29
+
30
+ // Matches our Codex hook wrappers whether installed at the repo root
31
+ // (.codex/coworker-ai/pre_shell.sh) or absolute user-scope path
32
+ // (/home/u/.codex/coworker-ai/pre_shell.sh).
33
+ export const CODEX_OUR_COMMAND_RE =
34
+ /\.codex\/coworker-ai\/(?:session_init|pre_shell|post_shell)\.sh(?:\s|$|"|')/;
35
+
36
+ export const CURSOR_OUR_COMMAND_RE =
37
+ /\.cursor\/coworker-ai\/(?:session-init|pre_shell|pre_read|pre_edit|post_shell|post_read|post_write)\.sh(?:\s|$|"|')/;
38
+
39
+ export const WINDSURF_OUR_COMMAND_RE =
40
+ /\.windsurf\/coworker-ai\/(?:pre_command|pre_read|pre_write|post_read|post_write)\.sh(?:\s|$|"|')/;
41
+
42
+ const CLAUDE_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse"];
43
+ const GEMINI_EVENTS = ["BeforeTool", "AfterTool"];
44
+ const CODEX_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse"];
45
+ const CURSOR_EVENTS = ["sessionStart", "preToolUse", "postToolUse"];
46
+ const WINDSURF_EVENTS = [
47
+ "pre_run_command", "pre_read_code", "pre_write_code",
48
+ "post_read_code", "post_write_code",
49
+ ];
50
+
51
+ // ── Build (object form of the template) ─────────────────────────────
52
+
53
+ export function buildClaudeSettings(accessKey, scope = "project") {
54
+ const dir = scope === "user" ? '"$HOME"' : '"$CLAUDE_PROJECT_DIR"';
55
+ return {
56
+ env: {
57
+ AI_SCANNER_BIN: "",
58
+ AI_SCANNER_EXCLUDE_DIRS: "",
59
+ AI_SCANNER_QUARANTINE_DAYS: "3",
60
+ AI_SCANNER_QUARANTINE_MODE: "scan",
61
+ AI_SCANNER_EXFIL_MODE: "on",
62
+ AI_SCANNER_WEBFETCH_YARA_THRESHOLD: "50",
63
+ AI_SCANNER_ACCESS_KEY: accessKey,
64
+ AI_SCANNER_LEVEL: "high",
65
+ },
66
+ hooks: {
67
+ SessionStart: [
68
+ { hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/start_scanner.sh`, timeout: 60 }] },
69
+ ],
70
+ PreToolUse: [
71
+ { matcher: "WebFetch", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_webfetch.sh`, timeout: 20 }] },
72
+ { matcher: "Edit|MultiEdit|Write", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_edit.sh`, timeout: 15 }] },
73
+ { matcher: "Bash", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_bash_input.sh`, timeout: 15 }] },
74
+ { matcher: "Read", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_read_input.sh`, timeout: 15 }] },
75
+ ],
76
+ PostToolUse: [
77
+ { matcher: "WebSearch|WebFetch", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/log_web_urls.sh`, timeout: 10 }] },
78
+ { matcher: "Read", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_read.sh`, timeout: 15 }] },
79
+ { matcher: "Bash", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_bash.sh`, timeout: 15 }] },
80
+ { matcher: "Edit|MultiEdit|Write", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_edit_output.sh`, timeout: 15 }] },
81
+ { matcher: "WebSearch", hooks: [{ type: "command", command: `${dir}/.claude/coworker-ai/scan_websearch.sh`, timeout: 15 }] },
82
+ ],
83
+ },
84
+ };
85
+ }
86
+
87
+ // Codex CLI hooks.json uses the same nested structure as Claude's settings.hooks:
88
+ // event: [{ matcher?, hooks: [{ type: "command", command, ... }] }]
89
+ // Only Bash tool hooks are emitted today (Codex runtime limitation).
90
+ export function buildCodexHooksJson(scope = "project", absHome = "") {
91
+ const p = scope === "user" ? `${absHome}/.codex/coworker-ai` : ".codex/coworker-ai";
92
+ // timeout is in seconds. Codex's documented default is 600s (10 min) — far
93
+ // too long for an inline scan hook. Cap at 15s here so the editor kills
94
+ // a runaway scanner well before the user notices a stalled tool call;
95
+ // the in-script `run_scanner` shell timeout (12s) trips first under
96
+ // normal conditions.
97
+ return {
98
+ hooks: {
99
+ SessionStart: [
100
+ {
101
+ matcher: "startup|resume",
102
+ hooks: [{ type: "command", command: `bash ${p}/session_init.sh`, timeout: 30 }],
103
+ },
104
+ ],
105
+ PreToolUse: [
106
+ {
107
+ matcher: "Bash",
108
+ hooks: [{
109
+ type: "command",
110
+ command: `bash ${p}/pre_shell.sh`,
111
+ timeout: 15,
112
+ statusMessage: "AIDR scan",
113
+ }],
114
+ },
115
+ ],
116
+ PostToolUse: [
117
+ {
118
+ matcher: "Bash",
119
+ hooks: [{
120
+ type: "command",
121
+ command: `bash ${p}/post_shell.sh`,
122
+ timeout: 15,
123
+ statusMessage: "AIDR review",
124
+ }],
125
+ },
126
+ ],
127
+ },
128
+ };
129
+ }
130
+
131
+ // Cursor hooks.json — flat entries, each event has [{ matcher?, command, timeout? }].
132
+ export function buildCursorHooksJson(scope = "project", absHome = "") {
133
+ const p = scope === "user" ? `${absHome}/.cursor/coworker-ai` : ".cursor/coworker-ai";
134
+ return {
135
+ version: 1,
136
+ hooks: {
137
+ sessionStart: [{ command: `${p}/session-init.sh`, timeout: 60 }],
138
+ preToolUse: [
139
+ { matcher: "Shell", command: `${p}/pre_shell.sh`, timeout: 15 },
140
+ { matcher: "Read", command: `${p}/pre_read.sh`, timeout: 15 },
141
+ { matcher: "Write", command: `${p}/pre_edit.sh`, timeout: 15 },
142
+ ],
143
+ postToolUse: [
144
+ { matcher: "Shell", command: `${p}/post_shell.sh`, timeout: 15 },
145
+ { matcher: "Read", command: `${p}/post_read.sh`, timeout: 15 },
146
+ { matcher: "Write", command: `${p}/post_write.sh`, timeout: 15 },
147
+ ],
148
+ },
149
+ };
150
+ }
151
+
152
+ // Windsurf hooks.json — flat entries, one command per event (no matcher).
153
+ export function buildWindsurfHooksJson(scope = "project", absHome = "") {
154
+ const p = scope === "user" ? `${absHome}/.windsurf/coworker-ai` : ".windsurf/coworker-ai";
155
+ return {
156
+ hooks: {
157
+ pre_run_command: [{ command: `${p}/pre_command.sh` }],
158
+ pre_read_code: [{ command: `${p}/pre_read.sh` }],
159
+ pre_write_code: [{ command: `${p}/pre_write.sh` }],
160
+ post_read_code: [{ command: `${p}/post_read.sh` }],
161
+ post_write_code: [{ command: `${p}/post_write.sh` }],
162
+ },
163
+ };
164
+ }
165
+
166
+ export function buildGeminiSettings(accessKey, binary) {
167
+ // timeout is in milliseconds (Gemini convention). Default 60000 (1 min)
168
+ // is too long for an inline hook — cap at 15000 so the editor kills a
169
+ // hung scanner cleanly. The shell-side run_scanner cap fires first under
170
+ // normal conditions.
171
+ return {
172
+ env: {
173
+ AI_SCANNER_ACCESS_KEY: accessKey,
174
+ AI_SCANNER_LEVEL: "high",
175
+ },
176
+ hooks: {
177
+ BeforeTool: [
178
+ { hooks: [{ type: "command", command: `"${binary}" scan hook-gemini --stage pre`, timeout: 15000 }] },
179
+ ],
180
+ AfterTool: [
181
+ { hooks: [{ type: "command", command: `"${binary}" scan hook-gemini --stage post`, timeout: 15000 }] },
182
+ ],
183
+ },
184
+ };
185
+ }
186
+
187
+ // ── Hook identity checks ────────────────────────────────────────────
188
+
189
+ export function isOurClaudeHook(hook) {
190
+ return !!hook
191
+ && hook.type === "command"
192
+ && typeof hook.command === "string"
193
+ && CLAUDE_OUR_COMMAND_RE.test(hook.command);
194
+ }
195
+
196
+ export function isOurGeminiHook(hook) {
197
+ return !!hook
198
+ && hook.type === "command"
199
+ && typeof hook.command === "string"
200
+ && GEMINI_OUR_COMMAND_RE.test(hook.command);
201
+ }
202
+
203
+ // Codex hooks live inside nested .hooks arrays (same shape as Claude).
204
+ export function isOurCodexHook(hook) {
205
+ return !!hook
206
+ && hook.type === "command"
207
+ && typeof hook.command === "string"
208
+ && CODEX_OUR_COMMAND_RE.test(hook.command);
209
+ }
210
+
211
+ // Cursor / Windsurf entries are flat — no inner "hooks" array, the command
212
+ // lives directly on the entry itself.
213
+ export function isOurCursorEntry(entry) {
214
+ return !!entry
215
+ && typeof entry.command === "string"
216
+ && CURSOR_OUR_COMMAND_RE.test(entry.command);
217
+ }
218
+
219
+ export function isOurWindsurfEntry(entry) {
220
+ return !!entry
221
+ && typeof entry.command === "string"
222
+ && WINDSURF_OUR_COMMAND_RE.test(entry.command);
223
+ }
224
+
225
+ // ── Merge ───────────────────────────────────────────────────────────
226
+
227
+ function mergeHooksForEvents(existingHooks, ourHooks, events, isOurHook) {
228
+ const merged = { ...(existingHooks && typeof existingHooks === "object" ? existingHooks : {}) };
229
+ for (const event of events) {
230
+ const cleaned = Array.isArray(merged[event])
231
+ ? merged[event]
232
+ .map((entry) => {
233
+ const filtered = Array.isArray(entry?.hooks)
234
+ ? entry.hooks.filter((h) => !isOurHook(h))
235
+ : [];
236
+ return { ...entry, hooks: filtered };
237
+ })
238
+ .filter((entry) => entry.hooks.length > 0)
239
+ : [];
240
+ merged[event] = [...cleaned, ...(ourHooks[event] || [])];
241
+ }
242
+ return merged;
243
+ }
244
+
245
+ function mergeSettings(existing, ours, events, isOurHook) {
246
+ // structuredClone preserves every user top-level key untouched (permissions,
247
+ // statusLine, model, mcpServers, apiKeyHelper, enableAllProjectMcpServers,
248
+ // …). We only overwrite env / hooks selectively below.
249
+ const merged = existing && typeof existing === "object"
250
+ ? structuredClone(existing)
251
+ : {};
252
+
253
+ merged.env = {
254
+ ...(merged.env && typeof merged.env === "object" ? merged.env : {}),
255
+ ...ours.env,
256
+ };
257
+
258
+ merged.hooks = mergeHooksForEvents(merged.hooks, ours.hooks, events, isOurHook);
259
+
260
+ return merged;
261
+ }
262
+
263
+ export function mergeClaudeSettings(existing, ours) {
264
+ return mergeSettings(existing, ours, CLAUDE_EVENTS, isOurClaudeHook);
265
+ }
266
+
267
+ export function mergeGeminiSettings(existing, ours) {
268
+ return mergeSettings(existing, ours, GEMINI_EVENTS, isOurGeminiHook);
269
+ }
270
+
271
+ // ── Unmerge (for uninstall) ─────────────────────────────────────────
272
+ // Strip ai-scanner's env keys + our hooks from an existing settings object.
273
+ // Returns { result, empty } — empty=true means the settings are now effectively
274
+ // blank so the file can be deleted entirely.
275
+
276
+ function stripAiScannerEnv(env) {
277
+ if (!env || typeof env !== "object") return env;
278
+ const next = { ...env };
279
+ for (const key of Object.keys(next)) {
280
+ if (key.startsWith("AI_SCANNER_")) delete next[key];
281
+ }
282
+ return next;
283
+ }
284
+
285
+ function stripOurHooks(hooks, events, isOurHook) {
286
+ if (!hooks || typeof hooks !== "object") return hooks;
287
+ const next = { ...hooks };
288
+ for (const event of events) {
289
+ if (!Array.isArray(next[event])) continue;
290
+ const cleaned = next[event]
291
+ .map((entry) => ({
292
+ ...entry,
293
+ hooks: Array.isArray(entry?.hooks)
294
+ ? entry.hooks.filter((h) => !isOurHook(h))
295
+ : [],
296
+ }))
297
+ .filter((entry) => entry.hooks.length > 0);
298
+ if (cleaned.length === 0) delete next[event];
299
+ else next[event] = cleaned;
300
+ }
301
+ return next;
302
+ }
303
+
304
+ function unmergeSettings(existing, events, isOurHook) {
305
+ if (!existing || typeof existing !== "object") return { result: null, empty: true };
306
+ const next = structuredClone(existing);
307
+
308
+ if (next.env) {
309
+ next.env = stripAiScannerEnv(next.env);
310
+ if (Object.keys(next.env).length === 0) delete next.env;
311
+ }
312
+
313
+ if (next.hooks) {
314
+ next.hooks = stripOurHooks(next.hooks, events, isOurHook);
315
+ if (Object.keys(next.hooks).length === 0) delete next.hooks;
316
+ }
317
+
318
+ const empty = Object.keys(next).length === 0;
319
+ return { result: empty ? null : next, empty };
320
+ }
321
+
322
+ export function unmergeClaudeSettings(existing) {
323
+ return unmergeSettings(existing, CLAUDE_EVENTS, isOurClaudeHook);
324
+ }
325
+
326
+ export function unmergeGeminiSettings(existing) {
327
+ return unmergeSettings(existing, GEMINI_EVENTS, isOurGeminiHook);
328
+ }
329
+
330
+ // ── Codex / Cursor / Windsurf hooks.json ────────────────────────────
331
+ // Top-level shape: { hooks: { <event>: [entry, ...], ... }, ...otherUserKeys }.
332
+ // Codex event entries wrap a nested .hooks[] array (reuse nested merger).
333
+ // Cursor + Windsurf event entries are flat ({ matcher?, command }).
334
+
335
+ function mergeHooksFlatForEvents(existingHooks, ourHooks, events, isOurEntry) {
336
+ const merged = { ...(existingHooks && typeof existingHooks === "object" ? existingHooks : {}) };
337
+ for (const event of events) {
338
+ const cleaned = Array.isArray(merged[event])
339
+ ? merged[event].filter((entry) => !isOurEntry(entry))
340
+ : [];
341
+ merged[event] = [...cleaned, ...(ourHooks[event] || [])];
342
+ }
343
+ return merged;
344
+ }
345
+
346
+ function mergeHooksTopLevel(existing, ours, events, mergeFn) {
347
+ const merged = existing && typeof existing === "object"
348
+ ? structuredClone(existing)
349
+ : {};
350
+ merged.hooks = mergeFn(merged.hooks, ours.hooks, events);
351
+ // Preserve user top-level keys (version, etc.). Copy non-reserved keys from ours.
352
+ for (const key of Object.keys(ours)) {
353
+ if (key === "hooks") continue;
354
+ if (merged[key] === undefined) merged[key] = ours[key];
355
+ }
356
+ return merged;
357
+ }
358
+
359
+ export function mergeCodexHooksJson(existing, ours) {
360
+ return mergeHooksTopLevel(existing, ours, CODEX_EVENTS,
361
+ (e, o, ev) => mergeHooksForEvents(e, o, ev, isOurCodexHook));
362
+ }
363
+
364
+ export function mergeCursorHooksJson(existing, ours) {
365
+ return mergeHooksTopLevel(existing, ours, CURSOR_EVENTS,
366
+ (e, o, ev) => mergeHooksFlatForEvents(e, o, ev, isOurCursorEntry));
367
+ }
368
+
369
+ export function mergeWindsurfHooksJson(existing, ours) {
370
+ return mergeHooksTopLevel(existing, ours, WINDSURF_EVENTS,
371
+ (e, o, ev) => mergeHooksFlatForEvents(e, o, ev, isOurWindsurfEntry));
372
+ }
373
+
374
+ // ── Unmerge for hooks.json variants ─────────────────────────────────
375
+
376
+ function stripOurFlatHooks(hooks, events, isOurEntry) {
377
+ if (!hooks || typeof hooks !== "object") return hooks;
378
+ const next = { ...hooks };
379
+ for (const event of events) {
380
+ if (!Array.isArray(next[event])) continue;
381
+ const cleaned = next[event].filter((entry) => !isOurEntry(entry));
382
+ if (cleaned.length === 0) delete next[event];
383
+ else next[event] = cleaned;
384
+ }
385
+ return next;
386
+ }
387
+
388
+ function unmergeHooksJson(existing, stripFn) {
389
+ if (!existing || typeof existing !== "object") return { result: null, empty: true };
390
+ const next = structuredClone(existing);
391
+ if (next.hooks) {
392
+ next.hooks = stripFn(next.hooks);
393
+ if (Object.keys(next.hooks).length === 0) delete next.hooks;
394
+ }
395
+ const empty = Object.keys(next).length === 0;
396
+ return { result: empty ? null : next, empty };
397
+ }
398
+
399
+ export function unmergeCodexHooksJson(existing) {
400
+ return unmergeHooksJson(existing,
401
+ (h) => stripOurHooks(h, CODEX_EVENTS, isOurCodexHook));
402
+ }
403
+
404
+ export function unmergeCursorHooksJson(existing) {
405
+ return unmergeHooksJson(existing,
406
+ (h) => stripOurFlatHooks(h, CURSOR_EVENTS, isOurCursorEntry));
407
+ }
408
+
409
+ export function unmergeWindsurfHooksJson(existing) {
410
+ return unmergeHooksJson(existing,
411
+ (h) => stripOurFlatHooks(h, WINDSURF_EVENTS, isOurWindsurfEntry));
412
+ }