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.
|
* install:
|
||||||
* Later we'll auto-write the MCP entry to ~/.claude.json and hooks
|
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
|
||||||
* to ~/.claude/settings.json (mirroring claude-intercom's installer).
|
* 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 { 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 {
|
export function runInstall(): void {
|
||||||
// Resolve the path to this package's own index.ts so the generated
|
console.log("claudemesh install");
|
||||||
// command points at the right binary even when installed globally.
|
console.log("------------------");
|
||||||
const here = fileURLToPath(import.meta.url);
|
|
||||||
const entry = resolve(dirname(here), "..", "index.ts");
|
|
||||||
|
|
||||||
console.log("claudemesh — MCP registration");
|
if (!bunAvailable()) {
|
||||||
console.log("------------------------------");
|
console.error(
|
||||||
console.log("");
|
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
|
||||||
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:",
|
|
||||||
);
|
);
|
||||||
|
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("");
|
||||||
console.log(" claudemesh join <invite-link>");
|
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 { startMcpServer } from "./mcp/server";
|
||||||
import { runInstall } from "./commands/install";
|
import { runInstall, runUninstall } from "./commands/install";
|
||||||
import { runJoin } from "./commands/join";
|
import { runJoin } from "./commands/join";
|
||||||
import { runList } from "./commands/list";
|
import { runList } from "./commands/list";
|
||||||
import { runLeave } from "./commands/leave";
|
import { runLeave } from "./commands/leave";
|
||||||
@@ -22,7 +22,8 @@ Usage:
|
|||||||
claudemesh <command> [args]
|
claudemesh <command> [args]
|
||||||
|
|
||||||
Commands:
|
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/...)
|
join <link> Join a mesh via invite link (ic://join/...)
|
||||||
list Show all joined meshes
|
list Show all joined meshes
|
||||||
leave <slug> Leave a joined mesh
|
leave <slug> Leave a joined mesh
|
||||||
@@ -47,6 +48,9 @@ async function main(): Promise<void> {
|
|||||||
case "install":
|
case "install":
|
||||||
runInstall();
|
runInstall();
|
||||||
return;
|
return;
|
||||||
|
case "uninstall":
|
||||||
|
runUninstall();
|
||||||
|
return;
|
||||||
case "join":
|
case "join":
|
||||||
await runJoin(args);
|
await runJoin(args);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user