1 Commits

Author SHA1 Message Date
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
9 changed files with 230 additions and 56 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.7",
"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,231 @@
/** /**
* `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(dim(`Config: ${getConfigPath()}`));
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(`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.
const claudeArgs = [ const claudeArgs = [
"--dangerously-load-development-channels", "--dangerously-load-development-channels",
"server:claudemesh", "server:claudemesh",
...passthrough, ...args.claudeArgs,
]; ];
// 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 +234,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

@@ -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

@@ -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"),