relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
233 lines (214 loc) • 9.48 kB
JavaScript
// Registering Relay into the DESKTOP apps — Claude Desktop and the Codex app.
//
// Most people who are sent a relay have a desktop app and no CLI. Until now
// `installClaudeCode` wrote ~/.claude.json (the Claude *Code* CLI's file, which
// the desktop app never reads) and refused to create it when absent, so a
// desktop-only user was told "Claude Code was not found here, so it was skipped"
// and ended up with nothing registered anywhere.
//
// Writing claude_desktop_config.json is the only unattended path that exists:
// there is no CLI for Claude Desktop (`claude mcp add --scope` only ever writes
// Claude Code's config) and no install deep link. `.mcpb` Desktop Extensions
// cannot be installed without a human clicking "Install Extension?", so they are
// a convenience, never a substitute.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Every directory that might hold claude_desktop_config.json on this machine.
*
* Order matters only for reporting; we write to every candidate whose parent
* already exists, because on Windows the app READS a virtualised MSIX path while
* its own "Edit Config" button OPENS %APPDATA% — the two never sync, and picking
* one silently registers nothing for half of users.
*/
export function claudeDesktopConfigDirs({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
// An explicit override wins and is used verbatim — the app appends no app-name
// suffix when CLAUDE_USER_DATA_DIR is set.
if (env.CLAUDE_USER_DATA_DIR) return [env.CLAUDE_USER_DATA_DIR];
const home = env.HOME || os.homedir();
if (platform === "darwin") {
const base = path.posix.join(home, "Library", "Application Support");
// Claude-3p is the enterprise/partner build; it has its own userData dir.
return [path.posix.join(base, "Claude"), path.posix.join(base, "Claude-3p")].filter((dir) => exists(dir));
}
if (platform === "win32") {
const dirs = [];
const localAppData = env.LOCALAPPDATA;
if (localAppData) {
const packages = path.win32.join(localAppData, "Packages");
// The MSIX package family name carries a hash, so glob rather than pin it.
try {
for (const entry of fs.readdirSync(packages)) {
if (!/^Claude_/.test(entry)) continue;
const dir = path.win32.join(packages, entry, "LocalCache", "Roaming", "Claude");
if (exists(dir)) dirs.push(dir);
}
} catch {
// No Packages dir: not an MSIX install.
}
const thirdParty = path.win32.join(localAppData, "Claude-3p");
if (exists(thirdParty)) dirs.push(thirdParty);
}
if (env.APPDATA) {
const roaming = path.win32.join(env.APPDATA, "Claude");
if (exists(roaming)) dirs.push(roaming);
}
return dirs;
}
// Anthropic ships no documented Linux desktop build; guessing a path would
// write a file nothing reads and report success.
return [];
}
export function claudeDesktopConfigPathIn(dir) {
return path.join(dir, "claude_desktop_config.json");
}
/**
* The entry Claude Desktop accepts, and nothing else.
*
* Its schema is exactly `command` (required), `args`, `env`, and an internal
* `extensionId`. A `url`, `type` or `transport` key is not merely ignored — it
* can make the app rewrite the file and drop the whole mcpServers section,
* taking other tools' servers with it.
*/
export function claudeDesktopEntry({ node, script, command, args, relayHome, maxOldSpaceMb = 32 }) {
const entry = command
? { command, args: Array.isArray(args) ? args.map(String) : [] }
: { command: node, args: [`--max-old-space-size=${maxOldSpaceMb}`, script, "mcp"] };
if (relayHome) entry.env = { RELAY_HOME: relayHome };
return entry;
}
/**
* An entry this installer wrote whose target no longer exists.
*
* Claude Desktop validates the schema, not the filesystem, so a stale entry
* spawns and dies on every single app launch. David's machine has exactly this:
* a hand-written `relay_companion` pointing at a deleted checkout, crash-looping
* with MODULE_NOT_FOUND. Self-heal it rather than leaving a permanently broken
* server in someone's config.
*/
export function isDeadRelayEntry(name, entry, exists = fs.existsSync) {
if (!entry || typeof entry !== "object") return false;
if (!/relay/i.test(String(name))) return false;
const args = Array.isArray(entry.args) ? entry.args : [];
const target = /[/\\]\.relay[/\\]bin[/\\]mcp-bridge(?:-[0-9a-f]{16})?(?:\.exe)?$/i.test(String(entry.command || ""))
? String(entry.command)
: args.find((a) => typeof a === "string" && /relay[^/\\]*[/\\]bin[/\\]relay\.js$/.test(a));
if (!target) return false;
return !exists(target);
}
/**
* A live entry under the pre-rename server name. Unlike a dead entry it spawns
* fine — which is worse: it is a full duplicate of `relay`, so every Desktop
* session carries the whole toolset twice and the doubled count helps push the
* tools behind ToolSearch deferral. Installing under the current name is the
* moment to retire it, existing target or not.
*/
export function isLegacyRelayCompanionEntry(name, entry) {
if (name !== "relay_companion") return false;
if (!entry || typeof entry !== "object") return false;
const args = Array.isArray(entry.args) ? entry.args : [];
return /[/\\]\.relay[/\\]bin[/\\]mcp-bridge(?:-[0-9a-f]{16})?(?:\.exe)?$/i.test(String(entry.command || "")) || args.some(
(a) => typeof a === "string" && /relay[^/\\]*[/\\]bin[/\\](relay\.js|mcp-launcher\.cjs)$/.test(a),
);
}
/**
* Merge our server into an existing config, touching nothing else.
*
* The live file holds `coworkUserFilesPath` and a `preferences` object with the
* paired browser-extension device id, per-folder permission modes and consent
* grants. The MCP docs say to "replace the contents of the configuration file";
* for an installer that is destructive, so this merges and refuses to guess when
* the JSON is unparseable.
*/
export function mergeClaudeDesktopConfig(existingText, entry, { name = "relay", exists = fs.existsSync } = {}) {
let cfg = {};
const raw = (existingText || "").trim();
if (raw) {
try {
cfg = JSON.parse(raw);
} catch (error) {
throw new Error(`refusing to overwrite malformed claude_desktop_config.json: ${error.message}`);
}
}
if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
throw new Error("refusing to write: claude_desktop_config.json is not a JSON object");
}
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" && !Array.isArray(cfg.mcpServers)
? { ...cfg.mcpServers }
: {};
const removed = [];
for (const [key, value] of Object.entries(servers)) {
if (key !== name && (isDeadRelayEntry(key, value, exists) || isLegacyRelayCompanionEntry(key, value))) {
delete servers[key];
removed.push(key);
}
}
// Always overwrite our own key. No-oping when it already exists is what lets a
// stale path survive upgrades forever.
servers[name] = entry;
cfg.mcpServers = servers;
return { text: `${JSON.stringify(cfg, null, 2)}\n`, removed };
}
/**
* Claude Desktop launches servers from a GUI context with a curated PATH that
* contains no nvm/fnm/volta shims, so "npx" or a shim path resolves to nothing
* and the server dies silently. Prefer a stable absolute node that resolves to
* the same binary we are running, over the version-pinned Cellar path.
*/
export function stableNodeCandidates() {
return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
}
export function resolveStableNode({
execPath = process.execPath,
candidates = stableNodeCandidates(),
realpath = (p) => fs.realpathSync(p),
exists = fs.existsSync,
} = {}) {
let target;
try {
target = realpath(execPath);
} catch {
return execPath;
}
for (const candidate of candidates) {
if (!exists(candidate)) continue;
try {
if (realpath(candidate) === target) return candidate;
} catch {
// Unreadable symlink; try the next one.
}
}
return execPath;
}
/**
* A desktop-only Codex user still has a full codex binary — it ships inside the
* ChatGPT app, it just is not on PATH. Preferring it means we can use the
* supported `codex mcp add` rather than hand-editing TOML.
*/
export function codexBinaryCandidates({ env = process.env, platform = process.platform } = {}) {
const home = env.HOME || os.homedir();
if (platform === "darwin") {
return [
"/Applications/ChatGPT.app/Contents/Resources/codex",
path.join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"),
];
}
return [];
}
/** True when a Claude Desktop install is present, whether or not a CLI is. */
export function claudeDesktopPresent(options = {}) {
return claudeDesktopConfigDirs(options).length > 0;
}
/**
* Is a Codex desktop host installed?
*
* The Codex desktop experience ships inside the ChatGPT app rather than a
* separate bundle, and it reads the same ~/.codex/config.toml as the CLI — so
* finding the app is enough to know a Codex host exists, even before ~/.codex
* has been created by a first run.
*/
export function codexAppPresent({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
if (platform !== "darwin") return false;
return codexBinaryCandidates({ env, platform }).some((candidate) => exists(candidate));
}