Files
claudemesh/apps/cli/src/commands/me.ts
Alejandro Gutiérrez 43e429f204
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
feat(workspace): claudemesh me notifications + dashboard parity
ships v0.4.0 phase 3.

api: GET /v1/me/notifications aggregates the mesh.notification
table across every joined mesh in a 7-day window (?since=iso
overrides, ?include=all surfaces already-read). returns sender +
topic + mesh context plus a 240-char snippet for v1 plaintext
messages or raw ciphertext for v2 (the dashboard topic-key cache
decrypts client-side).

cli (1.12.0): claudemesh me notifications — terse unread feed
with @ dot, --all to include read, --since for custom window.

web: /dashboard/notifications mirrors the cli view in card form,
adds a notifications entry to the dashboard sidebar between
topics and invites. each card links straight to the topic chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:35:57 +01:00

309 lines
8.6 KiB
TypeScript

/**
* `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<number> {
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: "workspace-overview",
capabilities: ["read"],
},
async ({ secret }) => {
const ws = await request<WorkspaceResponse>({
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;
},
);
}
interface WorkspaceTopic {
topicId: string;
name: string;
description: string | null;
visibility: string;
createdAt: string;
meshId: string;
meshSlug: string;
meshName: string;
memberId: string;
unread: number;
lastMessageAt: string | null;
}
interface WorkspaceTopicsResponse {
topics: WorkspaceTopic[];
totals: { topics: number; unread: number };
}
export interface MeTopicsFlags extends MeFlags {
unread?: boolean;
}
export async function runMeTopics(flags: MeTopicsFlags): Promise<number> {
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: "workspace-topics",
capabilities: ["read"],
},
async ({ secret }) => {
const ws = await request<WorkspaceTopicsResponse>({
path: "/api/v1/me/topics",
token: secret,
});
const visible = flags.unread
? ws.topics.filter((t) => t.unread > 0)
: ws.topics;
if (flags.json) {
console.log(
JSON.stringify(
{ topics: visible, totals: ws.totals },
null,
2,
),
);
return EXIT.SUCCESS;
}
render.section(
`${clay("topics")}${ws.totals.topics} across all meshes ${dim(
ws.totals.unread > 0
? `· ${ws.totals.unread} unread`
: "· all read",
)}`,
);
if (visible.length === 0) {
process.stdout.write(
dim(
flags.unread
? " no unread topics\n"
: " no topics — run `claudemesh topic create #general`\n",
),
);
return EXIT.SUCCESS;
}
const slugWidth = Math.max(...visible.map((t) => t.meshSlug.length), 6);
const nameWidth = Math.max(...visible.map((t) => t.name.length), 8);
for (const t of visible) {
const slug = dim(t.meshSlug.padEnd(slugWidth));
const name = cyan(t.name.padEnd(nameWidth));
const unread =
t.unread > 0
? yellow(`${t.unread} unread`.padStart(10))
: dim("·".padStart(10));
const last = t.lastMessageAt
? dim(formatRelativeTime(t.lastMessageAt))
: dim("never");
process.stdout.write(` ${slug} ${name} ${unread} ${last}\n`);
}
return EXIT.SUCCESS;
},
);
}
interface WorkspaceNotification {
notificationId: string;
messageId: string;
topicId: string;
topicName: string;
meshId: string;
meshSlug: string;
meshName: string;
senderName: string | null;
snippet: string | null;
ciphertext: string | null;
bodyVersion: number;
read: boolean;
readAt: string | null;
createdAt: string;
}
interface WorkspaceNotificationsResponse {
notifications: WorkspaceNotification[];
totals: { unread: number; total: number };
}
export interface MeNotificationsFlags extends MeFlags {
all?: boolean;
since?: string;
}
export async function runMeNotifications(
flags: MeNotificationsFlags,
): Promise<number> {
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: "workspace-notifications",
capabilities: ["read"],
},
async ({ secret }) => {
const params = new URLSearchParams();
if (flags.all) params.set("include", "all");
if (flags.since) params.set("since", flags.since);
const path =
"/api/v1/me/notifications" +
(params.toString() ? `?${params.toString()}` : "");
const ws = await request<WorkspaceNotificationsResponse>({
path,
token: secret,
});
if (flags.json) {
console.log(JSON.stringify(ws, null, 2));
return EXIT.SUCCESS;
}
const headerLabel = flags.all ? "@-mentions (all)" : "@-mentions (unread)";
render.section(
`${clay(headerLabel)}${ws.totals.total} ${dim(
ws.totals.unread > 0 ? `· ${ws.totals.unread} unread` : "· nothing pending",
)}`,
);
if (ws.notifications.length === 0) {
process.stdout.write(
dim(
flags.all
? " no @-mentions in window\n"
: " inbox zero — nothing waiting\n",
),
);
return EXIT.SUCCESS;
}
const slugWidth = Math.max(
...ws.notifications.map((n) => n.meshSlug.length),
6,
);
for (const n of ws.notifications) {
const slug = dim(n.meshSlug.padEnd(slugWidth));
const topic = cyan(`#${n.topicName}`);
const sender = n.senderName ? `from ${n.senderName}` : "from ?";
const ago = formatRelativeTime(n.createdAt);
const dot = n.read ? dim("·") : yellow("●");
const snippet =
n.snippet ?? (n.ciphertext ? dim("[encrypted]") : dim("[empty]"));
process.stdout.write(
` ${dot} ${slug} ${topic} ${dim(sender)} ${dim(ago)}\n` +
` ${snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet}\n`,
);
}
return EXIT.SUCCESS;
},
);
}
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();
const sec = Math.max(0, Math.floor((now - then) / 1000));
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`;
if (sec < 86_400 * 365)
return `${Math.floor(sec / (86_400 * 30))}mo ago`;
return `${Math.floor(sec / (86_400 * 365))}y ago`;
}