@coworker-jp/aidr 0.1.144 → 0.1.271

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coworker-jp/aidr",
3
- "version": "0.1.144",
3
+ "version": "0.1.271",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,258 @@
1
+ // Register an MCP server with **Claude Desktop** by editing its configuration
2
+ // file, for the servers that have no other route into it.
3
+ //
4
+ // Why this exists at all: `claude mcp add` only knows Claude Code, and the
5
+ // `.mcpb` bundle format only carries servers we build and ship ourselves. The
6
+ // browser-control companion is neither — it is the upstream Apache-2.0 package
7
+ // run through `npx`, which we deliberately do not redistribute (`red-team.mjs`,
8
+ // `kind: "npx"`). That left the Claude Desktop user with one option: open
9
+ // `claude_desktop_config.json` and hand-edit JSON.
10
+ //
11
+ // Hand-editing is the wrong ask for the audience. A mistake there is not
12
+ // "the new server didn't start" — a malformed configuration file takes down
13
+ // EVERY MCP server in Claude Desktop, including the scanner `.mcpb` the user
14
+ // installed one step earlier. The failure is also silent from the user's point
15
+ // of view: Claude Desktop simply comes back with no tools.
16
+ //
17
+ // So the work moves here, where it can be done correctly once: parse, merge,
18
+ // back up, write atomically — all of which `fs-utils.mjs` already implements
19
+ // for the agent settings files.
20
+ //
21
+ // The other thing this buys, and the reason a `.mcpb` could not replace it:
22
+ // **the absolute path of `npx` is resolved on the machine being configured.**
23
+ // A bundle is static, so it would have to hard-code either `npx` (which relies
24
+ // on whatever PATH Claude Desktop hands its child processes — Claude Desktop
25
+ // ships no Node runtime of its own) or one absolute path (which differs per
26
+ // install: Homebrew, nvm, Volta, system). Running on the endpoint removes that
27
+ // guess entirely.
28
+
29
+ import fsp from "node:fs/promises";
30
+ import path from "node:path";
31
+ import { execFile } from "node:child_process";
32
+ import { promisify } from "node:util";
33
+
34
+ import { writeJsonMerge } from "./fs-utils.mjs";
35
+
36
+ const execFileP = promisify(execFile);
37
+
38
+ /**
39
+ * Where Claude Desktop keeps its configuration.
40
+ *
41
+ * Returns null where Claude Desktop does not exist, so callers can say so
42
+ * instead of writing a file nothing will ever read. There is no official Linux
43
+ * build; inventing a path for it would produce a silent no-op that looks like
44
+ * a successful install.
45
+ */
46
+ export function desktopConfigPath(home, { platform = process.platform, env = process.env } = {}) {
47
+ const p = pathFor(platform);
48
+ if (platform === "darwin") {
49
+ return p.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
50
+ }
51
+ if (platform === "win32") {
52
+ const appData = env.APPDATA || p.join(home, "AppData", "Roaming");
53
+ return p.join(appData, "Claude", "claude_desktop_config.json");
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Path semantics for the platform being configured, not for the host.
60
+ *
61
+ * `platform` is injectable so the Windows layout can be tested from CI (which
62
+ * runs Linux). Leaving `path` host-native would make that injection a half
63
+ * measure: the binary NAME would follow the argument while joining and
64
+ * `dirname` still used POSIX rules, so `dirname("C:\\…\\node.exe")` returns "."
65
+ * and the resolved command silently becomes a relative path. On a real Windows
66
+ * machine `process.platform` selects win32 here, so behaviour is unchanged.
67
+ */
68
+ function pathFor(platform) {
69
+ return platform === "win32" ? path.win32 : path.posix;
70
+ }
71
+
72
+ /**
73
+ * Absolute path to `npx` on this machine.
74
+ *
75
+ * Order: the sibling of the Node binary currently running (we were started by
76
+ * `npx` itself, so that directory is where npm's shims live on every standard
77
+ * install), then the platform's own lookup.
78
+ *
79
+ * Deliberately has NO fallback to the bare string `"npx"`. Writing that would
80
+ * hand the user back the exact failure this function exists to remove, and it
81
+ * would fail later — at Claude Desktop startup, with the server simply absent
82
+ * — rather than here, where we can still say what went wrong.
83
+ */
84
+ export async function resolveNpxPath({
85
+ platform = process.platform,
86
+ execPath = process.execPath,
87
+ lookup = defaultLookup,
88
+ exists = defaultExists,
89
+ } = {}) {
90
+ const p = pathFor(platform);
91
+ const binName = platform === "win32" ? "npx.cmd" : "npx";
92
+ const sibling = p.join(p.dirname(execPath), binName);
93
+ if (await exists(sibling)) return sibling;
94
+
95
+ const found = await lookup(binName, platform);
96
+ if (found && (await exists(found))) return found;
97
+
98
+ throw new Error(
99
+ "could not locate npx on this machine. " +
100
+ "Claude Desktop needs an absolute path because it does not inherit your shell's PATH. " +
101
+ "Install Node.js (which ships npx), then re-run this command.",
102
+ );
103
+ }
104
+
105
+ async function defaultLookup(binName, platform) {
106
+ const cmd = platform === "win32" ? "where" : "which";
107
+ try {
108
+ const { stdout } = await execFileP(cmd, [binName]);
109
+ // `where` can return several lines; take the first.
110
+ return (stdout || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ async function defaultExists(p) {
117
+ try {
118
+ await fsp.access(p);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Merge our server entry into an existing parsed configuration.
127
+ *
128
+ * Pure so it can be tested without touching a filesystem, and because every
129
+ * interesting case here is a shape the user's file might already be in:
130
+ *
131
+ * - `mcpServers` absent → create it
132
+ * - `mcpServers` an object → add / replace one member, keep the rest
133
+ * - `mcpServers` NOT an object → replace it
134
+ *
135
+ * That last case is not hypothetical: a real installation was observed with
136
+ * `"mcpServers": []` (an empty ARRAY). Spreading an array into an object would
137
+ * produce `{"0": …}`-shaped garbage, and leaving it alone would make the
138
+ * install silently do nothing. Every unrelated top-level key is preserved
139
+ * untouched — real files carry app state next to `mcpServers`.
140
+ */
141
+ export function mergeDesktopServers(existing, ours) {
142
+ const base = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
143
+ const servers =
144
+ base.mcpServers && typeof base.mcpServers === "object" && !Array.isArray(base.mcpServers)
145
+ ? base.mcpServers
146
+ : {};
147
+ return { ...base, mcpServers: { ...servers, ...ours } };
148
+ }
149
+
150
+ /** True when `name` already has an entry we did not write. */
151
+ export function hasServer(existing, name) {
152
+ const servers = existing && typeof existing === "object" ? existing.mcpServers : null;
153
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return false;
154
+ return Object.prototype.hasOwnProperty.call(servers, name);
155
+ }
156
+
157
+ /**
158
+ * Install an `npx`-kind MCP server into Claude Desktop.
159
+ *
160
+ * `meta` is a `RED_TEAM_PRODUCTS` entry (`kind: "npx"`). Binary products are
161
+ * rejected by the caller: those ship as `.mcpb` bundles, which is the route
162
+ * Claude Desktop already has for them.
163
+ */
164
+ export async function installDesktopCompanion({
165
+ meta,
166
+ home,
167
+ dryRun = false,
168
+ force = false,
169
+ platform = process.platform,
170
+ env = process.env,
171
+ stdout = console.log,
172
+ stderr = console.error,
173
+ npxPath = null,
174
+ readConfig = defaultReadConfig,
175
+ } = {}) {
176
+ const target = desktopConfigPath(home, { platform, env });
177
+ if (!target) {
178
+ throw new Error(
179
+ `Claude Desktop is not available on this platform (${platform}). ` +
180
+ "Use --agent claude to install into Claude Code instead.",
181
+ );
182
+ }
183
+
184
+ // Read before writing so we can refuse to take over a name the user already
185
+ // configured. Same rule as the Claude Code path: the upstream server names
186
+ // are generic, and a customer may well have registered `playwright`
187
+ // themselves with a different version or different flags.
188
+ const existing = await readConfig(target);
189
+ if (!force && hasServer(existing, meta.mcpServerName)) {
190
+ stderr(`'${meta.mcpServerName}' is already configured in ${target} — leaving it as is.`);
191
+ stderr(`Re-run with --force to replace it with: npx -y ${meta.npmSpec} ${meta.serverArgs.join(" ")}`);
192
+ return { installed: false, skipped: true, path: target };
193
+ }
194
+
195
+ const command = npxPath || (await resolveNpxPath({ platform }));
196
+ const ours = {
197
+ [meta.mcpServerName]: {
198
+ command,
199
+ args: ["-y", meta.npmSpec, ...meta.serverArgs],
200
+ },
201
+ };
202
+
203
+ const res = await writeJsonMerge(target, ours, mergeDesktopServers, { dryRun });
204
+ if (res.written) {
205
+ stdout(`claude desktop: registered '${meta.mcpServerName}' in ${target}`);
206
+ stdout(` command: ${command}`);
207
+ } else if (res.unchanged) {
208
+ stdout(`claude desktop: '${meta.mcpServerName}' already up to date in ${target}`);
209
+ }
210
+ return { installed: Boolean(res.written), path: target, backupPath: res.backupPath, command };
211
+ }
212
+
213
+ /** Remove our server entry from the Claude Desktop configuration. */
214
+ export async function uninstallDesktopCompanion({
215
+ meta,
216
+ home,
217
+ dryRun = false,
218
+ platform = process.platform,
219
+ env = process.env,
220
+ stdout = console.log,
221
+ readConfig = defaultReadConfig,
222
+ } = {}) {
223
+ const target = desktopConfigPath(home, { platform, env });
224
+ if (!target) return { removed: false };
225
+
226
+ const existing = await readConfig(target);
227
+ if (!hasServer(existing, meta.mcpServerName)) {
228
+ // Say so. Returning quietly here made "there was nothing to remove" and
229
+ // "the removal worked" look identical from the terminal — both were exit 0
230
+ // with no output, and the user has no way to tell which one happened.
231
+ stdout(`claude desktop: '${meta.mcpServerName}' is not registered in ${target} — nothing to remove.`);
232
+ return { removed: false, path: target };
233
+ }
234
+
235
+ // Rebuild without our entry rather than deleting the file: everything else in
236
+ // it belongs to the user.
237
+ const kept = { ...existing.mcpServers };
238
+ delete kept[meta.mcpServerName];
239
+ const res = await writeJsonMerge(
240
+ target,
241
+ {},
242
+ (base) => ({ ...(base || {}), mcpServers: kept }),
243
+ { dryRun },
244
+ );
245
+ if (res.written) stdout(`claude desktop: removed '${meta.mcpServerName}' from ${target}`);
246
+ return { removed: Boolean(res.written), path: target };
247
+ }
248
+
249
+ async function defaultReadConfig(target) {
250
+ try {
251
+ const text = await fsp.readFile(target, "utf8");
252
+ return JSON.parse(text.replace(/^\uFEFF/, ""));
253
+ } catch {
254
+ // Missing is the common first-install case. Malformed is caught (and
255
+ // refused) by writeJsonMerge, which is the only thing that writes.
256
+ return null;
257
+ }
258
+ }
package/src/cli.mjs CHANGED
@@ -549,12 +549,52 @@ aidr now installs agent hooks only:
549
549
  // install/uninstall の --agent 複数指定・--all の枠組みには乗せず、独立した
550
550
  // サブコマンドにしている。実装は red-team.mjs に分離 (scheduled/sentinel と
551
551
  // 同じ遅延 import パターン)。
552
+ // `claude` は Claude Code (`claude mcp add`)、`claude-desktop` は Claude Desktop
553
+ // (設定ファイルへのマージ)。後者を足したのは、Claude Desktop に入る唯一の手段が
554
+ // 「`claude_desktop_config.json` を手で編集する」だったため — 失敗すると設定
555
+ // ファイル全体が読めなくなり、その端末の MCP サーバーが全滅する (直前に入れた
556
+ // スキャナの `.mcpb` ごと)。実装は claude-desktop.mjs。
557
+ const RED_TEAM_AGENTS = new Set(["claude", "claude-desktop"]);
558
+
559
+ /**
560
+ * Which directory the install resolves its target against.
561
+ *
562
+ * Exported as a pure function so it can be tested on any platform. The same
563
+ * property asserted through the CLI would only ever run on macOS or Windows —
564
+ * `installDesktopCompanion` rejects Linux before it gets here — and CI runs on
565
+ * Linux, so a CLI-level test of this would be a test that never executes
566
+ * (CLAUDE.md: テストは「あること」ではなく「Linux CI で走ること」).
567
+ *
568
+ * Claude Desktop has exactly one configuration file, in the user's profile;
569
+ * there is no project-scoped variant. Honouring `--scope project` for it would
570
+ * write a file Claude Desktop never reads and report success either way.
571
+ */
572
+ export function redTeamInstallBase({ agent, scope, home, cwd }) {
573
+ if (agent === "claude-desktop") return home;
574
+ return scope === "user" ? home : cwd;
575
+ }
576
+
577
+ /**
578
+ * Reject an `--agent` this CLI does not know, for whichever subcommand asked.
579
+ *
580
+ * Shared by install and uninstall on purpose. When only install validated,
581
+ * `red-team uninstall --agent <typo>` did not fail — it fell through to the
582
+ * default branch and removed the **Claude Code** registration, reporting
583
+ * success. A typo silently doing the wrong removal is worse than the same typo
584
+ * on install, because there is nothing left on screen to say what happened.
585
+ */
586
+ function requireSupportedAgent(subcommand, agent) {
587
+ if (RED_TEAM_AGENTS.has(agent)) return;
588
+ console.error(
589
+ `red-team ${subcommand}: unsupported agent '${agent}' ` +
590
+ `(expected ${[...RED_TEAM_AGENTS].join(" | ")})`,
591
+ );
592
+ process.exit(1);
593
+ }
594
+
552
595
  async function cmdRedTeamInstall(opts) {
553
596
  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
- }
597
+ requireSupportedAgent("install", agent);
558
598
 
559
599
  const home = resolveCallerHome();
560
600
  const scope = opts.scope || "user";
@@ -576,12 +616,22 @@ async function cmdRedTeamInstall(opts) {
576
616
  accessKey: opts.key,
577
617
  env,
578
618
  scope,
579
- home: scope === "user" ? home : process.cwd(),
619
+ home: redTeamInstallBase({ agent, scope, home, cwd: process.cwd() }),
580
620
  dryRun: opts.dryRun,
621
+ force: opts.force,
622
+ product: opts.product || "network-scanner",
623
+ agent,
581
624
  });
582
625
  if (res.installed) {
583
626
  console.log("");
584
- console.log("Next: restart Claude Code so the MCP server is picked up.");
627
+ // Claude Desktop keeps running with the configuration it read at launch,
628
+ // and closing the window does not end the process — so "restart" has to
629
+ // say *quit*, or the user sees no new tools and concludes it failed.
630
+ console.log(
631
+ agent === "claude-desktop"
632
+ ? "Next: quit Claude Desktop completely (Cmd+Q on macOS / quit from the notification area on Windows) and start it again."
633
+ : "Next: restart Claude Code so the MCP server is picked up.",
634
+ );
585
635
  }
586
636
  } catch (e) {
587
637
  // 403 red_team_requires_10_seats は「Pro/Trial かつ契約シート 10 以上」の
@@ -593,14 +643,24 @@ async function cmdRedTeamInstall(opts) {
593
643
  }
594
644
 
595
645
  async function cmdRedTeamUninstall(opts) {
646
+ const agent = opts.agent || "claude";
647
+ requireSupportedAgent("uninstall", agent);
648
+
596
649
  const home = resolveCallerHome();
597
650
  const scope = opts.scope || "user";
598
651
  const { uninstallRedTeam } = await import("./red-team.mjs");
599
- await uninstallRedTeam({
600
- scope,
601
- home: scope === "user" ? home : process.cwd(),
602
- dryRun: opts.dryRun,
603
- });
652
+ try {
653
+ await uninstallRedTeam({
654
+ scope,
655
+ home: redTeamInstallBase({ agent, scope, home, cwd: process.cwd() }),
656
+ dryRun: opts.dryRun,
657
+ product: opts.product || "network-scanner",
658
+ agent,
659
+ });
660
+ } catch (e) {
661
+ console.error(`red-team uninstall failed: ${e.message}`);
662
+ process.exit(1);
663
+ }
604
664
  }
605
665
 
606
666
  function rejectRemovedFlags(argv) {
@@ -658,17 +718,21 @@ export async function run(argv) {
658
718
  redTeam
659
719
  .command("install")
660
720
  .description("Download the scanner and register it as an MCP server")
661
- .option("--agent <name>", "target agent (only 'claude' is supported)", "claude")
721
+ .option("--agent <name>", "target: claude (Claude Code) | claude-desktop (Claude Desktop)", "claude")
662
722
  .requiredOption("--key <ak_xxx>", "access key (ak_<43-char>)")
663
723
  .option("--env <env>", "target environment: dev|prod (default: auto-detected from package name)")
724
+ .option("--product <name>", "red team product: network-scanner|web-scanner|playwright", "network-scanner")
664
725
  .option("--scope <project|user>", "installation scope: user ($HOME) or project (cwd)", "user")
665
726
  .option("--no-verify", "skip /verify call")
727
+ .option("--force", "replace an existing MCP server registration of the same name", false)
666
728
  .option("--dry-run", "print planned actions, do not touch disk", false)
667
729
  .action(cmdRedTeamInstall);
668
730
 
669
731
  redTeam
670
732
  .command("uninstall")
671
733
  .description("Remove the MCP server registration and the downloaded scanner")
734
+ .option("--agent <name>", "target: claude (Claude Code) | claude-desktop (Claude Desktop)", "claude")
735
+ .option("--product <name>", "red team product: network-scanner|web-scanner|playwright", "network-scanner")
672
736
  .option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
673
737
  .option("--dry-run", "print what would be removed without touching disk", false)
674
738
  .action(cmdRedTeamUninstall);
package/src/red-team.mjs CHANGED
@@ -21,22 +21,84 @@ import { downloadBase } from "./verify.mjs";
21
21
 
22
22
  const execFileP = promisify(execFile);
23
23
 
24
- /** MCP サーバー名。`claude mcp remove network-scanner` で消せる。 */
25
- export const MCP_SERVER_NAME = "network-scanner";
24
+ // Playwright MCP の固定バージョン。顧客端末で `npx` が解決する = 実質そこが
25
+ // 依存ツリーの決定者なので、`@latest` ではなくピン留めする (公開直後の版を顧客
26
+ // 端末に降ろさない = 7 日ルールの趣旨。docs/scanner/SUPPLY_CHAIN.md)。
27
+ // 更新時は公開から 7 日以上経った stable を選ぶこと。
28
+ export const PLAYWRIGHT_MCP_VERSION = "0.0.78";
29
+
30
+ // RedTeam プロダクト。`kind` が配布方式を決める:
31
+ // binary — 我々がビルドして S3 で配る Rust バイナリ (ゲート付き)。
32
+ // npx — 上流の公開 npm パッケージをそのまま登録する (再配布しない)。
33
+ // network スキャナと web スキャナが別々なのは MCPB が 1 バンドル=1 サーバーで
34
+ // 同居できないため。`product` 省略時は network-scanner なので既存呼び出しは不変。
35
+ export const RED_TEAM_PRODUCTS = {
36
+ "network-scanner": {
37
+ kind: "binary",
38
+ mcpServerName: "network-scanner",
39
+ binName: "network-scanner",
40
+ },
41
+ "web-scanner": {
42
+ kind: "binary",
43
+ mcpServerName: "web-scanner",
44
+ binName: "web-scanner",
45
+ },
46
+ // web-scanner の相棒。エージェントがブラウザを操作できるようになるので、
47
+ // ログインが要る画面や SPA を診断できる (web-scanner 自身は HTTP と
48
+ // ヘッドレス DOM までで、ログイン操作の手段を持たない)。
49
+ // Apache-2.0 の公開パッケージなので再配布せず npx で直接叩く =
50
+ // 実行時のアクセスキーゲートは無い (掛けても意味がないため)。
51
+ // `--browser chrome` は端末にインストール済みの Chrome を使う指定
52
+ // (chromium を DL しない / 企業 CA・プロキシ設定をそのまま使える)。
53
+ playwright: {
54
+ kind: "npx",
55
+ mcpServerName: "playwright",
56
+ npmSpec: `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`,
57
+ serverArgs: ["--browser", "chrome", "--ignore-https-errors"],
58
+ },
59
+ };
60
+ export const DEFAULT_RED_TEAM_PRODUCT = "network-scanner";
61
+
62
+ /** 後方互換: network-scanner の MCP サーバー名。 */
63
+ export const MCP_SERVER_NAME = RED_TEAM_PRODUCTS[DEFAULT_RED_TEAM_PRODUCT].mcpServerName;
64
+
65
+ /** product 名 → メタ。未知なら throw (呼び出し側でメッセージ表示)。 */
66
+ function productMeta(product) {
67
+ const meta = RED_TEAM_PRODUCTS[product];
68
+ if (!meta) {
69
+ throw new Error(
70
+ `unknown red-team product '${product}' (expected ${Object.keys(RED_TEAM_PRODUCTS).join(" | ")})`,
71
+ );
72
+ }
73
+ return meta;
74
+ }
75
+
76
+ /** OS に応じた実ファイル名 (Windows は .exe)。 */
77
+ function binFileName(meta) {
78
+ return process.platform === "win32" ? `${meta.binName}.exe` : meta.binName;
79
+ }
26
80
 
27
81
  /**
28
82
  * Red Team の生バイナリを取得する。
29
83
  *
30
- * verify Lambda の `GET /download/red-team/<platform>` はアクセスキー認証に
31
- * 加えて Red Team エンタイトルメント (pro/trial かつ契約シート >= 10) を要求し、
32
- * 不適格なら 403 を返す。fetchAsset は非 2xx で throw するため、ここでは
33
- * 特別扱いせず呼び出し側にメッセージを見せる。
84
+ * verify Lambda の `GET /download/red-team/[<product>/]<platform>` はアクセス
85
+ * キー認証に加えて Red Team エンタイトルメント (pro/trial かつ契約シート >= 10)
86
+ * を要求し、不適格なら 403 を返す。fetchAsset は非 2xx で throw するため、
87
+ * ここでは特別扱いせず呼び出し側にメッセージを見せる。
88
+ *
89
+ * network-scanner は従来どおり 1 セグメント (`/red-team/<platform>`) を使う
90
+ * (配信済みルートとバイト等価)。他 product は `/red-team/<product>/<platform>`。
34
91
  */
35
- export async function fetchRedTeamBinary(destPath, { env = "prod", accessKey } = {}) {
92
+ export async function fetchRedTeamBinary(
93
+ destPath,
94
+ { env = "prod", accessKey, product = DEFAULT_RED_TEAM_PRODUCT } = {},
95
+ ) {
96
+ productMeta(product); // validate early
36
97
  const platform = detectPlatform();
37
- const binUrl = `${downloadBase(env)}/red-team/${platform}`;
98
+ const rel = product === DEFAULT_RED_TEAM_PRODUCT ? platform : `${product}/${platform}`;
99
+ const binUrl = `${downloadBase(env)}/red-team/${rel}`;
38
100
  const result = await fetchAsset(binUrl, destPath, { accessKey });
39
- return { ...result, platform };
101
+ return { ...result, platform, product };
40
102
  }
41
103
 
42
104
  /**
@@ -52,6 +114,21 @@ function claudeScopeFor(scope) {
52
114
  return scope === "user" ? "user" : "local";
53
115
  }
54
116
 
117
+ /**
118
+ * 同名の MCP サーバーが既に登録されているか (スコープ問わず)。
119
+ *
120
+ * `claude mcp get <name>` は見つからなければ非ゼロで終わる。登録済みなら
121
+ * 表示テキストをそのまま返して、呼び出し側が「何が登録済みか」を人間に見せる。
122
+ */
123
+ async function mcpServerExists(name) {
124
+ try {
125
+ const { stdout } = await execFileP("claude", ["mcp", "get", name]);
126
+ return { exists: true, detail: (stdout || "").trim() };
127
+ } catch {
128
+ return { exists: false, detail: "" };
129
+ }
130
+ }
131
+
55
132
  /** `claude` CLI が PATH にあるか。 */
56
133
  async function claudeCliAvailable() {
57
134
  try {
@@ -66,11 +143,11 @@ async function claudeCliAvailable() {
66
143
  * 手作業に落とす場合のコマンドを組み立てる(`claude` が PATH に無いとき、
67
144
  * および --dry-run のときに表示する)。
68
145
  */
69
- export function buildMcpAddArgs(binPath, accessKey, scope) {
146
+ export function buildMcpAddArgs(binPath, accessKey, scope, mcpServerName = MCP_SERVER_NAME) {
70
147
  return [
71
148
  "mcp",
72
149
  "add",
73
- MCP_SERVER_NAME,
150
+ mcpServerName,
74
151
  "-s",
75
152
  claudeScopeFor(scope),
76
153
  "-e",
@@ -80,6 +157,27 @@ export function buildMcpAddArgs(binPath, accessKey, scope) {
80
157
  ];
81
158
  }
82
159
 
160
+ /**
161
+ * `kind: "npx"` の product を登録する引数を組み立てる。
162
+ *
163
+ * 我々のバイナリではなく上流の公開パッケージを起動するだけなので、アクセス
164
+ * キーは渡さない (受け取らないし、実行時ゲートも無い)。
165
+ */
166
+ export function buildNpxMcpAddArgs(meta, scope) {
167
+ return [
168
+ "mcp",
169
+ "add",
170
+ meta.mcpServerName,
171
+ "-s",
172
+ claudeScopeFor(scope),
173
+ "--",
174
+ "npx",
175
+ "-y",
176
+ meta.npmSpec,
177
+ ...meta.serverArgs,
178
+ ];
179
+ }
180
+
83
181
  /**
84
182
  * Red Team バイナリを取得し、Claude Code に MCP サーバーとして登録する。
85
183
  *
@@ -92,29 +190,103 @@ export async function installRedTeam({
92
190
  scope = "user",
93
191
  home,
94
192
  dryRun = false,
193
+ force = false,
194
+ product = DEFAULT_RED_TEAM_PRODUCT,
195
+ // どちらの Claude に入れるか。`claude` = Claude Code (`claude mcp add`)、
196
+ // `claude-desktop` = Claude Desktop (設定ファイルへのマージ)。後者は
197
+ // `kind: "npx"` の product 専用 — バイナリ product は `.mcpb` という
198
+ // 専用の経路を既に持っており、そちらはアクセスキーの刻印も伴うため。
199
+ agent = "claude",
95
200
  stdout = console.log,
96
201
  stderr = console.error,
202
+ serverExists = mcpServerExists,
203
+ // `claude` の実在判定も注入できるようにする。ここが注入できないと、テストは
204
+ // 「実行マシンに claude が入っているか」に暗黙依存する — 実際 #754 のガード
205
+ // テストは開発機では緑・CI (claude 不在) では赤になり、その差が PR では
206
+ // 見えなかった (aidr スイートは main push でしか走っていなかった)。
207
+ claudeAvailable = claudeCliAvailable,
97
208
  }) {
209
+ const meta = productMeta(product);
210
+
211
+ // Claude Desktop はコマンドを受け付けないので、設定ファイルへ直接マージする。
212
+ // バイナリ product をここへ流さないのは意図的 — `.mcpb` がその経路であり、
213
+ // バンドルにはアクセスキーが刻印される。設定ファイルに同じことをすると
214
+ // 平文の鍵をユーザーの config に置くことになる。
215
+ if (agent === "claude-desktop") {
216
+ if (meta.kind !== "npx") {
217
+ throw new Error(
218
+ `--agent claude-desktop does not install '${product}'. ` +
219
+ "Download its .mcpb bundle from the portal and double-click it instead.",
220
+ );
221
+ }
222
+ const { installDesktopCompanion } = await import("./claude-desktop.mjs");
223
+ const res = await installDesktopCompanion({ meta, home, dryRun, force, stdout, stderr });
224
+ return { installed: res.installed, registered: res.installed, path: res.path };
225
+ }
226
+
227
+ // kind: "npx" — 我々は何も配らない。上流の公開パッケージを `claude mcp add`
228
+ // で登録するだけなので、ダウンロードも sha256 検証もアクセスキーも無い。
229
+ if (meta.kind === "npx") {
230
+ const npxArgs = buildNpxMcpAddArgs(meta, scope);
231
+ if (dryRun) {
232
+ stdout(`[dry-run] would register ${product} via npx (no download)`);
233
+ stdout(`[dry-run] would run: claude ${npxArgs.join(" ")}`);
234
+ return { installed: false, dryRun: true, registered: false };
235
+ }
236
+ if (!(await claudeAvailable())) {
237
+ stderr("`claude` CLI not found on PATH — nothing was registered.");
238
+ stderr("Run this once Claude Code is available:");
239
+ stderr(` claude ${npxArgs.join(" ")}`);
240
+ return { installed: false, registered: false };
241
+ }
242
+
243
+ // 上流パッケージのサーバー名 (`playwright` 等) は汎用的で、顧客が自分で
244
+ // 登録済みのことがある。install は同スコープで remove → add するため、
245
+ // 黙って進めると顧客自身の設定 (別バージョン・別フラグ・別プロファイル) を
246
+ // 上書きしてしまう。我々が所有していない名前は勝手に奪わない。
247
+ // 上書きしたいときだけ --force。
248
+ if (!force) {
249
+ const { exists, detail } = await serverExists(meta.mcpServerName);
250
+ if (exists) {
251
+ stderr(`'${meta.mcpServerName}' is already registered — leaving it as is.`);
252
+ if (detail) stderr(detail);
253
+ stderr(`Re-run with --force to replace it with: npx -y ${meta.npmSpec} ${meta.serverArgs.join(" ")}`);
254
+ return { installed: false, registered: false, skipped: true };
255
+ }
256
+ }
257
+ // 再実行を許すため、既存エントリがあれば先に外す。未登録なら失敗するので無視。
258
+ try {
259
+ await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
260
+ } catch {
261
+ // not registered yet — expected on first install
262
+ }
263
+ await execFileP("claude", npxArgs);
264
+ stdout(`mcp: registered '${meta.mcpServerName}' (scope=${claudeScopeFor(scope)}, via npx)`);
265
+ return { installed: true, registered: true };
266
+ }
267
+
98
268
  // 既存の install と同じ配置規約: <base>/.claude/bin/ に置く。
99
269
  const binDir = path.join(home, ".claude", "bin");
100
- const binName = process.platform === "win32" ? "network-scanner.exe" : "network-scanner";
270
+ const binName = binFileName(meta);
101
271
  const binPath = path.join(binDir, binName);
102
272
 
103
273
  if (dryRun) {
104
- stdout(`[dry-run] would download red-team binary -> ${binPath}`);
105
- stdout(`[dry-run] would run: claude ${buildMcpAddArgs(binPath, "<key>", scope).join(" ")}`);
274
+ stdout(`[dry-run] would download red-team binary (${product}) -> ${binPath}`);
275
+ stdout(
276
+ `[dry-run] would run: claude ${buildMcpAddArgs(binPath, "<key>", scope, meta.mcpServerName).join(" ")}`,
277
+ );
106
278
  return { installed: false, dryRun: true, path: binPath };
107
279
  }
108
280
 
109
281
  await fs.mkdir(binDir, { recursive: true });
110
- const res = await fetchRedTeamBinary(binPath, { env, accessKey });
282
+ const res = await fetchRedTeamBinary(binPath, { env, accessKey, product });
111
283
  stdout(
112
284
  `binary: ${res.path} (${res.platform}${res.verified ? ", sha256 verified" : ""})`,
113
285
  );
114
286
 
115
- const args = buildMcpAddArgs(binPath, accessKey, scope);
287
+ const args = buildMcpAddArgs(binPath, accessKey, scope, meta.mcpServerName);
116
288
 
117
- if (!(await claudeCliAvailable())) {
289
+ if (!(await claudeAvailable())) {
118
290
  stderr("`claude` CLI not found on PATH — the binary is installed but not registered.");
119
291
  stderr("Run this once Claude Code is available:");
120
292
  stderr(` claude ${args.join(" ")}`);
@@ -123,13 +295,13 @@ export async function installRedTeam({
123
295
 
124
296
  // 再実行を許すため、既存エントリがあれば先に外す。未登録なら失敗するので無視。
125
297
  try {
126
- await execFileP("claude", ["mcp", "remove", MCP_SERVER_NAME, "-s", claudeScopeFor(scope)]);
298
+ await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
127
299
  } catch {
128
300
  // not registered yet — expected on first install
129
301
  }
130
302
 
131
303
  await execFileP("claude", args);
132
- stdout(`mcp: registered '${MCP_SERVER_NAME}' (scope=${claudeScopeFor(scope)})`);
304
+ stdout(`mcp: registered '${meta.mcpServerName}' (scope=${claudeScopeFor(scope)})`);
133
305
 
134
306
  return { installed: true, registered: true, path: binPath };
135
307
  }
@@ -139,30 +311,51 @@ export async function uninstallRedTeam({
139
311
  scope = "user",
140
312
  home,
141
313
  dryRun = false,
314
+ product = DEFAULT_RED_TEAM_PRODUCT,
315
+ agent = "claude",
142
316
  stdout = console.log,
317
+ claudeAvailable = claudeCliAvailable,
143
318
  }) {
144
- const binPath = path.join(
145
- home,
146
- ".claude",
147
- "bin",
148
- process.platform === "win32" ? "network-scanner.exe" : "network-scanner",
149
- );
319
+ const meta = productMeta(product);
320
+
321
+ if (agent === "claude-desktop") {
322
+ // install と同じ範囲でしか外せない。ここに guard が無いと、`--product` を
323
+ // 省いた既定 (`network-scanner` = `.mcpb` product) がこの分岐へ流れ、
324
+ // `claude_desktop_config.json` に一度も書かれていないものを探して
325
+ // `{removed:false}` を**何も出力せず** exit 0 で返していた。利用者からは
326
+ // 「消えたのか、そもそも入っていなかったのか」が区別できない。
327
+ if (meta.kind !== "npx") {
328
+ throw new Error(
329
+ `--agent claude-desktop does not manage '${product}'. ` +
330
+ "It is a .mcpb bundle: remove it from Claude Desktop's extension list instead.",
331
+ );
332
+ }
333
+ const { uninstallDesktopCompanion } = await import("./claude-desktop.mjs");
334
+ return uninstallDesktopCompanion({ meta, home, dryRun, stdout });
335
+ }
336
+ // kind: "npx" は我々がディスクに置いたものが無いので、消すのは MCP 登録だけ。
337
+ const binPath =
338
+ meta.kind === "npx" ? null : path.join(home, ".claude", "bin", binFileName(meta));
150
339
 
151
340
  if (dryRun) {
152
- stdout(`[dry-run] would run: claude mcp remove ${MCP_SERVER_NAME} -s ${claudeScopeFor(scope)}`);
153
- stdout(`[dry-run] would remove ${binPath}`);
341
+ stdout(`[dry-run] would run: claude mcp remove ${meta.mcpServerName} -s ${claudeScopeFor(scope)}`);
342
+ if (binPath) stdout(`[dry-run] would remove ${binPath}`);
154
343
  return { removed: false, dryRun: true };
155
344
  }
156
345
 
157
- if (await claudeCliAvailable()) {
346
+ if (await claudeAvailable()) {
158
347
  try {
159
- await execFileP("claude", ["mcp", "remove", MCP_SERVER_NAME, "-s", claudeScopeFor(scope)]);
160
- stdout(`mcp: removed '${MCP_SERVER_NAME}'`);
348
+ await execFileP("claude", ["mcp", "remove", meta.mcpServerName, "-s", claudeScopeFor(scope)]);
349
+ stdout(`mcp: removed '${meta.mcpServerName}'`);
161
350
  } catch {
162
351
  // already absent
163
352
  }
164
353
  }
165
354
 
355
+ if (!binPath) {
356
+ return { removed: true };
357
+ }
358
+
166
359
  await fs.rm(binPath, { force: true });
167
360
  stdout(`removed ${binPath}`);
168
361
  return { removed: true };