-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstallAgent.ts
More file actions
552 lines (506 loc) · 25 KB
/
Copy pathinstallAgent.ts
File metadata and controls
552 lines (506 loc) · 25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
// installAgent.ts — `backthread install --agent <codex|cursor|gemini>`: write the
// agent's USER-GLOBAL MCP-server config + session-end capture hook, idempotently.
//
// WHY user-global (load-bearing, ARP-680): a per-PROJECT hook (e.g. <repo>/.cursor/
// hooks.json) is absent in git worktrees + every other repo, so capture silently
// never fires there — the exact bug that froze the dogfood log for a week. An
// installed plugin/extension registers globally; these manual writers must do the
// same, so they target ~/.<agent>/ (the user scope), never the project directory.
//
// Each writer MERGES (never clobbers): it reads the existing config, adds our entry
// only if absent, preserves everything else, and writes back. Re-running is a no-op.
// The MCP server + hook both invoke the published CLI via `npx -y backthread` (the
// 8A.8 spike's shape for non-CC agents — no bundled-binary pattern). The hook routes
// through the shared `--from-hook` entrypoint (per-agent payload via --agent) +
// --detach (so a slow/awaited hook never blocks the agent; the shared entrypoint
// dedupes per session, since Codex/Cursor stop fire per turn).
//
// The hosted query MCP (ARP-480) is NOT wired here: it needs a per-user device
// token, which can't be baked into a shared config — the LOCAL stdio `backthread
// mcp` reads the user's ~/.backthread token instead. Auth is a separate `backthread
// login` (the claim-code handoff threads it through `install`).
//
// CONFIG SHAPES are from the ARP-481 spike (verified against each agent's docs).
// Cursor's stop payload / hooks.json shape is confirmed-pending a live install
// (ARP-507) — flagged where used.
//
// PreToolUse grep-context hook (the two-tier grep-time context hook): NOT wired for
// codex/cursor/gemini here. It is a SYNCHRONOUS pre-tool context injection — Claude
// Code's PreToolUse `hookSpecificOutput.additionalContext` is the surface it needs,
// and the non-CC agents don't expose an equivalent synchronous pre-search
// context-injection point in these writers' config shapes. So the grep hook ships
// Claude-Code-plugin-only for now (cli/hooks/hooks.json); a codex/cursor/gemini
// equivalent is a follow-up if/when their hook surfaces gain one. (These agents
// still get capture + the local stdio `mcp` server, unchanged.)
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
const execFileP = promisify(execFile);
export type InstallAgent = 'codex' | 'cursor' | 'gemini';
export const INSTALL_AGENTS: readonly InstallAgent[] = ['codex', 'cursor', 'gemini'];
// The MCP stdio server every writer registers: the published CLI's `mcp` subcommand.
const MCP_COMMAND = 'npx';
const MCP_ARGS: readonly string[] = ['-y', 'backthread', 'mcp'];
/**
* The session-end/stop hook command for an agent (routes through the shared entrypoint).
* Pinned to `backthread@latest` (ARP-739) so npx RE-RESOLVES from the registry each
* session instead of reusing a stale cached/global copy — self-updating, like the CC
* hook (ARP-733). Detached, so the extra resolve is invisible.
*/
export function hookCommand(agent: string): string {
return `npx -y backthread@latest capture --from-hook --agent ${agent} --detach`;
}
/**
* The PRE-`@latest` hook command (ARP-739) — the form earlier installs wrote. Recognized
* so a re-install MIGRATES it to {@link hookCommand} in place (no duplicate, no double-
* capture), and so the Cursor stop-hook migration still strips the retired inline command.
*/
export function legacyHookCommand(agent: string): string {
return `npx -y backthread capture --from-hook --agent ${agent} --detach`;
}
// Minimum agent versions that support the hooks engine (spike ARP-481). Below these
// we WARN (not hard-block): a too-old agent ignores the hook config, but the MCP
// server still works, and we can't reliably detect every version — so we never
// refuse to write, we just tell the user to upgrade.
const MIN_VERSION: Record<InstallAgent, string> = {
codex: '0.124.0',
cursor: '1.7.0',
gemini: '0.26.0',
};
// The binary we probe for `--version` per agent. Cursor's CLI is `cursor-agent`
// (confirmed-pending ARP-507); a missing binary just skips the version gate.
const VERSION_BIN: Record<InstallAgent, string> = {
codex: 'codex',
cursor: 'cursor-agent',
gemini: 'gemini',
};
export interface AgentInstallDeps {
/** Home dir override (tests). Defaults to os.homedir(). */
home?: string;
readFileImpl?: (p: string) => Promise<string>;
writeFileImpl?: (p: string, d: string) => Promise<void>;
mkdirImpl?: (d: string) => Promise<void>;
/** Test seam: chmod the Cursor wrapper scripts executable (0755). Defaults to fs.chmod. */
chmodImpl?: (p: string, mode: number) => Promise<void>;
/**
* The Node bin dir baked into the Cursor wrapper scripts' PATH (ARP-692). Defaults to
* the dir of the Node running the installer (`process.execPath`) — guaranteed ≥22.18
* (our `engines` floor), which is exactly the Node we want Cursor to re-find. Test seam.
*/
nodeBinDir?: string;
/** Test seam: the version probe. Defaults to running `<bin> --version`. */
probeVersionImpl?: (agent: InstallAgent) => Promise<string | null>;
}
export interface AgentFileWrite {
path: string;
/** true = we wrote a change; false = already present (idempotent no-op). */
wrote: boolean;
}
export interface AgentInstallResult {
agent: InstallAgent;
/** Every config file we touched (MCP + hook), with whether a change was written. */
writes: AgentFileWrite[];
/** A "please upgrade <agent>" message when a too-old version was detected, else null. */
versionWarning: string | null;
/** Cursor only: the one-click `cursor://…` MCP-install deeplink (informational). */
deeplink: string | null;
}
// --- shared JSON config helpers ----------------------------------------------
/**
* Load a JSON config object: {} on a missing file (ENOENT), the parsed object when
* valid, and a THROW on a present-but-corrupt / non-object file (never clobber the
* user's recoverable content — mirrors install.ts registerHook).
*/
async function loadJsonObject(
readFileImpl: (p: string) => Promise<string>,
path: string,
): Promise<Record<string, unknown>> {
let raw: string;
try {
raw = await readFileImpl(path);
} catch (e) {
if (isNotFound(e)) return {};
throw e;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`${path} exists but is not valid JSON — refusing to overwrite it. Fix it (or add the config manually) and re-run.`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${path} is not a JSON object — refusing to overwrite it. Fix it (or add the config manually) and re-run.`);
}
return parsed as Record<string, unknown>;
}
function isNotFound(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === 'ENOENT';
}
function asObject(v: unknown): Record<string, unknown> {
return v && typeof v === 'object' && !Array.isArray(v) ? { ...(v as Record<string, unknown>) } : {};
}
/** Our MCP server entry — the value under mcpServers.backthread. */
function mcpServerEntry(): Record<string, unknown> {
return { command: MCP_COMMAND, args: [...MCP_ARGS] };
}
/**
* Ensure `obj.mcpServers.backthread` is our entry. Returns a NEW settings object +
* whether it changed. Idempotent: an identical existing entry is a no-op; a stale
* one is updated to ours (we own that key).
*/
function withMcpServer(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const mcpServers = asObject(settings.mcpServers);
const desired = mcpServerEntry();
if (JSON.stringify(mcpServers.backthread) === JSON.stringify(desired)) {
return { next: settings, changed: false };
}
mcpServers.backthread = desired;
return { next: { ...settings, mcpServers }, changed: true };
}
/** Does a CC/Gemini/Codex-shaped group contain a command hook running exactly `command`? */
function groupRunsCommand(group: unknown, command: string): boolean {
const inner = (group as { hooks?: unknown })?.hooks;
return Array.isArray(inner) && inner.some((h) => (h as { command?: unknown })?.command === command);
}
/**
* Return a NEW group with any hook running a `legacyCommands` command rewritten to
* `command` IN PLACE (preserving the hook's other fields — type/timeout/name); returns
* the SAME group reference when nothing changed (so the caller detects a migration by
* identity). Never mutates the input.
*/
function rewriteLegacyInGroup(
group: unknown,
legacyCommands: readonly string[],
command: string,
): unknown {
const inner = (group as { hooks?: unknown })?.hooks;
if (!Array.isArray(inner)) return group;
let changed = false;
const nextInner = inner.map((h) => {
const cmd = (h as { command?: unknown })?.command;
if (typeof cmd === 'string' && cmd !== command && legacyCommands.includes(cmd)) {
changed = true;
return { ...(h as Record<string, unknown>), command };
}
return h;
});
return changed ? { ...(group as Record<string, unknown>), hooks: nextInner } : group;
}
/**
* Ensure `obj.hooks[event]` contains a CC/Gemini/Codex-shaped command group running
* `command` (`[{ hooks: [{ type:'command', command, ...extra }] }]`). Idempotent: a
* group already running `command` is a no-op. Otherwise, if a group runs one of
* `legacyCommands` (a retired form), MIGRATE it to `command` IN PLACE — rather than
* append a duplicate, which would double-capture (mirrors ARP-733's mergeSessionEndHook).
* Only when neither is present do we append a fresh group, preserving any foreign hooks.
* Returns a NEW settings object + whether it changed.
*/
function withNestedHook(
settings: Record<string, unknown>,
event: string,
command: string,
extra: Record<string, unknown> = {},
legacyCommands: readonly string[] = [],
): { next: Record<string, unknown>; changed: boolean } {
const hooks = asObject(settings.hooks);
const list: unknown[] = Array.isArray(hooks[event]) ? [...(hooks[event] as unknown[])] : [];
if (list.some((g) => groupRunsCommand(g, command))) return { next: settings, changed: false };
let migrated = false;
const nextList = list.map((g) => {
const rewritten = rewriteLegacyInGroup(g, legacyCommands, command);
if (rewritten !== g) migrated = true;
return rewritten;
});
if (!migrated) nextList.push({ hooks: [{ type: 'command', command, ...extra }] });
hooks[event] = nextList;
return { next: { ...settings, hooks }, changed: true };
}
async function writeJson(
deps: AgentInstallDeps,
path: string,
obj: Record<string, unknown>,
): Promise<void> {
const doMkdir = deps.mkdirImpl ?? (async (d: string) => void (await mkdir(d, { recursive: true })));
const doWrite = deps.writeFileImpl ?? ((p: string, d: string) => writeFile(p, d));
await doMkdir(dirname(path));
await doWrite(path, JSON.stringify(obj, null, 2) + '\n');
}
// --- per-agent writers -------------------------------------------------------
/** Gemini: ~/.gemini/settings.json holds BOTH mcpServers + hooks.SessionEnd. */
async function installGemini(home: string, deps: AgentInstallDeps): Promise<AgentFileWrite[]> {
const doRead = deps.readFileImpl ?? ((p: string) => readFile(p, 'utf8'));
const path = join(home, '.gemini', 'settings.json');
const current = await loadJsonObject(doRead, path);
const a = withMcpServer(current);
const b = withNestedHook(a.next, 'SessionEnd', hookCommand('gemini-cli'), { name: 'backthread-capture' }, [
legacyHookCommand('gemini-cli'),
]);
if (a.changed || b.changed) await writeJson(deps, path, b.next);
return [{ path, wrote: a.changed || b.changed }];
}
/** Codex: MCP → ~/.codex/config.toml ([mcp_servers.backthread]); hook → ~/.codex/hooks.json (Stop). */
async function installCodex(home: string, deps: AgentInstallDeps): Promise<AgentFileWrite[]> {
const doRead = deps.readFileImpl ?? ((p: string) => readFile(p, 'utf8'));
const writes: AgentFileWrite[] = [];
// MCP — append a [mcp_servers.backthread] TOML table at the END (tables are
// append-safe; the "root keys before tables" gotcha can't bite a trailing table).
// Idempotent on the literal table header.
const tomlPath = join(home, '.codex', 'config.toml');
let toml = '';
try {
toml = await doRead(tomlPath);
} catch (e) {
if (!isNotFound(e)) throw e;
}
if (toml.includes('[mcp_servers.backthread]')) {
writes.push({ path: tomlPath, wrote: false });
} else {
const block = `[mcp_servers.backthread]\ncommand = "${MCP_COMMAND}"\nargs = [${MCP_ARGS.map((a) => `"${a}"`).join(', ')}]\n`;
const sep = toml.length === 0 ? '' : toml.endsWith('\n') ? '\n' : '\n\n';
const doMkdir = deps.mkdirImpl ?? (async (d: string) => void (await mkdir(d, { recursive: true })));
const doWrite = deps.writeFileImpl ?? ((p: string, d: string) => writeFile(p, d));
await doMkdir(dirname(tomlPath));
await doWrite(tomlPath, toml + sep + block);
writes.push({ path: tomlPath, wrote: true });
}
// Hook — ~/.codex/hooks.json, Stop event (turn-scope; --detach + dedupe handle it).
const hooksPath = join(home, '.codex', 'hooks.json');
const current = await loadJsonObject(doRead, hooksPath);
const h = withNestedHook(current, 'Stop', hookCommand('codex'), { timeout: 60 }, [legacyHookCommand('codex')]);
if (h.changed) await writeJson(deps, hooksPath, h.next);
writes.push({ path: hooksPath, wrote: h.changed });
return writes;
}
/**
* Cursor: write two USER-GLOBAL node-resolving WRAPPER SCRIPTS (ARP-692) + point the
* MCP config (~/.cursor/mcp.json) and the stop hook (~/.cursor/hooks.json) at them by
* ABSOLUTE path.
*
* WHY wrapper scripts (Cursor-specific, ARP-692/507): Cursor is a GUI app that does NOT
* inherit your login/nvm shell PATH, so an inline `npx`/`node` may be missing or resolve
* to a too-old system Node (Backthread needs ≥22.18 — the founder's machine had Node 18
* at /usr/local/bin/npx). Terminal-launched CLIs (Codex/Gemini) inherit the shell PATH,
* so only Cursor needs this. The script prepends the install-time Node bin dir to PATH
* then execs `npx -y backthread …`. Pinning PATH (not just an absolute npx) is REQUIRED
* because npx's own `#!/usr/bin/env node` shebang re-resolves node from PATH — an
* absolute npx would still launch under the wrong Node. The absolute script path also
* sidesteps Cursor's inline-command-vs-path ambiguity: a bare executable path always works.
*/
async function installCursor(home: string, deps: AgentInstallDeps): Promise<AgentFileWrite[]> {
const doRead = deps.readFileImpl ?? ((p: string) => readFile(p, 'utf8'));
const nodeBinDir = deps.nodeBinDir ?? dirname(process.execPath);
const writes: AgentFileWrite[] = [];
// (1) Wrapper scripts — ~/.cursor/hooks/backthread-{capture,mcp}.sh, chmod 0755.
const scriptDir = join(home, '.cursor', 'hooks');
const captureScriptPath = join(scriptDir, 'backthread-capture.sh');
const mcpScriptPath = join(scriptDir, 'backthread-mcp.sh');
writes.push(
await writeCursorScript(
deps,
captureScriptPath,
// capture hook → self-updating (@latest), like the other agents' hooks (ARP-739).
cursorWrapperScript(nodeBinDir, 'capture --from-hook --agent cursor --detach', true),
),
);
// MCP server → bare (long-running interactive surface; nudge handles staleness).
writes.push(await writeCursorScript(deps, mcpScriptPath, cursorWrapperScript(nodeBinDir, 'mcp')));
// (2) MCP — ~/.cursor/mcp.json: { mcpServers: { backthread: { command: <mcpScript>, args: [] } } }.
const mcpPath = join(home, '.cursor', 'mcp.json');
const mcpCurrent = await loadJsonObject(doRead, mcpPath);
const m = withCursorMcpServer(mcpCurrent, mcpScriptPath);
if (m.changed) await writeJson(deps, mcpPath, m.next);
writes.push({ path: mcpPath, wrote: m.changed });
// (3) Hook — ~/.cursor/hooks.json: { version: 1, hooks: { stop: [{ command: <captureScript> }] } }.
// Cursor's entries are FLAT { command } (no nested type/hooks), unlike CC/Gemini/Codex.
const hooksPath = join(home, '.cursor', 'hooks.json');
const hooksCurrent = await loadJsonObject(doRead, hooksPath);
const c = withCursorStopHook(hooksCurrent, captureScriptPath);
if (c.changed) await writeJson(deps, hooksPath, c.next);
writes.push({ path: hooksPath, wrote: c.changed });
return writes;
}
/** Single-quote a string for safe interpolation into a POSIX shell script. */
function shSingleQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/**
* The POSIX wrapper-script body: pin a ≥22 Node on PATH, then exec `npx -y backthread <args>`.
* `latest` pins `backthread@latest` (ARP-739) so the CAPTURE wrapper self-updates like the
* other agents' hooks; the MCP-server wrapper stays bare (a long-running interactive
* surface — staleness is handled by the upgrade nudge, ARP-734, not a per-start re-resolve).
*/
function cursorWrapperScript(nodeBinDir: string, backthreadArgs: string, latest = false): string {
const pkg = latest ? 'backthread@latest' : 'backthread';
return (
[
'#!/bin/sh',
'# Backthread wrapper for Cursor — generated by `backthread install --agent cursor` (ARP-692).',
'#',
'# Cursor is a GUI app and does NOT inherit your login/nvm shell PATH, so a bare',
'# `npx`/`node` here may be missing or resolve to a too-old system Node (Backthread',
'# needs Node >= 22.18). Prepend the Node bin dir detected at install time so capture',
"# and the MCP server always run on a new-enough Node. (npx's `#!/usr/bin/env node`",
'# shebang re-resolves node from PATH, so pinning PATH — not just an absolute npx — is',
'# what actually guarantees the right Node.)',
'#',
'# If your Node later moves (a new nvm version, an uninstall), re-run:',
'# npx backthread install --agent cursor',
`NODE_BIN_DIR=${shSingleQuote(nodeBinDir)}`,
'if [ -d "$NODE_BIN_DIR" ]; then',
' PATH="$NODE_BIN_DIR:$PATH"',
' export PATH',
'fi',
`exec npx -y ${pkg} ${backthreadArgs}`,
].join('\n') + '\n'
);
}
/**
* Write a Cursor wrapper script (idempotent on content) + chmod it 0755. Returns wrote:false
* when the on-disk content already matches (so a re-run is a no-op, like the JSON writers).
*/
async function writeCursorScript(
deps: AgentInstallDeps,
path: string,
content: string,
): Promise<AgentFileWrite> {
const doRead = deps.readFileImpl ?? ((p: string) => readFile(p, 'utf8'));
const doChmod = deps.chmodImpl ?? ((p: string, mode: number) => chmod(p, mode));
let existing: string | null = null;
try {
existing = await doRead(path);
} catch (e) {
if (!isNotFound(e)) throw e;
}
if (existing === content) {
// Content already current — but still (re)assert the exec bit so a re-run SELF-HEALS
// a stripped 0755 (a dotfile-sync tool, a manual edit, a restrictive umask on a prior
// partial write). A non-executable wrapper silently breaks capture — the exact
// reliability failure this writer exists to fix. Best-effort: a content-match must
// never fail the install (it was a pure no-op before), so a chmod hiccup is swallowed.
await doChmod(path, 0o755).catch(() => {});
return { path, wrote: false };
}
const doMkdir = deps.mkdirImpl ?? (async (d: string) => void (await mkdir(d, { recursive: true })));
const doWrite = deps.writeFileImpl ?? ((p: string, d: string) => writeFile(p, d));
await doMkdir(dirname(path));
await doWrite(path, content);
await doChmod(path, 0o755);
return { path, wrote: true };
}
/**
* Ensure ~/.cursor/mcp.json's `mcpServers.backthread` points at the absolute MCP wrapper
* script (ARP-692). We own that key: an identical entry is a no-op; any other value
* (including the pre-ARP-692 plain-`npx` entry) is MIGRATED to ours. Never touches other servers.
*/
function withCursorMcpServer(
settings: Record<string, unknown>,
mcpScriptPath: string,
): { next: Record<string, unknown>; changed: boolean } {
const mcpServers = asObject(settings.mcpServers);
const desired = { command: mcpScriptPath, args: [] as string[] };
if (JSON.stringify(mcpServers.backthread) === JSON.stringify(desired)) {
return { next: settings, changed: false };
}
mcpServers.backthread = desired;
return { next: { ...settings, mcpServers }, changed: true };
}
/**
* Cursor-specific: ensure hooks.stop contains our flat `{ command: <absolute captureScript> }`
* entry, MIGRATING the pre-ARP-692 inline command ({@link hookCommand}('cursor')) to it (strip
* the old, never duplicate). Sets the schema `version` to 1 only when ABSENT — never
* downgrades/rewrites a version the user (or a future Cursor) already set. Idempotent: our
* script entry present + no stale inline entry + a version already set is a no-op.
*/
function withCursorStopHook(
settings: Record<string, unknown>,
captureScriptPath: string,
): { next: Record<string, unknown>; changed: boolean } {
const legacyInline = legacyHookCommand('cursor'); // the retired pre-ARP-692 inline `npx …` command (never @latest)
const hooks = asObject(settings.hooks);
const stop: unknown[] = Array.isArray(hooks.stop) ? [...(hooks.stop as unknown[])] : [];
const hadDesired = stop.some((h) => (h as { command?: unknown })?.command === captureScriptPath);
const next = stop.filter((h) => (h as { command?: unknown })?.command !== legacyInline);
const removedLegacy = next.length !== stop.length;
if (!hadDesired) next.push({ command: captureScriptPath });
const hasVersion = typeof settings.version === 'number';
if (hadDesired && !removedLegacy && hasVersion) return { next: settings, changed: false };
hooks.stop = next;
return { next: { ...settings, version: hasVersion ? settings.version : 1, hooks }, changed: true };
}
// --- the Cursor deeplink -----------------------------------------------------
/** The one-click Cursor MCP-install deeplink (the website/app can render this). */
export function cursorDeeplink(): string {
const config = Buffer.from(JSON.stringify(mcpServerEntry())).toString('base64');
return `cursor://anysphere.cursor-deeplink/mcp/install?name=backthread&config=${config}`;
}
// --- version gate ------------------------------------------------------------
function parseSemver(s: string): [number, number, number] | null {
const m = /(\d+)\.(\d+)\.(\d+)/.exec(s);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function isBelow(a: [number, number, number], b: [number, number, number]): boolean {
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
return false;
}
/** Best-effort: run `<bin> --version`, return the raw output, or null on any failure. */
async function probeVersion(agent: InstallAgent): Promise<string | null> {
try {
const { stdout } = await execFileP(VERSION_BIN[agent], ['--version'], { timeout: 3000 });
return stdout?.trim() || null;
} catch {
return null;
}
}
/**
* Decide a version warning: null when we can't detect (proceed silently) or the
* version is fine; a "please upgrade" string when a detected version is below the
* hooks floor. Never throws — the gate must never block a write.
*/
async function versionGate(agent: InstallAgent, deps: AgentInstallDeps): Promise<string | null> {
const probe = deps.probeVersionImpl ?? probeVersion;
const raw = await probe(agent).catch(() => null);
if (!raw) return null;
const got = parseSemver(raw);
const min = parseSemver(MIN_VERSION[agent])!;
if (got && isBelow(got, min)) {
return `Detected ${agent} ${got.join('.')}, but the capture hook needs ${MIN_VERSION[agent]}+. The MCP query tool works now; upgrade ${agent} for auto-capture.`;
}
return null;
}
// --- the dispatcher ----------------------------------------------------------
/**
* Write the per-agent USER-GLOBAL MCP config + capture hook for `agent`, idempotently.
* Returns every file touched + a version warning + (Cursor) the install deeplink.
* A corrupt existing config THROWS (never clobbered); the caller reports it.
*/
export async function runInstallAgent(
agent: InstallAgent,
deps: AgentInstallDeps = {},
): Promise<AgentInstallResult> {
const home = deps.home ?? homedir();
const versionWarning = await versionGate(agent, deps);
let writes: AgentFileWrite[];
switch (agent) {
case 'gemini':
writes = await installGemini(home, deps);
break;
case 'codex':
writes = await installCodex(home, deps);
break;
case 'cursor':
writes = await installCursor(home, deps);
break;
}
return { agent, writes, versionWarning, deeplink: agent === 'cursor' ? cursorDeeplink() : null };
}
/** Parse a `--agent <x>` value into an InstallAgent, or null (CC path / unknown). */
export function parseInstallAgent(value: string | undefined): InstallAgent | 'claude-code' | null {
if (!value) return null;
const v = value.trim().toLowerCase();
if (v === 'claude-code' || v === 'claude' || v === 'cc') return 'claude-code';
if (v === 'gemini' || v === 'gemini-cli') return 'gemini';
if (v === 'codex' || v === 'cursor') return v;
return null;
}