diff --git a/apps/cli/package.json b/apps/cli/package.json index 4edeb10..510caef 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.9.5", + "version": "1.10.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/me.ts b/apps/cli/src/commands/me.ts new file mode 100644 index 0000000..a8359fe --- /dev/null +++ b/apps/cli/src/commands/me.ts @@ -0,0 +1,111 @@ +/** + * `claudemesh me` — cross-mesh workspace overview for the caller's user. + * + * Calls GET /v1/me/workspace which aggregates over every mesh the + * authenticated user belongs to: peer count, online count, topic count, + * unread @-mention count per mesh + global totals. + * + * Auth: mints a temporary read-scoped REST apikey on whichever mesh + * the user has joined first (any mesh works — the endpoint resolves + * to the issuing user, not the apikey's mesh). + * + * v0.4.0 substrate. Future verbs (`me topics`, `me notifications`, + * `me activity`, `me search`) layer on top of similar aggregating + * endpoints once they ship. + */ + +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, cyan, dim, green, yellow } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +interface WorkspaceMesh { + meshId: string; + slug: string; + name: string; + memberId: string; + myRole: string; + joinedAt: string; + peers: number; + online: number; + topics: number; + unreadMentions: number; +} + +interface WorkspaceResponse { + userId: string; + meshes: WorkspaceMesh[]; + totals: { + meshes: number; + peers: number; + online: number; + topics: number; + unreadMentions: number; + }; +} + +export interface MeFlags { + mesh?: string; + json?: boolean; +} + +export async function runMe(flags: MeFlags): Promise { + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: "workspace-overview", + capabilities: ["read"], + }, + async ({ secret }) => { + const ws = await request({ + path: "/api/v1/me/workspace", + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + render.section( + `${clay("workspace")} — ${bold(ws.userId.slice(0, 8))} ${dim( + `· ${ws.totals.meshes} mesh${ws.totals.meshes === 1 ? "" : "es"}`, + )}`, + ); + + const totalsLine = [ + `${green(String(ws.totals.online))}/${ws.totals.peers} online`, + `${ws.totals.topics} topic${ws.totals.topics === 1 ? "" : "s"}`, + ws.totals.unreadMentions > 0 + ? yellow(`${ws.totals.unreadMentions} unread @you`) + : dim("0 unread @you"), + ].join(dim(" · ")); + process.stdout.write(" " + totalsLine + "\n\n"); + + if (ws.meshes.length === 0) { + process.stdout.write( + dim(" no meshes joined — run `claudemesh new` or accept an invite\n"), + ); + return EXIT.SUCCESS; + } + + const slugWidth = Math.max(...ws.meshes.map((m) => m.slug.length), 8); + for (const m of ws.meshes) { + const slug = cyan(m.slug.padEnd(slugWidth)); + const peers = `${m.online}/${m.peers}`; + const role = dim(m.myRole); + const unread = + m.unreadMentions > 0 + ? " " + yellow(`${m.unreadMentions} @you`) + : ""; + process.stdout.write( + ` ${slug} ${peers.padStart(5)} online ${dim( + String(m.topics).padStart(2) + " topics", + )} ${role}${unread}\n`, + ); + } + return EXIT.SUCCESS; + }, + ); +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 02c63a3..d0eb1b0 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -123,6 +123,7 @@ Topic (conversation scope, v0.2.0) claudemesh topic tail live SSE tail [--limit --forward-only] claudemesh topic post encrypted REST post (v0.3.0 v2) [--reply-to ] claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext) + claudemesh me cross-mesh workspace overview (v0.4.0) claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -672,6 +673,25 @@ async function main(): Promise { break; } + // me — cross-mesh workspace overview (v0.4.0) + case "me": { + const sub = positionals[0]; + const f = { + mesh: flags.mesh as string, + json: !!flags.json, + }; + if (!sub || sub === "workspace" || sub === "overview") { + const { runMe } = await import("~/commands/me.js"); + process.exit(await runMe(f)); + } else { + console.error( + "Usage: claudemesh me (cross-mesh overview; future: me topics, me notifications, me activity)", + ); + process.exit(EXIT.INVALID_ARGS); + } + break; + } + // member — mesh roster with online state (v1.7.0) case "member": case "members": { const sub = positionals[0] ?? "list";