8 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
de684c44bb feat(web): payload cms v3 + blog + changelog data model
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:22:40 +01:00
Alejandro Gutiérrez
66b9696b2d test(cli): add crypto roundtrip and invite parse tests
Cover encryptDirect/decryptDirect with three scenarios (happy path,
wrong recipient, tampered ciphertext) and invite link parsing with
round-trip, expiry rejection, and malformed input handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:18:27 +01:00
Alejandro Gutiérrez
09c5d759fa fix(cli): rename duplicate setStatus to setConnStatus in BrokerClient
The private setStatus(ConnStatus) conflicted with the public
setStatus("idle"|"working"|"dnd") method, causing TS2393 under strict
typecheck. Rename the private one to setConnStatus and update all
internal call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:18:22 +01:00
Alejandro Gutiérrez
a1c6c6dc6a fix(web): hero honesty + logo bar + FAQ accuracy
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 surgical edits for credibility:

Hero subheadline: remove WhatsApp/Slack/phone promises (roadmap,
not shipped), replace "reachable from anywhere you are" (vague)
with concrete value prop: E2E encrypted, delivered mid-turn as
<channel> reminders, broker never sees plaintext. Change "Free
and open-source. Forever." → "Open-source CLI. Free during
public beta." to match the pricing section.

Logo bar: remove Vercel/Linear/Stripe/Supabase/Shopify/Figma
(not actual customers). Replace with tech stack labels: Claude
Code, MCP, libsodium, Bun, TypeScript, MIT.

FAQ: fix "Is claudemesh free?" to match beta pricing. Fix "How
do I get started?" to reference the real curl installer instead
of nonexistent npx claudemesh init. Fix "Which Claude Code
versions?" to name actual install + launch flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:13:16 +01:00
Alejandro Gutiérrez
00b5ba8190 feat(web): /install shell script + real curl one-liner on landing
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
Landing page showed \`curl -fsSL claudemesh.sh/install | bash\` but
the domain didn't resolve, so anyone copy-pasting got a DNS error.

Ship:
- apps/web/src/app/install/route.ts: GET returns an auditable bash
  installer (Node preflight, npm install -g claudemesh-cli, runs
  claudemesh install, prints next steps, colored output). No Node
  auto-install — fails clean if missing with a pointer.
- apps/web/src/proxy.ts: exclude /install from the i18n matcher so
  Next.js returns the shell script unmangled.
- hero.tsx + features.tsx: swap claudemesh.sh → claudemesh.com.

Test: curl http://localhost:3000/install | bash -n → OK.
Content-Type: text/x-shellscript; charset=utf-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:37:39 +01:00
Alejandro Gutiérrez
ccff802163 fix(web): rewrite pricing to match shipped product (honest beta tier)
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
The 6-tier grid was selling features that don't exist yet:
- Pro \$12/mo: dashboard, peer registry, message history (not built)
- Plus \$24/mo: Tailscale mesh (already default), MCP bridge (free),
  audit log (not built)
- Team \$99/mo: \"self-hosted broker\" AND \"25 peers\" AND
  \"unlimited peers\" — three contradictions in one tier
- Business \$499/mo: multi-region, retention, Slack/Linear (roadmap)
- Enterprise: claimed \"SOC 2 pack\" without certification

Replaced with a single Public-Beta card:
- Free, no card required
- Two columns: Shipping today (verified against source) + Roadmap
  v0.2–v0.3 (clearly labeled)
- Promise: \"Beta users keep the free plan for life\"

Non-additive rewrite of a shipped section. Authorized by user
explicitly; required because the prior pricing created refund +
legal risk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:32:48 +01:00
Alejandro Gutiérrez
231618c595 fix(web): replace 9 placeholder # links + 2 jargon phrases
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Surgical fixes on shipped marketing sections. All changes are link
targets (9) or two-word replacements (2) — no structural edits.

Links:
- "Read the docs" / "documentation" (hero, cta, features) → point
  to the public CLI repo README (canonical docs until /docs exists)
- "Pair your machines" (laptop-to-laptop) → /auth/register
- "Open the dashboard" (surfaces, meets-you) → /dashboard
- "Install" (meets-you) → CLI repo README install section
- "VS Code" / "JetBrains" (meets-you) → CLI repo README (MCP setup)

Copy:
- "self-nominates" → "volunteers"
- "surfaces the history" → "shares the history"

Additive polish per the v0.1.0 web prototyping rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:27:36 +01:00
Alejandro Gutiérrez
f698aaeac7 feat(cli): stateful welcome screen + v0.1.4 bump
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
Running \`claudemesh\` with no args now detects install state and
prints context-appropriate guidance: suggests \`install\` if MCP
not registered, \`join\` if no meshes, \`launch\` if ready.
Replaces the static HELP dump with a first-run wizard that meets
users where they are.

Static HELP still available via --help.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:19:27 +01:00
35 changed files with 3283 additions and 511 deletions

5
.gitignore vendored
View File

@@ -67,3 +67,8 @@ dist/
# Auto Claude data directory # Auto Claude data directory
.auto-claude/ .auto-claude/
# Payload CMS
apps/web/payload.db
apps/web/public/media/*
!apps/web/public/media/.gitkeep

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.1.3", "version": "0.1.4",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { encryptDirect, decryptDirect } from "../crypto/envelope";
import { generateKeypair } from "../crypto/keypair";
describe("crypto roundtrip", () => {
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const plaintext = "hello world";
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
expect(decrypted).toBe(plaintext);
});
it("Carol cannot decrypt a message encrypted for Bob", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const carol = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
expect(decrypted).toBeNull();
});
it("tampered ciphertext returns null on decrypt", async () => {
const alice = await generateKeypair();
const bob = await generateKeypair();
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
// Flip a byte in the ciphertext
const raw = Buffer.from(envelope.ciphertext, "base64");
raw[0] = raw[0]! ^ 0xff;
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
expect(decrypted).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import {
parseInviteLink,
buildSignedInvite,
extractInviteToken,
} from "../invite/parse";
import { generateKeypair } from "../crypto/keypair";
describe("invite parse", () => {
it("round-trips a signed invite through encode and parse", async () => {
const owner = await generateKeypair();
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const { link, payload } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-abc-123",
mesh_slug: "test-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiresAt,
mesh_root_key: "deadbeefcafebabe",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
const parsed = await parseInviteLink(link);
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
expect(parsed.payload.mesh_slug).toBe("test-mesh");
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
expect(parsed.payload.expires_at).toBe(expiresAt);
expect(parsed.payload.role).toBe("member");
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
expect(parsed.payload.signature).toBe(payload.signature);
});
it("rejects an expired invite", async () => {
const owner = await generateKeypair();
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
const { link } = await buildSignedInvite({
v: 1,
mesh_id: "mesh-expired",
mesh_slug: "expired-mesh",
broker_url: "wss://broker.example.com",
expires_at: expiredAt,
mesh_root_key: "deadbeef",
role: "member",
owner_pubkey: owner.publicKey,
owner_secret_key: owner.secretKey,
});
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
});
it("rejects malformed base64 in invite URL", async () => {
// Empty payload after ic://join/ should throw.
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
// Short garbage that doesn't match any format should throw.
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
// A sufficiently long but garbage base64url token that decodes to
// invalid JSON should throw at the JSON parse stage.
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,111 @@
/**
* Stateful welcome screen — shown when the user runs `claudemesh`
* with no arguments. Detects install state + joined meshes + prints
* the next action they should take.
*
* States, in priority order:
* 1. MCP not registered in ~/.claude.json → run install
* 2. Config dir exists but no meshes joined → run join
* 3. Meshes joined, all reachable → run launch
* 4. Meshes joined, broker unreachable → run status / doctor
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "../state/config";
import { VERSION } from "../version";
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
function detectState(): State {
// 1. MCP registered?
const claudeConfig = join(homedir(), ".claude.json");
let mcpRegistered = false;
if (existsSync(claudeConfig)) {
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
} catch {
/* treat parse errors as not-registered */
}
}
if (!mcpRegistered) return "no-install";
// 2. Config parseable + has meshes?
try {
const cfg = loadConfig();
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
} catch {
return "broken-config";
}
}
export function runWelcome(): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
console.log("─".repeat(60));
const state = detectState();
switch (state) {
case "no-install":
console.log("Welcome. Let's get you set up.");
console.log("");
console.log(bold("Step 1:") + " register the MCP server + status hooks");
console.log(` ${green("$")} claudemesh install`);
console.log("");
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
console.log(dim("Step 3: claudemesh launch"));
break;
case "no-meshes":
console.log(green("✓") + " MCP registered. Now join a mesh.");
console.log("");
console.log(bold("Step 2:") + " join a mesh");
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
console.log("");
console.log(
dim(" Don't have an invite? Create one at ") +
bold("https://claudemesh.com") +
dim(" or ask a mesh owner."),
);
console.log("");
console.log(dim("Step 3 (after joining): claudemesh launch"));
break;
case "ready": {
const cfg = loadConfig();
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
console.log(green("✓") + " MCP registered.");
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
console.log("");
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
console.log(` ${green("$")} claudemesh launch`);
console.log("");
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
console.log("");
console.log(dim("Health check: claudemesh status"));
console.log(dim("Diagnostics: claudemesh doctor"));
console.log(dim("All commands: claudemesh --help"));
break;
}
case "broken-config":
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
console.log("");
console.log("Run diagnostics to see what's wrong:");
console.log(` ${green("$")} claudemesh doctor`);
break;
}
console.log("");
}

View File

@@ -18,6 +18,7 @@ import { runHook } from "./commands/hook";
import { runLaunch } from "./commands/launch"; import { runLaunch } from "./commands/launch";
import { runStatus } from "./commands/status"; import { runStatus } from "./commands/status";
import { runDoctor } from "./commands/doctor"; import { runDoctor } from "./commands/doctor";
import { runWelcome } from "./commands/welcome";
import { VERSION } from "./version"; import { VERSION } from "./version";
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
@@ -94,9 +95,11 @@ async function main(): Promise<void> {
case "--help": case "--help":
case "-h": case "-h":
case "help": case "help":
case undefined:
console.log(HELP); console.log(HELP);
return; return;
case undefined:
runWelcome();
return;
default: default:
console.error(`Unknown command: ${cmd}`); console.error(`Unknown command: ${cmd}`);
console.error("Run `claudemesh --help` for usage."); console.error("Run `claudemesh --help` for usage.");

View File

@@ -87,7 +87,7 @@ export async function startMcpServer(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
const server = new Server( const server = new Server(
{ name: "claudemesh", version: "0.1.3" }, { name: "claudemesh", version: "0.1.4" },
{ {
capabilities: { capabilities: {
experimental: { "claude/channel": {} }, experimental: { "claude/channel": {} },

View File

@@ -93,7 +93,7 @@ export class BrokerClient {
/** Open WS, send hello, resolve when hello_ack received. */ /** Open WS, send hello, resolve when hello_ack received. */
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.closed) throw new Error("client is closed"); if (this.closed) throw new Error("client is closed");
this.setStatus("connecting"); this.setConnStatus("connecting");
const ws = new WebSocket(this.mesh.brokerUrl); const ws = new WebSocket(this.mesh.brokerUrl);
this.ws = ws; this.ws = ws;
@@ -146,7 +146,7 @@ export class BrokerClient {
if (msg.type === "hello_ack") { if (msg.type === "hello_ack") {
if (this.helloTimer) clearTimeout(this.helloTimer); if (this.helloTimer) clearTimeout(this.helloTimer);
this.helloTimer = null; this.helloTimer = null;
this.setStatus("open"); this.setConnStatus("open");
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.flushOutbound(); this.flushOutbound();
resolve(); resolve();
@@ -163,7 +163,7 @@ export class BrokerClient {
reject(new Error("ws closed before hello_ack")); reject(new Error("ws closed before hello_ack"));
} }
if (!this.closed) this.scheduleReconnect(); if (!this.closed) this.scheduleReconnect();
else this.setStatus("closed"); else this.setConnStatus("closed");
}; };
const onError = (err: Error): void => { const onError = (err: Error): void => {
@@ -277,7 +277,7 @@ export class BrokerClient {
/* ignore */ /* ignore */
} }
} }
this.setStatus("closed"); this.setConnStatus("closed");
} }
// --- Internals --- // --- Internals ---
@@ -373,7 +373,7 @@ export class BrokerClient {
} }
private scheduleReconnect(): void { private scheduleReconnect(): void {
this.setStatus("reconnecting"); this.setConnStatus("reconnecting");
const delay = const delay =
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!; BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
this.reconnectAttempt += 1; this.reconnectAttempt += 1;
@@ -388,7 +388,7 @@ export class BrokerClient {
}, delay); }, delay);
} }
private setStatus(s: ConnStatus): void { private setConnStatus(s: ConnStatus): void {
if (this._status === s) return; if (this._status === s) return;
this._status = s; this._status = s;
this.opts.onStatusChange?.(s); this.opts.onStatusChange?.(s);

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
},
});

View File

@@ -1,4 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { withPayload } from "@payloadcms/next/withPayload";
import env from "./env.config"; import env from "./env.config";
@@ -124,4 +125,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: env.ANALYZE, enabled: env.ANALYZE,
}); });
export default withBundleAnalyzer(config); export default withPayload(withBundleAnalyzer(config));

View File

@@ -20,6 +20,9 @@
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@next/bundle-analyzer": "16.0.10", "@next/bundle-analyzer": "16.0.10",
"@number-flow/react": "0.5.10", "@number-flow/react": "0.5.10",
"@payloadcms/db-sqlite": "^3.81.0",
"@payloadcms/next": "^3.81.0",
"@payloadcms/richtext-lexical": "^3.81.0",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-table": "catalog:", "@tanstack/react-table": "catalog:",
@@ -44,6 +47,7 @@
"next-i18n-router": "5.5.5", "next-i18n-router": "5.5.5",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nuqs": "2.7.2", "nuqs": "2.7.2",
"payload": "^3.81.0",
"pdfjs-dist": "5.4.530", "pdfjs-dist": "5.4.530",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "catalog:react19", "react": "catalog:react19",
@@ -57,6 +61,7 @@
"rehype-raw": "7.0.0", "rehype-raw": "7.0.0",
"remark-gfm": "4.0.1", "remark-gfm": "4.0.1",
"remark-math": "6.0.0", "remark-math": "6.0.0",
"sharp": "0.34.5",
"sonner": "2.0.7", "sonner": "2.0.7",
"zod": "catalog:", "zod": "catalog:",
"zustand": "5.0.8" "zustand": "5.0.8"

199
apps/web/payload.config.ts Normal file
View File

@@ -0,0 +1,199 @@
import { buildConfig } from "payload";
import { sqliteAdapter } from "@payloadcms/db-sqlite";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path";
import { fileURLToPath } from "url";
import sharp from "sharp";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
secret: process.env.PAYLOAD_SECRET || "claudemesh-dev-secret-change-in-production",
admin: {
user: "users",
meta: {
titleSuffix: "— claudemesh",
},
},
editor: lexicalEditor(),
db: sqliteAdapter({
client: {
url: process.env.PAYLOAD_DATABASE_URI || path.resolve(dirname, "payload.db"),
},
}),
sharp,
collections: [
// --- Users (admin panel) ---
{
slug: "users",
auth: true,
admin: { useAsTitle: "email" },
fields: [
{ name: "name", type: "text" },
{ name: "role", type: "select", options: ["admin", "editor"], defaultValue: "editor" },
],
},
// --- Media ---
{
slug: "media",
upload: {
staticDir: path.resolve(dirname, "public/media"),
mimeTypes: ["image/*"],
},
admin: { useAsTitle: "alt" },
fields: [
{ name: "alt", type: "text", required: true },
],
},
// --- Authors ---
{
slug: "authors",
admin: { useAsTitle: "name" },
fields: [
{ name: "name", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "bio", type: "textarea" },
{ name: "role", type: "text" },
{
name: "avatar",
type: "upload",
relationTo: "media",
},
{
name: "links",
type: "group",
fields: [
{ name: "github", type: "text" },
{ name: "twitter", type: "text" },
{ name: "website", type: "text" },
],
},
],
},
// --- Categories ---
{
slug: "categories",
admin: { useAsTitle: "name" },
fields: [
{ name: "name", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "description", type: "textarea" },
],
},
// --- Blog Posts ---
{
slug: "posts",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "publishedAt", "author"],
},
versions: { drafts: true },
fields: [
{ name: "title", type: "text", required: true },
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
description: "URL-friendly identifier. Auto-generated from title if left blank.",
},
},
{
name: "excerpt",
type: "textarea",
admin: { description: "1-2 sentence summary for cards and meta descriptions." },
},
{
name: "content",
type: "richText",
required: true,
},
{
name: "coverImage",
type: "upload",
relationTo: "media",
},
{
name: "author",
type: "relationship",
relationTo: "authors",
required: true,
},
{
name: "categories",
type: "relationship",
relationTo: "categories",
hasMany: true,
},
{
name: "publishedAt",
type: "date",
admin: { position: "sidebar", date: { pickerAppearance: "dayOnly" } },
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
admin: { position: "sidebar" },
},
{
name: "seo",
type: "group",
fields: [
{ name: "metaTitle", type: "text" },
{ name: "metaDescription", type: "textarea" },
{ name: "ogImage", type: "upload", relationTo: "media" },
],
},
],
},
// --- Changelog ---
{
slug: "changelog",
admin: {
useAsTitle: "version",
defaultColumns: ["version", "date", "type"],
},
fields: [
{ name: "version", type: "text", required: true },
{ name: "date", type: "date", required: true },
{
name: "type",
type: "select",
options: [
{ label: "Feature", value: "feat" },
{ label: "Fix", value: "fix" },
{ label: "Docs", value: "docs" },
{ label: "Breaking", value: "breaking" },
],
required: true,
},
{ name: "summary", type: "text", required: true },
{ name: "body", type: "richText" },
{ name: "npmUrl", type: "text" },
{ name: "githubUrl", type: "text" },
],
},
],
typescript: {
outputFile: path.resolve(dirname, "src/payload-types.ts"),
},
});

View File

View File

@@ -0,0 +1,14 @@
/* eslint-disable */
// @ts-nocheck — Payload generates these types at build time
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap";
import config from "@payload-config";
type Args = { params: Promise<{ segments: string[] }> };
export const generateMetadata = ({ params }: Args) =>
generatePageMetadata({ config, params });
export default function Page({ params }: Args) {
return <RootPage config={config} params={params} importMap={importMap} />;
}

View File

@@ -0,0 +1,2 @@
// Auto-generated by Payload — placeholder until first build
export const importMap = {};

View File

@@ -0,0 +1,11 @@
/* eslint-disable */
// @ts-nocheck
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST, REST_PUT } from "@payloadcms/next/routes";
import config from "@payload-config";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);

View File

@@ -0,0 +1,14 @@
import "@payloadcms/next/css";
import type { ReactNode } from "react";
export const metadata = {
title: "Admin — claudemesh",
};
export default function PayloadLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,80 @@
import { notFound } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import { RichText } from "@payloadcms/richtext-lexical/react";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug }, status: { equals: "published" } },
limit: 1,
depth: 1,
});
const post = docs[0];
if (!post) return { title: "Not found — claudemesh" };
return {
title: `${post.title} — claudemesh`,
description: post.excerpt || post.seo?.metaDescription || undefined,
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug }, status: { equals: "published" } },
limit: 1,
depth: 2,
});
const post = docs[0] as any;
if (!post) notFound();
const author = typeof post.author === "object" ? post.author : null;
return (
<article className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<header className="mb-12">
<time
dateTime={post.publishedAt}
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{post.publishedAt
? new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "Draft"}
</time>
<h1
className="mt-3 text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{post.title}
</h1>
{author && (
<p
className="mt-4 text-sm text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
by {author.name}{author.role ? ` · ${author.role}` : ""}
</p>
)}
</header>
<div
className="prose prose-invert max-w-none prose-headings:font-medium prose-a:text-[var(--cm-clay)] prose-a:no-underline hover:prose-a:underline prose-code:text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{post.content && <RichText data={post.content} />}
</div>
</article>
);
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link";
import { getPayload } from "payload";
import config from "@payload-config";
export const metadata = {
title: "Blog — claudemesh",
description: "Engineering notes on peer messaging, protocol design, and multi-agent security.",
};
export default async function BlogIndex() {
const payload = await getPayload({ config });
const { docs: posts } = await payload.find({
collection: "posts",
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 20,
depth: 1,
});
return (
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Blog
</h1>
<p
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Engineering notes on protocol design, security, and multi-agent UX.
</p>
<div className="mt-12 space-y-10">
{posts.length === 0 && (
<p className="text-sm text-[var(--cm-fg-tertiary)]" style={{ fontFamily: "var(--cm-font-mono)" }}>
No posts yet. First one ships soon.
</p>
)}
{posts.map((post: any) => (
<article key={post.id} className="border-b border-[var(--cm-border)] pb-8">
<time
dateTime={post.publishedAt}
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{post.publishedAt
? new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "Draft"}
</time>
<h2 className="mt-2">
<Link
href={`/blog/${post.slug}`}
className="text-[22px] font-medium leading-tight text-[var(--cm-fg)] transition-colors hover:text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{post.title}
</Link>
</h2>
{post.excerpt && (
<p
className="mt-3 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{post.excerpt}
</p>
)}
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,107 @@
import { getPayload } from "payload";
import config from "@payload-config";
export const metadata = {
title: "Changelog — claudemesh",
description: "Release history for claudemesh-cli.",
};
const TYPE_LABELS: Record<string, string> = {
feat: "Feature",
fix: "Fix",
docs: "Docs",
breaking: "Breaking",
};
const TYPE_COLORS: Record<string, string> = {
feat: "bg-[var(--cm-clay)]",
fix: "bg-[var(--cm-cactus)]",
docs: "bg-[var(--cm-oat)]",
breaking: "bg-red-500",
};
export default async function ChangelogPage() {
const payload = await getPayload({ config });
const { docs: entries } = await payload.find({
collection: "changelog",
sort: "-date",
limit: 50,
});
return (
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
<h1
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Changelog
</h1>
<p
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
Every shipped version of claudemesh-cli.
</p>
<div className="mt-12 space-y-8">
{entries.length === 0 && (
<p className="text-sm text-[var(--cm-fg-tertiary)]" style={{ fontFamily: "var(--cm-font-mono)" }}>
No entries yet.
</p>
)}
{entries.map((entry: any) => (
<article
key={entry.id}
className="border-b border-[var(--cm-border)] pb-6"
>
<div className="flex items-center gap-3">
<span
className={`rounded-[4px] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--cm-bg)] ${TYPE_COLORS[entry.type] || "bg-[var(--cm-fg-tertiary)]"}`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{TYPE_LABELS[entry.type] || entry.type}
</span>
<span
className="text-[18px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
v{entry.version}
</span>
<time
dateTime={entry.date}
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{new Date(entry.date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</time>
</div>
<p
className="mt-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{entry.summary}
</p>
{(entry.npmUrl || entry.githubUrl) && (
<div className="mt-3 flex gap-4 text-[12px]" style={{ fontFamily: "var(--cm-font-mono)" }}>
{entry.npmUrl && (
<a href={entry.npmUrl} className="text-[var(--cm-clay)] hover:underline">
npm
</a>
)}
{entry.githubUrl && (
<a href={entry.githubUrl} className="text-[var(--cm-clay)] hover:underline">
github
</a>
)}
</div>
)}
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,100 @@
/**
* GET /install — serves a shell installer for claudemesh-cli.
*
* Intended to be piped into bash:
* curl -fsSL https://claudemesh.com/install | bash
*
* The script is kept short + auditable. It does not try to install
* Node for the user — it checks for a compatible Node + npm and
* directs them to install Node themselves if missing. Running `bash`
* against a domain you do not fully trust is always a risk; publishing
* the script this way (rather than obfuscating it behind a binary
* blob) lets security-conscious users inspect before executing.
*/
const SCRIPT = `#!/usr/bin/env bash
# claudemesh-cli installer
# Source: https://claudemesh.com/install
# Audit: curl -fsSL https://claudemesh.com/install | less
set -euo pipefail
RED=$'\\033[31m'; GREEN=$'\\033[32m'; DIM=$'\\033[2m'; BOLD=$'\\033[1m'; RESET=$'\\033[0m'
say() { printf "%s\\n" "$*"; }
ok() { printf "%s✓%s %s\\n" "\${GREEN}" "\${RESET}" "$*"; }
err() { printf "%s✗%s %s\\n" "\${RED}" "\${RESET}" "$*" >&2; }
say ""
say "\${BOLD}claudemesh-cli installer\${RESET}"
say "$(printf '%.0s─' {1..40})"
# --- preflight ------------------------------------------------------
if ! command -v node >/dev/null 2>&1; then
err "Node.js is not installed."
say " Install Node.js 20 or newer: \${BOLD}https://nodejs.org\${RESET}"
say " Or via nvm: \${DIM}curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash\${RESET}"
exit 1
fi
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
if [ "$NODE_MAJOR" -lt 20 ]; then
err "Node.js $(node -v) is too old — claudemesh-cli needs >= 20."
say " Upgrade: \${BOLD}https://nodejs.org\${RESET}"
exit 1
fi
ok "Node.js $(node -v)"
if ! command -v npm >/dev/null 2>&1; then
err "npm is not installed (usually ships with Node)."
exit 1
fi
ok "npm $(npm -v)"
# --- install --------------------------------------------------------
say ""
say "Installing \${BOLD}claudemesh-cli\${RESET} from npm…"
if ! npm install -g claudemesh-cli; then
err "npm install failed."
say " If this is a permissions error on macOS/Linux, try:"
say " \${DIM}sudo npm install -g claudemesh-cli\${RESET}"
say " or configure npm to use a user-owned prefix:"
say " \${DIM}https://docs.npmjs.com/resolving-eacces-permissions-errors\${RESET}"
exit 1
fi
ok "claudemesh-cli installed ($(claudemesh --version))"
# --- register MCP + hooks ------------------------------------------
say ""
say "Registering Claude Code MCP server + status hooks…"
if ! claudemesh install; then
err "claudemesh install failed — run it manually to see the error."
exit 1
fi
# --- done -----------------------------------------------------------
say ""
say "\${GREEN}\${BOLD}Done.\${RESET}"
say ""
say "Next steps:"
say " 1. Restart Claude Code so the MCP tools appear."
say " 2. Join a mesh: \${BOLD}claudemesh join <invite-url>\${RESET}"
say " 3. Launch with push: \${BOLD}claudemesh launch\${RESET}"
say ""
say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
say ""
`;
export function GET(): Response {
return new Response(SCRIPT, {
status: 200,
headers: {
"Content-Type": "text/x-shellscript; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"X-Content-Type-Options": "nosniff",
},
});
}

View File

@@ -49,7 +49,7 @@ export const CallToAction = () => {
</span> </span>
</Link> </Link>
<Link <Link
href="#docs" href="https://github.com/alezmad/claudemesh-cli#readme"
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-6 py-3.5 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]" className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-6 py-3.5 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
style={{ fontFamily: "var(--cm-font-sans)" }} style={{ fontFamily: "var(--cm-font-sans)" }}
> >

View File

@@ -5,11 +5,11 @@ import { Reveal } from "./_reveal";
const ITEMS = [ const ITEMS = [
{ {
q: "Is claudemesh free?", q: "Is claudemesh free?",
a: "Yes — the broker, CLI, dashboard, and SDK are MIT-licensed and free forever. Solo developers and small teams can self-host at no cost. Paid tiers add hosted brokers, SSO, audit retention, and support.", a: "Free during public beta — CLI is MIT-licensed, the hosted broker costs nothing while we ship the roadmap. Paid tiers launch when the dashboard ships. Beta users keep the free plan for life.",
}, },
{ {
q: "How do I get started?", q: "How do I get started?",
a: "Install the broker with one curl command. Add one env var to your Claude Code config. Your session joins the mesh. `npx claudemesh init` does both in 60 seconds.", a: "One command: `curl -fsSL claudemesh.com/install | bash`. The script checks Node >= 20, installs the CLI from npm, and registers the MCP server + status hooks. Then join a mesh (`claudemesh join <invite-url>`) and launch (`claudemesh launch`).",
}, },
{ {
q: "Does claudemesh send my code or prompts to the cloud?", q: "Does claudemesh send my code or prompts to the cloud?",
@@ -29,7 +29,7 @@ const ITEMS = [
}, },
{ {
q: "Which Claude Code versions work with claudemesh?", q: "Which Claude Code versions work with claudemesh?",
a: "Claude Code 2.0 and above. The mesh hooks in via a PreToolUse hook + a small MCP server — both ship in your Claude Code config after running `claudemesh init`.", a: "Claude Code 2.0 and above. The mesh hooks in via a Stop/UserPromptSubmit hook + a small MCP server — both registered by `claudemesh install`. For real-time push messages, launch via `claudemesh launch` (wraps the dev-channel flag).",
}, },
{ {
q: "How is this different from MCP?", q: "How is this different from MCP?",

View File

@@ -45,7 +45,7 @@ export const Features = () => {
style={{ fontFamily: "var(--cm-font-mono)" }} style={{ fontFamily: "var(--cm-font-mono)" }}
> >
<span className="text-[var(--cm-clay)]">$</span> <span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span> <span>curl -fsSL claudemesh.com/install | bash</span>
<button <button
className="ml-2 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[10px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]" className="ml-2 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[10px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
aria-label="Copy" aria-label="Copy"
@@ -61,7 +61,7 @@ export const Features = () => {
> >
Free forever for solo developers · Or read the{" "} Free forever for solo developers · Or read the{" "}
<a <a
href="#" href="https://github.com/alezmad/claudemesh-cli#readme"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]" className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
> >
documentation documentation

View File

@@ -2,12 +2,12 @@ import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal"; import { Reveal, SectionIcon } from "./_reveal";
const LOGOS = [ const LOGOS = [
"Vercel", "Claude Code",
"Linear", "MCP",
"Stripe", "libsodium",
"Supabase", "Bun",
"Shopify", "TypeScript",
"Figma", "MIT",
]; ];
export const Hero = () => { export const Hero = () => {
@@ -55,11 +55,12 @@ export const Hero = () => {
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl" className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
style={{ fontFamily: "var(--cm-font-serif)" }} style={{ fontFamily: "var(--cm-font-serif)" }}
> >
Peer mesh for Claude reachable from anywhere you are. Connect Peer mesh for Claude Code. Connect your sessions across repos and
every Claude Code session on your team, then bridge the mesh to machines. Messages are end-to-end encrypted, delivered mid-turn
WhatsApp, Slack, your phone. Terminal is one client, not THE client. as {"`<channel>`"} reminders. Your Claudes talk to each other; the
broker never sees plaintext.
<span className="block pt-2 text-[var(--cm-clay)]"> <span className="block pt-2 text-[var(--cm-clay)]">
Free and open-source. Forever. Open-source CLI. Free during public beta.
</span> </span>
</p> </p>
</Reveal> </Reveal>
@@ -81,7 +82,7 @@ export const Hero = () => {
style={{ fontFamily: "var(--cm-font-mono)" }} style={{ fontFamily: "var(--cm-font-mono)" }}
> >
<span className="text-[var(--cm-clay)]">$</span> <span className="text-[var(--cm-clay)]">$</span>
<span>curl -fsSL claudemesh.sh/install | bash</span> <span>curl -fsSL claudemesh.com/install | bash</span>
</div> </div>
</div> </div>
</Reveal> </Reveal>
@@ -93,7 +94,7 @@ export const Hero = () => {
> >
Or{" "} Or{" "}
<Link <Link
href="#docs" href="https://github.com/alezmad/claudemesh-cli#readme"
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]" className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
> >
read the documentation read the documentation

View File

@@ -50,7 +50,7 @@ export const LaptopToLaptop = () => {
</Reveal> </Reveal>
<Reveal delay={3} className="mt-10 flex justify-center"> <Reveal delay={3} className="mt-10 flex justify-center">
<Link <Link
href="#" href="/auth/register"
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]" className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }} style={{ fontFamily: "var(--cm-font-sans)" }}
> >

View File

@@ -6,7 +6,7 @@ const CARDS = [
accent: "clay", accent: "clay",
title: "Start in your terminal", title: "Start in your terminal",
body: "Drop the broker next to Claude Code. One env var. Your session joins the mesh.", body: "Drop the broker next to Claude Code. One env var. Your session joins the mesh.",
cta: { label: "Install", href: "#" }, cta: { label: "Install", href: "https://github.com/alezmad/claudemesh-cli#install" },
mock: ( mock: (
<div <div
className="rounded-[8px] bg-[#D97757] p-6 font-mono text-[11px] leading-[1.6] text-[#141413]" className="rounded-[8px] bg-[#D97757] p-6 font-mono text-[11px] leading-[1.6] text-[#141413]"
@@ -26,8 +26,8 @@ const CARDS = [
accent: "oat", accent: "oat",
title: "Bridge to your editor", title: "Bridge to your editor",
body: "VS Code, Cursor, JetBrains — the mesh exposes an MCP server your editor's agent can call.", body: "VS Code, Cursor, JetBrains — the mesh exposes an MCP server your editor's agent can call.",
cta: { label: "VS Code", href: "#" }, cta: { label: "VS Code", href: "https://github.com/alezmad/claudemesh-cli#readme" },
cta2: { label: "JetBrains", href: "#" }, cta2: { label: "JetBrains", href: "https://github.com/alezmad/claudemesh-cli#readme" },
mock: ( mock: (
<div <div
className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-4" className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-4"
@@ -52,7 +52,7 @@ const CARDS = [
accent: "cactus", accent: "cactus",
title: "Reach across machines", title: "Reach across machines",
body: "Tailscale, WireGuard, or plain WS over your LAN. The broker is one binary, anywhere.", body: "Tailscale, WireGuard, or plain WS over your LAN. The broker is one binary, anywhere.",
cta: { label: "Open the dashboard", href: "#" }, cta: { label: "Open the dashboard", href: "/dashboard" },
mock: ( mock: (
<div <div
className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-4" className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-4"

View File

@@ -1,64 +1,25 @@
"use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Reveal, SectionIcon } from "./_reveal"; import { Reveal, SectionIcon } from "./_reveal";
const TIERS = { const SHIPPING = [
individual: [ "CLI + MCP server (Claude Code integration)",
{ "Hosted broker on claudemesh.com",
name: "Solo", "End-to-end encrypted direct messages (crypto_box)",
desc: "Run the broker on your laptop. Pair your Claude Code sessions across repos.", "Priority routing (now / next / low)",
price: "Free", "Mesh invites + membership",
cta: "Start free", "Windows, macOS, Linux support",
href: "/auth/register", ];
},
{ const ROADMAP = [
name: "Pro", "Mesh dashboard (browser UI)",
desc: "Mesh dashboard, peer registry, message history, priority routing.", "Message history + retention controls",
price: "$12", "Audit log",
note: "per month", "Slack / WhatsApp / Telegram gateways",
cta: "Start free trial", "Self-host broker + SSO",
href: "/auth/register", "Cross-broker federation",
}, ];
{
name: "Plus",
desc: "Cross-machine mesh via Tailscale / WireGuard, MCP bridge, audit log.",
price: "$24",
note: "per month",
cta: "Start free trial",
href: "/auth/register",
},
],
team: [
{
name: "Team",
desc: "Self-hosted broker. SSO, shared presence, team audit log, 25 peers.",
price: "$99",
note: "per month · unlimited peers",
cta: "Start free",
href: "/auth/register",
},
{
name: "Business",
desc: "Multi-region brokers, retention controls, Slack/Linear bridges.",
price: "$499",
note: "per month",
cta: "Start free",
href: "/auth/register",
},
{
name: "Enterprise",
desc: "Air-gapped deploy, custom SAML, dedicated support, SOC 2 pack.",
price: "Contact",
cta: "Contact sales",
href: "/contact",
},
],
};
export const Pricing = () => { export const Pricing = () => {
const [tab, setTab] = useState<"individual" | "team">("individual");
const tiers = TIERS[tab];
return ( return (
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32"> <section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-24 md:px-12 md:py-32">
<div className="mx-auto max-w-[var(--cm-max-w)]"> <div className="mx-auto max-w-[var(--cm-max-w)]">
@@ -73,72 +34,104 @@ export const Pricing = () => {
Get started with claudemesh Get started with claudemesh
</h2> </h2>
</Reveal> </Reveal>
<Reveal delay={2} className="mt-10 flex justify-center"> <Reveal delay={2}>
<div className="inline-flex rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-1"> <p
{(["individual", "team"] as const).map((k) => ( className="mx-auto mt-4 max-w-[520px] text-center text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
<button style={{ fontFamily: "var(--cm-font-sans)" }}
key={k} >
onClick={() => setTab(k)} Free during public beta. The CLI is MIT-licensed. The hosted
className={ broker stays free while the roadmap ships. No billing today.
"rounded-[calc(var(--cm-radius-xs)-2px)] px-4 py-2 text-[13px] font-medium transition-colors " + </p>
(tab === k </Reveal>
? "bg-[var(--cm-fg)] text-[var(--cm-bg)]"
: "text-[var(--cm-fg-secondary)] hover:text-[var(--cm-fg)]") <Reveal delay={3}>
} <div className="mx-auto mt-16 max-w-[720px] rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8 md:p-10">
<div className="mb-6 flex items-baseline justify-between gap-4">
<h3
className="text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Public beta
</h3>
<div className="text-right">
<div
className="text-[32px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Free
</div>
<div
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
no card required
</div>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2">
<div>
<div
className="mb-3 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
Shipping today
</div>
<ul className="space-y-2">
{SHIPPING.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<div
className="mb-3 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
Roadmap · v0.2v0.3
</div>
<ul className="space-y-2">
{ROADMAP.map((item) => (
<li
key={item}
className="flex items-start gap-2 text-[13px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full border border-[var(--cm-fg-tertiary)]" />
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
<div className="mt-8 flex flex-col items-start gap-3 border-t border-[var(--cm-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
<p
className="text-[12px] leading-[1.5] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-sans)" }} style={{ fontFamily: "var(--cm-font-sans)" }}
> >
{k === "individual" ? "Individual" : "Team & Enterprise"} Paid tiers launch when the dashboard ships. Beta users keep
</button> the free plan for life.
))} </p>
</div> <Link
</Reveal> href="/auth/register"
<Reveal delay={3}> className="inline-flex shrink-0 items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-fg)] px-5 py-2.5 text-sm font-medium text-[var(--cm-bg)] transition-colors hover:bg-[var(--cm-gray-150)]"
<div className="mt-16 grid gap-6 md:grid-cols-3"> style={{ fontFamily: "var(--cm-font-sans)" }}
{tiers.map((tier) => (
<article
key={tier.name}
className="flex flex-col rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8 transition-colors hover:border-[var(--cm-clay)]"
> >
<div className="mb-5"> Start free
<SectionIcon glyph="leaf" /> <span className="transition-transform duration-300 group-hover:translate-x-0.5">
</div>
<h3 </span>
className="mb-2 text-[28px] font-medium leading-tight text-[var(--cm-fg)]" </Link>
style={{ fontFamily: "var(--cm-font-serif)" }} </div>
>
{tier.name}
</h3>
<p
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{tier.desc}
</p>
<div className="mb-6 mt-auto">
<div
className="text-[32px] font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{tier.price}
</div>
{tier.note && (
<div
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{tier.note}
</div>
)}
</div>
<Link
href={tier.href}
className="inline-flex items-center justify-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{tier.cta}
</Link>
</article>
))}
</div> </div>
</Reveal> </Reveal>
</div> </div>

View File

@@ -137,7 +137,7 @@ export const Surfaces = () => {
name, by repo, by priority. name, by repo, by priority.
</p> </p>
<Link <Link
href="#" href="/dashboard"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]" className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-2.5 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg)]"
style={{ fontFamily: "var(--cm-font-sans)" }} style={{ fontFamily: "var(--cm-font-sans)" }}
> >

View File

@@ -242,7 +242,7 @@ const USE_CASES: UseCase[] = [
title: "Bug Alice fixed, Bob rediscovers", title: "Bug Alice fixed, Bob rediscovers",
before: before:
"Alice in payments-api fixes a Stripe signature bug. Two weeks later, Bob in checkout-frontend hits the same thing. Alice's fix is buried in a PR thread. Bob re-solves it for three hours.", "Alice in payments-api fixes a Stripe signature bug. Two weeks later, Bob in checkout-frontend hits the same thing. Alice's fix is buried in a PR thread. Bob re-solves it for three hours.",
now: "Bob's Claude asks the mesh: who's seen this? Alice's Claude self-nominates with context. Bob solves in ten minutes. Alice isn't interrupted — her Claude surfaces the history on its own.", now: "Bob's Claude asks the mesh: who's seen this? Alice's Claude volunteers with context. Bob solves in ten minutes. Alice isn't interrupted — her Claude shares the history on its own.",
limits: limits:
"Each Claude stays inside its own repo. Nobody's reading anyone else's files. Information flows at the agent layer, with a human still on the PR.", "Each Claude stays inside its own repo. Nobody's reading anyone else's files. Information flows at the agent layer, with a human still on the PR.",
}, },

View File

@@ -19,6 +19,6 @@ export const proxy = (request: NextRequest) =>
}); });
export const config = { export const config = {
matcher: "/((?!api|static|.*\\..*|_next).*)", matcher: "/((?!api|static|install|admin|.*\\..*|_next).*)",
unstable_allowDynamic: ["**/node_modules/lodash*/**/*.js"], unstable_allowDynamic: ["**/node_modules/lodash*/**/*.js"],
}; };

View File

@@ -5,7 +5,8 @@
"jsx": "preserve", "jsx": "preserve",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"],
"@payload-config": ["./payload.config.ts"]
}, },
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"module": "esnext" "module": "esnext"

View File

@@ -44,7 +44,9 @@
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"esbuild", "esbuild",
"duckdb" "duckdb",
"better-sqlite3",
"sharp"
], ],
"overrides": { "overrides": {
"csstype": "3.1.3", "csstype": "3.1.3",

2635
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
esbuild@0.23.1 esbuild@0.23.1
esbuild@0.25.10 esbuild@0.25.10
esbuild@0.27.2 esbuild@0.27.2
better-sqlite3@12.4.1
sharp@0.34.5