diff --git a/apps/cli/README.md b/apps/cli/README.md index 49f4339..b49ac0c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,9 @@ Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill. -> **What's new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket. +> **What's new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed. +> +> **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket. > > **Migration note (1.5.0):** the previous 79 MCP tools (`send_message`, `list_peers`, `remember`, …) are removed. Use the matching CLI verbs (`claudemesh send`, `claudemesh peers`, `claudemesh remember`). Run `claudemesh install` and the bundled skill teaches Claude the full surface. @@ -43,6 +45,9 @@ USAGE claudemesh profile view or edit your profile claudemesh topic ... create, list, join, send to topics + claudemesh topic tail live SSE tail of a topic + claudemesh member list mesh roster with online state + claudemesh notification list recent @-mentions of you claudemesh apikey ... issue, list, revoke API keys (REST clients) claudemesh bridge ... forward a topic between two meshes diff --git a/apps/cli/package.json b/apps/cli/package.json index d4afd49..be171c6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.6.1", + "version": "1.7.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index ca1d020..39338e2 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -26,4 +26,7 @@ export { runWelcome } from "./welcome.js"; export { runHook } from "./hook.js"; export { runMcp } from "./mcp.js"; export { runSeedTestMesh } from "./seed-test-mesh.js"; +export { runNotificationList } from "./notification.js"; +export { runMemberList } from "./member.js"; +export { runTopicTail } from "./topic-tail.js"; export { withMesh } from "./connect.js"; diff --git a/apps/cli/src/commands/member.ts b/apps/cli/src/commands/member.ts new file mode 100644 index 0000000..a185b0e --- /dev/null +++ b/apps/cli/src/commands/member.ts @@ -0,0 +1,78 @@ +/** + * `claudemesh member list` — every (non-revoked) member of the chosen + * mesh, decorated with online state. Distinct from `peer list`: peers + * shows live WS sessions, members shows roster. + */ + +import { withRestKey } from "~/services/api/with-rest-key.js"; +import { request } from "~/services/api/client.js"; +import { render } from "~/ui/render.js"; +import { bold, clay, dim, green, red, yellow } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +export interface MemberFlags { + mesh?: string; + json?: boolean; + /** Show only online members. */ + online?: boolean; +} + +interface MemberRow { + memberId: string; + pubkey: string; + displayName: string; + role: string; + isHuman: boolean; + joinedAt: string; + online: boolean; + status: string; + summary: string | null; +} + +function statusGlyph(m: MemberRow): string { + if (!m.online) return dim("○"); + if (m.status === "dnd") return red("●"); + if (m.status === "working") return yellow("●"); + return green("●"); +} + +export async function runMemberList(flags: MemberFlags): Promise { + return withRestKey( + { meshSlug: flags.mesh ?? null, purpose: "members" }, + async ({ secret, meshSlug }) => { + const result = await request<{ members: MemberRow[] }>({ + path: "/api/v1/members", + token: secret, + }); + + const filtered = flags.online + ? result.members.filter((m) => m.online) + : result.members; + + if (flags.json) { + console.log(JSON.stringify({ members: filtered }, null, 2)); + return EXIT.SUCCESS; + } + + if (filtered.length === 0) { + render.info( + dim(flags.online ? `no online members in ${meshSlug}.` : `no members in ${meshSlug}.`), + ); + return EXIT.SUCCESS; + } + + const onlineCount = result.members.filter((m) => m.online).length; + render.section( + `${clay(meshSlug)} members (${onlineCount}/${result.members.length} online)`, + ); + for (const m of filtered) { + const tag = m.isHuman ? dim("human") : dim("bot"); + const summary = m.summary ? ` — ${dim(m.summary)}` : ""; + process.stdout.write( + ` ${statusGlyph(m)} ${bold(m.displayName)} ${tag} ${dim(m.role)} ${dim(m.pubkey.slice(0, 8))}${summary}\n`, + ); + } + return EXIT.SUCCESS; + }, + ); +} diff --git a/apps/cli/src/commands/notification.ts b/apps/cli/src/commands/notification.ts new file mode 100644 index 0000000..b24d71a --- /dev/null +++ b/apps/cli/src/commands/notification.ts @@ -0,0 +1,93 @@ +/** + * `claudemesh notification list` — recent @-mentions of the viewer + * across topics in the chosen mesh. Server-side regex match over the + * v0.2.0 plaintext-base64 ciphertext; the v0.3.0 per-topic encryption + * cut will move this to a notification table populated at write time. + */ + +import { withRestKey } from "~/services/api/with-rest-key.js"; +import { request } from "~/services/api/client.js"; +import { render } from "~/ui/render.js"; +import { bold, clay, dim } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +export interface NotificationFlags { + mesh?: string; + json?: boolean; + since?: string; +} + +interface NotificationRow { + id: string; + topicId: string; + topicName: string; + senderName: string; + senderPubkey: string; + ciphertext: string; + createdAt: string; +} + +interface NotificationsResponse { + notifications: NotificationRow[]; + since: string; + mentionedAs: string; +} + +function decodeCiphertext(b64: string): string { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return "[decode failed]"; + } +} + +function fmtRelative(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return "now"; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`; + return `${Math.floor(ms / 86_400_000)}d`; +} + +export async function runNotificationList(flags: NotificationFlags): Promise { + return withRestKey( + { meshSlug: flags.mesh ?? null, purpose: "notifications" }, + async ({ secret }) => { + const qs = flags.since ? `?since=${encodeURIComponent(flags.since)}` : ""; + const result = await request({ + path: `/api/v1/notifications${qs}`, + token: secret, + }); + + if (flags.json) { + const decoded = result.notifications.map((n) => ({ + ...n, + message: decodeCiphertext(n.ciphertext), + })); + console.log(JSON.stringify({ ...result, notifications: decoded }, null, 2)); + return EXIT.SUCCESS; + } + + if (result.notifications.length === 0) { + render.info( + dim(`no mentions of @${result.mentionedAs} since ${result.since}.`), + ); + return EXIT.SUCCESS; + } + + render.section( + `mentions of @${bold(result.mentionedAs)} (${result.notifications.length})`, + ); + for (const n of result.notifications) { + const when = fmtRelative(n.createdAt); + const msg = decodeCiphertext(n.ciphertext).replace(/\s+/g, " ").trim(); + const snippet = msg.length > 100 ? msg.slice(0, 97) + "…" : msg; + process.stdout.write( + ` ${clay("#" + n.topicName)} ${dim(when)} ${bold(n.senderName)}\n`, + ); + process.stdout.write(` ${snippet}\n`); + } + return EXIT.SUCCESS; + }, + ); +} diff --git a/apps/cli/src/commands/topic-tail.ts b/apps/cli/src/commands/topic-tail.ts new file mode 100644 index 0000000..fa88809 --- /dev/null +++ b/apps/cli/src/commands/topic-tail.ts @@ -0,0 +1,196 @@ +/** + * `claudemesh topic tail ` — live SSE consumer of a topic stream. + * Prints the last N messages from /v1/topics/:name/messages, then opens + * the SSE firehose at /v1/topics/:name/stream and prints new messages + * as they arrive. Ctrl-C to exit. + */ + +import { URLS } from "~/constants/urls.js"; +import { withRestKey } from "~/services/api/with-rest-key.js"; +import { request } from "~/services/api/client.js"; +import { render } from "~/ui/render.js"; +import { bold, clay, dim } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +export interface TopicTailFlags { + mesh?: string; + json?: boolean; + limit?: number | string; + /** Skip the initial backfill — only show forward messages. */ + forwardOnly?: boolean; +} + +interface TopicMessage { + id: string; + senderPubkey: string; + senderName: string; + nonce: string; + ciphertext: string; + createdAt: string; +} + +interface HistoryResponse { + topic: string; + topicId: string; + messages: TopicMessage[]; +} + +function decodeCiphertext(b64: string): string { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return "[decode failed]"; + } +} + +function fmtTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return iso; + } +} + +function printMessage(m: TopicMessage, json: boolean): void { + const text = decodeCiphertext(m.ciphertext); + if (json) { + console.log(JSON.stringify({ ...m, message: text })); + return; + } + process.stdout.write( + ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${text}\n`, + ); +} + +interface SseEvent { + event: string; + id?: string; + data: string; +} + +async function* readSseStream( + reader: ReadableStreamDefaultReader, +): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf("\n\n")) !== -1) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const ev: SseEvent = { event: "message", data: "" }; + const dataLines: string[] = []; + for (const line of block.split("\n")) { + if (!line || line.startsWith(":")) continue; + const colon = line.indexOf(":"); + if (colon < 0) continue; + const field = line.slice(0, colon); + const val = line.slice(colon + 1).replace(/^ /, ""); + if (field === "event") ev.event = val; + else if (field === "id") ev.id = val; + else if (field === "data") dataLines.push(val); + } + ev.data = dataLines.join("\n"); + yield ev; + } + } +} + +export async function runTopicTail(name: string, flags: TopicTailFlags): Promise { + if (!name) { + render.err("Usage: claudemesh topic tail [--limit N]"); + return EXIT.INVALID_ARGS; + } + const cleanName = name.replace(/^#/, ""); + const limit = flags.limit ? Number(flags.limit) : 20; + + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: `tail-${cleanName}`, + capabilities: ["read"], + topicScopes: [cleanName], + }, + async ({ secret, meshSlug }) => { + // 1. Backfill the most recent N messages so the user sees context + // when they tail an active topic. + if (!flags.forwardOnly && limit > 0) { + try { + const history = await request({ + path: `/api/v1/topics/${encodeURIComponent(cleanName)}/messages?limit=${limit}`, + token: secret, + }); + if (!flags.json) { + render.section( + `${clay("#" + cleanName)} on ${dim(meshSlug)} — backfill ${history.messages.length}, then live`, + ); + } + // History is newest-first; reverse for chronological display. + for (const m of history.messages.slice().reverse()) { + printMessage(m, flags.json ?? false); + } + } catch (err) { + render.warn(`backfill failed: ${(err as Error).message}`); + } + } + + // 2. Open the SSE firehose. fetch + ReadableStream so the bearer + // stays in the Authorization header (no token-in-URL leak). + const url = `${URLS.API_BASE}/api/v1/topics/${encodeURIComponent(cleanName)}/stream`; + const ctl = new AbortController(); + const onSig = () => ctl.abort(); + process.once("SIGINT", onSig); + process.once("SIGTERM", onSig); + + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${secret}` }, + signal: ctl.signal, + }); + if (!res.ok || !res.body) { + render.err(`stream open failed: ${res.status}`); + return EXIT.INTERNAL_ERROR; + } + if (!flags.json) { + render.info(dim("tailing — Ctrl-C to exit")); + } + const reader = res.body.getReader() as ReadableStreamDefaultReader; + for await (const ev of readSseStream(reader)) { + if (ev.event === "ready" || ev.event === "heartbeat") continue; + if (ev.event === "error") { + try { + const parsed = JSON.parse(ev.data) as { error?: string }; + render.err(`stream error: ${parsed.error ?? "unknown"}`); + } catch { + render.err("stream error"); + } + continue; + } + if (ev.event === "message") { + try { + const m = JSON.parse(ev.data) as TopicMessage; + printMessage(m, flags.json ?? false); + } catch { + // skip malformed + } + } + } + return EXIT.SUCCESS; + } catch (err) { + if (ctl.signal.aborted) return EXIT.SUCCESS; // user Ctrl-C'd + render.err(`tail aborted: ${(err as Error).message}`); + return EXIT.INTERNAL_ERROR; + } finally { + process.removeListener("SIGINT", onSig); + process.removeListener("SIGTERM", onSig); + } + }, + ); +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index baae457..c7f1c65 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -119,7 +119,10 @@ Topic (conversation scope, v0.2.0) claudemesh topic members list topic subscribers claudemesh topic history fetch message history [--limit --before] claudemesh topic read mark all as read + claudemesh topic tail live SSE tail [--limit --forward-only] claudemesh send "#topic" "msg" send to a topic + claudemesh member list mesh roster with online state [--online] + claudemesh notification list recent @-mentions of you [--since ] Schedule (resource form) claudemesh schedule msg one-shot or recurring (alias: remind) @@ -573,7 +576,53 @@ async function main(): Promise { else if (sub === "members") { const { runTopicMembers } = await import("~/commands/topic.js"); process.exit(await runTopicMembers(arg, f)); } else if (sub === "history") { const { runTopicHistory } = await import("~/commands/topic.js"); process.exit(await runTopicHistory(arg, f)); } else if (sub === "read") { const { runTopicMarkRead } = await import("~/commands/topic.js"); process.exit(await runTopicMarkRead(arg, f)); } - else { console.error("Usage: claudemesh topic "); process.exit(EXIT.INVALID_ARGS); } + else if (sub === "tail") { + const tailFlags = { + mesh: flags.mesh as string, + json: !!flags.json, + limit: flags.limit as string | undefined, + forwardOnly: !!flags["forward-only"], + }; + const { runTopicTail } = await import("~/commands/topic-tail.js"); + process.exit(await runTopicTail(arg, tailFlags)); + } + else { console.error("Usage: claudemesh topic "); process.exit(EXIT.INVALID_ARGS); } + break; + } + + // notification — recent @-mentions of the viewer (v1.7.0) + case "notification": case "notifications": { + const sub = positionals[0] ?? "list"; + const f = { + mesh: flags.mesh as string, + json: !!flags.json, + since: flags.since as string, + }; + if (sub === "list") { + const { runNotificationList } = await import("~/commands/notification.js"); + process.exit(await runNotificationList(f)); + } else { + console.error("Usage: claudemesh notification list [--since ]"); + process.exit(EXIT.INVALID_ARGS); + } + break; + } + + // member — mesh roster with online state (v1.7.0) + case "member": case "members": { + const sub = positionals[0] ?? "list"; + const f = { + mesh: flags.mesh as string, + json: !!flags.json, + online: !!flags.online, + }; + if (sub === "list") { + const { runMemberList } = await import("~/commands/member.js"); + process.exit(await runMemberList(f)); + } else { + console.error("Usage: claudemesh member list [--online]"); + process.exit(EXIT.INVALID_ARGS); + } break; } diff --git a/apps/cli/src/services/api/with-rest-key.ts b/apps/cli/src/services/api/with-rest-key.ts new file mode 100644 index 0000000..4a2d74f --- /dev/null +++ b/apps/cli/src/services/api/with-rest-key.ts @@ -0,0 +1,67 @@ +/** + * Mint an ephemeral apikey via the broker WS, hand it to a REST callback, + * and revoke on exit. Lets `notification list`, `member list`, and + * `topic tail` reuse the v1 REST surface without making the user manage + * their own bearer tokens. + * + * The key is bound to the same mesh the WS connection picked, lives for + * 5 minutes max, and gets read-only capability + a label that makes the + * mesh dashboard's apikey list legible. We revoke even when fn throws. + */ + +import { withMesh } from "~/commands/connect.js"; +import type { BrokerClient } from "~/services/broker/facade.js"; +import type { JoinedMesh } from "~/services/config/facade.js"; + +export interface RestKeyContext { + secret: string; + meshId: string; + meshSlug: string; + client: BrokerClient; + mesh: JoinedMesh; +} + +export interface WithRestKeyOpts { + meshSlug?: string | null; + /** Capabilities to grant — defaults to ["read"]. */ + capabilities?: Array<"send" | "read" | "state_write" | "admin">; + /** Topic-scope allowlist — null = all topics. */ + topicScopes?: string[] | null; + /** Label suffix for the apikey list. */ + purpose?: string; +} + +export async function withRestKey( + opts: WithRestKeyOpts, + fn: (ctx: RestKeyContext) => Promise, +): Promise { + return withMesh({ meshSlug: opts.meshSlug ?? null }, async (client, mesh) => { + const result = await client.apiKeyCreate({ + label: `cli-${opts.purpose ?? "rest"}-${process.pid}`, + capabilities: opts.capabilities ?? ["read"], + topicScopes: opts.topicScopes ?? undefined, + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + }); + if (!result || !result.secret) { + throw new Error("apikey mint failed — broker did not return a secret"); + } + try { + return await fn({ + secret: result.secret, + meshId: mesh.meshId, + meshSlug: mesh.slug, + client, + mesh, + }); + } finally { + // Best-effort cleanup. If the broker connection already closed we + // just leak a 5-minute key — acceptable trade-off for keeping the + // command code linear. + try { + await client.apiKeyRevoke(result.id); + } catch { + // swallow — diagnostic noise without value + } + } + }); +}