tandem-editor 0.13.0 → 0.13.6

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/dist/cli/index.js CHANGED
@@ -370,10 +370,11 @@ var init_skill_content = __esm({
370
370
  });
371
371
 
372
372
  // src/shared/constants.ts
373
- var DEFAULT_MCP_PORT, TANDEM_REPO_URL, TANDEM_ISSUES_NEW_URL, MAX_FILE_SIZE, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, CHANNEL_CONNECT_FETCH_TIMEOUT_MS, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS, CHANNEL_MODE_FETCH_TIMEOUT_MS, CHANNEL_AWARENESS_FETCH_TIMEOUT_MS, CHANNEL_ERROR_REPORT_TIMEOUT_MS, CHANNEL_REPLY_FETCH_TIMEOUT_MS, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS, CHANNEL_MAX_SSE_BUFFER_BYTES, TOKEN_FILE_NAME;
373
+ var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, TANDEM_REPO_URL, TANDEM_ISSUES_NEW_URL, MAX_FILE_SIZE, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, CHANNEL_CONNECT_FETCH_TIMEOUT_MS, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS, CHANNEL_MODE_FETCH_TIMEOUT_MS, CHANNEL_AWARENESS_FETCH_TIMEOUT_MS, CHANNEL_ERROR_REPORT_TIMEOUT_MS, CHANNEL_REPLY_FETCH_TIMEOUT_MS, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS, CHANNEL_MAX_SSE_BUFFER_BYTES, TOKEN_FILE_NAME;
374
374
  var init_constants = __esm({
375
375
  "src/shared/constants.ts"() {
376
376
  "use strict";
377
+ DEFAULT_WS_PORT = 3478;
377
378
  DEFAULT_MCP_PORT = 3479;
378
379
  TANDEM_REPO_URL = "https://github.com/bloknayrb/tandem";
379
380
  TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;
@@ -886,6 +887,7 @@ async function applyConfig(configPath, ops) {
886
887
  ...ops.create
887
888
  };
888
889
  for (const key of ops.remove) {
890
+ if (key in ops.create) continue;
889
891
  if (merged[key]) {
890
892
  console.error(` Note: removed mcpServers.${key} from ${configPath}`);
891
893
  }
@@ -929,21 +931,24 @@ function readSkillVersion(skillContent) {
929
931
  function validateChannelShimPrereq(channelPath) {
930
932
  return existsSync(channelPath);
931
933
  }
934
+ function shouldRegisterChannelShim(targetKind, channelPath, override) {
935
+ if (targetKind === "claude-desktop") return false;
936
+ if (override !== void 0) return override;
937
+ return validateChannelShimPrereq(channelPath);
938
+ }
932
939
  async function applyConfigWithToken(token, opts = {}) {
933
940
  const targets = detectTargets({ force: opts.force });
934
941
  let updated = 0;
935
942
  const errors = [];
936
943
  for (const t of targets) {
944
+ const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);
937
945
  const entries = buildMcpEntries(CHANNEL_DIST, {
938
- withChannelShim: opts.withChannelShim,
946
+ withChannelShim,
939
947
  token: token ?? void 0,
940
948
  targetKind: t.kind
941
949
  });
942
950
  try {
943
- await applyConfig(
944
- t.configPath,
945
- applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
946
- );
951
+ await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));
947
952
  updated++;
948
953
  } catch (err) {
949
954
  errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1021,15 +1026,13 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
1021
1026
  console.error("\nWriting MCP configuration...");
1022
1027
  let failures = 0;
1023
1028
  for (const t of targets) {
1029
+ const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);
1024
1030
  const entries = buildMcpEntries(CHANNEL_DIST, {
1025
- withChannelShim: opts.withChannelShim,
1031
+ withChannelShim,
1026
1032
  targetKind: t.kind
1027
1033
  });
1028
1034
  try {
1029
- await applyConfig(
1030
- t.configPath,
1031
- applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
1032
- );
1035
+ await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));
1033
1036
  console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
1034
1037
  } catch (err) {
1035
1038
  failures++;
@@ -1060,16 +1063,15 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
1060
1063
  );
1061
1064
  }
1062
1065
  if (failures < targets.length) {
1066
+ const channelRegistered = validateChannelShimPrereq(CHANNEL_DIST);
1063
1067
  const pluginManifest = join4(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
1064
- const devInstructions = existsSync2(pluginManifest) ? ` Or for development, load directly from this package:
1068
+ const devInstructions = existsSync2(pluginManifest) ? ` For development, you can also load the package directly:
1065
1069
 
1066
1070
  claude --plugin-dir ${PACKAGE_ROOT}
1067
1071
 
1068
- ` : ` (Development plugin dir not found at ${pluginManifest}; skipping local-plugin instructions.)
1069
-
1070
- `;
1072
+ ` : "";
1071
1073
  console.error(
1072
- "\n\x1B[1mReal-time push notifications (recommended):\x1B[0m\n Install the Tandem plugin for instant events (one-time):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions + " Without the plugin, Claude still works but relies on tandem_checkInbox polling.\n"
1074
+ "\n\x1B[1mReal-time push notifications:\x1B[0m\n" + (channelRegistered ? " \x1B[32mEnabled\x1B[0m \u2014 the channel shim is registered; Claude Code receives events in real time.\n Relaunch any Claude Code session you started manually so it picks up the new server.\n\n" : " \x1B[33mUnavailable\x1B[0m \u2014 dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\n Run 'npm run build' and re-run setup to enable push.\n\n") + " A Tandem plugin is also published (skill + MCP; the real-time monitor it carries is\n forward-looking, pending Claude Code support):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions
1073
1075
  );
1074
1076
  }
1075
1077
  }
@@ -1644,7 +1646,7 @@ var init_types3 = __esm({
1644
1646
  SeveritySchema = z.enum(["info", "warning", "error", "success"]);
1645
1647
  TandemModeSchema = z.enum(["solo", "tandem"]);
1646
1648
  AuthorSchema = z.enum(["user", "claude", "import"]);
1647
- ReplyAuthorSchema = z.enum(["user", "claude"]);
1649
+ ReplyAuthorSchema = z.enum(["user", "claude", "import"]);
1648
1650
  AnnotationActionSchema = z.enum(["accept", "dismiss"]);
1649
1651
  ExportFormatSchema = z.enum(["markdown", "json"]);
1650
1652
  DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
@@ -2228,6 +2230,515 @@ var init_channel = __esm({
2228
2230
  }
2229
2231
  });
2230
2232
 
2233
+ // src/cli/doctor.ts
2234
+ var doctor_exports = {};
2235
+ __export(doctor_exports, {
2236
+ runDoctor: () => runDoctor,
2237
+ runDoctorCli: () => runDoctorCli
2238
+ });
2239
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
2240
+ import { request } from "http";
2241
+ import { createConnection as createConnection2 } from "net";
2242
+ import { homedir as homedir2, platform } from "os";
2243
+ import { join as join5 } from "path";
2244
+ function checkNodeVersion(r) {
2245
+ const version2 = process.version;
2246
+ const major = Number.parseInt(version2.slice(1), 10);
2247
+ if (major >= 22) {
2248
+ r.pass(`Node.js ${version2} (>= 22 required)`);
2249
+ } else {
2250
+ r.fail(
2251
+ `Node.js ${version2} \u2014 version 22+ required`,
2252
+ "Install Node.js 22+ from https://nodejs.org"
2253
+ );
2254
+ }
2255
+ }
2256
+ function checkNodeModules(r) {
2257
+ if (existsSync3(join5(process.cwd(), "node_modules"))) {
2258
+ r.pass("node_modules/ exists");
2259
+ } else {
2260
+ r.fail("node_modules/ not found", "npm install");
2261
+ }
2262
+ }
2263
+ function checkMcpJson(r) {
2264
+ const mcpPath = join5(process.cwd(), ".mcp.json");
2265
+ if (!existsSync3(mcpPath)) {
2266
+ r.fail(".mcp.json not found", "Restore it from git: git checkout .mcp.json");
2267
+ return;
2268
+ }
2269
+ let raw;
2270
+ try {
2271
+ raw = readFileSync3(mcpPath, "utf-8");
2272
+ } catch (err) {
2273
+ r.fail(`.mcp.json could not be read: ${errMsg(err)}`);
2274
+ return;
2275
+ }
2276
+ let config;
2277
+ try {
2278
+ config = JSON.parse(raw);
2279
+ } catch (err) {
2280
+ r.fail(`.mcp.json is not valid JSON: ${errMsg(err)}`);
2281
+ return;
2282
+ }
2283
+ const servers = config.mcpServers;
2284
+ if (!servers) {
2285
+ r.fail('.mcp.json missing "mcpServers" key');
2286
+ return;
2287
+ }
2288
+ const tandem = servers.tandem;
2289
+ if (!tandem) {
2290
+ r.fail('.mcp.json missing "tandem" server entry');
2291
+ } else if (tandem.type !== "http" || !tandem.url?.includes("/mcp")) {
2292
+ r.warn(`.mcp.json tandem: unexpected config \u2014 type=${tandem.type}, url=${tandem.url}`);
2293
+ } else {
2294
+ r.pass(`.mcp.json tandem \u2192 ${tandem.url}`);
2295
+ }
2296
+ const channel = servers["tandem-channel"];
2297
+ if (!channel) {
2298
+ r.warn(
2299
+ ".mcp.json missing tandem-channel \u2014 Claude will use polling instead of push notifications"
2300
+ );
2301
+ } else {
2302
+ const cmd = channel.command;
2303
+ const args2 = (channel.args || []).join(" ");
2304
+ if (cmd === "cmd" && args2.includes("/c")) {
2305
+ r.warn(
2306
+ `.mcp.json tandem-channel uses Windows-only "cmd /c" \u2014 won't work on macOS/Linux`,
2307
+ 'Change to: "command": "npx", "args": ["tsx", "src/channel/index.ts"]'
2308
+ );
2309
+ } else {
2310
+ r.pass(`.mcp.json tandem-channel \u2192 ${cmd} ${args2}`);
2311
+ }
2312
+ if (!channel.env?.TANDEM_URL) {
2313
+ r.warn(
2314
+ "tandem-channel missing TANDEM_URL env var",
2315
+ 'Add "env": {"TANDEM_URL": "http://127.0.0.1:3479"}'
2316
+ );
2317
+ }
2318
+ }
2319
+ }
2320
+ function checkUserMcpConfig(r) {
2321
+ const home = process.env.HOME || process.env.USERPROFILE || "";
2322
+ const claudeCodePath = join5(home, ".claude.json");
2323
+ if (!existsSync3(claudeCodePath)) {
2324
+ r.warn(
2325
+ "~/.claude.json not found",
2326
+ "Run: tandem setup (or ignore if using project-local .mcp.json)"
2327
+ );
2328
+ return;
2329
+ }
2330
+ let config;
2331
+ try {
2332
+ config = JSON.parse(readFileSync3(claudeCodePath, "utf-8"));
2333
+ } catch (err) {
2334
+ r.warn(`~/.claude.json is malformed JSON: ${errMsg(err)}`, "Run: tandem setup to rewrite it");
2335
+ return;
2336
+ }
2337
+ const servers = config?.mcpServers ?? {};
2338
+ if (!servers.tandem) {
2339
+ r.warn("tandem not registered in ~/.claude.json", "Run: tandem setup");
2340
+ } else {
2341
+ r.pass("tandem registered in ~/.claude.json");
2342
+ }
2343
+ if (!servers["tandem-channel"]) {
2344
+ r.warn(
2345
+ "tandem-channel not registered in ~/.claude.json \u2014 Claude Code will poll instead of receiving real-time push",
2346
+ "Run: tandem setup"
2347
+ );
2348
+ } else {
2349
+ r.pass("tandem-channel registered in ~/.claude.json");
2350
+ }
2351
+ }
2352
+ function probePort(port, timeoutMs = 2e3) {
2353
+ return new Promise((resolve4) => {
2354
+ const socket = createConnection2({ port, host: "127.0.0.1" }, () => {
2355
+ socket.destroy();
2356
+ resolve4(true);
2357
+ });
2358
+ socket.setTimeout(timeoutMs);
2359
+ socket.on("timeout", () => {
2360
+ socket.destroy();
2361
+ resolve4(false);
2362
+ });
2363
+ socket.on("error", () => {
2364
+ socket.destroy();
2365
+ resolve4(false);
2366
+ });
2367
+ });
2368
+ }
2369
+ async function checkPorts(r) {
2370
+ const [ws, mcp] = await Promise.all([probePort(WS_PORT), probePort(MCP_PORT)]);
2371
+ if (ws && mcp) {
2372
+ r.pass(`Ports ${WS_PORT} (WebSocket) + ${MCP_PORT} (MCP HTTP) in use`, void 0, { ws, mcp });
2373
+ } else if (!ws && !mcp) {
2374
+ r.fail(
2375
+ `Ports ${WS_PORT} + ${MCP_PORT} not listening \u2014 server not running`,
2376
+ "npm run dev:standalone",
2377
+ { ws, mcp }
2378
+ );
2379
+ } else {
2380
+ r.warn(
2381
+ `Partial: port ${WS_PORT} ${ws ? "up" : "down"}, port ${MCP_PORT} ${mcp ? "up" : "down"}`,
2382
+ "Server may be starting up or partially crashed",
2383
+ { ws, mcp }
2384
+ );
2385
+ }
2386
+ return { ws, mcp };
2387
+ }
2388
+ function httpGet(url, timeoutMs = 3e3) {
2389
+ return new Promise((resolve4) => {
2390
+ const req = request(url, { timeout: timeoutMs }, (res) => {
2391
+ let body = "";
2392
+ res.on("data", (chunk) => {
2393
+ body += chunk;
2394
+ });
2395
+ res.on("end", () => {
2396
+ try {
2397
+ resolve4({ status: res.statusCode, data: JSON.parse(body) });
2398
+ } catch {
2399
+ resolve4({ status: res.statusCode, data: null });
2400
+ }
2401
+ });
2402
+ });
2403
+ req.on("error", (err) => resolve4({ error: err.message }));
2404
+ req.on("timeout", () => {
2405
+ req.destroy();
2406
+ resolve4(null);
2407
+ });
2408
+ req.end();
2409
+ });
2410
+ }
2411
+ async function checkHealth(r) {
2412
+ const result = await httpGet(`http://127.0.0.1:${MCP_PORT}/health`);
2413
+ if (!result) {
2414
+ r.fail(`Server not responding on 127.0.0.1:${MCP_PORT}`, "npm run dev:standalone");
2415
+ return false;
2416
+ }
2417
+ if (result.error) {
2418
+ r.fail(
2419
+ `Server not responding on 127.0.0.1:${MCP_PORT} (${result.error})`,
2420
+ "npm run dev:standalone"
2421
+ );
2422
+ return false;
2423
+ }
2424
+ if (result.status !== 200) {
2425
+ r.fail(`/health returned status ${result.status}`);
2426
+ return false;
2427
+ }
2428
+ const d = result.data;
2429
+ if (d) {
2430
+ const session = d.hasSession ? "session active" : "no MCP session";
2431
+ r.pass(`Server healthy (v${d.version}, ${d.transport}, ${session})`, void 0, {
2432
+ version: d.version,
2433
+ transport: d.transport,
2434
+ hasSession: !!d.hasSession
2435
+ });
2436
+ if (!d.hasSession) {
2437
+ r.warn("No active MCP session \u2014 Claude Code hasn't connected yet");
2438
+ }
2439
+ } else {
2440
+ r.pass("Server responded on /health (could not parse body)");
2441
+ }
2442
+ return true;
2443
+ }
2444
+ function checkSseEndpoint(r) {
2445
+ return new Promise((resolve4) => {
2446
+ const req = request(`http://127.0.0.1:${MCP_PORT}/api/events`, { timeout: 2e3 }, (res) => {
2447
+ req.destroy();
2448
+ const ct = res.headers["content-type"] || "";
2449
+ if (res.statusCode === 200 && ct.includes("text/event-stream")) {
2450
+ r.pass("SSE event stream reachable (/api/events)");
2451
+ } else {
2452
+ r.warn(`/api/events responded with status ${res.statusCode}, content-type: ${ct}`);
2453
+ }
2454
+ resolve4();
2455
+ });
2456
+ req.on("error", (err) => {
2457
+ r.warn(`/api/events not reachable: ${err.message}`);
2458
+ resolve4();
2459
+ });
2460
+ req.on("timeout", () => {
2461
+ req.destroy();
2462
+ r.warn("/api/events timed out");
2463
+ resolve4();
2464
+ });
2465
+ req.end();
2466
+ });
2467
+ }
2468
+ function resolveAppDataDir2() {
2469
+ const override = process.env.TANDEM_APP_DATA_DIR;
2470
+ if (override && override.length > 0) return override;
2471
+ const home = homedir2();
2472
+ switch (platform()) {
2473
+ case "win32":
2474
+ return join5(process.env.LOCALAPPDATA || join5(home, "AppData", "Local"), "tandem", "Data");
2475
+ case "darwin":
2476
+ return join5(home, "Library", "Application Support", "tandem");
2477
+ default:
2478
+ return join5(process.env.XDG_DATA_HOME || join5(home, ".local", "share"), "tandem");
2479
+ }
2480
+ }
2481
+ function isPidLive(pid) {
2482
+ try {
2483
+ process.kill(pid, 0);
2484
+ return true;
2485
+ } catch (err) {
2486
+ return err?.code === "EPERM";
2487
+ }
2488
+ }
2489
+ function formatBytes(bytes) {
2490
+ if (bytes < 1024) return `${bytes} B`;
2491
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2492
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2493
+ }
2494
+ function checkAnnotationStore(r) {
2495
+ const dir = join5(resolveAppDataDir2(), "annotations");
2496
+ if (!existsSync3(dir)) {
2497
+ r.pass(`Annotation store dir not yet created (${dir}) \u2014 first open will create it`, void 0, {
2498
+ dir,
2499
+ docCount: 0,
2500
+ totalBytes: 0,
2501
+ corruptCount: 0,
2502
+ exists: false
2503
+ });
2504
+ return;
2505
+ }
2506
+ let entries;
2507
+ try {
2508
+ entries = readdirSync2(dir);
2509
+ } catch (err) {
2510
+ r.fail(`Annotation store dir unreadable: ${errMsg(err)}`, `Check permissions on ${dir}`);
2511
+ return;
2512
+ }
2513
+ const jsonFiles = entries.filter((f) => f.endsWith(".json") && !f.endsWith(".corrupt.json"));
2514
+ const corruptFiles = entries.filter((f) => f.includes(".corrupt."));
2515
+ let totalBytes = 0;
2516
+ let newest = { name: null, mtime: 0 };
2517
+ let sampleSchemaVersion = null;
2518
+ for (const f of jsonFiles) {
2519
+ try {
2520
+ const s = statSync2(join5(dir, f));
2521
+ totalBytes += s.size;
2522
+ if (s.mtimeMs > newest.mtime) {
2523
+ newest = { name: f, mtime: s.mtimeMs };
2524
+ }
2525
+ if (sampleSchemaVersion === null) {
2526
+ try {
2527
+ const parsed = JSON.parse(readFileSync3(join5(dir, f), "utf-8"));
2528
+ if (typeof parsed?.schemaVersion === "number") {
2529
+ sampleSchemaVersion = parsed.schemaVersion;
2530
+ }
2531
+ } catch {
2532
+ }
2533
+ }
2534
+ } catch {
2535
+ }
2536
+ }
2537
+ r.pass(
2538
+ `Annotation store: ${jsonFiles.length} doc(s), ${formatBytes(totalBytes)} total`,
2539
+ void 0,
2540
+ {
2541
+ dir,
2542
+ docCount: jsonFiles.length,
2543
+ totalBytes,
2544
+ corruptCount: corruptFiles.length
2545
+ }
2546
+ );
2547
+ if (newest.name) {
2548
+ const ageMs = Date.now() - newest.mtime;
2549
+ const ageStr = ageMs < 6e4 ? `${Math.floor(ageMs / 1e3)}s` : `${Math.floor(ageMs / 6e4)}m`;
2550
+ r.pass(`Most recent annotation write: ${newest.name} (${ageStr} ago)`, void 0, {
2551
+ name: newest.name,
2552
+ mtimeMs: newest.mtime,
2553
+ ageMs
2554
+ });
2555
+ }
2556
+ if (sampleSchemaVersion !== null) {
2557
+ r.pass(`Annotation schema version: ${sampleSchemaVersion}`, void 0, {
2558
+ schemaVersion: sampleSchemaVersion
2559
+ });
2560
+ }
2561
+ if (corruptFiles.length > 0) {
2562
+ r.warn(
2563
+ `${corruptFiles.length} quarantined annotation file(s) in ${dir}`,
2564
+ "Safe to delete after inspection; kept 7d by design.",
2565
+ {
2566
+ corruptCount: corruptFiles.length,
2567
+ dir
2568
+ }
2569
+ );
2570
+ }
2571
+ const lockPath = join5(dir, "store.lock");
2572
+ if (!existsSync3(lockPath)) {
2573
+ r.pass("Annotation store lock: not held (no running writer)", void 0, { lockHeld: false });
2574
+ return;
2575
+ }
2576
+ try {
2577
+ const raw = readFileSync3(lockPath, "utf-8").trim();
2578
+ const pid = Number.parseInt(raw, 10);
2579
+ if (!Number.isFinite(pid)) {
2580
+ r.warn(
2581
+ `Annotation store lock at ${lockPath} has unparseable content: "${raw}"`,
2582
+ "Restart Tandem or delete the lock file if no server is running.",
2583
+ { lockHeld: true, lockPath, lockContent: raw }
2584
+ );
2585
+ return;
2586
+ }
2587
+ if (isPidLive(pid)) {
2588
+ r.pass(`Annotation store lock held by live PID ${pid}`, void 0, {
2589
+ lockHeld: true,
2590
+ pid,
2591
+ pidLive: true
2592
+ });
2593
+ } else {
2594
+ r.warn(
2595
+ `Annotation store lock at ${lockPath} points to dead PID ${pid}`,
2596
+ "The next server start will reclaim the stale lock automatically.",
2597
+ { lockHeld: true, pid, pidLive: false }
2598
+ );
2599
+ }
2600
+ } catch (err) {
2601
+ r.warn(`Could not read annotation store lock: ${errMsg(err)}`);
2602
+ }
2603
+ }
2604
+ function errMsg(err) {
2605
+ return err instanceof Error ? err.message : String(err);
2606
+ }
2607
+ async function runDoctor() {
2608
+ const r = new Recorder();
2609
+ await r.check("node-version", () => checkNodeVersion(r));
2610
+ await r.check("node-modules", () => checkNodeModules(r));
2611
+ await r.check("mcp-json", () => checkMcpJson(r));
2612
+ await r.check("user-mcp-config", () => checkUserMcpConfig(r));
2613
+ await r.check("annotation-store", () => checkAnnotationStore(r));
2614
+ const { mcp } = await r.check("ports", () => checkPorts(r));
2615
+ if (mcp) {
2616
+ const healthy = await r.check("health", () => checkHealth(r));
2617
+ if (healthy) {
2618
+ await r.check("sse", () => checkSseEndpoint(r));
2619
+ }
2620
+ }
2621
+ const summary = r.failures > 0 ? `${r.failures} issue(s) found.` : r.warnings > 0 ? `${r.warnings} warning(s) \u2014 Tandem should work, but check the items above.` : "All checks passed. Tandem is ready.";
2622
+ return {
2623
+ ok: r.failures === 0,
2624
+ crashed: false,
2625
+ failures: r.failures,
2626
+ warnings: r.warnings,
2627
+ summary,
2628
+ error: null,
2629
+ results: r.results
2630
+ };
2631
+ }
2632
+ function colorTag(status) {
2633
+ switch (status) {
2634
+ case "pass":
2635
+ return "\x1B[32m[PASS]\x1B[0m";
2636
+ case "warn":
2637
+ return "\x1B[33m[WARN]\x1B[0m";
2638
+ case "fail":
2639
+ return "\x1B[31m[FAIL]\x1B[0m";
2640
+ }
2641
+ }
2642
+ async function runDoctorCli(opts = {}) {
2643
+ const json = opts.json ?? false;
2644
+ let report;
2645
+ try {
2646
+ report = await runDoctor();
2647
+ } catch (err) {
2648
+ const message = errMsg(err);
2649
+ if (json) {
2650
+ const crashed = {
2651
+ ok: false,
2652
+ crashed: true,
2653
+ failures: 0,
2654
+ warnings: 0,
2655
+ summary: `Tandem Doctor crashed unexpectedly: ${message}`,
2656
+ error: message,
2657
+ results: []
2658
+ };
2659
+ process.stdout.write(`${JSON.stringify(crashed, null, 2)}
2660
+ `);
2661
+ } else {
2662
+ process.stderr.write(`
2663
+ Tandem Doctor crashed unexpectedly: ${message}
2664
+ `);
2665
+ process.stderr.write(
2666
+ " Please report this at https://github.com/bloknayrb/tandem/issues\n\n"
2667
+ );
2668
+ }
2669
+ return 2;
2670
+ }
2671
+ const exitCode = report.failures > 0 ? 1 : 0;
2672
+ if (json) {
2673
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
2674
+ `);
2675
+ return exitCode;
2676
+ }
2677
+ const out = (line) => process.stdout.write(`${line}
2678
+ `);
2679
+ out("");
2680
+ out(" Tandem Doctor");
2681
+ out(" =============");
2682
+ out("");
2683
+ for (const res of report.results) {
2684
+ out(` ${colorTag(res.status)} ${res.message}`);
2685
+ if (res.status !== "pass" && res.fix) {
2686
+ out(` Fix: ${res.fix}`);
2687
+ }
2688
+ }
2689
+ out("");
2690
+ if (report.failures > 0) {
2691
+ out(` ${report.failures} issue(s) found. Fix the items above and re-run: tandem doctor`);
2692
+ } else if (report.warnings > 0) {
2693
+ out(` ${report.warnings} warning(s) \u2014 Tandem should work, but check the items above.`);
2694
+ } else {
2695
+ out(" All checks passed. Tandem is ready.");
2696
+ }
2697
+ out("");
2698
+ return exitCode;
2699
+ }
2700
+ var WS_PORT, MCP_PORT, Recorder;
2701
+ var init_doctor = __esm({
2702
+ "src/cli/doctor.ts"() {
2703
+ "use strict";
2704
+ init_constants();
2705
+ WS_PORT = DEFAULT_WS_PORT;
2706
+ MCP_PORT = DEFAULT_MCP_PORT;
2707
+ Recorder = class {
2708
+ failures = 0;
2709
+ warnings = 0;
2710
+ results = [];
2711
+ currentCheck = "";
2712
+ async check(name, fn) {
2713
+ const prev = this.currentCheck;
2714
+ this.currentCheck = name;
2715
+ try {
2716
+ return await fn();
2717
+ } finally {
2718
+ this.currentCheck = prev;
2719
+ }
2720
+ }
2721
+ record(status, msg, fix, fields) {
2722
+ const entry = { check: this.currentCheck, status, message: msg };
2723
+ if (fix) entry.fix = fix;
2724
+ if (fields) entry.data = fields;
2725
+ this.results.push(entry);
2726
+ }
2727
+ pass(msg, fix, fields) {
2728
+ this.record("pass", msg, fix, fields);
2729
+ }
2730
+ warn(msg, fix, fields) {
2731
+ this.warnings++;
2732
+ this.record("warn", msg, fix, fields);
2733
+ }
2734
+ fail(msg, fix, fields) {
2735
+ this.failures++;
2736
+ this.record("fail", msg, fix, fields);
2737
+ }
2738
+ };
2739
+ }
2740
+ });
2741
+
2231
2742
  // src/shared/auth/token-file.ts
2232
2743
  import envPaths2 from "env-paths";
2233
2744
  import fs2 from "fs";
@@ -2396,11 +2907,11 @@ __export(start_exports, {
2396
2907
  runStart: () => runStart
2397
2908
  });
2398
2909
  import { spawn } from "child_process";
2399
- import { existsSync as existsSync3 } from "fs";
2910
+ import { existsSync as existsSync4 } from "fs";
2400
2911
  import { dirname as dirname3, resolve as resolve3 } from "path";
2401
2912
  import { fileURLToPath as fileURLToPath3 } from "url";
2402
2913
  function runStart() {
2403
- if (!existsSync3(SERVER_DIST)) {
2914
+ if (!existsSync4(SERVER_DIST)) {
2404
2915
  console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
2405
2916
  console.error("[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor");
2406
2917
  process.exit(1);
@@ -2451,7 +2962,7 @@ process.once("unhandledRejection", (reason) => {
2451
2962
  `);
2452
2963
  process.exit(1);
2453
2964
  });
2454
- var version = true ? "0.13.0" : "0.0.0-dev";
2965
+ var version = true ? "0.13.6" : "0.0.0-dev";
2455
2966
  var args = process.argv.slice(2);
2456
2967
  var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
2457
2968
  if (!isStdioMode) {
@@ -2465,6 +2976,9 @@ Usage:
2465
2976
  tandem setup Register MCP tools with your AI client (Claude Code / Claude Desktop by default)
2466
2977
  tandem setup --force Register to default paths regardless of detection
2467
2978
  tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
2979
+ tandem doctor Diagnose setup issues (Node version, .mcp.json,
2980
+ ports, server health, annotation store)
2981
+ tandem doctor --json Same checks, emit a single JSON report on stdout
2468
2982
  tandem rotate-token Rotate the auth token with a 60-second grace window
2469
2983
  tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
2470
2984
  (used by the plugin's Cowork bridge; requires
@@ -2497,6 +3011,10 @@ try {
2497
3011
  } else if (args[0] === "channel") {
2498
3012
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
2499
3013
  await runChannelCli2();
3014
+ } else if (args[0] === "doctor") {
3015
+ const { runDoctorCli: runDoctorCli2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
3016
+ const exitCode = await runDoctorCli2({ json: args.includes("--json") });
3017
+ process.exit(exitCode);
2500
3018
  } else if (args[0] === "rotate-token") {
2501
3019
  const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
2502
3020
  await rotateToken2();