refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

View File

@@ -35,6 +35,10 @@ ENV BROKER_PORT=7900
COPY --from=deps --chown=bun:bun /deploy /app
# Copy migrations folder alongside the broker so runtime auto-migrate
# has files to apply. Workspace deploy subset drops them otherwise.
COPY --from=deps --chown=bun:bun /app/packages/db/migrations /app/migrations
EXPOSE 7900
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \

View File

@@ -26,6 +26,7 @@
"libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"postgres": "3.4.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"ws": "8.20.0",

View File

@@ -696,6 +696,12 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
if (req.method === "POST" && req.url?.startsWith("/cli/mesh/") && req.url?.endsWith("/grants")) {
const slug = req.url.slice("/cli/mesh/".length).replace("/grants", "");
handleCliMeshGrants(req, slug, res, started);
return;
}
if (req.method === "DELETE" && req.url?.startsWith("/cli/mesh/")) {
const slug = req.url.slice("/cli/mesh/".length);
handleMeshDelete(req, slug, res, started);
@@ -1836,6 +1842,28 @@ async function handleSend(
...(subtype ? { subtype } : {}),
};
// Per-peer grant enforcement — load recipient grant maps once per send.
// See .artifacts/specs/2026-04-15-per-peer-capabilities.md.
const DEFAULT_CAPS = ["read", "dm", "broadcast", "state-read"] as const;
const capNeeded: "dm" | "broadcast" = isMulticast ? "broadcast" : "dm";
const senderPubkey = conn.memberPubkey; // stable member key (survives session rotation)
// Fetch grant maps for all connected peers in this mesh in one query.
// Small (bounded by concurrent connections per mesh); acceptable per send.
const grantRows = await db
.select({ id: meshMember.id, peerGrants: meshMember.peerGrants })
.from(meshMember)
.where(eq(meshMember.meshId, conn.meshId));
const grantsByMemberId = new Map<string, Record<string, string[]>>(
grantRows.map((r) => [r.id, (r.peerGrants as Record<string, string[]>) ?? {}]),
);
function allowed(recipientMemberId: string): boolean {
const grants = grantsByMemberId.get(recipientMemberId);
if (!grants) return DEFAULT_CAPS.includes(capNeeded);
const entry = grants[senderPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(capNeeded);
return entry.includes(capNeeded);
}
for (const [pid, peer] of connections) {
if (pid === senderPresenceId) continue;
if (peer.meshId !== conn.meshId) continue;
@@ -1854,6 +1882,14 @@ async function handleSend(
continue;
}
// Per-peer capability check — silent drop if recipient hasn't granted
// `capNeeded` to this sender (Signal block semantics: sender sees
// delivered, recipient sees nothing).
if (!allowed(peer.memberId)) {
metrics.messagesDroppedByGrantTotal?.inc?.({ cap: capNeeded });
continue;
}
if (isMulticast) {
// Multicast: push directly to each connected peer. The queue
// row has one delivered_at — can only be claimed once. Direct
@@ -4319,7 +4355,12 @@ async function recoverScheduledMessages(): Promise<void> {
}
}
function main(): void {
async function main(): Promise<void> {
// Run pending migrations before the first connection is accepted.
// Exits non-zero on failure so Coolify sees a broken container.
const { runMigrationsOnStartup } = await import("./migrate");
await runMigrationsOnStartup();
const wss = new WebSocketServer({
noServer: true,
maxPayload: env.MAX_MESSAGE_BYTES,
@@ -5036,6 +5077,52 @@ import { checkPermission, getPermissions, setPermissions } from "./permissions";
import { meshPermission } from "@turbostarter/db/schema/mesh";
/** POST /cli/mesh/create — create a new mesh via CLI. */
/** POST /cli/mesh/:slug/grants — set per-peer grants for the caller's membership.
*
* Body: { user_id: string, grants: Record<peer_pubkey_hex, string[]> }
* Merges the map into the caller's mesh_member.peer_grants. Empty array
* for a specific peer = blocked. Explicit null = reset to defaults.
*/
async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
let body: { user_id: string; grants: Record<string, string[] | null> };
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body;
} catch {
writeJson(res, 400, { error: "Invalid body" });
return;
}
if (!body.user_id || !body.grants) {
writeJson(res, 400, { error: "user_id and grants required" });
return;
}
try {
const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1);
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
// Find the caller's member row.
const [member] = await db.select().from(meshMember)
.where(and(eq(meshMember.meshId, m.id), eq(meshMember.userId, body.user_id), isNull(meshMember.revokedAt)))
.limit(1);
if (!member) {
writeJson(res, 403, { error: "Not a member of this mesh" });
return;
}
const current = (member.peerGrants as Record<string, string[]>) ?? {};
const merged = { ...current };
for (const [pk, caps] of Object.entries(body.grants)) {
if (caps === null) delete merged[pk];
else merged[pk] = caps;
}
await db.update(meshMember).set({ peerGrants: merged }).where(eq(meshMember.id, member.id));
writeJson(res, 200, { ok: true, grants: merged });
log.info("mesh-grants", { route: "POST /cli/mesh/:slug/grants", slug, member_id: member.id, latency_ms: Date.now() - started });
} catch (e) {
log.error("mesh-grants", { error: e instanceof Error ? e.message : String(e) });
writeJson(res, 500, { error: "Failed to update grants" });
}
}
/** POST /cli/mesh/:slug/invite — generate an invite for a mesh. */
async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
let body: { user_id: string; email?: string; expires_in?: string; role?: string };
@@ -5363,5 +5450,8 @@ async function handlePermissionsSet(req: IncomingMessage, slug: string, res: Ser
// Skip starting the HTTP/WS server when running under vitest — tests import
// claimInviteV2Core() directly and must not bind ports on module load.
if (!process.env.VITEST) {
main();
main().catch((e) => {
console.error("fatal:", e instanceof Error ? e.stack : e);
process.exit(1);
});
}

View File

@@ -90,6 +90,10 @@ export const metrics = {
"broker_messages_rejected_total",
"Messages rejected (size, auth, malformed)",
),
messagesDroppedByGrantTotal: new Counter(
"broker_messages_dropped_by_grant_total",
"Messages silently dropped because recipient didn't grant sender the required capability",
),
queueDepth: new Gauge(
"broker_queue_depth",
"Undelivered messages currently in the queue",

View File

@@ -0,0 +1,59 @@
/**
* Runtime migrations on broker startup.
*
* Runs pending drizzle migrations against DATABASE_URL before the broker
* listens. Uses pg_advisory_lock so a multi-instance deploy doesn't race.
* If migrations fail, the process exits non-zero so the orchestrator (Coolify
* healthcheck) sees the container as broken and doesn't route traffic.
*/
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { dirname, join } from "node:path";
import { existsSync, readdirSync } from "node:fs";
const LOCK_ID = 74737_73831; // "cmsh" ascii — stable magic constant
export async function runMigrationsOnStartup(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL not set — skipping auto-migrate");
return;
}
// Resolve the migrations folder — it's shipped inside @turbostarter/db's
// deploy subset in the runtime image. Dev path also works.
const candidates = [
"/app/migrations",
"/app/node_modules/@turbostarter/db/migrations",
join(process.cwd(), "..", "..", "packages", "db", "migrations"),
join(process.cwd(), "packages", "db", "migrations"),
];
const migrationsFolder = candidates.find((p) => existsSync(p));
if (!migrationsFolder) {
console.error("[migrate] migrations folder not found — skipping. Searched:", candidates);
return;
}
const count = readdirSync(migrationsFolder).filter((f) => f.endsWith(".sql")).length;
console.log(`[migrate] ${count} migration files at ${migrationsFolder}`);
const sql = postgres(url, { max: 1, onnotice: () => { /* quiet */ } });
try {
// Advisory lock so parallel instances serialise.
await sql`SELECT pg_advisory_lock(${LOCK_ID})`;
try {
const db = drizzle(sql);
const start = Date.now();
await migrate(db, { migrationsFolder });
console.log(`[migrate] ok (${Date.now() - start}ms)`);
} finally {
await sql`SELECT pg_advisory_unlock(${LOCK_ID})`;
}
} catch (e) {
console.error("[migrate] FAILED:", e instanceof Error ? e.message : e);
process.exit(1);
} finally {
await sql.end({ timeout: 5 });
}
}

View File

@@ -1,90 +0,0 @@
# claudemesh-cli
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
## Install
```bash
npm i -g claudemesh-cli
```
## Quick start
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
## Commands
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Architecture
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── api/ typed HTTP client for claudemesh.com
│ ├── health/ 6 diagnostic checks
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
├── mcp/ MCP server with 79 tools across 21 families
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Development
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

View File

@@ -1,69 +0,0 @@
{
"name": "claudemesh-cli-v2",
"version": "1.0.0-alpha.31",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
"mcp",
"model-context-protocol",
"claudemesh",
"peer-messaging",
"multi-agent"
],
"author": "Alejandro Gutiérrez",
"license": "MIT",
"homepage": "https://claudemesh.com",
"repository": {
"type": "git",
"url": "https://github.com/alezmad/claudemesh.git",
"directory": "apps/cli-v2"
},
"type": "module",
"bin": {
"claudemesh": "./dist/entrypoints/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bun build.ts",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/entrypoints/cli.ts",
"start": "bun src/entrypoints/cli.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"qrcode-terminal": "0.12.0",
"ws": "8.20.0",
"zod": "4.1.13"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/qrcode-terminal": "0.12.2",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -1,65 +0,0 @@
import { readConfig } from "~/services/config/facade.js";
export async function connectTelegram(args: string[]): Promise<void> {
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
process.exit(1);
}
const mesh = config.meshes[0]!;
const linkOnly = args.includes("--link");
// Convert WS broker URL to HTTP
const brokerHttp = mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
console.log("Requesting Telegram connect token...");
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meshId: mesh.meshId,
memberId: mesh.memberId,
pubkey: mesh.pubkey,
secretKey: mesh.secretKey,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
const { token, deepLink } = (await res.json()) as {
token: string;
deepLink: string;
};
if (linkOnly) {
console.log(deepLink);
return;
}
// Print QR code using simple block characters
console.log("\n Connect Telegram to your mesh:\n");
console.log(` ${deepLink}\n`);
console.log(" Open this link on your phone, or scan the QR code");
console.log(" with your Telegram camera.\n");
// Try to generate QR with qrcode-terminal if available
try {
const QRCode = require("qrcode-terminal");
QRCode.generate(deepLink, { small: true }, (code: string) => {
console.log(code);
});
} catch {
// qrcode-terminal not available, link is enough
console.log(" (Install qrcode-terminal for QR code display)");
}
}

View File

@@ -1,81 +0,0 @@
/**
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
*
* Opens a connection to one mesh, runs a callback, then closes cleanly.
* The caller never deals with connect/close lifecycle.
*/
import { hostname } from "node:os";
import { createInterface } from "node:readline";
import { BrokerClient } from "~/services/broker/facade.js";
import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export interface ConnectOpts {
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
meshSlug?: string | null;
/** Display name for this session. Defaults to hostname-pid. */
displayName?: string;
/** Connect to all meshes and run fn for each. */
all?: boolean;
}
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
export async function withMesh<T>(
opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> {
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
let mesh: JoinedMesh;
if (opts.meshSlug) {
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
if (!found) {
console.error(
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
mesh = await pickMesh(config.meshes);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
const client = new BrokerClient(mesh, { displayName });
try {
await client.connect();
const result = await fn(client, mesh);
return result;
} finally {
client.close();
}
}

View File

@@ -1,281 +0,0 @@
/**
* `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 { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION, URLS } from "~/constants/urls.js";
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<string, unknown>;
};
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 {
readConfig();
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 = readConfig();
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),
};
}
}
async function checkBrokerWs(): Promise<Check> {
const wsUrl = URLS.BROKER;
const start = Date.now();
try {
const WebSocket = (await import("ws")).default;
const ws = new WebSocket(wsUrl);
const result = await new Promise<Check>((resolve) => {
const timer = setTimeout(() => {
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: `timeout after 5s (${wsUrl})`,
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
});
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
const latency = Date.now() - start;
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: true,
detail: `${latency}ms to ${wsUrl}`,
});
});
ws.once("error", (e) => {
clearTimeout(timer);
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: e.message,
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
});
});
});
return result;
} catch (e) {
return {
name: "Broker WebSocket reachable",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function checkNpmLatest(): Promise<Check> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
}
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
const up = latest === VERSION;
return {
name: "CLI up-to-date",
pass: up,
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
};
} catch {
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
}
}
export async function runDoctor(): Promise<void> {
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(),
await checkBrokerWs(),
await checkNpmLatest(),
];
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);
}
}

View File

@@ -1,123 +0,0 @@
/**
* `claudemesh hook <status>` — Claude Code hook handler.
*
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
* On each turn boundary, Claude Code invokes:
*
* Stop → `claudemesh hook idle`
* UserPromptSubmit → `claudemesh hook working`
*
* We read the Claude Code hook JSON payload from stdin (contains cwd +
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
* broker with {cwd, pid, status, session_id}. Each broker looks up
* its local presence row by (pid, cwd) and updates status.
*
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
* surface errors to the user. Debug logging available via
* CLAUDEMESH_HOOK_DEBUG=1.
*
* Why send to every broker? A user joined to multiple meshes has
* one presence row per mesh, each on its own broker. A turn boundary
* updates the status on every broker where this session is active.
* Brokers that don't have a matching presence just queue the signal
* in pending_status (harmless, TTL-swept).
*/
import { readConfig } from "~/services/config/facade.js";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
function debug(msg: string): void {
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
}
/** WS URL → HTTP URL (same host, swap scheme). */
function wsToHttp(wsUrl: string): string {
try {
const u = new URL(wsUrl);
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
return `${httpScheme}//${u.host}`;
} catch {
return wsUrl;
}
}
async function readStdinJson(): Promise<Record<string, unknown>> {
if (process.stdin.isTTY) return {};
const chunks: Uint8Array[] = [];
const reader = process.stdin;
try {
for await (const chunk of reader) {
chunks.push(chunk as Uint8Array);
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
}
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) return {};
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
async function postHook(
brokerWsUrl: string,
body: Record<string, unknown>,
): Promise<void> {
const base = wsToHttp(brokerWsUrl);
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 1000);
await fetch(`${base}/hook/set-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(t));
} catch (e) {
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
}
}
export async function runHook(args: string[]): Promise<void> {
const status = args[0];
if (!status || !["idle", "working", "dnd"].includes(status)) {
// Silent no-op — we never want a hook to surface an error.
process.exit(0);
}
// Read Claude Code's stdin payload for cwd + session_id.
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
setTimeout(() => r({}), 500),
);
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
const cwd =
(typeof payload.cwd === "string" && payload.cwd) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
const sessionId =
(typeof payload.session_id === "string" && payload.session_id) || "";
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = readConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);
}
if (config.meshes.length === 0) {
debug("no joined meshes, nothing to do");
process.exit(0);
}
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
debug(
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
);
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
// covers them (broker resolves presence by cwd+pid regardless).
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
process.exit(0);
}

View File

@@ -1,60 +0,0 @@
/**
* `claudemesh inbox` — read pending peer messages.
*
* Connects, waits briefly for push delivery, drains the buffer, prints.
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
export interface InboxFlags {
mesh?: string;
json?: boolean;
wait?: number;
}
function formatMessage(msg: InboundPush, useColor: boolean): string {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
}
export async function runInbox(flags: InboxFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const waitMs = (flags.wait ?? 1) * 1000;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
// Wait briefly for broker to push any held messages.
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer();
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return;
}
if (messages.length === 0) {
console.log(dim(`No messages on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
console.log("");
for (const msg of messages) {
console.log(formatMessage(msg, useColor));
console.log("");
}
});
}

View File

@@ -1,58 +0,0 @@
/**
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
*
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface InfoFlags {
mesh?: string;
json?: boolean;
}
export async function runInfo(flags: InfoFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([
client.meshInfo(),
client.listPeers(),
client.listState(),
]);
const output = {
slug: mesh.slug,
meshId: mesh.meshId,
memberId: mesh.memberId,
brokerUrl: mesh.brokerUrl,
displayName: config.displayName ?? null,
peerCount: peers.length,
stateCount: state.length,
...(brokerInfo ?? {}),
};
if (flags.json) {
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
console.log(dim(` mesh: ${mesh.meshId}`));
console.log(dim(` member: ${mesh.memberId}`));
console.log(` peers: ${peers.length} connected`);
console.log(` state: ${state.length} keys`);
if (brokerInfo && typeof brokerInfo === "object") {
for (const [k, v] of Object.entries(brokerInfo)) {
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
}
}
});
}

View File

@@ -1,564 +0,0 @@
/**
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
*
* 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,
copyFileSync,
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 { spawnSync } from "node:child_process";
import { readConfig } from "~/services/config/facade.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
const HOOK_COMMAND_STOP = "claudemesh hook idle";
const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working";
const HOOK_MARKER = "claudemesh hook ";
type McpEntry = {
command: string;
args?: string[];
env?: Record<string, string>;
};
interface HookCommand {
type: "command";
command: string;
}
interface HookMatcher {
matcher?: string;
hooks: HookCommand[];
}
type HooksConfig = Record<string, HookMatcher[]>;
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)}`,
);
}
}
/**
* Create a timestamped backup of ~/.claude.json before any write.
*/
function backupClaudeConfig(): void {
if (!existsSync(CLAUDE_CONFIG)) return;
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
mkdirSync(backupDir, { recursive: true });
const ts = Date.now();
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
copyFileSync(CLAUDE_CONFIG, dest);
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns the action taken ("added" | "updated" | "unchanged").
*/
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers =
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
if (!cfg.mcpServers) cfg.mcpServers = servers;
const existing = servers[MCP_NAME];
let action: "added" | "updated" | "unchanged";
if (!existing) {
servers[MCP_NAME] = entry;
action = "added";
} else if (entriesEqual(existing, entry)) {
return "unchanged";
} else {
servers[MCP_NAME] = entry;
action = "updated";
}
flushClaudeConfig(cfg);
return action;
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns true if an entry was removed.
*/
function removeMcpServer(): boolean {
if (!existsSync(CLAUDE_CONFIG)) return false;
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
if (!servers || !(MCP_NAME in servers)) return false;
delete servers[MCP_NAME];
cfg.mcpServers = servers;
flushClaudeConfig(cfg);
return true;
}
/** Low-level write — callers must backup + merge first. */
function flushClaudeConfig(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, node:child_process. */
function bunAvailable(): boolean {
const res =
platform() === "win32"
? spawnSync("where", ["bun"])
: spawnSync("sh", ["-c", "command -v bun"]);
return res.status === 0;
}
/** Absolute path to this CLI's entry file. */
function resolveEntry(): string {
const here = fileURLToPath(import.meta.url);
// When bundled (dist/index.js), this file IS the entry → return self.
// When running from source (src/index.ts via bun), walk up to the
// dir + resolve index.ts.
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
return here;
}
return resolve(dirname(here), "..", "index.ts");
}
/**
* Build the MCP server entry for Claude Code's config.
*
* Two modes:
* - Installed globally (npm i -g claudemesh-cli): use `claudemesh`
* as the command, relies on it being on PATH.
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
*/
function buildMcpEntry(entryPath: string): McpEntry {
const isBundled = entryPath.endsWith("/dist/index.js") ||
entryPath.endsWith("\\dist\\index.js");
if (isBundled) {
return {
command: "claudemesh",
args: ["mcp"],
};
}
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 ?? [])
);
}
function readClaudeSettings(): Record<string, unknown> {
if (!existsSync(CLAUDE_SETTINGS)) return {};
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
function writeClaudeSettings(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
writeFileSync(
CLAUDE_SETTINGS,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
}
/**
* All claudemesh MCP tool names, prefixed for allowedTools.
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
*/
const CLAUDEMESH_TOOLS = [
"mcp__claudemesh__cancel_scheduled",
"mcp__claudemesh__check_messages",
"mcp__claudemesh__claim_task",
"mcp__claudemesh__complete_task",
"mcp__claudemesh__create_stream",
"mcp__claudemesh__create_task",
"mcp__claudemesh__delete_file",
"mcp__claudemesh__file_status",
"mcp__claudemesh__forget",
"mcp__claudemesh__get_context",
"mcp__claudemesh__get_file",
"mcp__claudemesh__get_state",
"mcp__claudemesh__grant_file_access",
"mcp__claudemesh__graph_execute",
"mcp__claudemesh__graph_query",
"mcp__claudemesh__join_group",
"mcp__claudemesh__leave_group",
"mcp__claudemesh__list_collections",
"mcp__claudemesh__list_contexts",
"mcp__claudemesh__list_files",
"mcp__claudemesh__list_peers",
"mcp__claudemesh__list_scheduled",
"mcp__claudemesh__list_state",
"mcp__claudemesh__list_streams",
"mcp__claudemesh__list_tasks",
"mcp__claudemesh__mesh_execute",
"mcp__claudemesh__mesh_info",
"mcp__claudemesh__mesh_query",
"mcp__claudemesh__mesh_schema",
"mcp__claudemesh__message_status",
"mcp__claudemesh__ping_mesh",
"mcp__claudemesh__publish",
"mcp__claudemesh__recall",
"mcp__claudemesh__remember",
"mcp__claudemesh__schedule_reminder",
"mcp__claudemesh__send_message",
"mcp__claudemesh__set_state",
"mcp__claudemesh__set_status",
"mcp__claudemesh__set_summary",
"mcp__claudemesh__share_context",
"mcp__claudemesh__share_file",
"mcp__claudemesh__subscribe",
"mcp__claudemesh__vector_delete",
"mcp__claudemesh__vector_search",
"mcp__claudemesh__vector_store",
];
/**
* Pre-approve all claudemesh MCP tools in allowedTools.
* Merges into any existing list — never overwrites other entries.
* Returns which tools were added vs already present.
*/
function installAllowedTools(): { added: string[]; unchanged: number } {
const settings = readClaudeSettings();
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
if (toAdd.length > 0) {
settings.allowedTools = [...Array.from(existing), ...toAdd];
writeClaudeSettings(settings);
}
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
}
/**
* Remove claudemesh tools from allowedTools.
* Leaves all other entries intact. Returns count removed.
*/
function uninstallAllowedTools(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const existing = (settings.allowedTools as string[] | undefined) ?? [];
const toolSet = new Set(CLAUDEMESH_TOOLS);
const kept = existing.filter((t) => !toolSet.has(t));
const removed = existing.length - kept.length;
if (removed > 0) {
settings.allowedTools = kept;
writeClaudeSettings(settings);
}
return removed;
}
/**
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
* idempotent on the command string. Returns counts for reporting.
*/
function installHooks(): { added: number; unchanged: number } {
const settings = readClaudeSettings();
const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {};
let added = 0;
let unchanged = 0;
const ensure = (event: string, command: string): void => {
const list = (hooks[event] ??= []);
const alreadyPresent = list.some((entry) =>
(entry.hooks ?? []).some((h) => h.command === command),
);
if (alreadyPresent) {
unchanged += 1;
return;
}
list.push({ hooks: [{ type: "command", command }] });
added += 1;
};
ensure("Stop", HOOK_COMMAND_STOP);
ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT);
settings.hooks = hooks;
writeClaudeSettings(settings);
return { added, unchanged };
}
/**
* Remove every hook entry whose command contains "claudemesh hook "
* from ~/.claude/settings.json. Idempotent. Returns removed count.
*/
function uninstallHooks(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const hooks = settings.hooks as HooksConfig | undefined;
if (!hooks) return 0;
let removed = 0;
for (const event of Object.keys(hooks)) {
const kept: HookMatcher[] = [];
for (const entry of hooks[event] ?? []) {
const filtered = (entry.hooks ?? []).filter(
(h) => !(h.command ?? "").includes(HOOK_MARKER),
);
removed += (entry.hooks ?? []).length - filtered.length;
if (filtered.length > 0) kept.push({ ...entry, hooks: filtered });
}
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
}
settings.hooks = hooks;
writeClaudeSettings(settings);
return removed;
}
function installStatusLine(): { installed: boolean } {
const settings = readClaudeSettings();
const cmd = `claudemesh status-line`;
const current = (settings as { statusLine?: { command?: string } }).statusLine;
// If the user has their own statusLine command, don't clobber it.
if (current?.command && !current.command.includes("claudemesh status-line")) {
return { installed: false };
}
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
type: "command",
command: cmd,
};
writeClaudeSettings(settings);
return { installed: true };
}
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
// Dev mode (running from src/) requires bun on PATH; bundled mode
// (npm install -g) just uses node + the claudemesh bin shim.
if (!isBundled && !bunAvailable()) {
console.error(
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
);
process.exit(1);
}
if (!existsSync(entry)) {
console.error(`✗ MCP entry not found at ${entry}`);
process.exit(1);
}
const desired = buildMcpEntry(entry);
const action = patchMcpServer(desired);
// 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);
}
// ANSI color helpers — stick to 8-color set so terminals without
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
console.log(dim(` config: ${CLAUDE_CONFIG}`));
console.log(
dim(
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
),
);
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
// --dangerously-skip-permissions just to call mesh tools.
try {
const { added, unchanged } = installAllowedTools();
if (added.length > 0) {
console.log(
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
} else {
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
if (!skipHooks) {
try {
const { added, unchanged } = installHooks();
if (added > 0) {
console.log(
`✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`,
);
} else {
console.log(`✓ Hooks already registered (${unchanged} present)`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
);
console.error(
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
);
}
} else {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
// Opt-in status line (shows mesh + peer count in Claude Code).
if (wantStatusLine) {
try {
const { installed } = installStatusLine();
if (installed) {
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Check if user has any meshes joined — nudge them if not.
let hasMeshes = false;
try {
const meshConfig = readConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
}
console.log("");
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
if (!hasMeshes) {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh <invite-url>")}` +
dim(" — joins + launches in one step"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
);
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
}
console.log("");
console.log(dim("Optional:"));
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
console.log(dim(` claudemesh completions zsh # shell completions`));
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`✓ MCP server "${MCP_NAME}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`✓ Hooks removed (${removed} entries)`);
} else {
console.log("· No claudemesh hooks to remove");
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

@@ -1,193 +0,0 @@
/**
* `claudemesh join <invite-link-or-code>` — full join flow.
*
* Accepts either:
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
* persists mesh + fresh ed25519 identity.
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
* → parses signed payload, calls broker /join, persists.
*
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
*/
import { parseInviteLink } from "~/services/invite/facade.js";
import { enrollWithBroker } from "~/services/invite/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
import sodium from "libsodium-wrappers";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "~/constants/urls.js";
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
function deriveAppBaseUrl(): string {
const override = process.env.CLAUDEMESH_APP_URL;
if (override) return override.replace(/\/$/, "");
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
// For self-hosted: honour the broker host's parent domain as best-effort.
try {
const u = new URL(env.CLAUDEMESH_BROKER_URL);
const host = u.host.replace(/^ic\./, "");
const scheme = u.protocol === "wss:" ? "https:" : "http:";
return `${scheme}//${host}`;
} catch {
return "https://claudemesh.com";
}
}
async function runJoinV2(code: string): Promise<void> {
const appBaseUrl = deriveAppBaseUrl();
console.log(`Claiming invite ${code} via ${appBaseUrl}`);
let claim;
try {
claim = await claimInviteV2({ appBaseUrl, code });
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// Generate a fresh ed25519 identity for this peer. The v2 claim
// endpoint creates the member row keyed on the x25519 pubkey we sent;
// the ed25519 keypair is what the `hello` handshake and future
// envelope signing will use. Stored locally only.
const keypair = await generateKeypair();
const displayName = `${hostname()}-${process.pid}`;
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
// to match the format used everywhere else (broker stores it the
// same way in mesh.rootKey).
await sodium.ready;
const rootKeyB64 = sodium.to_base64(
claim.rootKey,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
// Persist. We don't have a mesh_slug in the v2 response — the server
// derives slug from name and slug is no longer globally unique. Use a
// stable short derivative of the mesh id so `list` / `launch --mesh`
// still have something to match on.
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
const config = readConfig();
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
config.meshes.push({
meshId: claim.meshId,
memberId: claim.memberId,
slug: fallbackSlug,
name: fallbackSlug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: env.CLAUDEMESH_BROKER_URL,
joinedAt: new Date().toISOString(),
rootKey: rootKeyB64,
inviteVersion: 2,
});
writeConfig(config);
console.log("");
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
console.log(` member id: ${claim.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-url-or-code>");
console.error("");
console.error("Examples:");
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
console.error(" claudemesh join abc12345");
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
process.exit(1);
}
// Try v2 first — short code / `/i/<code>` URL.
const v2Code = parseV2InviteInput(link);
if (v2Code) {
await runJoinV2(v2Code);
return;
}
// 1. Parse + verify signature client-side.
let invite;
try {
invite = await parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload, token } = invite;
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
// 2. Generate keypair.
const keypair = await generateKeypair();
// 3. Enroll with broker.
const displayName = `${hostname()}-${process.pid}`;
let enroll;
try {
enroll = await enrollWithBroker({
brokerWsUrl: payload.broker_url,
inviteToken: token,
invitePayload: payload,
peerPubkey: keypair.publicKey,
displayName,
});
} catch (e) {
console.error(
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// 4. Persist.
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== payload.mesh_slug,
);
config.meshes.push({
meshId: payload.mesh_id,
memberId: enroll.memberId,
slug: payload.mesh_slug,
name: payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: payload.broker_url,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 5. Report.
console.log("");
console.log(
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
);
console.log(` member id: ${enroll.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${payload.broker_url}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}

View File

@@ -1,823 +0,0 @@
// @ts-nocheck — v1 port, runtime-tested
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow:
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
import { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
name?: string;
role?: string;
groups?: string;
join?: string;
mesh?: string;
"message-mode"?: string;
"system-prompt"?: string;
resume?: string;
continue?: boolean;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
console.log(" ALL permission prompts — not just claudemesh tools.");
console.log(" Peers exchange text only — no file access, no tool calls.");
console.log("");
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === "" || a === "y" || a === "yes") {
resolve();
} else {
console.log("\n Aborted. Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
});
});
}
// --- Banner ---
import {
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
} from "~/ui/styles.js";
import {
enterFullScreen, exitFullScreen, writeCentered, termSize,
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
} from "~/ui/screen.js";
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
interface LaunchWizardResult {
mesh: JoinedMesh;
role: string | null;
groups: GroupEntry[];
messageMode: "push" | "inbox" | "off";
skipPermissions: boolean;
}
/**
* Full-screen launch wizard — spinning logo + interactive config.
* Mesh selection, role, groups, message mode, permissions — all in one TUI.
* Falls back to plain text on non-TTY.
*/
async function runLaunchWizard(opts: {
displayName: string;
meshes: JoinedMesh[];
selectedMesh: JoinedMesh | null;
existingRole: string | null;
existingGroups: GroupEntry[];
existingMessageMode: "push" | "inbox" | "off" | null;
skipPermConfirm: boolean;
}): Promise<LaunchWizardResult> {
if (!process.stdout.isTTY) {
return {
mesh: opts.selectedMesh ?? opts.meshes[0]!,
role: opts.existingRole,
groups: opts.existingGroups,
messageMode: opts.existingMessageMode ?? "push",
skipPermissions: opts.skipPermConfirm,
};
}
const { rows } = termSize();
enterFullScreen();
drawTopBar();
// Spinning logo centered in upper portion
const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2);
const brandRow = logoTop + FRAME_HEIGHT + 1;
const subtitleRow = brandRow + 1;
const formRow = subtitleRow + 2;
writeCentered(brandRow, boldOrange("claudemesh"));
writeCentered(subtitleRow, tDim("peer mesh for Claude Code"));
const spinner = createSpinner({
render(lines) {
for (let i = 0; i < lines.length; i++) {
writeCentered(logoTop + i, lines[i]!);
}
},
interval: 70,
});
spinner.start();
// Show detected info
let row = formRow;
writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`);
row++;
writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`);
row += 2;
// Mesh selection
let mesh: JoinedMesh;
if (opts.selectedMesh) {
mesh = opts.selectedMesh;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else if (opts.meshes.length === 1) {
mesh = opts.meshes[0]!;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else {
spinner.stop();
const choice = await menuSelect({
title: "Select mesh",
items: opts.meshes.map(m => m.slug),
row,
});
mesh = opts.meshes[choice]!;
// Redraw as confirmed
for (let i = 0; i < opts.meshes.length + 1; i++) {
writeCentered(row + i, " ");
}
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
spinner.start();
row++;
}
row++;
// Interactive fields
let role = opts.existingRole;
let groups = opts.existingGroups;
let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off";
// Role input
if (role === null) {
spinner.stop();
const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" });
if (answer) role = answer;
spinner.start();
row++;
} else {
writeCentered(row, `Role ${tGreen("✓")} ${role}`);
row++;
}
// Groups input
if (groups.length === 0) {
spinner.stop();
const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" });
if (answer) groups = parseGroupsString(answer);
spinner.start();
row++;
} else {
const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ");
writeCentered(row, `Groups ${tGreen("✓")} ${tags}`);
row++;
}
// Message mode selection
if (opts.existingMessageMode === null) {
row++;
spinner.stop();
const choice = await menuSelect({
title: "Message mode",
items: [
"Push (real-time, peers can interrupt)",
"Inbox (held until you check)",
"Off (tools only, no messages)",
],
row,
});
messageMode = (["push", "inbox", "off"] as const)[choice];
spinner.start();
row += 5;
} else {
writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`);
row++;
}
// Permissions confirmation
let skipPermissions = opts.skipPermConfirm;
if (!skipPermissions) {
row++;
spinner.stop();
writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,"));
writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh."));
row += 3;
const confirmed = await confirmPrompt({
message: boldOrange("Autonomous mode?"),
row,
defaultYes: true,
});
if (!confirmed) {
exitFullScreen();
console.log(" Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
skipPermissions = true;
spinner.start();
}
// Final animation
row += 2;
writeCentered(row, tDim("Launching Claude Code..."));
await new Promise(r => setTimeout(r, 800));
spinner.stop();
exitFullScreen();
return { mesh, role, groups, messageMode, skipPermissions };
}
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
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 bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule);
if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
console.log("");
}
// --- Main ---
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
// Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
resume: flags.resume ?? null,
continueSession: flags.continue ?? false,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { writeConfig } = await import("~/services/config/facade.js");
writeConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = readConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
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 code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
console.log(` Opening browser to sign in...\n`);
const opened = await openBrowser(url);
if (!opened) {
console.log(` Couldn't open browser automatically.`);
}
console.log(` ${dim(`Visit: ${url}`)}`);
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("\n Timed out waiting for sign-in.");
process.exit(1);
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("~/services/crypto/facade.js");
const keypair = await generateKeypair();
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const { syncWithBroker } = await import("~/services/auth/facade.js");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { writeConfig } = await import("~/services/config/facade.js");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
}
config.accountId = result.account_id;
writeConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
}
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
process.exit(1);
}
// Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1)
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
// Multiple meshes — wizard will handle selection
mesh = null as unknown as JoinedMesh; // set by wizard below
}
// 3. Session identity + role/groups via TUI wizard.
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
// entirely and use sensible defaults (role=member, no groups, push mode).
// Same applies to `--quiet` and the post-sync path where we already picked.
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
if (!nonInteractive) {
const wizardResult = await runLaunchWizard({
displayName,
meshes: config.meshes,
selectedMesh: mesh ?? null,
existingRole: args.role,
existingGroups: parsedGroups,
existingMessageMode: args.messageMode ?? null,
skipPermConfirm: args.skipPermConfirm,
});
mesh = wizardResult.mesh;
role = wizardResult.role;
parsedGroups = wizardResult.groups;
messageMode = wizardResult.messageMode;
args.skipPermConfirm = wizardResult.skipPermissions;
} else if (!mesh) {
// No mesh picked yet + non-interactive — pick the first one deterministically.
mesh = config.meshes[0]!;
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
const tmpBase = tmpdir();
try {
for (const entry of readdirSync(tmpBase)) {
if (!entry.startsWith("claudemesh-")) continue;
const full = join(tmpBase, entry);
const age = Date.now() - statSync(full).mtimeMs;
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
}
} catch { /* best effort */ }
// Clean up stale mesh MCP entries from crashed sessions
try {
const claudeConfigPath = join(homedir(), ".claude.json");
if (existsSync(claudeConfigPath)) {
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
let cleaned = 0;
for (const key of Object.keys(mcpServers)) {
if (!key.startsWith("mesh:")) continue;
const meta = mcpServers[key]?._meshSession;
if (!meta?.pid) continue;
// Check if the PID is still alive
try {
process.kill(meta.pid, 0); // signal 0 = check existence
} catch {
// PID is dead — remove stale entry
delete mcpServers[key];
cleaned++;
}
}
if (cleaned > 0) {
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
}
}
} catch { /* best effort */ }
// --- Fetch deployed services for native MCP entries ---
let serviceCatalog: Array<{
name: string;
description: string;
status: string;
tools: Array<{ name: string; description: string; inputSchema: object }>;
deployed_by: string;
}> = [];
try {
const tmpClient = new BrokerClient(mesh, { displayName });
await tmpClient.connect();
// Wait briefly for hello_ack with service catalog
await new Promise(r => setTimeout(r, 2000));
serviceCatalog = tmpClient.serviceCatalog;
tmpClient.close();
} catch {
// Non-fatal — launch without native service entries
if (!args.quiet) {
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
}
}
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
}
// --- Install native MCP entries for deployed mesh services ---
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
if (serviceCatalog.length > 0) {
const claudeConfigPath = join(homedir(), ".claude.json");
// Read-modify-write: only touch mesh:* entries in mcpServers
let claudeConfig: Record<string, unknown> = {};
try {
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
} catch {
claudeConfig = {};
}
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
// Session-scoped key: mesh:<service>:<sessionId>
const sessionTag = `${process.pid}`;
for (const svc of serviceCatalog) {
if (svc.status !== "running") continue;
const entryKey = `mesh:${svc.name}:${sessionTag}`;
const entry = {
command: "claudemesh",
args: ["mcp", "--service", svc.name],
env: {
CLAUDEMESH_CONFIG_DIR: tmpDir,
},
_meshSession: {
pid: process.pid,
meshSlug: mesh.slug,
serviceName: svc.name,
createdAt: new Date().toISOString(),
},
};
mcpServers[entryKey] = entry;
meshMcpEntries.push({ key: entryKey, entry });
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
if (!args.quiet && meshMcpEntries.length > 0) {
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
for (const { key } of meshMcpEntries) {
const svcName = key.split(":")[1];
const svc = serviceCatalog.find(s => s.name === svcName);
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
}
console.log("");
}
}
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
// Strip any user-supplied --dangerously flags to avoid duplicates.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
continue;
}
filtered.push(args.claudeArgs[i]!);
}
// --dangerously-skip-permissions is only added when the user explicitly
// passes -y / --yes. Without it, claudemesh tools still work because
// `claudemesh install` pre-approves them via allowedTools in settings.json.
// This keeps permissions tight for multi-person meshes.
// Session identity: --resume reuses existing session, otherwise generate new.
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
...(args.resume ? ["--resume", args.resume] : []),
...(args.continueSession ? ["--continue"] : []),
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
// Resolve the full path to `claude` — when launched from a non-interactive
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
const isWindows = process.platform === "win32";
let claudeBin = "claude";
if (!isWindows) {
const candidates = [
join(homedir(), ".local", "bin", "claude"),
"/usr/local/bin/claude",
join(homedir(), ".claude", "bin", "claude"),
];
for (const c of candidates) {
if (existsSync(c)) { claudeBin = c; break; }
}
}
// 7. Define cleanup — runs on every exit path via process.on('exit').
// Synchronous-only (rmSync + writeFileSync) so it works inside the
// 'exit' event, which does not allow async work.
const cleanup = (): void => {
// Remove mesh MCP entries from ~/.claude.json
if (meshMcpEntries.length > 0) {
try {
const claudeConfigPath = join(homedir(), ".claude.json");
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
for (const { key } of meshMcpEntries) {
delete mcpServers[key];
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ }
}
// Ephemeral config dir
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch { /* best effort */ }
};
// Register cleanup on every exit path — including normal exit, uncaught
// throws, and fatal signals. process.on('exit') fires synchronously, which
// is what the rmSync + writeFileSync above need.
process.on("exit", cleanup);
// 8. Hard-reset the TTY before handing control to claude.
//
// Every interactive element in the pre-launch flow — the full-screen
// wizard (tui/screen.ts), the permission confirmation, the callback-
// listener paste prompt, the mesh picker — attaches listeners to
// process.stdin, toggles raw mode, hides the cursor, and sometimes
// enters the alt-screen. Those helpers do best-effort cleanup in their
// own finally blocks, but any leak — an orphaned 'data' listener, a
// still-raw TTY, a pending render paint — means the parent node process
// keeps competing with claude's Ink TUI for the same keystrokes and
// stdout frames. Symptoms: dropped keystrokes at the claude prompt, or
// the wizard visibly repainting on top of claude after launch.
//
// Defensive reset here is cheap and guarantees a clean TTY regardless
// of what the wizard helpers did or didn't restore.
if (process.stdin.isTTY) {
try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ }
}
process.stdin.removeAllListeners("data");
process.stdin.removeAllListeners("keypress");
process.stdin.removeAllListeners("readable");
process.stdin.pause();
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?25h"); // show cursor
process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it
}
// 9. Block-and-wait on claude with spawnSync.
//
// Why spawnSync instead of spawn + child.on('exit'):
// - spawn keeps the parent node event loop running alongside claude.
// Any stray listener, setImmediate, or async wizard tail-end can
// still fire during claude's lifetime, stealing input or painting
// over claude's TUI.
// - spawnSync blocks the parent event loop completely until claude
// exits. No listeners fire. Nothing paints. The parent is effectively
// suspended, and claude has exclusive ownership of the TTY.
//
// Signal forwarding: claude inherits the TTY process group via
// stdio: "inherit". When the user hits Ctrl-C, the terminal sends
// SIGINT to the whole group. Claude handles it (Ink unmounts, exits
// cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise
// the same signal on the parent so it dies the same way.
const result = spawnSync(claudeBin, claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});
// 10. Handle the result. Cleanup runs automatically via process.on('exit').
if (result.error) {
const err = result.error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
}
process.exit(1);
}
if (result.signal) {
// Re-raise the same signal so the parent dies the same way the child did.
process.kill(process.pid, result.signal);
return;
}
process.exit(result.status ?? 0);
}

View File

@@ -1,25 +0,0 @@
/**
* `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 { readConfig, writeConfig } from "~/services/config/facade.js";
export function runLeave(args: string[]): void {
const slug = args[0];
if (!slug) {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
const config = readConfig();
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);
}
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -1,104 +0,0 @@
/**
* `claudemesh mesh list` — merged view of server + local meshes.
*/
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface ServerMesh {
id: string;
slug: string;
name: string;
role: string;
is_owner: boolean;
member_count: number;
active_peers: number;
joined_at: string;
}
export async function runList(): Promise<void> {
const config = readConfig();
const auth = getStoredToken();
// Try to fetch from server
let serverMeshes: ServerMesh[] = [];
if (auth) {
try {
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (userId) {
const res = await request<{ meshes: ServerMesh[] }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
serverMeshes = res.meshes ?? [];
}
} catch {}
}
// Merge: server meshes + local-only meshes
const localSlugs = new Set(config.meshes.map(m => m.slug));
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
if (allSlugs.size === 0) {
console.log("\n No meshes yet.\n");
console.log(" Create one: claudemesh mesh create <name>");
console.log(" Join one: claudemesh mesh add <invite-url>\n");
return;
}
console.log("\n Your meshes:\n");
for (const slug of allSlugs) {
const local = config.meshes.find(m => m.slug === slug);
const server = serverMeshes.find(m => m.slug === slug);
const name = server?.name ?? local?.name ?? slug;
const role = server?.role ?? "member";
const isOwner = server?.is_owner ?? false;
const roleLabel = isOwner ? "owner" : role;
const memberCount = server?.member_count;
const activePeers = server?.active_peers ?? 0;
// Status indicator
const inLocal = localSlugs.has(slug);
const inServer = serverSlugs.has(slug);
let status: string;
let icon: string;
if (inLocal && inServer) {
icon = green("●");
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
} else if (inLocal && !inServer) {
icon = yellow("●");
status = yellow("local only");
} else {
icon = dim("○");
status = dim("not added locally");
}
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
const parts = [roleLabel, memberInfo, status].filter(Boolean);
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
console.log(` ${parts.join(" · ")}`);
}
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
}

View File

@@ -1,74 +0,0 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Shows all meshes by default, or filter with --mesh.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
json?: boolean;
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
if (slugs.length === 0) {
render.err("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(1);
}
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
await withMesh({ meshSlug: slug }, async (client, mesh) => {
const peers = await client.listPeers();
if (flags.json) {
allJson.push({ mesh: mesh.slug, peers });
return;
}
render.section(`peers on ${mesh.slug} (${peers.length})`);
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
return;
}
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
render.info(`${statusDot} ${name}${groups}${metaStr}${summary}`);
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
}
});
} catch (e) {
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (flags.json) {
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
}
}

View File

@@ -1,114 +0,0 @@
/**
* `claudemesh profile` — view or edit your member profile.
*
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
* on the server. Changes are pushed to active sessions in real-time.
*/
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
export interface ProfileFlags {
mesh?: string;
"role-tag"?: string;
groups?: string;
"message-mode"?: string;
name?: string;
member?: string; // admin only: edit another member
json?: boolean;
}
export async function runProfile(flags: ProfileFlags): Promise<void> {
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 config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
// Pick mesh
const mesh = flags.mesh
? config.meshes.find(m => m.slug === flags.mesh)
: config.meshes[0]!;
if (!mesh) {
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
process.exit(1);
}
// Derive broker HTTP URL from WSS URL
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
if (hasEdits) {
// PATCH member profile
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
const body: Record<string, unknown> = {};
if (flags.name !== undefined) body.displayName = flags.name;
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
if (flags.groups !== undefined) {
body.groups = flags.groups.split(",").map(s => {
const [name, role] = s.trim().split(":");
return role ? { name: name!, role } : { name: name! };
});
}
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Member-Id": mesh.memberId,
},
body: JSON.stringify(body),
});
const result = await res.json() as Record<string, unknown>;
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.ok) {
console.log(green("✓ Profile updated"));
const member = result.member as Record<string, unknown>;
printProfile(member, dim);
} else {
console.error(`Error: ${result.error}`);
process.exit(1);
}
} else {
// GET members list, show current user's profile
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
if (!result.ok) {
console.error(`Error: ${result.error}`);
process.exit(1);
}
const me = result.members?.find(m => m.id === mesh.memberId);
if (flags.json) {
console.log(JSON.stringify(me ?? {}, null, 2));
} else if (me) {
printProfile(me, dim);
} else {
console.log("Member not found in mesh.");
}
}
}
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
const groupStr = groups?.length
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
: dim("(none)");
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
console.log(` Groups: ${groupStr}`);
console.log(` Messages: ${member.messageMode ?? "push"}`);
console.log(` Access: ${member.permission ?? "member"}`);
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
}

View File

@@ -1,142 +0,0 @@
/**
* `claudemesh remind <message> --in <duration> | --at <time>`
* `claudemesh remind list`
* `claudemesh remind cancel <id>`
*
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect.js";
export interface RemindFlags {
mesh?: string;
in?: string; // e.g. "2h", "30m", "90s"
at?: string; // ISO or HH:MM
cron?: string; // 5-field cron expression for recurring
to?: string; // default: self
json?: boolean;
}
function parseDuration(raw: string): number | null {
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
if (!m) return null;
const n = parseFloat(m[1]!);
const unit = (m[2] ?? "s").toLowerCase();
if (unit.startsWith("d")) return n * 86_400_000;
if (unit.startsWith("h")) return n * 3_600_000;
if (unit.startsWith("m")) return n * 60_000;
return n * 1_000;
}
function parseDeliverAt(flags: RemindFlags): number | null {
if (flags.in) {
const ms = parseDuration(flags.in);
if (ms === null) return null;
return Date.now() + ms;
}
if (flags.at) {
// Try HH:MM first
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
if (hm) {
const now = new Date();
const target = new Date(now);
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
return target.getTime();
}
const ts = Date.parse(flags.at);
return isNaN(ts) ? null : ts;
}
return null;
}
export async function runRemind(
flags: RemindFlags,
positional: string[],
): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const action = positional[0];
// claudemesh remind list
if (action === "list") {
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const scheduled = await client.listScheduled();
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
for (const m of scheduled) {
const when = new Date(m.deliverAt).toLocaleString();
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
console.log(` ${bold(m.id.slice(0, 8))}${to} at ${when}`);
console.log(` ${dim(m.message.slice(0, 80))}`);
console.log("");
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const ok = await client.cancelScheduled(id);
if (ok) console.log(`✓ Cancelled ${id}`);
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
});
return;
}
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
const message = action ?? positional.join(" ");
if (!message) {
console.error("Usage: claudemesh remind <message> --in <duration>");
console.error(" claudemesh remind <message> --at <time>");
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
console.error(" claudemesh remind list");
console.error(" claudemesh remind cancel <id>");
process.exit(1);
}
const isCron = !!flags.cron;
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
if (!isCron && deliverAt === null) {
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
process.exit(1);
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Determine target: --to flag or self
let targetSpec: string;
if (flags.to && flags.to !== "self") {
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
targetSpec = flags.to;
} else {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
if (!match) {
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
} else {
targetSpec = client.getSessionPubkey() ?? "*";
}
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
if (flags.json) { console.log(JSON.stringify(result)); return; }
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
if (isCron) {
const nextFire = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
}
});
}

View File

@@ -1,44 +0,0 @@
/**
* `claudemesh seed-test-mesh` — dev-only helper for 15b testing.
*
* Writes a locally-valid JoinedMesh entry to ~/.claudemesh/config.json
* so the MCP server can connect to a locally-running broker without
* invite-link / crypto plumbing.
*
* Usage:
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runSeedTestMesh(args: string[]): void {
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
if (!brokerUrl || !meshId || !memberId || !pubkey || !slug) {
console.error(
"Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>",
);
console.error("");
console.error(
'Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test',
);
process.exit(1);
}
const config = readConfig();
// Remove any prior entry with same slug (idempotent).
config.meshes = config.meshes.filter((m) => m.slug !== slug);
config.meshes.push({
meshId,
memberId,
slug,
name: `Test: ${slug}`,
pubkey,
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
brokerUrl,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
console.log(
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
);
}

View File

@@ -1,51 +0,0 @@
/**
* `claudemesh send <to> <message>` — send a message to a peer or group.
*
* <to> can be:
* - a display name ("Mou")
* - a pubkey hex ("abc123...")
* - @group ("@flexicar")
* - * (broadcast to all)
*/
import { withMesh } from "./connect.js";
import type { Priority } from "~/services/broker/facade.js";
export interface SendFlags {
mesh?: string;
priority?: string;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
const priority: Priority =
flags.priority === "now" ? "now"
: flags.priority === "low" ? "low"
: "next";
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Resolve display name → pubkey for direct messages.
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
// Treat as display name — look up pubkey via list_peers.
const peers = await client.listPeers();
const match = peers.find(
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
);
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
const result = await client.send(targetSpec, message, priority);
if (result.ok) {
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
} else {
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
process.exit(1);
}
});
}

View File

@@ -1,75 +0,0 @@
/**
* `claudemesh state get <key>` — read a shared state value
* `claudemesh state set <key> <value>` — write a shared state value
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect.js";
export interface StateFlags {
mesh?: string;
json?: boolean;
}
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const entry = await client.getState(key);
if (!entry) {
console.log(dim(`(not set)`));
return;
}
if (flags.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
console.log(val);
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
});
}
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${key} = ${JSON.stringify(parsed)}`);
});
}
export async function runStateList(flags: StateFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const entries = await client.listState();
if (flags.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
return;
}
for (const e of entries) {
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
console.log(`${bold(e.key)}: ${val}`);
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
}
});
}

View File

@@ -1,101 +0,0 @@
/**
* `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 { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
latencyMs?: number;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string; latencyMs?: number }> {
return new Promise((resolve) => {
const started = Date.now();
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
const latency = Date.now() - started;
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true, latencyMs: latency });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
export async function runStatus(): Promise<void> {
render.section(`status (v${VERSION})`);
const configPath = getConfigPath();
let configPermsNote = "missing";
if (existsSync(configPath)) {
const mode = (statSync(configPath).mode & 0o777).toString(8).padStart(4, "0");
configPermsNote = mode === "0600" ? `${mode}` : `${mode} — expected 0600`;
}
render.kv([["config", configPath], ["perms", configPermsNote]]);
const config = readConfig();
if (config.meshes.length === 0) {
render.blank();
render.info("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(0);
}
render.blank();
render.heading(`meshes (${config.meshes.length})`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
const probe = await probeBroker(m.brokerUrl);
const entry: MeshStatus = {
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
latencyMs: probe.latencyMs,
};
results.push(entry);
if (probe.ok) {
render.ok(`${m.slug}`, `${probe.latencyMs}ms → ${m.brokerUrl}`);
} else {
render.err(`${m.slug}`, `unreachable (${probe.error})`);
}
}
render.blank();
for (const r of results) {
render.kv([[r.slug, `${r.pubkey.slice(0, 16)}`]]);
}
const allOk = results.every((r) => r.reachable);
render.blank();
if (allOk) {
render.ok("all meshes reachable");
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
render.err(`${broken} of ${results.length} mesh(es) unreachable`);
process.exit(1);
}
}

View File

@@ -1,89 +0,0 @@
/**
* `claudemesh sync` — re-sync meshes from dashboard account.
*
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
* merges new meshes into local config.
*/
import { createInterface } from "node:readline";
import { hostname } from "node:os";
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
export async function runSync(args: { force?: boolean }): Promise<void> {
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 config = readConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`Opening browser to sync meshes...`);
console.log(dim(`Visit: ${url}`));
await openBrowser(url);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question("Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("Timed out waiting for sign-in.");
process.exit(1);
}
// Use existing keypair from first mesh, or generate new
const keypair = config.meshes.length > 0
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
: await generateKeypair();
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
// Merge: add new meshes, skip duplicates
let added = 0;
for (const m of result.meshes) {
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
added++;
}
config.accountId = result.account_id;
writeConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));
} else {
console.log(`Already up to date (${config.meshes.length} meshes)`);
}
}

View File

@@ -1,72 +0,0 @@
/**
* `claudemesh` with no args + no joined meshes → unified onboarding.
*
* One flow, one keystroke per decision. Collapses the old three-branch
* picker (signup / login / join) into a linear path:
*
* 1. Already have an invite URL? → paste it, run the bare-URL join+launch.
* (no account needed — invites are self-signed capabilities)
* 2. Else: open the browser for sign-in + mesh creation at claudemesh.com
* and fall back to paste-sync when the browser hand-off lands.
*
* The branch that used to be "register" collapses into the browser flow
* (the web handles signup + mesh creation as one wizard there).
*/
import { createInterface } from "node:readline";
import { readConfig } from "~/services/config/facade.js";
import { renderWelcome } from "~/ui/welcome/index.js";
import { login } from "./login.js";
import { render } from "~/ui/render.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
import { EXIT } from "~/constants/exit-codes.js";
function prompt(q: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
});
}
export async function runWelcome(): Promise<number> {
const config = readConfig();
if (config.meshes.length > 0) return EXIT.SUCCESS;
renderWelcome();
render.info("Do you already have an invite link? (y/n) [n]");
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
if (hasInvite) {
render.blank();
render.info("Paste your invite link (claudemesh.com/i/... or claudemesh://...)");
const raw = await prompt(" > ");
if (!raw || !isInviteUrl(raw)) {
render.err("That doesn't look like a claudemesh invite URL.");
render.hint("Check your email — the link starts with https://claudemesh.com/i/");
return EXIT.INVALID_ARGS;
}
const normalised = normaliseInviteUrl(raw);
render.blank();
render.ok(`Joining via ${normalised}`);
const { runLaunch } = await import("./launch.js");
await runLaunch(
{
join: normalised,
name: process.env.USER ?? process.env.USERNAME ?? undefined,
yes: false,
},
[],
);
return EXIT.SUCCESS;
}
// No invite → browser-first sign-in + mesh creation.
render.blank();
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
render.hint("After sign-in, paste the sync token back here when prompted.");
render.blank();
return await login();
}
export { runWelcome as _stub };

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
/**
* 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 | string[]; // peer name, pubkey, @group, or array of targets
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;
}
// --- Service deployment types ---
export type ServiceScope =
| "peer"
| "mesh"
| { peers: string[] }
| { group: string }
| { groups: string[] }
| { role: string };
export interface ServiceInfo {
name: string;
type: "mcp" | "skill";
description: string;
status: string;
tool_count: number;
deployed_by: string;
scope: ServiceScope;
source_type: string;
runtime?: string;
created_at: string;
}
export interface ServiceToolSchema {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface VaultEntry {
key: string;
entry_type: "env" | "file";
mount_path?: string;
description?: string;
updated_at: string;
}
export interface MeshMcpDeployArgs {
server_name: string;
file_id?: string;
git_url?: string;
git_branch?: string;
env?: Record<string, string>;
runtime?: "node" | "python" | "bun";
memory_mb?: number;
network_allow?: string[];
scope?: ServiceScope;
}
export interface VaultSetArgs {
key: string;
value: string;
type?: "env" | "file";
mount_path?: string;
description?: string;
}

View File

@@ -1,11 +0,0 @@
export interface MeshTemplate { name: string; description: string; groups: Array<{ name: string; roles: string[] }>; stateKeys: Record<string, string>; suggestedRoles: string[]; systemPromptHint: string; }
import { template as devTeam } from "./dev-team.js";
import { template as research } from "./research.js";
import { template as opsIncident } from "./ops-incident.js";
import { template as simulation } from "./simulation.js";
import { template as personal } from "./personal.js";
export const TEMPLATES: Record<string, MeshTemplate> = { "dev-team": devTeam, research, "ops-incident": opsIncident, simulation, personal };
export function listTemplates(): MeshTemplate[] { return Object.values(TEMPLATES); }
export function getTemplate(name: string): MeshTemplate | undefined { return TEMPLATES[name]; }

View File

@@ -1,15 +0,0 @@
{
"extends": "@turbostarter/tsconfig/base.json",
"compilerOptions": {
"lib": ["es2022"],
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
},
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
alias: {
"~": resolve(__dirname, "src"),
},
},
});

View File

@@ -1,83 +1,90 @@
# claudemesh-cli
Client tool for claudemesh — install once per machine, join one or more
meshes, and your Claude Code sessions can talk to peers on demand.
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
## Install
```sh
# From npm (once published)
npm install -g claudemesh-cli
# Or from the monorepo during dev
cd apps/cli && bun link
```bash
npm i -g claudemesh-cli
```
Then register the MCP server with Claude Code:
## Quick start
```sh
claudemesh install
# prints: claude mcp add claudemesh --scope user -- claudemesh mcp
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
Run the printed command, then restart Claude Code.
## Join a mesh
```sh
claudemesh join https://claudemesh.com/join/<token>
```
## Launch Claude Code
For real-time **push messages** from peers (messages injected mid-turn
as `<channel source="claudemesh">` system reminders), launch with:
```sh
claudemesh launch
# or pass through any claude flags:
claudemesh launch --model opus
claudemesh launch --resume
```
Under the hood this runs:
```sh
claude --dangerously-load-development-channels server:claudemesh
```
Plain `claude` still works — the MCP tools are available — but incoming
messages are **pull-only** via the `check_messages` tool instead of
being pushed to Claude immediately.
The invite link is generated by whoever runs the mesh. It bundles the
mesh id, expiry, signing key, and role. Your CLI verifies it,
generates a fresh keypair, enrolls you with the broker, and persists
the result to `~/.claudemesh/config.json`.
## Commands
```sh
claudemesh install # register MCP + status hooks
claudemesh uninstall # remove MCP + status hooks
claudemesh launch [args] # launch Claude Code with push messages enabled
claudemesh join <url> # join a mesh via invite URL
claudemesh list # show joined meshes + identities
claudemesh leave <slug> # leave a mesh
claudemesh mcp # start MCP server (stdio — Claude Code only)
claudemesh --help # show usage
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Env overrides
## Architecture
| Var | Default | Purpose |
| ----------------------- | ---------------------------- | ------------------------------ |
| `CLAUDEMESH_BROKER_URL` | `wss://ic.claudemesh.com/ws` | Point at a self-hosted broker |
| `CLAUDEMESH_CONFIG_DIR` | `~/.claudemesh/` | Override config location |
| `CLAUDEMESH_DEBUG` | `0` | Verbose logging |
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── api/ typed HTTP client for claudemesh.com
│ ├── health/ 6 diagnostic checks
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
├── mcp/ MCP server with 79 tools across 21 families
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Status
## Development
v0.1.0 scaffold — CLI commands + MCP server shell in place. WS broker
connection, libsodium crypto, invite-link verification, and auto-install
of hooks land in subsequent steps.
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

View File

@@ -1,3 +0,0 @@
import baseConfig from "@turbostarter/eslint-config/base";
export default baseConfig;

View File

@@ -1,7 +1,7 @@
{
"name": "claudemesh-cli",
"version": "0.10.6",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"version": "1.0.0-alpha.31",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
"mcp",
@@ -20,7 +20,7 @@
},
"type": "module",
"bin": {
"claudemesh": "./dist/index.js"
"claudemesh": "./dist/entrypoints/cli.js"
},
"files": [
"dist",
@@ -31,10 +31,10 @@
"access": "public"
},
"scripts": {
"build": "bun build src/index.ts --target=node --outfile dist/index.js --banner \"#!/usr/bin/env node\" && chmod +x dist/index.js",
"build": "bun build.ts",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/index.ts",
"start": "bun src/index.ts",
"dev": "bun --hot src/entrypoints/cli.ts",
"start": "bun src/entrypoints/cli.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
@@ -49,6 +49,7 @@
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"qrcode-terminal": "0.12.0",
"ws": "8.20.0",
"zod": "4.1.13"
},
@@ -58,6 +59,7 @@
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/qrcode-terminal": "0.12.2",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env bun
/**
* Full join → connect → send round-trip.
*
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
* Creates a fresh invite link, runs the join command, connects with
* the newly-generated member identity, sends a message to peer B,
* asserts receipt.
*/
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
// ESM imports hoist above statements, so we can't set process.env
// after the `import { env }` side effect has already run.
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
import { loadConfig, getConfigPath } from "../src/state/config";
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
console.error(
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
);
process.exit(1);
}
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
stdio: "ignore",
});
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerB: { memberId: string; pubkey: string; secretKey: string };
};
async function main(): Promise<void> {
// 1. Build invite.
const link = execSync("bun scripts/make-invite.ts").toString().trim();
console.log("[rt] invite:", link.slice(0, 60) + "…");
// 2. Run `claudemesh join` with the same CONFIG_DIR.
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
},
}).toString();
console.log("[rt] join output (tail):");
console.log(
joinOut
.split("\n")
.slice(-7)
.map((l) => " " + l)
.join("\n"),
);
// 3. Load the fresh config and connect as the new peer.
console.log(`[rt] loading config from: ${getConfigPath()}`);
const config = loadConfig();
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
const joined = config.meshes.find((m) => m.slug === "smoke-test");
if (!joined) throw new Error("smoke-test mesh not found in config");
const joinedMesh: JoinedMesh = joined;
console.log(
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}`,
);
// 4. Connect also as peer-B (the target) so we can observe receipt.
// Uses the real keypair from the seed (needed for crypto_box decrypt).
const targetMesh: JoinedMesh = {
...joinedMesh,
memberId: seed.peerB.memberId,
slug: "rt-join-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
const joiner = new BrokerClient(joinedMesh);
const target = new BrokerClient(targetMesh);
let received = "";
target.onPush((m) => {
received = m.plaintext ?? "";
console.log(`[rt] target got: "${received}"`);
});
await Promise.all([joiner.connect(), target.connect()]);
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
const res = await joiner.send(
seed.peerB.pubkey,
"sent-by-newly-joined-peer",
"now",
);
console.log("[rt] send result:", res);
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
joiner.close();
target.close();
if (!res.ok) {
console.error("✗ FAIL: send did not ack");
process.exit(1);
}
if (received !== "sent-by-newly-joined-peer") {
console.error(`✗ FAIL: receive mismatch: "${received}"`);
process.exit(1);
}
console.log("✓ join → connect → send → receive FLOW PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bun
/**
* Emit the signed invite link produced by the broker's seed-test-mesh.
*
* The seed script (apps/broker/scripts/seed-test-mesh.ts) creates a
* mesh with an owner keypair and a signed invite row, then writes
* both into /tmp/cli-seed.json. We just echo its inviteLink here so
* downstream test scripts can pipe it.
*/
import { readFileSync } from "node:fs";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
inviteLink: string;
};
if (!seed.inviteLink) {
console.error(
"seed missing inviteLink — re-run apps/broker/scripts/seed-test-mesh.ts",
);
process.exit(1);
}
console.log(seed.inviteLink);

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env bun
/**
* End-to-end round-trip: two BrokerClient instances talking via the
* broker. Runs against a live broker + seeded DB.
*
* Reads /tmp/cli-seed.json (output of broker's scripts/seed-test-mesh.ts),
* connects peer A and peer B, sends a message from A to B, waits for
* the push on B, asserts receipt + sender pubkey.
*/
import { readFileSync } from "node:fs";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const meshA: JoinedMesh = {
meshId: seed.meshId,
memberId: seed.peerA.memberId,
slug: "rt-a",
name: "roundtrip-a",
pubkey: seed.peerA.pubkey,
secretKey: seed.peerA.secretKey,
brokerUrl,
joinedAt: new Date().toISOString(),
};
const meshB: JoinedMesh = {
...meshA,
memberId: seed.peerB.memberId,
slug: "rt-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
async function main(): Promise<void> {
const a = new BrokerClient(meshA, { debug: true });
const b = new BrokerClient(meshB, { debug: true });
let received: string | null = null;
let receivedSender: string | null = null;
b.onPush((msg) => {
received = msg.plaintext;
receivedSender = msg.senderPubkey;
console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}`);
});
console.log("[rt] connecting A + B…");
await Promise.all([a.connect(), b.connect()]);
console.log(`[rt] A: ${a.status}, B: ${b.status}`);
console.log("[rt] A → B …");
const result = await a.send(seed.peerB.pubkey, "hello from A", "now");
console.log("[rt] send result:", result);
// Wait up to 3s for the push to land.
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
a.close();
b.close();
if (!received) {
console.error("✗ FAIL: no push received");
process.exit(1);
}
if (received !== "hello from A") {
console.error(`✗ FAIL: body mismatch: "${received}"`);
process.exit(1);
}
if (receivedSender !== seed.peerA.pubkey) {
console.error(`✗ FAIL: sender mismatch: "${receivedSender}"`);
process.exit(1);
}
console.log("✓ round-trip PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect } from "vitest";
import { encryptDirect, decryptDirect } from "../crypto/envelope";
import { generateKeypair } from "../crypto/keypair";
describe("crypto roundtrip", () => {
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const plaintext = "hello world";
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
expect(decrypted).toBe(plaintext);
});
it("Carol cannot decrypt a message encrypted for Bob", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const carol = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
expect(decrypted).toBeNull();
});
it("tampered ciphertext returns null on decrypt", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
// Flip a byte in the ciphertext
const raw = Buffer.from(envelope.ciphertext, "base64");
raw[0] = raw[0]! ^ 0xff;
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
expect(decrypted).toBeNull();
});
});

View File

@@ -1,67 +0,0 @@
import { describe, it, expect } from "vitest";
import {
parseInviteLink,
buildSignedInvite,
extractInviteToken,
} from "../invite/parse";
import { generateKeypair } from "../crypto/keypair";
describe("invite parse", () => {
it("round-trips a signed invite through encode and parse", async () => {
const owner = await generateKeypair();
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const { link, payload } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-abc-123",
mesh_slug: "test-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiresAt,
mesh_root_key: "deadbeefcafebabe",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
const parsed = await parseInviteLink(link);
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
expect(parsed.payload.mesh_slug).toBe("test-mesh");
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
expect(parsed.payload.expires_at).toBe(expiresAt);
expect(parsed.payload.role).toBe("member");
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
expect(parsed.payload.signature).toBe(payload.signature);
});
it("rejects an expired invite", async () => {
const owner = await generateKeypair();
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
const { link } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-expired",
mesh_slug: "expired-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiredAt,
mesh_root_key: "deadbeef",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
});
it("rejects malformed base64 in invite URL", async () => {
// Empty payload after ic://join/ should throw.
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
// Short garbage that doesn't match any format should throw.
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
// A sufficiently long but garbage base64url token that decodes to
// invalid JSON should throw at the JSON parse stage.
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
});
});

View File

@@ -1,90 +0,0 @@
/**
* Localhost HTTP callback listener for CLI-to-browser sync flow.
*
* Endpoints:
* GET /ping → reachability check (web page preflight)
* GET /callback → receives sync token via ?token= query param
* OPTIONS * → CORS preflight for claudemesh.com
*/
import { createServer, type Server } from "node:http";
export interface CallbackListener {
/** Port the server is listening on. */
port: number;
/** Resolves when the /callback endpoint receives a token. */
token: Promise<string>;
/** Shut down the server. */
close: () => void;
}
/**
* Start a localhost HTTP server on a random OS-assigned port.
* Returns the port and a promise that resolves with the sync token.
*/
export function startCallbackListener(): Promise<CallbackListener> {
return new Promise((resolveStart) => {
let resolveToken: (token: string) => void;
const tokenPromise = new Promise<string>((r) => {
resolveToken = r;
});
const server: Server = createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
// CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "https://claudemesh.com",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
// Reachability check — web page calls this before redirecting
if (url.pathname === "/ping") {
res.writeHead(200, {
"Content-Type": "text/plain",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end("ok");
return;
}
// Sync token callback
if (url.pathname === "/callback") {
const token = url.searchParams.get("token");
if (token) {
res.writeHead(200, {
"Content-Type": "text/html",
"Access-Control-Allow-Origin": "https://claudemesh.com",
});
res.end(
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
);
resolveToken(token);
// Close server after a short delay to ensure response is sent
setTimeout(() => server.close(), 500);
} else {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Missing token");
}
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as { port: number };
resolveStart({
port: addr.port,
token: tokenPromise,
close: () => server.close(),
});
});
});
}

View File

@@ -1,4 +0,0 @@
export { startCallbackListener, type CallbackListener } from "./callback-listener";
export { openBrowser } from "./open-browser";
export { generatePairingCode } from "./pairing-code";
export { syncWithBroker, type SyncResult } from "./sync-with-broker";

View File

@@ -1,33 +0,0 @@
/**
* Cross-platform browser opener.
* Respects BROWSER env var. Falls back to platform-specific launcher.
*/
import { exec } from "node:child_process";
/**
* Open a URL in the user's default browser.
* Returns true if the command succeeded, false otherwise.
* Non-fatal — callers should show the URL as fallback.
*/
export function openBrowser(url: string): Promise<boolean> {
// Validate URL
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return Promise.resolve(false);
}
const quoted = JSON.stringify(url);
const browserCmd = process.env.BROWSER;
const cmd = browserCmd
? `${browserCmd} ${quoted}`
: process.platform === "darwin"
? `open ${quoted}`
: process.platform === "win32"
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
: `xdg-open ${quoted}`;
return new Promise((resolve) => {
exec(cmd, (err) => resolve(!err));
});
}

View File

@@ -1,17 +0,0 @@
/**
* Generate a short pairing code for CLI-to-browser visual confirmation.
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
*/
import { randomBytes } from "node:crypto";
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
/**
* Generate a 4-character alphanumeric pairing code.
* Example output: "A3Kx", "Hn7v", "pQ4m"
*/
export function generatePairingCode(): string {
const bytes = randomBytes(4);
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
}

View File

@@ -1,83 +0,0 @@
/**
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
*
* Takes a sync JWT (from the browser callback) and a freshly generated
* ed25519 keypair. The broker creates member rows and returns mesh details.
*/
export interface SyncResult {
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
/**
* Sync meshes from dashboard via broker.
*
* @param syncToken - JWT from the browser sync flow
* @param peerPubkey - ed25519 public key hex (64 chars)
* @param displayName - display name for the new member
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
*/
export async function syncWithBroker(
syncToken: string,
peerPubkey: string,
displayName: string,
brokerBaseUrl?: string,
): Promise<SyncResult> {
// Default broker URL — derive HTTPS from WSS
const base = brokerBaseUrl ?? deriveHttpUrl(
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
);
const res = await fetch(`${base}/cli-sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sync_token: syncToken,
peer_pubkey: peerPubkey,
display_name: displayName,
}),
});
if (!res.ok) {
const body = await res.text();
let msg: string;
try {
msg = JSON.parse(body).error ?? body;
} catch {
msg = body;
}
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
}
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
if (!body.ok) {
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
}
return {
account_id: body.account_id!,
meshes: body.meshes!,
};
}
/**
* Convert a WSS broker URL to an HTTPS base URL.
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
* ws://localhost:3001/ws → http://localhost:3001
*/
function deriveHttpUrl(wssUrl: string): string {
const url = new URL(wssUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
// Remove /ws path suffix
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
// Remove trailing slash
return url.toString().replace(/\/$/, "");
}

View File

@@ -1,7 +1,7 @@
import { loadConfig } from "../state/config";
import { readConfig } from "~/services/config/facade.js";
export async function connectTelegram(args: string[]): Promise<void> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
process.exit(1);

View File

@@ -6,22 +6,47 @@
*/
import { hostname } from "node:os";
import { BrokerClient } from "../ws/client";
import { loadConfig } from "../state/config";
import type { JoinedMesh } from "../state/config";
import { createInterface } from "node:readline";
import { BrokerClient } from "~/services/broker/facade.js";
import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export interface ConnectOpts {
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
meshSlug?: string | null;
/** Display name for this session. Defaults to hostname-pid. */
displayName?: string;
/** Connect to all meshes and run fn for each. */
all?: boolean;
}
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
export async function withMesh<T>(
opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
@@ -40,10 +65,7 @@ export async function withMesh<T>(
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
console.error(
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
mesh = await pickMesh(config.meshes);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;

View File

@@ -1,39 +0,0 @@
/**
* `claudemesh create` — Create a new mesh with an optional template.
* Lists available templates if --list-templates is passed.
*/
import { listTemplates, getTemplate } from "../templates/index.js";
export function runCreate(args: Record<string, unknown>): void {
if (args["list-templates"]) {
console.log("Available mesh templates:\n");
for (const t of listTemplates()) {
console.log(` ${t.name}`);
console.log(` ${t.description}`);
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
console.log();
}
return;
}
const templateName = args.template as string | undefined;
if (templateName) {
const template = getTemplate(templateName);
if (!template) {
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
process.exit(1);
}
console.log(`Template "${template.name}" loaded:`);
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
console.log();
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
// Future: wire into actual mesh creation API
return;
}
console.log("Usage: claudemesh create --template <name>");
console.log(" claudemesh create --list-templates");
}

View File

@@ -1,3 +0,0 @@
export async function disconnectTelegram(): Promise<void> {
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
}

View File

@@ -10,8 +10,8 @@ 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";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION, URLS } from "~/constants/urls.js";
interface Check {
name: string;
@@ -110,7 +110,7 @@ function checkConfigFile(): Check {
};
}
try {
loadConfig();
readConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
@@ -132,7 +132,7 @@ function checkConfigFile(): Check {
function checkKeypairs(): Check {
try {
const cfg = loadConfig();
const cfg = readConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
@@ -172,6 +172,73 @@ function checkKeypairs(): Check {
}
}
async function checkBrokerWs(): Promise<Check> {
const wsUrl = URLS.BROKER;
const start = Date.now();
try {
const WebSocket = (await import("ws")).default;
const ws = new WebSocket(wsUrl);
const result = await new Promise<Check>((resolve) => {
const timer = setTimeout(() => {
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: `timeout after 5s (${wsUrl})`,
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
});
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
const latency = Date.now() - start;
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: true,
detail: `${latency}ms to ${wsUrl}`,
});
});
ws.once("error", (e) => {
clearTimeout(timer);
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: e.message,
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
});
});
});
return result;
} catch (e) {
return {
name: "Broker WebSocket reachable",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function checkNpmLatest(): Promise<Check> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
}
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
const up = latest === VERSION;
return {
name: "CLI up-to-date",
pass: up,
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
};
} catch {
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
}
}
export async function runDoctor(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
@@ -189,6 +256,8 @@ export async function runDoctor(): Promise<void> {
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
await checkBrokerWs(),
await checkNpmLatest(),
];
for (const c of checks) {

View File

@@ -23,6 +23,35 @@ import { readConfig } from "~/services/config/facade.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
// Mirror local grant edits to the broker so enforcement happens server-side
// as well as client-side (spec: 2026-04-15-per-peer-capabilities.md). Fails
// open — if sync fails the client filter still drops disallowed messages.
async function syncToBroker(meshSlug: string, grants: Record<string, string[] | null>): Promise<void> {
const auth = getStoredToken();
if (!auth) return;
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch { return; }
if (!userId) return;
try {
await request<{ ok: true }>({
path: `/cli/mesh/${meshSlug}/grants`,
method: "POST",
body: { user_id: userId, grants },
baseUrl: BROKER_HTTP,
});
} catch (e) {
render.warn(`broker grant sync failed — client filter still active: ${e instanceof Error ? e.message : e}`);
}
}
export type Capability =
| "read"
@@ -93,6 +122,7 @@ export async function runGrant(peer: string | undefined, caps: string[], opts: {
meshGrants[resolved.pubkey] = merged;
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: merged });
render.ok(`Granted ${wanted.join(", ")} to ${resolved.displayName} on ${mesh}.`);
render.kv([["now", merged.join(", ")]]);
@@ -117,6 +147,7 @@ export async function runRevoke(peer: string | undefined, caps: string[], opts:
meshGrants[resolved.pubkey] = after;
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: after });
render.ok(`Revoked ${wanted.join(", ")} from ${resolved.displayName} on ${mesh}.`);
render.kv([["now", after.length ? after.join(", ") : "(none)"]]);
@@ -134,6 +165,7 @@ export async function runBlock(peer: string | undefined, opts: { mesh?: string }
meshGrants[resolved.pubkey] = [];
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: [] });
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
return EXIT.SUCCESS;

View File

@@ -23,7 +23,7 @@
* in pending_status (harmless, TTL-swept).
*/
import { loadConfig } from "../state/config";
import { readConfig } from "~/services/config/facade.js";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
@@ -100,7 +100,7 @@ export async function runHook(args: string[]): Promise<void> {
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = loadConfig();
config = readConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);

View File

@@ -5,8 +5,8 @@
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect";
import type { InboundPush } from "../ws/client";
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
export interface InboxFlags {
mesh?: string;

View File

@@ -4,8 +4,8 @@
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect";
import { loadConfig } from "../state/config";
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface InfoFlags {
mesh?: string;
@@ -18,7 +18,7 @@ export async function runInfo(flags: InfoFlags): Promise<void> {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const config = loadConfig();
const config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([

View File

@@ -29,7 +29,7 @@ import { homedir, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { loadConfig } from "../state/config";
import { readConfig } from "~/services/config/facade.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
@@ -356,8 +356,25 @@ function uninstallHooks(): number {
return removed;
}
function installStatusLine(): { installed: boolean } {
const settings = readClaudeSettings();
const cmd = `claudemesh status-line`;
const current = (settings as { statusLine?: { command?: string } }).statusLine;
// If the user has their own statusLine command, don't clobber it.
if (current?.command && !current.command.includes("claudemesh status-line")) {
return { installed: false };
}
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
type: "command",
command: cmd,
};
writeClaudeSettings(settings);
return { installed: true };
}
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
@@ -452,10 +469,25 @@ export function runInstall(args: string[] = []): void {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
// Opt-in status line (shows mesh + peer count in Claude Code).
if (wantStatusLine) {
try {
const { installed } = installStatusLine();
if (installed) {
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Check if user has any meshes joined — nudge them if not.
let hasMeshes = false;
try {
const meshConfig = loadConfig();
const meshConfig = readConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
@@ -468,8 +500,8 @@ export function runInstall(args: string[] = []): void {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh join <invite-url>")}` +
dim(" — join an existing mesh"),
` ${bold("claudemesh <invite-url>")}` +
dim(" — joins + launches in one step"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
@@ -477,21 +509,15 @@ export function runInstall(args: string[] = []): void {
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
}
console.log("");
console.log(
yellow("⚠ For real-time push messages from peers, launch with:"),
);
console.log(
` ${bold("claudemesh launch")}` +
dim(" (or: claude --dangerously-load-development-channels server:claudemesh)"),
);
console.log(
dim(" Plain `claude` still works — messages are then pull-only via check_messages."),
);
console.log(dim("Optional:"));
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
console.log(dim(` claudemesh completions zsh # shell completions`));
}
export function runUninstall(): void {

View File

@@ -11,16 +11,16 @@
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
*/
import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
import { parseInviteLink } from "~/services/invite/facade.js";
import { enrollWithBroker } from "~/services/invite/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
import sodium from "libsodium-wrappers";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "../env";
import { env } from "~/constants/urls.js";
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
function deriveAppBaseUrl(): string {
@@ -73,7 +73,7 @@ async function runJoinV2(code: string): Promise<void> {
// stable short derivative of the mesh id so `list` / `launch --mesh`
// still have something to match on.
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
config.meshes.push({
meshId: claim.meshId,
@@ -87,7 +87,7 @@ async function runJoinV2(code: string): Promise<void> {
rootKey: rootKeyB64,
inviteVersion: 2,
});
saveConfig(config);
writeConfig(config);
console.log("");
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
@@ -153,7 +153,7 @@ export async function runJoin(args: string[]): Promise<void> {
}
// 4. Persist.
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== payload.mesh_slug,
);
@@ -167,7 +167,7 @@ export async function runJoin(args: string[]): Promise<void> {
brokerUrl: payload.broker_url,
joinedAt: new Date().toISOString(),
});
saveConfig(config);
writeConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");

View File

@@ -1,3 +1,4 @@
// @ts-nocheck — v1 port, runtime-tested
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
@@ -19,10 +20,11 @@ import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync,
import { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
import { BrokerClient } from "../ws/client";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
@@ -132,12 +134,12 @@ async function confirmPermissions(): Promise<void> {
import {
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
} from "../tui/colors";
} from "~/ui/styles.js";
import {
enterFullScreen, exitFullScreen, writeCentered, termSize,
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
} from "../tui/screen";
import { createSpinner, FRAME_HEIGHT } from "../tui/spinner";
} from "~/ui/screen.js";
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
interface LaunchWizardResult {
mesh: JoinedMesh;
@@ -372,7 +374,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
@@ -380,7 +382,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
@@ -394,15 +396,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
const { writeConfig } = await import("~/services/config/facade.js");
writeConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
const config = readConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
@@ -452,15 +454,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("../crypto/keypair");
const { generateKeypair } = await import("~/services/crypto/facade.js");
const keypair = await generateKeypair();
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const { syncWithBroker } = await import("../auth/sync-with-broker");
const { syncWithBroker } = await import("~/services/auth/facade.js");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { saveConfig } = await import("../state/config");
const { writeConfig } = await import("~/services/config/facade.js");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
@@ -474,7 +476,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
});
}
config.accountId = result.account_id;
saveConfig(config);
writeConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
@@ -504,13 +506,17 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}
// 3. Session identity + role/groups via TUI wizard.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
if (!args.quiet && !justSynced) {
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
// entirely and use sensible defaults (role=member, no groups, push mode).
// Same applies to `--quiet` and the post-sync path where we already picked.
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
if (!nonInteractive) {
const wizardResult = await runLaunchWizard({
displayName,
meshes: config.meshes,
@@ -526,8 +532,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
messageMode = wizardResult.messageMode;
args.skipPermConfirm = wizardResult.skipPermissions;
} else if (!mesh) {
// Quiet mode + multiple meshes — fall back to old picker
mesh = await pickMesh(config.meshes);
// No mesh picked yet + non-interactive — pick the first one deterministically.
mesh = config.meshes[0]!;
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)

View File

@@ -5,7 +5,7 @@
* best-effort revoke request before removing the entry.
*/
import { loadConfig, saveConfig } from "../state/config";
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runLeave(args: string[]): void {
const slug = args[0];
@@ -13,13 +13,13 @@ export function runLeave(args: string[]): void {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
const config = loadConfig();
const config = readConfig();
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);
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -1,30 +1,104 @@
/**
* `claudemesh list` — show all joined meshes + their status.
* `claudemesh mesh list` — merged view of server + local meshes.
*/
import { loadConfig, getConfigPath } from "../state/config";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
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 https://claudemesh.com/join/<token>",
);
console.log(`Config file: ${getConfigPath()}`);
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface ServerMesh {
id: string;
slug: string;
name: string;
role: string;
is_owner: boolean;
member_count: number;
active_peers: number;
joined_at: string;
}
export async function runList(): Promise<void> {
const config = readConfig();
const auth = getStoredToken();
// Try to fetch from server
let serverMeshes: ServerMesh[] = [];
if (auth) {
try {
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (userId) {
const res = await request<{ meshes: ServerMesh[] }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
serverMeshes = res.meshes ?? [];
}
} catch {}
}
// Merge: server meshes + local-only meshes
const localSlugs = new Set(config.meshes.map(m => m.slug));
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
if (allSlugs.size === 0) {
console.log("\n No meshes yet.\n");
console.log(" Create one: claudemesh mesh create <name>");
console.log(" Join one: claudemesh mesh add <invite-url>\n");
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("\n Your meshes:\n");
for (const slug of allSlugs) {
const local = config.meshes.find(m => m.slug === slug);
const server = serverMeshes.find(m => m.slug === slug);
const name = server?.name ?? local?.name ?? slug;
const role = server?.role ?? "member";
const isOwner = server?.is_owner ?? false;
const roleLabel = isOwner ? "owner" : role;
const memberCount = server?.member_count;
const activePeers = server?.active_peers ?? 0;
// Status indicator
const inLocal = localSlugs.has(slug);
const inServer = serverSlugs.has(slug);
let status: string;
let icon: string;
if (inLocal && inServer) {
icon = green("●");
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
} else if (inLocal && !inServer) {
icon = yellow("●");
status = yellow("local only");
} else {
icon = dim("○");
status = dim("not added locally");
}
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
const parts = [roleLabel, memberInfo, status].filter(Boolean);
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
console.log(` ${parts.join(" · ")}`);
}
console.log(`Config: ${getConfigPath()}`);
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
}

View File

@@ -1,63 +0,0 @@
/**
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
* `claudemesh recall <query>` — search mesh memory.
*
* Useful for AI agents using bash when the MCP server isn't active.
*/
import { withMesh } from "./connect";
export interface MemoryFlags {
mesh?: string;
tags?: string;
json?: boolean;
}
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
const tags = flags.tags
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
: undefined;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const id = await client.remember(content, tags);
if (flags.json) {
console.log(JSON.stringify({ id, content, tags }));
return;
}
if (id) {
console.log(`✓ Remembered (${id.slice(0, 8)})`);
} else {
console.error("✗ Failed to store memory");
process.exit(1);
}
});
}
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const memories = await client.recall(query);
if (flags.json) {
console.log(JSON.stringify(memories, null, 2));
return;
}
if (memories.length === 0) {
console.log(dim("No memories found."));
return;
}
for (const m of memories) {
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
console.log(` ${m.content}`);
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
console.log("");
}
});
}

View File

@@ -1,10 +1,13 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Connects, fetches the peer list, prints it, disconnects.
* Shows all meshes by default, or filter with --mesh.
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
@@ -12,44 +15,60 @@ export interface PeersFlags {
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const peers = await client.listPeers();
if (slugs.length === 0) {
render.err("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(1);
}
if (flags.json) {
console.log(JSON.stringify(peers, null, 2));
return;
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
await withMesh({ meshSlug: slug }, async (client, mesh) => {
const peers = await client.listPeers();
if (flags.json) {
allJson.push({ mesh: mesh.slug, peers });
return;
}
render.section(`peers on ${mesh.slug} (${peers.length})`);
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
return;
}
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
render.info(`${statusDot} ${name}${groups}${metaStr}${summary}`);
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
}
});
} catch (e) {
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (peers.length === 0) {
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
console.log("");
for (const p of peers) {
const groups = p.groups.length
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const statusIcon = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
const summary = p.summary ? dim(` ${p.summary}`) : "";
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
if (cwdStr) console.log(` ${cwdStr}`);
}
console.log("");
});
if (flags.json) {
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
}
}

View File

@@ -5,8 +5,8 @@
* on the server. Changes are pushed to active sessions in real-time.
*/
import { loadConfig } from "../state/config";
import { BrokerClient } from "../ws/client";
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
export interface ProfileFlags {
mesh?: string;
@@ -23,7 +23,7 @@ export async function runProfile(flags: ProfileFlags): Promise<void> {
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 config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);

View File

@@ -6,7 +6,7 @@
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
export interface RemindFlags {
mesh?: string;

View File

@@ -9,7 +9,7 @@
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
*/
import { loadConfig, saveConfig } from "../state/config";
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runSeedTestMesh(args: string[]): void {
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
@@ -23,7 +23,7 @@ export function runSeedTestMesh(args: string[]): void {
);
process.exit(1);
}
const config = loadConfig();
const config = readConfig();
// Remove any prior entry with same slug (idempotent).
config.meshes = config.meshes.filter((m) => m.slug !== slug);
config.meshes.push({
@@ -36,7 +36,7 @@ export function runSeedTestMesh(args: string[]): void {
brokerUrl,
joinedAt: new Date().toISOString(),
});
saveConfig(config);
writeConfig(config);
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
console.log(
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,

View File

@@ -8,8 +8,8 @@
* - * (broadcast to all)
*/
import { withMesh } from "./connect";
import type { Priority } from "../ws/client";
import { withMesh } from "./connect.js";
import type { Priority } from "~/services/broker/facade.js";
export interface SendFlags {
mesh?: string;

View File

@@ -4,7 +4,7 @@
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
export interface StateFlags {
mesh?: string;

View File

@@ -8,8 +8,9 @@
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
interface MeshStatus {
slug: string;
@@ -17,10 +18,12 @@ interface MeshStatus {
pubkey: string;
reachable: boolean;
error?: string;
latencyMs?: number;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string; latencyMs?: number }> {
return new Promise((resolve) => {
const started = Date.now();
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
@@ -28,8 +31,9 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
const latency = Date.now() - started;
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
resolve({ ok: true, latencyMs: latency });
});
ws.on("error", (err) => {
clearTimeout(timer);
@@ -39,65 +43,59 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
}
export async function runStatus(): Promise<void> {
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));
render.section(`status (v${VERSION})`);
const configPath = getConfigPath();
let configPerms = "missing";
let configPermsNote = "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)`;
const mode = (statSync(configPath).mode & 0o777).toString(8).padStart(4, "0");
configPermsNote = mode === "0600" ? `${mode}` : `${mode} — expected 0600`;
}
console.log(`Config: ${configPath} (${configPerms})`);
render.kv([["config", configPath], ["perms", configPermsNote]]);
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
render.blank();
render.info("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(0);
}
console.log("");
console.log(`Meshes (${config.meshes.length}):`);
render.blank();
render.heading(`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({
const entry: MeshStatus = {
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
latencyMs: probe.latencyMs,
};
results.push(entry);
if (probe.ok) {
console.log(green("reachable"));
render.ok(`${m.slug}`, `${probe.latencyMs}ms → ${m.brokerUrl}`);
} else {
console.log(red(`unreachable (${probe.error})`));
render.err(`${m.slug}`, `unreachable (${probe.error})`);
}
}
console.log("");
render.blank();
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
render.kv([[r.slug, `${r.pubkey.slice(0, 16)}`]]);
}
const allOk = results.every((r) => r.reachable);
console.log("");
render.blank();
if (allOk) {
console.log(green("All meshes reachable."));
render.ok("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.`));
render.err(`${broken} of ${results.length} mesh(es) unreachable`);
process.exit(1);
}
}

View File

@@ -7,16 +7,17 @@
import { createInterface } from "node:readline";
import { hostname } from "node:os";
import { loadConfig, saveConfig } from "../state/config";
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
import { generateKeypair } from "../crypto/keypair";
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
export async function runSync(args: { force?: boolean }): Promise<void> {
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 config = loadConfig();
const config = readConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
@@ -78,7 +79,7 @@ export async function runSync(args: { force?: boolean }): Promise<void> {
added++;
}
config.accountId = result.account_id;
saveConfig(config);
writeConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));

Some files were not shown because too many files have changed in this diff Show More