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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +78 -0
- package/README.md +152 -45
- package/package.json +7 -2
- package/src/cli/doctor.js +108 -11
- package/src/cli/install.js +163 -37
- package/src/cli/main.js +6 -1
- package/src/cli/uninstall.js +204 -0
- package/src/core/gate.js +91 -11
- package/src/core/load-config.js +18 -9
- package/src/core/process.js +0 -19
- package/src/core/transcript.js +12 -45
- package/src/core/verdict.js +25 -2
- package/src/hosts/claude-code.js +221 -19
- package/src/hosts/index.js +2 -1
- package/src/reviewers/_shared.js +146 -0
- package/src/reviewers/codex.js +52 -98
- package/src/reviewers/custom.js +52 -95
- package/src/reviewers/index.js +5 -3
- package/src/reviewers/opencode.js +92 -150
package/src/cli/install.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
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 =
|
|
343
|
+
const home = resolveHomeDir(env);
|
|
344
|
+
|
|
345
|
+
const { hosts, reviewerMap, dryRun, projectConfigPath, userScope } = parseArgs(argv);
|
|
277
346
|
|
|
278
|
-
|
|
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
|
|
295
|
-
|
|
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
|
|
452
|
-
//
|
|
453
|
-
|
|
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
|
|
461
|
-
|
|
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:
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
617
|
-
* are meaningful for a project config (not DEFAULT_CONFIG boilerplate),
|
|
618
|
-
* the computed hosts/reviewers from the install run.
|
|
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
|
+
}
|