From e76ade64d28734c15b0d06d538e5f071a4298b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:53:42 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20scheduled=20messages=20=E2=80=94=20sche?= =?UTF-8?q?dule=5Freminder,=20send=5Flater,=20list=5Fscheduled,=20cancel?= =?UTF-8?q?=5Fscheduled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Broker: schedule/list_scheduled/cancel_scheduled WS message types + in-memory delivery - Client: scheduleMessage(), listScheduled(), cancelScheduled() with resolver Map pattern - MCP: schedule_reminder, send_later, list_scheduled, cancel_scheduled tools - CLI: claudemesh remind --in 2h | --at 15:00 | list | cancel - Types: WSScheduleMessage, WSScheduledAckMessage, WSScheduledListMessage, WSCancelScheduledAckMessage Co-Authored-By: Claude Sonnet 4.6 --- apps/broker/src/index.ts | 100 +++++++++++++++++++++++ apps/broker/src/types.ts | 62 +++++++++++++- apps/cli/package.json | 2 +- apps/cli/src/commands/connect.ts | 59 ++++++++++++++ apps/cli/src/commands/inbox.ts | 60 ++++++++++++++ apps/cli/src/commands/info.ts | 58 +++++++++++++ apps/cli/src/commands/install.ts | 116 ++++++++++++++++++++++++++ apps/cli/src/commands/launch.ts | 16 ++-- apps/cli/src/commands/memory.ts | 63 +++++++++++++++ apps/cli/src/commands/peers.ts | 48 +++++++++++ apps/cli/src/commands/remind.ts | 134 +++++++++++++++++++++++++++++++ apps/cli/src/commands/send.ts | 51 ++++++++++++ apps/cli/src/commands/state.ts | 75 +++++++++++++++++ apps/cli/src/index.ts | 104 ++++++++++++++++++++++++ apps/cli/src/mcp/server.ts | 62 ++++++++++++++ apps/cli/src/mcp/tools.ts | 47 +++++++++++ apps/cli/src/ws/client.ts | 64 +++++++++++++++ package.json | 44 ++++++++-- 18 files changed, 1152 insertions(+), 13 deletions(-) create mode 100644 apps/cli/src/commands/connect.ts create mode 100644 apps/cli/src/commands/inbox.ts create mode 100644 apps/cli/src/commands/info.ts create mode 100644 apps/cli/src/commands/memory.ts create mode 100644 apps/cli/src/commands/peers.ts create mode 100644 apps/cli/src/commands/remind.ts create mode 100644 apps/cli/src/commands/send.ts create mode 100644 apps/cli/src/commands/state.ts diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index b34947a..2704e3e 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -102,6 +102,19 @@ const connectionsPerMesh = new Map(); // Stream subscriptions: "meshId:streamName" → Set of presenceIds const streamSubscriptions = new Map>(); + +// Scheduled messages: meshId → Map +interface ScheduledEntry { + id: string; + meshId: string; + presenceId: string; + to: string; + message: string; + deliverAt: number; + createdAt: number; + timer: ReturnType; +} +const scheduledMessages = new Map(); // keyed by scheduledId const hookRateLimit = new TokenBucket( env.HOOK_RATE_LIMIT_PER_MIN, env.HOOK_RATE_LIMIT_PER_MIN, @@ -1788,6 +1801,93 @@ function handleConnection(ws: WebSocket): void { log.info("ws mesh_info", { presence_id: presenceId }); break; } + + // --- Scheduled messages --- + + case "schedule": { + const sm = msg as Extract; + const scheduledId = crypto.randomUUID(); + const now = Date.now(); + const delay = Math.max(0, sm.deliverAt - now); + + const deliver = (): void => { + scheduledMessages.delete(scheduledId); + // Deliver via the normal send path by constructing a WSSendMessage + // and routing it through handleSend so encryption + push logic applies. + const conn2 = connections.get(presenceId); + if (!conn2) return; // session gone — drop + const fakeMsg: Extract = { + type: "send", + id: crypto.randomUUID(), + targetSpec: sm.to, + priority: "now", + nonce: "", + ciphertext: Buffer.from(sm.message, "utf-8").toString("base64"), + }; + handleSend(conn2, presenceId, fakeMsg).catch((e) => + log.warn("scheduled delivery error", { scheduled_id: scheduledId, error: String(e) }), + ); + log.info("ws schedule deliver", { scheduled_id: scheduledId, to: sm.to }); + }; + + const entry: ScheduledEntry = { + id: scheduledId, + meshId: conn.meshId, + presenceId, + to: sm.to, + message: sm.message, + deliverAt: sm.deliverAt, + createdAt: now, + timer: setTimeout(deliver, delay), + }; + scheduledMessages.set(scheduledId, entry); + + sendToPeer(presenceId, { + type: "scheduled_ack", + scheduledId, + deliverAt: sm.deliverAt, + ...(_reqId ? { _reqId } : {}), + }); + log.info("ws schedule", { + presence_id: presenceId, + scheduled_id: scheduledId, + delay_ms: delay, + to: sm.to, + }); + break; + } + + case "list_scheduled": { + const mine = [...scheduledMessages.values()] + .filter((e) => e.meshId === conn.meshId && e.presenceId === presenceId) + .map((e) => ({ id: e.id, to: e.to, message: e.message, deliverAt: e.deliverAt, createdAt: e.createdAt })); + sendToPeer(presenceId, { + type: "scheduled_list", + messages: mine, + ...(_reqId ? { _reqId } : {}), + }); + log.info("ws list_scheduled", { presence_id: presenceId, count: mine.length }); + break; + } + + case "cancel_scheduled": { + const cs = msg as Extract; + const entry = scheduledMessages.get(cs.scheduledId); + let ok = false; + if (entry && entry.meshId === conn.meshId && entry.presenceId === presenceId) { + clearTimeout(entry.timer); + scheduledMessages.delete(cs.scheduledId); + ok = true; + } + sendToPeer(presenceId, { + type: "cancel_scheduled_ack", + scheduledId: cs.scheduledId, + ok, + ...(_reqId ? { _reqId } : {}), + }); + log.info("ws cancel_scheduled", { presence_id: presenceId, scheduled_id: cs.scheduledId, ok }); + break; + } } } catch (e) { metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" }); diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index 79308f3..5944024 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -664,6 +664,60 @@ export interface WSErrorMessage { _reqId?: string; } +// --- Scheduled messages --- + +/** Client → broker: schedule a message for future delivery. */ +export interface WSScheduleMessage { + type: "schedule"; + to: string; + message: string; + /** Unix timestamp (ms) when to deliver. */ + deliverAt: number; + _reqId?: string; +} + +/** Client → broker: list pending scheduled messages for this member. */ +export interface WSListScheduledMessage { + type: "list_scheduled"; + _reqId?: string; +} + +/** Client → broker: cancel a scheduled message by id. */ +export interface WSCancelScheduledMessage { + type: "cancel_scheduled"; + scheduledId: string; + _reqId?: string; +} + +/** Broker → client: acknowledgement for schedule, carries the assigned id. */ +export interface WSScheduledAckMessage { + type: "scheduled_ack"; + scheduledId: string; + deliverAt: number; + _reqId?: string; +} + +/** Broker → client: list of pending scheduled messages. */ +export interface WSScheduledListMessage { + type: "scheduled_list"; + messages: Array<{ + id: string; + to: string; + message: string; + deliverAt: number; + createdAt: number; + }>; + _reqId?: string; +} + +/** Broker → client: cancel confirmation. */ +export interface WSCancelScheduledAckMessage { + type: "cancel_scheduled_ack"; + scheduledId: string; + ok: boolean; + _reqId?: string; +} + export type WSClientMessage = | WSHelloMessage | WSSendMessage @@ -705,7 +759,10 @@ export type WSClientMessage = | WSSubscribeMessage | WSUnsubscribeMessage | WSListStreamsMessage - | WSMeshInfoMessage; + | WSMeshInfoMessage + | WSScheduleMessage + | WSListScheduledMessage + | WSCancelScheduledMessage; export type WSServerMessage = | WSHelloAckMessage @@ -738,4 +795,7 @@ export type WSServerMessage = | WSSubscribedMessage | WSStreamListMessage | WSMeshInfoResultMessage + | WSScheduledAckMessage + | WSScheduledListMessage + | WSCancelScheduledAckMessage | WSErrorMessage; diff --git a/apps/cli/package.json b/apps/cli/package.json index 3778c25..850202c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.6.6", + "version": "0.6.5", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/connect.ts b/apps/cli/src/commands/connect.ts new file mode 100644 index 0000000..786a9bb --- /dev/null +++ b/apps/cli/src/commands/connect.ts @@ -0,0 +1,59 @@ +/** + * Short-lived WS connection helper for CLI commands (peers, send, inbox, state). + * + * Opens a connection to one mesh, runs a callback, then closes cleanly. + * The caller never deals with connect/close lifecycle. + */ + +import { hostname } from "node:os"; +import { BrokerClient } from "../ws/client"; +import { loadConfig } from "../state/config"; +import type { JoinedMesh } from "../state/config"; + +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; +} + +export async function withMesh( + opts: ConnectOpts, + fn: (client: BrokerClient, mesh: JoinedMesh) => Promise, +): Promise { + const config = loadConfig(); + if (config.meshes.length === 0) { + console.error("No meshes joined. Run `claudemesh join ` first."); + process.exit(1); + } + + let mesh: JoinedMesh; + if (opts.meshSlug) { + const found = config.meshes.find((m) => m.slug === opts.meshSlug); + if (!found) { + console.error( + `Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`, + ); + process.exit(1); + } + mesh = found; + } else if (config.meshes.length === 1) { + mesh = config.meshes[0]!; + } else { + console.error( + `Multiple meshes joined. Specify one with --mesh .\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`, + ); + process.exit(1); + } + + const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`; + const client = new BrokerClient(mesh, { displayName }); + + try { + await client.connect(); + const result = await fn(client, mesh); + return result; + } finally { + client.close(); + } +} diff --git a/apps/cli/src/commands/inbox.ts b/apps/cli/src/commands/inbox.ts new file mode 100644 index 0000000..8fef6fd --- /dev/null +++ b/apps/cli/src/commands/inbox.ts @@ -0,0 +1,60 @@ +/** + * `claudemesh inbox` — read pending peer messages. + * + * Connects, waits briefly for push delivery, drains the buffer, prints. + * Works best when message-mode is "inbox" or "off" (messages held at broker). + */ + +import { withMesh } from "./connect"; +import type { InboundPush } from "../ws/client"; + +export interface InboxFlags { + mesh?: string; + json?: boolean; + wait?: number; +} + +function formatMessage(msg: InboundPush, useColor: boolean): string { + const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); + const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); + + const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`; + const from = msg.senderPubkey.slice(0, 8); + const time = new Date(msg.createdAt).toLocaleTimeString(); + const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind; + + return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`; +} + +export async function runInbox(flags: InboxFlags): Promise { + const useColor = + !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; + const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); + const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); + + const waitMs = (flags.wait ?? 1) * 1000; + + await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { + // Wait briefly for broker to push any held messages. + await new Promise((resolve) => setTimeout(resolve, waitMs)); + + const messages = client.drainPushBuffer(); + + if (flags.json) { + console.log(JSON.stringify(messages, null, 2)); + return; + } + + if (messages.length === 0) { + console.log(dim(`No messages on mesh "${mesh.slug}".`)); + return; + } + + console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`)); + console.log(""); + for (const msg of messages) { + console.log(formatMessage(msg, useColor)); + console.log(""); + } + }); +} diff --git a/apps/cli/src/commands/info.ts b/apps/cli/src/commands/info.ts new file mode 100644 index 0000000..7003abf --- /dev/null +++ b/apps/cli/src/commands/info.ts @@ -0,0 +1,58 @@ +/** + * `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count. + * + * Useful for AI agents to orient themselves in a mesh via bash. + */ + +import { withMesh } from "./connect"; +import { loadConfig } from "../state/config"; + +export interface InfoFlags { + mesh?: string; + json?: boolean; +} + +export async function runInfo(flags: InfoFlags): Promise { + const useColor = + !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; + const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); + const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); + + const config = loadConfig(); + + await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { + const [brokerInfo, peers, state] = await Promise.all([ + client.meshInfo(), + client.listPeers(), + client.listState(), + ]); + + const output = { + slug: mesh.slug, + meshId: mesh.meshId, + memberId: mesh.memberId, + brokerUrl: mesh.brokerUrl, + displayName: config.displayName ?? null, + peerCount: peers.length, + stateCount: state.length, + ...(brokerInfo ?? {}), + }; + + if (flags.json) { + console.log(JSON.stringify(output, null, 2)); + return; + } + + console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`)); + console.log(dim(` mesh: ${mesh.meshId}`)); + console.log(dim(` member: ${mesh.memberId}`)); + console.log(` peers: ${peers.length} connected`); + console.log(` state: ${state.length} keys`); + if (brokerInfo && typeof brokerInfo === "object") { + for (const [k, v] of Object.entries(brokerInfo)) { + if (["slug", "meshId", "brokerUrl"].includes(k)) continue; + console.log(dim(` ${k}: ${JSON.stringify(v)}`)); + } + } + }); +} diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 0b9238c..c371def 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -212,6 +212,88 @@ function writeClaudeSettings(obj: Record): void { ); } +/** + * All claudemesh MCP tool names, prefixed for allowedTools. + * These let Claude Code use claudemesh tools without --dangerously-skip-permissions. + */ +const CLAUDEMESH_TOOLS = [ + "mcp__claudemesh__send_message", + "mcp__claudemesh__list_peers", + "mcp__claudemesh__check_messages", + "mcp__claudemesh__set_summary", + "mcp__claudemesh__set_status", + "mcp__claudemesh__join_group", + "mcp__claudemesh__leave_group", + "mcp__claudemesh__get_state", + "mcp__claudemesh__set_state", + "mcp__claudemesh__list_state", + "mcp__claudemesh__remember", + "mcp__claudemesh__recall", + "mcp__claudemesh__forget", + "mcp__claudemesh__share_file", + "mcp__claudemesh__get_file", + "mcp__claudemesh__list_files", + "mcp__claudemesh__file_status", + "mcp__claudemesh__delete_file", + "mcp__claudemesh__vector_store", + "mcp__claudemesh__vector_search", + "mcp__claudemesh__vector_delete", + "mcp__claudemesh__list_collections", + "mcp__claudemesh__graph_query", + "mcp__claudemesh__graph_execute", + "mcp__claudemesh__mesh_info", + "mcp__claudemesh__ping_mesh", + "mcp__claudemesh__message_status", + "mcp__claudemesh__share_context", + "mcp__claudemesh__get_context", + "mcp__claudemesh__list_contexts", + "mcp__claudemesh__create_task", + "mcp__claudemesh__claim_task", + "mcp__claudemesh__complete_task", + "mcp__claudemesh__list_tasks", + "mcp__claudemesh__create_stream", + "mcp__claudemesh__publish", + "mcp__claudemesh__subscribe", + "mcp__claudemesh__list_streams", + "mcp__claudemesh__mesh_execute", + "mcp__claudemesh__mesh_query", + "mcp__claudemesh__mesh_schema", +]; + +/** + * Pre-approve all claudemesh MCP tools in allowedTools. + * Merges into any existing list — never overwrites other entries. + * Returns which tools were added vs already present. + */ +function installAllowedTools(): { added: string[]; unchanged: number } { + const settings = readClaudeSettings(); + const existing = new Set((settings.allowedTools as string[] | undefined) ?? []); + const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t)); + if (toAdd.length > 0) { + settings.allowedTools = [...Array.from(existing), ...toAdd]; + writeClaudeSettings(settings); + } + return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length }; +} + +/** + * Remove claudemesh tools from allowedTools. + * Leaves all other entries intact. Returns count removed. + */ +function uninstallAllowedTools(): number { + if (!existsSync(CLAUDE_SETTINGS)) return 0; + const settings = readClaudeSettings(); + const existing = (settings.allowedTools as string[] | undefined) ?? []; + const toolSet = new Set(CLAUDEMESH_TOOLS); + const kept = existing.filter((t) => !toolSet.has(t)); + const removed = existing.length - kept.length; + if (removed > 0) { + settings.allowedTools = kept; + writeClaudeSettings(settings); + } + return removed; +} + /** * Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json, * idempotent on the command string. Returns counts for reporting. @@ -321,6 +403,26 @@ export function runInstall(args: string[] = []): void { ), ); + // allowedTools — pre-approve claudemesh MCP tools so peers don't need + // --dangerously-skip-permissions just to call mesh tools. + try { + const { added, unchanged } = installAllowedTools(); + if (added.length > 0) { + console.log( + `✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`, + ); + console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`)); + console.log(dim(` Your existing allowedTools entries were preserved.`)); + } else { + console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`); + } + console.log(dim(` config: ${CLAUDE_SETTINGS}`)); + } catch (e) { + console.error( + `⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + // Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status). if (!skipHooks) { try { @@ -375,6 +477,20 @@ export function runUninstall(): void { console.log(`· MCP server "${MCP_NAME}" not present`); } + // allowedTools + try { + const removed = uninstallAllowedTools(); + if (removed > 0) { + console.log(`✓ allowedTools: ${removed} claudemesh tools removed`); + } else { + console.log("· No claudemesh allowedTools to remove"); + } + } catch (e) { + console.error( + `⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + // Hooks try { const removed = uninstallHooks(); diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index 9edffc2..93a0a78 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -98,12 +98,12 @@ async function confirmPermissions(): Promise { console.log(yellow(bold(" Autonomous mode"))); console.log(""); - console.log(" Claude will send and receive peer messages without asking"); - console.log(" you first. Peers exchange text only — no file access,"); - console.log(" no tool calls, no code execution."); + console.log(" Claude will run with --dangerously-skip-permissions, bypassing"); + console.log(" ALL permission prompts — not just claudemesh tools."); + console.log(" Peers exchange text only — no file access, no tool calls."); console.log(""); - console.log(dim(" Same as: claude --dangerously-skip-permissions")); - console.log(dim(" Skip this prompt: claudemesh launch -y")); + console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools).")); + console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes.")); console.log(""); const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -313,10 +313,14 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise< } filtered.push(args.claudeArgs[i]!); } + // --dangerously-skip-permissions is only added when the user explicitly + // passes -y / --yes. Without it, claudemesh tools still work because + // `claudemesh install` pre-approves them via allowedTools in settings.json. + // This keeps permissions tight for multi-person meshes. const claudeArgs = [ "--dangerously-load-development-channels", "server:claudemesh", - "--dangerously-skip-permissions", + ...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []), ...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []), ...filtered, ]; diff --git a/apps/cli/src/commands/memory.ts b/apps/cli/src/commands/memory.ts new file mode 100644 index 0000000..b36bcf9 --- /dev/null +++ b/apps/cli/src/commands/memory.ts @@ -0,0 +1,63 @@ +/** + * `claudemesh remember [--tags tag1,tag2]` — store a memory in the mesh. + * `claudemesh recall ` — 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 { + 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 { + 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(""); + } + }); +} diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts new file mode 100644 index 0000000..2ade9a3 --- /dev/null +++ b/apps/cli/src/commands/peers.ts @@ -0,0 +1,48 @@ +/** + * `claudemesh peers` — list connected peers in the mesh. + * + * Connects, fetches the peer list, prints it, disconnects. + */ + +import { withMesh } from "./connect"; + +export interface PeersFlags { + mesh?: string; + json?: boolean; +} + +export async function runPeers(flags: PeersFlags): Promise { + 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); + + await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { + const peers = await client.listPeers(); + + if (flags.json) { + console.log(JSON.stringify(peers, null, 2)); + return; + } + + 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 summary = p.summary ? dim(` ${p.summary}`) : ""; + console.log(` ${statusIcon} ${name}${groups}${summary}`); + } + console.log(""); + }); +} diff --git a/apps/cli/src/commands/remind.ts b/apps/cli/src/commands/remind.ts new file mode 100644 index 0000000..117f768 --- /dev/null +++ b/apps/cli/src/commands/remind.ts @@ -0,0 +1,134 @@ +/** + * `claudemesh remind --in | --at