@coworker-jp/aidr 0.1.11 → 0.1.75

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,62 +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
- // On Windows there is no ai-scanner binary and no cron/systemd/launchd, so
158
- // the scheduled endpoint-info scan cannot run — the standalone install is
159
- // coworker-sentinel-only there (`--with-sentinel` installs the daemon below).
160
- // Force scheduled off so it doesn't abort the install before sentinel.
161
- if (process.platform === "win32" && opts.scheduled) {
162
- console.error("Note: scheduled scanning is not available on Windows; installing coworker-sentinel only.");
163
- opts.scheduled = false;
164
- }
165
-
166
- // --scheduled installs a system-wide cron / systemd / launchd entry that
167
- // runs `ai-scanner scan endpoint-info` periodically as root, against an
168
- // agent-independent binary at /opt/coworker/aidr/bin/ai-scanner. Auto-add
169
- // the standalone install so the binary fetch loop drops a copy there
170
- // even when the user only listed AI agents (claude / cursor / ...) on
171
- // the command line.
172
- if (opts.scheduled && !agents.includes("standalone")) {
173
- agents.push("standalone");
174
- }
175
-
176
- const intervalHours = parseInt(opts.interval ?? "4", 10);
177
- if (opts.scheduled && (isNaN(intervalHours) || intervalHours < 1)) {
178
- console.error("--interval must be an integer >= 1");
179
- process.exit(1);
180
- }
181
-
182
- // --scheduled の system path 書き込みは root 必須。
183
- // - TTY あり (対話 shell): `sudo -E` で自分自身を re-exec、password prompt が出る
184
- // - TTY なし (CI / pipe): sudo は対話的に password を聞けないので fail-fast 案内
185
- // smoke / unit test では `AI_SCANNER_FAKE_ROOT=1` を設定してこのチェックを skip 可能。
186
- if (opts.scheduled && !opts.dryRun
187
- && process.getuid && process.getuid() !== 0
188
- && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
189
- const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
190
- if (interactive) {
191
- console.error("--scheduled requires root. Re-running via sudo (you may be prompted for your password)...");
192
- const result = spawnSync(
193
- "sudo",
194
- ["-E", process.execPath, ...process.argv.slice(1)],
195
- { stdio: "inherit" }
196
- );
197
- process.exit(result.status ?? 1);
198
- }
199
- console.error("Error: --scheduled requires root privileges (writes /opt/coworker/aidr, /etc/systemd/system, /etc/aidr).");
200
- console.error("Re-run with sudo (non-interactive shells cannot prompt for password):");
201
- console.error(` sudo -E npx @coworker-jp/aidr install --scheduled --key <ak_xxx>`);
202
- process.exit(2);
203
- }
204
-
205
139
  // Confirmation for existing shared settings.json (claude / gemini).
206
140
  // --yes bypasses; --dry-run doesn't write so skip; otherwise prompt on TTY,
207
141
  // error if non-interactive (safer than silently merging in CI).
@@ -231,16 +165,9 @@ async function cmdInstall(opts) {
231
165
  }
232
166
 
233
167
  if (!opts.skipBinary && !opts.dryRun) {
234
- const { SYSTEM_BIN_DIR } = await import("./scheduled.mjs");
235
168
  const agentBinDirs = [];
236
169
  for (const name of agents) {
237
170
  const mod = await loadAgent(name);
238
- // --scheduled 時の standalone は system path (/opt/coworker/aidr/bin)
239
- // に置く。それ以外は従来通り per-user の ~/.<agent>/bin。
240
- if (name === "standalone" && opts.scheduled) {
241
- agentBinDirs.push(SYSTEM_BIN_DIR);
242
- continue;
243
- }
244
171
  const agentDir = mod.meta.agentDir;
245
172
  if (!agentDir) continue;
246
173
  agentBinDirs.push(path.join(home, agentDir, "bin"));
@@ -272,10 +199,27 @@ async function cmdInstall(opts) {
272
199
  const src = path.join(primaryDir, file);
273
200
  const dest = path.join(restDir, file);
274
201
  await fs.mkdir(restDir, { recursive: true });
275
- await fs.copyFile(src, dest);
276
- await fs.chmod(dest, 0o755);
277
- if (process.platform === "darwin") {
278
- 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;
279
223
  }
280
224
  console.log(`binary: ${dest} (copied)`);
281
225
  })
@@ -315,54 +259,29 @@ async function cmdInstall(opts) {
315
259
  .filter((r) => r.name !== "standalone")
316
260
  .map((r) => r.displayName);
317
261
 
318
- if (opts.scheduled && !opts.dryRun) {
319
- const { installScheduled } = await import("./scheduled.mjs");
320
- // Schedule entries always point at /opt/coworker/aidr/bin/ai-scanner
321
- // (system-wide, root-owned). The path is hard-coded inside scheduled.mjs.
322
- try {
323
- await installScheduled({
324
- accessKey: opts.key,
325
- dryRun: opts.dryRun,
326
- interval: intervalHours,
327
- });
328
- } catch (e) {
329
- console.error(`scheduled install failed: ${e.message}`);
330
- process.exit(1);
331
- }
332
- }
333
-
334
- if (opts.withSentinel) {
335
- const { installSentinel } = await import("./sentinel.mjs");
336
- try {
337
- const platform = (await import("./binary-fetcher.mjs")).detectPlatform();
338
- const presignedUrl = verifyInfo?.sentinel_download_urls?.[platform] || undefined;
339
- // Windows additionally needs the sibling WinDivert.dll presigned URL.
340
- const dllPresignedUrl =
341
- verifyInfo?.sentinel_download_urls?.[`${platform}-windivert-dll`] || undefined;
342
- await installSentinel({
343
- accessKey: opts.key,
344
- dryRun: opts.dryRun,
345
- skipBinary: opts.skipBinary,
346
- presignedUrl,
347
- dllPresignedUrl,
348
- env: opts.env || DEFAULT_ENV,
349
- noActivate: process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1",
350
- });
351
- } catch (e) {
352
- console.error(`sentinel install failed: ${e.message}`);
353
- process.exit(1);
354
- }
355
- }
356
-
357
- if (opts.browserExtension && !opts.dryRun) {
358
- const firstAgentName = agents[0];
359
- const firstAgent = await loadAgent(firstAgentName);
360
- const binaryPath = path.join(home, firstAgent.meta.agentDir, "bin", "ai-scanner");
361
- try {
362
- await installBrowserExtension(home, binaryPath);
363
- } catch (e) {
364
- 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
+ }
365
282
  }
283
+ } catch (e) {
284
+ console.error(`legacy scheduled cleanup warning: ${e.message}`);
366
285
  }
367
286
 
368
287
  // Auto-sudo re-exec wrote per-agent files as root into the original user's
@@ -505,18 +424,20 @@ async function cmdUninstall(opts) {
505
424
  }
506
425
  }));
507
426
 
508
- // Schedule entries live in system paths (/etc, /opt, /Library/LaunchDaemons)
509
- // and need root to remove. Mirror cmdInstall's auto-sudo: TTY なら sudo に
510
- // 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 は触らない)。
511
432
  try {
512
- const { uninstallScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
433
+ const { cleanupLegacyScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
513
434
  if (await isScheduledInstalled()) {
514
435
  const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
515
436
  || (process.getuid && process.getuid() === 0);
516
437
  if (!isRoot) {
517
438
  const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
518
439
  if (interactive) {
519
- 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)...");
520
441
  const result = spawnSync(
521
442
  "sudo",
522
443
  ["-E", process.execPath, ...process.argv.slice(1)],
@@ -524,19 +445,23 @@ async function cmdUninstall(opts) {
524
445
  );
525
446
  process.exit(result.status ?? 0);
526
447
  }
527
- 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:");
528
449
  console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
529
450
  } else {
530
- await uninstallScheduled({ dryRun: opts.dryRun });
451
+ await cleanupLegacyScheduled({ dryRun: opts.dryRun });
531
452
  }
532
453
  }
533
454
  } catch (e) {
534
- console.error(`scheduled uninstall warning: ${e.message}`);
455
+ console.error(`scheduled cleanup warning: ${e.message}`);
535
456
  }
536
457
 
537
- // Sentinel daemon (LaunchDaemon / systemd service) — same auto-sudo
538
- // pattern. Independent try-block so a scheduled-uninstall hiccup doesn't
539
- // 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).
540
465
  try {
541
466
  const { uninstallSentinel, isSentinelInstalled } = await import("./sentinel.mjs");
542
467
  if (await isSentinelInstalled()) {
@@ -545,7 +470,7 @@ async function cmdUninstall(opts) {
545
470
  if (!isRoot) {
546
471
  const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
547
472
  if (interactive) {
548
- 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)...");
549
474
  const result = spawnSync(
550
475
  "sudo",
551
476
  ["-E", process.execPath, ...process.argv.slice(1)],
@@ -553,7 +478,7 @@ async function cmdUninstall(opts) {
553
478
  );
554
479
  process.exit(result.status ?? 0);
555
480
  }
556
- 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:");
557
482
  console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
558
483
  } else {
559
484
  await uninstallSentinel({ dryRun: opts.dryRun });
@@ -597,7 +522,101 @@ async function cmdListAgents() {
597
522
  }
598
523
  }
599
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
+
600
618
  export async function run(argv) {
619
+ rejectRemovedFlags(argv);
601
620
  const program = new Command();
602
621
  program
603
622
  .name("aidr")
@@ -617,12 +636,9 @@ export async function run(argv) {
617
636
  .option("--no-verify", "skip /verify call")
618
637
  .option("--skip-binary", "skip binary download", false)
619
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)
620
640
  .option("-y, --yes", "proceed without confirmation when existing settings.json is detected", false)
621
641
  .option("--uninstall", "remove all ai-scanner hooks/scripts/binaries for the selected agents (see 'uninstall' command)", false)
622
- .option("--scheduled", "enable scheduled endpoint-info collection (creates a cron/systemd/launchd entry; without this flag endpoint-info is never collected)", false)
623
- .option("--interval <hours>", "scheduled scan interval in hours (min 1, default 4; requires --scheduled)", "4")
624
- .option("--with-sentinel", "install coworker-sentinel host-level EDR daemon (Pro/Trial plan; requires sudo)", false)
625
- .option("--browser-extension", "install browser extension native messaging host for Shadow IT network monitoring (default: disabled)", false)
626
642
  .action(cmdInstall);
627
643
 
628
644
  program
@@ -635,6 +651,28 @@ export async function run(argv) {
635
651
  .option("--dry-run", "print what would be removed without touching disk", false)
636
652
  .action(cmdUninstall);
637
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
+
638
676
  program.command("doctor").action(cmdDoctor);
639
677
  program.command("list-agents").action(cmdListAgents);
640
678