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,211 @@
1
+ /**
2
+ * Browser half of `cdp install`: binary detection, debug-launch alias generation, and an
3
+ * idempotent shell-rc append. The other half (harness/MCP config) lives in a sibling module and
4
+ * is not this file's concern — this file only knows about browsers and shell rc files.
5
+ *
6
+ * Zero runtime dependencies beyond node:fs, node:fs/promises, node:os, node:path.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { delimiter, dirname, join } from "node:path";
12
+
13
+ export type BrowserChoice = "arc" | "chrome" | "firefox";
14
+
15
+ export interface BrowserInfo {
16
+ choice: BrowserChoice;
17
+ /**
18
+ * The binary path or command name to launch. When a PATH-relative name (e.g. "google-chrome")
19
+ * is the chosen candidate, this is returned UNRESOLVED — as the bare name, not an absolute path
20
+ * scanned off today's PATH — so the alias stays portable and lets the shell re-resolve it against
21
+ * PATH at invocation time rather than pinning it to wherever it happened to be found right now.
22
+ */
23
+ binary: string;
24
+ found: boolean;
25
+ }
26
+
27
+ export interface AliasOptions {
28
+ choice: BrowserChoice;
29
+ port: number;
30
+ aliasName?: string;
31
+ profileDir?: string;
32
+ }
33
+
34
+ export interface RcAppendResult {
35
+ rcPath: string;
36
+ action: "created-block" | "replaced-block" | "unchanged";
37
+ }
38
+
39
+ /* ------------------------------- binary detection ------------------------------- */
40
+
41
+ /**
42
+ * Per-choice candidate lists, built fresh on every call so env var overrides (CHROME_BIN /
43
+ * ARC_BIN / FIREFOX_BIN) are read live rather than captured once at module load — important for
44
+ * tests that set/restore these env vars around a single call.
45
+ *
46
+ * macOS-first, Linux fallback. Order mirrors the spec: env override, then the macOS .app bundle
47
+ * path, then PATH-relative command names. Arc is macOS-mostly, so found=false on Linux (nothing
48
+ * in this list resolves there) is expected, not a bug.
49
+ */
50
+ const CANDIDATE_BUILDERS: Record<BrowserChoice, () => Array<string | undefined>> = {
51
+ chrome: () => [
52
+ process.env.CHROME_BIN,
53
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
54
+ "google-chrome",
55
+ "google-chrome-stable",
56
+ "chromium",
57
+ ],
58
+ arc: () => [process.env.ARC_BIN, "/Applications/Arc.app/Contents/MacOS/Arc", "arc"],
59
+ firefox: () => [
60
+ process.env.FIREFOX_BIN,
61
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
62
+ "firefox",
63
+ "/usr/bin/firefox",
64
+ ],
65
+ };
66
+
67
+ /** Scan $PATH for `cmd`. Used only to decide `found` for a bare candidate — never to rewrite it. */
68
+ function existsOnPath(cmd: string): boolean {
69
+ const pathEnv = process.env.PATH;
70
+ if (!pathEnv) return false;
71
+ for (const dir of pathEnv.split(delimiter)) {
72
+ if (!dir) continue;
73
+ if (existsSync(join(dir, cmd))) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Mirrors the resolveBinary logic in src/bidi/launch.ts: an ABSOLUTE candidate counts only if it
80
+ * exists on disk (otherwise a hardcoded macOS path would shadow a real PATH fallback on Linux and
81
+ * yield a not-found result at spawn time); a bare PATH name is returned as-is, unresolved, for the
82
+ * shell to resolve — but IS checked against $PATH here purely to set an accurate `found` flag.
83
+ */
84
+ function resolveCandidate(candidates: Array<string | undefined>): { binary: string; found: boolean } {
85
+ let lastDefined = "";
86
+ for (const c of candidates) {
87
+ if (!c) continue;
88
+ lastDefined = c;
89
+ if (c.startsWith("/")) {
90
+ if (existsSync(c)) return { binary: c, found: true };
91
+ continue;
92
+ }
93
+ if (existsOnPath(c)) return { binary: c, found: true };
94
+ }
95
+ return { binary: lastDefined, found: false };
96
+ }
97
+
98
+ export function detectBrowser(choice: BrowserChoice): BrowserInfo {
99
+ const { binary, found } = resolveCandidate(CANDIDATE_BUILDERS[choice]());
100
+ return { choice, binary, found };
101
+ }
102
+
103
+ /* ------------------------------- alias generation ------------------------------- */
104
+
105
+ function defaultProfileDir(choice: BrowserChoice): string {
106
+ // Literal $HOME (not resolved via os.homedir()) so it survives into the alias's stored text and
107
+ // expands fresh every time the alias runs — see the two-phase alias substitution note below.
108
+ return `$HOME/.cdp-toolkit/${choice}-profile`;
109
+ }
110
+
111
+ export function defaultAliasName(choice: BrowserChoice): string {
112
+ return `cdp-${choice}`;
113
+ }
114
+
115
+ /**
116
+ * The launch flags only (no binary). Chrome and Arc are both Chromium-based and Arc accepts
117
+ * Chrome's flags, so they share a shape: --remote-debugging-port plus a DEDICATED
118
+ * --user-data-dir. The dedicated profile is required, not cosmetic — Chrome silently ignores
119
+ * --remote-debugging-port when it hands off to an already-running instance on the default
120
+ * profile, so the port never opens and there is no error, just a hung attach later.
121
+ *
122
+ * Firefox's --marionette flag is likewise required, not optional: cdp-toolkit's Firefox
123
+ * orphan-recovery path (src/bidi/launch.ts's header comment) uses the Marionette side channel to
124
+ * recover a dropped BiDi connection, and that recovery cannot fire without it.
125
+ */
126
+ export function browserLaunchArgs(o: AliasOptions): string[] {
127
+ const profileDir = o.profileDir ?? defaultProfileDir(o.choice);
128
+ if (o.choice === "firefox") {
129
+ return ["--remote-debugging-port", String(o.port), "--marionette", "--no-remote", "--profile", profileDir];
130
+ }
131
+ return [`--remote-debugging-port=${o.port}`, `--user-data-dir=${profileDir}`];
132
+ }
133
+
134
+ /** Quote only if needed: wrapping a plain token in quotes is harmless but unquoted-with-a-space breaks word splitting. */
135
+ function shellQuoteIfSpaced(token: string): string {
136
+ return /\s/.test(token) ? `"${token}"` : token;
137
+ }
138
+
139
+ /**
140
+ * A single `alias name='<binary> <flags>'` line. The whole value is wrapped in single quotes (so
141
+ * it is stored literally, unexpanded, when the alias is DEFINED); the binary is additionally
142
+ * double-quoted when it contains a space (the macOS "Google Chrome.app" / future spaced paths),
143
+ * because shell alias substitution is a two-phase process — text substitution first, then a fresh
144
+ * parse of the substituted text — so the inner double quotes and the bare $HOME in the profile
145
+ * dir both take effect correctly at every INVOCATION of the alias, not at definition time.
146
+ */
147
+ export function aliasLine(o: AliasOptions): string {
148
+ const name = o.aliasName ?? defaultAliasName(o.choice);
149
+ const { binary } = detectBrowser(o.choice);
150
+ const parts = [shellQuoteIfSpaced(binary), ...browserLaunchArgs(o)];
151
+ return `alias ${name}='${parts.join(" ")}'`;
152
+ }
153
+
154
+ /* ------------------------------- shell rc detection ------------------------------- */
155
+
156
+ export function detectShellRc(): string {
157
+ const shell = process.env.SHELL ?? "";
158
+ if (shell.endsWith("zsh")) return join(homedir(), ".zshrc");
159
+ if (shell.endsWith("bash")) return join(homedir(), ".bashrc");
160
+ return join(homedir(), process.platform === "darwin" ? ".zshrc" : ".bashrc");
161
+ }
162
+
163
+ /* ------------------------------- idempotent rc append ------------------------------- */
164
+
165
+ const MARKER_START = "# >>> cdp-toolkit alias >>>";
166
+ const MARKER_END = "# <<< cdp-toolkit alias <<<";
167
+
168
+ function ensureTrailingNewline(s: string): string {
169
+ return s.endsWith("\n") ? s : `${s}\n`;
170
+ }
171
+
172
+ /**
173
+ * Idempotently write ONE marker block into rcPath, replacing its body in place on re-runs rather
174
+ * than accumulating duplicates. The wizard owns composing aliasBlockBody (e.g. joining several
175
+ * browsers' alias lines with "\n") — this function only manages the single marker block, so
176
+ * calling it again with a fuller body updates the same block rather than adding a second one.
177
+ */
178
+ export async function appendAliasToRc(rcPath: string, aliasBlockBody: string): Promise<RcAppendResult> {
179
+ let existing = "";
180
+ try {
181
+ existing = await readFile(rcPath, "utf8");
182
+ } catch (err) {
183
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
184
+ }
185
+
186
+ const block = `${MARKER_START}\n${aliasBlockBody}\n${MARKER_END}`;
187
+
188
+ const startIdx = existing.indexOf(MARKER_START);
189
+ const endIdx = existing.indexOf(MARKER_END);
190
+
191
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
192
+ const endOfEndLine = existing.indexOf("\n", endIdx);
193
+ const before = existing.slice(0, startIdx);
194
+ const after = endOfEndLine === -1 ? "" : existing.slice(endOfEndLine + 1);
195
+ const existingBlock = existing.slice(startIdx, endOfEndLine === -1 ? existing.length : endOfEndLine);
196
+
197
+ if (existingBlock === block) {
198
+ return { rcPath, action: "unchanged" };
199
+ }
200
+
201
+ const next = ensureTrailingNewline(`${before}${block}\n${after}`);
202
+ await writeFile(rcPath, next, "utf8");
203
+ return { rcPath, action: "replaced-block" };
204
+ }
205
+
206
+ await mkdir(dirname(rcPath), { recursive: true });
207
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
208
+ const next = `${existing}${sep}${block}\n`;
209
+ await writeFile(rcPath, next, "utf8");
210
+ return { rcPath, action: "created-block" };
211
+ }
@@ -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
+ }