chore: wrap up the gap-closing session
- info/inbox commands → unified render.ts - install route: drop in-memory counter, rely on PostHog + structured logs - docs: roadmap, CLAUDE.md reflect alpha.31 state - tests workflow now also builds + smoke-tests the CLI bundle - homebrew tap bootstrap kit in packaging/homebrew-tap-bootstrap/ (README + copy of the formula template for dropping into the tap repo) - upstream Claude Code issue draft for rich <channel> UI Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
# Feature request draft: rich `<channel>` 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 `<channel>` 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": "<ed25519 hex>",
|
||||||
|
"from_name": "alice",
|
||||||
|
"priority": "now",
|
||||||
|
"sent_at": "2026-04-15T00:00:00Z",
|
||||||
|
"mesh_slug": "team-platform",
|
||||||
|
"kind": "direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Today this renders as a `<channel>` 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 `<channel>` fallback means older clients still see
|
||||||
|
the same text — additive, not breaking
|
||||||
|
|
||||||
|
**Willing to contribute a PR** if the feature is on-roadmap.
|
||||||
84
.artifacts/specs/2026-04-15-invite-v2-cli-migration.md
Normal file
84
.artifacts/specs/2026-04-15-invite-v2-cli-migration.md
Normal file
@@ -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/<token>` contains the root key
|
||||||
|
- Short URL `/i/<code>` 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/<code>? 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/<code>/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.
|
||||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -45,3 +45,12 @@ jobs:
|
|||||||
|
|
||||||
- name: 🧪 Test
|
- name: 🧪 Test
|
||||||
run: pnpm run 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
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -4,10 +4,12 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
|
- `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)
|
- `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
|
- `apps/web/` — Marketing site + dashboard at claudemesh.com
|
||||||
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
- `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
|
## Key docs
|
||||||
|
|
||||||
@@ -18,8 +20,10 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
|||||||
|
|
||||||
## Deploy
|
## 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"`
|
- **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:** `cd apps/cli && pnpm publish --access public --no-git-checks`
|
- **CLI:**
|
||||||
|
- npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts`
|
||||||
|
- Binaries: `git tag cli-v<version> && git push github cli-v<version>` — workflow builds 5 platforms.
|
||||||
- **Web:** Vercel auto-deploy on push to GitHub
|
- **Web:** Vercel auto-deploy on push to GitHub
|
||||||
|
|
||||||
## Dev
|
## Dev
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import { withMesh } from "./connect.js";
|
import { withMesh } from "./connect.js";
|
||||||
import type { InboundPush } from "~/services/broker/facade.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 {
|
export interface InboxFlags {
|
||||||
mesh?: string;
|
mesh?: string;
|
||||||
@@ -14,47 +16,34 @@ export interface InboxFlags {
|
|||||||
wait?: number;
|
wait?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(msg: InboundPush, useColor: boolean): string {
|
function formatMessage(msg: InboundPush): string {
|
||||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
|
||||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
|
||||||
|
|
||||||
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
||||||
const from = msg.senderPubkey.slice(0, 8);
|
const from = msg.senderPubkey.slice(0, 8);
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString();
|
const time = new Date(msg.createdAt).toLocaleTimeString();
|
||||||
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
||||||
|
|
||||||
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runInbox(flags: InboxFlags): Promise<void> {
|
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||||
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;
|
const waitMs = (flags.wait ?? 1) * 1000;
|
||||||
|
|
||||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
// Wait briefly for broker to push any held messages.
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||||
|
|
||||||
const messages = client.drainPushBuffer();
|
const messages = client.drainPushBuffer();
|
||||||
|
|
||||||
if (flags.json) {
|
if (flags.json) {
|
||||||
console.log(JSON.stringify(messages, null, 2));
|
process.stdout.write(JSON.stringify(messages, null, 2) + "\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
render.info(dim(`No messages on mesh "${mesh.slug}".`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
render.section(`inbox — ${mesh.slug} (${messages.length} message${messages.length === 1 ? "" : "s"})`);
|
||||||
console.log("");
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
console.log(formatMessage(msg, useColor));
|
process.stdout.write(formatMessage(msg) + "\n\n");
|
||||||
console.log("");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { withMesh } from "./connect.js";
|
import { withMesh } from "./connect.js";
|
||||||
import { readConfig } from "~/services/config/facade.js";
|
import { readConfig } from "~/services/config/facade.js";
|
||||||
|
import { render } from "~/ui/render.js";
|
||||||
|
|
||||||
export interface InfoFlags {
|
export interface InfoFlags {
|
||||||
mesh?: string;
|
mesh?: string;
|
||||||
@@ -13,11 +14,6 @@ export interface InfoFlags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runInfo(flags: InfoFlags): Promise<void> {
|
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||||
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();
|
const config = readConfig();
|
||||||
|
|
||||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
@@ -39,20 +35,24 @@ export async function runInfo(flags: InfoFlags): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (flags.json) {
|
if (flags.json) {
|
||||||
console.log(JSON.stringify(output, null, 2));
|
process.stdout.write(JSON.stringify(output, null, 2) + "\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
render.section(`${mesh.slug} · ${mesh.brokerUrl}`);
|
||||||
console.log(dim(` mesh: ${mesh.meshId}`));
|
render.kv([
|
||||||
console.log(dim(` member: ${mesh.memberId}`));
|
["mesh", mesh.meshId],
|
||||||
console.log(` peers: ${peers.length} connected`);
|
["member", mesh.memberId],
|
||||||
console.log(` state: ${state.length} keys`);
|
["peers", `${peers.length} connected`],
|
||||||
|
["state", `${state.length} keys`],
|
||||||
|
]);
|
||||||
if (brokerInfo && typeof brokerInfo === "object") {
|
if (brokerInfo && typeof brokerInfo === "object") {
|
||||||
|
const extras: Array<[string, string]> = [];
|
||||||
for (const [k, v] of Object.entries(brokerInfo)) {
|
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||||
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
// In-memory counter (resets on deploy — good enough for a signal).
|
// Persistent counts live in PostHog (event: install_script_fetched).
|
||||||
// For persistent tracking, write to DB or use PostHog server SDK.
|
// Stdout logs are aggregated by the Coolify log collector. No in-memory
|
||||||
let installFetches = 0;
|
// counter — it reset on every deploy and misled operators into thinking
|
||||||
|
// the signal was fresh.
|
||||||
|
|
||||||
const SCRIPT = `#!/usr/bin/env bash
|
const SCRIPT = `#!/usr/bin/env bash
|
||||||
# claudemesh-cli installer
|
# claudemesh-cli installer
|
||||||
@@ -140,19 +141,24 @@ say ""
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
installFetches++;
|
// Log server-side for Coolify log aggregation.
|
||||||
|
|
||||||
// Log server-side for monitoring
|
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
const ua = h.get("user-agent") ?? "unknown";
|
const ua = h.get("user-agent") ?? "unknown";
|
||||||
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
|
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
|
||||||
const referer = h.get("referer") ?? "direct";
|
const referer = h.get("referer") ?? "direct";
|
||||||
|
const ts = new Date().toISOString();
|
||||||
console.log(
|
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 {
|
try {
|
||||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||||
@@ -164,10 +170,10 @@ export async function GET(): Promise<Response> {
|
|||||||
api_key: posthogKey,
|
api_key: posthogKey,
|
||||||
event: "install_script_fetched",
|
event: "install_script_fetched",
|
||||||
distinct_id: ip,
|
distinct_id: ip,
|
||||||
|
timestamp: ts,
|
||||||
properties: {
|
properties: {
|
||||||
user_agent: ua,
|
user_agent: ua,
|
||||||
referer,
|
referer,
|
||||||
install_count: installFetches,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}).catch(() => {}); // fire-and-forget
|
}).catch(() => {}); // fire-and-forget
|
||||||
|
|||||||
@@ -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
|
The ship-all push — Claude Code-grade CLI, zero-Node binary distribution,
|
||||||
v0.2.0 cuts.
|
end-to-end crypto backup, per-peer capability grants, self-update.
|
||||||
|
|
||||||
- **v2 invite protocol** — short opaque codes (`claudemesh.com/i/{code}`)
|
- **Single-binary distribution** — `curl -fsSL claudemesh.com/install | sh`
|
||||||
replace base64url URLs that embedded the mesh root key. The key is
|
downloads the right binary (darwin/linux/windows × x64/arm64) when
|
||||||
now sealed to a recipient-controlled x25519 pubkey on claim, never in
|
Node isn't present. GitHub Releases auto-publishes on each `cli-v*` tag.
|
||||||
a URL. v1 invites keep working through v0.1.x; removed at v0.2.0.
|
- **`claudemesh://` URL scheme** — invite emails become one-click.
|
||||||
- **Email invites** — admins invite by email. A new `pending_invite`
|
`claudemesh url-handler install` registers the scheme per-OS.
|
||||||
table tracks `{email, code, sentAt, acceptedAt, revokedAt}`;
|
- **`claudemesh <url>`** — join + launch in one command. `-y` makes it
|
||||||
delivery goes through Postmark.
|
fully non-interactive for CI.
|
||||||
|
- **Live status line in Claude Code** — `◇ <mesh> · 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 <peer>` 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
54
packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb
Normal file
54
packaging/homebrew-tap-bootstrap/Formula/claudemesh.rb
Normal file
@@ -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
|
||||||
38
packaging/homebrew-tap-bootstrap/README.md
Normal file
38
packaging/homebrew-tap-bootstrap/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Bootstrapping the `homebrew-claudemesh` tap
|
||||||
|
|
||||||
|
A Homebrew tap is just a GitHub repo named `homebrew-<anything>` 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.
|
||||||
Reference in New Issue
Block a user