cdp-toolkit 1.10.0 → 2.0.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,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
+ }