backthread 0.7.0 → 0.8.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.7.0",
5
+ "version": "0.8.0",
6
6
  "author": {
7
7
  "name": "Backthread"
8
8
  },
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/backthread?logo=npm)](https://www.npmjs.com/package/backthread)
4
4
  [![license](https://img.shields.io/npm/l/backthread?label=license)](./LICENSE)
5
5
 
6
- **Keep the thread on what your AI agent actually shipped.**
6
+ **Backthread keeps the thread on what your AI coding agent ships — it captures the why behind every change and turns it into a living 'How it works' view of your codebase you can actually query.**
7
7
 
8
8
  ```bash
9
9
  npx backthread
@@ -63,8 +63,9 @@ The bare command is the unified front door. Under the hood it:
63
63
 
64
64
  Then keep coding. At the end of every Claude Code session, Backthread captures
65
65
  the decisions automatically — nothing to remember. Ask *"how does X work?"* right
66
- inside Claude Code (the `backthread` MCP server exposes a `query` tool), or open
67
- the live diagram at [app.backthread.dev](https://app.backthread.dev).
66
+ from the terminal (`backthread how "how does auth work?"`) or inside Claude Code
67
+ (the `backthread` MCP server exposes a `query` tool + a `/backthread:how` slash
68
+ command), or open the live diagram at [app.backthread.dev](https://app.backthread.dev).
68
69
 
69
70
  ### Claude Code plugin (alternative)
70
71
 
@@ -102,15 +103,18 @@ instead, and Codex users the [plugin](https://github.com/backthread/backthread/t
102
103
  ## Commands
103
104
 
104
105
  ```
105
- backthread Set up Backthread — the unified front door (sign in + connect + capture).
106
- Idempotent: a returning user is told they're good to go.
107
- backthread install Set up capture for this repo (sign in + hook + backfill)
108
- backthread start First-run for the Claude Code plugin (sign in + your next step)
109
- backthread login Authorize this device (opens your browser)
110
- backthread whoami Show this device's config (your token is never printed)
111
- backthread capture Capture a session's decisions (run automatically by the hook)
112
- backthread mcp Start the MCP server the capture + "how does X work?" query tools
113
- backthread help Show usage
106
+ backthread Set up Backthread — the front door (sign in + connect + capture).
107
+ Idempotent: a returning user is told they're good to go.
108
+ backthread how "<question>" Ask how/why something works a grounded, cited answer from your log
109
+ backthread install Set up capture for this repo (sign in + hook + backfill)
110
+ backthread login / logout Authorize this device / sign it out (drops the local token)
111
+ backthread doctor Diagnose your setup (auth, hook, connectivity, version, repo)
112
+ backthread update Update a global install to the latest (also -u)
113
+ backthread version Print the installed version (also --version, -v)
114
+ backthread whoami Show this device's config (your token is never printed)
115
+ backthread capture Capture a session's decisions (run automatically by the hook)
116
+ backthread mcp Start the MCP server — the capture + "how does X work?" query tools
117
+ backthread help Show the full usage (also --help, -h)
114
118
  ```
115
119
 
116
120
  ## Requirements
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __commonJS = (cb, mod) => function __require() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ try {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ } catch (e) {
12
+ throw mod = 0, e;
13
+ }
10
14
  };
11
15
  var __export = (target, all) => {
12
16
  for (var name in all)
@@ -6887,7 +6891,7 @@ var require_dist = __commonJS({
6887
6891
 
6888
6892
  // src/bin/backthread.ts
6889
6893
  import { fileURLToPath as fileURLToPath2 } from "node:url";
6890
- import { realpathSync } from "node:fs";
6894
+ import { realpathSync as realpathSync2 } from "node:fs";
6891
6895
 
6892
6896
  // src/login.ts
6893
6897
  import { hostname } from "node:os";
@@ -6945,7 +6949,7 @@ function browserCommand(platform) {
6945
6949
  case "darwin":
6946
6950
  return { cmd: "open", prefixArgs: [] };
6947
6951
  case "win32":
6948
- return { cmd: "cmd", prefixArgs: ["/c", "start", ""] };
6952
+ return { cmd: "rundll32", prefixArgs: ["url.dll,FileProtocolHandler"] };
6949
6953
  default:
6950
6954
  return { cmd: "xdg-open", prefixArgs: [] };
6951
6955
  }
@@ -7258,7 +7262,7 @@ async function pollForToken(sessionId, keypair, opts = {}) {
7258
7262
  return { ok: false, reason: "expired", message: "the login session expired before you authorized" };
7259
7263
  }
7260
7264
  if (status === "consumed") {
7261
- return { ok: false, reason: "error", message: "this login was already used \u2014 start a fresh `bt login`" };
7265
+ return { ok: false, reason: "error", message: "this login was already used \u2014 start a fresh `backthread login`" };
7262
7266
  }
7263
7267
  await sleep(interval);
7264
7268
  }
@@ -7348,8 +7352,416 @@ async function ensureAuth(opts = {}) {
7348
7352
  return readConfig(env);
7349
7353
  }
7350
7354
 
7355
+ // src/logout.ts
7356
+ async function runLogout(env = process.env) {
7357
+ const where = configLocationHint3(env);
7358
+ let cfg;
7359
+ try {
7360
+ cfg = await readConfig(env);
7361
+ } catch (err) {
7362
+ return { ok: false, cleared: false, message: `Couldn't read ${where} to sign out (${err.message ?? err}). Check its permissions and retry.` };
7363
+ }
7364
+ if (!cfg.device_token) {
7365
+ return { ok: true, cleared: false, message: `Already signed out \u2014 no device token in ${where}.` };
7366
+ }
7367
+ const next = {};
7368
+ if (cfg.account !== void 0) next.account = cfg.account;
7369
+ if (cfg.repo !== void 0) next.repo = cfg.repo;
7370
+ await writeConfig(next, env);
7371
+ const kept = cfg.repo ? ` (kept your ${cfg.repo} link)` : "";
7372
+ return {
7373
+ ok: true,
7374
+ cleared: true,
7375
+ message: `Signed out. Removed this device's token from ${where}${kept}.
7376
+ Revoke it server-side under Account \u2192 Connected devices; \`backthread login\` re-authorizes.`
7377
+ };
7378
+ }
7379
+ function configLocationHint3(env) {
7380
+ return env.BACKTHREAD_CONFIG_DIR ? configPath(env) : "~/.backthread/config.json";
7381
+ }
7382
+
7383
+ // src/update.ts
7384
+ import { realpathSync } from "node:fs";
7385
+
7386
+ // src/upgradeNudge.ts
7387
+ import { join as join3 } from "node:path";
7388
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, chmod as chmod2 } from "node:fs/promises";
7389
+ function upgradeNudgeStatePath(env = process.env) {
7390
+ return join3(configDir(env), "upgrade-nudge.json");
7391
+ }
7392
+ var UPGRADE_NUDGE_THROTTLE_MS = 24 * 60 * 60 * 1e3;
7393
+ function parseState(raw) {
7394
+ try {
7395
+ const obj = JSON.parse(raw);
7396
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
7397
+ const at = obj.lastUpgradeNudgeAt;
7398
+ if (typeof at === "number" && Number.isFinite(at)) return { lastUpgradeNudgeAt: at };
7399
+ }
7400
+ } catch {
7401
+ }
7402
+ return {};
7403
+ }
7404
+ async function readState(env) {
7405
+ try {
7406
+ return parseState(await readFile2(upgradeNudgeStatePath(env), "utf8"));
7407
+ } catch {
7408
+ return {};
7409
+ }
7410
+ }
7411
+ async function writeState(state, env) {
7412
+ try {
7413
+ const dir = configDir(env);
7414
+ await mkdir2(dir, { recursive: true, mode: DIR_MODE });
7415
+ await chmod2(dir, DIR_MODE).catch(() => {
7416
+ });
7417
+ const path = upgradeNudgeStatePath(env);
7418
+ await writeFile2(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
7419
+ await chmod2(path, CONFIG_MODE).catch(() => {
7420
+ });
7421
+ } catch {
7422
+ }
7423
+ }
7424
+ async function maybeUpgradeNudge(upgrade, deps = {}) {
7425
+ try {
7426
+ if (typeof upgrade !== "string" || upgrade.trim().length === 0) return null;
7427
+ const env = deps.env ?? process.env;
7428
+ const now = deps.now ? deps.now() : Date.now();
7429
+ const state = await readState(env);
7430
+ if (typeof state.lastUpgradeNudgeAt === "number" && now - state.lastUpgradeNudgeAt < UPGRADE_NUDGE_THROTTLE_MS) {
7431
+ return null;
7432
+ }
7433
+ await writeState({ lastUpgradeNudgeAt: now }, env);
7434
+ return upgrade.trim();
7435
+ } catch {
7436
+ return null;
7437
+ }
7438
+ }
7439
+ async function resetUpgradeNudge(deps = {}) {
7440
+ try {
7441
+ const env = deps.env ?? process.env;
7442
+ const now = deps.now ? deps.now() : Date.now();
7443
+ await writeState({ lastUpgradeNudgeAt: now }, env);
7444
+ } catch {
7445
+ }
7446
+ }
7447
+
7448
+ // src/npm.ts
7449
+ import { execFile } from "node:child_process";
7450
+ function runNpm(args) {
7451
+ const isWin = process.platform === "win32";
7452
+ const npm = isWin ? "npm.cmd" : "npm";
7453
+ return new Promise((resolve) => {
7454
+ try {
7455
+ execFile(
7456
+ npm,
7457
+ args,
7458
+ { timeout: 12e4, windowsHide: true, shell: isWin, maxBuffer: 8 * 1024 * 1024 },
7459
+ (err, stdout, stderr) => {
7460
+ resolve({
7461
+ ok: !err,
7462
+ stdout: (stdout ?? "").toString().trim(),
7463
+ stderr: (stderr ?? "").toString().trim()
7464
+ });
7465
+ }
7466
+ );
7467
+ } catch (e) {
7468
+ resolve({ ok: false, stdout: "", stderr: e.message ?? String(e) });
7469
+ }
7470
+ });
7471
+ }
7472
+
7473
+ // src/update.ts
7474
+ var SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
7475
+ var NPX_SEGMENT_RE = /(?:^|[\\/])_npx[\\/]/;
7476
+ function detectInstallContext(env, scriptPath) {
7477
+ if (typeof env.CLAUDE_PLUGIN_ROOT === "string" && env.CLAUDE_PLUGIN_ROOT.trim().length > 0) return "plugin";
7478
+ if (scriptPath && NPX_SEGMENT_RE.test(scriptPath)) return "npx";
7479
+ return "global";
7480
+ }
7481
+ function resolveScriptPath() {
7482
+ const raw = process.argv[1] ?? "";
7483
+ if (!raw) return "";
7484
+ if (NPX_SEGMENT_RE.test(raw)) return raw;
7485
+ try {
7486
+ return realpathSync(raw);
7487
+ } catch {
7488
+ return raw;
7489
+ }
7490
+ }
7491
+ function firstLine(s) {
7492
+ const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
7493
+ return line ?? "unknown npm error";
7494
+ }
7495
+ async function runUpdate(deps = {}) {
7496
+ const env = deps.env ?? process.env;
7497
+ const log = deps.log ?? ((m) => console.error(m));
7498
+ const current = (deps.currentVersion ?? cliVersion)();
7499
+ const runNpm2 = deps.runNpm ?? runNpm;
7500
+ const resetNudge = deps.resetNudge ?? ((e) => resetUpgradeNudge({ env: e }));
7501
+ const scriptPath = deps.scriptPath ?? resolveScriptPath();
7502
+ const context = detectInstallContext(env, scriptPath);
7503
+ if (context === "npx") {
7504
+ return {
7505
+ ok: true,
7506
+ context,
7507
+ updated: false,
7508
+ message: "You're running Backthread via `npx`, which already fetches the latest published version\non every run \u2014 nothing to update. Want a pinned, always-available binary?\n npm i -g backthread\nThen `backthread update` pulls new releases on demand."
7509
+ };
7510
+ }
7511
+ if (context === "plugin") {
7512
+ return {
7513
+ ok: true,
7514
+ context,
7515
+ updated: false,
7516
+ message: "This is the Claude Code plugin's bundled copy of Backthread \u2014 the plugin manages it,\nnot npm. Update it from Claude Code:\n /plugin update backthread\nFor a standalone terminal CLI too: `npm i -g backthread`."
7517
+ };
7518
+ }
7519
+ log("Checking npm for the latest backthread\u2026");
7520
+ const view = await runNpm2(["view", "backthread", "version"]);
7521
+ if (!view.ok || !SEMVER_RE.test(view.stdout)) {
7522
+ const why = view.ok ? `unexpected npm output "${view.stdout}"` : firstLine(view.stderr);
7523
+ return {
7524
+ ok: false,
7525
+ context,
7526
+ updated: false,
7527
+ message: `Couldn't check npm for the latest version (${why}). Are you online? Your current install (${current}) is untouched.`
7528
+ };
7529
+ }
7530
+ const latest = view.stdout;
7531
+ if (current === latest) {
7532
+ await resetNudge(env);
7533
+ return { ok: true, context, updated: false, message: `Backthread is already up to date (${current} is the latest).` };
7534
+ }
7535
+ log(`Updating backthread ${current} \u2192 ${latest} (npm i -g backthread@latest)\u2026`);
7536
+ const install = await runNpm2(["install", "-g", "backthread@latest"]);
7537
+ if (!install.ok) {
7538
+ return {
7539
+ ok: false,
7540
+ context,
7541
+ updated: false,
7542
+ message: `npm couldn't install backthread@latest: ${firstLine(install.stderr)}
7543
+ Your current install (${current}) is untouched. If this is a permissions error, retry with your global-install method (e.g. a Node version manager, or sudo).`
7544
+ };
7545
+ }
7546
+ await resetNudge(env);
7547
+ return {
7548
+ ok: true,
7549
+ context,
7550
+ updated: true,
7551
+ message: `Updated Backthread ${current} \u2192 ${latest}. Restart any long-running sessions to pick it up.`
7552
+ };
7553
+ }
7554
+
7555
+ // src/doctor.ts
7556
+ import { homedir as homedir2 } from "node:os";
7557
+ import { join as join4 } from "node:path";
7558
+ import { readFile as readFile3, stat } from "node:fs/promises";
7559
+ var SEMVER_RE2 = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
7560
+ var REPO_SLUG_RE = /^[^/\s]+\/[^/\s]+$/;
7561
+ async function loadConfig(env) {
7562
+ try {
7563
+ return { config: await readConfig(env), error: null };
7564
+ } catch (e) {
7565
+ return { config: null, error: e };
7566
+ }
7567
+ }
7568
+ function authCheck(loaded, env) {
7569
+ if (loaded.error) {
7570
+ return {
7571
+ key: "auth",
7572
+ label: "Auth",
7573
+ status: "fail",
7574
+ critical: true,
7575
+ detail: `couldn't read ${configHint(env)} (${loaded.error.message ?? loaded.error}) \u2014 check its permissions`
7576
+ };
7577
+ }
7578
+ if (loaded.config?.device_token) {
7579
+ return { key: "auth", label: "Auth", status: "ok", detail: "signed in (device token present)" };
7580
+ }
7581
+ return { key: "auth", label: "Auth", status: "fail", critical: true, detail: "not signed in \u2014 run `backthread login`" };
7582
+ }
7583
+ async function permsCheck(deps, env) {
7584
+ if (process.platform === "win32") {
7585
+ return { key: "perms", label: "Config perms", status: "info", detail: "n/a on Windows (POSIX modes not enforced)" };
7586
+ }
7587
+ const doStat = deps.statImpl ?? ((p) => stat(p));
7588
+ const filePath = configPath(env);
7589
+ const dirPath = configDir(env);
7590
+ let fileMode = null;
7591
+ let dirMode = null;
7592
+ try {
7593
+ fileMode = (await doStat(filePath)).mode & 511;
7594
+ } catch {
7595
+ return { key: "perms", label: "Config perms", status: "info", detail: "no config file yet (run `backthread login`)" };
7596
+ }
7597
+ try {
7598
+ dirMode = (await doStat(dirPath)).mode & 511;
7599
+ } catch {
7600
+ dirMode = null;
7601
+ }
7602
+ const fileLoose = (fileMode & 63) !== 0;
7603
+ const dirLoose = dirMode !== null && (dirMode & 63) !== 0;
7604
+ if (fileLoose || dirLoose) {
7605
+ return {
7606
+ key: "perms",
7607
+ label: "Config perms",
7608
+ status: "warn",
7609
+ detail: `too open (config ${octal(fileMode)}${dirMode !== null ? `, dir ${octal(dirMode)}` : ""}) \u2014 run \`chmod 600 ${configHint(env)}\` (dir 700)`
7610
+ };
7611
+ }
7612
+ return { key: "perms", label: "Config perms", status: "ok", detail: `config 0600${dirMode !== null ? ", dir 0700" : ""}` };
7613
+ }
7614
+ function repoCheck(loaded) {
7615
+ if (loaded.error) {
7616
+ return { key: "repo", label: "Repo", status: "warn", detail: "could not read the connected repo" };
7617
+ }
7618
+ const repo = loaded.config?.repo;
7619
+ if (repo && REPO_SLUG_RE.test(repo)) {
7620
+ return { key: "repo", label: "Repo", status: "ok", detail: repo };
7621
+ }
7622
+ if (repo) {
7623
+ return { key: "repo", label: "Repo", status: "warn", detail: `connected slug "${repo}" is not owner/name \u2014 reconnect in the web app` };
7624
+ }
7625
+ return { key: "repo", label: "Repo", status: "warn", detail: "no repo connected \u2014 run `backthread install` (or connect it in the web app)" };
7626
+ }
7627
+ var AGENT_HOOK_FILES = [
7628
+ { agent: "claude-code", files: (h) => [join4(h, ".claude", "settings.json")] },
7629
+ { agent: "gemini", files: (h) => [join4(h, ".gemini", "settings.json")] },
7630
+ { agent: "codex", files: (h) => [join4(h, ".codex", "hooks.json"), join4(h, ".codex", "config.toml")] },
7631
+ { agent: "cursor", files: (h) => [join4(h, ".cursor", "hooks.json"), join4(h, ".cursor", "mcp.json")] }
7632
+ ];
7633
+ async function hookCheck(deps, env) {
7634
+ const home = deps.home ?? homedir2();
7635
+ const cwd = deps.cwd ?? process.cwd();
7636
+ const doRead = deps.readFileImpl ?? ((p) => readFile3(p, "utf8"));
7637
+ const mentions = async (path) => {
7638
+ try {
7639
+ return (await doRead(path)).includes("backthread");
7640
+ } catch {
7641
+ return false;
7642
+ }
7643
+ };
7644
+ const asPlugin = typeof env.CLAUDE_PLUGIN_ROOT === "string" && env.CLAUDE_PLUGIN_ROOT.trim().length > 0;
7645
+ const wired = [];
7646
+ if (asPlugin) wired.push("claude-code (plugin)");
7647
+ for (const { agent, files } of AGENT_HOOK_FILES) {
7648
+ for (const f of files(home)) {
7649
+ if (await mentions(f)) {
7650
+ wired.push(agent);
7651
+ break;
7652
+ }
7653
+ }
7654
+ }
7655
+ const projectScoped = await mentions(join4(cwd, ".claude", "settings.json")) || await mentions(join4(cwd, ".claude", "settings.local.json"));
7656
+ const userScopedCC = asPlugin || await mentions(join4(home, ".claude", "settings.json"));
7657
+ const uniqueWired = Array.from(new Set(wired));
7658
+ if (projectScoped && !userScopedCC) {
7659
+ return {
7660
+ key: "hook",
7661
+ label: "Capture hook",
7662
+ status: "warn",
7663
+ detail: "PROJECT-scoped only \u2014 blind in git worktrees + other repos (ARP-680). Re-run `backthread install` for the user-scope hook."
7664
+ };
7665
+ }
7666
+ if (uniqueWired.length > 0) {
7667
+ return { key: "hook", label: "Capture hook", status: "ok", detail: `wired for ${uniqueWired.join(", ")}` };
7668
+ }
7669
+ return {
7670
+ key: "hook",
7671
+ label: "Capture hook",
7672
+ status: "warn",
7673
+ detail: "not detected \u2014 run `backthread install` here (or `backthread install --agent <codex|cursor|gemini>`)"
7674
+ };
7675
+ }
7676
+ async function connectivityCheck(deps, env) {
7677
+ const doFetch = deps.fetchImpl ?? fetch;
7678
+ const timeout = deps.connectTimeoutMs ?? 5e3;
7679
+ const targets = [
7680
+ { name: "worker", url: workerBaseUrl(env) },
7681
+ { name: "functions", url: functionsBaseUrl(env) }
7682
+ ];
7683
+ const results = await Promise.all(
7684
+ targets.map(async ({ name, url: url2 }) => {
7685
+ const controller = new AbortController();
7686
+ const timer = setTimeout(() => controller.abort(), timeout);
7687
+ try {
7688
+ const res = await doFetch(url2, { method: "GET", signal: controller.signal });
7689
+ await res.body?.cancel?.().catch(() => {
7690
+ });
7691
+ return { name, reachable: true };
7692
+ } catch {
7693
+ return { name, reachable: false };
7694
+ } finally {
7695
+ clearTimeout(timer);
7696
+ }
7697
+ })
7698
+ );
7699
+ const down = results.filter((r) => !r.reachable).map((r) => r.name);
7700
+ if (down.length === 0) {
7701
+ return { key: "connectivity", label: "Connectivity", status: "ok", detail: "worker + functions reachable" };
7702
+ }
7703
+ return {
7704
+ key: "connectivity",
7705
+ label: "Connectivity",
7706
+ status: "warn",
7707
+ detail: `couldn't reach ${down.join(" + ")} (offline, or blocked by a proxy/firewall?)`
7708
+ };
7709
+ }
7710
+ async function versionCheck(deps) {
7711
+ const current = cliVersion();
7712
+ const redact = redactVersion();
7713
+ const base = `backthread ${current} \xB7 redact ${redact}`;
7714
+ const runNpm2 = deps.runNpm ?? runNpm;
7715
+ const view = await runNpm2(["view", "backthread", "version"]);
7716
+ if (!view.ok || !SEMVER_RE2.test(view.stdout)) {
7717
+ return { key: "version", label: "Version", status: "info", detail: `${base} (couldn't check npm for the latest \u2014 offline?)` };
7718
+ }
7719
+ const latest = view.stdout;
7720
+ if (current === latest) {
7721
+ return { key: "version", label: "Version", status: "ok", detail: `${base} (latest)` };
7722
+ }
7723
+ return { key: "version", label: "Version", status: "info", detail: `${base} \u2014 update available (${latest}): \`backthread update\`` };
7724
+ }
7725
+ async function collectChecks(deps = {}) {
7726
+ const env = deps.env ?? process.env;
7727
+ const loaded = await loadConfig(env);
7728
+ const [perms, hook, connectivity, version2] = await Promise.all([
7729
+ permsCheck(deps, env),
7730
+ hookCheck(deps, env),
7731
+ connectivityCheck(deps, env),
7732
+ versionCheck(deps)
7733
+ ]);
7734
+ return [authCheck(loaded, env), perms, repoCheck(loaded), hook, connectivity, version2];
7735
+ }
7736
+ var GLYPH = { ok: "\u2713", fail: "\u2717", warn: "\u26A0", info: "\u2139" };
7737
+ function formatReport(checks) {
7738
+ const width = Math.max(...checks.map((c) => c.label.length));
7739
+ const lines = checks.map((c) => `${GLYPH[c.status]} ${c.label.padEnd(width)} ${c.detail}`);
7740
+ const fails = checks.filter((c) => c.status === "fail").length;
7741
+ const warns = checks.filter((c) => c.status === "warn").length;
7742
+ let summary;
7743
+ if (fails > 0) summary = `
7744
+ ${fails} issue${fails === 1 ? "" : "s"} to fix \u2014 see the \u2717 above, then re-run \`backthread doctor\`.`;
7745
+ else if (warns > 0) summary = `
7746
+ Mostly good \u2014 the \u26A0 above are worth a look but capture can still run.`;
7747
+ else summary = `
7748
+ All good \u2014 Backthread is set up. \u{1F9F5}`;
7749
+ return ["backthread doctor\n", ...lines, summary].join("\n");
7750
+ }
7751
+ async function runDoctor(deps = {}) {
7752
+ const checks = await collectChecks(deps);
7753
+ const exitCode = checks.some((c) => c.status === "fail" && c.critical) ? 1 : 0;
7754
+ return { text: formatReport(checks), exitCode, checks };
7755
+ }
7756
+ function octal(mode) {
7757
+ return "0" + mode.toString(8).padStart(3, "0");
7758
+ }
7759
+ function configHint(env) {
7760
+ return env.BACKTHREAD_CONFIG_DIR ? configPath(env) : "~/.backthread/config.json";
7761
+ }
7762
+
7351
7763
  // src/capture.ts
7352
- import { readFile as readFile9 } from "node:fs/promises";
7764
+ import { readFile as readFile10 } from "node:fs/promises";
7353
7765
 
7354
7766
  // ../packages/redact/src/index.ts
7355
7767
  var CODE_REDACTION = "[code redacted]";
@@ -7709,8 +8121,8 @@ async function inferDecisions(transcript, config2, opts = {}) {
7709
8121
  }
7710
8122
 
7711
8123
  // src/connectNudge.ts
7712
- import { join as join3 } from "node:path";
7713
- import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, chmod as chmod2 } from "node:fs/promises";
8124
+ import { join as join5 } from "node:path";
8125
+ import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod3 } from "node:fs/promises";
7714
8126
  function parseRepoStatus(value) {
7715
8127
  return value === "connected" || value === "not_connected" || value === "disconnected" ? value : null;
7716
8128
  }
@@ -7723,10 +8135,10 @@ function parseNextStep(value) {
7723
8135
  return "absent";
7724
8136
  }
7725
8137
  function nudgeStatePath(env = process.env) {
7726
- return join3(configDir(env), "connect-nudge.json");
8138
+ return join5(configDir(env), "connect-nudge.json");
7727
8139
  }
7728
8140
  var MAX_REMEMBERED = 50;
7729
- function parseState(raw) {
8141
+ function parseState2(raw) {
7730
8142
  try {
7731
8143
  const obj = JSON.parse(raw);
7732
8144
  if (obj && typeof obj === "object" && Array.isArray(obj.nudged)) {
@@ -7737,22 +8149,22 @@ function parseState(raw) {
7737
8149
  }
7738
8150
  return { nudged: [] };
7739
8151
  }
7740
- async function readState(env) {
8152
+ async function readState2(env) {
7741
8153
  try {
7742
- return parseState(await readFile2(nudgeStatePath(env), "utf8"));
8154
+ return parseState2(await readFile4(nudgeStatePath(env), "utf8"));
7743
8155
  } catch {
7744
8156
  return { nudged: [] };
7745
8157
  }
7746
8158
  }
7747
- async function writeState(state, env) {
8159
+ async function writeState2(state, env) {
7748
8160
  try {
7749
8161
  const dir = configDir(env);
7750
- await mkdir2(dir, { recursive: true, mode: DIR_MODE });
7751
- await chmod2(dir, DIR_MODE).catch(() => {
8162
+ await mkdir3(dir, { recursive: true, mode: DIR_MODE });
8163
+ await chmod3(dir, DIR_MODE).catch(() => {
7752
8164
  });
7753
8165
  const path = nudgeStatePath(env);
7754
- await writeFile2(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
7755
- await chmod2(path, CONFIG_MODE).catch(() => {
8166
+ await writeFile3(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
8167
+ await chmod3(path, CONFIG_MODE).catch(() => {
7756
8168
  });
7757
8169
  } catch {
7758
8170
  }
@@ -7790,12 +8202,12 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
7790
8202
  }
7791
8203
  if (line === null) return false;
7792
8204
  const log = deps.log ?? ((m) => console.error(m));
7793
- const state = await readState(env);
8205
+ const state = await readState2(env);
7794
8206
  if (state.nudged.includes(sessionId)) return false;
7795
8207
  log(line);
7796
8208
  const nudged = [...state.nudged, sessionId];
7797
8209
  if (nudged.length > MAX_REMEMBERED) nudged.splice(0, nudged.length - MAX_REMEMBERED);
7798
- await writeState({ nudged }, env);
8210
+ await writeState2({ nudged }, env);
7799
8211
  return true;
7800
8212
  } catch {
7801
8213
  return false;
@@ -7803,84 +8215,28 @@ async function maybeNudge(status, repo, sessionId, deps = {}) {
7803
8215
  }
7804
8216
 
7805
8217
  // src/firstRun.ts
7806
- import { join as join10 } from "node:path";
7807
- import { readFile as readFile8, writeFile as writeFile7, mkdir as mkdir7, chmod as chmod6 } from "node:fs/promises";
8218
+ import { join as join11 } from "node:path";
8219
+ import { readFile as readFile9, writeFile as writeFile7, mkdir as mkdir7, chmod as chmod6 } from "node:fs/promises";
7808
8220
 
7809
8221
  // src/install.ts
7810
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
7811
- import { homedir as homedir5 } from "node:os";
7812
- import { join as join9 } from "node:path";
7813
-
7814
- // src/captureCommand.ts
7815
- import { stat } from "node:fs/promises";
7816
- import { homedir as homedir2 } from "node:os";
7817
- import { join as join5 } from "node:path";
7818
-
7819
- // src/upgradeNudge.ts
7820
- import { join as join4 } from "node:path";
7821
- import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3, chmod as chmod3 } from "node:fs/promises";
7822
- function upgradeNudgeStatePath(env = process.env) {
7823
- return join4(configDir(env), "upgrade-nudge.json");
7824
- }
7825
- var UPGRADE_NUDGE_THROTTLE_MS = 24 * 60 * 60 * 1e3;
7826
- function parseState2(raw) {
7827
- try {
7828
- const obj = JSON.parse(raw);
7829
- if (obj && typeof obj === "object" && !Array.isArray(obj)) {
7830
- const at = obj.lastUpgradeNudgeAt;
7831
- if (typeof at === "number" && Number.isFinite(at)) return { lastUpgradeNudgeAt: at };
7832
- }
7833
- } catch {
7834
- }
7835
- return {};
7836
- }
7837
- async function readState2(env) {
7838
- try {
7839
- return parseState2(await readFile3(upgradeNudgeStatePath(env), "utf8"));
7840
- } catch {
7841
- return {};
7842
- }
7843
- }
7844
- async function writeState2(state, env) {
7845
- try {
7846
- const dir = configDir(env);
7847
- await mkdir3(dir, { recursive: true, mode: DIR_MODE });
7848
- await chmod3(dir, DIR_MODE).catch(() => {
7849
- });
7850
- const path = upgradeNudgeStatePath(env);
7851
- await writeFile3(path, JSON.stringify(state) + "\n", { mode: CONFIG_MODE });
7852
- await chmod3(path, CONFIG_MODE).catch(() => {
7853
- });
7854
- } catch {
7855
- }
7856
- }
7857
- async function maybeUpgradeNudge(upgrade, deps = {}) {
7858
- try {
7859
- if (typeof upgrade !== "string" || upgrade.trim().length === 0) return null;
7860
- const env = deps.env ?? process.env;
7861
- const now = deps.now ? deps.now() : Date.now();
7862
- const state = await readState2(env);
7863
- if (typeof state.lastUpgradeNudgeAt === "number" && now - state.lastUpgradeNudgeAt < UPGRADE_NUDGE_THROTTLE_MS) {
7864
- return null;
7865
- }
7866
- await writeState2({ lastUpgradeNudgeAt: now }, env);
7867
- return upgrade.trim();
7868
- } catch {
7869
- return null;
7870
- }
7871
- }
8222
+ import { readFile as readFile8, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
8223
+ import { homedir as homedir6 } from "node:os";
8224
+ import { join as join10 } from "node:path";
7872
8225
 
7873
8226
  // src/captureCommand.ts
8227
+ import { stat as stat2 } from "node:fs/promises";
8228
+ import { homedir as homedir3 } from "node:os";
8229
+ import { join as join6 } from "node:path";
7874
8230
  function slugifyCwd(cwd) {
7875
8231
  return cwd.replace(/[^A-Za-z0-9]/g, "-");
7876
8232
  }
7877
8233
  function deriveTranscriptPath(sessionId, cwd, home) {
7878
8234
  if (!sessionId || sessionId.trim().length === 0) return null;
7879
- return join5(home, ".claude", "projects", slugifyCwd(cwd), `${sessionId}.jsonl`);
8235
+ return join6(home, ".claude", "projects", slugifyCwd(cwd), `${sessionId}.jsonl`);
7880
8236
  }
7881
8237
  async function defaultStat(path) {
7882
8238
  try {
7883
- const s = await stat(path);
8239
+ const s = await stat2(path);
7884
8240
  return s.isFile();
7885
8241
  } catch {
7886
8242
  return false;
@@ -7889,7 +8245,7 @@ async function defaultStat(path) {
7889
8245
  async function resolveTranscriptPath(input, deps = {}) {
7890
8246
  const explicit = input.transcriptPath;
7891
8247
  if (explicit && explicit.trim().length > 0) return explicit;
7892
- const home = (deps.homedirImpl ?? homedir2)();
8248
+ const home = (deps.homedirImpl ?? homedir3)();
7893
8249
  const cwd = input.cwd ?? process.cwd();
7894
8250
  const derived = deriveTranscriptPath(input.sessionId, cwd, home);
7895
8251
  if (!derived) return null;
@@ -7980,17 +8336,17 @@ function parseManualArgs(argv) {
7980
8336
  }
7981
8337
 
7982
8338
  // src/sweep.ts
7983
- import { readFile as readFile5, stat as stat2, readdir } from "node:fs/promises";
8339
+ import { readFile as readFile6, stat as stat3, readdir } from "node:fs/promises";
7984
8340
  import { execFileSync as execFileSync2 } from "node:child_process";
7985
- import { homedir as homedir3 } from "node:os";
7986
- import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "node:path";
8341
+ import { homedir as homedir4 } from "node:os";
8342
+ import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join8 } from "node:path";
7987
8343
 
7988
8344
  // src/sweepLedger.ts
7989
- import { join as join6 } from "node:path";
7990
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4, chmod as chmod4 } from "node:fs/promises";
8345
+ import { join as join7 } from "node:path";
8346
+ import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4, chmod as chmod4 } from "node:fs/promises";
7991
8347
  var MAX_PROCESSED = 2e4;
7992
8348
  function sweepStatePath(env = process.env) {
7993
- return join6(configDir(env), "sweep-state.json");
8349
+ return join7(configDir(env), "sweep-state.json");
7994
8350
  }
7995
8351
  function parseSweepState(raw) {
7996
8352
  try {
@@ -8015,7 +8371,7 @@ function serializeSweepState(state) {
8015
8371
  }
8016
8372
  async function readSweepState(env = process.env) {
8017
8373
  try {
8018
- return parseSweepState(await readFile4(sweepStatePath(env), "utf8"));
8374
+ return parseSweepState(await readFile5(sweepStatePath(env), "utf8"));
8019
8375
  } catch {
8020
8376
  return { processed: [], lastSweptAt: {} };
8021
8377
  }
@@ -8118,7 +8474,7 @@ async function defaultReadDir(dir) {
8118
8474
  }
8119
8475
  async function defaultPathExists(path) {
8120
8476
  try {
8121
- await stat2(path);
8477
+ await stat3(path);
8122
8478
  return true;
8123
8479
  } catch {
8124
8480
  return false;
@@ -8132,7 +8488,7 @@ function defaultMainRoot(cwd) {
8132
8488
  stdio: ["ignore", "pipe", "ignore"]
8133
8489
  }).trim();
8134
8490
  if (!out) return null;
8135
- const abs = isAbsolute2(out) ? out : join7(cwd, out);
8491
+ const abs = isAbsolute2(out) ? out : join8(cwd, out);
8136
8492
  return dirname2(abs.replace(/\/+$/, ""));
8137
8493
  } catch {
8138
8494
  return null;
@@ -8150,7 +8506,7 @@ async function runSweep(input = {}, deps = {}) {
8150
8506
  return [];
8151
8507
  }
8152
8508
  };
8153
- const baseReadFile = deps.readFileImpl ?? ((p) => readFile5(p, "utf8"));
8509
+ const baseReadFile = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8154
8510
  const doReadFile = async (p) => {
8155
8511
  try {
8156
8512
  return await baseReadFile(p);
@@ -8179,7 +8535,7 @@ async function runSweep(input = {}, deps = {}) {
8179
8535
  const doReadState = deps.readSweepStateImpl ?? readSweepState;
8180
8536
  const doWriteState = deps.writeSweepStateImpl ?? writeSweepState;
8181
8537
  try {
8182
- const home = (deps.homedirImpl ?? homedir3)();
8538
+ const home = (deps.homedirImpl ?? homedir4)();
8183
8539
  const now = (deps.nowImpl ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
8184
8540
  const cwd = input.cwd ?? process.cwd();
8185
8541
  const target = resolveRepo(cwd, readRemote);
@@ -8207,7 +8563,7 @@ async function runSweep(input = {}, deps = {}) {
8207
8563
  }
8208
8564
  const mainRoot = doMainRoot(cwd) ?? cwd;
8209
8565
  const mainSlug = slugifyCwd(mainRoot);
8210
- const projectsRoot = join7(home, ".claude", "projects");
8566
+ const projectsRoot = join8(home, ".claude", "projects");
8211
8567
  const entries = await doReadDir(projectsRoot);
8212
8568
  const candidates = entries.filter((n) => n === mainSlug || n.startsWith(mainSlug + "-")).sort();
8213
8569
  const skip = new Set(state.processed);
@@ -8220,12 +8576,12 @@ async function runSweep(input = {}, deps = {}) {
8220
8576
  let captured = 0;
8221
8577
  let decisions = 0;
8222
8578
  for (const dirName of candidates) {
8223
- const dir = join7(projectsRoot, dirName);
8579
+ const dir = join8(projectsRoot, dirName);
8224
8580
  const files = (await doReadDir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
8225
8581
  if (files.length === 0) continue;
8226
8582
  let embeddedCwd = null;
8227
8583
  for (const file2 of files) {
8228
- embeddedCwd = extractCwdFromRaw(await doReadFile(join7(dir, file2)));
8584
+ embeddedCwd = extractCwdFromRaw(await doReadFile(join8(dir, file2)));
8229
8585
  if (embeddedCwd) break;
8230
8586
  }
8231
8587
  const cwdExists = embeddedCwd ? await doPathExists(embeddedCwd) : false;
@@ -8264,7 +8620,7 @@ async function runSweep(input = {}, deps = {}) {
8264
8620
  try {
8265
8621
  outcome = await run(
8266
8622
  {
8267
- transcript_path: join7(dir, file2),
8623
+ transcript_path: join8(dir, file2),
8268
8624
  cwd: cls.cwd ?? mainRoot,
8269
8625
  session_id: sid,
8270
8626
  hook_event_name: "SessionEnd"
@@ -8325,12 +8681,12 @@ async function runBackfill(input = {}, deps = {}) {
8325
8681
  }
8326
8682
 
8327
8683
  // src/installAgent.ts
8328
- import { execFile } from "node:child_process";
8684
+ import { execFile as execFile2 } from "node:child_process";
8329
8685
  import { promisify } from "node:util";
8330
- import { homedir as homedir4 } from "node:os";
8331
- import { join as join8, dirname as dirname3 } from "node:path";
8332
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod5 } from "node:fs/promises";
8333
- var execFileP = promisify(execFile);
8686
+ import { homedir as homedir5 } from "node:os";
8687
+ import { join as join9, dirname as dirname3 } from "node:path";
8688
+ import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod5 } from "node:fs/promises";
8689
+ var execFileP = promisify(execFile2);
8334
8690
  var MCP_COMMAND = "npx";
8335
8691
  var MCP_ARGS = ["-y", "backthread", "mcp"];
8336
8692
  function hookCommand(agent) {
@@ -8425,8 +8781,8 @@ async function writeJson(deps, path, obj) {
8425
8781
  await doWrite(path, JSON.stringify(obj, null, 2) + "\n");
8426
8782
  }
8427
8783
  async function installGemini(home, deps) {
8428
- const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8429
- const path = join8(home, ".gemini", "settings.json");
8784
+ const doRead = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
8785
+ const path = join9(home, ".gemini", "settings.json");
8430
8786
  const current = await loadJsonObject(doRead, path);
8431
8787
  const a = withMcpServer(current);
8432
8788
  const b = withNestedHook(a.next, "SessionEnd", hookCommand("gemini-cli"), { name: "backthread-capture" }, [
@@ -8436,9 +8792,9 @@ async function installGemini(home, deps) {
8436
8792
  return [{ path, wrote: a.changed || b.changed }];
8437
8793
  }
8438
8794
  async function installCodex(home, deps) {
8439
- const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8795
+ const doRead = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
8440
8796
  const writes = [];
8441
- const tomlPath = join8(home, ".codex", "config.toml");
8797
+ const tomlPath = join9(home, ".codex", "config.toml");
8442
8798
  let toml = "";
8443
8799
  try {
8444
8800
  toml = await doRead(tomlPath);
@@ -8459,7 +8815,7 @@ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
8459
8815
  await doWrite(tomlPath, toml + sep + block);
8460
8816
  writes.push({ path: tomlPath, wrote: true });
8461
8817
  }
8462
- const hooksPath = join8(home, ".codex", "hooks.json");
8818
+ const hooksPath = join9(home, ".codex", "hooks.json");
8463
8819
  const current = await loadJsonObject(doRead, hooksPath);
8464
8820
  const h = withNestedHook(current, "Stop", hookCommand("codex"), { timeout: 60 }, [legacyHookCommand("codex")]);
8465
8821
  if (h.changed) await writeJson(deps, hooksPath, h.next);
@@ -8467,12 +8823,12 @@ args = [${MCP_ARGS.map((a) => `"${a}"`).join(", ")}]
8467
8823
  return writes;
8468
8824
  }
8469
8825
  async function installCursor(home, deps) {
8470
- const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8826
+ const doRead = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
8471
8827
  const nodeBinDir = deps.nodeBinDir ?? dirname3(process.execPath);
8472
8828
  const writes = [];
8473
- const scriptDir = join8(home, ".cursor", "hooks");
8474
- const captureScriptPath = join8(scriptDir, "backthread-capture.sh");
8475
- const mcpScriptPath = join8(scriptDir, "backthread-mcp.sh");
8829
+ const scriptDir = join9(home, ".cursor", "hooks");
8830
+ const captureScriptPath = join9(scriptDir, "backthread-capture.sh");
8831
+ const mcpScriptPath = join9(scriptDir, "backthread-mcp.sh");
8476
8832
  writes.push(
8477
8833
  await writeCursorScript(
8478
8834
  deps,
@@ -8482,12 +8838,12 @@ async function installCursor(home, deps) {
8482
8838
  )
8483
8839
  );
8484
8840
  writes.push(await writeCursorScript(deps, mcpScriptPath, cursorWrapperScript(nodeBinDir, "mcp")));
8485
- const mcpPath = join8(home, ".cursor", "mcp.json");
8841
+ const mcpPath = join9(home, ".cursor", "mcp.json");
8486
8842
  const mcpCurrent = await loadJsonObject(doRead, mcpPath);
8487
8843
  const m = withCursorMcpServer(mcpCurrent, mcpScriptPath);
8488
8844
  if (m.changed) await writeJson(deps, mcpPath, m.next);
8489
8845
  writes.push({ path: mcpPath, wrote: m.changed });
8490
- const hooksPath = join8(home, ".cursor", "hooks.json");
8846
+ const hooksPath = join9(home, ".cursor", "hooks.json");
8491
8847
  const hooksCurrent = await loadJsonObject(doRead, hooksPath);
8492
8848
  const c = withCursorStopHook(hooksCurrent, captureScriptPath);
8493
8849
  if (c.changed) await writeJson(deps, hooksPath, c.next);
@@ -8521,7 +8877,7 @@ function cursorWrapperScript(nodeBinDir, backthreadArgs, latest = false) {
8521
8877
  ].join("\n") + "\n";
8522
8878
  }
8523
8879
  async function writeCursorScript(deps, path, content) {
8524
- const doRead = deps.readFileImpl ?? ((p) => readFile6(p, "utf8"));
8880
+ const doRead = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
8525
8881
  const doChmod = deps.chmodImpl ?? ((p, mode) => chmod5(p, mode));
8526
8882
  let existing = null;
8527
8883
  try {
@@ -8595,7 +8951,7 @@ async function versionGate(agent, deps) {
8595
8951
  return null;
8596
8952
  }
8597
8953
  async function runInstallAgent(agent, deps = {}) {
8598
- const home = deps.home ?? homedir4();
8954
+ const home = deps.home ?? homedir5();
8599
8955
  const versionWarning = await versionGate(agent, deps);
8600
8956
  let writes;
8601
8957
  switch (agent) {
@@ -8637,12 +8993,12 @@ var LEGACY_HOOK_COMMANDS = [
8637
8993
  ];
8638
8994
  var OUR_HOOK_COMMANDS = /* @__PURE__ */ new Set([HOOK_COMMAND, ...LEGACY_HOOK_COMMANDS]);
8639
8995
  async function registerHook(deps = {}) {
8640
- const doReadFile = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
8996
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile8(p, "utf8"));
8641
8997
  const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile6(p, d));
8642
8998
  const doMkdir = deps.mkdirImpl ?? (async (d) => void await mkdir6(d, { recursive: true }));
8643
- const home = deps.home ?? homedir5();
8644
- const settingsDir = join9(home, ".claude");
8645
- const settingsPath = join9(settingsDir, "settings.json");
8999
+ const home = deps.home ?? homedir6();
9000
+ const settingsDir = join10(home, ".claude");
9001
+ const settingsPath = join10(settingsDir, "settings.json");
8646
9002
  let settings = {};
8647
9003
  let raw = null;
8648
9004
  try {
@@ -8754,9 +9110,9 @@ function stripSessionEndHook(settings) {
8754
9110
  return next;
8755
9111
  }
8756
9112
  async function unregisterProjectHook(cwd, deps = {}) {
8757
- const doReadFile = deps.readFileImpl ?? ((p) => readFile7(p, "utf8"));
9113
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile8(p, "utf8"));
8758
9114
  const doWriteFile = deps.writeFileImpl ?? ((p, d) => writeFile6(p, d));
8759
- const settingsPath = join9(cwd, ".claude", "settings.json");
9115
+ const settingsPath = join10(cwd, ".claude", "settings.json");
8760
9116
  let raw;
8761
9117
  try {
8762
9118
  raw = await doReadFile(settingsPath);
@@ -9021,7 +9377,7 @@ function normalizeState(raw) {
9021
9377
 
9022
9378
  // src/firstRun.ts
9023
9379
  function firstRunStatePath(env = process.env) {
9024
- return join10(configDir(env), "first-run.json");
9380
+ return join11(configDir(env), "first-run.json");
9025
9381
  }
9026
9382
  function parseFirstRunState(raw) {
9027
9383
  try {
@@ -9040,7 +9396,7 @@ function parseFirstRunState(raw) {
9040
9396
  }
9041
9397
  async function readFirstRunState(env = process.env) {
9042
9398
  try {
9043
- return parseFirstRunState(await readFile8(firstRunStatePath(env), "utf8"));
9399
+ return parseFirstRunState(await readFile9(firstRunStatePath(env), "utf8"));
9044
9400
  } catch {
9045
9401
  return {};
9046
9402
  }
@@ -9215,7 +9571,7 @@ function readStream(stream) {
9215
9571
  async function runCapture(input, deps = {}) {
9216
9572
  const env = deps.env ?? process.env;
9217
9573
  const log = deps.log ?? ((m) => console.error(m));
9218
- const doReadFile = deps.readFileImpl ?? ((p) => readFile9(p, "utf8"));
9574
+ const doReadFile = deps.readFileImpl ?? ((p) => readFile10(p, "utf8"));
9219
9575
  const doReadConfig = deps.readConfigImpl ?? readConfig;
9220
9576
  const fireEnsureAuth = deps.ensureAuthImpl ?? ((e) => {
9221
9577
  void ensureAuth({ env: e }).catch(() => {
@@ -9395,8 +9751,8 @@ async function persistDerived(decisions, repo, config2, decidedAt, ctx) {
9395
9751
 
9396
9752
  // src/fromHook.ts
9397
9753
  import { spawn as spawn2 } from "node:child_process";
9398
- import { join as join11 } from "node:path";
9399
- import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir8, chmod as chmod7 } from "node:fs/promises";
9754
+ import { join as join12 } from "node:path";
9755
+ import { readFile as readFile11, writeFile as writeFile8, mkdir as mkdir8, chmod as chmod7 } from "node:fs/promises";
9400
9756
  var KNOWN_AGENTS = /* @__PURE__ */ new Set([
9401
9757
  "claude-code",
9402
9758
  "codex",
@@ -9437,7 +9793,7 @@ function normalizeHookInput(payload, _agent) {
9437
9793
  return out;
9438
9794
  }
9439
9795
  function captureStatePath(env = process.env) {
9440
- return join11(configDir(env), "capture-sessions.json");
9796
+ return join12(configDir(env), "capture-sessions.json");
9441
9797
  }
9442
9798
  var MAX_REMEMBERED2 = 200;
9443
9799
  function parseState3(raw) {
@@ -9460,7 +9816,7 @@ function parseState3(raw) {
9460
9816
  }
9461
9817
  async function readState3(env) {
9462
9818
  try {
9463
- return parseState3(await readFile10(captureStatePath(env), "utf8"));
9819
+ return parseState3(await readFile11(captureStatePath(env), "utf8"));
9464
9820
  } catch {
9465
9821
  return { captured: [], watermarks: {} };
9466
9822
  }
@@ -9633,6 +9989,45 @@ function codexStdout(agent, status, outcome) {
9633
9989
  return ack;
9634
9990
  }
9635
9991
 
9992
+ // src/suggest.ts
9993
+ var MAX_DISTANCE = 2;
9994
+ function editDistance(a, b) {
9995
+ const m = a.length;
9996
+ const n = b.length;
9997
+ if (m === 0) return n;
9998
+ if (n === 0) return m;
9999
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
10000
+ let curr = new Array(n + 1);
10001
+ for (let i = 1; i <= m; i++) {
10002
+ curr[0] = i;
10003
+ for (let j = 1; j <= n; j++) {
10004
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
10005
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
10006
+ }
10007
+ [prev, curr] = [curr, prev];
10008
+ }
10009
+ return prev[n];
10010
+ }
10011
+ function preferable(candidate, incumbent) {
10012
+ if (candidate.length !== incumbent.length) return candidate.length < incumbent.length;
10013
+ return candidate < incumbent;
10014
+ }
10015
+ function nearestCommand(input, commands) {
10016
+ const needle = input.toLowerCase();
10017
+ if (needle.length === 0) return null;
10018
+ let best = null;
10019
+ let bestDist = Infinity;
10020
+ for (const cmd of commands) {
10021
+ const d = editDistance(needle, cmd.toLowerCase());
10022
+ if (d > MAX_DISTANCE || d >= needle.length) continue;
10023
+ if (d < bestDist || d === bestDist && best !== null && preferable(cmd, best)) {
10024
+ best = cmd;
10025
+ bestDist = d;
10026
+ }
10027
+ }
10028
+ return best;
10029
+ }
10030
+
9636
10031
  // ../node_modules/zod/v3/helpers/util.js
9637
10032
  var util;
9638
10033
  (function(util2) {
@@ -33921,15 +34316,15 @@ async function startMcpServer(deps = {}) {
33921
34316
  }
33922
34317
 
33923
34318
  // src/routingStats.ts
33924
- import { join as join12 } from "node:path";
33925
- import { readFile as readFile11, writeFile as writeFile9, mkdir as mkdir9, chmod as chmod8 } from "node:fs/promises";
34319
+ import { join as join13 } from "node:path";
34320
+ import { readFile as readFile12, writeFile as writeFile9, mkdir as mkdir9, chmod as chmod8 } from "node:fs/promises";
33926
34321
  var STATS_FILE = "routing-stats.json";
33927
34322
  function statsPath(env) {
33928
- return join12(configDir(env), STATS_FILE);
34323
+ return join13(configDir(env), STATS_FILE);
33929
34324
  }
33930
34325
  async function readRoutingStats(deps = {}) {
33931
34326
  const env = deps.env ?? process.env;
33932
- const read = deps.readFileImpl ?? readFile11;
34327
+ const read = deps.readFileImpl ?? readFile12;
33933
34328
  try {
33934
34329
  const raw = await read(statsPath(env), "utf8");
33935
34330
  const obj = JSON.parse(raw);
@@ -34002,40 +34397,60 @@ async function runSessionStart(deps = {}) {
34002
34397
  }
34003
34398
 
34004
34399
  // src/bin/backthread.ts
34005
- var USAGE = `backthread \u2014 capture the "why" of your AI-coded changes
34400
+ var USAGE = `backthread \u2014 keep the thread on what your AI agent actually shipped
34006
34401
 
34007
34402
  Usage:
34008
- backthread Set up Backthread (the unified front door \u2014 same as
34009
- \`backthread start\`): trust copy + one-tap auth + your next
34010
- step. Idempotent. [--claim <code>]
34011
- backthread start First-run setup (backs the /backthread:start slash command):
34012
- trust copy + one-tap auth + your next step. Idempotent.
34403
+ backthread [command] [flags]
34404
+
34405
+ Setup
34406
+ backthread Set up Backthread here (the front door): sign in, connect
34407
+ this repo, wire up capture. Idempotent \u2014 re-run it anytime.
34013
34408
  [--claim <code>]
34014
- backthread login Authorize this device (opens your browser)
34015
- backthread login --claim <code>
34016
- Authorize with a single-use claim code from the web app
34017
- (no browser needed \u2014 codes expire in ~10 minutes)
34018
- backthread login --device Headless / SSH login (device-code flow \u2014 coming soon)
34019
- backthread whoami Show the current device's config (token is never printed)
34020
- backthread how <question> Ask how/why something in this repo works \u2014 prints a
34021
- grounded, cited answer from your Backthread decision log
34022
- (backs the /backthread:how slash command). [--cwd <path>]
34409
+ backthread start Same as above, behind the /backthread:start slash command.
34410
+ backthread login Authorize this device (opens your browser; works over SSH \u2014
34411
+ the printed URL opens on any device) [--claim <code>] [--device]
34412
+ backthread logout Sign this device out \u2014 drop the local token, keep the repo link
34413
+ backthread whoami Show this device's config (the token is never printed)
34414
+
34415
+ Ask
34416
+ backthread how <question> Ask how/why something here works \u2014 a grounded, cited answer
34417
+ from your decision log (backs /backthread:how). [--cwd <path>]
34418
+
34419
+ Capture
34023
34420
  backthread capture Capture this session's decisions (run by the SessionEnd/Stop hook)
34024
- backthread capture --from-hook
34025
- Shared multi-agent hook entrypoint: read the hook payload off
34026
- STDIN and capture the named transcript (always exits 0)
34027
- [--agent <codex|cursor|gemini-cli>] [--detach]
34028
- backthread capture --manual Manually capture a session now (the /backthread capture slash command)
34421
+ backthread capture --manual Capture the current session now (the /backthread capture command)
34029
34422
  [--session <id>] [--transcript <path>] [--cwd <dir>]
34030
34423
  backthread mcp Start the MCP server (capture + query tools) over stdio
34031
- backthread install Set up capture for this repo (login + hook + backfill history)
34032
- [--claim <code>] [--skip-auth] [--skip-hook] [--skip-backfill]
34033
- backthread install --agent <codex|cursor|gemini>
34034
- Set up capture for another agent: write its USER-GLOBAL
34035
- MCP server config + session-end capture hook (idempotent)
34036
- backthread help Show this message
34037
34424
 
34038
- Docs: https://app.backthread.dev`;
34425
+ Manage
34426
+ backthread install Set up capture for this repo (login + hook + backfill history)
34427
+ [--claim <code>] [--agent <codex|cursor|gemini>] [--skip-auth]
34428
+ [--skip-hook] [--skip-backfill]
34429
+ backthread update Update a global install to the latest (also -u). npx is
34430
+ always latest already; the plugin updates via /plugin update.
34431
+ backthread doctor Diagnose your setup \u2014 auth, capture hook, connectivity,
34432
+ version, repo. Prints \u2713/\u2717 with fix hints; exits non-zero if broken.
34433
+ backthread version Print the installed version (also --version, -v)
34434
+ backthread help Show this message (also --help, -h)
34435
+
34436
+ Your source never leaves your machine unredacted \u2014 it's checkable in this OSS repo.
34437
+ Docs: https://app.backthread.dev
34438
+ Security: https://backthread.dev/security`;
34439
+ var KNOWN_COMMANDS = [
34440
+ "start",
34441
+ "login",
34442
+ "logout",
34443
+ "whoami",
34444
+ "how",
34445
+ "ask",
34446
+ "capture",
34447
+ "mcp",
34448
+ "install",
34449
+ "update",
34450
+ "doctor",
34451
+ "version",
34452
+ "help"
34453
+ ];
34039
34454
  function parseClaimFlag(rest) {
34040
34455
  const i = rest.indexOf("--claim");
34041
34456
  if (i === -1) return void 0;
@@ -34089,6 +34504,18 @@ async function main(argv, deps = {}) {
34089
34504
  console.log(lines.join("\n"));
34090
34505
  return cfg.device_token ? 0 : 1;
34091
34506
  }
34507
+ case "logout": {
34508
+ const logoutImpl = deps.runLogoutImpl ?? runLogout;
34509
+ const result = await logoutImpl();
34510
+ console.log(result.message);
34511
+ return result.ok ? 0 : 1;
34512
+ }
34513
+ case "doctor": {
34514
+ const doctorImpl = deps.runDoctorImpl ?? runDoctor;
34515
+ const result = await doctorImpl();
34516
+ console.log(result.text);
34517
+ return result.exitCode;
34518
+ }
34092
34519
  case "capture": {
34093
34520
  if (rest.includes("--from-hook")) {
34094
34521
  const raw = await readRawHookInput();
@@ -34165,6 +34592,19 @@ async function main(argv, deps = {}) {
34165
34592
  }
34166
34593
  case void 0:
34167
34594
  return onboarding(rest);
34595
+ case "update":
34596
+ case "--update":
34597
+ case "-u": {
34598
+ const updateImpl = deps.runUpdateImpl ?? runUpdate;
34599
+ const result = await updateImpl();
34600
+ console.log(result.message);
34601
+ return result.ok ? 0 : 1;
34602
+ }
34603
+ case "version":
34604
+ case "--version":
34605
+ case "-v":
34606
+ console.log(cliVersion());
34607
+ return 0;
34168
34608
  case "help":
34169
34609
  case "--help":
34170
34610
  case "-h":
@@ -34172,9 +34612,14 @@ async function main(argv, deps = {}) {
34172
34612
  return 0;
34173
34613
  default:
34174
34614
  if (command.startsWith("-")) return onboarding(argv);
34175
- console.error(`Unknown command: ${command}
34176
-
34177
- ${USAGE}`);
34615
+ {
34616
+ const guess = nearestCommand(command, KNOWN_COMMANDS);
34617
+ const didYouMean = guess ? ` Did you mean \`backthread ${guess}\`?` : "";
34618
+ console.error(
34619
+ `Unknown command: ${command}.${didYouMean}
34620
+ Run \`backthread help\` to see everything backthread can do.`
34621
+ );
34622
+ }
34178
34623
  return 1;
34179
34624
  }
34180
34625
  }
@@ -34185,7 +34630,7 @@ function isEntryPoint() {
34185
34630
  const self = fileURLToPath2(import.meta.url);
34186
34631
  const resolve = (p) => {
34187
34632
  try {
34188
- return realpathSync(p);
34633
+ return realpathSync2(p);
34189
34634
  } catch {
34190
34635
  return p;
34191
34636
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backthread",
3
- "version": "0.7.0",
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.",
3
+ "version": "0.8.0",
4
+ "description": "Backthread keeps the thread on what your AI coding agent ships it captures the why behind every change and turns it into a living 'How it works' view of your codebase you can actually query.",
5
5
  "license": "MIT",
6
6
  "author": "Backthread",
7
7
  "homepage": "https://backthread.dev",
@@ -12,14 +12,20 @@
12
12
  },
13
13
  "bugs": "https://github.com/backthread/backthread/issues",
14
14
  "keywords": [
15
+ "backthread",
16
+ "ai-coding-agent",
15
17
  "claude-code",
16
- "ai",
18
+ "cursor",
19
+ "codex",
20
+ "codebase-context",
21
+ "code-comprehension",
22
+ "how-it-works",
17
23
  "architecture",
18
- "codebase",
19
- "documentation",
20
- "backthread",
24
+ "decision-log",
25
+ "ai",
26
+ "mcp",
21
27
  "cli",
22
- "mcp"
28
+ "developer-tools"
23
29
  ],
24
30
  "type": "module",
25
31
  "comment": "PUBLISHED BIN = the self-contained esbuild bundle (dist-bundle/backthread.js), NOT the tsc dist/. Reason: @backthread/redact ships SOURCE-ONLY (.ts), and Node refuses to strip types for files inside node_modules — so a registry install of the tsc dist/ would fail to load redact at runtime. The bundle inlines redact (and the MCP SDK), so the published CLI has ZERO runtime dependencies and `npx backthread` stays fast and robust. Dev/test resolve @backthread/redact via the workspace symlink (realpath escapes node_modules → stripping applies).",