diff --git a/.artifacts/specs/2026-04-15-claude-code-rich-channel-ui-request.md b/.artifacts/specs/2026-04-15-claude-code-rich-channel-ui-request.md new file mode 100644 index 0000000..2c47f14 --- /dev/null +++ b/.artifacts/specs/2026-04-15-claude-code-rich-channel-ui-request.md @@ -0,0 +1,71 @@ +# Feature request draft: rich `` notification UI + +**Target:** `anthropics/claude-code` GitHub issues / feedback channel. +**Drafted:** 2026-04-15. + +Paste the section below once the issue template is ready. Adjust tone +to match Claude Code's issue style. + +--- + +### Title + +Rich UI for `notifications/claude/channel` messages (first-class chat, not just reminders) + +### Body + +**Summary** + +MCP servers can emit `notifications/claude/channel` notifications which +Claude Code renders inside the current turn as a `` reminder. +For MCP servers that are conversational in nature (peer messaging, +collaborative sessions, delegated agents), rendering these inline as +plain-text reminders misses the UX affordances users expect from chat: + +- sender avatar / identity +- timestamp +- priority badge (urgent / normal / low) +- expandable quote from the original thread +- optional inline reply action that calls a specific MCP tool + +**Concrete use case** + +[claudemesh](https://claudemesh.com) is a peer mesh for Claude Code +sessions. When a peer sends a message it arrives as +`notifications/claude/channel` with structured metadata in `meta`: + +```json +{ + "method": "notifications/claude/channel", + "params": { + "content": "alice: can you rebase main before deploy?", + "meta": { + "from_id": "", + "from_name": "alice", + "priority": "now", + "sent_at": "2026-04-15T00:00:00Z", + "mesh_slug": "team-platform", + "kind": "direct" + } + } +} +``` + +Today this renders as a `` text block — useful, but the user +can't tell at a glance that it's from another human. + +**What we'd like** + +A hint on the notification (e.g. `meta.display: "chat"`) that lets +Claude Code render it as a chat bubble with the `from_name` as the +speaker, priority visualised, and an optional "Reply" action bound to +a declared MCP tool (`reply_tool_name`). + +**Why users would benefit beyond claudemesh** + +- Delegated agent frameworks can render sub-agent responses as chat +- Live-pairing MCP servers get a proper UI without inventing their own +- The existing `` fallback means older clients still see + the same text — additive, not breaking + +**Willing to contribute a PR** if the feature is on-roadmap. diff --git a/.artifacts/specs/2026-04-15-invite-v2-cli-migration.md b/.artifacts/specs/2026-04-15-invite-v2-cli-migration.md new file mode 100644 index 0000000..1a8189a --- /dev/null +++ b/.artifacts/specs/2026-04-15-invite-v2-cli-migration.md @@ -0,0 +1,84 @@ +# Invite v2 — CLI migration (server-side already shipped) + +## Current state + +**Server-side (broker) — DEPLOYED** +- `canonicalInviteV2` bytes format (crypto.ts) +- `verifyInviteV2` signature check +- `claimInviteV2Core` at `POST /invites/:code/claim` +- `sealRootKeyToRecipient` using crypto_box_seal +- Every v1 invite also stores `capability_v2` for cross-compat +- Web route `/api/public/invites/:code/claim` proxies to broker + +**Client-side (CLI) — NOT MIGRATED** +The CLI still uses the v1 flow (`enrollWithBroker`) which reads +`mesh_root_key` from the invite token's base64 payload. This means: +- Long URL `/join/` contains the root key +- Short URL `/i/` resolves to the long URL → still contains root key +- Anyone who can read the URL (history, screenshot, mail archive) has the key + +## The v2 CLI flow + +``` +parseInviteLinkV2(url) + → short URL /i/? GET /api/public/invite-code/:code + → returns `{ found, code, mesh_slug, broker_url, owner_pubkey, + canonical_v2, expires_at, role }` (NO root_key) + → generate local x25519 keypair (curve25519) + → POST /invites//claim { recipient_x25519_pubkey, display_name } + → broker verifies capability_v2 signature + → broker seals mesh.root_key with crypto_box_seal(root_key, our_pubkey) + → returns { sealed_root_key, mesh_id, member_id, owner_pubkey, canonical_v2 } + → open sealed_root_key with our x25519 secret key + → store root_key in ~/.claudemesh/config.json.meshes[].rootKey + (NOT in the invite link — it was never transmitted unsealed) + → upgrade enroll to use claim response instead of the /join endpoint +``` + +## What needs to change in the CLI + +1. **New file** `apps/cli/src/services/invite/parse-v2.ts` + - Detect short URL, resolve via `/api/public/invite-code/:code` + - Expect the API returns v2 shape (server already has this route; verify field names) + - Generate x25519 keypair via libsodium + - POST to claim endpoint + - Unseal root_key + +2. **Conditional in `parseInviteLink`** + - If URL is short-form and broker supports v2, use the new path + - Fall back to v1 for legacy long-form URLs in transit + +3. **Config schema** already has `rootKey` per mesh — just write from + unsealed bytes instead of from the token payload. + +4. **Spec test** `tests/golden/invite-v2.test.ts` + - Broker already has `claimInviteV2Core` tests; add a CLI-side + end-to-end that hits a local broker and verifies the sealed key + round-trips. + +## Why it wasn't rushed in this session + +Crypto code deserves review. The server-side v2 shipped weeks ago +with its own testing and audit; the CLI migration needs the same +rigor — at minimum, a test that proves the sealed key we unseal +matches the root_key the broker had in its DB, verified against +`canonical_v2` signature. + +The current v1 flow is a known quantity (the root_key-in-URL risk +is documented in the spec). Broker is already v2-ready so when the +CLI migration lands, emails / links can immediately start using the +claim-only short URL without a server deploy. + +## Rollout plan + +1. Ship CLI v2 path behind `CLAUDEMESH_INVITE_V2=1` env. +2. Dogfood: new invites generated by `claudemesh share` use `/api/public/invite-code/:code` with v2-shape response that omits token; CLI resolves via claim. +3. Verify with `claudemesh verify` safety numbers cross-check. +4. After 2 weeks uneventful, flip default to v2. +5. After 60 days, stop embedding root_key in long URLs entirely. +6. v3 (future): short URL becomes the only form. + +## Effort + +~1 day of focused crypto + testing. Broker work is done; API work is +done; CLI work is a new parse path + a new enroll path + a few tests. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c4e1bb..f5f3a1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,3 +45,12 @@ jobs: - name: 🧪 Test run: pnpm run test + + - name: 📦 Build CLI bundle (check size budget) + working-directory: apps/cli + run: pnpm run build + + - name: 🔧 CLI smoke — --version + --help + run: | + node apps/cli/dist/entrypoints/cli.js --version + node apps/cli/dist/entrypoints/cli.js --help | head -5 diff --git a/CLAUDE.md b/CLAUDE.md index d61ecfd..cd42d89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,12 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server. ## Structure -- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws` -- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server) +- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`. Runs drizzle migrations on startup under pg_advisory_lock. +- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server). Was `apps/cli-v2/` until 2026-04-15; legacy v0 at branch `legacy-cli-archive` + tag `cli-v0-legacy-final`. - `apps/web/` — Marketing site + dashboard at claudemesh.com - `docs/` — Protocol spec, quickstart, FAQ, roadmap +- `packaging/` — Homebrew formula + winget manifest templates +- `.github/workflows/release-cli.yml` — tag `cli-v*` → 5 platform binaries → GitHub Release with SHA256SUMS ## Key docs @@ -18,8 +20,10 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server. ## Deploy -- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"` -- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks` +- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`. Pending migrations apply automatically on startup. +- **CLI:** + - npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts` + - Binaries: `git tag cli-v && git push github cli-v` — workflow builds 5 platforms. - **Web:** Vercel auto-deploy on push to GitHub ## Dev diff --git a/apps/cli/src/commands/inbox.ts b/apps/cli/src/commands/inbox.ts index 406e310..31e62a5 100644 --- a/apps/cli/src/commands/inbox.ts +++ b/apps/cli/src/commands/inbox.ts @@ -7,6 +7,8 @@ import { withMesh } from "./connect.js"; import type { InboundPush } from "~/services/broker/facade.js"; +import { render } from "~/ui/render.js"; +import { bold, dim } from "~/ui/styles.js"; export interface InboxFlags { mesh?: string; @@ -14,47 +16,34 @@ export interface InboxFlags { wait?: number; } -function formatMessage(msg: InboundPush, useColor: boolean): string { - const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); - const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); - +function formatMessage(msg: InboundPush): string { const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`; const from = msg.senderPubkey.slice(0, 8); const time = new Date(msg.createdAt).toLocaleTimeString(); const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind; - return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`; } export async function runInbox(flags: InboxFlags): Promise { - const useColor = - !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; - const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); - const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); - const waitMs = (flags.wait ?? 1) * 1000; await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { - // Wait briefly for broker to push any held messages. await new Promise((resolve) => setTimeout(resolve, waitMs)); - const messages = client.drainPushBuffer(); if (flags.json) { - console.log(JSON.stringify(messages, null, 2)); + process.stdout.write(JSON.stringify(messages, null, 2) + "\n"); return; } if (messages.length === 0) { - console.log(dim(`No messages on mesh "${mesh.slug}".`)); + render.info(dim(`No messages on mesh "${mesh.slug}".`)); return; } - console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`)); - console.log(""); + render.section(`inbox — ${mesh.slug} (${messages.length} message${messages.length === 1 ? "" : "s"})`); for (const msg of messages) { - console.log(formatMessage(msg, useColor)); - console.log(""); + process.stdout.write(formatMessage(msg) + "\n\n"); } }); } diff --git a/apps/cli/src/commands/info.ts b/apps/cli/src/commands/info.ts index 801497b..81f5487 100644 --- a/apps/cli/src/commands/info.ts +++ b/apps/cli/src/commands/info.ts @@ -6,6 +6,7 @@ import { withMesh } from "./connect.js"; import { readConfig } from "~/services/config/facade.js"; +import { render } from "~/ui/render.js"; export interface InfoFlags { mesh?: string; @@ -13,11 +14,6 @@ export interface InfoFlags { } export async function runInfo(flags: InfoFlags): Promise { - const useColor = - !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY; - const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s); - const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s); - const config = readConfig(); await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => { @@ -39,20 +35,24 @@ export async function runInfo(flags: InfoFlags): Promise { }; if (flags.json) { - console.log(JSON.stringify(output, null, 2)); + process.stdout.write(JSON.stringify(output, null, 2) + "\n"); return; } - console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`)); - console.log(dim(` mesh: ${mesh.meshId}`)); - console.log(dim(` member: ${mesh.memberId}`)); - console.log(` peers: ${peers.length} connected`); - console.log(` state: ${state.length} keys`); + render.section(`${mesh.slug} · ${mesh.brokerUrl}`); + render.kv([ + ["mesh", mesh.meshId], + ["member", mesh.memberId], + ["peers", `${peers.length} connected`], + ["state", `${state.length} keys`], + ]); if (brokerInfo && typeof brokerInfo === "object") { + const extras: Array<[string, string]> = []; for (const [k, v] of Object.entries(brokerInfo)) { if (["slug", "meshId", "brokerUrl"].includes(k)) continue; - console.log(dim(` ${k}: ${JSON.stringify(v)}`)); + extras.push([k, JSON.stringify(v)]); } + if (extras.length) render.kv(extras); } }); } diff --git a/apps/web/src/app/install/route.ts b/apps/web/src/app/install/route.ts index 127eace..1e1110a 100644 --- a/apps/web/src/app/install/route.ts +++ b/apps/web/src/app/install/route.ts @@ -9,9 +9,10 @@ import { headers } from "next/headers"; -// In-memory counter (resets on deploy — good enough for a signal). -// For persistent tracking, write to DB or use PostHog server SDK. -let installFetches = 0; +// Persistent counts live in PostHog (event: install_script_fetched). +// Stdout logs are aggregated by the Coolify log collector. No in-memory +// counter — it reset on every deploy and misled operators into thinking +// the signal was fresh. const SCRIPT = `#!/usr/bin/env bash # claudemesh-cli installer @@ -140,19 +141,24 @@ say "" `; export async function GET(): Promise { - installFetches++; - - // Log server-side for monitoring + // Log server-side for Coolify log aggregation. const h = await headers(); const ua = h.get("user-agent") ?? "unknown"; const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown"; const referer = h.get("referer") ?? "direct"; - + const ts = new Date().toISOString(); console.log( - `[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`, + JSON.stringify({ + level: "info", + event: "install_script_fetched", + ts, + ip, + ua: ua.slice(0, 120), + referer, + }), ); - // PostHog server-side event (if configured) + // PostHog server-side event — source of truth for counts. try { const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; @@ -164,10 +170,10 @@ export async function GET(): Promise { api_key: posthogKey, event: "install_script_fetched", distinct_id: ip, + timestamp: ts, properties: { user_agent: ua, referer, - install_count: installFetches, }, }), }).catch(() => {}); // fire-and-forget diff --git a/docs/roadmap.md b/docs/roadmap.md index 46538dc..360a381 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -17,18 +17,42 @@ broker, ready for real teams. --- -## In progress — *v0.1.x* +## v1.0.0-alpha — *shipping now* -Security and onboarding work landing inside the v0.1 line, before -v0.2.0 cuts. +The ship-all push — Claude Code-grade CLI, zero-Node binary distribution, +end-to-end crypto backup, per-peer capability grants, self-update. -- **v2 invite protocol** — short opaque codes (`claudemesh.com/i/{code}`) - replace base64url URLs that embedded the mesh root key. The key is - now sealed to a recipient-controlled x25519 pubkey on claim, never in - a URL. v1 invites keep working through v0.1.x; removed at v0.2.0. -- **Email invites** — admins invite by email. A new `pending_invite` - table tracks `{email, code, sentAt, acceptedAt, revokedAt}`; - delivery goes through Postmark. +- **Single-binary distribution** — `curl -fsSL claudemesh.com/install | sh` + downloads the right binary (darwin/linux/windows × x64/arm64) when + Node isn't present. GitHub Releases auto-publishes on each `cli-v*` tag. +- **`claudemesh://` URL scheme** — invite emails become one-click. + `claudemesh url-handler install` registers the scheme per-OS. +- **`claudemesh `** — join + launch in one command. `-y` makes it + fully non-interactive for CI. +- **Live status line in Claude Code** — `◇ · N/M online` polled + from the MCP server's peer cache. Enable with + `claudemesh install --status-line`. +- **Per-peer capability grants** — `claudemesh grant/revoke/block/grants`. + Enforced server-side in the broker (silent drop) and client-side in + the MCP server. +- **Encrypted backup / restore** — `claudemesh backup` / `restore` with + Argon2id + XChaCha20-Poly1305. Portable `.cmb` recovery file. +- **Safety numbers** — `claudemesh verify ` shows a 30-digit SAS + derived from both ed25519 pubkeys, for out-of-band verification. +- **Shell completions** — `claudemesh completions zsh|bash|fish`. +- **QR on share** — `claudemesh share` prints a terminal QR for + phone-to-laptop pairing. +- **Self-update** — `claudemesh upgrade` reinstalls the latest alpha + via the npm that installed the running binary. +- **Auto-migrate on broker startup** — pending drizzle migrations apply + under `pg_advisory_lock` before the HTTP server binds. Exits non-zero + on failure so Coolify fails the healthcheck closed. +- **v2 invite protocol (broker + API)** — short opaque codes + (`/i/{code}`); broker seals `mesh_root_key` to a recipient x25519 + pubkey via `crypto_box_seal`. CLI migration tracked at + `.artifacts/specs/2026-04-15-invite-v2-cli-migration.md`. +- **Email invites** — admins invite by email via Postmark with a + branded react-email template. --- diff --git a/packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb b/packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb new file mode 100644 index 0000000..a17edb8 --- /dev/null +++ b/packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb @@ -0,0 +1,54 @@ +# Homebrew formula template — lives in the `alezmad/homebrew-claudemesh` tap. +# +# The release-cli workflow bumps `version`, `url`, and `sha256` per platform +# via `brew bump-formula-pr`. This template is the source shape — copy it +# into the tap repo as `Formula/claudemesh.rb` when bootstrapping, then let +# CI keep it up to date. + +class Claudemesh < Formula + desc "Peer mesh for Claude Code sessions" + homepage "https://claudemesh.com" + version "1.0.0-alpha.28" + license "MIT" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-darwin-arm64" + sha256 "REPLACED_BY_CI" + else + url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-darwin-x64" + sha256 "REPLACED_BY_CI" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-linux-arm64" + sha256 "REPLACED_BY_CI" + else + url "https://github.com/alezmad/claudemesh/releases/download/cli-v#{version}/claudemesh-linux-x64" + sha256 "REPLACED_BY_CI" + end + end + + def install + bin.install Dir["*"].first => "claudemesh" + end + + def caveats + <<~EOS + To enable click-to-launch from invite emails: + claudemesh url-handler install + + To show live peer count in Claude Code: + claudemesh install --status-line + + Shell completions: + claudemesh completions zsh > "$(brew --prefix)/share/zsh/site-functions/_claudemesh" + EOS + end + + test do + assert_match "claudemesh", shell_output("#{bin}/claudemesh --version") + end +end diff --git a/packaging/homebrew-tap-bootstrap/README.md b/packaging/homebrew-tap-bootstrap/README.md new file mode 100644 index 0000000..ab71626 --- /dev/null +++ b/packaging/homebrew-tap-bootstrap/README.md @@ -0,0 +1,38 @@ +# Bootstrapping the `homebrew-claudemesh` tap + +A Homebrew tap is just a GitHub repo named `homebrew-` in the +organization whose formulas you want to expose. Users add it with: + +``` +brew tap alezmad/claudemesh +brew install claudemesh +``` + +## One-time setup + +1. Create a public GitHub repo called **`homebrew-claudemesh`** under the + `alezmad` account (or any organization Homebrew can resolve — the name + after `homebrew-` is the "tap" users type, and the owner is the namespace). +2. Copy `packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb` from THIS + repo into the tap's `Formula/claudemesh.rb`. +3. Fill in the `sha256` placeholders. For each platform, run: + ``` + curl -sL https://github.com/alezmad/claudemesh/releases/download/cli-v1.0.0/claudemesh-darwin-arm64 | sha256sum + ``` + (And so on for the three other platforms.) +4. Commit + push. Users can now `brew tap alezmad/claudemesh && brew install claudemesh`. + +## Keeping it up to date + +The release workflow (`.github/workflows/release-cli.yml`) has an +`update-homebrew` job that fires on non-prerelease tags. It calls +`brew bump-formula-pr` against the tap, which opens a PR with updated +`url`, `version`, and `sha256` entries. + +Requires a `HOMEBREW_TAP_TOKEN` secret on the repo — a PAT scoped to the +tap repo with `contents:write`. + +## Why not auto-create the repo from this workflow? + +Repo creation needs org-level permissions that shouldn't live in CI. One +manual step, then everything after is automatic.