diff --git a/apps/cli/package.json b/apps/cli/package.json index 9c8b4c3..022c661 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts new file mode 100644 index 0000000..1ca2b47 --- /dev/null +++ b/apps/cli/src/commands/doctor.ts @@ -0,0 +1,212 @@ +/** + * `claudemesh doctor` — diagnostic checks. + * + * Walks through the install + runtime preconditions and prints each + * as pass/fail with a fix hint on failure. Exit 0 if everything + * passes, 1 otherwise. + */ + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { loadConfig, getConfigPath } from "../state/config"; +import { VERSION } from "../version"; + +interface Check { + name: string; + pass: boolean; + detail?: string; + fix?: string; +} + +function checkNode(): Check { + const major = Number(process.versions.node.split(".")[0]); + return { + name: "Node.js >= 20", + pass: major >= 20, + detail: `v${process.versions.node}`, + fix: "Install Node 20 or newer (https://nodejs.org)", + }; +} + +function checkClaudeOnPath(): Check { + const res = + platform() === "win32" + ? spawnSync("where", ["claude"]) + : spawnSync("sh", ["-c", "command -v claude"]); + const onPath = res.status === 0; + const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined; + return { + name: "claude binary on PATH", + pass: onPath, + detail: location, + fix: "Install Claude Code (https://claude.com/claude-code)", + }; +} + +function checkMcpRegistered(): Check { + const claudeConfig = join(homedir(), ".claude.json"); + if (!existsSync(claudeConfig)) { + return { + name: "claudemesh MCP registered in ~/.claude.json", + pass: false, + fix: "Run `claudemesh install`", + }; + } + try { + const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as { + mcpServers?: Record; + }; + const registered = Boolean(cfg.mcpServers?.["claudemesh"]); + return { + name: "claudemesh MCP registered in ~/.claude.json", + pass: registered, + fix: registered ? undefined : "Run `claudemesh install`", + }; + } catch (e) { + return { + name: "claudemesh MCP registered in ~/.claude.json", + pass: false, + detail: e instanceof Error ? e.message : String(e), + fix: "Check ~/.claude.json for JSON parse errors", + }; + } +} + +function checkHooksRegistered(): Check { + const settings = join(homedir(), ".claude", "settings.json"); + if (!existsSync(settings)) { + return { + name: "Status hooks registered in ~/.claude/settings.json", + pass: false, + fix: "Run `claudemesh install` (remove --no-hooks)", + }; + } + try { + const raw = readFileSync(settings, "utf-8"); + const has = raw.includes("claudemesh hook "); + return { + name: "Status hooks registered in ~/.claude/settings.json", + pass: has, + fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)", + }; + } catch (e) { + return { + name: "Status hooks registered in ~/.claude/settings.json", + pass: false, + detail: e instanceof Error ? e.message : String(e), + }; + } +} + +function checkConfigFile(): Check { + const path = getConfigPath(); + if (!existsSync(path)) { + return { + name: "~/.claudemesh/config.json exists and parses", + pass: true, + detail: "not created yet (fine — no meshes joined)", + }; + } + try { + loadConfig(); + const st = statSync(path); + const mode = (st.mode & 0o777).toString(8); + const secure = platform() === "win32" || mode === "600"; + return { + name: "~/.claudemesh/config.json parses + chmod 0600", + pass: secure, + detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`, + fix: secure ? undefined : `chmod 600 ${path}`, + }; + } catch (e) { + return { + name: "~/.claudemesh/config.json exists and parses", + pass: false, + detail: e instanceof Error ? e.message : String(e), + fix: "Inspect or delete ~/.claudemesh/config.json and re-join", + }; + } +} + +function checkKeypairs(): Check { + try { + const cfg = loadConfig(); + if (cfg.meshes.length === 0) { + return { + name: "Mesh keypairs valid", + pass: true, + detail: "no meshes joined", + }; + } + for (const m of cfg.meshes) { + if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) { + return { + name: "Mesh keypairs valid", + pass: false, + detail: `${m.slug}: pubkey malformed`, + fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`, + }; + } + if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) { + return { + name: "Mesh keypairs valid", + pass: false, + detail: `${m.slug}: secret key malformed`, + fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`, + }; + } + } + return { + name: "Mesh keypairs valid", + pass: true, + detail: `${cfg.meshes.length} mesh(es)`, + }; + } catch (e) { + return { + name: "Mesh keypairs valid", + pass: false, + detail: e instanceof Error ? e.message : String(e), + }; + } +} + +export async function runDoctor(): Promise { + const useColor = + !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; + const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); + const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s); + const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s); + + console.log(`claudemesh doctor (v${VERSION})`); + console.log("─".repeat(60)); + + const checks: Check[] = [ + checkNode(), + checkClaudeOnPath(), + checkMcpRegistered(), + checkHooksRegistered(), + checkConfigFile(), + checkKeypairs(), + ]; + + for (const c of checks) { + const mark = c.pass ? green("✓") : red("✗"); + const detail = c.detail ? dim(` (${c.detail})`) : ""; + console.log(`${mark} ${c.name}${detail}`); + if (!c.pass && c.fix) { + console.log(dim(` → ${c.fix}`)); + } + } + + const failing = checks.filter((c) => !c.pass); + console.log(""); + if (failing.length === 0) { + console.log(green("All checks passed.")); + process.exit(0); + } else { + console.log(red(`${failing.length} check(s) failed.`)); + process.exit(1); + } +} diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts new file mode 100644 index 0000000..d41e214 --- /dev/null +++ b/apps/cli/src/commands/status.ts @@ -0,0 +1,103 @@ +/** + * `claudemesh status` — one-shot health report. + * + * Reports CLI version, config path + permissions, each joined mesh + * with broker reachability (WS handshake probe). Exit 0 if every + * mesh's broker is reachable, 1 otherwise. + */ + +import { statSync, existsSync } from "node:fs"; +import WebSocket from "ws"; +import { loadConfig, getConfigPath } from "../state/config"; +import { VERSION } from "../version"; + +interface MeshStatus { + slug: string; + brokerUrl: string; + pubkey: string; + reachable: boolean; + error?: string; +} + +async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { ws.terminate(); } catch { /* noop */ } + resolve({ ok: false, error: "timeout" }); + }, timeoutMs); + ws.on("open", () => { + clearTimeout(timer); + try { ws.close(); } catch { /* noop */ } + resolve({ ok: true }); + }); + ws.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, error: err.message }); + }); + }); +} + +export async function runStatus(): Promise { + const useColor = + !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; + const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); + const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s); + const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s); + + console.log(`claudemesh status (v${VERSION})`); + console.log("─".repeat(60)); + + const configPath = getConfigPath(); + let configPerms = "missing"; + if (existsSync(configPath)) { + const st = statSync(configPath); + const mode = (st.mode & 0o777).toString(8).padStart(4, "0"); + configPerms = mode === "0600" ? `${mode} ✓` : `${mode} ⚠ (expected 0600)`; + } + console.log(`Config: ${configPath} (${configPerms})`); + + const config = loadConfig(); + if (config.meshes.length === 0) { + console.log(""); + console.log(dim("No meshes joined. Run `claudemesh join ` to get started.")); + process.exit(0); + } + + console.log(""); + console.log(`Meshes (${config.meshes.length}):`); + + const results: MeshStatus[] = []; + for (const m of config.meshes) { + process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}… `); + const probe = await probeBroker(m.brokerUrl); + results.push({ + slug: m.slug, + brokerUrl: m.brokerUrl, + pubkey: m.pubkey, + reachable: probe.ok, + error: probe.error, + }); + if (probe.ok) { + console.log(green("reachable")); + } else { + console.log(red(`unreachable (${probe.error})`)); + } + } + + console.log(""); + for (const r of results) { + console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}…`)); + } + + const allOk = results.every((r) => r.reachable); + console.log(""); + if (allOk) { + console.log(green("All meshes reachable.")); + process.exit(0); + } else { + const broken = results.filter((r) => !r.reachable).length; + console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`)); + process.exit(1); + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1956bb4..e2c80f9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -16,8 +16,11 @@ import { runLeave } from "./commands/leave"; import { runSeedTestMesh } from "./commands/seed-test-mesh"; import { runHook } from "./commands/hook"; import { runLaunch } from "./commands/launch"; +import { runStatus } from "./commands/status"; +import { runDoctor } from "./commands/doctor"; +import { VERSION } from "./version"; -const HELP = `claudemesh — peer mesh for Claude Code sessions +const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions Usage: claudemesh [args] @@ -32,9 +35,12 @@ Commands: join Join a mesh via https://claudemesh.com/join/... URL list Show all joined meshes leave Leave a joined mesh + status Health report: broker reachability per joined mesh + doctor Diagnostic checks (install, config, keypairs, PATH) seed-test-mesh Dev-only: inject a mesh into config (skips invite flow) mcp Start MCP server (stdio) — invoked by Claude Code --help, -h Show this help + --version, -v Show the CLI version Environment: CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws) @@ -71,9 +77,20 @@ async function main(): Promise { case "leave": runLeave(args); return; + case "status": + await runStatus(); + return; + case "doctor": + await runDoctor(); + return; case "seed-test-mesh": runSeedTestMesh(args); return; + case "--version": + case "-v": + case "version": + console.log(VERSION); + return; case "--help": case "-h": case "help": diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index d9d885c..8eafa96 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -87,7 +87,7 @@ export async function startMcpServer(): Promise { const config = loadConfig(); const server = new Server( - { name: "claudemesh", version: "0.1.2" }, + { name: "claudemesh", version: "0.1.3" }, { capabilities: { experimental: { "claude/channel": {} }, diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 0000000..d4330f3 --- /dev/null +++ b/apps/cli/src/version.ts @@ -0,0 +1,8 @@ +/** + * Bundled version string. Bun inlines the package.json JSON at build + * time, so the shipped binary carries the exact version that was + * published. + */ +import pkg from "../package.json" with { type: "json" }; + +export const VERSION: string = pkg.version;