feat(cli): v1.7.0 — terminal parity for SSE + members + mentions
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

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:
Alejandro Gutiérrez
2026-05-02 20:02:29 +01:00
parent c31a591681
commit dd80d4e946
8 changed files with 494 additions and 3 deletions

View 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
}
}
});
}