backthread 0.3.1 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/dist-bundle/backthread.js +158 -16
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "backthread",
|
|
3
3
|
"displayName": "Backthread",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. It captures the why behind every Claude Code session so you can ask \"how does X work?\" without digging through PRs.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Backthread"
|
|
8
8
|
},
|
|
@@ -7746,7 +7746,7 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
|
|
|
7746
7746
|
|
|
7747
7747
|
// src/firstRun.ts
|
|
7748
7748
|
import { join as join9 } from "node:path";
|
|
7749
|
-
import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir6, chmod as
|
|
7749
|
+
import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir6, chmod as chmod5 } from "node:fs/promises";
|
|
7750
7750
|
|
|
7751
7751
|
// src/install.ts
|
|
7752
7752
|
import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "node:fs/promises";
|
|
@@ -8211,7 +8211,7 @@ import { execFile } from "node:child_process";
|
|
|
8211
8211
|
import { promisify } from "node:util";
|
|
8212
8212
|
import { homedir as homedir4 } from "node:os";
|
|
8213
8213
|
import { join as join7, dirname as dirname3 } from "node:path";
|
|
8214
|
-
import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
|
|
8214
|
+
import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4, chmod as chmod4 } from "node:fs/promises";
|
|
8215
8215
|
var execFileP = promisify(execFile);
|
|
8216
8216
|
var MCP_COMMAND = "npx";
|
|
8217
8217
|
var MCP_ARGS = ["-y", "backthread", "mcp"];
|
|
@@ -8325,28 +8325,97 @@ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
|
|
|
8325
8325
|
}
|
|
8326
8326
|
async function installCursor(home, deps) {
|
|
8327
8327
|
const doRead = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
|
|
8328
|
+
const nodeBinDir = deps.nodeBinDir ?? dirname3(process.execPath);
|
|
8328
8329
|
const writes = [];
|
|
8330
|
+
const scriptDir = join7(home, ".cursor", "hooks");
|
|
8331
|
+
const captureScriptPath = join7(scriptDir, "backthread-capture.sh");
|
|
8332
|
+
const mcpScriptPath = join7(scriptDir, "backthread-mcp.sh");
|
|
8333
|
+
writes.push(
|
|
8334
|
+
await writeCursorScript(
|
|
8335
|
+
deps,
|
|
8336
|
+
captureScriptPath,
|
|
8337
|
+
cursorWrapperScript(nodeBinDir, "capture --from-hook --agent cursor --detach")
|
|
8338
|
+
)
|
|
8339
|
+
);
|
|
8340
|
+
writes.push(await writeCursorScript(deps, mcpScriptPath, cursorWrapperScript(nodeBinDir, "mcp")));
|
|
8329
8341
|
const mcpPath = join7(home, ".cursor", "mcp.json");
|
|
8330
8342
|
const mcpCurrent = await loadJsonObject(doRead, mcpPath);
|
|
8331
|
-
const m =
|
|
8343
|
+
const m = withCursorMcpServer(mcpCurrent, mcpScriptPath);
|
|
8332
8344
|
if (m.changed) await writeJson(deps, mcpPath, m.next);
|
|
8333
8345
|
writes.push({ path: mcpPath, wrote: m.changed });
|
|
8334
8346
|
const hooksPath = join7(home, ".cursor", "hooks.json");
|
|
8335
8347
|
const hooksCurrent = await loadJsonObject(doRead, hooksPath);
|
|
8336
|
-
const c = withCursorStopHook(hooksCurrent);
|
|
8348
|
+
const c = withCursorStopHook(hooksCurrent, captureScriptPath);
|
|
8337
8349
|
if (c.changed) await writeJson(deps, hooksPath, c.next);
|
|
8338
8350
|
writes.push({ path: hooksPath, wrote: c.changed });
|
|
8339
8351
|
return writes;
|
|
8340
8352
|
}
|
|
8341
|
-
function
|
|
8342
|
-
|
|
8353
|
+
function shSingleQuote(s) {
|
|
8354
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
8355
|
+
}
|
|
8356
|
+
function cursorWrapperScript(nodeBinDir, backthreadArgs) {
|
|
8357
|
+
return [
|
|
8358
|
+
"#!/bin/sh",
|
|
8359
|
+
"# Backthread wrapper for Cursor \u2014 generated by `backthread install --agent cursor` (ARP-692).",
|
|
8360
|
+
"#",
|
|
8361
|
+
"# Cursor is a GUI app and does NOT inherit your login/nvm shell PATH, so a bare",
|
|
8362
|
+
"# `npx`/`node` here may be missing or resolve to a too-old system Node (Backthread",
|
|
8363
|
+
"# needs Node >= 22.18). Prepend the Node bin dir detected at install time so capture",
|
|
8364
|
+
"# and the MCP server always run on a new-enough Node. (npx's `#!/usr/bin/env node`",
|
|
8365
|
+
"# shebang re-resolves node from PATH, so pinning PATH \u2014 not just an absolute npx \u2014 is",
|
|
8366
|
+
"# what actually guarantees the right Node.)",
|
|
8367
|
+
"#",
|
|
8368
|
+
"# If your Node later moves (a new nvm version, an uninstall), re-run:",
|
|
8369
|
+
"# npx backthread install --agent cursor",
|
|
8370
|
+
`NODE_BIN_DIR=${shSingleQuote(nodeBinDir)}`,
|
|
8371
|
+
'if [ -d "$NODE_BIN_DIR" ]; then',
|
|
8372
|
+
' PATH="$NODE_BIN_DIR:$PATH"',
|
|
8373
|
+
" export PATH",
|
|
8374
|
+
"fi",
|
|
8375
|
+
`exec npx -y backthread ${backthreadArgs}`
|
|
8376
|
+
].join("\n") + "\n";
|
|
8377
|
+
}
|
|
8378
|
+
async function writeCursorScript(deps, path, content) {
|
|
8379
|
+
const doRead = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
|
|
8380
|
+
const doChmod = deps.chmodImpl ?? ((p, mode) => chmod4(p, mode));
|
|
8381
|
+
let existing = null;
|
|
8382
|
+
try {
|
|
8383
|
+
existing = await doRead(path);
|
|
8384
|
+
} catch (e) {
|
|
8385
|
+
if (!isNotFound2(e)) throw e;
|
|
8386
|
+
}
|
|
8387
|
+
if (existing === content) {
|
|
8388
|
+
await doChmod(path, 493).catch(() => {
|
|
8389
|
+
});
|
|
8390
|
+
return { path, wrote: false };
|
|
8391
|
+
}
|
|
8392
|
+
const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir4(d, { recursive: true }));
|
|
8393
|
+
const doWrite = deps.writeFileImpl ?? ((p, d) => writeFile4(p, d));
|
|
8394
|
+
await doMkdir(dirname3(path));
|
|
8395
|
+
await doWrite(path, content);
|
|
8396
|
+
await doChmod(path, 493);
|
|
8397
|
+
return { path, wrote: true };
|
|
8398
|
+
}
|
|
8399
|
+
function withCursorMcpServer(settings, mcpScriptPath) {
|
|
8400
|
+
const mcpServers = asObject(settings.mcpServers);
|
|
8401
|
+
const desired = { command: mcpScriptPath, args: [] };
|
|
8402
|
+
if (JSON.stringify(mcpServers.backthread) === JSON.stringify(desired)) {
|
|
8403
|
+
return { next: settings, changed: false };
|
|
8404
|
+
}
|
|
8405
|
+
mcpServers.backthread = desired;
|
|
8406
|
+
return { next: { ...settings, mcpServers }, changed: true };
|
|
8407
|
+
}
|
|
8408
|
+
function withCursorStopHook(settings, captureScriptPath) {
|
|
8409
|
+
const legacyInline = hookCommand("cursor");
|
|
8343
8410
|
const hooks = asObject(settings.hooks);
|
|
8344
8411
|
const stop = Array.isArray(hooks.stop) ? [...hooks.stop] : [];
|
|
8345
|
-
const
|
|
8412
|
+
const hadDesired = stop.some((h) => h?.command === captureScriptPath);
|
|
8413
|
+
const next = stop.filter((h) => h?.command !== legacyInline);
|
|
8414
|
+
const removedLegacy = next.length !== stop.length;
|
|
8415
|
+
if (!hadDesired) next.push({ command: captureScriptPath });
|
|
8346
8416
|
const hasVersion = typeof settings.version === "number";
|
|
8347
|
-
if (
|
|
8348
|
-
|
|
8349
|
-
hooks.stop = stop;
|
|
8417
|
+
if (hadDesired && !removedLegacy && hasVersion) return { next: settings, changed: false };
|
|
8418
|
+
hooks.stop = next;
|
|
8350
8419
|
return { next: { ...settings, version: hasVersion ? settings.version : 1, hooks }, changed: true };
|
|
8351
8420
|
}
|
|
8352
8421
|
function cursorDeeplink() {
|
|
@@ -8502,6 +8571,67 @@ function rewriteLegacyCommand(group) {
|
|
|
8502
8571
|
});
|
|
8503
8572
|
return changed ? { ...group, hooks: nextInner } : group;
|
|
8504
8573
|
}
|
|
8574
|
+
function stripSessionEndHook(settings) {
|
|
8575
|
+
const hooksVal = settings.hooks;
|
|
8576
|
+
if (!hooksVal || typeof hooksVal !== "object" || Array.isArray(hooksVal)) return null;
|
|
8577
|
+
const seVal = hooksVal.SessionEnd;
|
|
8578
|
+
if (!Array.isArray(seVal)) return null;
|
|
8579
|
+
let changed = false;
|
|
8580
|
+
const nextSessionEnd = [];
|
|
8581
|
+
for (const group of seVal) {
|
|
8582
|
+
const inner = group?.hooks;
|
|
8583
|
+
if (!group || typeof group !== "object" || !Array.isArray(inner)) {
|
|
8584
|
+
nextSessionEnd.push(group);
|
|
8585
|
+
continue;
|
|
8586
|
+
}
|
|
8587
|
+
const keptInner = inner.filter((h) => {
|
|
8588
|
+
const cmd = h?.command;
|
|
8589
|
+
const isOurs = typeof cmd === "string" && OUR_HOOK_COMMANDS.has(cmd);
|
|
8590
|
+
if (isOurs) changed = true;
|
|
8591
|
+
return !isOurs;
|
|
8592
|
+
});
|
|
8593
|
+
if (keptInner.length === 0) continue;
|
|
8594
|
+
if (keptInner.length !== inner.length) {
|
|
8595
|
+
nextSessionEnd.push({ ...group, hooks: keptInner });
|
|
8596
|
+
} else {
|
|
8597
|
+
nextSessionEnd.push(group);
|
|
8598
|
+
}
|
|
8599
|
+
}
|
|
8600
|
+
if (!changed) return null;
|
|
8601
|
+
const hooks = { ...hooksVal };
|
|
8602
|
+
if (nextSessionEnd.length === 0) delete hooks.SessionEnd;
|
|
8603
|
+
else hooks.SessionEnd = nextSessionEnd;
|
|
8604
|
+
const next = { ...settings, hooks };
|
|
8605
|
+
if (Object.keys(hooks).length === 0) delete next.hooks;
|
|
8606
|
+
return next;
|
|
8607
|
+
}
|
|
8608
|
+
async function unregisterProjectHook(cwd, deps = {}) {
|
|
8609
|
+
const doReadFile = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
|
|
8610
|
+
const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile5(p, d));
|
|
8611
|
+
const settingsPath = join8(cwd, ".claude", "settings.json");
|
|
8612
|
+
let raw;
|
|
8613
|
+
try {
|
|
8614
|
+
raw = await doReadFile(settingsPath);
|
|
8615
|
+
} catch (e) {
|
|
8616
|
+
if (isNotFound3(e)) return { stripped: false, path: settingsPath };
|
|
8617
|
+
throw e;
|
|
8618
|
+
}
|
|
8619
|
+
let parsed;
|
|
8620
|
+
try {
|
|
8621
|
+
parsed = JSON.parse(raw);
|
|
8622
|
+
} catch {
|
|
8623
|
+
throw new Error(
|
|
8624
|
+
`${settingsPath} exists but is not valid JSON \u2014 refusing to modify it. Remove the stale SessionEnd hook manually if present.`
|
|
8625
|
+
);
|
|
8626
|
+
}
|
|
8627
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8628
|
+
throw new Error(`${settingsPath} is not a JSON object \u2014 refusing to modify it.`);
|
|
8629
|
+
}
|
|
8630
|
+
const stripped = stripSessionEndHook(parsed);
|
|
8631
|
+
if (stripped === null) return { stripped: false, path: settingsPath };
|
|
8632
|
+
await doWriteFile(settingsPath, JSON.stringify(stripped, null, 2) + "\n");
|
|
8633
|
+
return { stripped: true, path: settingsPath };
|
|
8634
|
+
}
|
|
8505
8635
|
async function runInstall(opts = {}, deps = {}) {
|
|
8506
8636
|
const env = opts.env ?? process.env;
|
|
8507
8637
|
const log = opts.log ?? ((m) => console.error(m));
|
|
@@ -8551,6 +8681,7 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8551
8681
|
return { exitCode: exitCode2, authed, hookRegistered: agentResult !== null, backfill: null, agentResult };
|
|
8552
8682
|
}
|
|
8553
8683
|
let hookRegistered = false;
|
|
8684
|
+
let projectHookMigrated = false;
|
|
8554
8685
|
if (opts.skipHook) {
|
|
8555
8686
|
log("[2/3] Hook: skipped (registered by the plugin manifest).");
|
|
8556
8687
|
} else {
|
|
@@ -8564,6 +8695,17 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8564
8695
|
log(`[2/3] Hook: not registered \u2014 ${e.message}`);
|
|
8565
8696
|
log(" You can add it manually (see the README \u203A Registering the hook).");
|
|
8566
8697
|
}
|
|
8698
|
+
if (hookRegistered) {
|
|
8699
|
+
try {
|
|
8700
|
+
const { stripped, path } = await unregisterProjectHook(cwd, deps);
|
|
8701
|
+
if (stripped) {
|
|
8702
|
+
projectHookMigrated = true;
|
|
8703
|
+
log(` Migrated: removed the stale project-scope SessionEnd hook from ${path} (it now lives at user scope).`);
|
|
8704
|
+
}
|
|
8705
|
+
} catch (e) {
|
|
8706
|
+
log(` Note: left the project-scope settings.json untouched \u2014 ${e.message}`);
|
|
8707
|
+
}
|
|
8708
|
+
}
|
|
8567
8709
|
}
|
|
8568
8710
|
let backfill = null;
|
|
8569
8711
|
if (opts.skipBackfill) {
|
|
@@ -8580,7 +8722,7 @@ Backthread is set up for ${targetAgent}. New sessions are captured automatically
|
|
|
8580
8722
|
}
|
|
8581
8723
|
log("\nBackthread is set up. New sessions are captured automatically when they end.");
|
|
8582
8724
|
const exitCode = !authed && !opts.skipAuth ? 1 : 0;
|
|
8583
|
-
return { exitCode, authed, hookRegistered, backfill, agentResult: null };
|
|
8725
|
+
return { exitCode, authed, hookRegistered, backfill, agentResult: null, projectHookMigrated };
|
|
8584
8726
|
}
|
|
8585
8727
|
|
|
8586
8728
|
// src/entry.ts
|
|
@@ -8761,11 +8903,11 @@ async function updateFirstRunState(patch, env = process.env) {
|
|
|
8761
8903
|
const next = { ...current, ...patch };
|
|
8762
8904
|
const dir = configDir(env);
|
|
8763
8905
|
await mkdir6(dir, { recursive: true, mode: DIR_MODE });
|
|
8764
|
-
await
|
|
8906
|
+
await chmod5(dir, DIR_MODE).catch(() => {
|
|
8765
8907
|
});
|
|
8766
8908
|
const path = firstRunStatePath(env);
|
|
8767
8909
|
await writeFile6(path, JSON.stringify(next) + "\n", { mode: CONFIG_MODE });
|
|
8768
|
-
await
|
|
8910
|
+
await chmod5(path, CONFIG_MODE).catch(() => {
|
|
8769
8911
|
});
|
|
8770
8912
|
} catch {
|
|
8771
8913
|
}
|
|
@@ -9101,7 +9243,7 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
|
|
|
9101
9243
|
// src/fromHook.ts
|
|
9102
9244
|
import { spawn as spawn2 } from "node:child_process";
|
|
9103
9245
|
import { join as join10 } from "node:path";
|
|
9104
|
-
import { readFile as readFile9, writeFile as writeFile7, mkdir as mkdir7, chmod as
|
|
9246
|
+
import { readFile as readFile9, writeFile as writeFile7, mkdir as mkdir7, chmod as chmod6 } from "node:fs/promises";
|
|
9105
9247
|
var KNOWN_AGENTS = /* @__PURE__ */ new Set([
|
|
9106
9248
|
"claude-code",
|
|
9107
9249
|
"codex",
|
|
@@ -9174,11 +9316,11 @@ async function writeState2(state, env) {
|
|
|
9174
9316
|
try {
|
|
9175
9317
|
const dir = configDir(env);
|
|
9176
9318
|
await mkdir7(dir, { recursive: true, mode: DIR_MODE });
|
|
9177
|
-
await
|
|
9319
|
+
await chmod6(dir, DIR_MODE).catch(() => {
|
|
9178
9320
|
});
|
|
9179
9321
|
const path = captureStatePath(env);
|
|
9180
9322
|
await writeFile7(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
|
|
9181
|
-
await
|
|
9323
|
+
await chmod6(path, CONFIG_MODE).catch(() => {
|
|
9182
9324
|
});
|
|
9183
9325
|
} catch {
|
|
9184
9326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backthread",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Backthread helps you understand your codebase while AI ships features. The CLI captures the why behind every AI session and lets you ask how your codebase works, right from the terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Backthread",
|