@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/README.md +27 -2
- package/dist/config-files.js +63 -0
- package/dist/durable-entry.js +122 -0
- package/dist/headless-runtime.js +59 -8
- package/dist/hud/hooks.js +102 -37
- package/dist/index.js +129 -12
- package/dist/local-data-paths.js +1 -1
- package/dist/mcp-control.js +215 -0
- package/dist/migrate.js +36 -3
- package/dist/setup.js +670 -116
- package/dist/source-session.js +253 -75
- package/dist/v1-contract.js +34 -0
- package/package.json +4 -2
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
735
|
+
execClaudeCodeCommandSync(command, ["--version"], {
|
|
630
736
|
encoding: "utf8",
|
|
631
737
|
stdio: ["ignore", "pipe", "ignore"],
|
|
632
|
-
timeout:
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
738
|
+
timeout: remaining,
|
|
739
|
+
});
|
|
740
|
+
if (!commandCandidates)
|
|
741
|
+
cachedClaudeCodeCommand = command;
|
|
742
|
+
return command;
|
|
636
743
|
}
|
|
637
744
|
catch {
|
|
638
|
-
/*
|
|
745
|
+
/* Keep trying only while the shared deadline has time left. */
|
|
639
746
|
}
|
|
640
747
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
825
|
+
execClaudeCodeCommandSync(command, args, {
|
|
698
826
|
cwd,
|
|
699
827
|
encoding: "utf8",
|
|
700
828
|
stdio: ["ignore", "pipe", "pipe"],
|
|
701
|
-
timeout:
|
|
702
|
-
}
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
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" &&
|
|
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
|
-
|
|
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();
|