@echomem/mcp 1.4.49 → 1.4.51

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/setup.js CHANGED
@@ -26,15 +26,19 @@ import axios from "axios";
26
26
  import { KeyStore } from "./keystore.js";
27
27
  import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
28
28
  import { buildOnboardingStatsPayload } from "./onboarding-stats.js";
29
- import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
29
+ import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, discoverSessions, dedupeSessionsByConversation, MIGRATE_CONCURRENCY, } from "./migrate.js";
30
30
  import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
- import { resolveClaudeDesktopSupportRoots } from "./local-data-paths.js";
34
- import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
33
+ import { resolveClaudeCoworkSessionRoots, resolveClaudeDesktopSupportRoots, resolveClaudeProjectsDir, resolveCodexSessionRoots, } from "./local-data-paths.js";
34
+ import { isEphemeralNpxPath, reexecFromDurableRuntime, resolveDurableDistPath, resolveGlobalEntry } from "./durable-entry.js";
35
+ import { installSaveCheckpointHooks, installSourceSessionHooks, removeLifecycleHooks } from "./hud/hooks.js";
36
+ import { atomicWriteJsonObject, atomicWriteTextFile, readJsonObjectFile } from "./config-files.js";
35
37
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
38
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
- import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.js";
39
+ import { startMcpControlServer } from "./mcp-control.js";
40
+ import { loadHudDiagnostics } from "./hud/diagnostics.js";
41
+ import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, removeHeadlessRuntime, } from "./headless-runtime.js";
38
42
  // The setup dashboard, account login, and encryption passphrase entry are all served by this
39
43
  // localhost bridge. The hosted API only sends OTP email, verifies the code, and mints a device token.
40
44
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
@@ -216,18 +220,45 @@ function runtimeModuleUrl(name) {
216
220
  }
217
221
  return jsUrl.href;
218
222
  }
223
+ function claudeDesktopRootHasBundledCode(supportRoot, platform) {
224
+ return versionedClaudeCodeExecutables(path.join(supportRoot, "claude-code"), platform === "win32" ? "claude.exe" : "claude", 1).length > 0;
225
+ }
226
+ /**
227
+ * Config targeting needs installation evidence; history discovery intentionally does not.
228
+ * A stale support directory can still contain importable Cowork history, but must not make setup
229
+ * claim that Claude Desktop is installed. An explicit support-root override is itself evidence.
230
+ */
231
+ export function claudeDesktopConfigRoots(defaultRoot, options = {}) {
232
+ const env = options.env ?? process.env;
233
+ const platform = options.platform ?? process.platform;
234
+ const homeDir = options.homeDir ?? os.homedir();
235
+ const supportRoots = resolveClaudeDesktopSupportRoots(options);
236
+ if (env.CLAUDE_DESKTOP_SUPPORT_DIR?.trim())
237
+ return supportRoots;
238
+ if (platform === "darwin") {
239
+ const installed = [
240
+ "/Applications/Claude.app",
241
+ path.join(homeDir, "Applications", "Claude.app"),
242
+ ].some((appPath) => fs.existsSync(appPath));
243
+ if (!installed)
244
+ return [];
245
+ return supportRoots.length > 0 ? supportRoots : [defaultRoot];
246
+ }
247
+ return supportRoots.filter((supportRoot) => claudeDesktopRootHasBundledCode(supportRoot, platform));
248
+ }
219
249
  /** Known clients and where their MCP server map lives. */
220
250
  export function knownClients() {
221
251
  const appSupport = process.platform === "darwin"
222
252
  ? home("Library", "Application Support")
223
253
  : process.env.APPDATA || home(".config");
224
254
  const defaultClaudeDesktopRoot = path.join(appSupport, "Claude");
225
- const claudeDesktopRoots = resolveClaudeDesktopSupportRoots();
255
+ const claudeDesktopRoots = claudeDesktopConfigRoots(defaultClaudeDesktopRoot);
226
256
  const claudeDesktopClients = (claudeDesktopRoots.length > 0 ? claudeDesktopRoots : [defaultClaudeDesktopRoot]).map((supportRoot) => ({
227
257
  id: "claude-desktop",
228
258
  label: "Claude Desktop",
229
259
  kind: "json",
230
260
  configPath: path.join(supportRoot, "claude_desktop_config.json"),
261
+ detected: claudeDesktopRoots.length > 0,
231
262
  }));
232
263
  return [
233
264
  { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
@@ -238,14 +269,27 @@ export function knownClients() {
238
269
  ];
239
270
  }
240
271
  /** A client is "present" if its config dir already exists — a cheap, side-effect-free heuristic. */
272
+ /**
273
+ * Claude Code is present when its CLI runs OR when it has left a profile behind.
274
+ *
275
+ * The CLI probe alone misses a Store-packaged Claude Desktop, whose bundled binary never reaches
276
+ * PATH; a profile alone used to mean "detected but unconfigurable". Now that writeClaudeCodeConfig
277
+ * can merge the user entry into ~/.claude.json without a CLI, a profile is enough to act on.
278
+ */
279
+ export function claudeCodeInstalled() {
280
+ return fs.existsSync(home(".claude")) || fs.existsSync(home(".claude.json")) || claudeCodeCliAvailable();
281
+ }
241
282
  export function detectClients() {
242
283
  return knownClients().filter((c) => {
243
- if (c.kind === "json")
284
+ if (c.kind === "json") {
285
+ if (c.id === "claude-desktop")
286
+ return c.detected === true;
244
287
  return fs.existsSync(path.dirname(c.configPath));
288
+ }
245
289
  if (c.kind === "command")
246
290
  return fs.existsSync(c.detectDir);
247
291
  if (c.id === "claude-code")
248
- return claudeCodeCliAvailable();
292
+ return claudeCodeInstalled();
249
293
  return false;
250
294
  });
251
295
  }
@@ -287,33 +331,6 @@ export function buildServerEntry(opts = {}) {
287
331
  // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
288
332
  return { command: "npx", args: ["@echomem/mcp"] };
289
333
  }
290
- /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
291
- function isEphemeralNpxPath(entry) {
292
- return entry.split(path.sep).includes("_npx");
293
- }
294
- /**
295
- * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
296
- * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
297
- * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
298
- */
299
- function resolveGlobalEntry() {
300
- const nodeDir = path.dirname(process.execPath);
301
- const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
302
- const candidates = [
303
- path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
304
- path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
305
- ];
306
- for (const candidate of candidates) {
307
- try {
308
- if (fs.existsSync(candidate))
309
- return fs.realpathSync(candidate);
310
- }
311
- catch {
312
- /* keep trying */
313
- }
314
- }
315
- return null;
316
- }
317
334
  function installDurableHeadlessRuntime() {
318
335
  if (process.env.ECHO_DISABLE_RUNTIME_BOOTSTRAP === "1")
319
336
  return;
@@ -394,13 +411,38 @@ export function writeCodexConfig(configPath, entry, options = {}) {
394
411
  if (lines.slice(start, end).join("\n").trimEnd() === block)
395
412
  return "exists"; // already correct
396
413
  const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
397
- fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
414
+ atomicWriteTextFile(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
398
415
  return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
399
416
  }
400
417
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
401
- fs.appendFileSync(configPath, sep + block + "\n");
418
+ atomicWriteTextFile(configPath, content + sep + block + "\n");
402
419
  return "wrote";
403
420
  }
421
+ /** Remove only EchoMem's TOML section and preserve every other Codex setting. */
422
+ export function removeCodexConfig(configPath) {
423
+ let content;
424
+ try {
425
+ content = fs.readFileSync(configPath, "utf8");
426
+ }
427
+ catch (error) {
428
+ if (error.code === "ENOENT")
429
+ return false;
430
+ throw error;
431
+ }
432
+ const lines = content.split("\n");
433
+ const start = lines.findIndex((line) => /^\s*\[mcp_servers\.echomem\]\s*$/.test(line));
434
+ if (start < 0)
435
+ return false;
436
+ let end = start + 1;
437
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
438
+ end += 1;
439
+ const next = [...lines.slice(0, start), ...lines.slice(end)]
440
+ .join("\n")
441
+ .replace(/^\n+/, "")
442
+ .replace(/\n{3,}/g, "\n\n");
443
+ atomicWriteTextFile(configPath, next);
444
+ return true;
445
+ }
404
446
  /**
405
447
  * Write the EchoMem guidance block into the agent's GLOBAL memory file (~/.codex/AGENTS.md,
406
448
  * ~/.claude/CLAUDE.md) so the agent treats EchoMem as its core memory tool without the tool
@@ -455,13 +497,35 @@ export function writeAgentsMemoryGuidance(filePath) {
455
497
  const current = content.slice(start, end + AGENTS_MD_END.length);
456
498
  if (current === block)
457
499
  return "exists";
458
- fs.writeFileSync(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
500
+ atomicWriteTextFile(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
459
501
  return "updated";
460
502
  }
461
503
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
462
- fs.appendFileSync(filePath, sep + block + "\n");
504
+ atomicWriteTextFile(filePath, content + sep + block + "\n");
463
505
  return "wrote";
464
506
  }
507
+ /** Remove EchoMem's marker-owned guidance block without touching user-authored content. */
508
+ export function removeAgentsMemoryGuidance(filePath) {
509
+ let content;
510
+ try {
511
+ content = fs.readFileSync(filePath, "utf8");
512
+ }
513
+ catch (error) {
514
+ if (error.code === "ENOENT")
515
+ return false;
516
+ throw error;
517
+ }
518
+ const start = content.indexOf(AGENTS_MD_BEGIN);
519
+ const end = content.indexOf(AGENTS_MD_END);
520
+ if (start < 0 || end <= start)
521
+ return false;
522
+ const next = (content.slice(0, start) + content.slice(end + AGENTS_MD_END.length))
523
+ .replace(/^\s*\n/, "")
524
+ .replace(/\n{3,}/g, "\n\n")
525
+ .trimEnd();
526
+ atomicWriteTextFile(filePath, next ? `${next}\n` : "");
527
+ return true;
528
+ }
465
529
  /**
466
530
  * Refresh marker-owned guidance for users upgrading an existing standalone MCP install.
467
531
  * This intentionally does not create global memory files: setup owns first installation,
@@ -493,32 +557,30 @@ export function refreshInstalledMemoryGuidance() {
493
557
  }
494
558
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
495
559
  export function writeJsonClientConfig(configPath, entry, options = {}) {
496
- let config = {};
497
- try {
498
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
499
- }
500
- catch {
501
- /* fresh config */
502
- }
503
- config.mcpServers = config.mcpServers || {};
504
- if (!options.forceHeadless && validDesktopManagedEntry(config.mcpServers.echomem)) {
560
+ const config = readJsonObjectFile(configPath, "MCP client configuration");
561
+ const servers = objectRecord(config.mcpServers) ?? {};
562
+ config.mcpServers = servers;
563
+ if (!options.forceHeadless && validDesktopManagedEntry(servers.echomem)) {
505
564
  return "desktop-managed";
506
565
  }
507
- config.mcpServers.echomem = entry;
508
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
509
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
566
+ servers.echomem = entry;
567
+ atomicWriteJsonObject(configPath, config);
510
568
  return "wrote";
511
569
  }
570
+ /** Remove only the EchoMem server entry from a JSON MCP client configuration. */
571
+ export function removeJsonClientConfig(configPath) {
572
+ if (!fs.existsSync(configPath))
573
+ return false;
574
+ const config = readJsonObjectFile(configPath, "MCP client configuration");
575
+ const servers = objectRecord(config.mcpServers);
576
+ if (!servers || !("echomem" in servers))
577
+ return false;
578
+ delete servers.echomem;
579
+ atomicWriteJsonObject(configPath, config);
580
+ return true;
581
+ }
512
582
  function readClaudeCodeConfigFile(configPath) {
513
- try {
514
- const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
515
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
516
- ? parsed
517
- : {};
518
- }
519
- catch {
520
- return {};
521
- }
583
+ return readJsonObjectFile(configPath, "Claude Code user configuration");
522
584
  }
523
585
  function echoMemEntryFromServers(value) {
524
586
  return objectRecord(objectRecord(value)?.echomem);
@@ -552,7 +614,7 @@ function claudeCodeLocalEchoMemProjects(configPath) {
552
614
  .map(([projectPath]) => projectPath)
553
615
  .sort();
554
616
  }
555
- function versionedClaudeCodeExecutables(root, executable) {
617
+ function versionedClaudeCodeExecutables(root, executable, limit = Number.POSITIVE_INFINITY) {
556
618
  let versions;
557
619
  try {
558
620
  versions = fs.readdirSync(root, { withFileTypes: true });
@@ -564,7 +626,8 @@ function versionedClaudeCodeExecutables(root, executable) {
564
626
  .filter((entry) => entry.isDirectory())
565
627
  .sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
566
628
  .map((entry) => path.join(root, entry.name, executable))
567
- .filter((candidate) => fs.existsSync(candidate));
629
+ .filter((candidate) => fs.existsSync(candidate))
630
+ .slice(0, limit);
568
631
  }
569
632
  /**
570
633
  * Return every safe Claude Code launcher location worth trying. Claude Desktop bundles the CLI,
@@ -583,13 +646,13 @@ export function claudeCodeCommandCandidates(options = {}) {
583
646
  if (platform === "win32") {
584
647
  const appData = env.APPDATA?.trim() || path.join(homeDir, "AppData", "Roaming");
585
648
  const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir, "AppData", "Local");
586
- candidates.push(...versionedClaudeCodeExecutables(path.join(appData, "Claude", "claude-code"), "claude.exe"), ...versionedClaudeCodeExecutables(path.join(localAppData, "Claude", "claude-code"), "claude.exe"));
649
+ candidates.push(...versionedClaudeCodeExecutables(path.join(appData, "Claude", "claude-code"), "claude.exe", 1), ...versionedClaudeCodeExecutables(path.join(localAppData, "Claude", "claude-code"), "claude.exe", 1));
587
650
  const packagesRoot = path.join(localAppData, "Packages");
588
651
  try {
589
652
  for (const entry of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
590
653
  if (!entry.isDirectory() || !/^Claude_/i.test(entry.name))
591
654
  continue;
592
- candidates.push(...versionedClaudeCodeExecutables(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude", "claude-code"), "claude.exe"));
655
+ candidates.push(...versionedClaudeCodeExecutables(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude", "claude-code"), "claude.exe", 1));
593
656
  }
594
657
  }
595
658
  catch {
@@ -622,55 +685,106 @@ function escapeWindowsCmdArgument(value) {
622
685
  escaped = `"${escaped}"`;
623
686
  return escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
624
687
  }
625
- function execClaudeCodeSync(args, options, commandCandidates) {
688
+ function execClaudeCodeCommandSync(command, args, options) {
689
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
690
+ const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
691
+ const spawnOptions = {
692
+ ...options,
693
+ windowsHide: true,
694
+ windowsVerbatimArguments: true,
695
+ };
696
+ const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
697
+ if (result.error)
698
+ throw result.error;
699
+ if (result.status !== 0) {
700
+ throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
701
+ }
702
+ return result.stdout || "";
703
+ }
704
+ return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
705
+ }
706
+ function claudeCodeCommandsOnPath(timeout) {
707
+ if (process.platform !== "win32" || timeout <= 0)
708
+ return [];
709
+ try {
710
+ return execFileSync("where.exe", ["claude"], {
711
+ encoding: "utf8",
712
+ stdio: ["ignore", "pipe", "ignore"],
713
+ timeout,
714
+ windowsHide: true,
715
+ }).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
716
+ }
717
+ catch {
718
+ return [];
719
+ }
720
+ }
721
+ let cachedClaudeCodeCommand = null;
722
+ function resolveRunnableClaudeCodeCommand(commandCandidates, deadline = Date.now() + 3000) {
723
+ if (!commandCandidates && cachedClaudeCodeCommand)
724
+ return cachedClaudeCodeCommand;
626
725
  let candidates = commandCandidates ? [...commandCandidates] : claudeCodeCommandCandidates();
627
- if (process.platform === "win32" && !commandCandidates) {
726
+ if (!commandCandidates) {
727
+ const remainingForPath = Math.max(1, Math.min(1000, deadline - Date.now()));
728
+ candidates = [...new Set([...claudeCodeCommandsOnPath(remainingForPath), ...candidates])];
729
+ }
730
+ for (const command of candidates) {
731
+ const remaining = deadline - Date.now();
732
+ if (remaining <= 0)
733
+ break;
628
734
  try {
629
- const pathMatches = execFileSync("where.exe", ["claude"], {
735
+ execClaudeCodeCommandSync(command, ["--version"], {
630
736
  encoding: "utf8",
631
737
  stdio: ["ignore", "pipe", "ignore"],
632
- timeout: 3000,
633
- windowsHide: true,
634
- }).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
635
- candidates = [...new Set([...pathMatches, ...candidates])];
738
+ timeout: remaining,
739
+ });
740
+ if (!commandCandidates)
741
+ cachedClaudeCodeCommand = command;
742
+ return command;
636
743
  }
637
744
  catch {
638
- /* The bundled Desktop candidates below remain available. */
745
+ /* Keep trying only while the shared deadline has time left. */
639
746
  }
640
747
  }
641
- let lastError = new Error("Claude Code CLI was not found.");
642
- for (const command of candidates) {
643
- try {
644
- if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
645
- const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
646
- const spawnOptions = {
647
- ...options,
648
- windowsHide: true,
649
- windowsVerbatimArguments: true,
650
- };
651
- const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
652
- if (result.error)
653
- throw result.error;
654
- if (result.status !== 0) {
655
- throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
656
- }
657
- return result.stdout || "";
658
- }
659
- return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
660
- }
661
- catch (error) {
662
- lastError = error;
663
- }
664
- }
665
- throw lastError;
748
+ return null;
749
+ }
750
+ function execClaudeCodeSync(args, options, commandCandidates) {
751
+ const probeBudget = typeof options.timeout === "number" ? Math.min(options.timeout, 3000) : 3000;
752
+ const command = resolveRunnableClaudeCodeCommand(commandCandidates, Date.now() + probeBudget);
753
+ if (!command)
754
+ throw new Error("Claude Code CLI was not found within the setup time budget.");
755
+ return execClaudeCodeCommandSync(command, args, options);
666
756
  }
667
757
  export function claudeCodeCliAvailable() {
758
+ return resolveRunnableClaudeCodeCommand(undefined, Date.now() + 3000) !== null;
759
+ }
760
+ /**
761
+ * Merge the user-scope EchoMem entry straight into ~/.claude.json.
762
+ *
763
+ * Reserved for machines where NO Claude Code CLI can be executed at all — a Store-packaged Claude
764
+ * Desktop whose bundled binary never lands on PATH, an npm shim in a stripped environment. Without
765
+ * this, such a machine can never be configured, even though the file is trivial to update.
766
+ *
767
+ * Deliberately narrow: read immediately before write, touch only `mcpServers.echomem`, preserve
768
+ * every other key (this file also holds Claude account and session state), and swap it in via a
769
+ * same-directory temp file so a crash cannot truncate it. Project-local entries are still left to
770
+ * the CLI — removing those means knowing which project a scope belongs to.
771
+ */
772
+ function writeUserEntryDirectly(configPath, entry) {
668
773
  try {
669
- execClaudeCodeSync(["--version"], {
670
- encoding: "utf8",
671
- stdio: ["ignore", "pipe", "ignore"],
672
- timeout: 3000,
673
- });
774
+ const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8").trim() : "";
775
+ const parsed = raw ? JSON.parse(raw) : {};
776
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
777
+ return false;
778
+ const config = parsed;
779
+ const existing = config.mcpServers;
780
+ const servers = typeof existing === "object" && existing !== null && !Array.isArray(existing)
781
+ ? existing
782
+ : {};
783
+ servers.echomem = entry;
784
+ config.mcpServers = servers;
785
+ const tmp = `${configPath}.echomem-${process.pid}.tmp`;
786
+ fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`);
787
+ fs.renameSync(tmp, configPath);
674
788
  return true;
675
789
  }
676
790
  catch {
@@ -682,6 +796,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
682
796
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
683
797
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
684
798
  const configPath = options.configPath ?? home(".claude.json");
799
+ const configurationDeadline = Date.now() + 5000;
685
800
  let failureReason;
686
801
  const emptyResult = () => ({
687
802
  state: "unavailable",
@@ -692,14 +807,27 @@ export function writeClaudeCodeConfig(entry, options = {}) {
692
807
  preservedDesktopManaged: false,
693
808
  failureReason,
694
809
  });
810
+ let runnableCommand;
811
+ const cliCommand = () => {
812
+ if (runnableCommand === undefined) {
813
+ runnableCommand = resolveRunnableClaudeCodeCommand(options.claudeCommands, configurationDeadline);
814
+ }
815
+ return runnableCommand;
816
+ };
695
817
  const runClaude = (args, cwd) => {
818
+ const command = cliCommand();
819
+ const remaining = configurationDeadline - Date.now();
820
+ if (!command || remaining <= 0) {
821
+ failureReason = "Claude Code CLI was unavailable within the onboarding time budget.";
822
+ return false;
823
+ }
696
824
  try {
697
- execClaudeCodeSync(args, {
825
+ execClaudeCodeCommandSync(command, args, {
698
826
  cwd,
699
827
  encoding: "utf8",
700
828
  stdio: ["ignore", "pipe", "pipe"],
701
- timeout: 10000,
702
- }, options.claudeCommands);
829
+ timeout: Math.min(remaining, 5000),
830
+ });
703
831
  failureReason = undefined;
704
832
  return true;
705
833
  }
@@ -718,16 +846,31 @@ export function writeClaudeCodeConfig(entry, options = {}) {
718
846
  && validDesktopManagedEntry(previousUserEntry);
719
847
  const desiredUserEntry = preservedDesktopManaged ? previousUserEntry : entry;
720
848
  let restoredPreviousUserEntry = false;
849
+ let usedDirectWrite = false;
850
+ const fallbackWrite = () => {
851
+ // A working CLI that refuses is a real failure; masking it would hide a genuine problem.
852
+ if (cliCommand())
853
+ return false;
854
+ if (!writeUserEntryDirectly(configPath, desiredUserEntry))
855
+ return false;
856
+ usedDirectWrite = true;
857
+ failureReason = undefined;
858
+ return true;
859
+ };
721
860
  // Avoid interrupting active/new sessions when the correct global entry is already installed.
722
861
  if (!claudeEntriesMatch(previousUserEntry, desiredUserEntry)) {
723
- if (previousUserEntry && !removeUser())
724
- return emptyResult();
725
- if (!addUser(desiredUserEntry)) {
726
- const addFailureReason = failureReason;
727
- if (previousUserEntry)
728
- restoredPreviousUserEntry = addUser(previousUserEntry);
729
- failureReason = addFailureReason;
730
- return { ...emptyResult(), restoredPreviousUserEntry };
862
+ if (previousUserEntry && !removeUser()) {
863
+ if (!fallbackWrite())
864
+ return emptyResult();
865
+ }
866
+ else if (!addUser(desiredUserEntry)) {
867
+ if (!fallbackWrite()) {
868
+ const addFailureReason = failureReason;
869
+ if (previousUserEntry)
870
+ restoredPreviousUserEntry = addUser(previousUserEntry);
871
+ failureReason = addFailureReason;
872
+ return { ...emptyResult(), restoredPreviousUserEntry };
873
+ }
731
874
  }
732
875
  }
733
876
  const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
@@ -768,8 +911,39 @@ export function writeClaudeCodeConfig(entry, options = {}) {
768
911
  failedLocalProjects: unresolved,
769
912
  restoredPreviousUserEntry,
770
913
  preservedDesktopManaged,
914
+ usedDirectWrite,
771
915
  };
772
916
  }
917
+ /** Remove EchoMem at Claude Code user/project scope while preserving account and sibling config. */
918
+ export function removeClaudeCodeConfig(configPath = home(".claude.json")) {
919
+ if (!fs.existsSync(configPath))
920
+ return { removedUserEntry: false, removedProjectEntries: [] };
921
+ const config = readClaudeCodeConfigFile(configPath);
922
+ let changed = false;
923
+ let removedUserEntry = false;
924
+ const userServers = objectRecord(config.mcpServers);
925
+ if (userServers && "echomem" in userServers) {
926
+ delete userServers.echomem;
927
+ removedUserEntry = true;
928
+ changed = true;
929
+ }
930
+ const removedProjectEntries = [];
931
+ const projects = objectRecord(config.projects);
932
+ if (projects) {
933
+ for (const [projectPath, value] of Object.entries(projects)) {
934
+ const project = objectRecord(value);
935
+ const servers = objectRecord(project?.mcpServers);
936
+ if (!servers || !("echomem" in servers))
937
+ continue;
938
+ delete servers.echomem;
939
+ removedProjectEntries.push(projectPath);
940
+ changed = true;
941
+ }
942
+ }
943
+ if (changed)
944
+ atomicWriteJsonObject(configPath, config);
945
+ return { removedUserEntry, removedProjectEntries };
946
+ }
773
947
  function readJsonClientEntry(configPath) {
774
948
  try {
775
949
  const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
@@ -2611,6 +2785,11 @@ function parseFlags(argv) {
2611
2785
  }
2612
2786
  return flags;
2613
2787
  }
2788
+ /**
2789
+ * Configure MCP clients without starting local-history HTML onboarding. Echo Desktop on macOS owns
2790
+ * its native onboarding and invokes this setup surface with --skip-login; only CLI `init` below owns
2791
+ * the browser flow used by Windows/headless users.
2792
+ */
2614
2793
  async function cmdSetup(flags) {
2615
2794
  if (!flags.dev && !flags["skip-runtime-install"]) {
2616
2795
  try {
@@ -2663,6 +2842,9 @@ async function cmdSetup(flags) {
2663
2842
  if (result.preservedDesktopManaged) {
2664
2843
  console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2665
2844
  }
2845
+ else if (result.usedDirectWrite) {
2846
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} directly (no runnable \`claude\` CLI was found) — start a new Claude Code session to load it.`);
2847
+ }
2666
2848
  else {
2667
2849
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2668
2850
  }
@@ -2733,7 +2915,8 @@ async function cmdSetup(flags) {
2733
2915
  }
2734
2916
  }
2735
2917
  /**
2736
- * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2918
+ * `echomem-mcp init` — the CLI-owned one-command install used by Windows/headless users. Configures
2919
+ * EVERY coding agent installed on this
2737
2920
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2738
2921
  * Codex skills and writes the AGENTS.md memory guidance. One browser
2739
2922
  * bridge then runs permission → login → plan if needed → extraction in that order.
@@ -2835,11 +3018,14 @@ async function cmdUpdate(flags) {
2835
3018
  function selectSetupTargets(requested, all) {
2836
3019
  if (all || requested === "all") {
2837
3020
  return knownClients().filter((client) => {
2838
- if (client.kind === "json")
3021
+ if (client.kind === "json") {
3022
+ if (client.id === "claude-desktop")
3023
+ return client.detected === true;
2839
3024
  return fs.existsSync(path.dirname(client.configPath));
3025
+ }
2840
3026
  if (client.kind === "command")
2841
3027
  return fs.existsSync(client.detectDir);
2842
- return client.id === "claude-code" && claudeCodeCliAvailable();
3028
+ return client.id === "claude-code" && claudeCodeInstalled();
2843
3029
  });
2844
3030
  }
2845
3031
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -3587,6 +3773,293 @@ function cmdLock() {
3587
3773
  store.clearKey();
3588
3774
  console.log("🔒 EchoMem vault locked on this device. Your login remains connected.");
3589
3775
  }
3776
+ export async function collectMcpDoctorReport(options = {}) {
3777
+ const updateStatus = options.noNetwork
3778
+ ? readCachedUpdateStatus()
3779
+ : await checkLatestUpdateStatus({ force: true });
3780
+ const desiredVersion = updateStatus?.latestVersion ?? MCP_PACKAGE_VERSION;
3781
+ const runtime = readHeadlessRuntimeInstallation();
3782
+ const store = new KeyStore();
3783
+ const token = store.getToken();
3784
+ const key = store.getKey();
3785
+ let account = token
3786
+ ? { state: options.noNetwork ? "not_checked" : "unreachable", detail: options.noNetwork ? "Account validation skipped." : "Account validation did not finish." }
3787
+ : { state: "not_connected", detail: "Connect this Windows profile to an Echo account." };
3788
+ let vault = key
3789
+ ? { state: options.noNetwork ? "not_checked" : "unknown", detail: options.noNetwork ? "Vault validation skipped." : "Checking the saved vault key." }
3790
+ : { state: token ? "unknown" : "locked", detail: token ? "Vault state has not been checked." : "Connect the account before checking the vault." };
3791
+ let lastSearch;
3792
+ if (token && !options.noNetwork) {
3793
+ const diagnostics = await loadHudDiagnostics();
3794
+ if (diagnostics.account.state === "connected") {
3795
+ account = {
3796
+ state: "connected",
3797
+ plan: diagnostics.account.plan,
3798
+ detail: diagnostics.account.plan
3799
+ ? `${diagnostics.account.plan} account verified.`
3800
+ : "Echo account verified.",
3801
+ };
3802
+ }
3803
+ else if (diagnostics.accountRequest.httpStatus === 401 || diagnostics.accountRequest.httpStatus === 403) {
3804
+ account = { state: "invalid", detail: "The saved Echo credential is no longer valid. Reconnect the account." };
3805
+ }
3806
+ else {
3807
+ account = { state: "unreachable", detail: diagnostics.account.error || "Echo account validation is temporarily unavailable." };
3808
+ }
3809
+ if (diagnostics.lastSearch) {
3810
+ lastSearch = {
3811
+ at: diagnostics.lastSearch.at,
3812
+ ok: diagnostics.lastSearch.ok,
3813
+ httpStatus: diagnostics.lastSearch.httpStatus,
3814
+ latencyMs: diagnostics.lastSearch.latencyMs,
3815
+ };
3816
+ }
3817
+ if (account.state === "connected") {
3818
+ try {
3819
+ const encryption = await fetchEncryptionConfig(authedAxios(token));
3820
+ if (!encryption.enabled) {
3821
+ vault = { state: "unencrypted", detail: "This account does not require a local vault key." };
3822
+ }
3823
+ else if (!key) {
3824
+ vault = { state: "locked", detail: "The encrypted vault is locked on this Windows profile." };
3825
+ }
3826
+ else if (await verifyKeyB64(key, encryption)) {
3827
+ vault = { state: "unlocked", detail: "The local vault key is valid." };
3828
+ }
3829
+ else {
3830
+ vault = { state: "invalid", detail: "The saved vault key does not match this account. Reconnect and unlock again." };
3831
+ }
3832
+ }
3833
+ catch (error) {
3834
+ vault = { state: "unknown", detail: `Vault validation unavailable: ${formatVerificationError(error)}` };
3835
+ }
3836
+ }
3837
+ else if (!key) {
3838
+ vault = { state: "unknown", detail: "Vault state cannot be verified until the account reconnects." };
3839
+ }
3840
+ }
3841
+ const wsl = process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
3842
+ const clients = inspectClientConfigs(desiredVersion).map((client) => {
3843
+ const health = !client.configured
3844
+ ? "not_configured"
3845
+ : client.state === "ok" || client.state === "newer"
3846
+ ? "ready"
3847
+ : client.state === "stale" || client.state === "missing"
3848
+ ? "repair"
3849
+ : "attention";
3850
+ return {
3851
+ ...client,
3852
+ detected: true,
3853
+ health,
3854
+ recommendedAction: health === "not_configured" ? "connect" : health === "repair" || health === "attention" ? "repair" : "none",
3855
+ };
3856
+ });
3857
+ return {
3858
+ packageVersion: MCP_PACKAGE_VERSION,
3859
+ publishedLatest: updateStatus?.latestVersion,
3860
+ platform: process.platform,
3861
+ credentialsPresent: Boolean(token),
3862
+ vaultKeyPresent: Boolean(key),
3863
+ runtime: runtime ? { version: runtime.version, launcher: runtime.launcher } : undefined,
3864
+ account,
3865
+ vault,
3866
+ environment: {
3867
+ windowsNative: process.platform === "win32",
3868
+ wsl,
3869
+ detail: wsl
3870
+ ? "This is WSL. Native Windows agents use a separate EchoMem installation and configuration."
3871
+ : process.platform === "win32"
3872
+ ? "Native Windows profile. WSL installations are managed separately."
3873
+ : "This Connect Echo page is running outside native Windows.",
3874
+ },
3875
+ lastSearch,
3876
+ clients,
3877
+ };
3878
+ }
3879
+ /**
3880
+ * Remove standalone EchoMem MCP integration without deleting login/vault credentials or cloud data.
3881
+ * Every user-owned config is edited narrowly and atomically; malformed files fail closed.
3882
+ */
3883
+ export function uninstallStandaloneMcp() {
3884
+ const store = new KeyStore();
3885
+ const report = {
3886
+ removedClients: [],
3887
+ removedGuidance: [],
3888
+ removedSkills: [],
3889
+ removedHookGroups: 0,
3890
+ removedRuntime: false,
3891
+ credentialsPreserved: Boolean(store.getToken() || store.getKey()),
3892
+ errors: [],
3893
+ };
3894
+ for (const client of knownClients()) {
3895
+ try {
3896
+ if (client.kind === "json") {
3897
+ if (removeJsonClientConfig(client.configPath))
3898
+ report.removedClients.push(client.label);
3899
+ }
3900
+ else if (client.kind === "command") {
3901
+ if (removeCodexConfig(client.configPath))
3902
+ report.removedClients.push(client.label);
3903
+ }
3904
+ else {
3905
+ const removed = removeClaudeCodeConfig();
3906
+ if (removed.removedUserEntry || removed.removedProjectEntries.length > 0)
3907
+ report.removedClients.push(client.label);
3908
+ }
3909
+ }
3910
+ catch (error) {
3911
+ report.errors.push(`${client.label}: ${error instanceof Error ? error.message : String(error)}`);
3912
+ }
3913
+ }
3914
+ try {
3915
+ const hooks = removeLifecycleHooks("both");
3916
+ report.removedHookGroups = hooks.removedGroups;
3917
+ }
3918
+ catch (error) {
3919
+ report.errors.push(`Lifecycle hooks: ${error instanceof Error ? error.message : String(error)}`);
3920
+ }
3921
+ for (const file of [path.join(codexHome(), "AGENTS.md"), path.join(claudeConfigHome(), "CLAUDE.md")]) {
3922
+ try {
3923
+ if (removeAgentsMemoryGuidance(file))
3924
+ report.removedGuidance.push(file);
3925
+ }
3926
+ catch (error) {
3927
+ report.errors.push(`Guidance ${file}: ${error instanceof Error ? error.message : String(error)}`);
3928
+ }
3929
+ }
3930
+ for (const skillName of CODEX_SKILL_NAMES) {
3931
+ const directory = path.join(codexHome(), "skills", skillName);
3932
+ try {
3933
+ if (!fs.existsSync(directory))
3934
+ continue;
3935
+ fs.rmSync(directory, { recursive: true, force: true });
3936
+ report.removedSkills.push(skillName);
3937
+ }
3938
+ catch (error) {
3939
+ report.errors.push(`Codex skill ${skillName}: ${error instanceof Error ? error.message : String(error)}`);
3940
+ }
3941
+ }
3942
+ try {
3943
+ report.removedRuntime = removeHeadlessRuntime().removed;
3944
+ }
3945
+ catch (error) {
3946
+ report.errors.push(`Managed runtime: ${error instanceof Error ? error.message : String(error)}`);
3947
+ }
3948
+ return report;
3949
+ }
3950
+ function requireClient(clientId) {
3951
+ const client = knownClients().find((candidate) => candidate.id === clientId);
3952
+ if (!client)
3953
+ throw new Error(`Unsupported MCP host: ${clientId}`);
3954
+ return client;
3955
+ }
3956
+ export async function connectStandaloneMcpHost(clientId, options = {}) {
3957
+ requireClient(clientId);
3958
+ const updateStatus = await checkLatestUpdateStatus({ force: true });
3959
+ const targetVersion = updateStatus?.latestVersion ?? MCP_PACKAGE_VERSION;
3960
+ installHeadlessRuntimeSync(targetVersion, {
3961
+ force: options.repair === true,
3962
+ prune: options.repair === true,
3963
+ });
3964
+ await cmdSetup({
3965
+ client: clientId,
3966
+ "skip-login": true,
3967
+ "skip-runtime-install": true,
3968
+ "continue-on-client-error": true,
3969
+ });
3970
+ return collectMcpDoctorReport({ noNetwork: true });
3971
+ }
3972
+ export async function disconnectStandaloneMcpHost(clientId) {
3973
+ const client = requireClient(clientId);
3974
+ if (client.kind === "json") {
3975
+ removeJsonClientConfig(client.configPath);
3976
+ }
3977
+ else if (client.kind === "command") {
3978
+ removeCodexConfig(client.configPath);
3979
+ }
3980
+ else {
3981
+ removeClaudeCodeConfig();
3982
+ }
3983
+ if (client.id === "codex") {
3984
+ removeLifecycleHooks("codex");
3985
+ removeAgentsMemoryGuidance(path.join(codexHome(), "AGENTS.md"));
3986
+ for (const skillName of CODEX_SKILL_NAMES) {
3987
+ fs.rmSync(path.join(codexHome(), "skills", skillName), { recursive: true, force: true });
3988
+ }
3989
+ }
3990
+ if (client.id === "claude-code")
3991
+ removeLifecycleHooks("claude-code");
3992
+ if (client.id === "claude-code" || client.id === "claude-desktop") {
3993
+ const otherId = client.id === "claude-code" ? "claude-desktop" : "claude-code";
3994
+ const other = requireClient(otherId);
3995
+ const otherReport = inspectClientConfig(other, MCP_PACKAGE_VERSION);
3996
+ if (!otherReport?.configured)
3997
+ removeAgentsMemoryGuidance(path.join(claudeConfigHome(), "CLAUDE.md"));
3998
+ }
3999
+ return collectMcpDoctorReport({ noNetwork: true });
4000
+ }
4001
+ async function cleanReconnectStandaloneMcp() {
4002
+ const updateStatus = await checkLatestUpdateStatus({ force: true });
4003
+ const targetVersion = updateStatus?.latestVersion ?? MCP_PACKAGE_VERSION;
4004
+ console.log(`Installing a clean ${MCP_PACKAGE_NAME}@${targetVersion} runtime…`);
4005
+ installHeadlessRuntimeSync(targetVersion, { force: true, prune: true });
4006
+ await cmdSetup({
4007
+ all: true,
4008
+ "skip-login": true,
4009
+ "skip-runtime-install": true,
4010
+ "continue-on-client-error": true,
4011
+ });
4012
+ return collectMcpDoctorReport({ noNetwork: true });
4013
+ }
4014
+ async function cmdControl() {
4015
+ const control = await startMcpControlServer({
4016
+ doctor: () => collectMcpDoctorReport(),
4017
+ connectHost: (clientId, repair) => connectStandaloneMcpHost(clientId, { repair }),
4018
+ disconnectHost: (clientId) => disconnectStandaloneMcpHost(clientId),
4019
+ reconnect: () => cleanReconnectStandaloneMcp(),
4020
+ uninstall: () => uninstallStandaloneMcp(),
4021
+ });
4022
+ console.log("Connect Echo is running locally.");
4023
+ console.log(control.url);
4024
+ console.log("Close this terminal or press Ctrl+C when you are finished.");
4025
+ openBrowser(control.url);
4026
+ }
4027
+ async function cmdConnect(flags) {
4028
+ if (flags["skip-login"] !== true) {
4029
+ const current = await collectMcpDoctorReport();
4030
+ const accountNeedsLogin = current.account.state === "not_connected" || current.account.state === "invalid";
4031
+ const vaultNeedsUnlock = current.account.state === "connected"
4032
+ && (current.vault.state === "locked" || current.vault.state === "invalid");
4033
+ if (accountNeedsLogin || vaultNeedsUnlock) {
4034
+ const connected = await cmdLogin({ ...flags, force: true });
4035
+ if (!connected)
4036
+ return;
4037
+ }
4038
+ }
4039
+ await cmdControl();
4040
+ }
4041
+ async function cmdReconnect(flags) {
4042
+ const report = await cleanReconnectStandaloneMcp();
4043
+ const configured = report.clients.filter((client) => client.configured).length;
4044
+ console.log(`Clean reconnect complete: ${configured} MCP host${configured === 1 ? "" : "s"} configured.`);
4045
+ console.log("Start a new session in each MCP host to load the clean runtime.");
4046
+ }
4047
+ function cmdUninstall(flags) {
4048
+ if (flags.confirm !== true) {
4049
+ console.error("Uninstall requires explicit confirmation: echomem-mcp uninstall --confirm");
4050
+ console.error("This preserves EchoMem login, vault credentials, and cloud memories.");
4051
+ process.exitCode = 1;
4052
+ return;
4053
+ }
4054
+ const report = uninstallStandaloneMcp();
4055
+ console.log(`Removed EchoMem MCP from ${report.removedClients.length} host configuration${report.removedClients.length === 1 ? "" : "s"}.`);
4056
+ console.log(`Managed runtime: ${report.removedRuntime ? "removed" : "not installed"}.`);
4057
+ console.log("EchoMem login, vault credentials, and cloud memories were preserved.");
4058
+ if (report.errors.length > 0) {
4059
+ console.error(`Some components could not be removed:\n${report.errors.map((error) => `- ${error}`).join("\n")}`);
4060
+ process.exitCode = 1;
4061
+ }
4062
+ }
3590
4063
  async function cmdStatus(flags = {}) {
3591
4064
  const store = new KeyStore();
3592
4065
  const token = store.getToken();
@@ -3640,6 +4113,60 @@ async function cmdStatus(flags = {}) {
3640
4113
  }
3641
4114
  }
3642
4115
  }
4116
+ /**
4117
+ * Where this install lives and what it can actually see.
4118
+ *
4119
+ * `status` answers "who am I logged in as". This answers "why is EchoMem not finding my sessions",
4120
+ * which is the question that needs a command — a bare zero cannot distinguish "no store here" from
4121
+ * "store found, nothing in it". Local only, so it still works with no network and no account.
4122
+ */
4123
+ export function doctorReportLines() {
4124
+ const lines = ["Environment:"];
4125
+ const entry = resolveDurableDistPath("index.js");
4126
+ const runtime = readHeadlessRuntimeInstallation();
4127
+ lines.push(` package ${MCP_PACKAGE_NAME}@${MCP_PACKAGE_VERSION}`);
4128
+ lines.push(` entry point ${entry.path}${entry.ephemeral ? " [temporary npx cache — npm can delete this]" : ""}`);
4129
+ lines.push(` durable runtime ${runtime ? `${runtime.version} at ${runtime.entry}` : "not installed"}`);
4130
+ const candidates = claudeCodeCommandCandidates();
4131
+ lines.push(` claude CLI ${claudeCodeCliAvailable() ? "runnable" : `not runnable (${candidates.length} candidate${candidates.length === 1 ? "" : "s"} tried)`}`);
4132
+ const supportRoots = resolveClaudeDesktopSupportRoots();
4133
+ const containerised = supportRoots.some((root) => /[\\/]Packages[\\/]Claude_/i.test(root));
4134
+ lines.push(` Claude Desktop ${supportRoots.length === 0
4135
+ ? "not detected"
4136
+ : `${containerised ? "Store/MSIX container" : "unpackaged install"} (${supportRoots.length} support root${supportRoots.length === 1 ? "" : "s"})`}`);
4137
+ const claudeRoot = resolveClaudeProjectsDir();
4138
+ const codexRoot = resolveCodexSessionRoots()[0]?.path;
4139
+ const coworkRoots = resolveClaudeCoworkSessionRoots();
4140
+ // Dedupe as the import path does: inside an MSIX container the plain and LocalCache support roots
4141
+ // expose the same files, and realpath does not collapse that, so raw discovery counts them twice.
4142
+ const sessions = dedupeSessionsByConversation(discoverSessions({ claudeRoot: claudeRoot ?? undefined, coworkRoots, codexRoot }));
4143
+ const row = (label, source, root) => {
4144
+ const found = sessions.filter((session) => session.source === source).length;
4145
+ if (found > 0)
4146
+ return ` ${label.padEnd(15)} ${found} ${root ?? ""}`.trimEnd();
4147
+ // A zero must say which zero it is.
4148
+ return ` ${label.padEnd(15)} 0 ${root ? `store found at ${root}, nothing importable in it` : "no store on this machine"}`;
4149
+ };
4150
+ lines.push("", "Local sessions:");
4151
+ lines.push(row("Claude Code", "claude-code", claudeRoot ?? undefined));
4152
+ lines.push(row("Codex", "codex", codexRoot));
4153
+ lines.push(row("Cowork", "claude-desktop", coworkRoots[0]));
4154
+ return lines;
4155
+ }
4156
+ function cmdDoctor() {
4157
+ console.log(`EchoMem doctor — ${MCP_PACKAGE_LABEL}\n`);
4158
+ for (const line of doctorReportLines())
4159
+ console.log(line);
4160
+ const reports = inspectClientConfigs(MCP_PACKAGE_VERSION);
4161
+ if (reports.length) {
4162
+ console.log("\nClient MCP configs:");
4163
+ for (const report of reports) {
4164
+ for (const line of formatClientConfigReport(report))
4165
+ console.log(line);
4166
+ }
4167
+ }
4168
+ console.log("\nAccount, credentials and update checks: `status`.");
4169
+ }
3643
4170
  function cmdLogout() {
3644
4171
  const store = new KeyStore();
3645
4172
  try {
@@ -3653,6 +4180,7 @@ function cmdLogout() {
3653
4180
  const HELP = `EchoMem MCP — local memory bridge
3654
4181
 
3655
4182
  Usage:
4183
+ echomem-mcp connect Open Connect Echo; authenticate if needed, then manage MCP hosts
3656
4184
  echomem-mcp init Legacy/headless setup: configure agents + login + local-history onboarding
3657
4185
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3658
4186
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
@@ -3667,6 +4195,9 @@ Usage:
3667
4195
  echomem-mcp lock Remove the local vault key while keeping the device login
3668
4196
  echomem-mcp status Show token/key/clients
3669
4197
  echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
4198
+ echomem-mcp control Alias for the returning-user Connect Echo control page
4199
+ echomem-mcp reconnect Clean-reinstall the managed runtime and repair detected hosts
4200
+ echomem-mcp uninstall --confirm Remove MCP integration but preserve credentials and cloud memories
3670
4201
  echomem-mcp logout Remove stored credentials
3671
4202
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
3672
4203
  echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
@@ -3688,10 +4219,22 @@ Manual / headless:
3688
4219
  Current bridge version: ${MCP_PACKAGE_VERSION}
3689
4220
  `;
3690
4221
  /** Returns true if argv was a recognized subcommand (and was handled). */
4222
+ /** Commands that persist paths into other programs' configs, and so must run from a durable copy. */
4223
+ const CONFIG_WRITING_COMMANDS = new Set(["init", "setup", "update"]);
3691
4224
  export async function runCli(argv) {
3692
4225
  const cmd = argv[0];
3693
4226
  const flags = parseFlags(argv.slice(1));
4227
+ if (cmd && CONFIG_WRITING_COMMANDS.has(cmd)) {
4228
+ const status = reexecFromDurableRuntime(argv);
4229
+ if (status !== null) {
4230
+ process.exitCode = status;
4231
+ return true;
4232
+ }
4233
+ }
3694
4234
  switch (cmd) {
4235
+ case "connect":
4236
+ await cmdConnect(flags);
4237
+ return true;
3695
4238
  case "init":
3696
4239
  await cmdInit(flags);
3697
4240
  return true;
@@ -3714,7 +4257,18 @@ export async function runCli(argv) {
3714
4257
  await cmdStatus(flags);
3715
4258
  return true;
3716
4259
  case "doctor":
3717
- await cmdStatus(flags);
4260
+ cmdDoctor();
4261
+ return true;
4262
+ case "control":
4263
+ case "manage":
4264
+ await cmdControl();
4265
+ return true;
4266
+ case "reconnect":
4267
+ case "repair":
4268
+ await cmdReconnect(flags);
4269
+ return true;
4270
+ case "uninstall":
4271
+ cmdUninstall(flags);
3718
4272
  return true;
3719
4273
  case "logout":
3720
4274
  cmdLogout();