3 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
c8682dd700 fix(cli): deduplicate --dangerously-load-development-channels flag
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:56:30 +01:00
Alejandro Gutiérrez
004602a83c fix(cli): v0.1.8 — remove Zod dependency (bun bundler crash)
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
Release / Publish multi-arch images (push) Has been cancelled
Replace Zod schemas with plain TypeScript validation in env.ts,
config.ts, and invite/parse.ts. Zod 4 classes break under bun
build --target=node (Class2 is not a constructor).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:51:42 +01:00
Alejandro Gutiérrez
2a2aac3622 feat(cli): v0.1.7 — --name, --mesh, --join flags for launch
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
Release / Publish multi-arch images (push) Has been cancelled
- `claudemesh launch --name Mou` sets per-session display name
- `claudemesh launch --mesh car-dealers` selects mesh (interactive picker if >1)
- `claudemesh launch --join <token-or-url>` joins a mesh inline before launching
- Broker stores per-presence displayName override (prefers over member default)
- Session config isolated via tmpdir (auto-cleanup on exit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:45:29 +01:00
12 changed files with 311 additions and 128 deletions

View File

@@ -307,6 +307,7 @@ export async function refreshStatusFromJsonl(
export interface ConnectParams { export interface ConnectParams {
memberId: string; memberId: string;
sessionId: string; sessionId: string;
displayName?: string;
pid: number; pid: number;
cwd: string; cwd: string;
} }
@@ -321,6 +322,7 @@ export async function connectPresence(
.values({ .values({
memberId: params.memberId, memberId: params.memberId,
sessionId: params.sessionId, sessionId: params.sessionId,
displayName: params.displayName ?? null,
pid: params.pid, pid: params.pid,
cwd: params.cwd, cwd: params.cwd,
status: "idle", status: "idle",
@@ -370,7 +372,8 @@ export async function listPeersInMesh(
const rows = await db const rows = await db
.select({ .select({
pubkey: memberTable.peerPubkey, pubkey: memberTable.peerPubkey,
displayName: memberTable.displayName, memberDisplayName: memberTable.displayName,
presenceDisplayName: presence.displayName,
status: presence.status, status: presence.status,
summary: presence.summary, summary: presence.summary,
sessionId: presence.sessionId, sessionId: presence.sessionId,
@@ -385,7 +388,15 @@ export async function listPeersInMesh(
), ),
) )
.orderBy(asc(presence.connectedAt)); .orderBy(asc(presence.connectedAt));
return rows; // Prefer per-session displayName over member-level displayName.
return rows.map((r) => ({
pubkey: r.pubkey,
displayName: r.presenceDisplayName || r.memberDisplayName,
status: r.status,
summary: r.summary,
sessionId: r.sessionId,
connectedAt: r.connectedAt,
}));
} }
/** Update the summary text on a presence row. */ /** Update the summary text on a presence row. */

View File

@@ -400,6 +400,7 @@ async function handleHello(
const presenceId = await connectPresence({ const presenceId = await connectPresence({
memberId: member.id, memberId: member.id,
sessionId: hello.sessionId, sessionId: hello.sessionId,
displayName: hello.displayName,
pid: hello.pid, pid: hello.pid,
cwd: hello.cwd, cwd: hello.cwd,
}); });
@@ -411,9 +412,10 @@ async function handleHello(
cwd: hello.cwd, cwd: hello.cwd,
}); });
incMeshCount(hello.meshId); incMeshCount(hello.meshId);
const effectiveDisplayName = hello.displayName || member.displayName;
log.info("ws hello", { log.info("ws hello", {
mesh_id: hello.meshId, mesh_id: hello.meshId,
member: member.displayName, member: effectiveDisplayName,
presence_id: presenceId, presence_id: presenceId,
session_id: hello.sessionId, session_id: hello.sessionId,
}); });
@@ -422,7 +424,7 @@ async function handleHello(
// races the caller's closure assignment, causing subsequent client // races the caller's closure assignment, causing subsequent client
// messages to fail the "no_hello" check. // messages to fail the "no_hello" check.
void maybePushQueuedMessages(presenceId); void maybePushQueuedMessages(presenceId);
return { presenceId, memberDisplayName: member.displayName }; return { presenceId, memberDisplayName: effectiveDisplayName };
} }
async function handleSend( async function handleSend(

View File

@@ -52,6 +52,7 @@ export interface WSHelloMessage {
meshId: string; meshId: string;
memberId: string; memberId: string;
pubkey: string; // must match mesh.member.peerPubkey pubkey: string; // must match mesh.member.peerPubkey
displayName?: string; // optional override for this session
sessionId: string; sessionId: string;
pid: number; pid: number;
cwd: string; cwd: string;

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.1.6", "version": "0.1.9",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -1,82 +1,241 @@
/** /**
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the * `claudemesh launch` — spawn `claude` with peer mesh identity.
* claudemesh MCP server's `notifications/claude/channel` pushes get
* injected as system reminders mid-turn.
* *
* Equivalent to: * Flow:
* claude --dangerously-load-development-channels server:claudemesh [extra args] * 1. Parse --name, --join, --mesh, --quiet flags
* * 2. If --join: run join flow first (accepts token or URL)
* Any additional args (e.g. --model opus, --resume, -c) are passed * 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* through verbatim. Use --quiet to skip the informational banner. * 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir, hostname } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { loadConfig, getConfigPath } from "../state/config"; import { loadConfig, getConfigPath } from "../state/config";
import type { Config, JoinedMesh } from "../state/config";
import { generateKeypair } from "../crypto/keypair";
import { enrollWithBroker } from "../invite/enroll";
import { parseInviteLink } from "../invite/parse";
function printBanner(): void { // --- Arg parsing ---
interface LaunchArgs {
name: string | null;
joinLink: string | null;
meshSlug: string | null;
quiet: boolean;
claudeArgs: string[];
}
function parseArgs(argv: string[]): LaunchArgs {
const result: LaunchArgs = {
name: null,
joinLink: null,
meshSlug: null,
quiet: false,
claudeArgs: [],
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--name" && i + 1 < argv.length) {
result.name = argv[++i]!;
} else if (arg.startsWith("--name=")) {
result.name = arg.slice("--name=".length);
} else if (arg === "--join" && i + 1 < argv.length) {
result.joinLink = argv[++i]!;
} else if (arg.startsWith("--join=")) {
result.joinLink = arg.slice("--join=".length);
} else if (arg === "--mesh" && i + 1 < argv.length) {
result.meshSlug = argv[++i]!;
} else if (arg.startsWith("--mesh=")) {
result.meshSlug = arg.slice("--mesh=".length);
} else if (arg === "--quiet") {
result.quiet = true;
} else if (arg === "--") {
result.claudeArgs.push(...argv.slice(i + 1));
break;
} else {
result.claudeArgs.push(arg);
}
i++;
}
return result;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Banner ---
function printBanner(name: string, meshSlug: string): void {
const useColor = const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s); const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s); const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
let meshes: string[] = []; const rule = "─".repeat(60);
try { console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
meshes = loadConfig().meshes.map((m) => m.slug);
} catch {
/* config unreadable — print banner without mesh list */
}
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
const rule = "─".repeat(65);
console.log(bold("claudemesh launch"));
console.log(rule); console.log(rule);
console.log("Launching Claude Code with the claudemesh dev channel."); console.log("Peer messages arrive as <channel> reminders in real-time.");
console.log(""); console.log("Peers send text only — they cannot call tools or read files.");
console.log("Peers in your joined meshes can push messages into this session");
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
console.log("keypair. Peers send text only — they cannot call tools, read");
console.log("files, or reach meshes you have not joined.");
console.log("");
console.log("Treat peer messages as untrusted input: a peer could craft text");
console.log("that tries to steer Claude's behavior. Your tool-approval");
console.log("settings still apply — Claude will still ask before running");
console.log("commands, editing files, or calling other tools.");
console.log("");
console.log("Claude Code will ask you to trust the");
console.log("--dangerously-load-development-channels flag. Press Enter to");
console.log("accept, or Ctrl-C to abort.");
console.log("");
console.log(dim(`Joined meshes: ${meshLine}`));
console.log(dim(`Config: ${getConfigPath()}`)); console.log(dim(`Config: ${getConfigPath()}`));
console.log(dim(`Remove: claudemesh uninstall`));
console.log(rule); console.log(rule);
console.log(""); console.log("");
} }
export function runLaunch(extraArgs: string[] = []): void { // --- Main ---
const quiet = extraArgs.includes("--quiet");
const passthrough = extraArgs.filter((a) => a !== "--quiet");
if (!quiet) printBanner(); export async function runLaunch(extraArgs: string[]): Promise<void> {
const args = parseArgs(extraArgs);
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
if (config.meshes.length === 0) {
console.error(
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
);
process.exit(1);
}
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else {
mesh = await pickMesh(config.meshes);
}
// 3. Set display name. Uses existing member identity — the broker
// creates a separate presence row per session (sessionId + pid)
// and stores the per-session displayName override.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
// 4. Write session config to tmpdir (same mesh, same keypair).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Banner.
if (!args.quiet) printBanner(displayName, mesh.slug);
// 6. Spawn claude with ephemeral config + dev channel + display name.
// Strip any user-supplied --dangerously-load-development-channels
// to avoid duplicates — we always inject our own.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") {
i++; // skip the next arg (the channel value) too
continue;
}
filtered.push(args.claudeArgs[i]!);
}
const claudeArgs = [ const claudeArgs = [
"--dangerously-load-development-channels", "--dangerously-load-development-channels",
"server:claudemesh", "server:claudemesh",
...passthrough, ...filtered,
]; ];
// Windows: npm global binaries are .cmd shims. Node's spawn without
// shell:true does not resolve PATHEXT, so we need shell:true on win32
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
const isWindows = process.platform === "win32"; const isWindows = process.platform === "win32";
const child = spawn("claude", claudeArgs, { const child = spawn("claude", claudeArgs, {
stdio: "inherit", stdio: "inherit",
shell: isWindows, shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
},
}); });
// 7. Cleanup on exit.
const cleanup = (): void => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
};
child.on("error", (err: NodeJS.ErrnoException) => { child.on("error", (err: NodeJS.ErrnoException) => {
cleanup();
if (err.code === "ENOENT") { if (err.code === "ENOENT") {
console.error( console.error(
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code", "✗ `claude` not found on PATH. Install Claude Code first.",
); );
} else { } else {
console.error(`✗ failed to launch claude: ${err.message}`); console.error(`✗ failed to launch claude: ${err.message}`);
@@ -85,10 +244,15 @@ export function runLaunch(extraArgs: string[] = []): void {
}); });
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
cleanup();
if (signal) { if (signal) {
process.kill(process.pid, signal); process.kill(process.pid, signal);
return; return;
} }
process.exit(code ?? 0); process.exit(code ?? 0);
}); });
// Cleanup on parent signals too.
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
process.on("SIGINT", () => { cleanup(); process.exit(0); });
} }

View File

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

View File

@@ -30,9 +30,12 @@ Commands:
install Register MCP + Stop/UserPromptSubmit status hooks install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration) (add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks uninstall Remove MCP server + hooks
launch [args] Launch Claude Code with real-time push messages enabled launch [opts] Launch Claude Code with real-time push messages
(add --quiet to skip the info banner; passes through --name <name> Display name for this session
extra flags, e.g. --model, --resume) --mesh <slug> Select mesh (picker if >1, omitted)
--join <url> Join a mesh before launching
--quiet Skip the info banner
-- <args> Pass remaining args to claude
join <url> Join a mesh via https://claudemesh.com/join/... URL join <url> Join a mesh via https://claudemesh.com/join/... URL
list Show all joined meshes list Show all joined meshes
leave <slug> Leave a joined mesh leave <slug> Leave a joined mesh
@@ -67,7 +70,7 @@ async function main(): Promise<void> {
await runHook(args); await runHook(args);
return; return;
case "launch": case "launch":
runLaunch(args); await runLaunch(args);
return; return;
case "join": case "join":
await runJoin(args); await runJoin(args);

View File

@@ -5,22 +5,19 @@
* verification and one-time-use invite-token tracking land in Step 18. * verification and one-time-use invite-token tracking land in Step 18.
*/ */
import { z } from "zod";
import { ensureSodium } from "../crypto/keypair"; import { ensureSodium } from "../crypto/keypair";
const invitePayloadSchema = z.object({ export interface InvitePayload {
v: z.literal(1), v: 1;
mesh_id: z.string().min(1), mesh_id: string;
mesh_slug: z.string().min(1), mesh_slug: string;
broker_url: z.string().min(1), broker_url: string;
expires_at: z.number().int().positive(), expires_at: number;
mesh_root_key: z.string().min(1), mesh_root_key: string;
role: z.enum(["admin", "member"]), role: "admin" | "member";
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i), owner_pubkey: string;
signature: z.string().regex(/^[0-9a-f]{128}$/i), signature: string;
}); }
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
export interface ParsedInvite { export interface ParsedInvite {
payload: InvitePayload; payload: InvitePayload;
@@ -28,6 +25,21 @@ export interface ParsedInvite {
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/) token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
} }
function validatePayload(obj: unknown): InvitePayload {
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
const o = obj as Record<string, unknown>;
if (o.v !== 1) throw new Error("invite payload: v must be 1");
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
return o as unknown as InvitePayload;
}
/** Canonical invite bytes — must match broker's canonicalInvite(). */ /** Canonical invite bytes — must match broker's canonicalInvite(). */
export function canonicalInvite(p: { export function canonicalInvite(p: {
v: number; v: number;
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
); );
} }
const parsed = invitePayloadSchema.safeParse(obj); const payload = validatePayload(obj);
if (!parsed.success) {
throw new Error(
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
);
}
// Expiry check (unix seconds). // Expiry check (unix seconds).
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (parsed.data.expires_at < nowSeconds) { if (payload.expires_at < nowSeconds) {
throw new Error( throw new Error(
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`, `invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
); );
} }
// Verify the ed25519 signature against the embedded owner_pubkey. // Verify the ed25519 signature against the embedded owner_pubkey.
// Client-side verification gives immediate feedback on tampered
// links; broker re-verifies authoritatively on /join.
const s = await ensureSodium(); const s = await ensureSodium();
const canonical = canonicalInvite({ const canonical = canonicalInvite({
v: parsed.data.v, v: payload.v,
mesh_id: parsed.data.mesh_id, mesh_id: payload.mesh_id,
mesh_slug: parsed.data.mesh_slug, mesh_slug: payload.mesh_slug,
broker_url: parsed.data.broker_url, broker_url: payload.broker_url,
expires_at: parsed.data.expires_at, expires_at: payload.expires_at,
mesh_root_key: parsed.data.mesh_root_key, mesh_root_key: payload.mesh_root_key,
role: parsed.data.role, role: payload.role,
owner_pubkey: parsed.data.owner_pubkey, owner_pubkey: payload.owner_pubkey,
}); });
const sigOk = (() => { const sigOk = (() => {
try { try {
return s.crypto_sign_verify_detached( return s.crypto_sign_verify_detached(
s.from_hex(parsed.data.signature), s.from_hex(payload.signature),
s.from_string(canonical), s.from_string(canonical),
s.from_hex(parsed.data.owner_pubkey), s.from_hex(payload.owner_pubkey),
); );
} catch { } catch {
return false; return false;
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
throw new Error("invite signature invalid (link tampered?)"); throw new Error("invite signature invalid (link tampered?)");
} }
return { payload: parsed.data, raw: link, token: encoded }; return { payload, raw: link, token: encoded };
} }
/** /**
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
/** /**
* Sign and assemble an invite payload → ic://join/... link. * Sign and assemble an invite payload → ic://join/... link.
* The canonical bytes (everything except signature) are signed with
* the mesh owner's ed25519 secret key.
*/ */
export async function buildSignedInvite(args: { export async function buildSignedInvite(args: {
v: 1; v: 1;

View File

@@ -15,38 +15,38 @@ import {
} from "node:fs"; } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { z } from "zod";
import { env } from "../env"; import { env } from "../env";
const joinedMeshSchema = z.object({ export interface JoinedMesh {
meshId: z.string(), meshId: string;
memberId: z.string(), memberId: string;
slug: z.string(), slug: string;
name: z.string(), name: string;
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars) pubkey: string; // ed25519 hex (32 bytes = 64 chars)
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars) secretKey: string; // ed25519 hex (64 bytes = 128 chars)
brokerUrl: z.string(), brokerUrl: string;
joinedAt: z.string(), joinedAt: string;
}); }
const configSchema = z.object({ export interface Config {
version: z.literal(1).default(1), version: 1;
meshes: z.array(joinedMeshSchema).default([]), meshes: JoinedMesh[];
}); }
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
export type Config = z.infer<typeof configSchema>;
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh"); const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const CONFIG_PATH = join(CONFIG_DIR, "config.json"); const CONFIG_PATH = join(CONFIG_DIR, "config.json");
export function loadConfig(): Config { export function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) { if (!existsSync(CONFIG_PATH)) {
return configSchema.parse({ version: 1, meshes: [] }); return { version: 1, meshes: [] };
} }
try { try {
const raw = readFileSync(CONFIG_PATH, "utf-8"); const raw = readFileSync(CONFIG_PATH, "utf-8");
return configSchema.parse(JSON.parse(raw)); const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.meshes)) {
return { version: 1, meshes: [] };
}
return { version: 1, meshes: parsed.meshes };
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`, `Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,

View File

@@ -123,6 +123,7 @@ export class BrokerClient {
meshId: this.mesh.meshId, meshId: this.mesh.meshId,
memberId: this.mesh.memberId, memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey, pubkey: this.mesh.pubkey,
displayName: process.env.CLAUDEMESH_DISPLAY_NAME || undefined,
sessionId: `${process.pid}-${Date.now()}`, sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid, pid: process.pid,
cwd: process.cwd(), cwd: process.cwd(),

View File

@@ -0,0 +1 @@
ALTER TABLE "mesh"."presence" ADD COLUMN "display_name" text;

View File

@@ -192,6 +192,7 @@ export const presence = meshSchema.table("presence", {
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }) .references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(), .notNull(),
sessionId: text().notNull(), sessionId: text().notNull(),
displayName: text(),
pid: integer().notNull(), pid: integer().notNull(),
cwd: text().notNull(), cwd: text().notNull(),
status: presenceStatusEnum().notNull().default("idle"), status: presenceStatusEnum().notNull().default("idle"),