backthread 0.3.0 → 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.
@@ -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.3.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 chmod4 } from "node:fs/promises";
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 = withMcpServer(mcpCurrent);
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 withCursorStopHook(settings) {
8342
- const command = hookCommand("cursor");
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 present = stop.some((h) => h?.command === command);
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 (present && hasVersion) return { next: settings, changed: false };
8348
- if (!present) stop.push({ command });
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 chmod4(dir, DIR_MODE).catch(() => {
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 chmod4(path, CONFIG_MODE).catch(() => {
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 chmod5 } from "node:fs/promises";
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 chmod5(dir, DIR_MODE).catch(() => {
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 chmod5(path, CONFIG_MODE).catch(() => {
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.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",