feat(cli): scaffold @claudemesh/cli MCP client package (stubs)

The user-facing tool. Two invocation modes:
  - `claudemesh mcp`           → MCP server (stdio), consumed by Claude Code
  - `claudemesh <subcommand>`  → human CLI

Layout:
  apps/cli/
  ├── package.json       bin: { claudemesh: ./src/index.ts }
  ├── README.md          install + usage
  └── src/
      ├── index.ts       dispatcher (mcp | install | join | list | leave | --help)
      ├── env.ts         CLAUDEMESH_BROKER_URL, CONFIG_DIR, DEBUG
      ├── mcp/
      │   ├── server.ts  MCP stdio server with 5 tools
      │   ├── tools.ts   tool schemas (send_message, list_peers,
      │   │              check_messages, set_summary, set_status)
      │   └── types.ts
      ├── ws/client.ts   broker connection (stub for 15b)
      ├── state/config.ts ~/.claudemesh/config.json (joined meshes + keys)
      └── commands/
          ├── install.ts print `claude mcp add ...` instruction
          ├── join.ts    parse ic://join/... (stub, Step 17)
          ├── list.ts    show joined meshes
          └── leave.ts   remove mesh from local config

Tool stubs return "not connected, run `claudemesh join <invite-link>`"
errors until 15b wires the WS client.

Verified:
- `bun src/index.ts --help` → prints usage
- `bun src/index.ts install` → prints MCP add command with resolved path
- `bun src/index.ts list` → "No meshes joined yet"
- `bun src/index.ts mcp` (via stdin) → returns tools/list with all 5 tools

Deps: @modelcontextprotocol/sdk, ws, libsodium-wrappers, zod.
Lockfile regenerated in the same commit per claudemesh-3's flag —
avoids breaking Coolify's --frozen-lockfile deploys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:23:12 +01:00
parent c6674e971a
commit 8931296e82
16 changed files with 1048 additions and 2 deletions

View File

@@ -0,0 +1,36 @@
/**
* `claudemesh install` — print Claude Code MCP registration instructions.
*
* 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).
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
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 — 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:",
);
console.log("");
console.log(" claudemesh join <invite-link>");
console.log("");
console.log("(Auto-install of hooks + MCP entry will ship in a later step.)");
}

View File

@@ -0,0 +1,30 @@
/**
* `claudemesh join <invite-link>` — parse a mesh invite link and
* join the mesh.
*
* STUB: real invite-link parsing + keypair generation + broker
* enrollment lands in Step 17. For now this just validates the link
* shape and tells the user what's coming.
*/
export function runJoin(args: string[]): void {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-link>");
console.error("");
console.error("Example: claudemesh join ic://join/BASE64URL...");
process.exit(1);
}
if (!link.startsWith("ic://join/")) {
console.error(
`claudemesh: invalid invite link. Expected ic://join/... got "${link}"`,
);
process.exit(1);
}
console.log("claudemesh: join not yet implemented (Step 17).");
console.log(` Invite link parsed: ${link.slice(0, 40)}...`);
console.log(
" Real flow will: verify sig, generate keypair, enroll member, persist to ~/.claudemesh/config.json",
);
process.exit(0);
}

View File

@@ -0,0 +1,25 @@
/**
* `claudemesh leave <slug>` — remove a mesh from local config.
*
* Does NOT (yet) notify the broker. In 15b+ this will send a
* best-effort revoke request before removing the entry.
*/
import { loadConfig, saveConfig } from "../state/config";
export function runLeave(args: string[]): void {
const slug = args[0];
if (!slug) {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
const config = loadConfig();
const before = config.meshes.length;
config.meshes = config.meshes.filter((m) => m.slug !== slug);
if (config.meshes.length === before) {
console.error(`claudemesh: no joined mesh with slug "${slug}"`);
process.exit(1);
}
saveConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -0,0 +1,28 @@
/**
* `claudemesh list` — show all joined meshes + their status.
*/
import { loadConfig, getConfigPath } from "../state/config";
export function runList(): void {
const config = loadConfig();
if (config.meshes.length === 0) {
console.log("No meshes joined yet.");
console.log("");
console.log("Join one with: claudemesh join <invite-link>");
console.log(`Config file: ${getConfigPath()}`);
return;
}
console.log(`Joined meshes (${config.meshes.length}):`);
console.log("");
for (const m of config.meshes) {
console.log(` ${m.name} (${m.slug})`);
console.log(` mesh id: ${m.meshId}`);
console.log(` member id: ${m.memberId}`);
console.log(` pubkey: ${m.pubkey.slice(0, 16)}`);
console.log(` broker: ${m.brokerUrl}`);
console.log(` joined: ${m.joinedAt}`);
console.log("");
}
console.log(`Config: ${getConfigPath()}`);
}

27
apps/cli/src/env.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from "zod";
/**
* CLI environment config.
*
* Read once at startup. Overridable via env vars so users can point
* at a self-hosted broker or a staging instance without rebuilding.
*/
const envSchema = z.object({
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
});
export type CliEnv = z.infer<typeof envSchema>;
export function loadEnv(): CliEnv {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("[claudemesh] invalid environment:");
console.error(z.treeifyError(parsed.error));
process.exit(1);
}
return parsed.data;
}
export const env = loadEnv();

73
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bun
/**
* @claudemesh/cli entry point.
*
* Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand
*
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
*/
import { startMcpServer } from "./mcp/server";
import { runInstall } from "./commands/install";
import { runJoin } from "./commands/join";
import { runList } from "./commands/list";
import { runLeave } from "./commands/leave";
const HELP = `claudemesh — peer mesh for Claude Code sessions
Usage:
claudemesh <command> [args]
Commands:
install Print Claude Code MCP registration instructions
join <link> Join a mesh via invite link (ic://join/...)
list Show all joined meshes
leave <slug> Leave a joined mesh
mcp Start MCP server (stdio) — invoked by Claude Code
--help, -h Show this help
Environment:
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
CLAUDEMESH_DEBUG=1 Verbose logging
`;
const cmd = process.argv[2];
const args = process.argv.slice(3);
async function main(): Promise<void> {
switch (cmd) {
case "mcp":
await startMcpServer();
return;
case "install":
runInstall();
return;
case "join":
runJoin(args);
return;
case "list":
runList();
return;
case "leave":
runLeave(args);
return;
case "--help":
case "-h":
case "help":
case undefined:
console.log(HELP);
return;
default:
console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage.");
process.exit(1);
}
}
main().catch((e) => {
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
});

View File

@@ -0,0 +1,86 @@
/**
* MCP server (stdio transport) for @claudemesh/cli.
*
* Invoked by Claude Code as a stdio subprocess. Exposes the 5 tools
* in tools.ts. In this 15a scaffold, all tools return a "not
* connected" response; 15b will wire them to a live WS broker
* connection.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { TOOLS } from "./tools";
import { loadConfig } from "../state/config";
const NOT_CONNECTED = {
content: [
{
type: "text" as const,
text: "claudemesh: not yet connected to broker. Run `claudemesh join <invite-link>` to join a mesh, then restart your Claude Code session. (Broker client wiring lands in Step 15b — scaffold only for now.)",
},
],
isError: true,
};
const INSTRUCTIONS = `You are connected to a claudemesh — a peer-to-peer network of other Claude Code sessions.
Use these tools to coordinate with peers on demand. Each mesh is a trust boundary; messages are E2E-encrypted and routed through a shared broker.
Available tools:
- send_message: send a direct or channel message
- list_peers: see who else is in your meshes and their status
- check_messages: pull undelivered messages (normally pushed automatically)
- set_summary: describe what you're working on (visible to peers)
- set_status: manually override your presence (idle/working/dnd)
When you receive an inbound message (channel notification), respond promptly — like answering a knock on the door. The sender is waiting on you.`;
export async function startMcpServer(): Promise<void> {
// Load config so we know which meshes the user has joined.
const config = loadConfig();
const server = new Server(
{ name: "claudemesh", version: "0.1.0" },
{
capabilities: { tools: {} },
instructions: INSTRUCTIONS,
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS,
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name } = req.params;
// Stubs: all tools return "not connected" until 15b.
if (config.meshes.length === 0) {
return {
content: [
{
type: "text" as const,
text: `claudemesh: no meshes joined yet. Run \`claudemesh join <invite-link>\` to join one.`,
},
],
isError: true,
};
}
switch (name) {
case "send_message":
case "list_peers":
case "check_messages":
case "set_summary":
case "set_status":
return NOT_CONNECTED;
default:
throw new Error(`Unknown tool: ${name}`);
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
}

81
apps/cli/src/mcp/tools.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* MCP tool definitions exposed to Claude Code.
*
* Mirror the claude-intercom tool surface: send_message, list_peers,
* check_messages, set_summary, set_status. Tools return "not
* connected" errors until 15b wires the WS client.
*/
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
export const TOOLS: Tool[] = [
{
name: "send_message",
description:
"Send a message to a peer in one of your joined meshes. `to` is a peer display name, hex pubkey, or `#channel`. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
inputSchema: {
type: "object",
properties: {
to: {
type: "string",
description: "Peer name, pubkey, or #channel",
},
message: { type: "string", description: "Message text" },
priority: {
type: "string",
enum: ["now", "next", "low"],
description: "Delivery priority (default: next)",
},
},
required: ["to", "message"],
},
},
{
name: "list_peers",
description:
"List peers across all joined meshes. Shows name, mesh, status (idle/working/dnd), and current summary.",
inputSchema: {
type: "object",
properties: {
mesh_slug: {
type: "string",
description: "Only list peers in this mesh (optional)",
},
},
},
},
{
name: "check_messages",
description:
"Pull any undelivered messages from the broker. Normally messages arrive via push; use this to drain the queue after being offline.",
inputSchema: { type: "object", properties: {} },
},
{
name: "set_summary",
description:
"Set a 12 sentence summary of what you're working on. Visible to other peers.",
inputSchema: {
type: "object",
properties: {
summary: { type: "string", description: "1-2 sentence summary" },
},
required: ["summary"],
},
},
{
name: "set_status",
description:
"Manually override your status. `dnd` blocks everything except `now`-priority messages.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["idle", "working", "dnd"],
description: "Your status",
},
},
required: ["status"],
},
},
];

24
apps/cli/src/mcp/types.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* MCP tool schemas + shared types for the CLI's MCP server.
*/
export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string; // peer name, pubkey, or #channel
message: string;
priority?: Priority;
}
export interface ListPeersArgs {
mesh_slug?: string; // filter to one joined mesh
}
export interface SetSummaryArgs {
summary: string;
}
export interface SetStatusArgs {
status: PeerStatus;
}

View File

@@ -0,0 +1,58 @@
/**
* Local persistent config — ~/.claudemesh/config.json
*
* Stores: joined meshes, per-mesh identity keys (ed25519 keypairs),
* last-seen broker URL. Loaded on CLI start, on MCP server start,
* and on every join/leave.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { z } from "zod";
import { env } from "../env";
const joinedMeshSchema = z.object({
meshId: z.string(),
memberId: z.string(),
slug: z.string(),
name: z.string(),
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
brokerUrl: z.string(),
joinedAt: z.string(),
});
const configSchema = z.object({
version: z.literal(1).default(1),
meshes: z.array(joinedMeshSchema).default([]),
});
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
export type Config = z.infer<typeof configSchema>;
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
export function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
return configSchema.parse({ version: 1, meshes: [] });
}
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
return configSchema.parse(JSON.parse(raw));
} catch (e) {
throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
export function saveConfig(config: Config): void {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
}
export function getConfigPath(): string {
return CONFIG_PATH;
}

40
apps/cli/src/ws/client.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* WS client to the broker (STUB).
*
* Final implementation in Step 15b — connects to broker, sends hello
* (with signed nonce), pumps messages to/from the MCP server, handles
* reconnect. For now just a placeholder type surface so the MCP
* server can depend on it.
*/
import type { JoinedMesh } from "../state/config";
export interface BrokerConnection {
meshId: string;
isConnected(): boolean;
sendMessage(args: {
targetSpec: string;
priority: "now" | "next" | "low";
nonce: string;
ciphertext: string;
}): Promise<{ ok: boolean; messageId?: string; error?: string }>;
close(): void;
}
/**
* Stub broker connection. Returns "not implemented" errors on every
* call. Real implementation in 15b will connect to env.CLAUDEMESH_BROKER_URL.
*/
export function connectBroker(_mesh: JoinedMesh): BrokerConnection {
return {
meshId: _mesh.meshId,
isConnected: () => false,
sendMessage: async () => ({
ok: false,
error: "broker client not implemented (Step 15b)",
}),
close: () => {
/* noop */
},
};
}