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.
- package/.contextspin.example.json +72 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +40 -0
- package/src/cli.js +492 -0
- package/src/config.js +232 -0
- package/src/daemon-entry.js +8 -0
- package/src/daemon.js +294 -0
- package/src/formatter.js +166 -0
- package/src/inject/patcher.js +757 -0
- package/src/inject/statusline.js +310 -0
- package/src/runner.js +69 -0
- package/src/sources/cli.js +148 -0
- package/src/sources/http.js +294 -0
- package/src/sources/mcp.js +586 -0
|
@@ -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
|
+
}
|