contextspin 0.1.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,757 @@
1
+ // src/inject/patcher.js — EXPERIMENTAL spinner-word patcher for Claude Code installs.
2
+ //
3
+ // ============================================================================
4
+ // EXPERIMENTAL / FRAGILE — READ BEFORE TOUCHING
5
+ // ============================================================================
6
+ // This module rewrites the hardcoded spinner-word array inside a Claude Code
7
+ // install so the "Flibbertigibbeting..."-style gerunds become your live context
8
+ // snippets. It supports two install forms:
9
+ //
10
+ // * TEXT install (minified cli.js): the array literal can be freely rewritten;
11
+ // file length may change.
12
+ // * BINARY install (Bun-compiled native executable, ELF/Mach-O/PE): edits MUST
13
+ // be LENGTH-PRESERVING. Changing the byte length would shift section offsets
14
+ // baked into the container and corrupt the executable. We therefore replace
15
+ // the array in place and PAD with spaces to keep the exact original byte
16
+ // length, dropping words if the replacement would not otherwise fit. On
17
+ // macOS the binary is re-signed ad-hoc (`codesign -s - -f`) afterwards.
18
+ //
19
+ // Detection is MARKER-BASED: we look for an array literal containing >= 3 known
20
+ // marker words. We NEVER key off the variable name — the minifier renames it on
21
+ // every release.
22
+ //
23
+ // IMPORTANT: Claude Code auto-updates OVERWRITE this patch. The patch is also
24
+ // moot until Claude Code is FULLY RESTARTED (a running process keeps the old
25
+ // code in memory). installPatcher() emits a shell wrapper that re-applies the
26
+ // patch before launching claude, which self-heals across updates.
27
+ //
28
+ // node-lief (optional) is only needed to fully crack/repack the Bun container.
29
+ // Stage 1 does best-effort in-place buffer replacement and SKIPS any binary it
30
+ // cannot safely edit, recommending claude-depester for those.
31
+ // ============================================================================
32
+
33
+ import fs from "node:fs";
34
+ import fsp from "node:fs/promises";
35
+ import path from "node:path";
36
+ import os from "node:os";
37
+ import { execSync, spawnSync } from "node:child_process";
38
+ import { fileURLToPath } from "node:url";
39
+ import { STATE_DIR, CACHE_PATH, PATCHER_BACKUP_SUFFIX } from "../config.js";
40
+
41
+ /**
42
+ * Known spinner marker words. Presence of >= 3 of these inside an array literal
43
+ * identifies the spinner-word array regardless of how the variable was minified.
44
+ * @type {string[]}
45
+ */
46
+ export const MARKER_WORDS = [
47
+ "Flibbertigibbeting",
48
+ "Discombobulating",
49
+ "Clauding",
50
+ "Smooshing",
51
+ "Wibbling",
52
+ "Schlepping",
53
+ ];
54
+
55
+ /** The single marker we require to be present in any candidate file's bytes. */
56
+ const REQUIRED_MARKER = "Flibbertigibbeting";
57
+
58
+ /** Max bytes to scan backward from the marker for the opening "[" in a binary. */
59
+ const BIN_BACKSCAN = 5000;
60
+ /** Max bytes to scan forward from the marker for the closing "]" in a binary. */
61
+ const BIN_FORWARDSCAN = 20000;
62
+
63
+ /**
64
+ * Attempt to load the optional node-lief dependency. Returns null if absent.
65
+ * (Reserved for future container repacking; Stage 1 never requires it.)
66
+ * @returns {Promise<*>} The node-lief module, or null.
67
+ */
68
+ async function tryLoadLief() {
69
+ try {
70
+ const mod = await import("node-lief");
71
+ return mod && mod.default ? mod.default : mod;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Run a command and return its trimmed stdout, swallowing any failure.
79
+ * @param {string} cmd
80
+ * @returns {string} stdout (trimmed) or "" on error.
81
+ */
82
+ function execTrim(cmd) {
83
+ try {
84
+ return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Classify a file as "binary" or "text" by inspecting its first 4 bytes for
92
+ * known executable magic numbers (ELF / Mach-O / PE). Anything else is "text".
93
+ * @param {string} filePath
94
+ * @returns {"binary"|"text"} The classification (defaults to "text" on error).
95
+ */
96
+ function classifyFile(filePath) {
97
+ try {
98
+ const fd = fs.openSync(filePath, "r");
99
+ try {
100
+ const buf = Buffer.alloc(4);
101
+ fs.readSync(fd, buf, 0, 4, 0);
102
+ const m = buf.readUInt32BE(0);
103
+ const mLE = buf.readUInt32LE(0);
104
+ // ELF: 0x7F 'E' 'L' 'F'
105
+ if (m === 0x7f454c46) return "binary";
106
+ // Mach-O 32/64 and byte-swapped variants.
107
+ const machos = [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca];
108
+ if (machos.includes(m) || machos.includes(mLE)) return "binary";
109
+ // PE/COFF executables start with "MZ".
110
+ if (buf[0] === 0x4d && buf[1] === 0x5a) return "binary";
111
+ return "text";
112
+ } finally {
113
+ fs.closeSync(fd);
114
+ }
115
+ } catch {
116
+ return "text";
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve newest-semver `claude` binary under each `versions/*` root.
122
+ * @param {string} versionsRoot - Directory containing version-named subfolders.
123
+ * @returns {string|null} Absolute path to the newest version's claude, or null.
124
+ */
125
+ function newestVersionClaude(versionsRoot) {
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(versionsRoot, { withFileTypes: true });
129
+ } catch {
130
+ return null;
131
+ }
132
+ const versions = entries
133
+ .filter((e) => e.isDirectory())
134
+ .map((e) => e.name)
135
+ .sort(compareSemverDesc);
136
+ for (const v of versions) {
137
+ const candidate = path.join(versionsRoot, v, "claude");
138
+ try {
139
+ if (fs.statSync(candidate).isFile()) return candidate;
140
+ } catch {
141
+ // continue
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Compare two semver-ish strings for descending sort (newest first).
149
+ * @param {string} a
150
+ * @param {string} b
151
+ * @returns {number}
152
+ */
153
+ function compareSemverDesc(a, b) {
154
+ const pa = String(a).split(/[.\-+]/).map((n) => parseInt(n, 10));
155
+ const pb = String(b).split(/[.\-+]/).map((n) => parseInt(n, 10));
156
+ const len = Math.max(pa.length, pb.length);
157
+ for (let i = 0; i < len; i++) {
158
+ const x = Number.isFinite(pa[i]) ? pa[i] : 0;
159
+ const y = Number.isFinite(pb[i]) ? pb[i] : 0;
160
+ if (x !== y) return y - x; // descending
161
+ }
162
+ return String(b).localeCompare(String(a));
163
+ }
164
+
165
+ /**
166
+ * @typedef {Object} ClaudeInstall
167
+ * @property {string} path - Absolute path to the claude executable / cli.js.
168
+ * @property {"binary"|"text"} type - File classification.
169
+ */
170
+
171
+ /**
172
+ * Discover candidate Claude Code installs across the common locations, dedupe by
173
+ * realpath, classify each as binary/text, and KEEP ONLY files whose bytes
174
+ * contain the required spinner marker word.
175
+ *
176
+ * Candidate locations:
177
+ * - `which claude` -> realpath
178
+ * - newest semver under ~/.local/share/claude/versions (per-version claude)
179
+ * - newest semver under ~/Library/Application Support/Claude/versions
180
+ * - `npm root -g` + @anthropic-ai/claude-code/cli.js
181
+ * - ~/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js
182
+ * - /opt/homebrew & /usr/local lib/node_modules @anthropic-ai/claude-code/cli.js
183
+ *
184
+ * @returns {Promise<ClaudeInstall[]>}
185
+ */
186
+ export async function findClaudeInstalls() {
187
+ const installs = [];
188
+ for (const real of gatherCandidatePaths()) {
189
+ // Keep only files actually containing the spinner marker (i.e. patchable
190
+ // and not already patched).
191
+ let hasMarker = false;
192
+ try {
193
+ const buf = fs.readFileSync(real);
194
+ hasMarker = buf.indexOf(Buffer.from(REQUIRED_MARKER, "utf8")) !== -1;
195
+ } catch {
196
+ hasMarker = false;
197
+ }
198
+ if (!hasMarker) continue;
199
+
200
+ installs.push({ path: real, type: classifyFile(real) });
201
+ }
202
+
203
+ return installs;
204
+ }
205
+
206
+ /**
207
+ * Gather candidate Claude Code install paths across the common locations,
208
+ * deduped by realpath and limited to existing regular files. UNLIKE
209
+ * findClaudeInstalls(), this does NOT apply the spinner-marker filter — a
210
+ * patched install no longer contains the marker, so restorePatcher() must still
211
+ * be able to see it to put the backup back.
212
+ *
213
+ * @returns {string[]} Absolute (realpath) candidate paths.
214
+ */
215
+ function gatherCandidatePaths() {
216
+ const home = os.homedir();
217
+ const candidates = [];
218
+
219
+ // which claude -> realpath
220
+ const which = execTrim("which claude");
221
+ if (which) {
222
+ try {
223
+ candidates.push(fs.realpathSync(which));
224
+ } catch {
225
+ candidates.push(which);
226
+ }
227
+ }
228
+
229
+ // versions roots
230
+ const fromLocalShare = newestVersionClaude(
231
+ path.join(home, ".local", "share", "claude", "versions")
232
+ );
233
+ if (fromLocalShare) candidates.push(fromLocalShare);
234
+
235
+ const fromAppSupport = newestVersionClaude(
236
+ path.join(home, "Library", "Application Support", "Claude", "versions")
237
+ );
238
+ if (fromAppSupport) candidates.push(fromAppSupport);
239
+
240
+ // npm global root
241
+ const npmRoot = execTrim("npm root -g");
242
+ if (npmRoot) {
243
+ candidates.push(path.join(npmRoot, "@anthropic-ai", "claude-code", "cli.js"));
244
+ }
245
+
246
+ // user-local npm install
247
+ candidates.push(
248
+ path.join(home, ".claude", "local", "node_modules", "@anthropic-ai", "claude-code", "cli.js")
249
+ );
250
+
251
+ // homebrew prefixes
252
+ candidates.push(
253
+ path.join("/opt/homebrew", "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js")
254
+ );
255
+ candidates.push(
256
+ path.join("/usr/local", "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js")
257
+ );
258
+
259
+ // Dedup by realpath, keep only existing regular files.
260
+ const seen = new Set();
261
+ const out = [];
262
+ for (const c of candidates) {
263
+ if (!c) continue;
264
+ let real;
265
+ try {
266
+ const st = fs.statSync(c);
267
+ if (!st.isFile()) continue;
268
+ real = fs.realpathSync(c);
269
+ } catch {
270
+ continue;
271
+ }
272
+ if (seen.has(real)) continue;
273
+ seen.add(real);
274
+ out.push(real);
275
+ }
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * Build the replacement spinner words from the current cache snippets, capped to
281
+ * config.injection.maxVisible (default 5). Falls back to ["Thinking"] if empty.
282
+ * @param {object} config - Normalized ContextSpin config.
283
+ * @returns {Promise<string[]>}
284
+ */
285
+ export async function buildSpinnerWords(config) {
286
+ const maxVisible =
287
+ config && config.injection && typeof config.injection.maxVisible === "number"
288
+ ? config.injection.maxVisible
289
+ : 5;
290
+
291
+ let snippets = [];
292
+ try {
293
+ const cache = JSON.parse(await fsp.readFile(CACHE_PATH, "utf8"));
294
+ if (cache && Array.isArray(cache.snippets)) snippets = cache.snippets;
295
+ } catch {
296
+ snippets = [];
297
+ }
298
+
299
+ const words = snippets
300
+ .map((s) => (s && typeof s.text === "string" ? s.text : null))
301
+ .filter((t) => t && t.trim() !== "")
302
+ .slice(0, maxVisible);
303
+
304
+ return words.length > 0 ? words : ["Thinking"];
305
+ }
306
+
307
+ /**
308
+ * Count how many distinct MARKER_WORDS appear in a string.
309
+ * @param {string} str
310
+ * @returns {number}
311
+ */
312
+ function countMarkers(str) {
313
+ let n = 0;
314
+ for (const w of MARKER_WORDS) {
315
+ if (str.includes(w)) n++;
316
+ }
317
+ return n;
318
+ }
319
+
320
+ /**
321
+ * Scan a string for the first top-level array literal (matched brackets) whose
322
+ * contents include >= 3 marker words. Marker-based; never keys off a var name.
323
+ * @param {string} text
324
+ * @returns {{start:number, end:number}|null} Inclusive bracket span, or null.
325
+ */
326
+ function findMarkerArrayInText(text) {
327
+ let searchFrom = 0;
328
+ while (true) {
329
+ const open = text.indexOf("[", searchFrom);
330
+ if (open === -1) return null;
331
+ const close = matchBracket(text, open);
332
+ if (close === -1) return null;
333
+ const inner = text.slice(open, close + 1);
334
+ if (countMarkers(inner) >= 3) {
335
+ return { start: open, end: close };
336
+ }
337
+ searchFrom = open + 1;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Find the index of the "]" that closes the "[" at `openIdx`, respecting nested
343
+ * brackets and string literals. Returns -1 if unbalanced.
344
+ * @param {string} text
345
+ * @param {number} openIdx
346
+ * @returns {number}
347
+ */
348
+ function matchBracket(text, openIdx) {
349
+ let depth = 0;
350
+ let quote = null;
351
+ for (let i = openIdx; i < text.length; i++) {
352
+ const ch = text[i];
353
+ if (quote) {
354
+ if (ch === "\\") {
355
+ i++; // skip escaped char
356
+ continue;
357
+ }
358
+ if (ch === quote) quote = null;
359
+ continue;
360
+ }
361
+ if (ch === '"' || ch === "'" || ch === "`") {
362
+ quote = ch;
363
+ continue;
364
+ }
365
+ if (ch === "[") depth++;
366
+ else if (ch === "]") {
367
+ depth--;
368
+ if (depth === 0) return i;
369
+ }
370
+ }
371
+ return -1;
372
+ }
373
+
374
+ /**
375
+ * @typedef {Object} PatchResult
376
+ * @property {string} path - Install path that was processed.
377
+ * @property {"binary"|"text"} type
378
+ * @property {boolean} patched - Whether the spinner array was replaced.
379
+ * @property {string} note - Human-readable detail.
380
+ */
381
+
382
+ /**
383
+ * Back up an install (only if no backup exists yet).
384
+ * @param {string} filePath
385
+ * @returns {Promise<void>}
386
+ */
387
+ async function backupOnce(filePath) {
388
+ const backup = filePath + PATCHER_BACKUP_SUFFIX;
389
+ if (!fs.existsSync(backup)) {
390
+ await fsp.copyFile(filePath, backup);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Patch a TEXT (minified cli.js) install: replace the marker array literal with
396
+ * a JSON array of the given words. File length may change freely.
397
+ * @param {ClaudeInstall} install
398
+ * @param {string[]} words
399
+ * @returns {Promise<PatchResult>}
400
+ */
401
+ async function patchTextInstall(install, words) {
402
+ const text = await fsp.readFile(install.path, "utf8");
403
+ const span = findMarkerArrayInText(text);
404
+ if (!span) {
405
+ return {
406
+ path: install.path,
407
+ type: "text",
408
+ patched: false,
409
+ note: "Could not locate the spinner-word array (>=3 markers) in the text install.",
410
+ };
411
+ }
412
+ const replacement = JSON.stringify(words);
413
+ const next = text.slice(0, span.start) + replacement + text.slice(span.end + 1);
414
+
415
+ // tmp + rename preserving mode.
416
+ const mode = (await fsp.stat(install.path)).mode;
417
+ const tmp = install.path + ".contextspin.tmp";
418
+ await fsp.writeFile(tmp, next);
419
+ await fsp.chmod(tmp, mode);
420
+ await fsp.rename(tmp, install.path);
421
+
422
+ return {
423
+ path: install.path,
424
+ type: "text",
425
+ patched: true,
426
+ note: `Replaced spinner array with ${words.length} word(s).`,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Build a LENGTH-PRESERVING replacement for a binary spinner array.
432
+ *
433
+ * Produces bytes for a JSON array of `words` that fits EXACTLY into `spanLen`
434
+ * bytes (the original "[...]" span). Words are dropped from the end until the
435
+ * JSON array fits; remaining slack is padded with spaces inside the array,
436
+ * just before the closing "]", so the total byte length is unchanged.
437
+ *
438
+ * @param {string[]} words
439
+ * @param {number} spanLen - Byte length of the original "[...]" span.
440
+ * @returns {Buffer|null} Exactly `spanLen` bytes, or null if even "[]" won't fit.
441
+ */
442
+ function buildBinaryReplacement(words, spanLen) {
443
+ let list = Array.isArray(words) && words.length > 0 ? words.slice() : ["Thinking"];
444
+ while (true) {
445
+ const json = JSON.stringify(list); // e.g. ["a","b"]
446
+ const jsonBuf = Buffer.from(json, "utf8");
447
+ if (jsonBuf.length <= spanLen) {
448
+ const pad = spanLen - jsonBuf.length;
449
+ if (pad === 0) return jsonBuf;
450
+ // Insert `pad` spaces just before the final "]" to keep valid JSON-ish
451
+ // array syntax and exact length.
452
+ const head = jsonBuf.subarray(0, jsonBuf.length - 1); // everything but "]"
453
+ const spaces = Buffer.alloc(pad, 0x20);
454
+ return Buffer.concat([head, spaces, Buffer.from("]", "utf8")]);
455
+ }
456
+ if (list.length <= 1) {
457
+ // Even a single (shortest) word will not fit the original span. Give up
458
+ // rather than writing an EMPTY spinner array (which would leave Claude
459
+ // Code with no spinner words at all and strip the restore marker).
460
+ return null;
461
+ }
462
+ list = list.slice(0, list.length - 1); // drop the last word and retry
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Patch a BINARY (Bun-compiled) install LENGTH-PRESERVINGLY.
468
+ * - Locate the marker, back-scan for "[", forward-scan for "]".
469
+ * - Validate the span contains >= 3 markers.
470
+ * - Build an exact-length replacement (drop/trim words, pad with spaces).
471
+ * - Overwrite the span in the buffer; write via tmp + rename preserving mode.
472
+ * - On darwin, best-effort ad-hoc re-sign.
473
+ * @param {ClaudeInstall} install
474
+ * @param {string[]} words
475
+ * @returns {Promise<PatchResult>}
476
+ */
477
+ async function patchBinaryInstall(install, words) {
478
+ const buf = await fsp.readFile(install.path);
479
+ const markerBuf = Buffer.from(REQUIRED_MARKER, "utf8");
480
+ const markerIdx = buf.indexOf(markerBuf);
481
+ if (markerIdx === -1) {
482
+ return {
483
+ path: install.path,
484
+ type: "binary",
485
+ patched: false,
486
+ note: "Marker word not found in binary; skipping (try claude-depester).",
487
+ };
488
+ }
489
+
490
+ // Back-scan for "[".
491
+ const backStart = Math.max(0, markerIdx - BIN_BACKSCAN);
492
+ let open = -1;
493
+ for (let i = markerIdx; i >= backStart; i--) {
494
+ if (buf[i] === 0x5b) {
495
+ open = i;
496
+ break;
497
+ }
498
+ }
499
+ if (open === -1) {
500
+ return {
501
+ path: install.path,
502
+ type: "binary",
503
+ patched: false,
504
+ note: "Could not find opening '[' near marker; skipping (try claude-depester).",
505
+ };
506
+ }
507
+
508
+ // Forward-scan for "]".
509
+ const fwdEnd = Math.min(buf.length - 1, markerIdx + BIN_FORWARDSCAN);
510
+ let close = -1;
511
+ for (let i = markerIdx; i <= fwdEnd; i++) {
512
+ if (buf[i] === 0x5d) {
513
+ close = i;
514
+ break;
515
+ }
516
+ }
517
+ if (close === -1 || close <= open) {
518
+ return {
519
+ path: install.path,
520
+ type: "binary",
521
+ patched: false,
522
+ note: "Could not find closing ']' near marker; skipping (try claude-depester).",
523
+ };
524
+ }
525
+
526
+ const spanLen = close - open + 1;
527
+ const spanStr = buf.subarray(open, close + 1).toString("utf8");
528
+ if (countMarkers(spanStr) < 3) {
529
+ return {
530
+ path: install.path,
531
+ type: "binary",
532
+ patched: false,
533
+ note: "Bracket span did not contain >=3 marker words; skipping (try claude-depester).",
534
+ };
535
+ }
536
+
537
+ const replacement = buildBinaryReplacement(words, spanLen);
538
+ if (!replacement || replacement.length !== spanLen) {
539
+ return {
540
+ path: install.path,
541
+ type: "binary",
542
+ patched: false,
543
+ note:
544
+ "Snippets are too long to fit the binary's spinner span (it must be length-preserving); skipped to avoid an empty/corrupt array — try shorter snippets or claude-depester.",
545
+ };
546
+ }
547
+
548
+ // Overwrite the span in place — LENGTH-PRESERVING, file size unchanged.
549
+ replacement.copy(buf, open);
550
+
551
+ // Write via tmp + rename preserving mode.
552
+ const mode = (await fsp.stat(install.path)).mode;
553
+ const tmp = install.path + ".contextspin.tmp";
554
+ await fsp.writeFile(tmp, buf);
555
+ await fsp.chmod(tmp, mode);
556
+ await fsp.rename(tmp, install.path);
557
+
558
+ let note = "Replaced spinner array in place (length-preserving).";
559
+
560
+ // Best-effort ad-hoc re-sign on macOS.
561
+ if (process.platform === "darwin") {
562
+ try {
563
+ const r = spawnSync("codesign", ["-s", "-", "-f", install.path], {
564
+ stdio: ["ignore", "ignore", "pipe"],
565
+ encoding: "utf8",
566
+ });
567
+ if (r.status !== 0) {
568
+ note += ` Warning: ad-hoc codesign failed (${(r.stderr || "").trim() || "unknown error"}); the binary may be quarantined until re-signed.`;
569
+ } else {
570
+ note += " Re-signed ad-hoc (codesign -s - -f).";
571
+ }
572
+ } catch (err) {
573
+ note += ` Warning: could not run codesign (${err.message}).`;
574
+ }
575
+ }
576
+
577
+ return { path: install.path, type: "binary", patched: true, note };
578
+ }
579
+
580
+ /**
581
+ * Patch a single install (text or binary), backing it up first.
582
+ * @param {ClaudeInstall} install
583
+ * @param {string[]} words
584
+ * @returns {Promise<PatchResult>}
585
+ */
586
+ export async function patchInstall(install, words) {
587
+ await backupOnce(install.path);
588
+ // node-lief is optional and only useful for full container repacking; Stage 1
589
+ // proceeds with raw-buffer logic whether or not it is present.
590
+ await tryLoadLief();
591
+
592
+ if (install.type === "binary") {
593
+ return patchBinaryInstall(install, words);
594
+ }
595
+ return patchTextInstall(install, words);
596
+ }
597
+
598
+ /**
599
+ * Build the self-healing wrapper script that re-applies the patch before
600
+ * launching claude (so auto-updates that overwrite the patch are healed).
601
+ * @returns {string} Shell script source.
602
+ */
603
+ function buildWrapperScript() {
604
+ const entry = path.join(STATE_DIR, "patch-run.mjs");
605
+ return `#!/usr/bin/env bash
606
+ # contextspin cl.sh — patch-before-load wrapper for Claude Code.
607
+ # Claude Code auto-updates overwrite the spinner patch; running the patcher here
608
+ # re-applies it each launch so the patch self-heals. Patching only takes effect
609
+ # after Claude Code is fully restarted (this wrapper launches a fresh process).
610
+ node ${JSON.stringify(entry)} >/dev/null 2>&1 || true
611
+ exec claude "$@"
612
+ `;
613
+ }
614
+
615
+ /**
616
+ * Build the tiny Node entry that the wrapper invokes to re-apply the patch.
617
+ * @param {string} packageRoot - Absolute path to the contextspin package root.
618
+ * @returns {string} ESM source.
619
+ */
620
+ function buildPatchRunEntry(packageRoot) {
621
+ const patcherUrl = JSON.stringify(
622
+ "file://" + path.join(packageRoot, "src", "inject", "patcher.js")
623
+ );
624
+ const configUrl = JSON.stringify(
625
+ "file://" + path.join(packageRoot, "src", "config.js")
626
+ );
627
+ return `// contextspin patch-run.mjs (generated) — re-applies the spinner patch.
628
+ import { installPatcher } from ${patcherUrl};
629
+ import { loadConfig } from ${configUrl};
630
+ try {
631
+ const config = await loadConfig();
632
+ await installPatcher(config);
633
+ } catch (err) {
634
+ // Best-effort: never block launching claude.
635
+ process.stderr.write("contextspin patch-run: " + (err && err.message) + "\\n");
636
+ }
637
+ `;
638
+ }
639
+
640
+ /**
641
+ * @typedef {Object} InstallPatcherResult
642
+ * @property {PatchResult[]} patched - Per-install results.
643
+ * @property {string} [wrapper] - Path to the generated cl.sh wrapper.
644
+ * @property {string} warning - EXPERIMENTAL warning / no-install message.
645
+ * @property {string} [note] - Restart + update guidance.
646
+ */
647
+
648
+ /**
649
+ * Install the EXPERIMENTAL spinner patch across all detected Claude installs and
650
+ * write a self-healing launch wrapper.
651
+ * @param {object} config - Normalized ContextSpin config.
652
+ * @returns {Promise<InstallPatcherResult>}
653
+ */
654
+ export async function installPatcher(config) {
655
+ const installs = await findClaudeInstalls();
656
+ if (installs.length === 0) {
657
+ return {
658
+ patched: [],
659
+ warning: "No Claude Code install containing spinner words was found.",
660
+ };
661
+ }
662
+
663
+ const words = await buildSpinnerWords(config);
664
+
665
+ const patched = [];
666
+ for (const install of installs) {
667
+ try {
668
+ patched.push(await patchInstall(install, words));
669
+ } catch (err) {
670
+ patched.push({
671
+ path: install.path,
672
+ type: install.type,
673
+ patched: false,
674
+ note: `Patch failed: ${err.message}`,
675
+ });
676
+ }
677
+ }
678
+
679
+ // Resolve the package root from this module's location (src/inject -> root).
680
+ // Use fileURLToPath (not URL.pathname) so a path containing spaces is
681
+ // percent-DECODED — otherwise the generated patch-run entry imports a path
682
+ // like /Users/me/my%20projects/... that does not exist on disk.
683
+ const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
684
+
685
+ // Write the self-healing wrapper + its entry.
686
+ await fsp.mkdir(STATE_DIR, { recursive: true });
687
+ const patchRunEntry = path.join(STATE_DIR, "patch-run.mjs");
688
+ await fsp.writeFile(patchRunEntry, buildPatchRunEntry(packageRoot));
689
+
690
+ const wrapper = path.join(STATE_DIR, "cl.sh");
691
+ await fsp.writeFile(wrapper, buildWrapperScript());
692
+ await fsp.chmod(wrapper, 0o755);
693
+
694
+ return {
695
+ patched,
696
+ wrapper,
697
+ warning:
698
+ "EXPERIMENTAL: spinner patching is fragile and may break with Claude Code releases. " +
699
+ "If a binary was skipped, consider the dedicated `claude-depester` tool.",
700
+ note:
701
+ `Claude Code auto-updates OVERWRITE this patch. Launch Claude via the wrapper ` +
702
+ `(${wrapper}) — alias it to \`cl\` — so the patch self-heals on every start. ` +
703
+ `RESTART REQUIRED: a running Claude Code process keeps the old spinner words in ` +
704
+ `memory; fully quit and reopen Claude Code for the patch to take effect.`,
705
+ };
706
+ }
707
+
708
+ /**
709
+ * @typedef {Object} RestorePatcherResult
710
+ * @property {Array<{path:string, restored:boolean, note:string}>} restored
711
+ */
712
+
713
+ /**
714
+ * Restore every install from its `.contextspin.backup` if present.
715
+ * @returns {Promise<RestorePatcherResult>}
716
+ */
717
+ export async function restorePatcher() {
718
+ // Enumerate candidate paths WITHOUT the marker filter: a patched install no
719
+ // longer contains the marker, so findClaudeInstalls() would not see it.
720
+ const results = [];
721
+
722
+ for (const target of gatherCandidatePaths()) {
723
+ const backup = target + PATCHER_BACKUP_SUFFIX;
724
+ if (!fs.existsSync(backup)) continue; // only restore what we backed up
725
+
726
+ try {
727
+ const mode = (await fsp.stat(target)).mode;
728
+ const data = await fsp.readFile(backup);
729
+ const tmp = target + ".contextspin.tmp";
730
+ await fsp.writeFile(tmp, data);
731
+ await fsp.chmod(tmp, mode);
732
+ await fsp.rename(tmp, target);
733
+
734
+ if (process.platform === "darwin" && classifyFile(target) === "binary") {
735
+ try {
736
+ spawnSync("codesign", ["-s", "-", "-f", target], { stdio: "ignore" });
737
+ } catch {
738
+ // best effort
739
+ }
740
+ }
741
+
742
+ // Drop the backup after a successful restore so a later patch makes a
743
+ // fresh backup of the (now clean) file.
744
+ try {
745
+ await fsp.unlink(backup);
746
+ } catch {
747
+ // best effort
748
+ }
749
+
750
+ results.push({ path: target, restored: true, note: "Restored from backup." });
751
+ } catch (err) {
752
+ results.push({ path: target, restored: false, note: `Restore failed: ${err.message}` });
753
+ }
754
+ }
755
+
756
+ return { restored: results };
757
+ }