@coworker-jp/aidr 0.1.10 → 0.1.18

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/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { AGENT_ORDER, AGENT_REGISTRY, loadAgent } from "./agents/index.mjs";
12
12
  import { verifyKey, maskKey } from "./verify.mjs";
13
13
  import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
14
14
  import { detectInstalled } from "./detect.mjs";
15
- import { installBrowserExtension } from "./browser-extension.mjs";
15
+ import { evaluateInstallGate, formatLocalPcGuidance } from "./env-gate.mjs";
16
16
  import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
17
17
 
18
18
  // Package identity — read at module load from the bundled package.json.
@@ -84,47 +84,37 @@ async function cmdInstall(opts) {
84
84
  });
85
85
  }
86
86
 
87
+ // Install-environment gate (issue #243): an interactive terminal with zero
88
+ // cloud/CI/remote indicators is almost certainly someone's own PC — the
89
+ // machine that should run coworker-sentinel, not the ai-scanner hooks.
90
+ // Stop and point at the sentinel installer unless --local overrides.
91
+ // Non-interactive runs (CI, cloud sandboxes, agent-executed shells) pass
92
+ // silently: they are ai-scanner's intended environments.
93
+ if (!opts.local) {
94
+ const gate = evaluateInstallGate();
95
+ if (gate.block) {
96
+ console.error(formatLocalPcGuidance({
97
+ sentinelPlatform: gate.sentinelPlatform,
98
+ env: opts.env || DEFAULT_ENV,
99
+ }));
100
+ process.exit(1);
101
+ }
102
+ }
103
+
87
104
  const home = resolveCallerHome();
88
105
  const scope = opts.scope || "user";
89
106
  const base = scope === "user" ? home : process.cwd();
90
107
  validateKey(opts.key);
91
108
 
92
- let verifyInfo = null;
93
109
  if (opts.verify !== false) {
94
110
  try {
95
- verifyInfo = await verifyKey(opts.key, opts.env || DEFAULT_ENV);
111
+ await verifyKey(opts.key, opts.env || DEFAULT_ENV);
96
112
  } catch (e) {
97
113
  console.error(`verify failed: ${e.message}`);
98
114
  process.exit(1);
99
115
  }
100
116
  }
101
117
 
102
- // --with-sentinel requires pro or trial plan
103
- if (opts.withSentinel && verifyInfo && !verifyInfo.edr_enabled) {
104
- console.error("Error: --with-sentinel requires a Pro or Trial plan.");
105
- console.error(`Current plan: ${verifyInfo.plan}`);
106
- process.exit(1);
107
- }
108
-
109
- // --with-sentinel requires root (same as --scheduled)
110
- if (opts.withSentinel && !opts.dryRun
111
- && process.getuid && process.getuid() !== 0
112
- && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
113
- const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
114
- if (interactive) {
115
- console.error("--with-sentinel requires root. Re-running via sudo (you may be prompted for your password)...");
116
- const result = spawnSync(
117
- "sudo",
118
- ["-E", process.execPath, ...process.argv.slice(1)],
119
- { stdio: "inherit" }
120
- );
121
- process.exit(result.status ?? 1);
122
- }
123
- console.error("Error: --with-sentinel requires root privileges.");
124
- console.error(` sudo -E npx @coworker-jp/aidr install --with-sentinel --key <ak_xxx>`);
125
- process.exit(2);
126
- }
127
-
128
118
  let agents;
129
119
  if (opts.all) {
130
120
  agents = detectInstalled(home);
@@ -146,53 +136,6 @@ async function cmdInstall(opts) {
146
136
  }
147
137
  }
148
138
 
149
- // Standalone has no hook scripts and no settings.json — scheduled
150
- // endpoint-info collection is the whole reason to pick it, so turn
151
- // --scheduled on by default when standalone is selected. --binary-only
152
- // still short-circuits later.
153
- if (agents.includes("standalone")) {
154
- opts.scheduled = true;
155
- }
156
-
157
- // --scheduled installs a system-wide cron / systemd / launchd entry that
158
- // runs `ai-scanner scan endpoint-info` periodically as root, against an
159
- // agent-independent binary at /opt/coworker/aidr/bin/ai-scanner. Auto-add
160
- // the standalone install so the binary fetch loop drops a copy there
161
- // even when the user only listed AI agents (claude / cursor / ...) on
162
- // the command line.
163
- if (opts.scheduled && !agents.includes("standalone")) {
164
- agents.push("standalone");
165
- }
166
-
167
- const intervalHours = parseInt(opts.interval ?? "4", 10);
168
- if (opts.scheduled && (isNaN(intervalHours) || intervalHours < 1)) {
169
- console.error("--interval must be an integer >= 1");
170
- process.exit(1);
171
- }
172
-
173
- // --scheduled の system path 書き込みは root 必須。
174
- // - TTY あり (対話 shell): `sudo -E` で自分自身を re-exec、password prompt が出る
175
- // - TTY なし (CI / pipe): sudo は対話的に password を聞けないので fail-fast 案内
176
- // smoke / unit test では `AI_SCANNER_FAKE_ROOT=1` を設定してこのチェックを skip 可能。
177
- if (opts.scheduled && !opts.dryRun
178
- && process.getuid && process.getuid() !== 0
179
- && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
180
- const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
181
- if (interactive) {
182
- console.error("--scheduled requires root. Re-running via sudo (you may be prompted for your password)...");
183
- const result = spawnSync(
184
- "sudo",
185
- ["-E", process.execPath, ...process.argv.slice(1)],
186
- { stdio: "inherit" }
187
- );
188
- process.exit(result.status ?? 1);
189
- }
190
- console.error("Error: --scheduled requires root privileges (writes /opt/coworker/aidr, /etc/systemd/system, /etc/aidr).");
191
- console.error("Re-run with sudo (non-interactive shells cannot prompt for password):");
192
- console.error(` sudo -E npx @coworker-jp/aidr install --scheduled --key <ak_xxx>`);
193
- process.exit(2);
194
- }
195
-
196
139
  // Confirmation for existing shared settings.json (claude / gemini).
197
140
  // --yes bypasses; --dry-run doesn't write so skip; otherwise prompt on TTY,
198
141
  // error if non-interactive (safer than silently merging in CI).
@@ -222,16 +165,9 @@ async function cmdInstall(opts) {
222
165
  }
223
166
 
224
167
  if (!opts.skipBinary && !opts.dryRun) {
225
- const { SYSTEM_BIN_DIR } = await import("./scheduled.mjs");
226
168
  const agentBinDirs = [];
227
169
  for (const name of agents) {
228
170
  const mod = await loadAgent(name);
229
- // --scheduled 時の standalone は system path (/opt/coworker/aidr/bin)
230
- // に置く。それ以外は従来通り per-user の ~/.<agent>/bin。
231
- if (name === "standalone" && opts.scheduled) {
232
- agentBinDirs.push(SYSTEM_BIN_DIR);
233
- continue;
234
- }
235
171
  const agentDir = mod.meta.agentDir;
236
172
  if (!agentDir) continue;
237
173
  agentBinDirs.push(path.join(home, agentDir, "bin"));
@@ -263,10 +199,27 @@ async function cmdInstall(opts) {
263
199
  const src = path.join(primaryDir, file);
264
200
  const dest = path.join(restDir, file);
265
201
  await fs.mkdir(restDir, { recursive: true });
266
- await fs.copyFile(src, dest);
267
- await fs.chmod(dest, 0o755);
268
- if (process.platform === "darwin") {
269
- try { await execFileP("xattr", ["-d", "com.apple.quarantine", dest]); } catch {}
202
+ // Temp + atomic rename, NEVER copyFile onto the live path — same
203
+ // rule (and same reason) as binary-fetcher.mjs::fetchAsset. On a
204
+ // re-install this path already holds a binary the user's agent has
205
+ // exec'd, and on macOS rewriting an exec'd file in place makes the
206
+ // kernel mark THAT VNODE's code signature invalid: every later exec
207
+ // of the inode is SIGKILLed until reboot, even though the bytes on
208
+ // disk are valid and re-running the installer cannot repair it
209
+ // (the inode never changes). See issue #171 / ADR-0005 §52.
210
+ // The temp file is in the destination dir, so the rename is on one
211
+ // filesystem = atomic, and mode/xattr are set BEFORE it goes live.
212
+ const tmp = `${dest}.tmp-${process.pid}`;
213
+ try {
214
+ await fs.copyFile(src, tmp);
215
+ await fs.chmod(tmp, 0o755);
216
+ if (process.platform === "darwin") {
217
+ try { await execFileP("xattr", ["-d", "com.apple.quarantine", tmp]); } catch {}
218
+ }
219
+ await fs.rename(tmp, dest);
220
+ } catch (e) {
221
+ await fs.rm(tmp, { force: true }).catch(() => {});
222
+ throw e;
270
223
  }
271
224
  console.log(`binary: ${dest} (copied)`);
272
225
  })
@@ -306,54 +259,29 @@ async function cmdInstall(opts) {
306
259
  .filter((r) => r.name !== "standalone")
307
260
  .map((r) => r.displayName);
308
261
 
309
- if (opts.scheduled && !opts.dryRun) {
310
- const { installScheduled } = await import("./scheduled.mjs");
311
- // Schedule entries always point at /opt/coworker/aidr/bin/ai-scanner
312
- // (system-wide, root-owned). The path is hard-coded inside scheduled.mjs.
313
- try {
314
- await installScheduled({
315
- accessKey: opts.key,
316
- dryRun: opts.dryRun,
317
- interval: intervalHours,
318
- });
319
- } catch (e) {
320
- console.error(`scheduled install failed: ${e.message}`);
321
- process.exit(1);
322
- }
323
- }
324
-
325
- if (opts.withSentinel) {
326
- const { installSentinel } = await import("./sentinel.mjs");
327
- try {
328
- const platform = (await import("./binary-fetcher.mjs")).detectPlatform();
329
- const presignedUrl = verifyInfo?.sentinel_download_urls?.[platform] || undefined;
330
- // Windows additionally needs the sibling WinDivert.dll presigned URL.
331
- const dllPresignedUrl =
332
- verifyInfo?.sentinel_download_urls?.[`${platform}-windivert-dll`] || undefined;
333
- await installSentinel({
334
- accessKey: opts.key,
335
- dryRun: opts.dryRun,
336
- skipBinary: opts.skipBinary,
337
- presignedUrl,
338
- dllPresignedUrl,
339
- env: opts.env || DEFAULT_ENV,
340
- noActivate: process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1",
341
- });
342
- } catch (e) {
343
- console.error(`sentinel install failed: ${e.message}`);
344
- process.exit(1);
345
- }
346
- }
347
-
348
- if (opts.browserExtension && !opts.dryRun) {
349
- const firstAgentName = agents[0];
350
- const firstAgent = await loadAgent(firstAgentName);
351
- const binaryPath = path.join(home, firstAgent.meta.agentDir, "bin", "ai-scanner");
352
- try {
353
- await installBrowserExtension(home, binaryPath);
354
- } catch (e) {
355
- console.error(`browser extension install warning: ${e.message}`);
262
+ // Legacy `--scheduled` artifacts (cron.d / launchd plist / wrapper /
263
+ // scheduled.log) are retired — endpoint-info collection now runs from the
264
+ // coworker-sentinel daemon's internal timer. Remove leftovers from older
265
+ // installs when we have root; otherwise just point the user at sudo.
266
+ // /etc/aidr/aidr.env is intentionally preserved (sentinel reads it).
267
+ try {
268
+ const { isScheduledInstalled, cleanupLegacyScheduled } = await import("./scheduled.mjs");
269
+ if (await isScheduledInstalled()) {
270
+ const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
271
+ || (process.getuid && process.getuid() === 0);
272
+ if (opts.dryRun) {
273
+ console.error("[dry-run] legacy scheduled endpoint-info entries detected; a real install would remove them (root required).");
274
+ } else if (isRoot) {
275
+ await cleanupLegacyScheduled();
276
+ console.error("Removed legacy scheduled endpoint-info entries (cron/launchd) — collection is now handled by the coworker-sentinel daemon.");
277
+ } else {
278
+ console.error("Note: legacy scheduled endpoint-info entries detected (cron/launchd). They are obsolete —");
279
+ console.error("collection is now handled by the coworker-sentinel daemon. Re-run with sudo to remove them:");
280
+ console.error(" sudo -E npx @coworker-jp/aidr install ...");
281
+ }
356
282
  }
283
+ } catch (e) {
284
+ console.error(`legacy scheduled cleanup warning: ${e.message}`);
357
285
  }
358
286
 
359
287
  // Auto-sudo re-exec wrote per-agent files as root into the original user's
@@ -496,18 +424,20 @@ async function cmdUninstall(opts) {
496
424
  }
497
425
  }));
498
426
 
499
- // Schedule entries live in system paths (/etc, /opt, /Library/LaunchDaemons)
500
- // and need root to remove. Mirror cmdInstall's auto-sudo: TTY なら sudo に
501
- // re-exec、non-TTY は guidance を出して per-user 部分だけ撤去で完了。
427
+ // Legacy `--scheduled` entries live in system paths (/etc/cron.d,
428
+ // /Library/LaunchDaemons) and need root to remove. Mirror cmdInstall's
429
+ // auto-sudo: TTY なら sudo に re-exec、non-TTY は guidance を出して
430
+ // per-user 部分だけ撤去で完了。/etc/aidr/aidr.env は sentinel が読むので
431
+ // 残す (cleanupLegacyScheduled は触らない)。
502
432
  try {
503
- const { uninstallScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
433
+ const { cleanupLegacyScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
504
434
  if (await isScheduledInstalled()) {
505
435
  const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
506
436
  || (process.getuid && process.getuid() === 0);
507
437
  if (!isRoot) {
508
438
  const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
509
439
  if (interactive) {
510
- console.error("Scheduled entry detected. Re-running via sudo to remove (you may be prompted for your password)...");
440
+ console.error("Legacy scheduled entry detected. Re-running via sudo to remove (you may be prompted for your password)...");
511
441
  const result = spawnSync(
512
442
  "sudo",
513
443
  ["-E", process.execPath, ...process.argv.slice(1)],
@@ -515,19 +445,23 @@ async function cmdUninstall(opts) {
515
445
  );
516
446
  process.exit(result.status ?? 0);
517
447
  }
518
- console.error("Note: scheduled entry detected but uninstall needs root. Re-run:");
448
+ console.error("Note: legacy scheduled entry detected but uninstall needs root. Re-run:");
519
449
  console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
520
450
  } else {
521
- await uninstallScheduled({ dryRun: opts.dryRun });
451
+ await cleanupLegacyScheduled({ dryRun: opts.dryRun });
522
452
  }
523
453
  }
524
454
  } catch (e) {
525
- console.error(`scheduled uninstall warning: ${e.message}`);
455
+ console.error(`scheduled cleanup warning: ${e.message}`);
526
456
  }
527
457
 
528
- // Sentinel daemon (LaunchDaemon / systemd service) — same auto-sudo
529
- // pattern. Independent try-block so a scheduled-uninstall hiccup doesn't
530
- // skip sentinel cleanup (and vice-versa).
458
+ // LEGACY npx-installed sentinel daemon (Linux systemd unit under
459
+ // /etc/systemd/system + /opt binaries) — same auto-sudo pattern.
460
+ // isSentinelInstalled matches only that legacy layout: deb installs
461
+ // (unit under /lib/systemd/system), the Windows setup.exe, and the macOS
462
+ // DMG are owned by their own uninstallers and never touched here.
463
+ // Independent try-block so a scheduled-uninstall hiccup doesn't skip
464
+ // sentinel cleanup (and vice-versa).
531
465
  try {
532
466
  const { uninstallSentinel, isSentinelInstalled } = await import("./sentinel.mjs");
533
467
  if (await isSentinelInstalled()) {
@@ -536,7 +470,7 @@ async function cmdUninstall(opts) {
536
470
  if (!isRoot) {
537
471
  const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
538
472
  if (interactive) {
539
- console.error("Sentinel daemon detected. Re-running via sudo to remove (you may be prompted for your password)...");
473
+ console.error("Legacy npx-installed sentinel daemon detected. Re-running via sudo to remove (you may be prompted for your password)...");
540
474
  const result = spawnSync(
541
475
  "sudo",
542
476
  ["-E", process.execPath, ...process.argv.slice(1)],
@@ -544,7 +478,7 @@ async function cmdUninstall(opts) {
544
478
  );
545
479
  process.exit(result.status ?? 0);
546
480
  }
547
- console.error("Note: sentinel daemon detected but uninstall needs root. Re-run:");
481
+ console.error("Note: legacy npx-installed sentinel daemon detected but uninstall needs root. Re-run:");
548
482
  console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
549
483
  } else {
550
484
  await uninstallSentinel({ dryRun: opts.dryRun });
@@ -588,7 +522,101 @@ async function cmdListAgents() {
588
522
  }
589
523
  }
590
524
 
525
+ // `--scheduled` / `--interval` were retired when endpoint-info collection
526
+ // moved into the coworker-sentinel daemon (internal 6h timer), and
527
+ // `--with-sentinel` was retired when sentinel distribution moved to the
528
+ // OS-native installers (setup.exe / DMG / deb). Commander no longer knows
529
+ // the flags, so scan argv ourselves to fail with a migration message
530
+ // instead of a generic "unknown option" error.
531
+ const SENTINEL_OS_INSTALLERS_HINT = `coworker-sentinel is installed with the OS-native installer (get it from the user portal's Download page):
532
+ Windows: coworker-sentinel-setup.exe
533
+ macOS: CoworkerSentinel.dmg (drag to Applications, launch, enter your access key)
534
+ Linux: sudo apt install ./coworker-sentinel.deb && sudo coworker-sentinel activate --key <ak_xxx>`;
535
+
536
+ const REMOVED_SCHEDULED_FLAGS_MESSAGE = `Error: --scheduled / --interval are no longer supported.
537
+ Endpoint-info collection is now scheduled by the coworker-sentinel daemon (every 6 hours).
538
+ ${SENTINEL_OS_INSTALLERS_HINT}
539
+ Toggle collection from the macOS menu-bar app, or edit /etc/aidr/sentinel-settings.json
540
+ (Windows: %ProgramData%\\coworker\\sentinel-settings.json).`;
541
+
542
+ const REMOVED_WITH_SENTINEL_MESSAGE = `Error: --with-sentinel is no longer supported.
543
+ ${SENTINEL_OS_INSTALLERS_HINT}
544
+ aidr now installs agent hooks only:
545
+ npx @coworker-jp/aidr install --agent <list> --key <ak_xxx>`;
546
+
547
+ // Red Team は hook 方式のエージェント統合とは別物 — hook を撒くのではなく、
548
+ // ネットワーク脆弱性スキャナを MCP サーバーとして 1 つ登録するだけ。そのため
549
+ // install/uninstall の --agent 複数指定・--all の枠組みには乗せず、独立した
550
+ // サブコマンドにしている。実装は red-team.mjs に分離 (scheduled/sentinel と
551
+ // 同じ遅延 import パターン)。
552
+ async function cmdRedTeamInstall(opts) {
553
+ const agent = opts.agent || "claude";
554
+ if (agent !== "claude") {
555
+ console.error(`red-team install: unsupported agent '${agent}' (only 'claude' is supported)`);
556
+ process.exit(1);
557
+ }
558
+
559
+ const home = resolveCallerHome();
560
+ const scope = opts.scope || "user";
561
+ validateKey(opts.key);
562
+
563
+ const env = opts.env || DEFAULT_ENV;
564
+ if (opts.verify !== false && !opts.dryRun) {
565
+ try {
566
+ await verifyKey(opts.key, env);
567
+ } catch (e) {
568
+ console.error(`verify failed: ${e.message}`);
569
+ process.exit(1);
570
+ }
571
+ }
572
+
573
+ const { installRedTeam } = await import("./red-team.mjs");
574
+ try {
575
+ const res = await installRedTeam({
576
+ accessKey: opts.key,
577
+ env,
578
+ scope,
579
+ home: scope === "user" ? home : process.cwd(),
580
+ dryRun: opts.dryRun,
581
+ });
582
+ if (res.installed) {
583
+ console.log("");
584
+ console.log("Next: restart Claude Code so the MCP server is picked up.");
585
+ }
586
+ } catch (e) {
587
+ // 403 red_team_requires_10_seats は「Pro/Trial かつ契約シート 10 以上」の
588
+ // エンタイトルメント不足。サーバー側が唯一の判断者なので、ここでは
589
+ // メッセージをそのまま見せる。
590
+ console.error(`red-team install failed: ${e.message}`);
591
+ process.exit(1);
592
+ }
593
+ }
594
+
595
+ async function cmdRedTeamUninstall(opts) {
596
+ const home = resolveCallerHome();
597
+ const scope = opts.scope || "user";
598
+ const { uninstallRedTeam } = await import("./red-team.mjs");
599
+ await uninstallRedTeam({
600
+ scope,
601
+ home: scope === "user" ? home : process.cwd(),
602
+ dryRun: opts.dryRun,
603
+ });
604
+ }
605
+
606
+ function rejectRemovedFlags(argv) {
607
+ const args = argv.slice(2);
608
+ if (args.some((a) => a === "--scheduled" || a === "--interval" || a.startsWith("--interval="))) {
609
+ console.error(REMOVED_SCHEDULED_FLAGS_MESSAGE);
610
+ process.exit(1);
611
+ }
612
+ if (args.includes("--with-sentinel")) {
613
+ console.error(REMOVED_WITH_SENTINEL_MESSAGE);
614
+ process.exit(1);
615
+ }
616
+ }
617
+
591
618
  export async function run(argv) {
619
+ rejectRemovedFlags(argv);
592
620
  const program = new Command();
593
621
  program
594
622
  .name("aidr")
@@ -608,12 +636,9 @@ export async function run(argv) {
608
636
  .option("--no-verify", "skip /verify call")
609
637
  .option("--skip-binary", "skip binary download", false)
610
638
  .option("--scope <project|user>", "installation scope: user ($HOME) or project (cwd)", "user")
639
+ .option("--local", "install on this local PC anyway (ai-scanner targets cloud/CI agent environments; a personal PC should run coworker-sentinel)", false)
611
640
  .option("-y, --yes", "proceed without confirmation when existing settings.json is detected", false)
612
641
  .option("--uninstall", "remove all ai-scanner hooks/scripts/binaries for the selected agents (see 'uninstall' command)", false)
613
- .option("--scheduled", "enable scheduled endpoint-info collection (creates a cron/systemd/launchd entry; without this flag endpoint-info is never collected)", false)
614
- .option("--interval <hours>", "scheduled scan interval in hours (min 1, default 4; requires --scheduled)", "4")
615
- .option("--with-sentinel", "install coworker-sentinel host-level EDR daemon (Pro/Trial plan; requires sudo)", false)
616
- .option("--browser-extension", "install browser extension native messaging host for Shadow IT network monitoring (default: disabled)", false)
617
642
  .action(cmdInstall);
618
643
 
619
644
  program
@@ -626,6 +651,28 @@ export async function run(argv) {
626
651
  .option("--dry-run", "print what would be removed without touching disk", false)
627
652
  .action(cmdUninstall);
628
653
 
654
+ const redTeam = program
655
+ .command("red-team")
656
+ .description("Red Team network vulnerability scanner (MCP server for Claude Code)");
657
+
658
+ redTeam
659
+ .command("install")
660
+ .description("Download the scanner and register it as an MCP server")
661
+ .option("--agent <name>", "target agent (only 'claude' is supported)", "claude")
662
+ .requiredOption("--key <ak_xxx>", "access key (ak_<43-char>)")
663
+ .option("--env <env>", "target environment: dev|prod (default: auto-detected from package name)")
664
+ .option("--scope <project|user>", "installation scope: user ($HOME) or project (cwd)", "user")
665
+ .option("--no-verify", "skip /verify call")
666
+ .option("--dry-run", "print planned actions, do not touch disk", false)
667
+ .action(cmdRedTeamInstall);
668
+
669
+ redTeam
670
+ .command("uninstall")
671
+ .description("Remove the MCP server registration and the downloaded scanner")
672
+ .option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
673
+ .option("--dry-run", "print what would be removed without touching disk", false)
674
+ .action(cmdRedTeamUninstall);
675
+
629
676
  program.command("doctor").action(cmdDoctor);
630
677
  program.command("list-agents").action(cmdListAgents);
631
678