feat(cli): install command auto-writes ~/.claude.json MCP entry
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled

The previous flow printed a \`claude mcp add ...\` command and asked
users to paste it. That's 2 steps, a typo surface, and a point of
user dropoff. Replace with direct read-modify-write of ~/.claude.json.

install:
- preflights bun on PATH (clear error + Bun.com link if missing)
- verifies the MCP entry file exists on disk
- reads ~/.claude.json (empty object if absent)
- adds/updates mcpServers.claudemesh with resolved absolute path
- writes back with 0600 perms, creates parent dir if needed
- read-back verification (bails loudly if post-write state is wrong)
- idempotent: re-running returns "unchanged" if entry already matches
- preserves existing mcpServers entries + other top-level config keys

uninstall:
- removes the claudemesh entry if present
- no-ops cleanly when entry or config file doesn't exist
- doesn't touch anything else

Both print a clear post-action hint: "Restart Claude Code to load
the MCP server. Then join a mesh with claudemesh join <invite-link>".

verified locally with HOME=/tmp/fake-home:
- fresh install → ✓ added, config emitted correctly
- re-install → ✓ unchanged (idempotent)
- install alongside existing "other-mcp" entry → both preserved,
  plus unrelated top-level keys kept verbatim
- uninstall → ✓ removed, claudemesh gone, other entries intact
- uninstall again → · not present (no error)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-05 14:19:58 +01:00
parent d1cab7b807
commit 47304d2a52
2 changed files with 167 additions and 26 deletions

View File

@@ -1,36 +1,173 @@
/**
* `claudemesh install` — print Claude Code MCP registration instructions.
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
*
* In the v1 flow, users copy-paste a `claude mcp add ...` command.
* Later we'll auto-write the MCP entry to ~/.claude.json and hooks
* to ~/.claude/settings.json (mirroring claude-intercom's installer).
* install:
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
* 2. Read ~/.claude.json (or empty object if absent).
* 3. Add/update `mcpServers.claudemesh` with the resolved entry path.
* 4. Write back with 0600 perms.
* 5. Verify via read-back, print success.
*
* uninstall:
* 1. Read ~/.claude.json (bail if missing).
* 2. Delete `mcpServers.claudemesh` if present.
* 3. Write back.
*
* Both are idempotent — re-running install is a no-op if the entry is
* already correct, and uninstall is a no-op if no entry exists.
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
type McpEntry = {
command: string;
args?: string[];
env?: Record<string, string>;
};
function readClaudeConfig(): Record<string, unknown> {
if (!existsSync(CLAUDE_CONFIG)) return {};
const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_CONFIG}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
function writeClaudeConfig(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
writeFileSync(
CLAUDE_CONFIG,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
try {
chmodSync(CLAUDE_CONFIG, 0o600);
} catch {
/* windows has no chmod */
}
}
/** Check `bun` is on PATH — OS-agnostic. */
function bunAvailable(): boolean {
const which =
platform() === "win32"
? Bun.spawnSync(["where", "bun"])
: Bun.spawnSync(["sh", "-c", "command -v bun"]);
return which.exitCode === 0;
}
/** Absolute path to this CLI's entry file. */
function resolveEntry(): string {
const here = fileURLToPath(import.meta.url);
return resolve(dirname(here), "..", "index.ts");
}
function buildMcpEntry(entryPath: string): McpEntry {
return {
command: "bun",
args: [entryPath, "mcp"],
};
}
function entriesEqual(a: McpEntry, b: McpEntry): boolean {
return (
a.command === b.command &&
JSON.stringify(a.args ?? []) === JSON.stringify(b.args ?? [])
);
}
export function runInstall(): void {
// Resolve the path to this package's own index.ts so the generated
// command points at the right binary even when installed globally.
const here = fileURLToPath(import.meta.url);
const entry = resolve(dirname(here), "..", "index.ts");
console.log("claudemesh install");
console.log("------------------");
console.log("claudemesh — MCP registration");
console.log("------------------------------");
if (!bunAvailable()) {
console.error(
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
);
process.exit(1);
}
const entry = resolveEntry();
if (!existsSync(entry)) {
console.error(`✗ MCP entry not found at ${entry}`);
process.exit(1);
}
const cfg = readClaudeConfig();
const servers =
((cfg.mcpServers ??= {}) as Record<string, McpEntry>) ?? {};
const desired = buildMcpEntry(entry);
const existing = servers[MCP_NAME];
let action: "added" | "updated" | "unchanged";
if (!existing) {
servers[MCP_NAME] = desired;
action = "added";
} else if (entriesEqual(existing, desired)) {
action = "unchanged";
} else {
servers[MCP_NAME] = desired;
action = "updated";
}
cfg.mcpServers = servers;
writeClaudeConfig(cfg);
// Read-back verification.
const verify = readClaudeConfig();
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
const stored = verifyServers[MCP_NAME];
if (!stored || !entriesEqual(stored, desired)) {
console.error(
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
);
process.exit(1);
}
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
console.log(` config: ${CLAUDE_CONFIG}`);
console.log(` command: bun ${entry} mcp`);
console.log("");
console.log("Register the MCP server with Claude Code:");
console.log("");
console.log(` claude mcp add claudemesh --scope user -- bun ${entry} mcp`);
console.log("");
console.log("Or if installed globally:");
console.log("");
console.log(` claude mcp add claudemesh --scope user -- claudemesh mcp`);
console.log("");
console.log(
"After registering, restart Claude Code. Then join a mesh with:",
);
console.log("Restart Claude Code to load the MCP server.");
console.log("Then join a mesh:");
console.log("");
console.log(" claudemesh join <invite-link>");
console.log("");
console.log("(Auto-install of hooks + MCP entry will ship in a later step.)");
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
if (!existsSync(CLAUDE_CONFIG)) {
console.log(`· no ${CLAUDE_CONFIG} — nothing to remove`);
return;
}
const cfg = readClaudeConfig();
const servers = cfg.mcpServers as
| Record<string, McpEntry>
| undefined;
if (!servers || !(MCP_NAME in servers)) {
console.log(`· MCP server "${MCP_NAME}" not present — nothing to remove`);
return;
}
delete servers[MCP_NAME];
cfg.mcpServers = servers;
writeClaudeConfig(cfg);
console.log(`✓ MCP server "${MCP_NAME}" removed`);
console.log("Restart Claude Code to drop the MCP connection.");
}

View File

@@ -10,7 +10,7 @@
*/
import { startMcpServer } from "./mcp/server";
import { runInstall } from "./commands/install";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
import { runList } from "./commands/list";
import { runLeave } from "./commands/leave";
@@ -22,7 +22,8 @@ Usage:
claudemesh <command> [args]
Commands:
install Print Claude Code MCP registration instructions
install Register claudemesh as a Claude Code MCP server
uninstall Remove claudemesh MCP server registration
join <link> Join a mesh via invite link (ic://join/...)
list Show all joined meshes
leave <slug> Leave a joined mesh
@@ -47,6 +48,9 @@ async function main(): Promise<void> {
case "install":
runInstall();
return;
case "uninstall":
runUninstall();
return;
case "join":
await runJoin(args);
return;