@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 ADDED
@@ -0,0 +1,55 @@
1
+ # aidr
2
+
3
+ CLI installer for [ai-scanner](https://github.com/coworker-jp/ai-scanner) hooks across 19+ AI coding agents.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx @coworker-jp/aidr install --agent claude --key ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ aidr install --agent <list|--all> --key <ak_...>
15
+ [--dry-run] [--force] [--binary-only]
16
+ [--no-verify] [--skip-binary]
17
+ aidr uninstall [--agent <list>]
18
+ aidr doctor
19
+ aidr list-agents
20
+ ```
21
+
22
+ Options:
23
+
24
+ - `--agent <list>`: comma-separated names (`claude,cursor`).
25
+ - `--all`: install for every agent detected in `$HOME`.
26
+ - `--key <ak_...>`: access key (format `ak_` + 43 urlsafe chars).
27
+ - `--dry-run`: print planned writes, do not touch disk.
28
+ - `--force`: overwrite existing files.
29
+ - `--binary-only`: fetch only the scanner binary.
30
+ - `--no-verify`: skip the server-side access-key check.
31
+ - `--skip-binary`: skip the scanner binary download.
32
+
33
+ ## Supported agents
34
+
35
+ | name | status |
36
+ |---|---|
37
+ | claude | full |
38
+ | cursor | full |
39
+ | windsurf | full |
40
+ | kiro | full |
41
+ | gemini | full |
42
+ | codex | full |
43
+ | cline, roo, copilot, qwen, trae, amazonq, jetbrains, opencode, aider, amp, crush, antigravity, continue | stub (Phase 2) |
44
+
45
+ ## Developing
46
+
47
+ ```bash
48
+ node bin/aidr.js list-agents
49
+ node bin/aidr.js install --agent claude --key ak_xxx --dry-run --no-verify --skip-binary
50
+ node --test tests/
51
+ ```
52
+
53
+ ## Third-party components
54
+
55
+ This package downloads and installs [Opengrep](https://github.com/opengrep/opengrep) (LGPL-2.1) alongside the ai-scanner binary. Opengrep is invoked by ai-scanner as a separate process; the process boundary is the license boundary. The upstream binary is shipped unmodified. Set `AI_SCANNER_OPENGREP=/path/to/opengrep` to substitute a different LGPL-2.1 build. See `LICENSES/opengrep-LGPL-2.1.txt` in the downloaded scanner distribution for the full license text and pinned release metadata.
package/bin/aidr.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('../src/cli.mjs').then(m => m.run(process.argv)).catch(e => { console.error(e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@coworker-jp/aidr",
3
+ "version": "0.0.1",
4
+ "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "aidr": "./bin/aidr.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "commander": "^12.1.0"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "private": false,
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/coworker-jp/ai-scanner"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test tests/"
28
+ }
29
+ }
@@ -0,0 +1,18 @@
1
+ import path from "node:path";
2
+
3
+ export function makeStub(name, displayName, configRel) {
4
+ return {
5
+ meta: {
6
+ name,
7
+ displayName,
8
+ configDir: (home) => path.join(home, configRel),
9
+ stub: true,
10
+ },
11
+ async install() {
12
+ console.warn(`[${name}] coming soon — full installer planned for Phase 2`);
13
+ },
14
+ async uninstall(installBase, opts = {}) {
15
+ return { removedFiles: [], settings: { action: "none" }, dirsRemoved: [] };
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("aider", "Aider", ".aider");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("amazonq", "Amazon Q", ".aws/amazonq");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("amp", "Amp", ".amp");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("antigravity", "Antigravity", ".antigravity");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,92 @@
1
+ import path from "node:path";
2
+ import fsp from "node:fs/promises";
3
+ import { writeFileSafe, writeJsonMerge, unmergeJsonFile, pathExists, rmdirIfEmpty } from "../fs-utils.mjs";
4
+ import * as T from "../templates.mjs";
5
+ import { buildClaudeSettings, mergeClaudeSettings, unmergeClaudeSettings } from "../merge.mjs";
6
+ import { downloadBase } from "../verify.mjs";
7
+
8
+ const SCRIPT_FILES = [
9
+ "start_scanner.sh", "stop_scanner.sh",
10
+ "scan_bash.sh", "scan_bash_input.sh",
11
+ "scan_edit.sh", "scan_edit_output.sh",
12
+ "scan_read.sh", "scan_read_input.sh",
13
+ "scan_webfetch.sh", "scan_websearch.sh",
14
+ "log_web_urls.sh",
15
+ ];
16
+ const BIN_FILES = ["ai-scanner", "opengrep"];
17
+
18
+ export const meta = {
19
+ name: "claude",
20
+ displayName: "Claude Code",
21
+ agentDir: ".claude",
22
+ configDir: (home) => path.join(home, ".claude"),
23
+ };
24
+
25
+ export async function install(installBase, accessKey, opts = {}) {
26
+ const { dryRun = false, env = "prod", scope = "project" } = opts;
27
+ const base = meta.configDir(installBase);
28
+ const aidrDir = path.join(base, "coworker-ai");
29
+ const dlBase = downloadBase(env);
30
+ const scriptEntries = [
31
+ [path.join(aidrDir, "start_scanner.sh"), T.getStartScannerSh(dlBase, "claude-code"), 0o755],
32
+ [path.join(aidrDir, "stop_scanner.sh"), T.STOP_SCANNER_SH, 0o755],
33
+ [path.join(aidrDir, "scan_bash.sh"), T.SCAN_BASH_SH, 0o755],
34
+ [path.join(aidrDir, "scan_bash_input.sh"), T.SCAN_BASH_INPUT_SH, 0o755],
35
+ [path.join(aidrDir, "scan_edit.sh"), T.SCAN_EDIT_SH, 0o755],
36
+ [path.join(aidrDir, "scan_edit_output.sh"), T.SCAN_EDIT_OUTPUT_SH, 0o755],
37
+ [path.join(aidrDir, "scan_read.sh"), T.SCAN_READ_SH, 0o755],
38
+ [path.join(aidrDir, "scan_read_input.sh"), T.SCAN_READ_INPUT_SH, 0o755],
39
+ [path.join(aidrDir, "scan_webfetch.sh"), T.SCAN_WEBFETCH_SH, 0o755],
40
+ [path.join(aidrDir, "scan_websearch.sh"), T.SCAN_WEBSEARCH_SH, 0o755],
41
+ [path.join(aidrDir, "log_web_urls.sh"), T.LOG_WEB_URLS_SH, 0o755],
42
+ ];
43
+ const settingsPath = path.join(base, "settings.json");
44
+ const [settingsRes, ...scriptResults] = await Promise.all([
45
+ writeJsonMerge(settingsPath, buildClaudeSettings(accessKey, scope), mergeClaudeSettings, { dryRun }),
46
+ ...scriptEntries.map(([p, c, mode]) => writeFileSafe(p, c, { dryRun, force: true, backup: false, mode })),
47
+ ]);
48
+ const scriptBackups = scriptResults.map((r) => r && r.backupPath).filter(Boolean);
49
+ return {
50
+ installed: [settingsPath, ...scriptEntries.map(([p]) => p)],
51
+ backupPath: settingsRes.backupPath,
52
+ backupPaths: [
53
+ ...(settingsRes.backupPath ? [settingsRes.backupPath] : []),
54
+ ...scriptBackups,
55
+ ],
56
+ };
57
+ }
58
+
59
+ // Plan + execute uninstall. With dryRun: plan is computed and returned but no
60
+ // writes happen. Returns { removedFiles, settings, dirsRemoved } so the caller
61
+ // (cli.mjs) can print the plan and confirm with the user.
62
+ export async function uninstall(installBase, opts = {}) {
63
+ const { dryRun = false } = opts;
64
+ const base = meta.configDir(installBase);
65
+ const aidrDir = path.join(base, "coworker-ai");
66
+ const binDir = path.join(base, "bin");
67
+ const settingsPath = path.join(base, "settings.json");
68
+
69
+ const candidateFiles = [
70
+ ...SCRIPT_FILES.map(n => path.join(aidrDir, n)),
71
+ ...BIN_FILES.map(n => path.join(binDir, n)),
72
+ ];
73
+ const removedFiles = [];
74
+ for (const f of candidateFiles) {
75
+ if (await pathExists(f)) removedFiles.push(f);
76
+ }
77
+
78
+ const settings = await unmergeJsonFile(settingsPath, unmergeClaudeSettings, { dryRun });
79
+
80
+ if (dryRun) {
81
+ return { removedFiles, settings, dirsRemoved: [] };
82
+ }
83
+
84
+ await Promise.all(removedFiles.map(f => fsp.rm(f, { force: true })));
85
+ const dirsRemoved = [];
86
+ // rmdirIfEmpty only removes the dir if empty, so user-authored files in
87
+ // .claude/coworker-ai/ keep the dir alive.
88
+ for (const d of [aidrDir, binDir]) {
89
+ if (await rmdirIfEmpty(d)) dirsRemoved.push(d);
90
+ }
91
+ return { removedFiles, settings, dirsRemoved };
92
+ }
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("cline", "Cline", ".cline");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,225 @@
1
+ import path from "node:path";
2
+ import fsp from "node:fs/promises";
3
+ import {
4
+ writeFileSafe,
5
+ writeJsonMerge,
6
+ unmergeJsonFile,
7
+ pathExists,
8
+ rmdirIfEmpty,
9
+ backupPathFor,
10
+ } from "../fs-utils.mjs";
11
+ import * as T from "../templates.mjs";
12
+ import {
13
+ buildCodexHooksJson,
14
+ mergeCodexHooksJson,
15
+ unmergeCodexHooksJson,
16
+ } from "../merge.mjs";
17
+ import { mergeCodexHookFeature, unmergeCodexHookFeature } from "../toml-merge.mjs";
18
+ import { downloadBase } from "../verify.mjs";
19
+
20
+ // Owned scripts under .codex/coworker-ai/. hooks.json and config.toml are NOT
21
+ // listed here because they're merged (not overwritten) — uninstall handles
22
+ // them separately via the unmerge helpers below.
23
+ const SCRIPT_FILES = [
24
+ "env.sh",
25
+ "start_scanner.sh",
26
+ "session_init.sh",
27
+ "pre_shell.sh",
28
+ "post_shell.sh",
29
+ ];
30
+ const BIN_FILES = ["ai-scanner", "opengrep"];
31
+ const ROOT_FILES = ["AGENTS.md"];
32
+
33
+ export const meta = {
34
+ name: "codex",
35
+ displayName: "Codex CLI",
36
+ agentDir: ".codex",
37
+ configDir: (home) => path.join(home, ".codex"),
38
+ };
39
+
40
+ const CODEX_AGENTS_MD = `# AIDR Scanner
41
+
42
+ AIDR security scanner is active for this Codex session. Set the access key
43
+ via the \`AI_SCANNER_ACCESS_KEY\` environment variable. Hooks are registered
44
+ in \`.codex/hooks.json\` and require \`[features] codex_hooks = true\` in
45
+ \`.codex/config.toml\` (this setup writes both for you).
46
+
47
+ Codex currently emits Bash tool events only. Read / Edit / WebFetch / WebSearch
48
+ are not hookable — for those, use Claude Code.
49
+ `;
50
+
51
+ export async function install(installBase, accessKey, opts = {}) {
52
+ const { dryRun = false, env = "prod", scope = "project", home = installBase } = opts;
53
+ const base = meta.configDir(installBase);
54
+ const aidrDir = path.join(base, "coworker-ai");
55
+ const dlBase = downloadBase(env);
56
+
57
+ // 1) Merge hooks.json — preserves user's own hooks if any.
58
+ const hooksRes = await writeJsonMerge(
59
+ path.join(base, "hooks.json"),
60
+ buildCodexHooksJson(scope, home),
61
+ mergeCodexHooksJson,
62
+ { dryRun, mode: 0o644 },
63
+ );
64
+
65
+ // 2) Merge config.toml to enable the feature flag. Backs up pre-merge TOML
66
+ // so a rollback is possible if our merge logic corrupts the file.
67
+ const tomlRes = await mergeCodexConfigToml(path.join(base, "config.toml"), { dryRun });
68
+
69
+ // 3) Write owned scripts + AGENTS.md. AGENTS.md is plain prose owned by us
70
+ // so we keep a backup; the regenerated shell wrappers skip backup.
71
+ const markdownEntry = [path.join(base, "AGENTS.md"), CODEX_AGENTS_MD, 0o644];
72
+ const scriptEntries = [
73
+ [path.join(aidrDir, "env.sh"), T.agentEnvSh(accessKey, meta.agentDir), 0o755],
74
+ [path.join(aidrDir, "start_scanner.sh"), T.getStartScannerSh(dlBase, "codex"), 0o755],
75
+ [path.join(aidrDir, "session_init.sh"), T.CODEX_SESSION_INIT_SH, 0o755],
76
+ [path.join(aidrDir, "pre_shell.sh"), T.CODEX_PRE_SHELL_SH, 0o755],
77
+ [path.join(aidrDir, "post_shell.sh"), T.CODEX_POST_SHELL_SH, 0o755],
78
+ ];
79
+ const entries = [markdownEntry, ...scriptEntries];
80
+ const [markdownRes, ...shScriptResults] = await Promise.all([
81
+ writeFileSafe(markdownEntry[0], markdownEntry[1], { dryRun, force: true, mode: markdownEntry[2] }),
82
+ ...scriptEntries.map(([p, c, mode]) => writeFileSafe(p, c, { dryRun, force: true, backup: false, mode })),
83
+ ]);
84
+ const scriptResults = [markdownRes, ...shScriptResults];
85
+
86
+ const backupPaths = [
87
+ ...(hooksRes && hooksRes.backupPath ? [hooksRes.backupPath] : []),
88
+ ...(tomlRes && tomlRes.backupPath ? [tomlRes.backupPath] : []),
89
+ ...scriptResults.map((r) => r && r.backupPath).filter(Boolean),
90
+ ];
91
+
92
+ return {
93
+ installed: [
94
+ path.join(base, "hooks.json"),
95
+ path.join(base, "config.toml"),
96
+ ...entries.map(([p]) => p),
97
+ ],
98
+ backupPaths,
99
+ };
100
+ }
101
+
102
+ export async function uninstall(installBase, opts = {}) {
103
+ const { dryRun = false } = opts;
104
+ const base = meta.configDir(installBase);
105
+ const aidrDir = path.join(base, "coworker-ai");
106
+ const binDir = path.join(base, "bin");
107
+
108
+ const candidateFiles = [
109
+ ...ROOT_FILES.map((n) => path.join(base, n)),
110
+ ...SCRIPT_FILES.map((n) => path.join(aidrDir, n)),
111
+ ...BIN_FILES.map((n) => path.join(binDir, n)),
112
+ ];
113
+ const removedFiles = [];
114
+ for (const f of candidateFiles) {
115
+ // eslint-disable-next-line no-await-in-loop
116
+ if (await pathExists(f)) removedFiles.push(f);
117
+ }
118
+
119
+ // Unmerge hooks.json + config.toml instead of unconditional rm.
120
+ const hooksAction = await unmergeJsonFile(
121
+ path.join(base, "hooks.json"),
122
+ unmergeCodexHooksJson,
123
+ { dryRun },
124
+ );
125
+ const tomlAction = await unmergeCodexConfigToml(path.join(base, "config.toml"), { dryRun });
126
+
127
+ if (dryRun) {
128
+ return {
129
+ removedFiles,
130
+ settings: { action: "unmerge", hooksJson: hooksAction, configToml: tomlAction },
131
+ dirsRemoved: [],
132
+ };
133
+ }
134
+
135
+ await Promise.all(removedFiles.map((f) => fsp.rm(f, { force: true })));
136
+ const dirsRemoved = [];
137
+ for (const d of [aidrDir, binDir]) {
138
+ // eslint-disable-next-line no-await-in-loop
139
+ if (await rmdirIfEmpty(d)) dirsRemoved.push(d);
140
+ }
141
+
142
+ return {
143
+ removedFiles,
144
+ settings: { action: "unmerge", hooksJson: hooksAction, configToml: tomlAction },
145
+ dirsRemoved,
146
+ };
147
+ }
148
+
149
+ // ── TOML helpers (Codex-specific) ─────────────────────────────────────
150
+ async function mergeCodexConfigToml(target, { dryRun, stderr = process.stderr }) {
151
+ // Refuse to follow a user-intentional symlink (same policy as writeJsonMerge).
152
+ let stat = null;
153
+ try { stat = await fsp.lstat(target); }
154
+ catch (e) { if (e.code !== "ENOENT") throw e; }
155
+ if (stat && stat.isSymbolicLink()) {
156
+ throw new Error(
157
+ `${target} is a symlink. Refusing to overwrite. ` +
158
+ `Remove the symlink and re-run, or edit the target file manually.`
159
+ );
160
+ }
161
+
162
+ let existing = "";
163
+ if (stat) {
164
+ existing = await fsp.readFile(target, "utf8");
165
+ }
166
+
167
+ const merged = mergeCodexHookFeature(existing);
168
+ if (merged === existing) {
169
+ return { action: "noop", path: target, backupPath: null };
170
+ }
171
+ if (dryRun) {
172
+ if (stat) {
173
+ stderr.write(
174
+ `WARNING: ${target} already exists. (dry-run: would back up)\n` +
175
+ `Merging [features] codex_hooks; your other settings are preserved.\n`
176
+ );
177
+ }
178
+ process.stdout.write(
179
+ `\n--- [dry-run] ${target} (merged TOML, ${merged.length} bytes) ---\n${merged}\n--- end ---\n`,
180
+ );
181
+ return { action: "would-merge", path: target, backupPath: null };
182
+ }
183
+
184
+ // Back up the pre-merge TOML so a rollback after a bad merge is always possible.
185
+ let backupPath = null;
186
+ if (stat) {
187
+ backupPath = backupPathFor(target);
188
+ await fsp.copyFile(target, backupPath);
189
+ stderr.write(
190
+ `WARNING: ${target} already exists. Backed up to ${backupPath}.\n` +
191
+ `Merging [features] codex_hooks; your other settings are preserved.\n`
192
+ );
193
+ }
194
+
195
+ await fsp.mkdir(path.dirname(target), { recursive: true });
196
+ const tmp = `${target}.aidr-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
197
+ await fsp.writeFile(tmp, merged, { mode: 0o644 });
198
+ await fsp.chmod(tmp, 0o644);
199
+ await fsp.rename(tmp, target);
200
+ return { action: "merge", path: target, backupPath };
201
+ }
202
+
203
+ async function unmergeCodexConfigToml(target, { dryRun }) {
204
+ let existing = "";
205
+ try { existing = await fsp.readFile(target, "utf8"); }
206
+ catch (e) {
207
+ if (e.code === "ENOENT") return { action: "none", path: target };
208
+ throw e;
209
+ }
210
+ const { text, empty } = unmergeCodexHookFeature(existing);
211
+ if (text === existing) return { action: "noop", path: target };
212
+
213
+ if (dryRun) {
214
+ return { action: empty ? "delete" : "modify", path: target, preview: empty ? null : text };
215
+ }
216
+ if (empty) {
217
+ await fsp.rm(target, { force: true });
218
+ return { action: "delete", path: target };
219
+ }
220
+ const tmp = `${target}.aidr-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
221
+ await fsp.writeFile(tmp, text, { mode: 0o644 });
222
+ await fsp.chmod(tmp, 0o644);
223
+ await fsp.rename(tmp, target);
224
+ return { action: "modify", path: target };
225
+ }
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("continue", "Continue", ".continue");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("copilot", "GitHub Copilot", ".github/copilot");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,5 @@
1
+ import { makeStub } from "./_stub.mjs";
2
+ const s = makeStub("crush", "Crush", ".crush");
3
+ export const meta = s.meta;
4
+ export const install = s.install;
5
+ export const uninstall = s.uninstall;
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import fsp from "node:fs/promises";
3
+ import {
4
+ writeFileSafe,
5
+ writeJsonMerge,
6
+ unmergeJsonFile,
7
+ pathExists,
8
+ rmdirIfEmpty,
9
+ } from "../fs-utils.mjs";
10
+ import * as T from "../templates.mjs";
11
+ import {
12
+ buildCursorHooksJson,
13
+ mergeCursorHooksJson,
14
+ unmergeCursorHooksJson,
15
+ } from "../merge.mjs";
16
+ import { downloadBase } from "../verify.mjs";
17
+
18
+ // hooks.json is merged (not listed here); only scripts/bin files are
19
+ // unconditionally owned and can be rm'd on uninstall.
20
+ const SCRIPT_FILES = [
21
+ "env.sh",
22
+ "start_scanner.sh",
23
+ "session-init.sh",
24
+ "pre_shell.sh",
25
+ "pre_read.sh",
26
+ "pre_edit.sh",
27
+ "post_shell.sh",
28
+ "post_read.sh",
29
+ "post_write.sh",
30
+ ];
31
+ const BIN_FILES = ["ai-scanner", "opengrep"];
32
+
33
+ export const meta = {
34
+ name: "cursor",
35
+ displayName: "Cursor",
36
+ agentDir: ".cursor",
37
+ configDir: (home) => path.join(home, ".cursor"),
38
+ };
39
+
40
+ export async function install(installBase, accessKey, opts = {}) {
41
+ const { dryRun = false, env = "prod", scope = "project", home = installBase } = opts;
42
+ const base = meta.configDir(installBase);
43
+ const aidrDir = path.join(base, "coworker-ai");
44
+ const dlBase = downloadBase(env);
45
+
46
+ const hooksRes = await writeJsonMerge(
47
+ path.join(base, "hooks.json"),
48
+ buildCursorHooksJson(scope, home),
49
+ mergeCursorHooksJson,
50
+ { dryRun, mode: 0o644 },
51
+ );
52
+
53
+ const entries = [
54
+ [path.join(aidrDir, "env.sh"), T.agentEnvSh(accessKey, meta.agentDir), 0o755],
55
+ [path.join(aidrDir, "start_scanner.sh"), T.getStartScannerSh(dlBase, "cursor"), 0o755],
56
+ [path.join(aidrDir, "session-init.sh"), T.getCursorSessionInitSh(accessKey), 0o755],
57
+ [path.join(aidrDir, "pre_shell.sh"), T.CURSOR_PRE_SHELL_SH, 0o755],
58
+ [path.join(aidrDir, "pre_read.sh"), T.CURSOR_PRE_READ_SH, 0o755],
59
+ [path.join(aidrDir, "pre_edit.sh"), T.CURSOR_PRE_EDIT_SH, 0o755],
60
+ [path.join(aidrDir, "post_shell.sh"), T.CURSOR_POST_SHELL_SH, 0o755],
61
+ [path.join(aidrDir, "post_read.sh"), T.CURSOR_POST_READ_SH, 0o755],
62
+ [path.join(aidrDir, "post_write.sh"), T.CURSOR_POST_WRITE_SH, 0o755],
63
+ ];
64
+ const scriptResults = await Promise.all(
65
+ entries.map(([p, c, mode]) => writeFileSafe(p, c, { dryRun, force: true, backup: false, mode })),
66
+ );
67
+ const backupPaths = [
68
+ ...(hooksRes && hooksRes.backupPath ? [hooksRes.backupPath] : []),
69
+ ...scriptResults.map((r) => r && r.backupPath).filter(Boolean),
70
+ ];
71
+ return {
72
+ installed: [path.join(base, "hooks.json"), ...entries.map(([p]) => p)],
73
+ backupPaths,
74
+ };
75
+ }
76
+
77
+ export async function uninstall(installBase, opts = {}) {
78
+ const { dryRun = false } = opts;
79
+ const base = meta.configDir(installBase);
80
+ const aidrDir = path.join(base, "coworker-ai");
81
+ const binDir = path.join(base, "bin");
82
+
83
+ const candidateFiles = [
84
+ ...SCRIPT_FILES.map((n) => path.join(aidrDir, n)),
85
+ ...BIN_FILES.map((n) => path.join(binDir, n)),
86
+ ];
87
+ const checks = await Promise.all(
88
+ candidateFiles.map(async (p) => ((await pathExists(p)) ? p : null)),
89
+ );
90
+ const removedFiles = checks.filter((p) => p !== null);
91
+
92
+ const hooksAction = await unmergeJsonFile(
93
+ path.join(base, "hooks.json"),
94
+ unmergeCursorHooksJson,
95
+ { dryRun },
96
+ );
97
+
98
+ if (dryRun) {
99
+ return { removedFiles, settings: { action: "unmerge", hooksJson: hooksAction }, dirsRemoved: [] };
100
+ }
101
+
102
+ await Promise.all(removedFiles.map((f) => fsp.rm(f, { force: true })));
103
+ const dirChecks = await Promise.all(
104
+ [aidrDir, binDir].map(async (d) => ((await rmdirIfEmpty(d)) ? d : null)),
105
+ );
106
+ const dirsRemoved = dirChecks.filter((d) => d !== null);
107
+
108
+ return { removedFiles, settings: { action: "unmerge", hooksJson: hooksAction }, dirsRemoved };
109
+ }
@@ -0,0 +1,66 @@
1
+ import path from "node:path";
2
+ import fsp from "node:fs/promises";
3
+ import { writeFileSafe, writeJsonMerge, unmergeJsonFile, pathExists, rmdirIfEmpty } from "../fs-utils.mjs";
4
+ import * as T from "../templates.mjs";
5
+ import { buildGeminiSettings, mergeGeminiSettings, unmergeGeminiSettings } from "../merge.mjs";
6
+
7
+ const HOOK_FILES = ["aidr-pre.sh"];
8
+ const BIN_FILES = ["ai-scanner", "opengrep"];
9
+
10
+ export const meta = {
11
+ name: "gemini",
12
+ displayName: "Gemini CLI",
13
+ agentDir: ".gemini",
14
+ configDir: (home) => path.join(home, ".gemini"),
15
+ };
16
+
17
+ export async function install(home, accessKey, opts = {}) {
18
+ const { dryRun = false } = opts;
19
+ const base = meta.configDir(home);
20
+ const hooks = path.join(base, "hooks");
21
+ const binary = path.join(home, meta.agentDir, "bin", "ai-scanner");
22
+ const settingsPath = path.join(base, "settings.json");
23
+ const preHookPath = path.join(hooks, "aidr-pre.sh");
24
+ const [settingsRes, hookRes] = await Promise.all([
25
+ writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun }),
26
+ writeFileSafe(preHookPath, T.getGeminiPreHookSh(binary), { dryRun, force: true, mode: 0o755 }),
27
+ ]);
28
+ return {
29
+ installed: [settingsPath, preHookPath],
30
+ backupPath: settingsRes.backupPath,
31
+ backupPaths: [
32
+ ...(settingsRes.backupPath ? [settingsRes.backupPath] : []),
33
+ ...(hookRes.backupPath ? [hookRes.backupPath] : []),
34
+ ],
35
+ };
36
+ }
37
+
38
+ export async function uninstall(installBase, opts = {}) {
39
+ const { dryRun = false } = opts;
40
+ const base = meta.configDir(installBase);
41
+ const hooksDir = path.join(base, "hooks");
42
+ const binDir = path.join(base, "bin");
43
+ const settingsPath = path.join(base, "settings.json");
44
+
45
+ const candidateFiles = [
46
+ ...HOOK_FILES.map(n => path.join(hooksDir, n)),
47
+ ...BIN_FILES.map(n => path.join(binDir, n)),
48
+ ];
49
+ const removedFiles = [];
50
+ for (const f of candidateFiles) {
51
+ if (await pathExists(f)) removedFiles.push(f);
52
+ }
53
+
54
+ const settings = await unmergeJsonFile(settingsPath, unmergeGeminiSettings, { dryRun });
55
+
56
+ if (dryRun) {
57
+ return { removedFiles, settings, dirsRemoved: [] };
58
+ }
59
+
60
+ await Promise.all(removedFiles.map(f => fsp.rm(f, { force: true })));
61
+ const dirsRemoved = [];
62
+ for (const d of [hooksDir, binDir]) {
63
+ if (await rmdirIfEmpty(d)) dirsRemoved.push(d);
64
+ }
65
+ return { removedFiles, settings, dirsRemoved };
66
+ }