adversarial-review-gate 2.0.3 → 2.1.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.
@@ -18,18 +18,21 @@
18
18
  // In dry-run mode: print every planned write path and its note, then exit 0
19
19
  // WITHOUT writing any files.
20
20
 
21
- import { readFile, writeFile, mkdir } from "node:fs/promises";
21
+ import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
22
22
  import { existsSync } from "node:fs";
23
23
  import path from "node:path";
24
- import os from "node:os";
25
24
  import { fileURLToPath } from "node:url";
26
25
 
27
26
  import { mergeConfig, applyPolicyFloor, DEFAULT_CONFIG } from "../core/config.js";
28
27
  import { HOSTS } from "../hosts/index.js";
29
- import { plannedClaudeCodeWrites } from "../hosts/claude-code.js";
28
+ import {
29
+ plannedClaudeCodeWrites,
30
+ claudeCodeSettingsPath,
31
+ } from "../hosts/claude-code.js";
30
32
  import { wrapperInstructions } from "../hosts/wrapper.js";
31
33
  import { createReviewer } from "../reviewers/index.js";
32
34
  import { resolveExecutable } from "../core/process.js";
35
+ import { resolveHomeDir } from "../core/load-config.js";
33
36
 
34
37
  // Path constants (relative to cwd / home).
35
38
  const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
@@ -66,18 +69,23 @@ const OPENCODE_REVIEWER_DEFAULTS = Object.freeze({
66
69
  * Parse the install command's argv array into structured options.
67
70
  *
68
71
  * @param {string[]} argv
69
- * @returns {{ hosts: string[], reviewerMap: Map<string,string>, dryRun: boolean, projectConfigPath: string|null }}
72
+ * @returns {{ hosts: string[], reviewerMap: Map<string,string>, dryRun: boolean, projectConfigPath: string|null, userScope: boolean }}
70
73
  */
71
74
  function parseArgs(argv) {
72
75
  const hosts = [];
73
76
  const reviewerMap = new Map();
74
77
  let dryRun = false;
75
78
  let projectConfigPath = null;
79
+ let userScope = false;
76
80
 
77
81
  for (let i = 0; i < argv.length; i++) {
78
82
  const arg = argv[i];
79
83
  if (arg === "--dry-run") {
80
84
  dryRun = true;
85
+ } else if (arg === "--user" || arg === "--global") {
86
+ // Machine-wide install: write to <home>/.adversarial-review/config.json
87
+ // and merge hooks into <home>/.claude/settings.json instead of cwd.
88
+ userScope = true;
81
89
  } else if (arg === "--hosts" && argv[i + 1]) {
82
90
  // Accept either `--hosts a,b` or `--hosts a --hosts b`.
83
91
  argv[i + 1].split(",").forEach((h) => hosts.push(h.trim()));
@@ -97,7 +105,7 @@ function parseArgs(argv) {
97
105
  }
98
106
  }
99
107
 
100
- return { hosts, reviewerMap, dryRun, projectConfigPath };
108
+ return { hosts, reviewerMap, dryRun, projectConfigPath, userScope };
101
109
  }
102
110
 
103
111
  // ---------------------------------------------------------------------------
@@ -117,20 +125,73 @@ async function readJsonTolerant(filePath) {
117
125
  }
118
126
 
119
127
  /**
120
- * Resolve the user's home directory, honoring an injected env so tests can
121
- * redirect home-based writes (registry, opencode agent) without touching the
122
- * real home dir. This MUST match load-config.js's homeDir() resolution so the
123
- * installer writes to the SAME user-level base the gate later reads. Priority:
124
- * 1. ADVERSARIAL_REVIEW_HOME dedicated override for the user-level base;
125
- * 2. HOME / USERPROFILE — standard OS home env vars;
126
- * 3. os.homedir() the real home dir.
128
+ * Read an existing Claude Code settings.json for merging. Distinguishes
129
+ * three cases so the caller can decide whether to back up the original:
130
+ * - missing/unreadable: { settings: {}, corrupt: false } nothing to back up.
131
+ * - present but invalid JSON: { settings: {}, corrupt: true } — back up first.
132
+ * - present + valid object: { settings: <obj>, corrupt: false }.
133
+ *
134
+ * @param {string} filePath
135
+ * @returns {Promise<{ settings: object, corrupt: boolean }>}
136
+ */
137
+ async function readSettingsForMerge(filePath) {
138
+ let raw;
139
+ try {
140
+ raw = await readFile(filePath, "utf8");
141
+ } catch {
142
+ return { settings: {}, corrupt: false }; // Missing: nothing to back up.
143
+ }
144
+ try {
145
+ const parsed = JSON.parse(raw);
146
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
147
+ return { settings: parsed, corrupt: false };
148
+ }
149
+ // Valid JSON but not an object (e.g. an array or scalar) — treat as corrupt
150
+ // so we preserve the original via backup rather than silently dropping it.
151
+ return { settings: {}, corrupt: true };
152
+ } catch {
153
+ return { settings: {}, corrupt: true };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Back up a corrupt settings.json to settings.json.bak before it is overwritten
159
+ * by the merged (from-scratch) result. Best-effort; never throws.
160
+ *
161
+ * @param {string} filePath
162
+ * @param {object} io - { stdout }
163
+ */
164
+ async function backupCorruptSettings(filePath, io) {
165
+ const bakPath = `${filePath}.bak`;
166
+ try {
167
+ await copyFile(filePath, bakPath);
168
+ io.stdout.write(
169
+ ` NOTE: ${filePath} was not valid JSON; backed it up to ${bakPath} before merging.\n`
170
+ );
171
+ } catch {
172
+ // If the backup itself fails we still proceed — but note it.
173
+ io.stdout.write(
174
+ ` WARNING: ${filePath} was not valid JSON and could not be backed up; merging from scratch.\n`
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Normalize a directory path into a STABLE install-registry key so the same
181
+ * project never produces duplicate registry entries. We path.resolve() to an
182
+ * absolute, normalized form and, on win32, lowercase a leading drive letter
183
+ * (`D:\` and `d:\` denote the same path). Other path casing is left intact
184
+ * because POSIX filesystems are case-sensitive.
185
+ *
186
+ * @param {string} dir
187
+ * @returns {string}
127
188
  */
128
- function homeDir(env) {
129
- if (env) {
130
- const fromEnv = env.ADVERSARIAL_REVIEW_HOME || env.HOME || env.USERPROFILE;
131
- if (fromEnv) return fromEnv;
189
+ function normalizeRegistryKey(dir) {
190
+ let resolved = path.resolve(dir);
191
+ if (process.platform === "win32" && /^[a-zA-Z]:/.test(resolved)) {
192
+ resolved = resolved[0].toLowerCase() + resolved.slice(1);
132
193
  }
133
- return os.homedir();
194
+ return resolved;
134
195
  }
135
196
 
136
197
  /**
@@ -249,14 +310,20 @@ async function checkReviewerAvailability(reviewerId, config, env) {
249
310
  * Writes atomically by writing to a temp file and renaming (best-effort on
250
311
  * Windows where rename semantics differ; we do a two-step write+rename).
251
312
  *
313
+ * The file mode is parameterized PER FILE: team-shared/committed files
314
+ * (project config, .claude/settings.json) are written 0o644 so collaborators
315
+ * can read them, while user-level secrets-adjacent files (user config, registry,
316
+ * state) stay 0o600. Defaults to 0o600 (the safe default).
317
+ *
252
318
  * @param {string} filePath
253
319
  * @param {string} content
320
+ * @param {number} [mode=0o600]
254
321
  */
255
- async function atomicWrite(filePath, content) {
322
+ async function atomicWrite(filePath, content, mode = 0o600) {
256
323
  const dir = path.dirname(filePath);
257
324
  await mkdir(dir, { recursive: true });
258
325
  const tmp = `${filePath}.tmp${Date.now()}`;
259
- await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
326
+ await writeFile(tmp, content, { encoding: "utf8", mode });
260
327
  // node:fs rename is atomic on POSIX; on Windows it will overwrite on Node 14+.
261
328
  const { rename } = await import("node:fs/promises");
262
329
  await rename(tmp, filePath);
@@ -273,9 +340,15 @@ async function atomicWrite(filePath, content) {
273
340
  export async function installCommand(argv, io) {
274
341
  const cwd = io.cwd || process.cwd();
275
342
  const env = io.env || process.env;
276
- const home = homeDir(env);
343
+ const home = resolveHomeDir(env);
344
+
345
+ const { hosts, reviewerMap, dryRun, projectConfigPath, userScope } = parseArgs(argv);
277
346
 
278
- const { hosts, reviewerMap, dryRun, projectConfigPath } = parseArgs(argv);
347
+ // Scope base: the directory whose .adversarial-review/config.json and
348
+ // .claude/settings.json we write. User scope targets <home>; default targets
349
+ // the project <cwd>. The install registry + opencode agent always live under
350
+ // <home> regardless of scope.
351
+ const scopeBase = userScope ? home : cwd;
279
352
 
280
353
  // --- Require at least one host ---
281
354
  if (!hosts.length) {
@@ -291,8 +364,12 @@ export async function installCommand(argv, io) {
291
364
  const userPolicyPath = path.join(home, USER_POLICY_REL);
292
365
  const userPolicyFloor = await readJsonTolerant(userPolicyPath);
293
366
 
294
- // --- Load existing project config (from explicit path or default location) ---
295
- const projectConfigPath2 = projectConfigPath || path.join(cwd, PROJECT_CONFIG_REL);
367
+ // --- Load existing config to layer onto (scope-aware) ---
368
+ // For project scope this is <cwd>/.adversarial-review/config.json; for user
369
+ // scope it is <home>/.adversarial-review/config.json. An explicit
370
+ // --project-config path always wins.
371
+ const projectConfigPath2 =
372
+ projectConfigPath || path.join(scopeBase, PROJECT_CONFIG_REL);
296
373
  const existingProjectConfig = await readJsonTolerant(projectConfigPath2);
297
374
 
298
375
  // --- Read legacy config and merge ---
@@ -448,41 +525,57 @@ export async function installCommand(argv, io) {
448
525
  // Merge with DEFAULT_CONFIG and enforce policy floor.
449
526
  const resolvedConfig = mergeConfig(newProjectConfig, userPolicyFloor);
450
527
 
451
- // Serialize the project-level config (strip runtime defaults that came from
452
- // DEFAULT_CONFIG; keep only what was explicitly set or migrated).
453
- const configToWrite = buildProjectConfigToWrite(newProjectConfig, resolvedConfig);
528
+ // Serialize the config. For USER scope we write the full machine-wide config
529
+ // (always include policy.mode and the reviewers block) so the user-level
530
+ // defaults are explicit; for project scope we keep only what was explicitly
531
+ // set or migrated (no DEFAULT_CONFIG boilerplate).
532
+ const configToWrite = buildProjectConfigToWrite(
533
+ newProjectConfig,
534
+ resolvedConfig,
535
+ userScope
536
+ );
454
537
  const configJson = JSON.stringify(configToWrite, null, 2);
455
538
 
456
539
  // --- Collect planned writes ---
457
540
 
458
541
  const plannedWrites = [];
459
542
 
460
- // 1. Project config.
461
- const projectConfigOutPath = path.join(cwd, PROJECT_CONFIG_REL);
543
+ // 1. Config (scope-aware). Project scope -> <cwd>/.adversarial-review/...;
544
+ // user scope -> <home>/.adversarial-review/... — a team-shared/committed
545
+ // file in either case, so mode 0o644.
546
+ const projectConfigOutPath = path.join(scopeBase, PROJECT_CONFIG_REL);
462
547
  plannedWrites.push({
463
548
  path: projectConfigOutPath,
464
549
  content: configJson,
465
- note: "Project config (.adversarial-review/config.json)",
550
+ note: userScope
551
+ ? "User config (machine-wide defaults: ~/.adversarial-review/config.json)"
552
+ : "Project config (.adversarial-review/config.json)",
466
553
  type: "project-config",
554
+ mode: 0o644,
467
555
  });
468
556
 
469
- // 2. User-level install registry.
557
+ // 2. User-level install registry. Keyed by a NORMALIZED path so the same
558
+ // project never produces duplicate entries. User-level + secrets-adjacent:
559
+ // mode 0o600.
470
560
  const installRegistryPath = path.join(home, USER_INSTALL_REL);
471
561
  const existingRegistry = await readJsonTolerant(installRegistryPath);
472
562
  const registryEntry = {
473
563
  installedAt: new Date().toISOString(),
564
+ scope: userScope ? "user" : "project",
474
565
  hosts,
475
566
  reviewers: Object.fromEntries(reviewerMap),
476
567
  };
568
+ const registryKey = normalizeRegistryKey(userScope ? home : cwd);
477
569
  const updatedRegistry = {
478
570
  ...existingRegistry,
479
- [cwd]: registryEntry,
571
+ [registryKey]: registryEntry,
480
572
  };
481
573
  plannedWrites.push({
482
574
  path: installRegistryPath,
483
575
  content: JSON.stringify(updatedRegistry, null, 2),
484
576
  note: "User-level install registry (~/.adversarial-review/install.json)",
485
577
  type: "install-registry",
578
+ mode: 0o600,
486
579
  });
487
580
 
488
581
  // FIX 3: pick the hook/wrapper command once. Prefer the direct bin name when
@@ -497,13 +590,33 @@ export async function installCommand(argv, io) {
497
590
  if (hostInfo.enforcement === "native-enforced") {
498
591
  // Native host: compute planned file writes.
499
592
  if (host === "claude-code") {
500
- const nativeWrites = plannedClaudeCodeWrites({ cwd, binPath: hookBin.command });
593
+ // CRITICAL: read the existing settings.json so we DEEP-MERGE rather than
594
+ // clobber. If the file is corrupt, back it up to settings.json.bak before
595
+ // we overwrite it with the merged result (which starts from {}). The
596
+ // settings.json is a team-shared/committed file -> mode 0o644.
597
+ const settingsPath = claudeCodeSettingsPath(scopeBase);
598
+ const { settings: existingSettings, corrupt } =
599
+ await readSettingsForMerge(settingsPath);
600
+ if (corrupt && !dryRun) {
601
+ await backupCorruptSettings(settingsPath, io);
602
+ } else if (corrupt && dryRun) {
603
+ io.stdout.write(
604
+ ` NOTE: ${settingsPath} is not valid JSON; a real install would back ` +
605
+ `it up to settings.json.bak and start fresh.\n`
606
+ );
607
+ }
608
+ const nativeWrites = plannedClaudeCodeWrites({
609
+ baseDir: scopeBase,
610
+ binPath: hookBin.command,
611
+ existingSettings,
612
+ });
501
613
  for (const w of nativeWrites) {
502
614
  plannedWrites.push({
503
615
  path: w.path,
504
616
  content: w.content,
505
617
  note: w.note,
506
618
  type: "native-hook",
619
+ mode: 0o644,
507
620
  });
508
621
  }
509
622
  }
@@ -540,6 +653,8 @@ export async function installCommand(argv, io) {
540
653
  ? "opencode read-only agent (adversarial-reviewer.md) — already present, will be kept"
541
654
  : "opencode read-only agent (adversarial-reviewer.md) — mode:primary, read-only",
542
655
  type: "opencode-agent",
656
+ // User-level shared agent: a normal-readable file -> 0o644.
657
+ mode: 0o644,
543
658
  // Idempotency marker: when true the real-mode writer skips this entry.
544
659
  skipExisting: agentAlreadyPresent,
545
660
  });
@@ -581,7 +696,7 @@ export async function installCommand(argv, io) {
581
696
  continue;
582
697
  }
583
698
  io.stdout.write(`Writing ${w.path} ...\n`);
584
- await atomicWrite(w.path, w.content);
699
+ await atomicWrite(w.path, w.content, w.mode);
585
700
  io.stdout.write(` OK: ${w.note}\n`);
586
701
  }
587
702
 
@@ -613,16 +728,19 @@ export async function installCommand(argv, io) {
613
728
  // ---------------------------------------------------------------------------
614
729
 
615
730
  /**
616
- * Build the project config object to write. We include only the keys that
617
- * are meaningful for a project config (not DEFAULT_CONFIG boilerplate), plus
618
- * the computed hosts/reviewers from the install run. We always run through
731
+ * Build the config object to write. For PROJECT scope we include only the keys
732
+ * that are meaningful for a project config (not DEFAULT_CONFIG boilerplate),
733
+ * plus the computed hosts/reviewers from the install run. For USER scope
734
+ * (fullMachineConfig=true) we always emit policy.mode and the reviewers block so
735
+ * the machine-wide config is explicit and self-describing. We always run through
619
736
  * applyPolicyFloor to ensure we never loosen the user floor.
620
737
  *
621
738
  * @param {object} newProjectConfig - merged project + legacy + install config
622
739
  * @param {object} resolvedConfig - fully resolved config (post applyPolicyFloor)
740
+ * @param {boolean} [fullMachineConfig=false] - emit the full machine-wide config
623
741
  * @returns {object}
624
742
  */
625
- function buildProjectConfigToWrite(newProjectConfig, resolvedConfig) {
743
+ function buildProjectConfigToWrite(newProjectConfig, resolvedConfig, fullMachineConfig = false) {
626
744
  // Start from the project-level config (not DEFAULT_CONFIG) so we don't
627
745
  // flood the project file with defaults.
628
746
  const out = {
@@ -638,5 +756,13 @@ function buildProjectConfigToWrite(newProjectConfig, resolvedConfig) {
638
756
  if (newProjectConfig.reviewers) out.reviewers = resolvedConfig.reviewers;
639
757
  if (newProjectConfig.sensitivity) out.sensitivity = resolvedConfig.sensitivity;
640
758
 
759
+ // USER scope: the machine-wide config must be self-describing. Always emit
760
+ // policy.mode and the reviewers block even when no explicit override was given.
761
+ if (fullMachineConfig) {
762
+ if (!out.policy) out.policy = {};
763
+ if (out.policy.mode === undefined) out.policy.mode = resolvedConfig.policy.mode;
764
+ if (!out.reviewers) out.reviewers = resolvedConfig.reviewers || {};
765
+ }
766
+
641
767
  return out;
642
768
  }
package/src/cli/main.js CHANGED
@@ -1,4 +1,4 @@
1
- const COMMANDS = new Set(["install", "check", "hook", "run", "doctor", "help"]);
1
+ const COMMANDS = new Set(["install", "uninstall", "check", "hook", "run", "doctor", "help"]);
2
2
 
3
3
  export async function main(argv, io) {
4
4
  const [cmd = "help", ...rest] = argv;
@@ -28,6 +28,10 @@ export async function main(argv, io) {
28
28
  const { runCommand } = await import("./run.js");
29
29
  return runCommand(rest, io);
30
30
  }
31
+ if (cmd === "uninstall") {
32
+ const { uninstallCommand } = await import("./uninstall.js");
33
+ return uninstallCommand(rest, io);
34
+ }
31
35
  const { installCommand } = await import("./install.js");
32
36
  return installCommand(rest, io);
33
37
  }
@@ -38,6 +42,7 @@ export function helpText() {
38
42
  "",
39
43
  "Commands:",
40
44
  " install Install host integrations and project config",
45
+ " uninstall Remove our host hooks and registry entry (keeps shared agent)",
41
46
  " check Run the review gate on the current workspace",
42
47
  " hook Run as a native host lifecycle hook",
43
48
  " run Wrap a host tool command and gate after it exits",
@@ -0,0 +1,204 @@
1
+ // `adversarial-review uninstall` — remove our hook entries and registry entry.
2
+ //
3
+ // Flags:
4
+ // --user / --global operate on user scope (<home>/.claude/settings.json
5
+ // and <home>/.adversarial-review/config.json) instead
6
+ // of the project (cwd) scope.
7
+ // --host <claude-code> restrict to a single native host (default: all
8
+ // native hosts; currently only claude-code).
9
+ // --remove-config also delete <scope>/.adversarial-review/config.json.
10
+ // --dry-run print what WOULD change; touch nothing.
11
+ //
12
+ // Behavior (tolerant + idempotent):
13
+ // - Remove ONLY our adversarial-review hook entries (dedupe key) from the
14
+ // relevant .claude/settings.json; preserve every other key/hook.
15
+ // - Optionally remove the scope's .adversarial-review/config.json.
16
+ // - Remove this scope's entry from the install registry
17
+ // (<home>/.adversarial-review/install.json).
18
+ // - PRINT what it KEPT (it never deletes the shared opencode agent by default).
19
+
20
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
21
+ import { existsSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import {
25
+ removeClaudeCodeHooks,
26
+ detectClaudeCodeHooks,
27
+ claudeCodeSettingsPath,
28
+ } from "../hosts/claude-code.js";
29
+ import { resolveHomeDir } from "../core/load-config.js";
30
+
31
+ const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
32
+ const USER_INSTALL_REL = path.join(".adversarial-review", "install.json");
33
+ const OPENCODE_AGENT_REL = path.join(
34
+ ".config",
35
+ "opencode",
36
+ "agent",
37
+ "adversarial-reviewer.md"
38
+ );
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Tolerantly read+parse a JSON object file; returns {} on any error. */
45
+ async function readJsonTolerant(filePath) {
46
+ try {
47
+ const raw = await readFile(filePath, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
50
+ return {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ /** Normalize a registry key (matches install.js): absolute + lowercased win32 drive. */
57
+ function normalizeRegistryKey(dir) {
58
+ let resolved = path.resolve(dir);
59
+ if (process.platform === "win32" && /^[a-zA-Z]:/.test(resolved)) {
60
+ resolved = resolved[0].toLowerCase() + resolved.slice(1);
61
+ }
62
+ return resolved;
63
+ }
64
+
65
+ /** Atomically write content (mode 0o644 — settings/config are team-shared). */
66
+ async function atomicWrite(filePath, content, mode = 0o644) {
67
+ const dir = path.dirname(filePath);
68
+ await mkdir(dir, { recursive: true });
69
+ const tmp = `${filePath}.tmp${Date.now()}`;
70
+ await writeFile(tmp, content, { encoding: "utf8", mode });
71
+ const { rename } = await import("node:fs/promises");
72
+ await rename(tmp, filePath);
73
+ }
74
+
75
+ /**
76
+ * Parse uninstall argv.
77
+ *
78
+ * @param {string[]} argv
79
+ * @returns {{ userScope: boolean, host: string|null, removeConfig: boolean, dryRun: boolean }}
80
+ */
81
+ function parseArgs(argv) {
82
+ let userScope = false;
83
+ let host = null;
84
+ let removeConfig = false;
85
+ let dryRun = false;
86
+
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const arg = argv[i];
89
+ if (arg === "--user" || arg === "--global") {
90
+ userScope = true;
91
+ } else if (arg === "--dry-run") {
92
+ dryRun = true;
93
+ } else if (arg === "--remove-config") {
94
+ removeConfig = true;
95
+ } else if (arg === "--host" && argv[i + 1]) {
96
+ host = argv[i + 1].trim();
97
+ i++;
98
+ } else if (arg.startsWith("--host=")) {
99
+ host = arg.slice("--host=".length).trim();
100
+ }
101
+ }
102
+
103
+ return { userScope, host, removeConfig, dryRun };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Main uninstall command
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * @param {string[]} argv
112
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
113
+ */
114
+ export async function uninstallCommand(argv, io) {
115
+ const cwd = io.cwd || process.cwd();
116
+ const env = io.env || process.env;
117
+ const home = resolveHomeDir(env);
118
+
119
+ const { userScope, host, removeConfig, dryRun } = parseArgs(argv);
120
+
121
+ // Unknown --host value is a usage error (only claude-code is a native host).
122
+ if (host && host !== "claude-code") {
123
+ io.stderr.write(
124
+ `adversarial-review uninstall: unsupported --host "${host}". ` +
125
+ `Only "claude-code" has native hooks to remove.\n`
126
+ );
127
+ process.exitCode = 2;
128
+ return;
129
+ }
130
+
131
+ const scopeBase = userScope ? home : cwd;
132
+ const w = (s) => io.stdout.write(s);
133
+
134
+ w(`adversarial-review uninstall${dryRun ? " --dry-run" : ""}: ${userScope ? "user" : "project"} scope\n`);
135
+
136
+ // --- 1. Remove our hooks from .claude/settings.json (claude-code) ---
137
+ const settingsPath = claudeCodeSettingsPath(scopeBase);
138
+ if (existsSync(settingsPath)) {
139
+ const existing = await readJsonTolerant(settingsPath);
140
+ const before = detectClaudeCodeHooks(existing);
141
+ if (before.sessionStart || before.stop) {
142
+ const cleaned = removeClaudeCodeHooks(existing);
143
+ if (dryRun) {
144
+ w(` WOULD remove adversarial-review hooks from ${settingsPath}\n`);
145
+ } else {
146
+ await atomicWrite(settingsPath, JSON.stringify(cleaned, null, 2), 0o644);
147
+ w(` Removed adversarial-review hooks from ${settingsPath}\n`);
148
+ }
149
+ } else {
150
+ w(` No adversarial-review hooks present in ${settingsPath} (nothing to remove).\n`);
151
+ }
152
+ } else {
153
+ w(` No .claude/settings.json at ${settingsPath} (nothing to remove).\n`);
154
+ }
155
+
156
+ // --- 2. Optionally remove the scope's config.json ---
157
+ const configPath = path.join(scopeBase, PROJECT_CONFIG_REL);
158
+ if (removeConfig) {
159
+ if (existsSync(configPath)) {
160
+ if (dryRun) {
161
+ w(` WOULD remove ${configPath}\n`);
162
+ } else {
163
+ await rm(configPath, { force: true });
164
+ w(` Removed ${configPath}\n`);
165
+ }
166
+ } else {
167
+ w(` No config at ${configPath} (nothing to remove).\n`);
168
+ }
169
+ } else if (existsSync(configPath)) {
170
+ w(` KEPT ${configPath} (pass --remove-config to delete it).\n`);
171
+ }
172
+
173
+ // --- 3. Remove this scope's entry from the install registry ---
174
+ const installRegistryPath = path.join(home, USER_INSTALL_REL);
175
+ const registryKey = normalizeRegistryKey(userScope ? home : cwd);
176
+ if (existsSync(installRegistryPath)) {
177
+ const registry = await readJsonTolerant(installRegistryPath);
178
+ if (Object.prototype.hasOwnProperty.call(registry, registryKey)) {
179
+ if (dryRun) {
180
+ w(` WOULD remove registry entry "${registryKey}" from ${installRegistryPath}\n`);
181
+ } else {
182
+ const { [registryKey]: _removed, ...rest } = registry;
183
+ await atomicWrite(installRegistryPath, JSON.stringify(rest, null, 2), 0o600);
184
+ w(` Removed registry entry "${registryKey}" from ${installRegistryPath}\n`);
185
+ }
186
+ } else {
187
+ w(` No registry entry for "${registryKey}" (nothing to remove).\n`);
188
+ }
189
+ } else {
190
+ w(` No install registry at ${installRegistryPath} (nothing to remove).\n`);
191
+ }
192
+
193
+ // --- 4. Report what we KEPT (the shared opencode agent is never deleted) ---
194
+ const opencodeAgentPath = path.join(home, OPENCODE_AGENT_REL);
195
+ if (existsSync(opencodeAgentPath)) {
196
+ w(
197
+ ` KEPT shared opencode read-only agent at ${opencodeAgentPath} ` +
198
+ `(shared machine-wide; delete it manually if no longer needed).\n`
199
+ );
200
+ }
201
+
202
+ w(`\nadversarial-review uninstall: complete.\n`);
203
+ process.exitCode = 0;
204
+ }