@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.
- package/README.md +55 -0
- package/bin/aidr.js +2 -0
- package/package.json +29 -0
- package/src/agents/_stub.mjs +18 -0
- package/src/agents/aider.mjs +5 -0
- package/src/agents/amazonq.mjs +5 -0
- package/src/agents/amp.mjs +5 -0
- package/src/agents/antigravity.mjs +5 -0
- package/src/agents/claude.mjs +92 -0
- package/src/agents/cline.mjs +5 -0
- package/src/agents/codex.mjs +225 -0
- package/src/agents/continue.mjs +5 -0
- package/src/agents/copilot.mjs +5 -0
- package/src/agents/crush.mjs +5 -0
- package/src/agents/cursor.mjs +109 -0
- package/src/agents/gemini.mjs +66 -0
- package/src/agents/index.mjs +36 -0
- package/src/agents/jetbrains.mjs +5 -0
- package/src/agents/kiro.mjs +79 -0
- package/src/agents/opencode.mjs +5 -0
- package/src/agents/qwen.mjs +5 -0
- package/src/agents/roo.mjs +5 -0
- package/src/agents/standalone.mjs +43 -0
- package/src/agents/trae.mjs +5 -0
- package/src/agents/windsurf.mjs +105 -0
- package/src/binary-fetcher.mjs +157 -0
- package/src/browser-extension.mjs +83 -0
- package/src/cli.mjs +494 -0
- package/src/detect.mjs +39 -0
- package/src/fs-utils.mjs +247 -0
- package/src/merge.mjs +412 -0
- package/src/scheduled.mjs +219 -0
- package/src/templates.mjs +759 -0
- package/src/toml-merge.mjs +167 -0
- package/src/verify.mjs +51 -0
package/src/fs-utils.mjs
ADDED
|
@@ -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
|
+
}
|