feat(cli): install command auto-writes ~/.claude.json MCP entry
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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:
@@ -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("------------------------------");
|
||||
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:",
|
||||
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("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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user