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

@@ -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,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(/\/$/, "");
}

30
apps/cli/src/cli/argv.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineCommand, runMain } from "citty";
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
export function parseArgv(argv: string[]): ParsedArgs {
const args = argv.slice(2);
const flags: Record<string, string | boolean | undefined> = {};
const positionals: string[] = [];
let command = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (arg.startsWith("-") && arg.length === 2) {
const key = arg.slice(1);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (!command) {
command = arg;
} else {
positionals.push(arg);
}
}
return { command, positionals, flags };
}
export { defineCommand, runMain };

7
apps/cli/src/cli/exit.ts Normal file
View File

@@ -0,0 +1,7 @@
import { EXIT } from "~/constants/exit-codes.js";
const cleanupHooks: Array<() => void> = [];
export function onExit(fn: () => void): void { cleanupHooks.push(fn); }
export function exit(code: number = EXIT.SUCCESS): never {
for (const fn of cleanupHooks) { try { fn(); } catch {} }
process.exit(code);
}

View File

@@ -0,0 +1,12 @@
import { EXIT } from "~/constants/exit-codes.js";
import { red } from "~/ui/styles.js";
export function handleUncaughtError(err: unknown): never {
const msg = err instanceof Error ? err.message : String(err);
console.error(red("\n Fatal: " + msg + "\n"));
if (process.env.CLAUDEMESH_DEBUG === "1" && err instanceof Error && err.stack) console.error(err.stack);
process.exit(EXIT.INTERNAL_ERROR);
}
export function installErrorHandlers(): void {
process.on("uncaughtException", handleUncaughtError);
process.on("unhandledRejection", (reason) => handleUncaughtError(reason));
}

View File

@@ -0,0 +1,6 @@
import { SHOW_CURSOR } from "~/ui/styles.js";
export function installSignalHandlers(): void {
const cleanup = () => { process.stdout.write(SHOW_CURSOR); };
process.on("SIGINT", () => { cleanup(); process.exit(1); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
}

View File

@@ -0,0 +1,6 @@
import type { JoinedMesh } from "~/services/config/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderMeshList(meshes: JoinedMesh[]): string {
if (meshes.length === 0) return " No meshes joined.";
return meshes.map((m, i) => " " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.meshId.slice(0, 8) + "\u2026)")).join("\n");
}

View File

@@ -0,0 +1,11 @@
import type { PeerInfo } from "~/services/broker/facade.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
const S: Record<string, (s: string) => string> = { idle: green, working: yellow, dnd: red };
export function renderPeers(peers: PeerInfo[], meshSlug: string): string {
if (peers.length === 0) return " No peers online in " + meshSlug + ".";
return peers.map(p => {
const icon = (S[p.status] ?? dim)("\u25CF");
const summary = p.summary ? dim(" \u2014 " + p.summary) : "";
return " " + icon + " " + bold(p.displayName) + summary;
}).join("\n");
}

View File

@@ -0,0 +1,3 @@
import { VERSION } from "~/constants/urls.js";
import { boldOrange } from "~/ui/styles.js";
export function renderVersion(): string { return " " + boldOrange("claudemesh") + " v" + VERSION; }

View File

@@ -0,0 +1,11 @@
import type { WhoAmIResult } from "~/services/auth/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderWhoAmI(result: WhoAmIResult): string {
if (!result.signed_in) return " Not signed in.";
const lines = [
" Signed in as " + bold(result.user!.display_name) + " (" + result.user!.email + ")",
" Token source: " + result.token_source + " " + dim("(~/.claudemesh/auth.json)"),
];
if (result.meshes) lines.push(" Meshes: " + result.meshes.owned + " owned, " + result.meshes.guest + " guest");
return lines.join("\n");
}

View File

@@ -0,0 +1,7 @@
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
export function print(msg: string): void { process.stdout.write(msg + "\n"); }
export function printErr(msg: string): void { process.stderr.write(msg + "\n"); }
export function isQuiet(): boolean { return process.argv.includes("-q") || process.argv.includes("--quiet"); }
export function isVerbose(): boolean { return process.argv.includes("-v") || process.argv.includes("--verbose"); }
export function isJson(): boolean { return process.argv.includes("--json"); }
export function isTty(): boolean { return !!isTTY; }

View File

@@ -0,0 +1,4 @@
export function jsonOutput<T>(data: T): string {
return JSON.stringify({ schema_version: "1.0", ...data }, null, 2);
}
export function writeJson<T>(data: T): void { console.log(jsonOutput(data)); }

View File

@@ -0,0 +1,11 @@
import { checkForUpdate } from "~/services/update/facade.js";
import { dim, yellow } from "~/ui/styles.js";
export async function showUpdateNotice(currentVersion: string): Promise<void> {
try {
const info = await checkForUpdate(currentVersion);
if (info.updateAvailable) {
console.error(yellow(" Update available: " + info.current + " \u2192 " + info.latest));
console.error(dim(" Run: npm i -g claudemesh-cli"));
}
} catch {}
}

View File

@@ -0,0 +1,147 @@
/**
* `claudemesh backup` — encrypt the local config and save a portable
* recovery file. Restore later with `claudemesh restore <file>` on any
* machine to recover mesh memberships.
*
* Crypto:
* - Argon2id KDF over a user passphrase → 32-byte key
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
* passphrase is still workable but brute-force remains expensive)
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
*
* Output: a single `.claudemesh-backup` file the user can store in
* 1Password, email to themselves, etc. Zero server involvement.
*
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
* passphrases shorter than 12 characters.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createInterface } from "node:readline";
import { getConfigPath } from "~/services/config/facade.js";
import { ensureSodium } from "~/services/crypto/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
const MAGIC = Buffer.from("CMB1", "utf-8");
function readHidden(prompt: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(prompt);
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// Node readline doesn't mask by default. Turn off echo manually.
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const wasRaw = Boolean(stdin.isRaw);
if (stdin.isTTY) {
stdin.setRawMode(true);
}
let buf = "";
const onData = (chunk: Buffer): void => {
const ch = chunk.toString("utf-8");
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
stdin.removeListener("data", onData);
if (stdin.isTTY) stdin.setRawMode(wasRaw);
process.stdout.write("\n");
rl.close();
resolve(buf);
return;
}
if (ch === "\u0003") { // ctrl-c
process.exit(130);
}
if (ch === "\u007f") { // backspace
buf = buf.slice(0, -1);
return;
}
buf += ch;
};
stdin.on("data", onData);
});
}
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
return s.crypto_pwhash(
32,
pass,
salt,
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
s.crypto_pwhash_ALG_ARGON2ID13,
);
}
export async function runBackup(outPath: string | undefined): Promise<number> {
const configPath = getConfigPath();
if (!existsSync(configPath)) {
console.error(" No config found — nothing to back up. Join a mesh first.");
return EXIT.NOT_FOUND;
}
const plaintext = readFileSync(configPath);
const pass = await readHidden(" Passphrase (min 12 chars): ");
if (pass.length < 12) {
console.error(" ✗ Passphrase too short.");
return EXIT.INVALID_ARGS;
}
const confirm = await readHidden(" Confirm passphrase: ");
if (confirm !== pass) {
console.error(" ✗ Passphrases did not match.");
return EXIT.INVALID_ARGS;
}
const s = await ensureSodium();
const salt = Buffer.from(s.randombytes_buf(16));
const nonce = Buffer.from(s.randombytes_buf(24));
const key = await deriveKey(pass, salt, s);
const ciphertext = Buffer.from(
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
);
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
writeFileSync(file, blob, { mode: 0o600 });
console.log(`\n ✓ Backup saved: ${file}`);
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
return EXIT.SUCCESS;
}
export async function runRestore(inPath: string | undefined): Promise<number> {
if (!inPath) {
console.error(" Usage: claudemesh restore <backup-file>");
return EXIT.INVALID_ARGS;
}
if (!existsSync(inPath)) {
console.error(` ✗ File not found: ${inPath}`);
return EXIT.NOT_FOUND;
}
const blob = readFileSync(inPath);
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
console.error(" ✗ Not a claudemesh backup file (bad magic).");
return EXIT.INVALID_ARGS;
}
const salt = blob.subarray(4, 20);
const nonce = blob.subarray(20, 44);
const ciphertext = blob.subarray(44);
const pass = await readHidden(" Passphrase: ");
const s = await ensureSodium();
const key = await deriveKey(pass, Buffer.from(salt), s);
let plaintext: Uint8Array;
try {
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
} catch {
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
return EXIT.INTERNAL_ERROR;
}
const configPath = getConfigPath();
if (existsSync(configPath)) {
const backupOld = `${configPath}.before-restore.${Date.now()}`;
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
console.log(` ↻ Existing config saved to ${backupOld}`);
}
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
console.log(`\n ✓ Config restored to ${configPath}`);
console.log(" Run `claudemesh list` to verify your meshes.\n");
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,122 @@
/**
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
*
* Users pipe it into their shell's completion system:
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
*/
import { EXIT } from "~/constants/exit-codes.js";
const COMMANDS = [
"create", "new", "join", "add", "launch", "connect", "disconnect",
"list", "ls", "delete", "rm", "rename", "share", "invite",
"peers", "send", "inbox", "state", "info",
"remember", "recall", "remind", "profile", "status",
"login", "register", "logout", "whoami",
"install", "uninstall", "doctor", "sync",
"completions", "verify", "url-handler",
"help",
];
const FLAGS = [
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
];
function bash(): string {
return `# claudemesh bash completion
_claudemesh_complete() {
local cur prev words cword
_init_completion || return
local commands="${COMMANDS.join(" ")}"
local flags="${FLAGS.join(" ")}"
if [[ \${cword} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
case "\${cur}" in
-*)
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
return 0
;;
esac
}
complete -F _claudemesh_complete claudemesh
`;
}
function zsh(): string {
return `#compdef claudemesh
# claudemesh zsh completion
_claudemesh() {
local -a commands flags
commands=(
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
)
flags=(
${FLAGS.map((f) => ` '${f}'`).join("\n")}
)
if (( CURRENT == 2 )); then
_describe 'command' commands
return
fi
case $words[2] in
join|add|launch|connect)
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
;;
share|invite)
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
;;
*)
_values 'flag' $flags
;;
esac
}
compdef _claudemesh claudemesh
`;
}
function fish(): string {
const cmdLines = COMMANDS.map(
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
).join("\n");
return `# claudemesh fish completion
${cmdLines}
complete -c claudemesh -l help -s h -d 'show help'
complete -c claudemesh -l version -s V -d 'show version'
complete -c claudemesh -l json -d 'machine-readable output'
complete -c claudemesh -l yes -s y -d 'skip confirmations'
complete -c claudemesh -l mesh -d 'mesh slug'
complete -c claudemesh -l name -d 'display name'
complete -c claudemesh -l join -d 'invite url'
`;
}
export async function runCompletions(shell: string | undefined): Promise<number> {
if (!shell) {
console.error("Usage: claudemesh completions <bash|zsh|fish>");
return EXIT.INVALID_ARGS;
}
switch (shell.toLowerCase()) {
case "bash":
process.stdout.write(bash());
return EXIT.SUCCESS;
case "zsh":
process.stdout.write(zsh());
return EXIT.SUCCESS;
case "fish":
process.stdout.write(fish());
return EXIT.SUCCESS;
default:
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
return EXIT.INVALID_ARGS;
}
}

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

@@ -0,0 +1,128 @@
import { createInterface } from "node:readline";
import { readConfig } from "~/services/config/facade.js";
import { leave as leaveMesh } from "~/services/mesh/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
});
}
function getUserId(token: string): string {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString()) as { sub?: string };
return payload.sub ?? "";
} catch { return ""; }
}
async function isOwner(slug: string, userId: string): Promise<boolean> {
try {
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
} catch { return false; }
}
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
const config = readConfig();
// Mesh picker if no slug given
if (!slug) {
if (config.meshes.length === 0) {
console.error(" No meshes to remove.");
return EXIT.NOT_FOUND;
}
console.log("\n Select mesh to remove:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
});
console.log("");
const choice = await prompt(" Choice: ");
const idx = parseInt(choice, 10) - 1;
if (idx < 0 || idx >= config.meshes.length) {
console.log(" Cancelled.");
return EXIT.USER_CANCELLED;
}
slug = config.meshes[idx]!.slug;
}
const auth = getStoredToken();
const userId = auth ? getUserId(auth.session_token) : "";
const ownerCheck = userId ? await isOwner(slug, userId) : false;
// Ask what to do
if (!opts.yes) {
console.log(`\n ${bold(slug)}\n`);
if (ownerCheck) {
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
console.log(` ${bold("3)")} Cancel`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
if (choice === "2") {
// Server-side delete — require confirmation
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
const confirm = await prompt(` Type "${slug}" to confirm: `);
if (confirm.toLowerCase() !== slug.toLowerCase()) {
console.log(" Cancelled.");
return EXIT.USER_CANCELLED;
}
try {
await request({
path: `/cli/mesh/${slug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
});
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${icons.cross} Server delete failed: ${msg}`);
}
leaveMesh(slug);
console.log(` ${green(icons.check)} Removed from local config.`);
return EXIT.SUCCESS;
}
// choice === "1" — local only, fall through
} else {
// Not owner — can only remove locally
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
console.log(` ${bold("2)")} Cancel`);
if (!ownerCheck && userId) {
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
}
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
}
}
// Local-only removal
const removed = leaveMesh(slug);
if (removed) {
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
} else {
console.error(` Mesh "${slug}" not found in local config.`);
}
return EXIT.SUCCESS;
}

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

@@ -0,0 +1,208 @@
/**
* `claudemesh grant / revoke / grants / block` — per-peer capability grants.
*
* Claudemesh's original threat model treats all mesh members as trusted, so
* every peer can send you messages and read your summary. These commands add
* a local filter: the broker still forwards messages, but the MCP server
* drops disallowed kinds before they reach Claude Code.
*
* Grants are stored in ~/.claudemesh/grants.json keyed on
* (mesh_slug, peer_pubkey). Default = read + dm (backwards-compatible).
* The `block` command sets an empty grant set (equivalent to revoke-all).
*
* Full grant-enforcement on the broker side is out of scope for this pass
* — see .artifacts/specs/2026-04-15-per-peer-capabilities.md for the
* server-side rollout plan. Client-side enforcement handles the 80% case
* (spam / noise) without needing a broker migration.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
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"
| "dm"
| "broadcast"
| "state-read"
| "state-write"
| "file-read";
const ALL_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read", "state-write", "file-read"];
const DEFAULT_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read"];
type GrantStore = Record<string, Record<string, Capability[]>>; // mesh → pubkey → caps
const GRANT_FILE = join(homedir(), ".claudemesh", "grants.json");
function readGrants(): GrantStore {
if (!existsSync(GRANT_FILE)) return {};
try {
return JSON.parse(readFileSync(GRANT_FILE, "utf-8")) as GrantStore;
} catch {
return {};
}
}
function writeGrants(g: GrantStore): void {
const dir = join(homedir(), ".claudemesh");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(GRANT_FILE, JSON.stringify(g, null, 2), { mode: 0o600 });
}
function resolveCaps(input: string[]): Capability[] {
if (input.includes("all")) return [...ALL_CAPS];
return input.filter((c): c is Capability => (ALL_CAPS as string[]).includes(c));
}
async function resolvePeer(meshSlug: string, name: string): Promise<{ displayName: string; pubkey: string } | null> {
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName === name || p.pubkey === name || p.pubkey.startsWith(name));
return match ? { displayName: match.displayName, pubkey: match.pubkey } : null;
});
}
function pickMesh(slug?: string): string | null {
const cfg = readConfig();
if (slug) return cfg.meshes.find((m) => m.slug === slug) ? slug : null;
return cfg.meshes[0]?.slug ?? null;
}
export async function runGrant(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh grant <peer> <capability...>");
render.hint(`Capabilities: ${ALL_CAPS.join(", ")}, all`);
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh — join one first."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = resolveCaps(caps);
if (wanted.length === 0) { render.err(`Unknown capabilities: ${caps.join(", ")}`); return EXIT.INVALID_ARGS; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const merged = Array.from(new Set([...existing, ...wanted]));
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(", ")]]);
return EXIT.SUCCESS;
}
export async function runRevoke(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh revoke <peer> <capability...>");
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = caps.includes("all") ? ALL_CAPS.slice() : resolveCaps(caps);
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const after = existing.filter((c) => !wanted.includes(c));
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)"]]);
return EXIT.SUCCESS;
}
export async function runBlock(peer: string | undefined, opts: { mesh?: string } = {}): Promise<number> {
if (!peer) { render.err("Usage: claudemesh block <peer>"); return EXIT.INVALID_ARGS; }
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
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;
}
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
return EXIT.SUCCESS;
}
render.section(`grants on ${mesh}`);
const peerPubkeys = Object.keys(meshGrants);
if (peerPubkeys.length === 0) {
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
return EXIT.SUCCESS;
}
await withMesh({ meshSlug: mesh }, async (client) => {
const peers = await client.listPeers();
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
for (const [pk, caps] of Object.entries(meshGrants)) {
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}`;
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
}
});
return EXIT.SUCCESS;
}
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
const store = readGrants();
const entry = store[meshSlug]?.[peerPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
return entry.includes(cap);
}

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

@@ -0,0 +1,29 @@
export { runJoin } from "./join.js";
export { newMesh } from "./new.js";
export { invite } from "./invite.js";
export { runList } from "./list.js";
export { rename } from "./rename.js";
export { runLeave } from "./leave.js";
export { runPeers } from "./peers.js";
export { runSend } from "./send.js";
export { runInbox } from "./inbox.js";
export { runStateGet, runStateSet } from "./state.js";
export { runInfo } from "./info.js";
export { remember } from "./remember.js";
export { recall } from "./recall.js";
export { runRemind } from "./remind.js";
export { runProfile } from "./profile.js";
export { runStatus } from "./status.js";
export { runDoctor } from "./doctor.js";
export { register } from "./register.js";
export { login } from "./login.js";
export { logout } from "./logout.js";
export { whoami } from "./whoami.js";
export { runInstall } from "./install.js";
export { uninstall } from "./uninstall.js";
export { runSync } from "./sync.js";
export { runWelcome } from "./welcome.js";
export { runHook } from "./hook.js";
export { runMcp } from "./mcp.js";
export { runSeedTestMesh } from "./seed-test-mesh.js";
export { withMesh } from "./connect.js";

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

@@ -0,0 +1,96 @@
import { createInterface } from "node:readline";
import { getStoredToken } from "~/services/auth/facade.js";
import { generateInvite } from "~/services/invite/generate.js";
import { readConfig } from "~/services/config/facade.js";
import { writeClipboard } from "~/services/clipboard/facade.js";
import { green, bold, dim, icons } from "~/ui/styles.js";
import { renderQrAsync } from "~/ui/qr.js";
import { EXIT } from "~/constants/exit-codes.js";
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
});
}
export async function invite(
email?: string,
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
): Promise<number> {
const auth = getStoredToken();
if (!auth) {
console.error(" Not signed in. Run `claudemesh login` first.");
return EXIT.AUTH_FAILED;
}
const config = readConfig();
if (config.meshes.length === 0) {
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
return EXIT.NOT_FOUND;
}
// Resolve which mesh to share
let meshSlug = opts.mesh;
if (!meshSlug) {
if (config.meshes.length === 1) {
meshSlug = config.meshes[0]!.slug;
} else {
// Show picker
console.log("\n Select mesh to share:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
});
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
const idx = parseInt(choice, 10) - 1;
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
}
}
try {
const result = await generateInvite(meshSlug, {
email,
expires_in: opts.expires ?? "7d",
max_uses: opts.uses,
role: opts.role,
});
const copied = writeClipboard(result.url);
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
} else {
if (email) {
if (result.emailed) {
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
} else {
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
console.log(` ${dim("Share the link manually:")}`);
console.log(` ${result.url}`);
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
}
} else {
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
console.log(` ${result.url}`);
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
const qr = await renderQrAsync(result.url, { small: true });
console.log("");
for (const line of qr.split("\n")) console.log(` ${line}`);
}
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
}
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("403") || msg.includes("permission")) {
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}
}

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

@@ -0,0 +1,118 @@
import { createInterface } from "node:readline";
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
import { my } from "~/services/api/facade.js";
import { green, dim, bold, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
import { URLS } from "~/constants/urls.js";
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
});
}
async function loginWithToken(): Promise<number> {
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
const token = await prompt(" Token: ");
if (!token) {
console.error(` ${icons.cross} No token provided.`);
return EXIT.AUTH_FAILED;
}
// Decode JWT to get user info
let user = { id: "", display_name: "", email: "" };
try {
const parts = token.split(".");
if (parts[1]) {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
sub?: string; email?: string; name?: string; exp?: number;
};
if (payload.exp && payload.exp < Date.now() / 1000) {
console.error(` ${icons.cross} Token expired. Generate a new one.`);
return EXIT.AUTH_FAILED;
}
user = {
id: payload.sub ?? "",
display_name: payload.name ?? payload.email ?? "",
email: payload.email ?? "",
};
}
} catch {
console.error(` ${icons.cross} Invalid token format.`);
return EXIT.AUTH_FAILED;
}
storeToken({ session_token: token, user, token_source: "manual" });
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
return EXIT.SUCCESS;
}
async function syncMeshes(token: string): Promise<void> {
try {
const meshes = await my.getMeshes(token);
if (meshes.length > 0) {
const names = meshes.map((m) => m.slug).join(", ");
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
}
} catch {}
}
export async function login(): Promise<number> {
const existing = getStoredToken();
if (existing) {
const name = existing.user.display_name || existing.user.email || "unknown";
console.log(`\n Already signed in as ${bold(name)}.`);
console.log("");
console.log(` ${bold("1)")} Continue as ${name}`);
console.log(` ${bold("2)")} Sign in via browser`);
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
console.log(` ${bold("4)")} Sign out`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "1") {
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
return EXIT.SUCCESS;
}
if (choice === "4") {
clearToken();
console.log(` ${green(icons.check)} Signed out.`);
return EXIT.SUCCESS;
}
if (choice === "3") {
clearToken();
return loginWithToken();
}
// choice === "2" → fall through to browser login
clearToken();
console.log(` ${dim("Signing in…")}`);
} else {
// Not logged in — show auth options
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
console.log("");
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "2") {
return loginWithToken();
}
// choice === "1" → fall through to browser login
}
try {
const result = await loginWithDeviceCode();
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
await syncMeshes(result.session_token);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -0,0 +1,22 @@
import { logout as doLogout } from "~/services/auth/facade.js";
import { green, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function logout(): Promise<number> {
try {
const { revoked } = await doLogout();
if (revoked) {
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
} else {
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
}
console.log(` ${green(icons.check)} Removed local credentials.`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -0,0 +1,9 @@
import { startMcpServer } from "~/mcp/server.js";
export async function runMcp(): Promise<never> {
await startMcpServer();
await new Promise(() => {});
process.exit(0);
}
export { runMcp as _stub };

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

@@ -0,0 +1,48 @@
import { create as createMesh } from "~/services/mesh/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { green, dim, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function newMesh(
name: string,
opts: { template?: string; description?: string; json?: boolean },
): Promise<number> {
if (!name) {
console.error(" Usage: claudemesh mesh create <name>");
return EXIT.INVALID_ARGS;
}
if (!getStoredToken()) {
console.log(dim(" Not signed in — starting login…\n"));
const { login } = await import("./login.js");
const loginResult = await login();
if (loginResult !== EXIT.SUCCESS) return loginResult;
console.log("");
}
try {
const result = await createMesh(name, {
template: opts.template,
description: opts.description,
});
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
} else {
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
console.log(` ${green(icons.check)} You're the owner`);
console.log(` ${green(icons.check)} Joined locally`);
console.log(`\n Share with: claudemesh mesh share\n`);
}
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("409") || msg.includes("already exists")) {
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}
}

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

@@ -0,0 +1,35 @@
import { allClients } from "~/services/broker/facade.js";
import { dim, bold } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function recall(
query: string,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const client = allClients()[0];
if (!client) {
console.error("Not connected to any mesh.");
return EXIT.NETWORK_ERROR;
}
const memories = await client.recall(query);
if (opts.json) {
console.log(JSON.stringify(memories, null, 2));
return EXIT.SUCCESS;
}
if (memories.length === 0) {
console.log(dim("No memories found."));
return EXIT.SUCCESS;
}
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} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
console.log("");
}
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,8 @@
import { login } from "./login.js";
// Register and login use the same device-code flow.
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
// which has a "Don't have an account? Register" link.
export async function register(): Promise<number> {
return login();
}

View File

@@ -0,0 +1,28 @@
import { allClients } from "~/services/broker/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function remember(
content: string,
opts: { mesh?: string; tags?: string; json?: boolean } = {},
): Promise<number> {
const client = allClients()[0];
if (!client) {
console.error("Not connected to any mesh.");
return EXIT.NETWORK_ERROR;
}
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
const id = await client.remember(content, tags);
if (opts.json) {
console.log(JSON.stringify({ id, content, tags }));
return EXIT.SUCCESS;
}
if (id) {
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
return EXIT.SUCCESS;
}
console.error("\u2717 Failed to store memory");
return EXIT.INTERNAL_ERROR;
}

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

@@ -0,0 +1,14 @@
import { rename as renameMesh } from "~/services/mesh/facade.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function rename(slug: string, newName: string): Promise<number> {
try {
await renameMesh(slug, newName);
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
return EXIT.INTERNAL_ERROR;
}
}

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

@@ -0,0 +1,69 @@
/**
* `claudemesh status-line` — one-line renderer for Claude Code's
* `statusLine` setting.
*
* Must be FAST (Claude Code polls it between every turn) — zero network
* I/O. Reads only local config + a peer-state cache maintained by the
* MCP server (~/.claudemesh/peer-cache.json, updated on every
* list_peers call).
*
* Output format:
* ◇ <mesh> · <online>/<total> peers · <you>
* or:
* ◇ claudemesh (not joined)
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
interface PeerCacheEntry {
total: number;
online: number;
updatedAt: string;
you?: string;
}
type PeerCache = Record<string, PeerCacheEntry>;
export async function runStatusLine(): Promise<number> {
try {
const config = readConfig();
if (config.meshes.length === 0) {
process.stdout.write("◇ claudemesh (not joined)");
return EXIT.SUCCESS;
}
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
let cache: PeerCache = {};
if (existsSync(cachePath)) {
try {
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
} catch {
// corrupt — ignore
}
}
// Pick the most-recently-used mesh if multiple.
const pick = config.meshes[0]!;
const entry = cache[pick.slug];
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
const fresh = age < 60_000; // < 1 min = live
if (entry && fresh) {
const you = entry.you ? ` · ${entry.you}` : "";
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} online${you}`);
} else if (entry) {
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} (stale)`);
} else {
process.stdout.write(`${pick.slug} · idle`);
}
return EXIT.SUCCESS;
} catch {
// Never break the status line — just print nothing.
return EXIT.SUCCESS;
}
}

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)`));

View File

@@ -0,0 +1,228 @@
/**
* `claudemesh test` — integration test battery against live broker.
*
* Creates a temporary mesh, runs all operations, verifies results,
* then cleans up. Safe to run repeatedly.
*/
import { getStoredToken } from "~/services/auth/facade.js";
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
import { readConfig } from "~/services/config/facade.js";
import { request } from "~/services/api/facade.js";
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
import { URLS } from "~/constants/urls.js";
import { runAllChecks } from "~/services/health/facade.js";
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface TestResult {
name: string;
ok: boolean;
detail: string;
ms: number;
}
const results: TestResult[] = [];
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
const start = Date.now();
try {
const detail = await fn();
results.push({ name, ok: true, detail, ms: Date.now() - start });
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
return true;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
results.push({ name, ok: false, detail, ms: Date.now() - start });
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
return false;
}
}
export async function runTest(): Promise<number> {
const started = Date.now();
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
console.log("");
console.log(` ${bold("claudemesh integration test")}`);
console.log(` ${dim("─".repeat(40))}`);
console.log("");
// --- Auth ---
const auth = getStoredToken();
if (!auth) {
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
return EXIT.AUTH_FAILED;
}
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
await run("auth", async () => {
if (!userId) throw new Error("invalid token");
return `signed in as ${auth.user.display_name || auth.user.email}`;
});
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
{
const checks = runAllChecks();
const failed = checks.filter(c => !c.ok);
if (failed.length > 0) {
const warns = failed.map(c => c.name).join(", ");
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
} else {
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
}
}
// --- Crypto ---
await run("crypto", async () => {
const kp = await generateKeypair();
const sig = await sign("test-message", kp.secretKey);
const valid = await verify("test-message", sig, kp.publicKey);
if (!valid) throw new Error("signature verification failed");
const tampered = await verify("tampered", sig, kp.publicKey);
if (tampered) throw new Error("tampered message should not verify");
return "keypair + sign + verify round-trip";
});
// --- Mesh create ---
let meshId = "";
const createOk = await run("create", async () => {
const result = await createMesh(meshSlug);
meshId = result.id;
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
});
if (!createOk) {
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
return EXIT.INTERNAL_ERROR;
}
// --- List ---
await run("list", async () => {
const config = readConfig();
const found = config.meshes.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not in local config");
return `found ${meshSlug} in local config`;
});
// --- Server list ---
await run("server list", async () => {
const res = await request<{ meshes: Array<{ slug: string }> }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
const found = res.meshes?.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not on server");
return `found ${meshSlug} on server (${res.meshes.length} total)`;
});
// --- Connect (broker WS) ---
const config = readConfig();
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
let client: BrokerClient | null = null;
if (meshConfig) {
await run("connect", async () => {
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
await client.connect();
if (client.status !== "open") throw new Error("status: " + client.status);
return "broker connected, hello_ack received";
});
// --- Peers ---
if (client) {
await run("peers", async () => {
const peers = await client!.listPeers();
return `${peers.length} peer(s) online`;
});
// --- Send ---
await run("send", async () => {
const result = await client!.send("*", "test-battery-ping", "low");
if (!result.ok) throw new Error(result.error ?? "send failed");
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
});
// --- Remember ---
let memoryId: string | null = null;
await run("remember", async () => {
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
if (!memoryId) throw new Error("no memory ID returned");
return `stored (${memoryId.slice(0, 8)}…)`;
});
// --- Recall (postgres full-text search) ---
await run("recall", async () => {
await new Promise(r => setTimeout(r, 500));
const memories = await client!.recall("integration test battery");
if (memories.length === 0) throw new Error("no memories found");
return `${memories.length} result(s)`;
});
// --- State ---
const stateVal = "test-value-" + Date.now();
await run("state set", async () => {
await client!.setState("test-e2e-key", stateVal);
return "key written";
});
await run("state get", async () => {
await new Promise(r => setTimeout(r, 500));
const result = await client!.getState("test-e2e-key");
if (!result) throw new Error("key not found");
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
return `read back: ${String(result.value).slice(0, 20)}`;
});
// --- Clean up memory ---
if (memoryId) {
await run("forget", async () => {
await client!.forget(memoryId!);
return "memory cleaned up";
});
}
// --- Disconnect ---
await run("disconnect", async () => {
client!.close();
return "connection closed";
});
}
}
// --- Delete mesh ---
await run("delete", async () => {
// Server-side delete
await request({
path: `/cli/mesh/${meshSlug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
});
leaveMesh(meshSlug);
return `deleted "${meshSlug}" from server + local`;
});
// --- Summary ---
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
const totalMs = Date.now() - started;
console.log("");
if (failed === 0) {
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
} else {
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
}
console.log("");
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
}

View File

@@ -0,0 +1,58 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function uninstall(): Promise<number> {
let removed = 0;
// Remove MCP server from ~/.claude.json
if (existsSync(PATHS.CLAUDE_JSON)) {
try {
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const servers = config.mcpServers as Record<string, unknown> | undefined;
if (servers && "claudemesh" in servers) {
delete servers.claudemesh;
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
removed++;
}
} catch {}
}
// Remove only claudemesh hooks from ~/.claude/settings.json
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
try {
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const hooks = config.hooks as Record<string, unknown[]> | undefined;
if (hooks) {
let removedHooks = 0;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
const filtered = entries.filter((h: unknown) => {
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
return !cmd.includes("claudemesh");
});
if (filtered.length < entries.length) {
removedHooks += entries.length - filtered.length;
if (filtered.length === 0) delete hooks[event];
else hooks[event] = filtered;
}
}
if (removedHooks > 0) {
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
removed++;
}
}
} catch {}
}
if (removed === 0) {
console.log(" Nothing to remove — claudemesh was not installed.");
}
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,99 @@
/**
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
*
* Strategy:
* 1. Query npm for the latest @alpha dist-tag.
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
* npm that installed us (detected from argv[1] path walk).
* 3. Print before/after versions.
*
* For users who got the CLI via the `/install` shell flow (portable Node
* in ~/.claudemesh), we call that npm directly so nothing else on the
* system is touched.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { URLS, VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
async function latestAlpha(): Promise<string | null> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
} catch {
return null;
}
}
function findNpm(): { npm: string; prefix?: string } {
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
if (existsSync(portable)) {
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
}
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
// walk up to find a sibling npm binary.
let cur = resolve(process.argv[1] ?? ".");
for (let i = 0; i < 6; i++) {
cur = dirname(cur);
const candidate = join(cur, "bin", "npm");
if (existsSync(candidate)) return { npm: candidate };
}
// Fallback to PATH.
return { npm: "npm" };
}
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
render.section("claudemesh upgrade");
render.kv([
["installed", VERSION],
["checking", "npm registry…"],
]);
const latest = await latestAlpha();
if (!latest) {
render.warn("Could not reach npm registry — skipped.");
return EXIT.SUCCESS;
}
render.kv([["latest", latest]]);
if (latest === VERSION) {
render.blank();
render.ok(`Already on latest (${latest}).`);
return EXIT.SUCCESS;
}
if (opts.check) {
render.blank();
render.warn(`Update available: ${VERSION}${latest}`);
render.hint("Run: claudemesh upgrade");
return EXIT.SUCCESS;
}
const { npm, prefix } = findNpm();
const args = ["install", "-g"];
if (prefix) args.push("--prefix", prefix);
args.push("claudemesh-cli@alpha");
render.blank();
render.info(`Updating ${VERSION}${latest}`);
render.hint(`${npm} ${args.join(" ")}`);
render.blank();
const res = spawnSync(npm, args, { stdio: "inherit" });
if (res.status !== 0) {
render.err(`npm exited with status ${res.status}`);
render.hint("Try: npm i -g claudemesh-cli@alpha");
return EXIT.INTERNAL_ERROR;
}
render.blank();
render.ok(`Upgraded to ${latest}.`);
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,178 @@
/**
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
* URL scheme handler with the OS so click-to-launch from email/web works.
*
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
* When activated, the OS opens the handler, which runs
* claudemesh https://claudemesh.com/i/<code>
* (inline join + launch path via the bare-URL dispatch in cli.ts).
*
* Platforms:
* - darwin → LSRegisterURL via a per-user .app bundle in
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
* - linux → xdg-mime default + a .desktop file in
* ~/.local/share/applications/claudemesh.desktop
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
*/
import { platform, homedir } from "node:os";
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { EXIT } from "~/constants/exit-codes.js";
function resolveClaudemeshBin(): string {
// argv[1] points to the running binary; prefer that over $PATH so we
// register the exact install the user ran.
return process.argv[1] ?? "claudemesh";
}
function installDarwin(): number {
const binPath = resolveClaudemeshBin();
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
const contents = join(appDir, "Contents");
const macOS = join(contents, "MacOS");
mkdirSync(macOS, { recursive: true });
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
<key>CFBundleName</key><string>Claudemesh</string>
<key>CFBundleExecutable</key><string>open-url</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleSignature</key><string>????</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>LSUIElement</key><true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
<key>CFBundleURLSchemes</key>
<array><string>claudemesh</string></array>
</dict>
</array>
</dict>
</plist>`;
writeFileSync(join(contents, "Info.plist"), plist);
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
// window so the user sees launch output.
const shim = `#!/bin/sh
URL="$1"
CODE=\${URL#claudemesh://}
CODE=\${CODE#i/}
CODE=\${CODE#join/}
# Open a Terminal window so the user can see claude launching
osascript <<EOF
tell application "Terminal"
activate
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
end tell
EOF
`;
const shimPath = join(macOS, "open-url");
writeFileSync(shimPath, shim);
chmodSync(shimPath, 0o755);
// Re-register with Launch Services so the scheme resolves here.
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
if (lsreg.status !== 0) {
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
}
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
console.log(` app bundle: ${appDir}`);
return EXIT.SUCCESS;
}
function installLinux(): number {
const binPath = resolveClaudemeshBin();
const appsDir = join(homedir(), ".local", "share", "applications");
mkdirSync(appsDir, { recursive: true });
const desktop = `[Desktop Entry]
Type=Application
Name=Claudemesh
Comment=Claudemesh invite handler
Exec=${binPath} %u
StartupNotify=false
Terminal=true
MimeType=x-scheme-handler/claudemesh;
NoDisplay=true
`;
const desktopPath = join(appsDir, "claudemesh.desktop");
writeFileSync(desktopPath, desktop);
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
if (xdg1.status !== 0) {
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
}
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
xdg2.status ?? 0; // best effort
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
console.log(` desktop entry: ${desktopPath}`);
return EXIT.SUCCESS;
}
function installWindows(): number {
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
const lines = [
`Windows Registry Editor Version 5.00`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
`@="URL:Claudemesh Invite"`,
`"URL Protocol"=""`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
];
const regPath = join(homedir(), "claudemesh-handler.reg");
writeFileSync(regPath, lines.join("\r\n"));
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
if (res.status !== 0) {
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
return EXIT.INTERNAL_ERROR;
}
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
return EXIT.SUCCESS;
}
function uninstallDarwin(): number {
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
console.log(" ✓ Removed claudemesh:// handler on macOS");
return EXIT.SUCCESS;
}
function uninstallLinux(): number {
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
console.log(" ✓ Removed claudemesh:// handler on Linux");
return EXIT.SUCCESS;
}
function uninstallWindows(): number {
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
console.log(" ✓ Removed claudemesh:// handler on Windows");
return EXIT.SUCCESS;
}
export async function runUrlHandler(action: string | undefined): Promise<number> {
const act = action ?? "install";
const p = platform();
if (act === "install") {
if (p === "darwin") return installDarwin();
if (p === "linux") return installLinux();
if (p === "win32") return installWindows();
} else if (act === "uninstall" || act === "remove") {
if (p === "darwin") return uninstallDarwin();
if (p === "linux") return uninstallLinux();
if (p === "win32") return uninstallWindows();
} else {
console.error("Usage: claudemesh url-handler <install|uninstall>");
return EXIT.INVALID_ARGS;
}
console.error(`Unsupported platform: ${p}`);
return EXIT.INTERNAL_ERROR;
}

View File

@@ -0,0 +1,95 @@
/**
* `claudemesh verify [peer]` — show safety numbers for a peer.
*
* A safety number is a derived, human-readable fingerprint of the peer's
* ed25519 public key plus your own. Both parties see the same digits,
* so out-of-band comparison (call, in-person) detects MITM.
*
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
* Whatsapp pattern so users don't have to learn a new mental model.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
import { createHash } from "node:crypto";
function safetyNumber(myPubkey: string, peerPubkey: string): string {
const a = Buffer.from(myPubkey, "hex");
const b = Buffer.from(peerPubkey, "hex");
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
const hash = createHash("sha256").update(lo).update(hi).digest();
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
const bits: number[] = [];
for (let i = 0; i < 15; i++) {
for (let b = 7; b >= 0; b--) {
bits.push((hash[i]! >> b) & 1);
}
}
const groups: string[] = [];
for (let g = 0; g < 6; g++) {
let val = 0;
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
groups.push(String(val % 100000).padStart(5, "0"));
}
return groups.join(" ");
}
export async function runVerify(
target: string | undefined,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
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 dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) {
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
return EXIT.NOT_FOUND;
}
const mesh = config.meshes.find((m) => m.slug === meshSlug);
if (!mesh) {
console.error(` Mesh "${meshSlug}" not found locally.`);
return EXIT.NOT_FOUND;
}
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const targets = target
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
: peers;
if (targets.length === 0) {
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
return EXIT.NOT_FOUND;
}
if (opts.json) {
console.log(JSON.stringify(targets.map((p) => ({
mesh: meshSlug,
peer: p.displayName,
pubkey: p.pubkey,
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
})), null, 2));
return EXIT.SUCCESS;
}
console.log("");
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
console.log("");
for (const p of targets) {
const sn = safetyNumber(mesh.pubkey, p.pubkey);
console.log(` ${bold(p.displayName)}`);
console.log(` ${clay(sn)}`);
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}`);
console.log("");
}
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
console.log("");
return EXIT.SUCCESS;
});
}

View File

@@ -1,111 +1,72 @@
/**
* Stateful welcome screen — shown when the user runs `claudemesh`
* with no arguments. Detects install state + joined meshes + prints
* the next action they should take.
* `claudemesh` with no args + no joined meshes → unified onboarding.
*
* States, in priority order:
* 1. MCP not registered in ~/.claude.json → run install
* 2. Config dir exists but no meshes joined → run join
* 3. Meshes joined, all reachable → run launch
* 4. Meshes joined, broker unreachable → run status / doctor
* 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 { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "../state/config";
import { VERSION } from "../version";
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";
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
function detectState(): State {
// 1. MCP registered?
const claudeConfig = join(homedir(), ".claude.json");
let mcpRegistered = false;
if (existsSync(claudeConfig)) {
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
} catch {
/* treat parse errors as not-registered */
}
}
if (!mcpRegistered) return "no-install";
// 2. Config parseable + has meshes?
try {
const cfg = loadConfig();
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
} catch {
return "broken-config";
}
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 function runWelcome(): 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 green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
export async function runWelcome(): Promise<number> {
const config = readConfig();
if (config.meshes.length > 0) return EXIT.SUCCESS;
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
console.log("─".repeat(60));
renderWelcome();
const state = detectState();
render.info("Do you already have an invite link? (y/n) [n]");
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
switch (state) {
case "no-install":
console.log("Welcome. Let's get you set up.");
console.log("");
console.log(bold("Step 1:") + " register the MCP server + status hooks");
console.log(` ${green("$")} claudemesh install`);
console.log("");
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
console.log(dim("Step 3: claudemesh launch"));
break;
case "no-meshes":
console.log(green("✓") + " MCP registered. Now join a mesh.");
console.log("");
console.log(bold("Step 2:") + " join a mesh");
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
console.log("");
console.log(
dim(" Don't have an invite? Create one at ") +
bold("https://claudemesh.com") +
dim(" or ask a mesh owner."),
);
console.log("");
console.log(dim("Step 3 (after joining): claudemesh launch"));
break;
case "ready": {
const cfg = loadConfig();
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
console.log(green("✓") + " MCP registered.");
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
console.log("");
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
console.log(` ${green("$")} claudemesh launch`);
console.log("");
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
console.log("");
console.log(dim("Health check: claudemesh status"));
console.log(dim("Diagnostics: claudemesh doctor"));
console.log(dim("All commands: claudemesh --help"));
break;
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;
}
case "broken-config":
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
console.log("");
console.log("Run diagnostics to see what's wrong:");
console.log(` ${green("$")} claudemesh doctor`);
break;
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;
}
console.log("");
// 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 };

View File

@@ -0,0 +1,26 @@
import { whoAmI } from "~/services/auth/facade.js";
import { dim, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function whoami(opts: { json?: boolean }): Promise<number> {
const result = await whoAmI();
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
return EXIT.SUCCESS;
}
if (!result.signed_in) {
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
return EXIT.AUTH_FAILED;
}
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
if (result.meshes) {
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
}
console.log();
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,14 @@
export const EXIT = {
SUCCESS: 0,
USER_CANCELLED: 1,
AUTH_FAILED: 2,
INVALID_ARGS: 3,
NETWORK_ERROR: 4,
NOT_FOUND: 5,
ALREADY_EXISTS: 6,
PERMISSION_DENIED: 7,
INTERNAL_ERROR: 8,
CLAUDE_MISSING: 9,
} as const;
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];

View File

@@ -0,0 +1,5 @@
export { EXIT } from "./exit-codes.js";
export type { ExitCode } from "./exit-codes.js";
export { PATHS } from "./paths.js";
export { URLS } from "./urls.js";
export { TIMINGS } from "./timings.js";

View File

@@ -0,0 +1,22 @@
import { homedir } from "node:os";
import { join } from "node:path";
const home = homedir();
export const PATHS = {
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
get CONFIG_FILE() {
return join(this.CONFIG_DIR, "config.json");
},
get AUTH_FILE() {
return join(this.CONFIG_DIR, "auth.json");
},
get KEYS_DIR() {
return join(this.CONFIG_DIR, "keys");
},
get LAST_USED_FILE() {
return join(this.CONFIG_DIR, "last-used.json");
},
CLAUDE_JSON: join(home, ".claude.json"),
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
} as const;

View File

@@ -0,0 +1,11 @@
export const TIMINGS = {
DEVICE_CODE_POLL_MS: 1500,
DEVICE_CODE_TIMEOUT_MS: 5 * 60 * 1000,
WS_RECONNECT_BASE_MS: 1000,
WS_RECONNECT_MAX_MS: 30_000,
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
TELEGRAM_CONNECT_TIMEOUT_MS: 5 * 60 * 1000,
TELEGRAM_POLL_INTERVAL_MS: 2000,
API_TIMEOUT_MS: 15_000,
API_RETRY_COUNT: 2,
} as const;

View File

@@ -0,0 +1,18 @@
export const URLS = {
BROKER: process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
API_BASE: process.env.CLAUDEMESH_API_URL ?? "https://claudemesh.com",
DASHBOARD: "https://claudemesh.com/dashboard",
NPM_REGISTRY: "https://registry.npmjs.org/claudemesh-cli",
} as const;
// Injected at build time from package.json#version via `bun build --define`
// (see build.ts). Falls back to a dev sentinel when running from source.
declare const __CLAUDEMESH_VERSION__: string;
export const VERSION: string =
typeof __CLAUDEMESH_VERSION__ !== "undefined" ? __CLAUDEMESH_VERSION__ : "0.0.0-dev";
export const env = {
CLAUDEMESH_BROKER_URL: URLS.BROKER,
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};

View File

@@ -1,96 +0,0 @@
/**
* Direct-message encryption via libsodium crypto_box.
*
* Keys: our peers hold ed25519 signing keypairs (from Step 17).
* crypto_box uses X25519 (curve25519) keys, so we convert on the fly
* via crypto_sign_ed25519_{pk,sk}_to_curve25519. One signing keypair
* serves both purposes cleanly.
*
* Wire format: {nonce, ciphertext} both base64. Nonce is 24 bytes
* (crypto_box_NONCEBYTES), fresh-random per message.
*
* Broadcasts ("*") and channels ("#foo") are NOT encrypted here —
* they need a shared key (mesh_root_key) and land in a later step.
*/
import { ensureSodium } from "./keypair";
export interface Envelope {
nonce: string; // base64
ciphertext: string; // base64
}
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
/** Does this targetSpec look like a direct-message pubkey? */
export function isDirectTarget(targetSpec: string): boolean {
return HEX_PUBKEY.test(targetSpec);
}
/**
* Encrypt a plaintext message addressed to a single recipient.
* Recipient's ed25519 pubkey (64 hex chars) is converted to X25519
* on the fly. Sender's full ed25519 secret key (128 hex chars) is
* also converted.
*/
export async function encryptDirect(
message: string,
recipientPubkeyHex: string,
senderSecretKeyHex: string,
): Promise<Envelope> {
const sodium = await ensureSodium();
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(senderSecretKeyHex),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const ciphertext = sodium.crypto_box_easy(
sodium.from_string(message),
nonce,
recipientPub,
senderSec,
);
return {
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
};
}
/**
* Decrypt an inbound envelope from a known sender. Returns null if
* decryption fails (wrong keys, tampered ciphertext, malformed input).
*/
export async function decryptDirect(
envelope: Envelope,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const sodium = await ensureSodium();
try {
const senderPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(senderPubkeyHex),
);
const recipientSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(recipientSecretKeyHex),
);
const nonce = sodium.from_base64(
envelope.nonce,
sodium.base64_variants.ORIGINAL,
);
const ciphertext = sodium.from_base64(
envelope.ciphertext,
sodium.base64_variants.ORIGINAL,
);
const plain = sodium.crypto_box_open_easy(
ciphertext,
nonce,
senderPub,
recipientSec,
);
return sodium.to_string(plain);
} catch {
return null;
}
}

View File

@@ -1,90 +0,0 @@
/**
* File encryption for claudemesh E2E file sharing.
*
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
* Key opening: crypto_box_seal_open with own X25519 keypair.
*/
import { ensureSodium } from "./keypair";
export interface EncryptedFile {
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
nonce: string; // base64 24-byte nonce
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
}
/**
* Encrypt file bytes with a fresh random symmetric key.
* Returns ciphertext, nonce (base64), and the plaintext Kf.
*/
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
const sodium = await ensureSodium();
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return {
ciphertext,
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
key,
};
}
/**
* Decrypt file bytes with the symmetric key Kf.
* Returns null if decryption fails.
*/
export async function decryptFile(
ciphertext: Uint8Array,
nonceB64: string,
key: Uint8Array,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
} catch {
return null;
}
}
/**
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
* Returns base64 sealed box.
*/
export async function sealKeyForPeer(
kf: Uint8Array,
recipientPubkeyHex: string,
): Promise<string> {
const sodium = await ensureSodium();
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
}
/**
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
* Returns the 32-byte Kf or null if decryption fails.
*/
export async function openSealedKey(
sealedB64: string,
myPubkeyHex: string,
mySecretKeyHex: string,
): Promise<Uint8Array | null> {
const sodium = await ensureSodium();
try {
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(myPubkeyHex),
);
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(mySecretKeyHex),
);
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
} catch {
return null;
}
}

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env node
import { parseArgv } from "~/cli/argv.js";
import { installSignalHandlers } from "~/cli/handlers/signal.js";
import { installErrorHandlers } from "~/cli/handlers/error.js";
import { showUpdateNotice } from "~/cli/update-notice.js";
import { VERSION } from "~/constants/urls.js";
import { EXIT } from "~/constants/exit-codes.js";
import { renderVersion } from "~/cli/output/version.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
installSignalHandlers();
installErrorHandlers();
const { command, positionals, flags } = parseArgv(process.argv);
const HELP = `
claudemesh — peer mesh for Claude Code sessions
${VERSION}
USAGE
claudemesh auto-connect to your mesh
claudemesh <invite-url> join a mesh, then launch
claudemesh launch --name <n> --join <url> join + launch in one step
Mesh
claudemesh create <name> create a new mesh
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
claudemesh list show your meshes (alias: ls)
claudemesh delete [slug] delete a mesh (alias: rm)
claudemesh rename <slug> <name> rename a mesh
claudemesh share [email] share mesh (invite link / send email)
Messaging
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state get|set|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 info mesh overview
Auth
claudemesh login sign in (browser or paste token)
claudemesh register create account + sign in
claudemesh logout sign out
claudemesh whoami show current identity
Security
claudemesh verify [peer] show ed25519 safety numbers (SAS)
claudemesh grant <peer> <cap> grant capability (dm, broadcast, state-read, all)
claudemesh revoke <peer> <cap> revoke capability (or 'all')
claudemesh block <peer> revoke all capabilities (silent drop)
claudemesh grants list per-peer overrides for current mesh
claudemesh backup [file] encrypt config → portable recovery file
claudemesh restore <file> restore config from a backup file
Setup
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
claudemesh doctor diagnose issues (broker, node, claude)
claudemesh status check broker connectivity
claudemesh sync refresh mesh list from dashboard
claudemesh completions <shell> emit bash / zsh / fish completion script
claudemesh url-handler install register claudemesh:// click-to-launch
claudemesh upgrade self-update to latest alpha (rustup-style)
Flags
--version, -V show version
--help, -h show this help
--json machine-readable output
--mesh <slug> override mesh selection
-y, --yes skip confirmations
-q, --quiet suppress non-essential output
`;
async function main(): Promise<void> {
if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); }
if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); }
// Bare command or invite URL
if (!command || isInviteUrl(command)) {
// `claudemesh <invite-url>` → join + launch in one step.
// `-y` skips all interactive prompts (role=member, no groups, push mode).
if (command && isInviteUrl(command)) {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
join: normaliseInviteUrl(command),
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
const { readConfig } = await import("~/services/config/facade.js");
const config = readConfig();
if (config.meshes.length === 0) {
const { runWelcome } = await import("~/commands/welcome.js");
process.exit(await runWelcome());
}
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
switch (command) {
case "help": { console.log(HELP); break; }
// Mesh management
case "create": case "new": { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[0] ?? "", { json: !!flags.json })); break; }
case "add": case "join": { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals); break; }
case "connect": case "launch": {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: positionals[0] ?? flags.mesh as string,
name: flags.name as string,
join: flags.join as string,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string,
}, process.argv.slice(2));
break;
}
case "disconnect": { console.log(" Connection closed."); process.exit(EXIT.SUCCESS); break; }
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
case "state": {
const sub = positionals[0];
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); }
else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
break;
}
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; }
case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; }
case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; }
case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; }
case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; }
// Auth
case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; }
case "register": { const { register } = await import("~/commands/register.js"); process.exit(await register()); break; }
case "logout": { const { logout } = await import("~/commands/logout.js"); process.exit(await logout()); break; }
case "whoami": { const { whoami } = await import("~/commands/whoami.js"); process.exit(await whoami({ json: !!flags.json })); break; }
// Setup
case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; }
case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; }
case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; }
case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; }
case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; }
// Test
case "test": { const { runTest } = await import("~/commands/test.js"); process.exit(await runTest()); break; }
// CLI utilities
case "completions": { const { runCompletions } = await import("~/commands/completions.js"); process.exit(await runCompletions(positionals[0])); break; }
case "verify": { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(positionals[0], { mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
case "url-handler": { const { runUrlHandler } = await import("~/commands/url-handler.js"); process.exit(await runUrlHandler(positionals[0])); break; }
case "status-line": { const { runStatusLine } = await import("~/commands/status-line.js"); process.exit(await runStatusLine()); break; }
case "backup": { const { runBackup } = await import("~/commands/backup.js"); process.exit(await runBackup(positionals[0])); break; }
case "restore": { const { runRestore } = await import("~/commands/backup.js"); process.exit(await runRestore(positionals[0])); break; }
case "upgrade": case "update": { const { runUpgrade } = await import("~/commands/upgrade.js"); process.exit(await runUpgrade({ check: !!flags.check, yes: !!flags.y || !!flags.yes })); break; }
case "grant": { const { runGrant } = await import("~/commands/grants.js"); process.exit(await runGrant(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "revoke": { const { runRevoke } = await import("~/commands/grants.js"); process.exit(await runRevoke(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; }
case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
// Internal
case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; }
case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; }
case "seed-test-mesh": { const { runSeedTestMesh } = await import("~/commands/seed-test-mesh.js"); runSeedTestMesh(positionals); break; }
default: {
console.error(` Unknown command: ${command}. Run \`claudemesh --help\` for usage.`);
process.exit(EXIT.INVALID_ARGS);
}
}
showUpdateNotice(VERSION).catch(() => {});
}
main().catch((err) => {
console.error("Fatal: " + (err instanceof Error ? err.message : String(err)));
process.exit(EXIT.INTERNAL_ERROR);
});

View File

@@ -0,0 +1,6 @@
import { startMcpServer } from "~/mcp/server.js";
startMcpServer().catch((err) => {
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : err}\n`);
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
/**
* CLI environment config.
*
* Read once at startup. Overridable via env vars so users can point
* at a self-hosted broker or a staging instance without rebuilding.
*/
export interface CliEnv {
CLAUDEMESH_BROKER_URL: string;
CLAUDEMESH_CONFIG_DIR: string | undefined;
CLAUDEMESH_DEBUG: boolean;
}
export function loadEnv(): CliEnv {
return {
CLAUDEMESH_BROKER_URL:
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};
}
export const env = loadEnv();

View File

@@ -1,357 +0,0 @@
/**
* claudemesh-cli entry point.
*
* Uses citty to define commands and flags. --help is generated from
* the command definitions — the flag list here IS the documentation.
*
* Dispatches between two modes:
* - `claudemesh mcp` → MCP server (stdio transport)
* - `claudemesh <subcommand>` → CLI subcommand
*/
import { defineCommand, runMain } from "citty";
import { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
import { runList } from "./commands/list";
import { runLeave } from "./commands/leave";
import { runSeedTestMesh } from "./commands/seed-test-mesh";
import { runHook } from "./commands/hook";
import { runLaunch } from "./commands/launch";
import { runStatus } from "./commands/status";
import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
import { runPeers } from "./commands/peers";
import { runSend } from "./commands/send";
import { runInbox } from "./commands/inbox";
import { runStateGet, runStateSet, runStateList } from "./commands/state";
import { runRemember, runRecall } from "./commands/memory";
import { runInfo } from "./commands/info";
import { runRemind } from "./commands/remind";
import { runCreate } from "./commands/create";
import { runSync } from "./commands/sync";
import { runProfile, type ProfileFlags } from "./commands/profile";
import { connectTelegram } from "./commands/connect-telegram";
import { disconnectTelegram } from "./commands/disconnect-telegram";
import { VERSION } from "./version";
const launch = defineCommand({
meta: {
name: "launch",
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
},
args: {
name: {
type: "string",
description: "Display name visible to other peers",
},
role: {
type: "string",
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
},
groups: {
type: "string",
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
},
mesh: {
type: "string",
description: "Mesh slug (interactive picker if omitted and >1 joined)",
},
join: {
type: "string",
description: "Join a mesh via invite URL before launching",
},
"message-mode": {
type: "string",
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
},
"system-prompt": {
type: "string",
description: "Custom system prompt for this Claude session",
},
yes: {
type: "boolean",
alias: "y",
description: "Skip the --dangerously-skip-permissions confirmation",
default: false,
},
resume: {
type: "string",
alias: "r",
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
},
continue: {
type: "boolean",
alias: "c",
description: "Continue the most recent conversation in this directory",
default: false,
},
quiet: {
type: "boolean",
description: "Suppress banner and interactive prompts",
default: false,
},
},
run({ args, rawArgs }) {
// Forward to the existing launch runner, preserving -- passthrough to claude.
return runLaunch(args, rawArgs);
},
});
const install = defineCommand({
meta: {
name: "install",
description: "Register MCP server and status hooks with Claude Code",
},
args: {
"no-hooks": {
type: "boolean",
description: "Register MCP server only, skip hooks",
default: false,
},
},
run({ rawArgs }) {
runInstall(rawArgs);
},
});
const join = defineCommand({
meta: {
name: "join",
description: "Join a mesh via invite URL or token",
},
args: {
url: {
type: "positional",
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
required: true,
},
},
run({ args }) {
return runJoin([args.url]);
},
});
const leave = defineCommand({
meta: {
name: "leave",
description: "Leave a joined mesh and remove its local keypair",
},
args: {
slug: {
type: "positional",
description: "Mesh slug to leave (see `claudemesh list`)",
required: true,
},
},
run({ args }) {
runLeave([args.slug]);
},
});
const main = defineCommand({
meta: {
name: "claudemesh",
version: VERSION,
description: "Peer mesh for Claude Code sessions",
},
subCommands: {
launch,
create: defineCommand({
meta: { name: "create", description: "Create a new mesh from a template" },
args: {
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
},
run({ args }) { runCreate(args); },
}),
install,
uninstall: defineCommand({
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
run() { runUninstall(); },
}),
join,
list: defineCommand({
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
run() { runList(); },
}),
leave,
peers: defineCommand({
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runPeers(args); },
}),
send: defineCommand({
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
args: {
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
message: { type: "positional", description: "Message text", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
},
async run({ args }) { await runSend(args, args.to, args.message); },
}),
inbox: defineCommand({
meta: { name: "inbox", description: "Drain pending inbound messages" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
},
async run({ args }) {
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
},
}),
state: defineCommand({
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
args: {
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
key: { type: "positional", description: "State key (required for `get` and `set`)" },
value: { type: "positional", description: "Value to store (required for `set`)" },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
if (args.action === "list") {
await runStateList(args);
} else if (args.action === "get") {
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
await runStateGet(args, args.key);
} else if (args.action === "set") {
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
await runStateSet(args, args.key, args.value);
} else {
console.error(`Unknown action "${args.action}". Use: get, set, list`);
process.exit(1);
}
},
}),
info: defineCommand({
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runInfo(args); },
}),
remember: defineCommand({
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
args: {
content: { type: "positional", description: "Text to store", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runRemember(args, args.content); },
}),
recall: defineCommand({
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
args: {
query: { type: "positional", description: "Full-text search query", required: true },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runRecall(args, args.query); },
}),
remind: defineCommand({
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
args: {
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args, rawArgs }) {
// Collect positional args from rawArgs (before any flags)
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
await runRemind(args, positionals);
},
}),
sync: defineCommand({
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
args: {
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
},
async run({ args }) { await runSync(args); },
}),
profile: defineCommand({
meta: { name: "profile", description: "View or edit your member profile" },
args: {
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
name: { type: "string", description: "Set display name" },
member: { type: "string", description: "Edit another member (admin only)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) { await runProfile(args as ProfileFlags); },
}),
status: defineCommand({
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
async run() { await runStatus(); },
}),
doctor: defineCommand({
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
async run() { await runDoctor(); },
}),
mcp: defineCommand({
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
async run() { await startMcpServer(); },
}),
"seed-test-mesh": defineCommand({
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
}),
hook: defineCommand({
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
async run({ rawArgs }) { await runHook(rawArgs); },
}),
connect: defineCommand({
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
async run({ args }) {
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
},
}),
disconnect: defineCommand({
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
async run({ args }) {
if (args.target === "telegram") await disconnectTelegram();
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
},
}),
},
async run() {
await runWelcome();
},
});
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
// every flag-only form as implicit `launch`.
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
// Flags citty handles on the root command — must not be rewritten to `launch`.
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
const argv = process.argv.slice(2);
const first = argv[0];
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
// Starts with a flag, or an unknown bareword → treat as launch args.
// (Unknown barewords that look like typos would otherwise hit citty's
// "unknown command" path; forwarding to launch lets claude surface the
// error if it's a real claude flag, and launch's own parser rejects junk.)
process.argv.splice(2, 0, "launch");
}
runMain(main);

View File

@@ -0,0 +1,10 @@
export const en = {
welcome: "Welcome to claudemesh",
signed_in_as: "Signed in as {name}",
mesh_created: 'Created "{slug}"',
invite_copied: "Invite URL copied to clipboard",
logout_success: "Signed out",
error_network: "Could not reach {url}. Check your connection.",
error_auth: "Authentication failed. Run \`claudemesh login\` to sign in.",
error_not_found: "{resource} not found",
} as const;

View File

@@ -0,0 +1 @@
export { en } from "./en.js";

View File

@@ -0,0 +1 @@
export { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

View File

@@ -0,0 +1 @@
export { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

View File

@@ -0,0 +1 @@
export function formatToolError(err: unknown): string { return err instanceof Error ? err.message : String(err); }

View File

@@ -0,0 +1,3 @@
export function logToolCall(toolName: string, durationMs: number): void {
if (process.env.CLAUDEMESH_DEBUG === "1") process.stderr.write("[mcp] " + toolName + " (" + durationMs + "ms)\n");
}

View File

@@ -0,0 +1,2 @@
// Tool dispatch — server.ts handles all routing via switch statement.
export const ROUTER_VERSION = "1.0" as const;

View File

@@ -15,9 +15,10 @@ import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { TOOLS } from "./tools";
import { loadConfig } from "../state/config";
import { startClients, stopAll, findClient, allClients } from "../ws/manager";
import { TOOLS } from "./tools/definitions.js";
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient, startClients, stopAll, findClient, allClients } from "~/services/broker/facade.js";
import type { InboundPush } from "~/services/broker/facade.js";
import type {
Priority,
PeerStatus,
@@ -25,9 +26,7 @@ import type {
SetStatusArgs,
SetSummaryArgs,
ListPeersArgs,
} from "./types";
import { BrokerClient } from "../ws/client";
import type { InboundPush } from "../ws/client";
} from "./types.js";
/** Compute a human-readable relative time string from an ISO timestamp. */
function relativeTime(isoStr: string): string {
@@ -105,6 +104,7 @@ async function resolveClient(to: string): Promise<{
p.displayName.toLowerCase().includes(nameLower),
);
if (partials.length === 1) {
process.stderr.write(`[claudemesh] resolved "${target}" → "${partials[0]!.displayName}" (partial match)\n`);
return { client: c, targetSpec: partials[0]!.pubkey };
}
}
@@ -155,7 +155,7 @@ export async function startMcpServer(): Promise<void> {
return startServiceProxy(process.argv[serviceIdx + 1]!);
}
const config = loadConfig();
const config = readConfig();
const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
@@ -433,7 +433,7 @@ Your message mode is "${messageMode}".
switch (name) {
case "send_message": {
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
const { to, message, priority } = (args ?? {}) as unknown as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
@@ -477,9 +477,17 @@ Your message mode is "${messageMode}".
true,
);
const sections: string[] = [];
// Keep the status-line cache fresh for Claude Code's statusLine renderer.
const statusCache: Record<string, { total: number; online: number; updatedAt: string; you?: string }> = {};
for (const c of clients) {
const peers = await c!.listPeers();
const header = `## ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`;
statusCache[c!.meshSlug] = {
total: peers.length,
online: peers.filter(p => p.status !== "offline").length,
updatedAt: new Date().toISOString(),
you: process.env.CLAUDEMESH_DISPLAY_NAME ?? undefined,
};
if (peers.length === 0) {
sections.push(`${header}\nNo peers connected.`);
} else {
@@ -502,6 +510,15 @@ Your message mode is "${messageMode}".
sections.push(`${header}\n${peerLines.join("\n")}`);
}
}
// Persist the peer-cache for claudemesh status-line. Best effort.
try {
const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
const { join: joinPath } = await import("node:path");
const { homedir } = await import("node:os");
const dir = joinPath(homedir(), ".claudemesh");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(joinPath(dir, "peer-cache.json"), JSON.stringify(statusCache));
} catch { /* non-fatal */ }
return text(sections.join("\n\n"));
}
@@ -542,7 +559,7 @@ Your message mode is "${messageMode}".
}
case "set_summary": {
const { summary } = (args ?? {}) as SetSummaryArgs;
const { summary } = (args ?? {}) as unknown as SetSummaryArgs;
if (!summary) return text("set_summary: `summary` required", true);
for (const c of allClients()) await c.setSummary(summary);
return text(
@@ -551,7 +568,7 @@ Your message mode is "${messageMode}".
}
case "set_status": {
const { status } = (args ?? {}) as SetStatusArgs;
const { status } = (args ?? {}) as unknown as SetStatusArgs;
if (!status) return text("set_status: `status` required", true);
const s = status as PeerStatus;
for (const c of allClients()) await c.setStatus(s);
@@ -654,6 +671,8 @@ Your message mode is "${messageMode}".
cron?: string;
};
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
const client = allClients()[0];
if (!client) return text("schedule_reminder: not connected", true);
const isCron = !!sArgs.cron;
@@ -710,6 +729,8 @@ Your message mode is "${messageMode}".
);
}
case "list_scheduled": {
const client = allClients()[0];
if (!client) return text("list_scheduled: not connected", true);
const scheduled = await client.listScheduled();
if (scheduled.length === 0) return text("No pending scheduled messages.");
const lines = scheduled.map((m) =>
@@ -718,6 +739,8 @@ Your message mode is "${messageMode}".
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
}
case "cancel_scheduled": {
const client = allClients()[0];
if (!client) return text("cancel_scheduled: not connected", true);
const { id: schedId } = (args ?? {}) as { id?: string };
if (!schedId) return text("cancel_scheduled: `id` required", true);
const ok = await client.cancelScheduled(schedId);
@@ -735,7 +758,7 @@ Your message mode is "${messageMode}".
// If 'to' specified, do E2E encryption
if (fileTo) {
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join, basename } = await import("node:path");
@@ -764,14 +787,15 @@ Your message mode is "${messageMode}".
];
// Build combined buffer: nonce (24 bytes) + ciphertext
const { ensureSodium } = await import("../crypto/keypair");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
combined.set(nonceBytes, 0);
combined.set(ciphertext, nonceBytes.length);
const baseName = fileName ?? basename(filePath);
const rawName = fileName ?? basename(filePath);
const baseName = basename(rawName).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255);
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
const tmpPath = join(tmpDir, baseName);
writeFileSync(tmpPath, combined);
@@ -814,32 +838,33 @@ Your message mode is "${messageMode}".
if (!result) return text(`get_file: file ${id} not found`, true);
if (result.encrypted) {
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const genericErr = "get_file: could not decrypt — you may not have access to this file";
if (!result.sealedKey) return text(genericErr, true);
const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) {
return text("get_file: no session keypair — cannot decrypt", true);
}
if (!myPubkey || !mySecret) return text(genericErr, true);
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("get_file: failed to open sealed key", true);
if (!kf) return text(genericErr, true);
// Download file bytes from presigned URL
const MAX_DOWNLOAD = 100 * 1024 * 1024; // 100 MB
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
const contentLength = parseInt(resp.headers.get("content-length") ?? "0", 10);
if (contentLength > MAX_DOWNLOAD) return text(`get_file: file too large (${contentLength} bytes)`, true);
const buf = new Uint8Array(await resp.arrayBuffer());
if (buf.length > MAX_DOWNLOAD) return text(`get_file: file too large (${buf.length} bytes)`, true);
// Wire format: first 24 bytes = nonce, rest = ciphertext
const sodium = await ensureSodium();
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES;
if (buf.length < NONCE_BYTES) return text(genericErr, true);
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
const ciphertext = buf.slice(NONCE_BYTES);
const plaintext = await decryptFile(ciphertext, nonce, kf);
if (!plaintext) return text("get_file: decryption failed", true);
if (!plaintext) return text(genericErr, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
@@ -852,7 +877,7 @@ Your message mode is "${messageMode}".
let res = await fetch(result.url, { signal: AbortSignal.timeout(10_000) }).catch(() => null);
if (!res || !res.ok) {
// Presigned URL failed (internal MinIO hostname) — use broker proxy
const brokerHttp = client.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
const brokerHttp = client.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
res = await fetch(`${brokerHttp}/download/${id}?mesh=${client.meshId}`, { signal: AbortSignal.timeout(30_000) });
}
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
@@ -1379,7 +1404,7 @@ Your message mode is "${messageMode}".
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
const { openSealedKey, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
@@ -1512,6 +1537,9 @@ Your message mode is "${messageMode}".
key?: string; value?: string; type?: "env" | "file"; mount_path?: string; description?: string;
};
if (!key || !value) return text("vault_set: `key` and `value` required", true);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(key)) return text("vault_set: `key` must be 1-128 alphanumeric/underscore/dot/dash chars", true);
if (mount_path && (mount_path.includes("..") || mount_path.length > 512)) return text("vault_set: invalid `mount_path`", true);
if (description && description.length > 500) return text("vault_set: `description` too long (max 500 chars)", true);
const client = allClients()[0];
if (!client) return text("vault_set: not connected", true);
const entryType = vType ?? "env";
@@ -1527,12 +1555,12 @@ Your message mode is "${messageMode}".
}
// E2E encrypt: crypto_secretbox with random Kf, then seal Kf with mesh pubkey
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const { ciphertext, nonce, key: kf } = await encryptFile(plaintextBytes);
const sealedKey = await sealKeyForPeer(kf, client.getMeshPubkey());
// Convert ciphertext to base64 for storage
const { ensureSodium } = await import("../crypto/keypair");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const ciphertextB64 = sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
@@ -1597,8 +1625,8 @@ Your message mode is "${messageMode}".
// Fetch + decrypt vault entries client-side
if (vaultRefs.length > 0) {
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const keys = vaultRefs.map(r => r.vaultKey);
@@ -1606,15 +1634,15 @@ Your message mode is "${messageMode}".
for (const ref of vaultRefs) {
const entry = encryptedEntries.find((e: any) => e.key === ref.vaultKey);
if (!entry) return text(`mesh_mcp_deploy: vault key "${ref.vaultKey}" not found. Use vault_set first.`, true);
if (!entry) return text(`mesh_mcp_deploy: a referenced vault key was not found. Use vault_set first.`, true);
// Decrypt: open sealed key with mesh keypair, then decrypt ciphertext
const kf = await openSealedKey(entry.sealed_key, client.getMeshPubkey(), client.getMeshSecretKey());
if (!kf) return text(`mesh_mcp_deploy: failed to decrypt vault key "${ref.vaultKey}" — wrong keypair?`, true);
if (!kf) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — wrong keypair?`, true);
const ciphertextBytes = sodium.from_base64(entry.ciphertext, sodium.base64_variants.ORIGINAL);
const plainBytes = await decryptFile(ciphertextBytes, entry.nonce, kf);
if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt vault entry "${ref.vaultKey}" — corrupted?`, true);
if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — data may be corrupted`, true);
if (ref.isFile && ref.mountPath) {
// For file-type entries: the plaintext is the file content (raw bytes).
@@ -1826,7 +1854,7 @@ Your message mode is "${messageMode}".
event: eventName,
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
...(Object.keys(data).length > 0 ? { eventData: data } : {}),
...(Object.keys(data).length > 0 ? { eventData: JSON.stringify(data) } : {}),
},
},
});
@@ -1842,6 +1870,18 @@ Your message mode is "${messageMode}".
? await resolvePeerName(client, fromPubkey)
: "unknown";
// Per-peer capability check — drop silently if sender lacks `dm`.
if (fromPubkey) {
try {
const { isAllowed } = await import("~/commands/grants.js");
const kindCap = msg.kind === "broadcast" ? "broadcast" : "dm";
if (!isAllowed(client.meshSlug, fromPubkey, kindCap)) {
process.stderr.write(`[claudemesh] dropped ${kindCap} from ${fromName} (not granted)\n`);
return;
}
} catch { /* fail-open on grant-read errors — don't break delivery */ }
}
if (messageMode === "inbox") {
try {
await server.notification({
@@ -1855,8 +1895,13 @@ Your message mode is "${messageMode}".
return;
}
// push mode — full content
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
// push mode — full content. Format the content so it reads as a
// first-class chat message even though Claude Code renders it as a
// <channel> reminder: sender attribution + priority badge + body.
const body = msg.plaintext ?? decryptFailedWarning(fromPubkey);
const prioBadge = msg.priority === "now" ? "[URGENT] " : msg.priority === "low" ? "[low] " : "";
const kindBadge = msg.kind === "broadcast" ? " (broadcast)" : "";
const content = `${prioBadge}${fromName}${kindBadge}: ${body}`;
try {
await server.notification({
method: "notifications/claude/channel",
@@ -1875,7 +1920,7 @@ Your message mode is "${messageMode}".
},
},
});
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${body.slice(0, 60)}\n`);
} catch (pushErr) {
process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`);
}
@@ -1970,7 +2015,7 @@ Your message mode is "${messageMode}".
* Code will not auto-restart it.
*/
async function startServiceProxy(serviceName: string): Promise<void> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
process.stderr.write(`[mesh:${serviceName}] no meshes joined\n`);
process.exit(1);
@@ -2035,13 +2080,13 @@ async function startServiceProxy(serviceName: string): Promise<void> {
const args = req.params.arguments ?? {};
// Wait for broker reconnection if needed
if (client.status !== "open") {
if ((client.status as string) !== "open") {
let waited = 0;
while (client.status !== "open" && waited < 10_000) {
while ((client.status as string) !== "open" && waited < 10_000) {
await new Promise((r) => setTimeout(r, 500));
waited += 500;
}
if (client.status !== "open") {
if ((client.status as string) !== "open") {
return {
content: [
{
@@ -2105,7 +2150,7 @@ async function startServiceProxy(serviceName: string): Promise<void> {
// Refresh tools
const newTools = (push.eventData as any)?.tools;
if (Array.isArray(newTools)) {
tools = newTools;
tools = newTools as typeof tools;
// Notify Claude Code that tools changed
server
.notification({

View File

@@ -0,0 +1,4 @@
// MCP tool family: clock-write
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "clock-write" as const;
export const TOOLS = ["mesh_set_clock", "mesh_pause_clock", "mesh_resume_clock"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: contexts
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "contexts" as const;
export const TOOLS = ["share_context", "get_context", "list_contexts"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: files
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "files" as const;
export const TOOLS = ["share_file", "get_file", "list_files", "file_status", "delete_file", "grant_file_access", "read_peer_file", "list_peer_files"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: graph
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "graph" as const;
export const TOOLS = ["graph_query", "graph_execute"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: groups
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "groups" as const;
export const TOOLS = ["join_group", "leave_group"] as const;

View File

@@ -0,0 +1,21 @@
export { FAMILY as memoryFamily, TOOLS as memoryTools } from "./memory.js";
export { FAMILY as stateFamily, TOOLS as stateTools } from "./state.js";
export { FAMILY as messagingFamily, TOOLS as messagingTools } from "./messaging.js";
export { FAMILY as profileFamily, TOOLS as profileTools } from "./profile.js";
export { FAMILY as groupsFamily, TOOLS as groupsTools } from "./groups.js";
export { FAMILY as filesFamily, TOOLS as filesTools } from "./files.js";
export { FAMILY as vectorsFamily, TOOLS as vectorsTools } from "./vectors.js";
export { FAMILY as graphFamily, TOOLS as graphTools } from "./graph.js";
export { FAMILY as sqlFamily, TOOLS as sqlTools } from "./sql.js";
export { FAMILY as streamsFamily, TOOLS as streamsTools } from "./streams.js";
export { FAMILY as contextsFamily, TOOLS as contextsTools } from "./contexts.js";
export { FAMILY as tasksFamily, TOOLS as tasksTools } from "./tasks.js";
export { FAMILY as schedulingFamily, TOOLS as schedulingTools } from "./scheduling.js";
export { FAMILY as meshMetaFamily, TOOLS as meshMetaTools } from "./mesh-meta.js";
export { FAMILY as clockWriteFamily, TOOLS as clockWriteTools } from "./clock-write.js";
export { FAMILY as skillsFamily, TOOLS as skillsTools } from "./skills.js";
export { FAMILY as mcpRegistryPeerFamily, TOOLS as mcpRegistryPeerTools } from "./mcp-registry-peer.js";
export { FAMILY as mcpRegistryBrokerFamily, TOOLS as mcpRegistryBrokerTools } from "./mcp-registry-broker.js";
export { FAMILY as vaultFamily, TOOLS as vaultTools } from "./vault.js";
export { FAMILY as urlWatchFamily, TOOLS as urlWatchTools } from "./url-watch.js";
export { FAMILY as webhooksFamily, TOOLS as webhooksTools } from "./webhooks.js";

View File

@@ -0,0 +1,4 @@
// MCP tool family: mcp-registry-broker
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "mcp-registry-broker" as const;
export const TOOLS = ["mesh_mcp_deploy", "mesh_mcp_undeploy", "mesh_mcp_update", "mesh_mcp_logs", "mesh_mcp_scope", "mesh_mcp_schema", "mesh_mcp_catalog"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: mcp-registry-peer
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "mcp-registry-peer" as const;
export const TOOLS = ["mesh_mcp_register", "mesh_mcp_list", "mesh_tool_call", "mesh_mcp_remove"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: memory
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "memory" as const;
export const TOOLS = ["remember", "recall", "forget"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: mesh-meta
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "mesh-meta" as const;
export const TOOLS = ["mesh_info", "mesh_stats", "mesh_clock", "ping_mesh"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: messaging
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "messaging" as const;
export const TOOLS = ["send_message", "list_peers", "check_messages", "message_status"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: profile
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "profile" as const;
export const TOOLS = ["set_profile", "set_status", "set_summary", "set_visible"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: scheduling
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "scheduling" as const;
export const TOOLS = ["schedule_reminder", "list_scheduled", "cancel_scheduled"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: skills
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "skills" as const;
export const TOOLS = ["share_skill", "get_skill", "list_skills", "remove_skill", "mesh_skill_deploy"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: sql
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "sql" as const;
export const TOOLS = ["mesh_query", "mesh_execute", "mesh_schema"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: state
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "state" as const;
export const TOOLS = ["set_state", "get_state", "list_state"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: streams
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "streams" as const;
export const TOOLS = ["create_stream", "publish", "subscribe", "list_streams"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: tasks
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "tasks" as const;
export const TOOLS = ["create_task", "claim_task", "complete_task", "list_tasks"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: url-watch
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "url-watch" as const;
export const TOOLS = ["mesh_watch", "mesh_unwatch", "mesh_watches"] as const;

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