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>
309 lines
8.6 KiB
TypeScript
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`;
|
|
}
|