feat(cli): v1.7.0 — terminal parity for SSE + members + mentions
Three new verbs that wrap the v1.6.x REST surface: claudemesh topic tail <name> → live SSE consumer with N-message backfill claudemesh member list → mesh roster decorated with online state claudemesh notification list → recent @-mentions of you across topics Each command auto-mints a 5-minute read-only apikey via the WS broker and revokes on exit, so users don't manage tokens. SSE client uses fetch + ReadableStream so the bearer stays in the Authorization header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<channel>` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill.
|
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 `<channel>` 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.
|
> **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 profile view or edit your profile
|
||||||
|
|
||||||
claudemesh topic ... create, list, join, send to topics
|
claudemesh topic ... create, list, join, send to topics
|
||||||
|
claudemesh topic tail <t> 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 apikey ... issue, list, revoke API keys (REST clients)
|
||||||
claudemesh bridge ... forward a topic between two meshes
|
claudemesh bridge ... forward a topic between two meshes
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -26,4 +26,7 @@ export { runWelcome } from "./welcome.js";
|
|||||||
export { runHook } from "./hook.js";
|
export { runHook } from "./hook.js";
|
||||||
export { runMcp } from "./mcp.js";
|
export { runMcp } from "./mcp.js";
|
||||||
export { runSeedTestMesh } from "./seed-test-mesh.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";
|
export { withMesh } from "./connect.js";
|
||||||
|
|||||||
78
apps/cli/src/commands/member.ts
Normal file
78
apps/cli/src/commands/member.ts
Normal file
@@ -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<number> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/cli/src/commands/notification.ts
Normal file
93
apps/cli/src/commands/notification.ts
Normal file
@@ -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<number> {
|
||||||
|
return withRestKey(
|
||||||
|
{ meshSlug: flags.mesh ?? null, purpose: "notifications" },
|
||||||
|
async ({ secret }) => {
|
||||||
|
const qs = flags.since ? `?since=${encodeURIComponent(flags.since)}` : "";
|
||||||
|
const result = await request<NotificationsResponse>({
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/cli/src/commands/topic-tail.ts
Normal file
196
apps/cli/src/commands/topic-tail.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh topic tail <name>` — 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<Uint8Array>,
|
||||||
|
): AsyncGenerator<SseEvent> {
|
||||||
|
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<number> {
|
||||||
|
if (!name) {
|
||||||
|
render.err("Usage: claudemesh topic tail <topic> [--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<HistoryResponse>({
|
||||||
|
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<Uint8Array>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -119,7 +119,10 @@ Topic (conversation scope, v0.2.0)
|
|||||||
claudemesh topic members <t> list topic subscribers
|
claudemesh topic members <t> list topic subscribers
|
||||||
claudemesh topic history <t> fetch message history [--limit --before]
|
claudemesh topic history <t> fetch message history [--limit --before]
|
||||||
claudemesh topic read <topic> mark all as read
|
claudemesh topic read <topic> mark all as read
|
||||||
|
claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
|
||||||
claudemesh send "#topic" "msg" send to a topic
|
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 <ISO>]
|
||||||
|
|
||||||
Schedule (resource form)
|
Schedule (resource form)
|
||||||
claudemesh schedule msg <m> one-shot or recurring (alias: remind)
|
claudemesh schedule msg <m> one-shot or recurring (alias: remind)
|
||||||
@@ -573,7 +576,53 @@ async function main(): Promise<void> {
|
|||||||
else if (sub === "members") { const { runTopicMembers } = await import("~/commands/topic.js"); process.exit(await runTopicMembers(arg, f)); }
|
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 === "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 if (sub === "read") { const { runTopicMarkRead } = await import("~/commands/topic.js"); process.exit(await runTopicMarkRead(arg, f)); }
|
||||||
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read>"); 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 <create|list|join|leave|members|history|read|tail>"); 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 <ISO>]");
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
apps/cli/src/services/api/with-rest-key.ts
Normal file
67
apps/cli/src/services/api/with-rest-key.ts
Normal file
@@ -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<T>(
|
||||||
|
opts: WithRestKeyOpts,
|
||||||
|
fn: (ctx: RestKeyContext) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user