The Store install of Claude Desktop lives under %LOCALAPPDATA%\Packages\Claude_<publisher-id>\, and its "AppData\Roaming" is virtualised to LocalCache\Roaming inside that package. The classic %APPDATA%\Claude\ folder is never read by the Store app, so a claude_desktop_config.json dropped there is invisible to it; Theseus's old existence check against path.dirname of that classic path was both false-negative (nothing in classic, Store present) and false-positive (empty classic folder left over, Store the real install) — the user saw "Not installed on this computer" with Claude Desktop clearly running. desktopConfigPath now looks for a Claude_<id> package first and uses its LocalCache\Roaming\Claude path when present; otherwise it falls back to the classic path. Added a desktopInstalled() helper that treats any of: the config dir, a Store package, %LOCALAPPDATA%\AnthropicClaude, %LOCALAPPDATA%\Programs\Claude, or Program Files\Claude as proof of an install. register() mkdirSyncs the chosen path so the Store case succeeds on first write.
213 lines
9.5 KiB
JavaScript
213 lines
9.5 KiB
JavaScript
// Registers the connector with the Claude apps on this computer, so the user
|
|
// clicks "Connect" instead of copying a command into a terminal.
|
|
//
|
|
// Claude Code: through its own CLI (`claude mcp add-json … --scope user`), so
|
|
// Claude Code writes its own config and nothing here edits ~/.claude.json,
|
|
// which a running Claude Code rewrites at will. That file is only READ, to
|
|
// show whether the entry is there and current.
|
|
//
|
|
// Claude Desktop: its claude_desktop_config.json is the file users are told
|
|
// to edit by hand. Only the `theseus` entry is added, changed or removed; the
|
|
// rest is kept as it was, a copy of the previous file is kept beside it, and
|
|
// a file that does not parse is left alone.
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const { execFile } = require("node:child_process");
|
|
|
|
const NAME = "theseus";
|
|
|
|
function env(opts) {
|
|
const e = opts.env || process.env;
|
|
const platform = opts.platform || process.platform;
|
|
const home = opts.home || os.homedir();
|
|
return { e, platform, home };
|
|
}
|
|
|
|
// ---- Claude Code ---------------------------------------------------------
|
|
|
|
// The CLI as a native binary only: an npm `claude.cmd` would need a shell,
|
|
// and a shell would have to re-quote the JSON argument.
|
|
function findClaudeCli(opts = {}) {
|
|
const { e, platform, home } = env(opts);
|
|
if (opts.cli) return fs.existsSync(opts.cli) ? opts.cli : null;
|
|
const names = platform === "win32" ? ["claude.exe"] : ["claude"];
|
|
const dirs = [
|
|
path.join(home, ".local", "bin"),
|
|
path.join(home, ".claude", "local"),
|
|
...(platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin"]),
|
|
...String(e.PATH || e.Path || "").split(path.delimiter).filter(Boolean),
|
|
];
|
|
for (const d of dirs) {
|
|
for (const n of names) {
|
|
const p = path.join(d, n);
|
|
try { if (fs.statSync(p).isFile()) return p; } catch {}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function claudeCodeConfigPath(opts = {}) {
|
|
const { e, home } = env(opts);
|
|
return e.CLAUDE_CONFIG_DIR ? path.join(e.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json");
|
|
}
|
|
|
|
function readJson(file) {
|
|
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
}
|
|
|
|
function httpEntry(url, token) {
|
|
return { type: "http", url, headers: { Authorization: `Bearer ${token}` } };
|
|
}
|
|
|
|
function sameHttp(entry, url, token) {
|
|
return !!entry && entry.url === url && entry.headers && entry.headers.Authorization === `Bearer ${token}`;
|
|
}
|
|
|
|
function run(cli, args, opts) {
|
|
return new Promise((resolve) => {
|
|
execFile(cli, args, { timeout: 30000, windowsHide: true, env: opts.env || process.env }, (err, stdout, stderr) => {
|
|
resolve({ ok: !err, code: err ? (err.code ?? 1) : 0, out: `${stdout || ""}${stderr || ""}`.trim() });
|
|
});
|
|
});
|
|
}
|
|
|
|
const claudeCode = {
|
|
status({ url, token }, opts = {}) {
|
|
const cli = findClaudeCli(opts);
|
|
const cfg = readJson(claudeCodeConfigPath(opts));
|
|
const entry = cfg && cfg.mcpServers ? cfg.mcpServers[NAME] : null;
|
|
return {
|
|
installed: !!cli,
|
|
configured: !!entry,
|
|
current: !!url && sameHttp(entry, url, token),
|
|
};
|
|
},
|
|
async register({ url, token }, opts = {}) {
|
|
const cli = findClaudeCli(opts);
|
|
if (!cli) throw new Error("Claude Code was not found on this computer (looked for the claude command)");
|
|
// add-json refuses an existing name, so an update is remove + add.
|
|
await run(cli, ["mcp", "remove", NAME, "--scope", "user"], opts);
|
|
const r = await run(cli, ["mcp", "add-json", NAME, JSON.stringify(httpEntry(url, token)), "--scope", "user"], opts);
|
|
if (!r.ok) throw new Error(`Claude Code refused the entry: ${r.out || `exit ${r.code}`}`);
|
|
return { ok: true };
|
|
},
|
|
async unregister(opts = {}) {
|
|
const cli = findClaudeCli(opts);
|
|
if (!cli) throw new Error("Claude Code was not found on this computer");
|
|
const r = await run(cli, ["mcp", "remove", NAME, "--scope", "user"], opts);
|
|
if (!r.ok && !/not found|no .*server/i.test(r.out)) throw new Error(`Claude Code: ${r.out || `exit ${r.code}`}`);
|
|
return { ok: true };
|
|
},
|
|
};
|
|
|
|
// ---- Claude Desktop ------------------------------------------------------
|
|
|
|
// Pick the Claude Desktop config path, preferring whichever install is
|
|
// actually present on this box.
|
|
// • Classic installer / per-user: %APPDATA%\Claude\claude_desktop_config.json
|
|
// • Microsoft Store install: the Store virtualises AppData under
|
|
// %LOCALAPPDATA%\Packages\Claude_<publisher-id>\LocalCache\Roaming\Claude\
|
|
// — the Store app's "AppData\Roaming" is that folder, not the user's.
|
|
// If both exist (or neither), the classic path wins; register() creates
|
|
// a fresh config under whichever directory is already there.
|
|
function desktopConfigPath(opts = {}) {
|
|
const { e, platform, home } = env(opts);
|
|
if (platform === "win32") {
|
|
// A Microsoft Store install of Claude Desktop virtualises AppData under
|
|
// its package folder; the Store app never reads the classic %APPDATA%\
|
|
// Claude\ path, so a config dropped there is invisible. Prefer the Store
|
|
// path whenever a Claude_<id> package is present. Fall back to classic
|
|
// otherwise.
|
|
try {
|
|
const packagesDir = path.join(e.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages");
|
|
if (fs.existsSync(packagesDir)) {
|
|
const match = fs.readdirSync(packagesDir).find((n) => /^Claude_/i.test(n));
|
|
if (match) return path.join(packagesDir, match, "LocalCache", "Roaming", "Claude", "claude_desktop_config.json");
|
|
}
|
|
} catch {}
|
|
return path.join(e.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
}
|
|
if (platform === "darwin") return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
return path.join(e.XDG_CONFIG_HOME || path.join(home, ".config"), "Claude", "claude_desktop_config.json");
|
|
}
|
|
// A broader "is Claude Desktop installed" signal than just the config
|
|
// folder existing — the Store install may not have created the roaming
|
|
// folder yet, and the Program Files install leaves per-user folders empty
|
|
// until the first launch. Any of these says "yes, it is here".
|
|
function desktopInstalled(opts = {}) {
|
|
const { e, platform, home } = env(opts);
|
|
if (fs.existsSync(path.dirname(desktopConfigPath(opts)))) return true;
|
|
if (platform !== "win32") return false;
|
|
const packagesDir = path.join(e.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages");
|
|
try {
|
|
if (fs.existsSync(packagesDir) && fs.readdirSync(packagesDir).some((n) => /^Claude_/i.test(n))) return true;
|
|
} catch {}
|
|
for (const p of [
|
|
path.join(e.LOCALAPPDATA || path.join(home, "AppData", "Local"), "AnthropicClaude"),
|
|
path.join(e.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "Claude"),
|
|
path.join(e["PROGRAMFILES"] || "C:\\Program Files", "Claude"),
|
|
]) { try { if (fs.existsSync(p)) return true; } catch {} }
|
|
return false;
|
|
}
|
|
|
|
// Claude Desktop starts stdio servers only: it runs Theseus itself in Node
|
|
// mode with the relay, which reads the address from Theseus's endpoint file.
|
|
function desktopEntry({ exe, shim, url, token }) {
|
|
return { command: exe, args: [shim], env: { ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: url, THESEUS_MCP_TOKEN: token } };
|
|
}
|
|
|
|
function sameDesktop(entry, want) {
|
|
return !!entry && entry.command === want.command && JSON.stringify(entry.args) === JSON.stringify(want.args)
|
|
&& entry.env && entry.env.THESEUS_MCP_URL === want.env.THESEUS_MCP_URL && entry.env.THESEUS_MCP_TOKEN === want.env.THESEUS_MCP_TOKEN;
|
|
}
|
|
|
|
function editDesktopConfig(file, edit) {
|
|
let cfg = {};
|
|
if (fs.existsSync(file)) {
|
|
const text = fs.readFileSync(file, "utf8");
|
|
try { cfg = text.trim() ? JSON.parse(text) : {}; }
|
|
catch { throw new Error(`${file} is not valid JSON; fix or remove it in Claude Desktop first`); }
|
|
fs.copyFileSync(file, file + ".theseus-backup");
|
|
}
|
|
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) throw new Error(`${file} does not hold a settings object`);
|
|
edit(cfg);
|
|
const tmp = file + ".tmp-" + process.pid;
|
|
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
const claudeDesktop = {
|
|
status(params, opts = {}) {
|
|
const file = desktopConfigPath(opts);
|
|
const cfg = readJson(file);
|
|
const entry = cfg && cfg.mcpServers ? cfg.mcpServers[NAME] : null;
|
|
return {
|
|
installed: desktopInstalled(opts),
|
|
configured: !!entry,
|
|
current: !!params.url && sameDesktop(entry, desktopEntry(params)),
|
|
};
|
|
},
|
|
async register(params, opts = {}) {
|
|
const file = desktopConfigPath(opts);
|
|
// Create the config directory if needed — the Store install virtualises
|
|
// AppData so the Roaming\Claude folder may be absent until first write;
|
|
// Claude Desktop will pick up the file we drop next to it.
|
|
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
|
|
if (!fs.existsSync(path.dirname(file))) throw new Error("Claude Desktop was not found on this computer (no Claude settings folder)");
|
|
editDesktopConfig(file, (cfg) => {
|
|
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") cfg.mcpServers = {};
|
|
cfg.mcpServers[NAME] = desktopEntry(params);
|
|
});
|
|
return { ok: true, restart: true };
|
|
},
|
|
async unregister(opts = {}) {
|
|
const file = desktopConfigPath(opts);
|
|
if (!fs.existsSync(file)) return { ok: true };
|
|
editDesktopConfig(file, (cfg) => { if (cfg.mcpServers) delete cfg.mcpServers[NAME]; });
|
|
return { ok: true, restart: true };
|
|
},
|
|
};
|
|
|
|
module.exports = { claudeCode, claudeDesktop, findClaudeCli, claudeCodeConfigPath, desktopConfigPath, desktopInstalled, httpEntry, desktopEntry };
|