cdp-toolkit 1.9.7 → 1.11.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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Registering the cdp-toolkit MCP stdio server into a coding-agent harness.
3
+ *
4
+ * Three harnesses, three different registration mechanisms, chosen per-harness
5
+ * from what was actually verified against real on-disk configs, the official
6
+ * docs, and the installed CLIs (not assumed):
7
+ *
8
+ * - claude: `claude mcp add` is NOT an upsert — it errors on a duplicate
9
+ * server name. So this does a direct read-modify-write of the JSON config
10
+ * (~/.claude.json) instead, which is naturally idempotent: same input in,
11
+ * same file out, unrelated keys and servers preserved untouched.
12
+ * - codex: no confirmed idempotent single command, so this shells out to
13
+ * `codex mcp add`, first checking `codex mcp get <name>` and doing a
14
+ * remove-then-add when the name already exists.
15
+ * - opencode: `opencode mcp add` is a confirmed idempotent upsert (verified
16
+ * against its source), so this just shells out to it directly.
17
+ *
18
+ * Only the pure argv builders (codexAddArgs, opencodeAddArgs) and the
19
+ * claude-side JSON shape are exported for unit testing; the codex/opencode
20
+ * shell-outs are not covered by tests here because doing so would mean either
21
+ * running the real CLIs or mocking node:child_process, and the spec for this
22
+ * module calls only for testing the pure builders plus the claude RMW path.
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+
29
+ export type HarnessChoice = "claude" | "codex" | "opencode";
30
+
31
+ export interface McpServerConfig {
32
+ command: string;
33
+ args: string[];
34
+ env: Record<string, string>;
35
+ }
36
+
37
+ export interface InstallResult {
38
+ harness: HarnessChoice;
39
+ method: "file" | "cli";
40
+ path?: string;
41
+ action: "added" | "updated" | "unchanged";
42
+ detail?: string;
43
+ }
44
+
45
+ export interface InstallOpts {
46
+ /** Overrides the on-disk config path (claude only). For tests. */
47
+ configPath?: string;
48
+ /** Overrides os.homedir() when deriving the default config path (claude only). For tests. */
49
+ homeDir?: string;
50
+ }
51
+
52
+ export async function installMcpServer(
53
+ harness: HarnessChoice,
54
+ name: string,
55
+ cfg: McpServerConfig,
56
+ opts: InstallOpts = {},
57
+ ): Promise<InstallResult> {
58
+ switch (harness) {
59
+ case "claude":
60
+ return installClaude(name, cfg, opts);
61
+ case "codex":
62
+ return installCodex(name, cfg);
63
+ case "opencode":
64
+ return installOpencode(name, cfg);
65
+ default: {
66
+ const exhaustive: never = harness;
67
+ throw new Error(`unknown harness: ${String(exhaustive)}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /* ------------------------------- claude: file RMW ------------------------------- */
73
+
74
+ async function installClaude(name: string, cfg: McpServerConfig, opts: InstallOpts): Promise<InstallResult> {
75
+ const path = opts.configPath ?? join(opts.homeDir ?? homedir(), ".claude.json");
76
+
77
+ let root: Record<string, unknown>;
78
+ let fileExisted: boolean;
79
+ try {
80
+ const text = await readFile(path, "utf8");
81
+ const parsed: unknown = JSON.parse(text);
82
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
83
+ throw new Error(`${path} does not contain a JSON object at its top level`);
84
+ }
85
+ root = parsed as Record<string, unknown>;
86
+ fileExisted = true;
87
+ } catch (err) {
88
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
89
+ root = {};
90
+ fileExisted = false;
91
+ } else {
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ const rawServers = root.mcpServers;
97
+ const mcpServers: Record<string, unknown> =
98
+ typeof rawServers === "object" && rawServers !== null && !Array.isArray(rawServers)
99
+ ? (rawServers as Record<string, unknown>)
100
+ : {};
101
+ root.mcpServers = mcpServers;
102
+
103
+ // No `type` field: stdio is the implicit default for this shape in
104
+ // ~/.claude.json, and adding one would diverge from what `claude mcp add`
105
+ // itself writes for a stdio server.
106
+ const entry: Record<string, unknown> = { command: cfg.command, args: cfg.args };
107
+ if (Object.keys(cfg.env).length > 0) entry.env = cfg.env;
108
+
109
+ const prior = mcpServers[name];
110
+ const action: InstallResult["action"] =
111
+ prior === undefined ? "added" : canonicalJson(prior) === canonicalJson(entry) ? "unchanged" : "updated";
112
+
113
+ mcpServers[name] = entry;
114
+
115
+ if (action === "unchanged") {
116
+ return { harness: "claude", method: "file", path, action, detail: "no change needed" };
117
+ }
118
+
119
+ await mkdir(dirname(path), { recursive: true });
120
+ await writeFile(path, JSON.stringify(root, null, 2) + "\n", "utf8");
121
+
122
+ return {
123
+ harness: "claude",
124
+ method: "file",
125
+ path,
126
+ action,
127
+ detail: fileExisted ? undefined : "created new config file",
128
+ };
129
+ }
130
+
131
+ /** Deep-equality via a key-sorted JSON string, so key order never causes a false "updated". */
132
+ function canonicalJson(value: unknown): string {
133
+ return JSON.stringify(canonicalize(value));
134
+ }
135
+
136
+ function canonicalize(value: unknown): unknown {
137
+ if (Array.isArray(value)) return value.map(canonicalize);
138
+ if (value !== null && typeof value === "object") {
139
+ const out: Record<string, unknown> = {};
140
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
141
+ out[key] = canonicalize((value as Record<string, unknown>)[key]);
142
+ }
143
+ return out;
144
+ }
145
+ return value;
146
+ }
147
+
148
+ /* -------------------------------- codex: CLI shell-out -------------------------------- */
149
+
150
+ /** Pure argv builder (argv AFTER `codex`), exported for unit tests. */
151
+ export function codexAddArgs(name: string, cfg: McpServerConfig): string[] {
152
+ const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
153
+ return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
154
+ }
155
+
156
+ async function installCodex(name: string, cfg: McpServerConfig): Promise<InstallResult> {
157
+ if (!isOnPath("codex")) {
158
+ throw new Error("codex CLI not found on PATH — install Codex or pick a different harness");
159
+ }
160
+
161
+ // codex mcp add is not confirmed idempotent upstream, so probe first and
162
+ // remove-then-add when the name already exists.
163
+ const probe = spawnSync("codex", ["mcp", "get", name], { encoding: "utf8" });
164
+ const exists = probe.status === 0;
165
+
166
+ if (exists) {
167
+ const removed = spawnSync("codex", ["mcp", "remove", name], { encoding: "utf8" });
168
+ if (removed.status !== 0) {
169
+ throw new Error(`codex mcp remove ${name} failed: ${(removed.stderr || removed.stdout || "").trim()}`);
170
+ }
171
+ }
172
+
173
+ const addArgs = codexAddArgs(name, cfg);
174
+ const added = spawnSync("codex", addArgs, { encoding: "utf8" });
175
+ if (added.status !== 0) {
176
+ throw new Error(`codex ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
177
+ }
178
+
179
+ return {
180
+ harness: "codex",
181
+ method: "cli",
182
+ action: exists ? "updated" : "added",
183
+ detail: added.stdout?.trim() || undefined,
184
+ };
185
+ }
186
+
187
+ /* ------------------------------ opencode: CLI shell-out ------------------------------- */
188
+
189
+ /** Pure argv builder (argv AFTER `opencode`), exported for unit tests. */
190
+ export function opencodeAddArgs(name: string, cfg: McpServerConfig): string[] {
191
+ const envArgs = Object.entries(cfg.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
192
+ return ["mcp", "add", name, ...envArgs, "--", cfg.command, ...cfg.args];
193
+ }
194
+
195
+ async function installOpencode(name: string, cfg: McpServerConfig): Promise<InstallResult> {
196
+ if (!isOnPath("opencode")) {
197
+ throw new Error("opencode CLI not found on PATH — install OpenCode or pick a different harness");
198
+ }
199
+
200
+ const addArgs = opencodeAddArgs(name, cfg);
201
+ const added = spawnSync("opencode", addArgs, { encoding: "utf8" });
202
+ if (added.status !== 0) {
203
+ throw new Error(`opencode ${addArgs.join(" ")} failed: ${(added.stderr || added.stdout || "").trim()}`);
204
+ }
205
+
206
+ // `opencode mcp add` is a confirmed idempotent upsert, and telling "added"
207
+ // from "updated" cheaply would mean an extra `opencode mcp get` round trip
208
+ // whose output shape isn't confirmed; "added" is accurate enough either way
209
+ // since the end state (server registered with this config) is identical.
210
+ return {
211
+ harness: "opencode",
212
+ method: "cli",
213
+ action: "added",
214
+ detail: added.stdout?.trim() || undefined,
215
+ };
216
+ }
217
+
218
+ /* ---------------------------------- shared helpers ------------------------------------ */
219
+
220
+ function isOnPath(bin: string): boolean {
221
+ const finder = process.platform === "win32" ? "where" : "which";
222
+ const result = spawnSync(finder, [bin], { encoding: "utf8" });
223
+ return result.status === 0;
224
+ }
@@ -0,0 +1,412 @@
1
+ /**
2
+ * `cdp install` — the interactive-or-flags installer that ties the two sibling install modules
3
+ * together into one command:
4
+ *
5
+ * 1. registers the cdp-toolkit MCP stdio server into the user's chosen coding harness
6
+ * (claude / codex / opencode), via src/install/harness.ts, and
7
+ * 2. appends a shell alias that launches the user's chosen browser with the remote-debugging
8
+ * port open, via src/install/browser.ts (the user chose AUTO-APPEND to the shell rc).
9
+ *
10
+ * This module owns ONLY the orchestration and the human interaction (flag parsing, interactive
11
+ * prompts, confirmation, summary). The actual writes — harness config and shell rc — are delegated
12
+ * to the two sibling modules; nothing here reimplements them.
13
+ *
14
+ * Test seams: runInstaller takes an `io` object whose `configPath` / `homeDir` (harness config
15
+ * target) and `rcPath` (shell rc target) let a test point every write at a temp file, so the
16
+ * installer can be exercised end-to-end without touching the real ~/.claude.json or ~/.zshrc.
17
+ * `stdout` / `stderr` default to the real process streams.
18
+ */
19
+ import { createInterface } from "node:readline";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import {
23
+ aliasLine,
24
+ appendAliasToRc,
25
+ defaultAliasName,
26
+ detectBrowser,
27
+ detectShellRc,
28
+ type BrowserChoice,
29
+ type RcAppendResult,
30
+ } from "./browser.ts";
31
+ import {
32
+ installMcpServer,
33
+ type HarnessChoice,
34
+ type InstallResult,
35
+ type McpServerConfig,
36
+ } from "./harness.ts";
37
+
38
+ export interface InstallArgs {
39
+ harness?: HarnessChoice;
40
+ browser?: BrowserChoice;
41
+ port?: number;
42
+ name?: string;
43
+ alias?: boolean;
44
+ aliasName?: string;
45
+ yes?: boolean;
46
+ help?: boolean;
47
+ }
48
+
49
+ export interface InstallerIO {
50
+ /** Whether we're attached to an interactive terminal. Defaults to process.stdin.isTTY. */
51
+ isTTY?: boolean;
52
+ /** Harness config path override (claude only). Test seam — points the write at a temp file. */
53
+ configPath?: string;
54
+ /** os.homedir() override when deriving the default claude config path. Test seam. */
55
+ homeDir?: string;
56
+ /** Shell rc path override. Test seam — points the alias append at a temp file. */
57
+ rcPath?: string;
58
+ /** stdout sink. Defaults to process.stdout.write. */
59
+ stdout?: (s: string) => void;
60
+ /** stderr sink. Defaults to process.stderr.write. */
61
+ stderr?: (s: string) => void;
62
+ }
63
+
64
+ const DEFAULT_NAME = "cdp-toolkit";
65
+ const HARNESSES: readonly HarnessChoice[] = ["claude", "codex", "opencode"];
66
+ const BROWSERS: readonly BrowserChoice[] = ["arc", "chrome", "firefox"];
67
+
68
+ /* --------------------------------- pure: arg parsing --------------------------------- */
69
+
70
+ /** Default remote-debugging port per browser: firefox listens on 9223 here, chromium on 9222. */
71
+ function defaultPortFor(browser: BrowserChoice): number {
72
+ return browser === "firefox" ? 9223 : 9222;
73
+ }
74
+
75
+ function parseHarness(v: string | undefined): HarnessChoice {
76
+ if (v === undefined) throw new Error("--harness requires a value: claude, codex, or opencode");
77
+ if ((HARNESSES as readonly string[]).includes(v)) return v as HarnessChoice;
78
+ throw new Error(`--harness must be one of claude|codex|opencode (got '${v}')`);
79
+ }
80
+
81
+ function parseBrowserChoice(v: string | undefined): BrowserChoice {
82
+ if (v === undefined) throw new Error("--browser requires a value: arc, chrome, or firefox");
83
+ if ((BROWSERS as readonly string[]).includes(v)) return v as BrowserChoice;
84
+ throw new Error(`--browser must be one of arc|chrome|firefox (got '${v}')`);
85
+ }
86
+
87
+ function parsePort(v: string | undefined): number {
88
+ if (v === undefined) throw new Error("--port requires a value (an integer 1-65535)");
89
+ const n = Number(v);
90
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
91
+ throw new Error(`--port must be an integer 1-65535 (got '${v}')`);
92
+ }
93
+ return n;
94
+ }
95
+
96
+ /** Expand `--key=value` into `["--key", "value"]` so both forms parse; leaves everything else alone. */
97
+ function normalizeArgv(argv: string[]): string[] {
98
+ const out: string[] = [];
99
+ for (const t of argv) {
100
+ if (t.startsWith("--") && t.includes("=")) {
101
+ const eq = t.indexOf("=");
102
+ out.push(t.slice(0, eq), t.slice(eq + 1));
103
+ } else {
104
+ out.push(t);
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * PURE. Parse `cdp install` flags. Throws on an unknown option, a missing required value, or an
112
+ * out-of-range/enum-invalid value. Defaults: name="cdp-toolkit", alias=true.
113
+ */
114
+ export function parseInstallArgs(argv: string[]): InstallArgs {
115
+ const tokens = normalizeArgv(argv);
116
+ const out: InstallArgs = { name: DEFAULT_NAME, alias: true };
117
+ for (let i = 0; i < tokens.length; i++) {
118
+ const t = tokens[i]!;
119
+ switch (t) {
120
+ case "--help":
121
+ case "-h":
122
+ out.help = true;
123
+ break;
124
+ case "--yes":
125
+ case "-y":
126
+ out.yes = true;
127
+ break;
128
+ case "--no-alias":
129
+ out.alias = false;
130
+ break;
131
+ case "--alias":
132
+ out.alias = true;
133
+ break;
134
+ case "--harness":
135
+ out.harness = parseHarness(tokens[++i]);
136
+ break;
137
+ case "--browser":
138
+ out.browser = parseBrowserChoice(tokens[++i]);
139
+ break;
140
+ case "--port":
141
+ out.port = parsePort(tokens[++i]);
142
+ break;
143
+ case "--name": {
144
+ const v = tokens[++i];
145
+ if (v === undefined) throw new Error("--name requires a value");
146
+ out.name = v;
147
+ break;
148
+ }
149
+ case "--alias-name": {
150
+ const v = tokens[++i];
151
+ if (v === undefined) throw new Error("--alias-name requires a value");
152
+ out.aliasName = v;
153
+ break;
154
+ }
155
+ default:
156
+ throw new Error(`cdp install: unknown option '${t}' (run \`cdp install --help\`)`);
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /* --------------------------------- pure: server config --------------------------------- */
163
+
164
+ /**
165
+ * PURE. Build the MCP stdio server config for a given browser + debug port.
166
+ *
167
+ * The server always runs `npx -y cdp-toolkit`. Only the env differs by browser, and even then
168
+ * chrome and arc are IDENTICAL: both are driven over CDP, and the driver just talks CDP to
169
+ * whatever is listening on CDP_BASE — the arc-vs-chrome distinction lives entirely in the shell
170
+ * ALIAS (which .app it launches), not in the server env. Firefox is the odd one out: it is driven
171
+ * over WebDriver BiDi, selected by CDP_BROWSER=firefox with the port in CDP_FIREFOX_ENDPOINT.
172
+ */
173
+ export function composeServerConfig(browser: BrowserChoice, port: number): McpServerConfig {
174
+ const env: Record<string, string> =
175
+ browser === "firefox"
176
+ ? { CDP_BROWSER: "firefox", CDP_FIREFOX_ENDPOINT: String(port) }
177
+ : { CDP_BASE: `http://127.0.0.1:${port}` };
178
+ return { command: "npx", args: ["-y", "cdp-toolkit"], env };
179
+ }
180
+
181
+ /* --------------------------------- help text --------------------------------- */
182
+
183
+ export function installHelpText(): string {
184
+ return `cdp install — register the cdp-toolkit MCP server with your coding harness and add a
185
+ browser-launch alias (with the remote-debugging port open) to your shell rc.
186
+
187
+ Usage:
188
+ cdp install [--harness <claude|codex|opencode>] [--browser <arc|chrome|firefox>]
189
+ [--port <n>] [--name <server-name>] [--alias-name <name>]
190
+ [--no-alias] [--yes|-y] [--help|-h]
191
+
192
+ Options:
193
+ --harness <h> Coding harness to register the MCP server with: claude, codex, opencode.
194
+ --browser <b> Browser the alias launches: arc, chrome, firefox.
195
+ --port <n> Remote-debugging port (1-65535). Default: 9222 (chrome/arc), 9223 (firefox).
196
+ --name <name> MCP server name to register. Default: ${DEFAULT_NAME}.
197
+ --alias-name <n> Shell alias name. Default: cdp-<browser> (e.g. cdp-firefox).
198
+ --no-alias Register the MCP server only; skip writing the shell alias.
199
+ --yes, -y Skip the confirmation prompt (proceed immediately).
200
+ --help, -h Show this help.
201
+
202
+ Interactive: run \`cdp install\` in a terminal with no flags and it prompts for harness, browser,
203
+ and port, then asks before writing anything.
204
+
205
+ Non-interactive (piped/CI): pass at least --harness and --browser (port defaults per browser); a
206
+ missing required value fails fast with usage instead of hanging on stdin.`;
207
+ }
208
+
209
+ /* --------------------------------- interactive prompts --------------------------------- */
210
+
211
+ /** One question over a fresh readline interface, closed before returning. Reached only when TTY. */
212
+ async function ask(question: string): Promise<string> {
213
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
214
+ try {
215
+ return await new Promise<string>((resolve) => rl.question(question, resolve));
216
+ } finally {
217
+ rl.close();
218
+ }
219
+ }
220
+
221
+ async function promptMenu<T extends string>(label: string, options: readonly T[]): Promise<T> {
222
+ const lines = [label];
223
+ options.forEach((o, i) => lines.push(` ${i + 1}) ${o}`));
224
+ process.stdout.write(`${lines.join("\n")}\n`);
225
+ for (;;) {
226
+ const ans = (await ask(`Enter 1-${options.length}: `)).trim();
227
+ const n = Number(ans);
228
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]!;
229
+ process.stdout.write(`Please enter a number between 1 and ${options.length}.\n`);
230
+ }
231
+ }
232
+
233
+ async function promptPort(def: number): Promise<number> {
234
+ for (;;) {
235
+ const ans = (await ask(`Remote-debugging port [${def}]: `)).trim();
236
+ if (ans === "") return def;
237
+ const n = Number(ans);
238
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) return n;
239
+ process.stdout.write("Please enter an integer between 1 and 65535.\n");
240
+ }
241
+ }
242
+
243
+ /* --------------------------------- helpers --------------------------------- */
244
+
245
+ /** Where the harness config lands, for the pre-write plan. claude → a file path; others → the CLI. */
246
+ function harnessTargetDesc(harness: HarnessChoice, io: InstallerIO): string {
247
+ if (harness === "claude") {
248
+ return io.configPath ?? join(io.homeDir ?? homedir(), ".claude.json");
249
+ }
250
+ return `via \`${harness} mcp add\``;
251
+ }
252
+
253
+ /** The env var that overrides binary detection for a given browser (see src/install/browser.ts). */
254
+ function binEnvVar(browser: BrowserChoice): string {
255
+ return `${browser.toUpperCase()}_BIN`;
256
+ }
257
+
258
+ /* --------------------------------- orchestration --------------------------------- */
259
+
260
+ /**
261
+ * Run the installer. Returns a process exit code (0 success, 1 failure). Never throws for an
262
+ * expected condition (bad flags, missing CLI, missing required option in a pipe) — those are
263
+ * reported to stderr and return 1.
264
+ */
265
+ export async function runInstaller(argv: string[], io: InstallerIO = {}): Promise<number> {
266
+ const write = io.stdout ?? ((s: string) => void process.stdout.write(s));
267
+ const writeErr = io.stderr ?? ((s: string) => void process.stderr.write(s));
268
+ const isTTY = io.isTTY ?? Boolean(process.stdin.isTTY);
269
+
270
+ // 1. parse
271
+ let args: InstallArgs;
272
+ try {
273
+ args = parseInstallArgs(argv);
274
+ } catch (err) {
275
+ writeErr(`${(err as Error).message}\n\n`);
276
+ writeErr(`${installHelpText()}\n`);
277
+ return 1;
278
+ }
279
+
280
+ if (args.help) {
281
+ write(`${installHelpText()}\n`);
282
+ return 0;
283
+ }
284
+
285
+ // 2. resolve harness/browser/port — prompt when interactive, fail fast in a pipe.
286
+ let harness = args.harness;
287
+ let browser = args.browser;
288
+ let port = args.port;
289
+
290
+ if (isTTY) {
291
+ if (harness === undefined) harness = await promptMenu("Which coding harness?", HARNESSES);
292
+ if (browser === undefined) browser = await promptMenu("Which browser?", BROWSERS);
293
+ if (port === undefined) port = await promptPort(defaultPortFor(browser));
294
+ } else {
295
+ const missing: string[] = [];
296
+ if (harness === undefined) missing.push("--harness");
297
+ if (browser === undefined) missing.push("--browser");
298
+ if (missing.length > 0) {
299
+ writeErr(`cdp install: missing required option(s) ${missing.join(", ")} in non-interactive mode.\n`);
300
+ writeErr("Run `cdp install` in a terminal to be prompted, or pass the option(s) explicitly.\n\n");
301
+ writeErr(`${installHelpText()}\n`);
302
+ return 1;
303
+ }
304
+ if (port === undefined) port = defaultPortFor(browser!);
305
+ }
306
+
307
+ if (harness === undefined || browser === undefined || port === undefined) {
308
+ // Unreachable: both branches above set all three or return. Defensive for the type-checker.
309
+ writeErr("cdp install: internal error resolving options\n");
310
+ return 1;
311
+ }
312
+
313
+ const name = args.name ?? DEFAULT_NAME;
314
+ const writeAlias = args.alias !== false;
315
+ const rcPath = io.rcPath ?? detectShellRc();
316
+ const resolvedAliasName = args.aliasName ?? defaultAliasName(browser);
317
+ const line = aliasLine({ choice: browser, port, aliasName: args.aliasName });
318
+
319
+ // 3. confirm the plan (interactive only; --yes or a fully-specified pipe run proceeds).
320
+ if (!args.yes && isTTY) {
321
+ const plan = [
322
+ "",
323
+ "About to:",
324
+ ` - Register MCP server "${name}" with ${harness} (${harnessTargetDesc(harness, io)})`,
325
+ writeAlias
326
+ ? ` - Append a shell alias to ${rcPath}:\n ${line}`
327
+ : " - (skipping shell alias: --no-alias)",
328
+ "",
329
+ ].join("\n");
330
+ write(`${plan}\n`);
331
+ const ans = (await ask("Proceed? [y/N] ")).trim();
332
+ if (!/^y(es)?$/i.test(ans)) {
333
+ write("Aborted. Nothing was written.\n");
334
+ return 0;
335
+ }
336
+ }
337
+
338
+ // 4. register the MCP server.
339
+ let harnessResult: InstallResult;
340
+ try {
341
+ harnessResult = await installMcpServer(harness, name, composeServerConfig(browser, port), {
342
+ configPath: io.configPath,
343
+ homeDir: io.homeDir,
344
+ });
345
+ } catch (err) {
346
+ writeErr(`cdp install: could not register the MCP server with ${harness}: ${(err as Error).message}\n`);
347
+ return 1;
348
+ }
349
+
350
+ // 5. append the shell alias (unless --no-alias). A missing browser binary is a WARNING, not a
351
+ // failure: the alias is still written, but we tell the user it won't launch until they fix it.
352
+ let rcResult: RcAppendResult | undefined;
353
+ let browserFound = true;
354
+ if (writeAlias) {
355
+ rcResult = await appendAliasToRc(rcPath, line);
356
+ browserFound = detectBrowser(browser).found;
357
+ if (!browserFound) {
358
+ writeErr(
359
+ `\nWarning: could not find the ${browser} binary at its expected location. The alias was ` +
360
+ `written anyway, but \`${resolvedAliasName}\` will not launch until the binary is found.\n` +
361
+ ` Fix: set ${binEnvVar(browser)}=/path/to/${browser} and re-run \`cdp install\`, or edit the\n` +
362
+ ` alias line in ${rcPath} to point at the real binary.\n`,
363
+ );
364
+ }
365
+ }
366
+
367
+ // 6. summary + next steps.
368
+ const summary: string[] = [
369
+ "",
370
+ "cdp-toolkit install complete.",
371
+ "",
372
+ `Harness: ${harness}`,
373
+ ` server name: ${name}`,
374
+ harnessResult.path ? ` config: ${harnessResult.path}` : ` registered: ${harnessTargetDesc(harness, io)}`,
375
+ ` method: ${harnessResult.method}`,
376
+ ` action: ${harnessResult.action}${harnessResult.detail ? ` (${harnessResult.detail})` : ""}`,
377
+ ];
378
+
379
+ if (writeAlias && rcResult) {
380
+ summary.push(
381
+ "",
382
+ `Browser alias: ${resolvedAliasName} -> ${rcResult.rcPath}`,
383
+ ` action: ${rcResult.action}`,
384
+ ` line: ${line}`,
385
+ );
386
+ if (!browserFound) summary.push(` note: ${browser} binary not found yet (see warning above)`);
387
+ } else {
388
+ summary.push("", "Browser alias: skipped (--no-alias).");
389
+ }
390
+
391
+ summary.push("", "Next steps:");
392
+ if (writeAlias && rcResult) {
393
+ summary.push(
394
+ ` 1. Load the alias into your shell: source ${rcResult.rcPath} (or open a new terminal)`,
395
+ ` 2. Launch the browser with the debug port open: ${resolvedAliasName}`,
396
+ ` 3. Restart / reconnect ${harness} so it picks up the "${name}" MCP server.`,
397
+ );
398
+ if (browser === "firefox") {
399
+ summary.push(
400
+ "",
401
+ "Firefox note: the alias includes --marionette, which cdp-toolkit needs for its orphan",
402
+ "auto-recovery path — don't remove it.",
403
+ );
404
+ }
405
+ } else {
406
+ summary.push(` 1. Restart / reconnect ${harness} so it picks up the "${name}" MCP server.`);
407
+ }
408
+ summary.push("");
409
+
410
+ write(`${summary.join("\n")}\n`);
411
+ return 0;
412
+ }