cdp-toolkit 1.10.0 → 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,265 @@
1
+ /**
2
+ * Firefox BiDi SESSION-slot lease: cross-process coordination for the ONE
3
+ * WebDriver-BiDi session a single Firefox instance will serve.
4
+ *
5
+ * WHY THIS EXISTS. Firefox admits exactly one active WebDriver-BiDi session per
6
+ * browser. When two cdp-toolkit MCP-server processes ATTACH to the same
7
+ * user-launched Firefox endpoint (e.g. ws://127.0.0.1:9223/session), they both
8
+ * want that one slot. Today the second process calls session.new and gets a
9
+ * hard "Maximum number of active sessions" with no way to tell a LIVE holder
10
+ * (another agent legitimately driving the browser) from a DEAD one (a crashed
11
+ * process that left the slot occupied). This module makes that distinction on
12
+ * disk, so a live holder is respected and a dead holder's orphan is reclaimed.
13
+ *
14
+ * WHY A FILE, AND WHY KEYED BY ENDPOINT. Same reasoning as the tab leases in
15
+ * ../leases.ts: two MCP servers are two processes, so in-process state would
16
+ * guard the wrong boundary. The tab lease is keyed by (backend, targetId)
17
+ * because it owns a tab; this lease is keyed by the BiDi ws ENDPOINT string
18
+ * because it owns the browser's single session slot, which is a property of the
19
+ * endpoint, not of any tab. One lock file per endpoint.
20
+ *
21
+ * WHY NO NONCE/TOKEN. A tab lease mints a nonce because a caller passes a token
22
+ * back and a reclamation must invalidate the superseded one. Nobody passes a
23
+ * session token back here: the slot is owned by whichever process currently
24
+ * holds the live BiDi connection and is released on dispose. Ownership is
25
+ * therefore identified by (pid, createdAt) of the record we wrote, which is
26
+ * enough to keep release from unlinking a record another process created after
27
+ * stealing the slot from us.
28
+ *
29
+ * WHY STALENESS IS PURE PID-LIVENESS, WITH NO TTL. A long-lived MCP server can
30
+ * legitimately hold the slot for hours, so a time-based expiry would reap a
31
+ * healthy holder. The only thing that makes a holder illegitimate is that its
32
+ * process is gone. So the sole staleness test is isPidAlive (reused from
33
+ * ../leases.ts): pid alive -> legitimate holder; pid dead -> orphan to steal.
34
+ *
35
+ * ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
36
+ * ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
37
+ * against a racing process for free, with no registry lock.
38
+ */
39
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+ import { isPidAlive, leaseDir } from "../leases.ts";
42
+
43
+ /**
44
+ * The on-disk record for a held session slot. Keyed by endpoint (one file per
45
+ * endpoint). No nonce: see the header. marionettePort is carried so a stealer
46
+ * can surface which Marionette port an orphaned session was bound to when it
47
+ * has to force-clear it.
48
+ */
49
+ export interface SessionSlotRecord {
50
+ endpoint: string;
51
+ /** Owning MCP server process. The one field staleness turns on. */
52
+ pid: number;
53
+ /** Caller-supplied agent label, surfaced in busy/steal reporting. */
54
+ label: string;
55
+ createdAt: number;
56
+ /** Optional Marionette control port bound to this Firefox, when known. */
57
+ marionettePort?: number;
58
+ }
59
+
60
+ /**
61
+ * What a holder keeps so it can release ITS OWN record and never another
62
+ * process's. endpoint locates the file; (pid, createdAt) identify our record.
63
+ */
64
+ export interface SessionSlotHandle {
65
+ endpoint: string;
66
+ pid: number;
67
+ createdAt: number;
68
+ }
69
+
70
+ export interface AcquireResult {
71
+ handle: SessionSlotHandle;
72
+ /**
73
+ * Set only when we STOLE a dead holder's slot. Its presence tells the caller
74
+ * that Firefox may still believe an orphaned BiDi session is active, so the
75
+ * caller must force-clear that session (e.g. via Marionette) before it calls
76
+ * session.new, or Firefox will still refuse with "Maximum number of active
77
+ * sessions". Absent on a clean first acquire.
78
+ */
79
+ staleHolder?: SessionSlotRecord;
80
+ }
81
+
82
+ export interface AcquireOptions {
83
+ label: string;
84
+ marionettePort?: number;
85
+ /** How long to keep polling a LIVE holder before giving up. Default sessionWaitMs(). */
86
+ waitMs?: number;
87
+ /** Injectable clock for the record's createdAt, so tests are deterministic. */
88
+ now?: number;
89
+ /** Poll interval while waiting on a live holder. Default DEFAULT_POLL_MS. */
90
+ pollMs?: number;
91
+ }
92
+
93
+ /** Env-overridable wait ceiling, in ms. */
94
+ const DEFAULT_SESSION_WAIT_MS = 10_000;
95
+ /** Poll cadence while a live holder is blocking us. */
96
+ const DEFAULT_POLL_MS = 250;
97
+
98
+ /** Same substitution rule as ../leases.ts safeId, applied to the endpoint URL. */
99
+ function safeEndpoint(endpoint: string): string {
100
+ return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
101
+ }
102
+
103
+ /** One lock file per endpoint, a sibling of the tab lease files in ARTIFACT_DIR. */
104
+ export function sessionSlotFile(endpoint: string): string {
105
+ return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
106
+ }
107
+
108
+ /** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
109
+ * that is not a finite number > 0 falls back to the default. */
110
+ export function sessionWaitMs(): number {
111
+ const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
112
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
113
+ }
114
+
115
+ /**
116
+ * Thrown when the slot is held by a LIVE process and did not free up within the
117
+ * wait window (or the attempt cap was hit). Names the live holder so the caller
118
+ * can decide to wait and retry or point this server at a different browser.
119
+ */
120
+ export class SessionSlotBusyError extends Error {
121
+ readonly endpoint: string;
122
+ readonly holderPid?: number;
123
+ readonly holderLabel?: string;
124
+ constructor(endpoint: string, holder?: SessionSlotRecord) {
125
+ const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
126
+ super(
127
+ `Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` +
128
+ `Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` +
129
+ `by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` +
130
+ `this server at a different endpoint/browser.`,
131
+ );
132
+ this.name = "SessionSlotBusyError";
133
+ this.endpoint = endpoint;
134
+ if (holder) {
135
+ this.holderPid = holder.pid;
136
+ this.holderLabel = holder.label;
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Read the slot record, or undefined if the slot is genuinely free.
143
+ *
144
+ * Mirrors ../leases.ts readLease's fail-closed contract: ENOENT is "free" and
145
+ * returns undefined; any OTHER errno (EACCES, EIO, EISDIR, EMFILE...) is
146
+ * rethrown rather than reported as absent, because reporting "free" for a file
147
+ * that merely could not be read would admit a second process to a slot that is
148
+ * very much held. A record that reads but does not PARSE is treated as absent,
149
+ * deliberately: a corrupt record can never become readable, and failing closed
150
+ * on it would brick the endpoint with no in-product recovery, whereas an errno
151
+ * is transient.
152
+ */
153
+ export async function readSessionSlot(endpoint: string): Promise<SessionSlotRecord | undefined> {
154
+ let raw: string;
155
+ try {
156
+ raw = await readFile(sessionSlotFile(endpoint), "utf8");
157
+ } catch (err) {
158
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; // genuinely free
159
+ throw err; // unreadable is not absent: fail closed
160
+ }
161
+ try {
162
+ const rec = JSON.parse(raw) as SessionSlotRecord;
163
+ if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
164
+ return rec;
165
+ }
166
+ } catch {
167
+ /* unparseable: fall through to undefined, see the doc comment above */
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Take the endpoint's session slot, coordinating with any other process.
174
+ *
175
+ * Returns { handle } on a clean acquire. Returns { handle, staleHolder } when
176
+ * the previous holder's process was dead and we stole its orphaned slot (the
177
+ * caller must then force-clear Firefox's orphaned session before session.new).
178
+ * Throws SessionSlotBusyError when a LIVE holder does not free the slot inside
179
+ * the wait window.
180
+ */
181
+ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions): Promise<AcquireResult> {
182
+ const waitMs = opts.waitMs ?? sessionWaitMs();
183
+ const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
184
+ // opts.now stamps OUR record deterministically for tests; the wait deadline
185
+ // uses real time (with real sleeps) so a small waitMs actually times out.
186
+ const createdAt = opts.now ?? Date.now();
187
+ const file = sessionSlotFile(endpoint);
188
+
189
+ const record: SessionSlotRecord = {
190
+ endpoint,
191
+ pid: process.pid,
192
+ label: opts.label,
193
+ createdAt,
194
+ };
195
+ if (opts.marionettePort !== undefined) record.marionettePort = opts.marionettePort;
196
+ const handle: SessionSlotHandle = { endpoint, pid: process.pid, createdAt };
197
+ const payload = JSON.stringify(record);
198
+
199
+ await mkdir(leaseDir(), { recursive: true });
200
+
201
+ const deadline = Date.now() + waitMs;
202
+ // Bounds the immediate-retry paths (released-mid-race, steal-raced) so a
203
+ // pathological create/release partner can never spin us forever. The live
204
+ // holder is bounded by `deadline` instead; this cap is generous relative to
205
+ // it so it only bites on pathology, never on ordinary waiting.
206
+ const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
207
+
208
+ let attempts = 0;
209
+ let lastLiveHolder: SessionSlotRecord | undefined;
210
+
211
+ while (true) {
212
+ if (attempts >= maxAttempts) throw new SessionSlotBusyError(endpoint, lastLiveHolder);
213
+ attempts++;
214
+
215
+ // 1. Try to take the slot outright.
216
+ try {
217
+ await writeFile(file, payload, { flag: "wx" });
218
+ return { handle };
219
+ } catch (err) {
220
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
221
+ }
222
+
223
+ // 2. Occupied. Read the holder to classify it.
224
+ const holder = await readSessionSlot(endpoint);
225
+ if (!holder) continue; // released between our create and our read: retry immediately
226
+
227
+ if (!isPidAlive(holder.pid)) {
228
+ // 3. DEAD holder: orphan. Steal it — unlink, then re-create OUR record.
229
+ await unlink(file).catch(() => undefined);
230
+ try {
231
+ await writeFile(file, payload, { flag: "wx" });
232
+ return { handle, staleHolder: holder };
233
+ } catch (err) {
234
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
235
+ continue; // steal raced another process: retry immediately
236
+ }
237
+ }
238
+
239
+ // 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
240
+ lastLiveHolder = holder;
241
+ const remaining = deadline - Date.now();
242
+ if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
243
+ await sleep(Math.min(pollMs, remaining));
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Release the slot, but ONLY if the record on disk is still the one we wrote.
249
+ *
250
+ * A record is ours iff its pid is this process AND its createdAt matches the
251
+ * handle. That guard is what stops us from unlinking a record another process
252
+ * created after stealing the slot from us (dead-pid steal), which would hand
253
+ * two processes the slot at once. Idempotent: a missing record is released:false.
254
+ */
255
+ export async function releaseSessionSlot(handle: SessionSlotHandle): Promise<{ released: boolean }> {
256
+ const current = await readSessionSlot(handle.endpoint);
257
+ if (!current) return { released: false };
258
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { released: false };
259
+ await unlink(sessionSlotFile(handle.endpoint)).catch(() => undefined); // swallow release races
260
+ return { released: true };
261
+ }
262
+
263
+ function sleep(ms: number): Promise<void> {
264
+ return new Promise((resolve) => setTimeout(resolve, ms));
265
+ }
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ import { toolAvailability } from "./capabilities.ts";
28
28
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
29
29
  import { leaseFromArgs, withLeaseScope } from "./leases.ts";
30
30
  import { MANIFEST } from "./manifest.ts";
31
+ import { runInstaller } from "./install/wizard.ts";
31
32
 
32
33
  const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
33
34
 
@@ -210,6 +211,15 @@ function printToolHelp(name: ToolName): void {
210
211
 
211
212
  async function main(): Promise<number> {
212
213
  const rawArgv = process.argv.slice(2);
214
+
215
+ // `install` is a CLI-ONLY subcommand (the `cdp install` wizard), not an MCP tool: it is not in
216
+ // TOOLS/MANIFEST and never appears in --list or tools/list. Dispatch it FIRST — before --browser
217
+ // resolution, --help, and tool dispatch — so `cdp install --help` reaches the installer's own
218
+ // help rather than the global USAGE, and so nothing below can reinterpret its flags.
219
+ if (rawArgv[0] === "install") {
220
+ return await runInstaller(rawArgv.slice(1));
221
+ }
222
+
213
223
  const { kind: browser, endpoint } = resolveBackend(rawArgv);
214
224
  const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
215
225
 
@@ -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
+ }