Compare commits
172 Commits
v0.1.16
...
dbea96960f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbea96960f | ||
|
|
a022da1998 | ||
|
|
5df2664bae | ||
|
|
816c42feae | ||
|
|
4c0a417b7c | ||
|
|
e6962f1454 | ||
|
|
1d506f3ea5 | ||
|
|
64266a75f7 | ||
|
|
2710f354a9 | ||
|
|
6b55859d38 | ||
|
|
7d31cc6283 | ||
|
|
0403cfeb76 | ||
|
|
d8e6900072 | ||
|
|
ed8dab8bd3 | ||
|
|
dad51870d9 | ||
|
|
a6af0f2154 | ||
|
|
0661e6223a | ||
|
|
05e3c43e29 | ||
|
|
e3fa6e6a5e | ||
|
|
17066b4f6c | ||
|
|
8d1685e64d | ||
|
|
bb28e16c7d | ||
|
|
ac59d2acfe | ||
|
|
0a1af84712 | ||
|
|
18dc29aba1 | ||
|
|
795217093f | ||
|
|
61b0813924 | ||
|
|
c10337ab9f | ||
|
|
126bbfeb2c | ||
|
|
c914f2b7db | ||
|
|
a8b9348b36 | ||
|
|
c3dd4efe82 | ||
|
|
a7d9ecab15 | ||
|
|
d263fe0f26 | ||
|
|
3226493e6d | ||
|
|
4cb5a97512 | ||
|
|
c080bc517f | ||
|
|
471e88b3e6 | ||
|
|
c66e3adf67 | ||
|
|
3f46a6657a | ||
|
|
83ba1aa373 | ||
|
|
7430e4ffe0 | ||
|
|
d72e49b8fd | ||
|
|
3f57944921 | ||
|
|
b31aab8aeb | ||
|
|
5db9842261 | ||
|
|
81e520fdbb | ||
|
|
26c4502277 | ||
|
|
bfc62b9a72 | ||
|
|
f8c6f9ae74 | ||
|
|
3497700fad | ||
|
|
2c156f832e | ||
|
|
4ee810242d | ||
|
|
b6224c4186 | ||
|
|
4c385a16cc | ||
|
|
4ae6a86bf6 | ||
|
|
c327c282e3 | ||
|
|
e645455b22 | ||
|
|
45505a1635 | ||
|
|
17e6361d64 | ||
|
|
528e7e21b1 | ||
|
|
7b875de301 | ||
|
|
8a3c96dc7c | ||
|
|
b0634b829c | ||
|
|
2bd388a5e2 | ||
|
|
71c0767a1b | ||
|
|
6a3f087209 | ||
|
|
873f588057 | ||
|
|
070a3b7422 | ||
|
|
75ca892ea7 | ||
|
|
a90046a8e3 | ||
|
|
02a165dd76 | ||
|
|
52393429f9 | ||
|
|
9474d985ae | ||
|
|
643c808685 | ||
|
|
2c24f667f9 | ||
|
|
b0113913f2 | ||
|
|
e1cafa54b3 | ||
|
|
a4f2e0aa81 | ||
|
|
cbcde4d910 | ||
|
|
495c234159 | ||
|
|
42c1d02f5e | ||
|
|
a33c925216 | ||
|
|
6ab3fbbea3 | ||
|
|
26adbafde2 | ||
|
|
13e8ce07ac | ||
|
|
5398ca6833 | ||
|
|
56b1cc0756 | ||
|
|
fc8a7edc23 | ||
|
|
e09671cdcb | ||
|
|
32fc4a0c98 | ||
|
|
b315b31cc9 | ||
|
|
21cb6efced | ||
|
|
125b576e2c | ||
|
|
3641618391 | ||
|
|
a92cf6b629 | ||
|
|
2c9c8c7b6c | ||
|
|
98fda20ab6 | ||
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 | ||
|
|
27c9d2a02c | ||
|
|
87e0d0004d | ||
|
|
dba0fb7b33 | ||
|
|
72be651ca8 | ||
|
|
db2bf3ea06 | ||
|
|
e87380775f | ||
|
|
58ba01f20f | ||
|
|
59332dc47d | ||
|
|
f34b8fbc6b | ||
|
|
79525af42e | ||
|
|
69e93d4b8c | ||
|
|
810f372d1c | ||
|
|
453705a4e1 | ||
|
|
5cb4cc4fe7 | ||
|
|
eeac47c360 | ||
|
|
0bb9d71a26 | ||
|
|
3ff7a61e3f | ||
|
|
e76ade64d2 | ||
|
|
59848f0d3e | ||
|
|
d0fa1c028f | ||
|
|
8f925d9a9e | ||
|
|
4ce1034dcd | ||
|
|
e26a36e543 | ||
|
|
60c74d9463 | ||
|
|
6fba9bd4eb | ||
|
|
5bcc1fe323 | ||
|
|
e70f0ed1ff | ||
|
|
5f696f47ea | ||
|
|
ccb9fb2a68 | ||
|
|
898c061089 | ||
|
|
f7a6559429 | ||
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c | ||
|
|
820ec085b2 | ||
|
|
9e6f6d7bc9 | ||
|
|
7194e7d28e | ||
|
|
0b4e389f2b | ||
|
|
7a5f786e0c | ||
|
|
10e5fdcfd1 | ||
|
|
cc6e56aef9 | ||
|
|
1aaa483d60 | ||
|
|
99d9d19079 | ||
|
|
888078876a | ||
|
|
02b1e5695f |
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
||||
|
||||
# Default locale of the apps, can be overridden separately in each app.
|
||||
DEFAULT_LOCALE="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
3
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# claudemesh
|
||||
|
||||
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/web/` — Marketing site + dashboard at claudemesh.com
|
||||
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
||||
|
||||
## Key docs
|
||||
|
||||
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
|
||||
- `docs/protocol.md` — Wire protocol reference
|
||||
- `docs/roadmap.md` — Public roadmap (shipped + planned)
|
||||
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
|
||||
|
||||
## 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`
|
||||
- **Web:** Vercel auto-deploy on push to GitHub
|
||||
|
||||
## Dev
|
||||
|
||||
- Monorepo: pnpm workspaces + Turborepo
|
||||
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
|
||||
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
|
||||
- CLI link for local testing: `cd apps/cli && npm link`
|
||||
@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@qdrant/js-client-rest": "1.17.0",
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
"ws": "8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
215
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Signed audit log with hash-chain integrity.
|
||||
*
|
||||
* Every significant mesh event is recorded as an append-only entry.
|
||||
* Each entry's SHA-256 hash includes the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified
|
||||
* or deleted, all subsequent hashes will fail verification.
|
||||
*
|
||||
* NEVER logs message content (ciphertext or plaintext) — only metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { asc, desc, eq, sql, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { auditLog } from "@turbostarter/db/schema/mesh";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastHash = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core audit logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an audit entry for a mesh event.
|
||||
*
|
||||
* Fire-and-forget safe — callers should `void audit(...)` or
|
||||
* `.catch(log.warn)` to avoid blocking the hot path.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const prevHash = lastHash.get(meshId) ?? "genesis";
|
||||
const createdAt = new Date();
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
|
||||
try {
|
||||
await db.insert(auditLog).values({
|
||||
meshId,
|
||||
eventType,
|
||||
actorMemberId,
|
||||
actorDisplayName,
|
||||
payload,
|
||||
prevHash,
|
||||
hash,
|
||||
createdAt,
|
||||
});
|
||||
lastHash.set(meshId, hash);
|
||||
} catch (e) {
|
||||
log.warn("audit log insert failed", {
|
||||
mesh_id: meshId,
|
||||
event_type: eventType,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: load last hash per mesh from DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadLastHashes(): Promise<void> {
|
||||
try {
|
||||
// For each mesh, find the most recent audit entry by id (serial).
|
||||
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
|
||||
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
|
||||
SELECT DISTINCT ON (mesh_id) mesh_id, hash
|
||||
FROM mesh.audit_log
|
||||
ORDER BY mesh_id, id DESC
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
lastHash.set(row.mesh_id, row.hash);
|
||||
}
|
||||
log.info("audit: loaded last hashes", { meshes: lastHash.size });
|
||||
} catch (e) {
|
||||
// Table may not exist yet on first boot — that's fine.
|
||||
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyChain(
|
||||
meshId: string,
|
||||
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(asc(auditLog.id));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: true, entries: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
|
||||
|
||||
// Verify prevHash linkage
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
|
||||
// Recompute hash and verify
|
||||
const recomputed = computeHash(
|
||||
row.prevHash,
|
||||
row.meshId,
|
||||
row.eventType,
|
||||
row.actorMemberId,
|
||||
row.payload as Record<string, unknown>,
|
||||
row.createdAt,
|
||||
);
|
||||
if (recomputed !== row.hash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, entries: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: paginated audit entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAuditLog(
|
||||
meshId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const conditions = [eq(auditLog.meshId, meshId)];
|
||||
if (options?.eventType) {
|
||||
conditions.push(eq(auditLog.eventType, options.eventType));
|
||||
}
|
||||
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
|
||||
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
eventType: r.eventType,
|
||||
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
|
||||
payload: r.payload as Record<string, unknown>,
|
||||
hash: r.hash,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure table exists (raw DDL for first-boot before migrations run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureAuditLogTable(): Promise<void> {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS mesh.audit_log (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_member_id TEXT,
|
||||
actor_display_name TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log.warn("audit: ensureAuditLogTable failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/broker/src/broker-crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Broker-side symmetric encryption for persisting resolved env vars.
|
||||
*
|
||||
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
|
||||
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
|
||||
* a random key is generated and logged on first use — operator should
|
||||
* persist it to survive broker restarts.
|
||||
*
|
||||
* This is NOT the same as peer-side E2E crypto (libsodium). This is
|
||||
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (_key) return _key;
|
||||
|
||||
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
|
||||
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
|
||||
} else {
|
||||
_key = randomBytes(32);
|
||||
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
|
||||
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
|
||||
}
|
||||
return _key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JSON-serializable value. Returns a base64 string containing
|
||||
* IV + ciphertext + auth tag.
|
||||
*/
|
||||
export function encryptForStorage(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Pack: IV (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encryptForStorage. Returns the plaintext
|
||||
* string, or null if decryption fails (wrong key, tampered).
|
||||
*/
|
||||
export function decryptFromStorage(packed: string): string | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const buf = Buffer.from(packed, "base64");
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
apps/broker/src/cli-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* POST /cli-sync handler.
|
||||
*
|
||||
* Accepts a sync JWT from the dashboard, creates or finds member rows
|
||||
* for each mesh in the token, and returns mesh details + member IDs.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
|
||||
|
||||
// Import schema tables
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
export interface CliSyncRequest {
|
||||
sync_token: string;
|
||||
peer_pubkey: string; // ed25519 hex (64 chars)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface CliSyncResponse {
|
||||
ok: true;
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CliSyncError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function handleCliSync(
|
||||
body: CliSyncRequest,
|
||||
): Promise<CliSyncResponse | CliSyncError> {
|
||||
// 1. Validate inputs
|
||||
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
|
||||
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
|
||||
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
|
||||
}
|
||||
|
||||
// 2. Verify JWT
|
||||
const tokenResult = await verifySyncToken(body.sync_token);
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
|
||||
}
|
||||
const payload = tokenResult.payload;
|
||||
|
||||
// 3. For each mesh in the token, create or find a member row
|
||||
const resultMeshes: CliSyncResponse["meshes"] = [];
|
||||
|
||||
for (const tokenMesh of payload.meshes) {
|
||||
// Verify mesh exists and is not archived
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, slug: meshTable.slug })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) {
|
||||
// Skip meshes that don't exist (could have been deleted)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this pubkey is already a member of this mesh
|
||||
const [existing] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.meshId, tokenMesh.id),
|
||||
eq(memberTable.peerPubkey, body.peer_pubkey),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
let memberId: string;
|
||||
let role: "admin" | "member";
|
||||
|
||||
if (existing) {
|
||||
// Already a member — update dashboard link + display name
|
||||
memberId = existing.id;
|
||||
role = existing.role;
|
||||
await db
|
||||
.update(memberTable)
|
||||
.set({
|
||||
dashboardUserId: payload.sub,
|
||||
displayName: body.display_name,
|
||||
})
|
||||
.where(eq(memberTable.id, existing.id));
|
||||
} else {
|
||||
// Create new member row
|
||||
memberId = generateId();
|
||||
role = tokenMesh.role;
|
||||
await db.insert(memberTable).values({
|
||||
id: memberId,
|
||||
meshId: tokenMesh.id,
|
||||
peerPubkey: body.peer_pubkey,
|
||||
displayName: body.display_name,
|
||||
role: tokenMesh.role,
|
||||
dashboardUserId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
resultMeshes.push({
|
||||
mesh_id: tokenMesh.id,
|
||||
slug: m.slug,
|
||||
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
member_id: memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
if (resultMeshes.length === 0) {
|
||||
return { ok: false, error: "no valid meshes found in sync token" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
account_id: payload.sub,
|
||||
meshes: resultMeshes,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,20 @@ const envSchema = z.object({
|
||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
146
apps/broker/src/jwt.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* JWT verification for CLI sync tokens.
|
||||
*
|
||||
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
||||
* shared secret between dashboard and broker via env var.
|
||||
*
|
||||
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
||||
*/
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface SyncTokenPayload {
|
||||
sub: string; // dashboard user ID
|
||||
email: string;
|
||||
meshes: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
jti: string; // unique token ID for replay prevention
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- JTI dedup ---
|
||||
|
||||
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
||||
|
||||
// Sweep expired JTIs every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [jti, exp] of usedJtis) {
|
||||
if (exp < now) usedJtis.delete(jti);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
// --- Verification ---
|
||||
|
||||
/**
|
||||
* Verify and decode a sync token JWT.
|
||||
* Returns the decoded payload on success, or an error string on failure.
|
||||
*/
|
||||
export async function verifySyncToken(
|
||||
token: string,
|
||||
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
||||
// Get shared secret from env
|
||||
const secret = env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JWT manually (HS256)
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: "malformed JWT" };
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Verify signature (HS256)
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
||||
if (!valid) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
|
||||
// Decode header — must be HS256
|
||||
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||
if (header.alg !== "HS256") {
|
||||
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
||||
) as SyncTokenPayload;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
return { ok: false, error: "token expired" };
|
||||
}
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (payload.iat && payload.iat > now + 30) {
|
||||
return { ok: false, error: "token issued in the future" };
|
||||
}
|
||||
|
||||
// JTI dedup
|
||||
if (!payload.jti) {
|
||||
return { ok: false, error: "missing jti" };
|
||||
}
|
||||
if (usedJtis.has(payload.jti)) {
|
||||
return { ok: false, error: "token already used" };
|
||||
}
|
||||
// Mark as used with expiry time
|
||||
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
||||
|
||||
// Basic validation
|
||||
if (!payload.sub || !payload.email) {
|
||||
return { ok: false, error: "missing sub or email" };
|
||||
}
|
||||
if (!Array.isArray(payload.meshes)) {
|
||||
return { ok: false, error: "missing meshes array" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
// Add padding
|
||||
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4) base64 += "=";
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Member profile REST API handlers.
|
||||
*
|
||||
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||
* GET /mesh/:meshId/members — list all members with online status
|
||||
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||
*
|
||||
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
presence as presenceTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MemberProfileUpdate {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
|
||||
export interface MemberPermissionUpdate {
|
||||
permission?: "admin" | "member"; // only admins can change this
|
||||
}
|
||||
|
||||
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||
|
||||
interface SelfEditablePolicy {
|
||||
displayName: boolean;
|
||||
roleTag: boolean;
|
||||
groups: boolean;
|
||||
messageMode: boolean;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
/**
|
||||
* Update a member's profile fields.
|
||||
*
|
||||
* Authorization:
|
||||
* - If caller is the target member: check mesh.selfEditable for each field
|
||||
* - If caller is a mesh admin: allow all fields
|
||||
* - permission field: admin-only always
|
||||
*
|
||||
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||
*/
|
||||
export async function updateMemberProfile(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
callerMemberId: string, // from auth header or WS connection
|
||||
updates: MemberUpdateRequest,
|
||||
): Promise<
|
||||
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// 1. Load mesh for selfEditable policy
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// 2. Load caller's member row to check permission
|
||||
const [caller] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||
|
||||
const isAdmin = caller.role === "admin";
|
||||
const isSelf = callerMemberId === memberId;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "not authorized — only admins or self can edit",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check self-edit permissions for non-admin self-edits
|
||||
const policy: SelfEditablePolicy =
|
||||
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||
displayName: true,
|
||||
roleTag: true,
|
||||
groups: true,
|
||||
messageMode: true,
|
||||
};
|
||||
|
||||
const rejected: string[] = [];
|
||||
if (!isAdmin && isSelf) {
|
||||
if (updates.displayName !== undefined && !policy.displayName)
|
||||
rejected.push("displayName");
|
||||
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||
rejected.push("roleTag");
|
||||
if (updates.groups !== undefined && !policy.groups)
|
||||
rejected.push("groups");
|
||||
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||
rejected.push("messageMode");
|
||||
if (updates.permission !== undefined) rejected.push("permission");
|
||||
}
|
||||
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Build update set
|
||||
const set: Record<string, unknown> = {};
|
||||
const changes: MemberProfileUpdate = {};
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
set.displayName = updates.displayName;
|
||||
changes.displayName = updates.displayName;
|
||||
}
|
||||
if (updates.roleTag !== undefined) {
|
||||
set.roleTag = updates.roleTag;
|
||||
changes.roleTag = updates.roleTag;
|
||||
}
|
||||
if (updates.groups !== undefined) {
|
||||
set.defaultGroups = updates.groups;
|
||||
changes.groups = updates.groups;
|
||||
}
|
||||
if (updates.messageMode !== undefined) {
|
||||
set.messageMode = updates.messageMode;
|
||||
changes.messageMode = updates.messageMode;
|
||||
}
|
||||
if (updates.permission !== undefined && isAdmin) {
|
||||
set.role = updates.permission;
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no fields to update" };
|
||||
}
|
||||
|
||||
// 5. Update member row
|
||||
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||
|
||||
// 6. Read back the updated member
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, memberId));
|
||||
|
||||
if (!updated) return { ok: false, error: "member not found after update" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
id: updated.id,
|
||||
displayName: updated.displayName,
|
||||
roleTag: updated.roleTag,
|
||||
groups: updated.defaultGroups,
|
||||
messageMode: updated.messageMode,
|
||||
permission: updated.role,
|
||||
dashboardUserId: updated.dashboardUserId,
|
||||
joinedAt: updated.joinedAt,
|
||||
lastSeenAt: updated.lastSeenAt,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all members of a mesh with online status.
|
||||
*/
|
||||
export async function listMeshMembers(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
| { ok: true; members: Array<Record<string, unknown>> }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// Verify mesh exists
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// Get all non-revoked members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||
);
|
||||
|
||||
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||
if (members.length === 0) {
|
||||
return { ok: true, members: [] };
|
||||
}
|
||||
|
||||
// Get active presences for online status
|
||||
const activePresences = await db
|
||||
.select({
|
||||
memberId: presenceTable.memberId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(presenceTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(presenceTable.disconnectedAt),
|
||||
sql`${presenceTable.memberId} IN (${sql.join(
|
||||
members.map((m) => sql`${m.id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(presenceTable.memberId);
|
||||
|
||||
const onlineMap = new Map(
|
||||
activePresences.map((p) => [p.memberId, p.count]),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
roleTag: member.roleTag,
|
||||
groups: member.defaultGroups,
|
||||
messageMode: member.messageMode,
|
||||
permission: member.role,
|
||||
dashboardUserId: member.dashboardUserId,
|
||||
joinedAt: member.joinedAt?.toISOString(),
|
||||
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||
online: onlineMap.has(member.id),
|
||||
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mesh settings (currently: selfEditable policy).
|
||||
* Admin-only.
|
||||
*/
|
||||
export async function updateMeshSettings(
|
||||
meshId: string,
|
||||
callerMemberId: string,
|
||||
settings: { selfEditable?: SelfEditablePolicy },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Check caller is admin
|
||||
const [caller] = await db
|
||||
.select({ role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller || caller.role !== "admin") {
|
||||
return { ok: false, error: "admin access required" };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = {};
|
||||
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no settings to update" };
|
||||
}
|
||||
|
||||
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
28
apps/broker/src/minio.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* MinIO client for file storage.
|
||||
*
|
||||
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
|
||||
* a key path that encodes persistence and origin:
|
||||
* - persistent: shared/{fileId}/{originalName}
|
||||
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
|
||||
*/
|
||||
|
||||
import { Client } from "minio";
|
||||
import { env } from "./env";
|
||||
|
||||
export const minioClient = new Client({
|
||||
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
|
||||
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
|
||||
useSSL: env.MINIO_USE_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
export async function ensureBucket(name: string): Promise<void> {
|
||||
const exists = await minioClient.bucketExists(name);
|
||||
if (!exists) await minioClient.makeBucket(name);
|
||||
}
|
||||
|
||||
export function meshBucketName(meshId: string): string {
|
||||
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
|
||||
}
|
||||
22
apps/broker/src/neo4j-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import neo4j from "neo4j-driver";
|
||||
import { env } from "./env";
|
||||
|
||||
export const neo4jDriver = neo4j.driver(
|
||||
env.NEO4J_URL,
|
||||
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
|
||||
);
|
||||
|
||||
export function meshDbName(meshId: string): string {
|
||||
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
|
||||
}
|
||||
|
||||
export async function ensureDatabase(name: string): Promise<void> {
|
||||
const session = neo4jDriver.session({ database: "system" });
|
||||
try {
|
||||
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
|
||||
} catch {
|
||||
/* may not support multi-db in community edition — fall back to default */
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
24
apps/broker/src/qdrant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { env } from "./env";
|
||||
|
||||
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
|
||||
|
||||
export function meshCollectionName(
|
||||
meshId: string,
|
||||
collection: string,
|
||||
): string {
|
||||
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||
}
|
||||
|
||||
export async function ensureCollection(
|
||||
name: string,
|
||||
vectorSize = 1536,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await qdrant.getCollection(name);
|
||||
} catch {
|
||||
await qdrant.createCollection(name, {
|
||||
vectors: { size: vectorSize, distance: "Cosine" },
|
||||
});
|
||||
}
|
||||
}
|
||||
788
apps/broker/src/service-manager.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Service Manager — lifecycle management for mesh-deployed MCP servers.
|
||||
*
|
||||
* Each deployed MCP server runs as a child process with its own stdio pipe.
|
||||
* The manager spawns, monitors, restarts, and routes tool calls to them.
|
||||
*
|
||||
* In production: child processes run inside a Docker container (one per mesh).
|
||||
* In dev: child processes run directly on the broker host.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** MCP tool definition returned by tools/list. */
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-service deploy-time configuration. */
|
||||
export interface ServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
memory_mb?: number;
|
||||
cpus?: number;
|
||||
network_allow?: string[];
|
||||
runtime?: "node" | "python" | "bun";
|
||||
}
|
||||
|
||||
/** Observable lifecycle states. */
|
||||
export type ServiceStatus =
|
||||
| "building"
|
||||
| "installing"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "failed"
|
||||
| "crashed"
|
||||
| "restarting";
|
||||
|
||||
/** Internal bookkeeping for a spawned service. */
|
||||
interface ManagedService {
|
||||
name: string;
|
||||
meshId: string;
|
||||
process: ChildProcess | null;
|
||||
tools: ToolDef[];
|
||||
status: ServiceStatus;
|
||||
config: ServiceConfig;
|
||||
sourcePath: string;
|
||||
runtime: "node" | "python" | "bun";
|
||||
restartCount: number;
|
||||
maxRestarts: number;
|
||||
healthFailures: number;
|
||||
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
|
||||
pendingCalls: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (result: { result?: unknown; error?: string }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>;
|
||||
pid?: number;
|
||||
startedAt?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_BUFFER_SIZE = 1000;
|
||||
const HEALTH_INTERVAL_MS = 30_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
const MAX_HEALTH_FAILURES = 3;
|
||||
const DEFAULT_MAX_RESTARTS = 5;
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const SERVICES_BASE_DIR =
|
||||
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
|
||||
let healthTimer: NodeJS.Timer | null = null;
|
||||
|
||||
function serviceKey(meshId: string, name: string): string {
|
||||
return `${meshId}:${name}`;
|
||||
}
|
||||
|
||||
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
export function validateServiceName(name: string): string | null {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
|
||||
}
|
||||
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
||||
return "service name must not contain path separators";
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the runtime for a service based on its source directory contents.
|
||||
*
|
||||
* Priority: bun (lockfile/config) > node (package.json) > python
|
||||
* (pyproject.toml / requirements.txt). Falls back to node.
|
||||
*/
|
||||
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
|
||||
if (
|
||||
existsSync(join(sourcePath, "bun.lockb")) ||
|
||||
existsSync(join(sourcePath, "bunfig.toml"))
|
||||
) {
|
||||
return "bun";
|
||||
}
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
return "node";
|
||||
}
|
||||
if (
|
||||
existsSync(join(sourcePath, "pyproject.toml")) ||
|
||||
existsSync(join(sourcePath, "requirements.txt"))
|
||||
) {
|
||||
return "python";
|
||||
}
|
||||
return "node"; // default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectEntry(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): { command: string; args: string[] } {
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
for (const entry of [
|
||||
"server.py",
|
||||
"src/server.py",
|
||||
"main.py",
|
||||
"src/main.py",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: "python", args: [entry] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) {
|
||||
return { command: "python", args: ["-m", "server"] };
|
||||
}
|
||||
return { command: "python", args: ["server.py"] };
|
||||
}
|
||||
|
||||
// Node / Bun
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(sourcePath, "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.main) return { command: cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin =
|
||||
typeof pkg.bin === "string"
|
||||
? pkg.bin
|
||||
: (Object.values(pkg.bin)[0] as string);
|
||||
if (bin) return { command: cmd, args: [bin] };
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Common entry points
|
||||
for (const entry of [
|
||||
"dist/index.js",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"index.js",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: cmd, args: [entry] };
|
||||
}
|
||||
}
|
||||
|
||||
return { command: cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install dependencies for a service. Resolves on success, rejects with
|
||||
* the tail of stderr on failure.
|
||||
*/
|
||||
export async function installDeps(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "."];
|
||||
}
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun";
|
||||
args = ["install"];
|
||||
} else {
|
||||
cmd = "npm";
|
||||
args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: sourcePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log ring buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendLog(svc: ManagedService, line: string): void {
|
||||
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
|
||||
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
|
||||
svc.logBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let callIdCounter = 0;
|
||||
|
||||
function sendMcpRequest(
|
||||
svc: ManagedService,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (!svc.process || !svc.process.stdin?.writable) {
|
||||
resolve({ error: "service not running" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `call_${++callIdCounter}`;
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
|
||||
}, CALL_TIMEOUT_MS);
|
||||
|
||||
svc.pendingCalls.set(id, { resolve, timer });
|
||||
|
||||
try {
|
||||
svc.process.stdin!.write(JSON.stringify(request) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({
|
||||
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize MCP server (handshake + tool discovery)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
|
||||
// MCP initialize handshake
|
||||
const initResult = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
|
||||
if (initResult.error) {
|
||||
throw new Error(`MCP initialize failed: ${initResult.error}`);
|
||||
}
|
||||
|
||||
// Send initialized notification (no response expected)
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch tool list
|
||||
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (toolsResult.error) {
|
||||
throw new Error(`tools/list failed: ${toolsResult.error}`);
|
||||
}
|
||||
|
||||
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
|
||||
return result?.tools ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn an MCP server child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnService(svc: ManagedService): void {
|
||||
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...(svc.config.env ?? {}),
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.startedAt = new Date();
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
// Read MCP JSON-RPC responses from stdout
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
|
||||
const pending = svc.pendingCalls.get(String(msg.id))!;
|
||||
clearTimeout(pending.timer);
|
||||
svc.pendingCalls.delete(String(msg.id));
|
||||
if (msg.error) {
|
||||
pending.resolve({
|
||||
error: msg.error.message ?? JSON.stringify(msg.error),
|
||||
});
|
||||
} else {
|
||||
pending.resolve({ result: msg.result });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as log output
|
||||
appendLog(svc, `[stdout] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr as logs
|
||||
const stderrRl = createInterface({ input: child.stderr! });
|
||||
stderrRl.on("line", (line) => {
|
||||
appendLog(svc, `[stderr] ${line}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
log.warn("service exited", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
code,
|
||||
signal,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
|
||||
// Reject all pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service crashed" });
|
||||
}
|
||||
svc.pendingCalls.clear();
|
||||
svc.process = null;
|
||||
svc.pid = undefined;
|
||||
|
||||
// Auto-restart if under limit
|
||||
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
|
||||
svc.restartCount++;
|
||||
svc.status = "restarting";
|
||||
log.info("auto-restarting service", {
|
||||
service: svc.name,
|
||||
attempt: svc.restartCount,
|
||||
});
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
|
||||
} else if (svc.status === "running") {
|
||||
svc.status = "crashed";
|
||||
log.error("service max restarts exceeded", {
|
||||
service: svc.name,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error("service spawn error", {
|
||||
service: svc.name,
|
||||
error: err.message,
|
||||
});
|
||||
svc.status = "failed";
|
||||
});
|
||||
|
||||
log.info("service spawned", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
pid: child.pid,
|
||||
command,
|
||||
args,
|
||||
runtime: svc.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deploy (or redeploy) an MCP server.
|
||||
*
|
||||
* Installs dependencies, spawns the child process, runs the MCP
|
||||
* initialize handshake, and returns the discovered tool list.
|
||||
*/
|
||||
export async function deploy(opts: {
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
|
||||
const key = serviceKey(opts.meshId, opts.name);
|
||||
|
||||
// Kill existing if redeploying
|
||||
const existing = services.get(key);
|
||||
if (existing?.process) {
|
||||
existing.process.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
|
||||
|
||||
const svc: ManagedService = {
|
||||
name: opts.name,
|
||||
meshId: opts.meshId,
|
||||
process: null,
|
||||
tools: [],
|
||||
status: "installing",
|
||||
config: {
|
||||
...opts.config,
|
||||
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
|
||||
},
|
||||
sourcePath: opts.sourcePath,
|
||||
runtime,
|
||||
restartCount: 0,
|
||||
maxRestarts: DEFAULT_MAX_RESTARTS,
|
||||
healthFailures: 0,
|
||||
logBuffer: [],
|
||||
pendingCalls: new Map(),
|
||||
};
|
||||
|
||||
services.set(key, svc);
|
||||
|
||||
// Install dependencies
|
||||
try {
|
||||
await installDeps(opts.sourcePath, runtime);
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Spawn and initialize
|
||||
spawnService(svc);
|
||||
|
||||
// Wait a moment for the process to start
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Get tool list via MCP initialize handshake
|
||||
try {
|
||||
svc.tools = await initializeMcp(svc);
|
||||
log.info("service deployed", {
|
||||
service: opts.name,
|
||||
mesh_id: opts.meshId,
|
||||
tools: svc.tools.length,
|
||||
runtime,
|
||||
});
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { tools: svc.tools, status: svc.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
|
||||
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
|
||||
*/
|
||||
export async function undeploy(meshId: string, name: string): Promise<void> {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return;
|
||||
|
||||
svc.status = "stopped";
|
||||
if (svc.process) {
|
||||
svc.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
svc.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
svc.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reject pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service undeployed" });
|
||||
}
|
||||
|
||||
services.delete(key);
|
||||
log.info("service undeployed", { service: name, mesh_id: meshId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a tool call to the named service. Returns the MCP response
|
||||
* payload or an error string.
|
||||
*/
|
||||
export async function callTool(
|
||||
meshId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
const key = serviceKey(meshId, serverName);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return { error: `service "${serverName}" not found` };
|
||||
if (svc.status !== "running")
|
||||
return { error: `service "${serverName}" is ${svc.status}` };
|
||||
if (!svc.process)
|
||||
return { error: `service "${serverName}" has no running process` };
|
||||
|
||||
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last N log lines for a service (from its ring buffer).
|
||||
*/
|
||||
export function getLogs(meshId: string, name: string, lines = 50): string[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return [];
|
||||
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current status, PID, restart count, tool list, and uptime
|
||||
* for a single service. Returns null if the service doesn't exist.
|
||||
*/
|
||||
export function getStatus(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): {
|
||||
status: ServiceStatus;
|
||||
pid?: number;
|
||||
restartCount: number;
|
||||
tools: ToolDef[];
|
||||
startedAt?: string;
|
||||
} | null {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return null;
|
||||
return {
|
||||
status: svc.status,
|
||||
pid: svc.pid,
|
||||
restartCount: svc.restartCount,
|
||||
tools: svc.tools,
|
||||
startedAt: svc.startedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the tool definitions for a service, or an empty array if the
|
||||
* service doesn't exist.
|
||||
*/
|
||||
export function getTools(meshId: string, name: string): ToolDef[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
return svc?.tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all services belonging to a mesh with summary info.
|
||||
*/
|
||||
export function listServices(
|
||||
meshId: string,
|
||||
): Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> {
|
||||
const result: Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> = [];
|
||||
for (const [key, svc] of services) {
|
||||
if (!key.startsWith(`${meshId}:`)) continue;
|
||||
result.push({
|
||||
name: svc.name,
|
||||
status: svc.status,
|
||||
toolCount: svc.tools.length,
|
||||
runtime: svc.runtime,
|
||||
restartCount: svc.restartCount,
|
||||
pid: svc.pid,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function healthCheckAll(): Promise<void> {
|
||||
for (const [, svc] of services) {
|
||||
if (svc.status !== "running" || !svc.process) continue;
|
||||
|
||||
const result = await sendMcpRequest(svc, "ping", {});
|
||||
if (result.error) {
|
||||
svc.healthFailures++;
|
||||
log.warn("health check failed", {
|
||||
service: svc.name,
|
||||
failures: svc.healthFailures,
|
||||
error: result.error,
|
||||
});
|
||||
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
|
||||
log.error("health check threshold exceeded, restarting", {
|
||||
service: svc.name,
|
||||
});
|
||||
svc.process.kill("SIGTERM");
|
||||
// exit handler will trigger auto-restart
|
||||
}
|
||||
} else {
|
||||
svc.healthFailures = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the periodic health check loop (30 s interval). No-op if already running. */
|
||||
export function startHealthChecks(): void {
|
||||
if (healthTimer) return;
|
||||
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the periodic health check loop. */
|
||||
export function stopHealthChecks(): void {
|
||||
if (healthTimer) {
|
||||
clearInterval(healthTimer);
|
||||
healthTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore all services on broker boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-deploy every persisted service record. Called once at broker startup
|
||||
* to bring services back after a restart. Failures are logged but don't
|
||||
* prevent other services from restoring.
|
||||
*/
|
||||
export async function restoreAll(
|
||||
getServiceRecords: () => Promise<
|
||||
Array<{
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const records = await getServiceRecords();
|
||||
log.info("restoring services", { count: records.length });
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
await deploy({
|
||||
meshId: record.meshId,
|
||||
name: record.name,
|
||||
sourcePath: record.sourcePath,
|
||||
config: record.config,
|
||||
resolvedEnv: record.resolvedEnv,
|
||||
});
|
||||
log.info("service restored", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
});
|
||||
} catch (e) {
|
||||
log.error("service restore failed", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startHealthChecks();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gracefully shut down all running services. Stops health checks, sends
|
||||
* SIGTERM to every child, waits for exit, then clears the registry.
|
||||
*/
|
||||
export async function shutdownAll(): Promise<void> {
|
||||
stopHealthChecks();
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [, svc] of services) {
|
||||
if (svc.process) {
|
||||
svc.status = "stopped";
|
||||
promises.push(undeploy(svc.meshId, svc.name));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
services.clear();
|
||||
}
|
||||
1711
apps/broker/src/telegram-bridge.ts
Normal file
148
apps/broker/src/telegram-token.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* JWT utilities for Telegram bridge connections.
|
||||
*
|
||||
* When a user connects their Telegram chat to a mesh, the broker generates
|
||||
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
|
||||
* this token to establish the connection.
|
||||
*
|
||||
* Pure-crypto implementation — no external JWT library.
|
||||
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
|
||||
*
|
||||
* IMPORTANT: The JWT payload contains the member's secretKey.
|
||||
* Never log the token or its decoded payload.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TelegramConnectPayload {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string; // ed25519 secret key — sensitive
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
|
||||
interface JwtClaims extends TelegramConnectPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64url(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
return Buffer.from(str, "base64url").toString("utf-8");
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(input).digest("base64url");
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
/**
|
||||
* Create a signed JWT containing Telegram connect credentials.
|
||||
* Expires in 15 minutes.
|
||||
*/
|
||||
export function generateTelegramConnectToken(
|
||||
payload: TelegramConnectPayload,
|
||||
secret: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const claims: JwtClaims = {
|
||||
...payload,
|
||||
iss: "claudemesh-broker",
|
||||
sub: "telegram-connect",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
|
||||
const signature = sign(signingInput, secret);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode a Telegram connect JWT.
|
||||
* Returns the payload on success, or null on any failure
|
||||
* (bad signature, expired, wrong subject).
|
||||
*/
|
||||
export function validateTelegramConnectToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): TelegramConnectPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSignature = sign(signingInput, secret);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const a = Buffer.from(signatureB64);
|
||||
const b = Buffer.from(expectedSignature);
|
||||
if (a.length !== b.length) return null;
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
if (!timingSafeEqual(a, b)) return null;
|
||||
|
||||
// Verify header algorithm
|
||||
const header = JSON.parse(base64urlDecode(headerB64));
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
// Decode and validate claims
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
|
||||
|
||||
// Check subject
|
||||
if (claims.sub !== "telegram-connect") return null;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp < now) return null;
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (claims.iat > now + 30) return null;
|
||||
|
||||
// Extract payload fields (strip JWT claims)
|
||||
const {
|
||||
meshId,
|
||||
meshSlug,
|
||||
memberId,
|
||||
pubkey,
|
||||
secretKey,
|
||||
createdBy,
|
||||
} = claims;
|
||||
|
||||
// Basic presence check
|
||||
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Telegram deep link that passes the JWT as start parameter.
|
||||
* Format: https://t.me/{botUsername}?start={token}
|
||||
*/
|
||||
export function generateDeepLink(token: string, botUsername: string): string {
|
||||
return `https://t.me/${botUsername}?start=${token}`;
|
||||
}
|
||||
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.1.16",
|
||||
"version": "0.9.2",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
|
||||
90
apps/cli/src/auth/callback-listener.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Localhost HTTP callback listener for CLI-to-browser sync flow.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /ping → reachability check (web page preflight)
|
||||
* GET /callback → receives sync token via ?token= query param
|
||||
* OPTIONS * → CORS preflight for claudemesh.com
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface CallbackListener {
|
||||
/** Port the server is listening on. */
|
||||
port: number;
|
||||
/** Resolves when the /callback endpoint receives a token. */
|
||||
token: Promise<string>;
|
||||
/** Shut down the server. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a localhost HTTP server on a random OS-assigned port.
|
||||
* Returns the port and a promise that resolves with the sync token.
|
||||
*/
|
||||
export function startCallbackListener(): Promise<CallbackListener> {
|
||||
return new Promise((resolveStart) => {
|
||||
let resolveToken: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((r) => {
|
||||
resolveToken = r;
|
||||
});
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reachability check — web page calls this before redirecting
|
||||
if (url.pathname === "/ping") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync token callback
|
||||
if (url.pathname === "/callback") {
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end(
|
||||
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
|
||||
);
|
||||
resolveToken(token);
|
||||
// Close server after a short delay to ensure response is sent
|
||||
setTimeout(() => server.close(), 500);
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing token");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveStart({
|
||||
port: addr.port,
|
||||
token: tokenPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
4
apps/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { startCallbackListener, type CallbackListener } from "./callback-listener";
|
||||
export { openBrowser } from "./open-browser";
|
||||
export { generatePairingCode } from "./pairing-code";
|
||||
export { syncWithBroker, type SyncResult } from "./sync-with-broker";
|
||||
33
apps/cli/src/auth/open-browser.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-platform browser opener.
|
||||
* Respects BROWSER env var. Falls back to platform-specific launcher.
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
* Returns true if the command succeeded, false otherwise.
|
||||
* Non-fatal — callers should show the URL as fallback.
|
||||
*/
|
||||
export function openBrowser(url: string): Promise<boolean> {
|
||||
// Validate URL
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const quoted = JSON.stringify(url);
|
||||
const browserCmd = process.env.BROWSER;
|
||||
|
||||
const cmd = browserCmd
|
||||
? `${browserCmd} ${quoted}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${quoted}`
|
||||
: process.platform === "win32"
|
||||
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
|
||||
: `xdg-open ${quoted}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
17
apps/cli/src/auth/pairing-code.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generate a short pairing code for CLI-to-browser visual confirmation.
|
||||
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
/**
|
||||
* Generate a 4-character alphanumeric pairing code.
|
||||
* Example output: "A3Kx", "Hn7v", "pQ4m"
|
||||
*/
|
||||
export function generatePairingCode(): string {
|
||||
const bytes = randomBytes(4);
|
||||
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
|
||||
}
|
||||
83
apps/cli/src/auth/sync-with-broker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
|
||||
*
|
||||
* Takes a sync JWT (from the browser callback) and a freshly generated
|
||||
* ed25519 keypair. The broker creates member rows and returns mesh details.
|
||||
*/
|
||||
|
||||
export interface SyncResult {
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync meshes from dashboard via broker.
|
||||
*
|
||||
* @param syncToken - JWT from the browser sync flow
|
||||
* @param peerPubkey - ed25519 public key hex (64 chars)
|
||||
* @param displayName - display name for the new member
|
||||
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
|
||||
*/
|
||||
export async function syncWithBroker(
|
||||
syncToken: string,
|
||||
peerPubkey: string,
|
||||
displayName: string,
|
||||
brokerBaseUrl?: string,
|
||||
): Promise<SyncResult> {
|
||||
// Default broker URL — derive HTTPS from WSS
|
||||
const base = brokerBaseUrl ?? deriveHttpUrl(
|
||||
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
);
|
||||
|
||||
const res = await fetch(`${base}/cli-sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sync_token: syncToken,
|
||||
peer_pubkey: peerPubkey,
|
||||
display_name: displayName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let msg: string;
|
||||
try {
|
||||
msg = JSON.parse(body).error ?? body;
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
|
||||
|
||||
if (!body.ok) {
|
||||
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: body.account_id!,
|
||||
meshes: body.meshes!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WSS broker URL to an HTTPS base URL.
|
||||
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
|
||||
* ws://localhost:3001/ws → http://localhost:3001
|
||||
*/
|
||||
function deriveHttpUrl(wssUrl: string): string {
|
||||
const url = new URL(wssUrl);
|
||||
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
||||
// Remove /ws path suffix
|
||||
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
|
||||
// Remove trailing slash
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
65
apps/cli/src/commands/connect-telegram.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mesh = config.meshes[0]!;
|
||||
const linkOnly = args.includes("--link");
|
||||
|
||||
// Convert WS broker URL to HTTP
|
||||
const brokerHttp = mesh.brokerUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace("/ws", "");
|
||||
|
||||
console.log("Requesting Telegram connect token...");
|
||||
|
||||
const res = await fetch(`${brokerHttp}/tg/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
pubkey: mesh.pubkey,
|
||||
secretKey: mesh.secretKey,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { token, deepLink } = (await res.json()) as {
|
||||
token: string;
|
||||
deepLink: string;
|
||||
};
|
||||
|
||||
if (linkOnly) {
|
||||
console.log(deepLink);
|
||||
return;
|
||||
}
|
||||
|
||||
// Print QR code using simple block characters
|
||||
console.log("\n Connect Telegram to your mesh:\n");
|
||||
console.log(` ${deepLink}\n`);
|
||||
console.log(" Open this link on your phone, or scan the QR code");
|
||||
console.log(" with your Telegram camera.\n");
|
||||
|
||||
// Try to generate QR with qrcode-terminal if available
|
||||
try {
|
||||
const QRCode = require("qrcode-terminal");
|
||||
QRCode.generate(deepLink, { small: true }, (code: string) => {
|
||||
console.log(code);
|
||||
});
|
||||
} catch {
|
||||
// qrcode-terminal not available, link is enough
|
||||
console.log(" (Install qrcode-terminal for QR code display)");
|
||||
}
|
||||
}
|
||||
59
apps/cli/src/commands/connect.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
|
||||
*
|
||||
* Opens a connection to one mesh, runs a callback, then closes cleanly.
|
||||
* The caller never deals with connect/close lifecycle.
|
||||
*/
|
||||
|
||||
import { hostname } from "node:os";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { loadConfig } from "../state/config";
|
||||
import type { JoinedMesh } from "../state/config";
|
||||
|
||||
export interface ConnectOpts {
|
||||
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||
meshSlug?: string | null;
|
||||
/** Display name for this session. Defaults to hostname-pid. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
const client = new BrokerClient(mesh, { displayName });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await fn(client, mesh);
|
||||
return result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
39
apps/cli/src/commands/create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `claudemesh create` — Create a new mesh with an optional template.
|
||||
* Lists available templates if --list-templates is passed.
|
||||
*/
|
||||
import { listTemplates, getTemplate } from "../templates/index.js";
|
||||
|
||||
export function runCreate(args: Record<string, unknown>): void {
|
||||
if (args["list-templates"]) {
|
||||
console.log("Available mesh templates:\n");
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`);
|
||||
console.log(` ${t.description}`);
|
||||
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
|
||||
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = args.template as string | undefined;
|
||||
if (templateName) {
|
||||
const template = getTemplate(templateName);
|
||||
if (!template) {
|
||||
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Template "${template.name}" loaded:`);
|
||||
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
|
||||
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
|
||||
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
|
||||
console.log();
|
||||
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
|
||||
// Future: wire into actual mesh creation API
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Usage: claudemesh create --template <name>");
|
||||
console.log(" claudemesh create --list-templates");
|
||||
}
|
||||
3
apps/cli/src/commands/disconnect-telegram.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
60
apps/cli/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { InboundPush } from "../ws/client";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
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);
|
||||
|
||||
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<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;
|
||||
|
||||
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));
|
||||
|
||||
const messages = client.drainPushBuffer();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
||||
console.log("");
|
||||
for (const msg of messages) {
|
||||
console.log(formatMessage(msg, useColor));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||
*
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export interface InfoFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
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 = loadConfig();
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const [brokerInfo, peers, state] = await Promise.all([
|
||||
client.meshInfo(),
|
||||
client.listPeers(),
|
||||
client.listState(),
|
||||
]);
|
||||
|
||||
const output = {
|
||||
slug: mesh.slug,
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
brokerUrl: mesh.brokerUrl,
|
||||
displayName: config.displayName ?? null,
|
||||
peerCount: peers.length,
|
||||
stateCount: state.length,
|
||||
...(brokerInfo ?? {}),
|
||||
};
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
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`);
|
||||
if (brokerInfo && typeof brokerInfo === "object") {
|
||||
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -212,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__cancel_scheduled",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__grant_file_access",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__list_scheduled",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__schedule_reminder",
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_store",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
@@ -321,6 +408,26 @@ export function runInstall(args: string[] = []): void {
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||
@@ -375,6 +505,20 @@ export function runUninstall(): void {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
* Flags are defined in index.ts (citty command) — that is the source of
|
||||
* truth. This file receives already-parsed flags and rawArgs.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
||||
* 2. If --join: run join flow first (accepts token or URL)
|
||||
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||
* 2. If --join: run join flow first
|
||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||
@@ -11,62 +14,29 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
||||
import { tmpdir, hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir, hostname, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
// --- Arg parsing ---
|
||||
|
||||
interface LaunchArgs {
|
||||
name: string | null;
|
||||
joinLink: string | null;
|
||||
meshSlug: string | null;
|
||||
quiet: boolean;
|
||||
skipPermConfirm: boolean;
|
||||
claudeArgs: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): LaunchArgs {
|
||||
const result: LaunchArgs = {
|
||||
name: null,
|
||||
joinLink: null,
|
||||
meshSlug: null,
|
||||
quiet: false,
|
||||
skipPermConfirm: false,
|
||||
claudeArgs: [],
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--name" && i + 1 < argv.length) {
|
||||
result.name = argv[++i]!;
|
||||
} else if (arg.startsWith("--name=")) {
|
||||
result.name = arg.slice("--name=".length);
|
||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
||||
result.joinLink = argv[++i]!;
|
||||
} else if (arg.startsWith("--join=")) {
|
||||
result.joinLink = arg.slice("--join=".length);
|
||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
||||
result.meshSlug = argv[++i]!;
|
||||
} else if (arg.startsWith("--mesh=")) {
|
||||
result.meshSlug = arg.slice("--mesh=".length);
|
||||
} else if (arg === "--quiet") {
|
||||
result.quiet = true;
|
||||
} else if (arg === "-y" || arg === "--yes") {
|
||||
result.skipPermConfirm = true;
|
||||
} else if (arg === "--") {
|
||||
result.claudeArgs.push(...argv.slice(i + 1));
|
||||
break;
|
||||
} else {
|
||||
result.claudeArgs.push(arg);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string;
|
||||
join?: string;
|
||||
mesh?: string;
|
||||
"message-mode"?: string;
|
||||
"system-prompt"?: string;
|
||||
resume?: string;
|
||||
continue?: boolean;
|
||||
yes?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
// --- Interactive mesh picker ---
|
||||
@@ -95,6 +65,33 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Group string parser ---
|
||||
|
||||
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
|
||||
function parseGroupsString(raw: string): GroupEntry[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
const idx = token.indexOf(":");
|
||||
if (idx === -1) return { name: token };
|
||||
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Interactive role/groups prompts ---
|
||||
|
||||
function askLine(prompt: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Permission confirmation ---
|
||||
|
||||
async function confirmPermissions(): Promise<void> {
|
||||
@@ -106,12 +103,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will send and receive peer messages without asking");
|
||||
console.log(" you first. Peers exchange text only — no file access,");
|
||||
console.log(" no tool calls, no code execution.");
|
||||
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||
console.log("");
|
||||
console.log(dim(" Same as: claude --dangerously-skip-permissions"));
|
||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
@@ -132,16 +129,27 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
// --- Banner ---
|
||||
|
||||
function printBanner(name: string, meshSlug: string): void {
|
||||
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const roleSuffix = role ? ` (${role})` : "";
|
||||
const groupTags = groups.length
|
||||
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
|
||||
const rule = "─".repeat(60);
|
||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
|
||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||
console.log(rule);
|
||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||
if (messageMode === "push") {
|
||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||
} else if (messageMode === "inbox") {
|
||||
console.log("Peer messages held in inbox. Use check_messages to read.");
|
||||
} else {
|
||||
console.log("Messages off. Use check_messages to poll manually.");
|
||||
}
|
||||
console.log("Peers send text only — they cannot call tools or read files.");
|
||||
console.log(dim(`Config: ${getConfigPath()}`));
|
||||
console.log(rule);
|
||||
@@ -150,8 +158,28 @@ function printBanner(name: string, meshSlug: string): void {
|
||||
|
||||
// --- Main ---
|
||||
|
||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
const args = parseArgs(extraArgs);
|
||||
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||
// Extract args that follow "--" — passed straight through to claude.
|
||||
const dashIdx = rawArgs.indexOf("--");
|
||||
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||
|
||||
// Normalise flags into the internal shape used below.
|
||||
const args = {
|
||||
name: flags.name ?? null,
|
||||
role: flags.role ?? null,
|
||||
groups: flags.groups ?? null,
|
||||
joinLink: flags.join ?? null,
|
||||
meshSlug: flags.mesh ?? null,
|
||||
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||
: null),
|
||||
systemPrompt: flags["system-prompt"] ?? null,
|
||||
resume: flags.resume ?? null,
|
||||
continueSession: flags.continue ?? false,
|
||||
quiet: flags.quiet ?? false,
|
||||
skipPermConfirm: flags.yes ?? false,
|
||||
claudeArgs: claudePassthrough,
|
||||
};
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
@@ -189,10 +217,85 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
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 code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||
console.log(` Opening browser to sign in...\n`);
|
||||
|
||||
const opened = await openBrowser(url);
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("\n Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(
|
||||
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
||||
);
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -210,11 +313,41 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
mesh = await pickMesh(config.meshes);
|
||||
}
|
||||
|
||||
// 3. Session identity. The WS client auto-generates a per-session
|
||||
// ephemeral keypair on connect (sent in hello as sessionPubkey).
|
||||
// We just set the display name via env var.
|
||||
// 3. Session identity + role/groups.
|
||||
// The WS client auto-generates a per-session ephemeral keypair on
|
||||
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
|
||||
let role: string | null = args.role;
|
||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet && !justSynced) {
|
||||
if (role === null) {
|
||||
const answer = await askLine(" Role (optional): ");
|
||||
if (answer) role = answer;
|
||||
}
|
||||
if (parsedGroups.length === 0 && args.groups === null) {
|
||||
const answer = await askLine(" Groups (comma-separated, optional): ");
|
||||
if (answer) parsedGroups = parseGroupsString(answer);
|
||||
}
|
||||
if (args.messageMode === null) {
|
||||
console.log("\n Message mode:");
|
||||
console.log(" 1) Push (real-time, peers can interrupt your work)");
|
||||
console.log(" 2) Inbox (held until you check, notification only)");
|
||||
console.log(" 3) Off (tools only, no messages)");
|
||||
console.log("");
|
||||
const answer = await askLine(" Choice [1]: ");
|
||||
const choice = parseInt(answer || "1", 10);
|
||||
if (choice === 2) messageMode = "inbox";
|
||||
else if (choice === 3) messageMode = "off";
|
||||
else messageMode = "push";
|
||||
}
|
||||
if (role || parsedGroups.length) console.log("");
|
||||
}
|
||||
|
||||
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||
const tmpBase = tmpdir();
|
||||
try {
|
||||
@@ -226,12 +359,65 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Clean up stale mesh MCP entries from crashed sessions
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
if (existsSync(claudeConfigPath)) {
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
let cleaned = 0;
|
||||
for (const key of Object.keys(mcpServers)) {
|
||||
if (!key.startsWith("mesh:")) continue;
|
||||
const meta = mcpServers[key]?._meshSession;
|
||||
if (!meta?.pid) continue;
|
||||
// Check if the PID is still alive
|
||||
try {
|
||||
process.kill(meta.pid, 0); // signal 0 = check existence
|
||||
} catch {
|
||||
// PID is dead — remove stale entry
|
||||
delete mcpServers[key];
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
if (cleaned > 0) {
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// --- Fetch deployed services for native MCP entries ---
|
||||
let serviceCatalog: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: object }>;
|
||||
deployed_by: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const tmpClient = new BrokerClient(mesh, { displayName });
|
||||
await tmpClient.connect();
|
||||
// Wait briefly for hello_ack with service catalog
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
serviceCatalog = tmpClient.serviceCatalog;
|
||||
tmpClient.close();
|
||||
} catch {
|
||||
// Non-fatal — launch without native service entries
|
||||
if (!args.quiet) {
|
||||
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||
const sessionConfig: Config = {
|
||||
version: 1,
|
||||
meshes: [mesh],
|
||||
displayName,
|
||||
...(role ? { role } : {}),
|
||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||
messageMode,
|
||||
};
|
||||
writeFileSync(
|
||||
join(tmpDir, "config.json"),
|
||||
@@ -241,13 +427,66 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
|
||||
// 5. Banner + permission confirmation.
|
||||
if (!args.quiet) {
|
||||
printBanner(displayName, mesh.slug);
|
||||
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||
if (!args.skipPermConfirm) {
|
||||
await confirmPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Install native MCP entries for deployed mesh services ---
|
||||
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||
|
||||
if (serviceCatalog.length > 0) {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
|
||||
// Read-modify-write: only touch mesh:* entries in mcpServers
|
||||
let claudeConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
} catch {
|
||||
claudeConfig = {};
|
||||
}
|
||||
|
||||
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Session-scoped key: mesh:<service>:<sessionId>
|
||||
const sessionTag = `${process.pid}`;
|
||||
|
||||
for (const svc of serviceCatalog) {
|
||||
if (svc.status !== "running") continue;
|
||||
const entryKey = `mesh:${svc.name}:${sessionTag}`;
|
||||
const entry = {
|
||||
command: "claudemesh",
|
||||
args: ["mcp", "--service", svc.name],
|
||||
env: {
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
},
|
||||
_meshSession: {
|
||||
pid: process.pid,
|
||||
meshSlug: mesh.slug,
|
||||
serviceName: svc.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
mcpServers[entryKey] = entry;
|
||||
meshMcpEntries.push({ key: entryKey, entry });
|
||||
}
|
||||
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
if (!args.quiet && meshMcpEntries.length > 0) {
|
||||
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
|
||||
for (const { key } of meshMcpEntries) {
|
||||
const svcName = key.split(":")[1];
|
||||
const svc = serviceCatalog.find(s => s.name === svcName);
|
||||
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||
const filtered: string[] = [];
|
||||
@@ -259,10 +498,23 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
// Session identity: --resume reuses existing session, otherwise generate new.
|
||||
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
|
||||
const isResume = args.resume !== null || args.continueSession;
|
||||
const claudeSessionId = isResume ? undefined : randomUUID();
|
||||
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
"--dangerously-skip-permissions",
|
||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||
...(args.resume ? ["--resume", args.resume] : []),
|
||||
...(args.continueSession ? ["--continue"] : []),
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
@@ -274,11 +526,29 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Cleanup on exit.
|
||||
const cleanup = (): void => {
|
||||
// Remove mesh MCP entries from ~/.claude.json
|
||||
if (meshMcpEntries.length > 0) {
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
for (const { key } of meshMcpEntries) {
|
||||
delete mcpServers[key];
|
||||
}
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
// Existing tmpdir cleanup
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
|
||||
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): 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);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): 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 green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
114
apps/cli/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
"role-tag"?: string;
|
||||
groups?: string;
|
||||
"message-mode"?: string;
|
||||
name?: string;
|
||||
member?: string; // admin only: edit another member
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
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 config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick mesh
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0]!;
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive broker HTTP URL from WSS URL
|
||||
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
|
||||
|
||||
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
|
||||
|
||||
if (hasEdits) {
|
||||
// PATCH member profile
|
||||
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.name !== undefined) body.displayName = flags.name;
|
||||
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
|
||||
if (flags.groups !== undefined) {
|
||||
body.groups = flags.groups.split(",").map(s => {
|
||||
const [name, role] = s.trim().split(":");
|
||||
return role ? { name: name!, role } : { name: name! };
|
||||
});
|
||||
}
|
||||
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
|
||||
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Member-Id": mesh.memberId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await res.json() as Record<string, unknown>;
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.ok) {
|
||||
console.log(green("✓ Profile updated"));
|
||||
const member = result.member as Record<string, unknown>;
|
||||
printProfile(member, dim);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// GET members list, show current user's profile
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
|
||||
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = result.members?.find(m => m.id === mesh.memberId);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(me ?? {}, null, 2));
|
||||
} else if (me) {
|
||||
printProfile(me, dim);
|
||||
} else {
|
||||
console.log("Member not found in mesh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
|
||||
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
|
||||
const groupStr = groups?.length
|
||||
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
|
||||
: dim("(none)");
|
||||
|
||||
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
|
||||
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
|
||||
console.log(` Groups: ${groupStr}`);
|
||||
console.log(` Messages: ${member.messageMode ?? "push"}`);
|
||||
console.log(` Access: ${member.permission ?? "member"}`);
|
||||
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
|
||||
}
|
||||
142
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): 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 action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): 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);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): 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);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||
*
|
||||
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||
* merges new meshes into local config.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
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 config = loadConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser to sync meshes...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* File encryption for claudemesh E2E file sharing.
|
||||
*
|
||||
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
|
||||
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
|
||||
* Key opening: crypto_box_seal_open with own X25519 keypair.
|
||||
*/
|
||||
|
||||
import { ensureSodium } from "./keypair";
|
||||
|
||||
export interface EncryptedFile {
|
||||
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
|
||||
nonce: string; // base64 24-byte nonce
|
||||
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt file bytes with a fresh random symmetric key.
|
||||
* Returns ciphertext, nonce (base64), and the plaintext Kf.
|
||||
*/
|
||||
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
|
||||
const sodium = await ensureSodium();
|
||||
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||
return {
|
||||
ciphertext,
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt file bytes with the symmetric key Kf.
|
||||
* Returns null if decryption fails.
|
||||
*/
|
||||
export async function decryptFile(
|
||||
ciphertext: Uint8Array,
|
||||
nonceB64: string,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
|
||||
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
|
||||
* Returns base64 sealed box.
|
||||
*/
|
||||
export async function sealKeyForPeer(
|
||||
kf: Uint8Array,
|
||||
recipientPubkeyHex: string,
|
||||
): Promise<string> {
|
||||
const sodium = await ensureSodium();
|
||||
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
|
||||
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
|
||||
* Returns the 32-byte Kf or null if decryption fails.
|
||||
*/
|
||||
export async function openSealedKey(
|
||||
sealedB64: string,
|
||||
myPubkeyHex: string,
|
||||
mySecretKeyHex: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(myPubkeyHex),
|
||||
);
|
||||
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||
sodium.from_hex(mySecretKeyHex),
|
||||
);
|
||||
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* claudemesh-cli entry point.
|
||||
*
|
||||
* Uses citty to define commands and flags. --help is generated from
|
||||
* the command definitions — the flag list here IS the documentation.
|
||||
*
|
||||
* Dispatches between two modes:
|
||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||
* - `claudemesh <subcommand>` → CLI subcommand
|
||||
*
|
||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
||||
*/
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { startMcpServer } from "./mcp/server";
|
||||
import { runInstall, runUninstall } from "./commands/install";
|
||||
import { runJoin } from "./commands/join";
|
||||
@@ -19,98 +21,319 @@ import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { runSync } from "./commands/sync";
|
||||
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||
import { connectTelegram } from "./commands/connect-telegram";
|
||||
import { disconnectTelegram } from "./commands/disconnect-telegram";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
||||
|
||||
Usage:
|
||||
claudemesh <command> [args]
|
||||
|
||||
Commands:
|
||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||
(add --no-hooks for bare MCP registration)
|
||||
uninstall Remove MCP server + hooks
|
||||
launch [opts] Launch Claude Code with real-time push messages
|
||||
--name <name> Display name for this session
|
||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
||||
--join <url> Join a mesh before launching
|
||||
--quiet Skip the info banner
|
||||
-- <args> Pass remaining args to claude
|
||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
status Health report: broker reachability per joined mesh
|
||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
||||
--help, -h Show this help
|
||||
--version, -v Show the CLI version
|
||||
|
||||
Environment:
|
||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
||||
`;
|
||||
|
||||
const cmd = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (cmd) {
|
||||
case "mcp":
|
||||
await startMcpServer();
|
||||
return;
|
||||
case "install":
|
||||
runInstall(args);
|
||||
return;
|
||||
case "uninstall":
|
||||
runUninstall();
|
||||
return;
|
||||
case "hook":
|
||||
await runHook(args);
|
||||
return;
|
||||
case "launch":
|
||||
await runLaunch(args);
|
||||
return;
|
||||
case "join":
|
||||
await runJoin(args);
|
||||
return;
|
||||
case "list":
|
||||
runList();
|
||||
return;
|
||||
case "leave":
|
||||
runLeave(args);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus();
|
||||
return;
|
||||
case "doctor":
|
||||
await runDoctor();
|
||||
return;
|
||||
case "seed-test-mesh":
|
||||
runSeedTestMesh(args);
|
||||
return;
|
||||
case "--version":
|
||||
case "-v":
|
||||
case "version":
|
||||
console.log(VERSION);
|
||||
return;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "help":
|
||||
console.log(HELP);
|
||||
return;
|
||||
case undefined:
|
||||
runWelcome();
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
console.error("Run `claudemesh --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
const launch = defineCommand({
|
||||
meta: {
|
||||
name: "launch",
|
||||
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name visible to other peers",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
|
||||
},
|
||||
groups: {
|
||||
type: "string",
|
||||
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
|
||||
},
|
||||
mesh: {
|
||||
type: "string",
|
||||
description: "Mesh slug (interactive picker if omitted and >1 joined)",
|
||||
},
|
||||
join: {
|
||||
type: "string",
|
||||
description: "Join a mesh via invite URL before launching",
|
||||
},
|
||||
"message-mode": {
|
||||
type: "string",
|
||||
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
|
||||
},
|
||||
"system-prompt": {
|
||||
type: "string",
|
||||
description: "Custom system prompt for this Claude session",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "Skip the --dangerously-skip-permissions confirmation",
|
||||
default: false,
|
||||
},
|
||||
resume: {
|
||||
type: "string",
|
||||
alias: "r",
|
||||
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
|
||||
},
|
||||
continue: {
|
||||
type: "boolean",
|
||||
alias: "c",
|
||||
description: "Continue the most recent conversation in this directory",
|
||||
default: false,
|
||||
},
|
||||
quiet: {
|
||||
type: "boolean",
|
||||
description: "Suppress banner and interactive prompts",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ args, rawArgs }) {
|
||||
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||
return runLaunch(args, rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const install = defineCommand({
|
||||
meta: {
|
||||
name: "install",
|
||||
description: "Register MCP server and status hooks with Claude Code",
|
||||
},
|
||||
args: {
|
||||
"no-hooks": {
|
||||
type: "boolean",
|
||||
description: "Register MCP server only, skip hooks",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ rawArgs }) {
|
||||
runInstall(rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const join = defineCommand({
|
||||
meta: {
|
||||
name: "join",
|
||||
description: "Join a mesh via invite URL or token",
|
||||
},
|
||||
args: {
|
||||
url: {
|
||||
type: "positional",
|
||||
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
return runJoin([args.url]);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = defineCommand({
|
||||
meta: {
|
||||
name: "leave",
|
||||
description: "Leave a joined mesh and remove its local keypair",
|
||||
},
|
||||
args: {
|
||||
slug: {
|
||||
type: "positional",
|
||||
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
runLeave([args.slug]);
|
||||
},
|
||||
});
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "claudemesh",
|
||||
version: VERSION,
|
||||
description: "Peer mesh for Claude Code sessions",
|
||||
},
|
||||
subCommands: {
|
||||
launch,
|
||||
create: defineCommand({
|
||||
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||
args: {
|
||||
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||
},
|
||||
run({ args }) { runCreate(args); },
|
||||
}),
|
||||
install,
|
||||
uninstall: defineCommand({
|
||||
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||
run() { runUninstall(); },
|
||||
}),
|
||||
join,
|
||||
list: defineCommand({
|
||||
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||
args: {
|
||||
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to store", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Full-text search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
sync: defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||
},
|
||||
async run({ args }) { await runSync(args); },
|
||||
}),
|
||||
profile: defineCommand({
|
||||
meta: { name: "profile", description: "View or edit your member profile" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||
name: { type: "string", description: "Set display name" },
|
||||
member: { type: "string", description: "Edit another member (admin only)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
}),
|
||||
doctor: defineCommand({
|
||||
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||
async run() { await runDoctor(); },
|
||||
}),
|
||||
mcp: defineCommand({
|
||||
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||
async run() { await startMcpServer(); },
|
||||
}),
|
||||
"seed-test-mesh": defineCommand({
|
||||
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||
}),
|
||||
hook: defineCommand({
|
||||
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||
}),
|
||||
connect: defineCommand({
|
||||
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
disconnect: defineCommand({
|
||||
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await disconnectTelegram();
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
|
||||
@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "send_message",
|
||||
description:
|
||||
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer name, pubkey, or #channel",
|
||||
oneOf: [
|
||||
{ type: "string", description: "Peer name, pubkey, @group" },
|
||||
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
|
||||
],
|
||||
description: "Single target or array of targets",
|
||||
},
|
||||
message: { type: "string", description: "Message text" },
|
||||
priority: {
|
||||
@@ -44,6 +47,21 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message_status",
|
||||
description:
|
||||
"Check the delivery status of a sent message. Shows whether each recipient received it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "Message ID (returned by send_message)",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_messages",
|
||||
description:
|
||||
@@ -78,4 +96,925 @@ export const TOOLS: Tool[] = [
|
||||
required: ["status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_visible",
|
||||
description:
|
||||
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description: "true to be visible (default), false to hide",
|
||||
},
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_profile",
|
||||
description:
|
||||
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
avatar: {
|
||||
type: "string",
|
||||
description: "Emoji or URL for your avatar",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
description: "One-liner about yourself",
|
||||
},
|
||||
capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What you can help with",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join_group",
|
||||
description:
|
||||
"Join a group with an optional role. Other peers see your group membership in list_peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Group name (without @)" },
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Your role in the group (e.g. lead, member, observer)",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leave_group",
|
||||
description: "Leave a group.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Group name (without @)" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- State tools ---
|
||||
{
|
||||
name: "set_state",
|
||||
description:
|
||||
"Set a shared state value visible to all peers in the mesh. Pushes a change notification.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { description: "Any JSON value" },
|
||||
},
|
||||
required: ["key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Read a shared state value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_state",
|
||||
description: "List all shared state keys and values in the mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Memory tools ---
|
||||
{
|
||||
name: "remember",
|
||||
description:
|
||||
"Store persistent knowledge in the mesh's shared memory. Survives across sessions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The knowledge to remember",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional categorization tags",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
description: "Search the mesh's shared memory by relevance.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forget",
|
||||
description: "Remove a memory from the mesh's shared knowledge.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Memory ID to forget" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- File tools ---
|
||||
{
|
||||
name: "share_file",
|
||||
description:
|
||||
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Local file path to share" },
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name (defaults to filename)",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_file",
|
||||
description: "Download a shared file to a local path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
save_to: {
|
||||
type: "string",
|
||||
description: "Local path to save the file",
|
||||
},
|
||||
},
|
||||
required: ["id", "save_to"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_files",
|
||||
description: "List files shared in the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search by name or tags" },
|
||||
from: { type: "string", description: "Filter by uploader name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file_status",
|
||||
description: "Check who has accessed a shared file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_file",
|
||||
description: "Remove a shared file from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grant_file_access",
|
||||
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: { type: "string", description: "File ID" },
|
||||
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||
},
|
||||
required: ["fileId", "to"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Vector tools ---
|
||||
{
|
||||
name: "vector_store",
|
||||
description:
|
||||
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
text: { type: "string", description: "Text to embed and store" },
|
||||
metadata: {
|
||||
type: "object",
|
||||
description: "Optional metadata to attach",
|
||||
},
|
||||
},
|
||||
required: ["collection", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_search",
|
||||
description: "Semantic search over stored embeddings in a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
query: { type: "string", description: "Search query text" },
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max results (default: 10)",
|
||||
},
|
||||
},
|
||||
required: ["collection", "query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_delete",
|
||||
description: "Remove an embedding from a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
id: { type: "string", description: "Embedding ID to delete" },
|
||||
},
|
||||
required: ["collection", "id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_collections",
|
||||
description: "List vector collections in this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Graph tools ---
|
||||
{
|
||||
name: "graph_query",
|
||||
description:
|
||||
"Run a read-only Cypher query on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher MATCH query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graph_execute",
|
||||
description:
|
||||
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher write query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh Database tools ---
|
||||
{
|
||||
name: "mesh_query",
|
||||
description:
|
||||
"Run a SELECT query on the per-mesh shared database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL SELECT query" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_execute",
|
||||
description:
|
||||
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL statement" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_schema",
|
||||
description:
|
||||
"List tables and columns in the per-mesh shared database.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stream tools ---
|
||||
{
|
||||
name: "create_stream",
|
||||
description:
|
||||
"Create a real-time data stream in the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
description:
|
||||
"Push data to a stream. Subscribers receive it in real-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
data: { description: "Any JSON data to publish" },
|
||||
},
|
||||
required: ["stream", "data"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscribe",
|
||||
description:
|
||||
"Subscribe to a stream. Data pushes arrive as channel notifications.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["stream"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_streams",
|
||||
description:
|
||||
"List active streams in the mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Context tools ---
|
||||
{
|
||||
name: "share_context",
|
||||
description:
|
||||
"Share your session understanding with the mesh. Call after exploring a codebase area.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Summary of what you explored/learned",
|
||||
},
|
||||
files_read: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths you read",
|
||||
},
|
||||
key_findings: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Key findings or insights",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["summary"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_context",
|
||||
description:
|
||||
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query (file path, topic, etc.)",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_contexts",
|
||||
description: "See what all peers currently know about the codebase.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Task tools ---
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a work item for the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Task title" },
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Peer name to assign (optional)",
|
||||
},
|
||||
priority: {
|
||||
type: "string",
|
||||
enum: ["low", "normal", "high", "urgent"],
|
||||
description: "Priority level (default: normal)",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claim_task",
|
||||
description: "Claim an unclaimed task to take ownership.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete_task",
|
||||
description: "Mark a task as done with an optional result summary.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
result: {
|
||||
type: "string",
|
||||
description: "Summary of what was done",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_tasks",
|
||||
description: "List tasks filtered by status and/or assignee.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["open", "claimed", "completed"],
|
||||
description: "Filter by status",
|
||||
},
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Filter by assignee name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Scheduled messages ---
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message or reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_scheduled",
|
||||
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "cancel_scheduled",
|
||||
description: "Cancel a pending scheduled message before it fires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Scheduled message ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
description:
|
||||
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stats ---
|
||||
{
|
||||
name: "mesh_stats",
|
||||
description:
|
||||
"View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- MCP Proxy ---
|
||||
{
|
||||
name: "mesh_mcp_register",
|
||||
description:
|
||||
"Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" },
|
||||
description: { type: "string", description: "What this MCP server does" },
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
inputSchema: { type: "object", description: "JSON Schema for tool arguments" },
|
||||
},
|
||||
required: ["name", "description", "inputSchema"],
|
||||
},
|
||||
description: "Tool definitions to expose",
|
||||
},
|
||||
persistent: {
|
||||
type: "boolean",
|
||||
description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false",
|
||||
},
|
||||
},
|
||||
required: ["server_name", "description", "tools"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_list",
|
||||
description:
|
||||
"List MCP servers available in the mesh with their tools. Shows which peer hosts each server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_tool_call",
|
||||
description:
|
||||
"Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server" },
|
||||
tool_name: { type: "string", description: "Name of the tool to call" },
|
||||
args: { type: "object", description: "Tool arguments (JSON object)" },
|
||||
},
|
||||
required: ["server_name", "tool_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_remove",
|
||||
description:
|
||||
"Unregister an MCP server you previously registered with the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server to remove" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// --- Simulation clock tools ---
|
||||
{
|
||||
name: "mesh_set_clock",
|
||||
description:
|
||||
"Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
speed: {
|
||||
type: "number",
|
||||
description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.",
|
||||
},
|
||||
},
|
||||
required: ["speed"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_pause_clock",
|
||||
description:
|
||||
"Pause the simulation clock. Ticks stop until resumed.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_resume_clock",
|
||||
description:
|
||||
"Resume a paused simulation clock.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_clock",
|
||||
description:
|
||||
"Get current simulation clock status: speed, tick count, simulated time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skills ---
|
||||
{
|
||||
name: "share_skill",
|
||||
description:
|
||||
"Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
|
||||
description: { type: "string", description: "Short description of what the skill does" },
|
||||
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for discoverability",
|
||||
},
|
||||
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
|
||||
allowed_tools: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
|
||||
},
|
||||
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
|
||||
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
|
||||
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
|
||||
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
|
||||
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
|
||||
},
|
||||
required: ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_skill",
|
||||
description:
|
||||
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to load" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_skills",
|
||||
description:
|
||||
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search keyword (optional)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_skill",
|
||||
description:
|
||||
"Remove a skill you published from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to remove" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Diagnostics ---
|
||||
{
|
||||
name: "ping_mesh",
|
||||
description:
|
||||
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
priorities: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["now", "next", "low"] },
|
||||
description: "Priorities to test (default: [\"now\", \"next\"])",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Peer file sharing ---
|
||||
{
|
||||
name: "read_peer_file",
|
||||
description:
|
||||
"Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "File path relative to peer's working directory" },
|
||||
},
|
||||
required: ["peer", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_peer_files",
|
||||
description:
|
||||
"List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" },
|
||||
pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" },
|
||||
},
|
||||
required: ["peer"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Webhooks ---
|
||||
{
|
||||
name: "create_webhook",
|
||||
description:
|
||||
"Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_webhooks",
|
||||
description: "List active webhooks for this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "delete_webhook",
|
||||
description: "Deactivate a webhook.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Webhook name to deactivate" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Service deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_mcp_deploy",
|
||||
description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the server in this mesh" },
|
||||
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
||||
git_url: { type: "string", description: "Git repo URL" },
|
||||
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
||||
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
|
||||
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
||||
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
||||
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
||||
network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" },
|
||||
scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_undeploy",
|
||||
description: "Stop and remove a managed MCP server from the mesh.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_update",
|
||||
description: "Pull latest code and restart a git-sourced MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_logs",
|
||||
description: "View recent logs from a managed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_scope",
|
||||
description: "Get or set the visibility scope of a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_schema",
|
||||
description: "Inspect tool schemas for a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_catalog",
|
||||
description: "List all deployed services in the mesh with status, scope, and tool count.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skill deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_skill_deploy",
|
||||
description: "Deploy a multi-file skill bundle from a zip or git repo.",
|
||||
inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } },
|
||||
},
|
||||
|
||||
// --- Vault tools ---
|
||||
|
||||
{
|
||||
name: "vault_set",
|
||||
description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] },
|
||||
},
|
||||
{
|
||||
name: "vault_list",
|
||||
description: "List your vault entries (keys and metadata only, no secret values).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "vault_delete",
|
||||
description: "Remove a credential from your vault.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
||||
},
|
||||
|
||||
// --- URL Watch tools ---
|
||||
|
||||
{
|
||||
name: "mesh_watch",
|
||||
description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL to watch" },
|
||||
mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" },
|
||||
extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" },
|
||||
interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" },
|
||||
notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" },
|
||||
headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" },
|
||||
label: { type: "string", description: "Human-readable label for this watch" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_unwatch",
|
||||
description: "Stop watching a URL.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { watch_id: { type: "string" } },
|
||||
required: ["watch_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_watches",
|
||||
description: "List your active URL watches.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
|
||||
export type PeerStatus = "idle" | "working" | "dnd";
|
||||
|
||||
export interface SendMessageArgs {
|
||||
to: string; // peer name, pubkey, or #channel
|
||||
to: string | string[]; // peer name, pubkey, @group, or array of targets
|
||||
message: string;
|
||||
priority?: Priority;
|
||||
}
|
||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
||||
export interface SetStatusArgs {
|
||||
status: PeerStatus;
|
||||
}
|
||||
|
||||
// --- Service deployment types ---
|
||||
|
||||
export type ServiceScope =
|
||||
| "peer"
|
||||
| "mesh"
|
||||
| { peers: string[] }
|
||||
| { group: string }
|
||||
| { groups: string[] }
|
||||
| { role: string };
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
type: "mcp" | "skill";
|
||||
description: string;
|
||||
status: string;
|
||||
tool_count: number;
|
||||
deployed_by: string;
|
||||
scope: ServiceScope;
|
||||
source_type: string;
|
||||
runtime?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
key: string;
|
||||
entry_type: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MeshMcpDeployArgs {
|
||||
server_name: string;
|
||||
file_id?: string;
|
||||
git_url?: string;
|
||||
git_branch?: string;
|
||||
env?: Record<string, string>;
|
||||
runtime?: "node" | "python" | "bun";
|
||||
memory_mb?: number;
|
||||
network_allow?: string[];
|
||||
scope?: ServiceScope;
|
||||
}
|
||||
|
||||
export interface VaultSetArgs {
|
||||
key: string;
|
||||
value: string;
|
||||
type?: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,19 @@ export interface JoinedMesh {
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupEntry {
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||
}
|
||||
|
||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
@@ -47,7 +56,7 @@ export function loadConfig(): Config {
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName };
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
17
apps/cli/src/templates/dev-team.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dev-team",
|
||||
"description": "Software development team with frontend, backend, and devops groups",
|
||||
"groups": [
|
||||
{ "name": "frontend", "roles": ["lead", "member"] },
|
||||
{ "name": "backend", "roles": ["lead", "member"] },
|
||||
{ "name": "devops", "roles": ["lead", "member"] },
|
||||
{ "name": "qa", "roles": ["lead", "member"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"sprint": "current",
|
||||
"deploy-frozen": "false",
|
||||
"pr-queue": "[]"
|
||||
},
|
||||
"suggestedRoles": ["lead", "member", "reviewer"],
|
||||
"systemPromptHint": "You are part of a dev team. Coordinate with @frontend, @backend, @devops groups. Check state keys for sprint status and deploy freezes before making changes."
|
||||
}
|
||||
30
apps/cli/src/templates/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import devTeam from "./dev-team.json" with { type: "json" };
|
||||
import research from "./research.json" with { type: "json" };
|
||||
import opsIncident from "./ops-incident.json" with { type: "json" };
|
||||
import simulation from "./simulation.json" with { type: "json" };
|
||||
import personal from "./personal.json" with { type: "json" };
|
||||
|
||||
export interface MeshTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
groups: Array<{ name: string; roles: string[] }>;
|
||||
stateKeys: Record<string, string>;
|
||||
suggestedRoles: string[];
|
||||
systemPromptHint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: Record<string, MeshTemplate> = {
|
||||
"dev-team": devTeam,
|
||||
research,
|
||||
"ops-incident": opsIncident,
|
||||
simulation,
|
||||
personal,
|
||||
};
|
||||
|
||||
export function listTemplates(): MeshTemplate[] {
|
||||
return Object.values(TEMPLATES);
|
||||
}
|
||||
|
||||
export function getTemplate(name: string): MeshTemplate | undefined {
|
||||
return TEMPLATES[name];
|
||||
}
|
||||
17
apps/cli/src/templates/ops-incident.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ops-incident",
|
||||
"description": "Incident response team with oncall, comms, and engineering groups",
|
||||
"groups": [
|
||||
{ "name": "oncall", "roles": ["primary", "secondary"] },
|
||||
{ "name": "comms", "roles": ["lead", "scribe"] },
|
||||
{ "name": "engineering", "roles": ["lead", "responder"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"incident-status": "investigating",
|
||||
"severity": "unknown",
|
||||
"commander": "",
|
||||
"timeline": "[]"
|
||||
},
|
||||
"suggestedRoles": ["commander", "primary-oncall", "scribe", "responder"],
|
||||
"systemPromptHint": "INCIDENT MODE. Priority: now for all messages. Update incident-status state. Commander coordinates. Scribe maintains timeline. Engineering fixes."
|
||||
}
|
||||
11
apps/cli/src/templates/personal.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "personal",
|
||||
"description": "Private mesh for a single user — all sessions auto-join",
|
||||
"groups": [],
|
||||
"stateKeys": {
|
||||
"focus": "",
|
||||
"todos": "[]"
|
||||
},
|
||||
"suggestedRoles": [],
|
||||
"systemPromptHint": "Personal workspace. All your Claude Code sessions share this mesh. Use state keys to track focus and todos across sessions."
|
||||
}
|
||||
16
apps/cli/src/templates/research.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "research",
|
||||
"description": "Research and analysis team focused on deep investigation and knowledge sharing",
|
||||
"groups": [
|
||||
{ "name": "analysis", "roles": ["lead", "analyst"] },
|
||||
{ "name": "writing", "roles": ["lead", "writer", "reviewer"] },
|
||||
{ "name": "data", "roles": ["engineer", "analyst"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"research-topic": "",
|
||||
"phase": "exploration",
|
||||
"findings-count": "0"
|
||||
},
|
||||
"suggestedRoles": ["lead", "analyst", "writer", "reviewer"],
|
||||
"systemPromptHint": "You are part of a research team. Share findings via remember(), use recall() before starting new analysis. Coordinate phases through state keys."
|
||||
}
|
||||
17
apps/cli/src/templates/simulation.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "simulation",
|
||||
"description": "Load testing simulation with configurable time multiplier and user personas",
|
||||
"groups": [
|
||||
{ "name": "personas", "roles": ["admin", "user", "customer"] },
|
||||
{ "name": "observers", "roles": ["monitor", "analyst"] },
|
||||
{ "name": "control", "roles": ["orchestrator"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"clock-speed": "x1",
|
||||
"sim-status": "paused",
|
||||
"tick-count": "0",
|
||||
"scenario": ""
|
||||
},
|
||||
"suggestedRoles": ["orchestrator", "persona", "monitor"],
|
||||
"systemPromptHint": "SIMULATION MODE. Follow the clock-speed state for time multiplier. Act according to your persona role and the simulated time. Report actions to @observers."
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { env } from "../env";
|
||||
|
||||
const clients = new Map<string, BrokerClient>();
|
||||
let configDisplayName: string | undefined;
|
||||
let configGroups: Config["groups"] = [];
|
||||
|
||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
clients.set(mesh.meshId, client);
|
||||
try {
|
||||
await client.connect();
|
||||
// Auto-join groups declared at launch time (--groups flag or config).
|
||||
for (const g of configGroups ?? []) {
|
||||
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Connect failed → client is in "reconnecting" state, leave it
|
||||
// wired so tool calls can surface the status.
|
||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||
export async function startClients(config: Config): Promise<void> {
|
||||
configDisplayName = config.displayName;
|
||||
configGroups = config.groups ?? [];
|
||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||
}
|
||||
|
||||
|
||||
38
apps/runner/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# claudemesh runner — executes deployed MCP servers as child processes.
|
||||
# Multi-runtime: Node 22 + Python 3.12 + uv + Bun
|
||||
#
|
||||
# The runner supervisor (Node) listens on HTTP :7901 for commands from
|
||||
# the broker (load, call, unload, health, list_tools). Each deployed
|
||||
# MCP server runs as a child process with its own stdio pipe.
|
||||
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install Python 3.12 + uv (fast pip replacement) + git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv \
|
||||
curl ca-certificates git unzip \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -sf /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://bun.sh/install | bash \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the runner supervisor
|
||||
COPY supervisor.mjs /app/supervisor.mjs
|
||||
|
||||
# Services directory (shared volume with broker)
|
||||
RUN mkdir -p /var/claudemesh/services
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RUNNER_PORT=7901
|
||||
|
||||
EXPOSE 7901
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "supervisor.mjs"]
|
||||
365
apps/runner/supervisor.mjs
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* claudemesh runner supervisor — manages MCP server child processes.
|
||||
*
|
||||
* HTTP API (called by broker):
|
||||
* POST /load { name, sourcePath, env, runtime } → spawn MCP, return tools
|
||||
* POST /call { name, tool, args } → route tool call
|
||||
* POST /unload { name } → kill process
|
||||
* GET /health → { ok, services }
|
||||
* GET /list { name? } → tools for a service
|
||||
*
|
||||
* Each MCP server is a child process with its own stdio pipe.
|
||||
* The supervisor talks MCP JSON-RPC over stdin/stdout to each child.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PORT = parseInt(process.env.RUNNER_PORT || "7901", 10);
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const LOG_BUFFER_SIZE = 500;
|
||||
|
||||
// --- Service registry ---
|
||||
|
||||
const services = new Map();
|
||||
let callIdCounter = 0;
|
||||
|
||||
// --- Runtime detection ---
|
||||
|
||||
function detectRuntime(sourcePath) {
|
||||
if (existsSync(join(sourcePath, "bun.lockb")) || existsSync(join(sourcePath, "bunfig.toml"))) return "bun";
|
||||
if (existsSync(join(sourcePath, "package.json"))) return "node";
|
||||
if (existsSync(join(sourcePath, "pyproject.toml")) || existsSync(join(sourcePath, "requirements.txt"))) return "python";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function detectEntry(sourcePath, runtime) {
|
||||
if (runtime === "python") {
|
||||
for (const e of ["server.py", "src/server.py", "main.py", "src/main.py"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd: "python3", args: [e] };
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) return { cmd: "python3", args: ["-m", "server"] };
|
||||
return { cmd: "python3", args: ["server.py"] };
|
||||
}
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf-8"));
|
||||
if (pkg.main) return { cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
|
||||
if (bin) return { cmd, args: [bin] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const e of ["dist/index.js", "src/index.js", "src/index.ts", "index.js"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd, args: [e] };
|
||||
}
|
||||
return { cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// --- Install deps ---
|
||||
|
||||
function installDeps(sourcePath, runtime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd, args;
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip3"; args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else { cmd = "pip3"; args = ["install", "--no-cache-dir", "."]; }
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun"; args = ["install"];
|
||||
} else {
|
||||
cmd = "npm"; args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
const child = spawn(cmd, args, { cwd: sourcePath, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", d => { stderr += d.toString(); });
|
||||
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} install exit ${code}: ${stderr.slice(-300)}`)));
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MCP JSON-RPC ---
|
||||
|
||||
function sendMcpRequest(svc, method, params) {
|
||||
return new Promise(resolve => {
|
||||
if (!svc.process?.stdin?.writable) { resolve({ error: "not running" }); return; }
|
||||
const id = `c_${++callIdCounter}`;
|
||||
const timer = setTimeout(() => { svc.pending.delete(id); resolve({ error: "timeout" }); }, CALL_TIMEOUT_MS);
|
||||
svc.pending.set(id, { resolve, timer });
|
||||
try {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer); svc.pending.delete(id);
|
||||
resolve({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initMcp(svc) {
|
||||
const init = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05", capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
if (init.error) throw new Error(`init failed: ${init.error}`);
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
||||
}
|
||||
const tools = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (tools.error) throw new Error(`tools/list failed: ${tools.error}`);
|
||||
return tools.result?.tools ?? [];
|
||||
}
|
||||
|
||||
// --- Spawn ---
|
||||
|
||||
function spawnService(svc) {
|
||||
// npx/uvx packages have pre-resolved entry points
|
||||
let cmd, args;
|
||||
if (svc._pythonModule) {
|
||||
// Python MCPs: run via venv python -m <module>
|
||||
cmd = svc._venvPython;
|
||||
args = ["-m", svc._pythonModule];
|
||||
} else if (svc._npxBin) {
|
||||
cmd = "node";
|
||||
args = [svc._npxBin];
|
||||
} else {
|
||||
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
|
||||
}
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...svc.env, NODE_ENV: "production" },
|
||||
});
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
rl.on("line", line => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pending.has(String(msg.id))) {
|
||||
const p = svc.pending.get(String(msg.id));
|
||||
clearTimeout(p.timer); svc.pending.delete(String(msg.id));
|
||||
p.resolve(msg.error ? { error: msg.error.message ?? JSON.stringify(msg.error) } : { result: msg.result });
|
||||
}
|
||||
} catch { svc.logs.push(`[stdout] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); }
|
||||
});
|
||||
|
||||
const errRl = createInterface({ input: child.stderr });
|
||||
errRl.on("line", line => { svc.logs.push(`[stderr] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); });
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[runner] ${svc.name} exited code=${code} signal=${signal} restarts=${svc.restarts}`);
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "crashed" }); }
|
||||
svc.pending.clear(); svc.process = null; svc.pid = null;
|
||||
if (svc.status === "running" && svc.restarts < 5) {
|
||||
svc.restarts++;
|
||||
svc.status = "restarting";
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restarts);
|
||||
} else if (svc.status === "running") { svc.status = "crashed"; }
|
||||
});
|
||||
|
||||
child.on("error", err => { console.error(`[runner] ${svc.name} spawn error: ${err.message}`); svc.status = "failed"; });
|
||||
console.log(`[runner] spawned ${svc.name} pid=${child.pid} cmd=${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
// --- HTTP API ---
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", c => chunks.push(c));
|
||||
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const svcs = [];
|
||||
for (const [name, svc] of services) {
|
||||
svcs.push({ name, status: svc.status, pid: svc.pid, tools: svc.tools.length, restarts: svc.restarts });
|
||||
}
|
||||
return json(res, 200, { ok: true, services: svcs });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/list")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
if (name) {
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
return json(res, 200, { tools: svc.tools });
|
||||
}
|
||||
const all = {};
|
||||
for (const [n, s] of services) all[n] = s.tools;
|
||||
return json(res, 200, all);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/logs")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
const lines = parseInt(url.searchParams.get("lines") || "50", 10);
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
return json(res, 200, { lines: svc.logs.slice(-lines) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/load") {
|
||||
const body = await readBody(req);
|
||||
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
|
||||
if (!name) return json(res, 400, { error: "name required" });
|
||||
|
||||
// Kill existing
|
||||
const existing = services.get(name);
|
||||
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
||||
|
||||
// Determine source path — git clone, npx, or pre-existing path
|
||||
let svcSourcePath = sourcePath;
|
||||
let svcRuntime = rt;
|
||||
|
||||
if (gitUrl) {
|
||||
// Git clone into runner's local storage
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
const { execSync } = await import("node:child_process");
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
try {
|
||||
// Clean existing clone
|
||||
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
|
||||
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `git clone failed: ${e.message}` });
|
||||
}
|
||||
} else if (npxPackage) {
|
||||
// npx-based: create a minimal package.json that depends on the package
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
|
||||
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
|
||||
svcRuntime = svcRuntime || "node";
|
||||
} else if (body.uvxPackage) {
|
||||
// uvx-based Python MCP: install via uv and find the entry point
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`uv venv --clear ${join(svcSourcePath, ".venv")}`, { timeout: 30_000, stdio: "pipe" });
|
||||
execSync(`uv pip install --python ${join(svcSourcePath, ".venv/bin/python")} "${body.uvxPackage}" "mcp[cli]"`, { timeout: 120_000, stdio: "pipe" });
|
||||
console.log(`[runner] uvx package installed: ${body.uvxPackage}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `uvx install failed: ${e.message}` });
|
||||
}
|
||||
// For Python MCPs: run via `python -m <module>` using the venv python.
|
||||
// The module name is derived from the package name: mcp-server-time → mcp_server_time
|
||||
const venvPython = join(svcSourcePath, ".venv/bin/python");
|
||||
const moduleName = body.uvxPackage.replace(/-/g, "_");
|
||||
svcRuntime = "python";
|
||||
// _pythonModule signals spawnService to use `python -m <module>` instead of binary
|
||||
const svc2 = { name, sourcePath: svcSourcePath, runtime: svcRuntime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "running", pending: new Map(), logs: [], restarts: 0, healthFailures: 0, _venvPython: venvPython, _pythonModule: moduleName };
|
||||
services.set(name, svc2);
|
||||
spawnService(svc2);
|
||||
// Python MCPs take longer to start — retry init with backoff
|
||||
let initErr = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500 + attempt * 1000));
|
||||
try {
|
||||
svc2.tools = await initMcp(svc2);
|
||||
console.log(`[runner] ${name} ready (uvx), ${svc2.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc2.tools });
|
||||
} catch (e) { initErr = e; }
|
||||
}
|
||||
svc2.status = "failed"; svc2.logs.push(`MCP init failed after 3 attempts: ${initErr?.message}`);
|
||||
return json(res, 500, { error: initErr?.message, logs: svc2.logs.slice(-10) });
|
||||
} else if (!svcSourcePath) {
|
||||
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
|
||||
}
|
||||
|
||||
const runtime = svcRuntime || detectRuntime(svcSourcePath);
|
||||
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
||||
services.set(name, svc);
|
||||
|
||||
// Install deps
|
||||
try { await installDeps(svcSourcePath, runtime); } catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message });
|
||||
}
|
||||
|
||||
// For npx packages: find the binary in node_modules/.bin
|
||||
if (npxPackage) {
|
||||
const binDir = join(svcSourcePath, "node_modules", ".bin");
|
||||
if (existsSync(binDir)) {
|
||||
const bins = readdirSync(binDir).filter(b => !["node-which", "which", "semver", "resolve"].includes(b));
|
||||
// Prefer binary matching the package name
|
||||
const pkgShort = npxPackage.split("/").pop().replace(/^@/, "");
|
||||
const match = bins.find(b => b === pkgShort || b.includes(pkgShort)) || bins[0];
|
||||
if (match) {
|
||||
svc._npxBin = join(binDir, match);
|
||||
console.log(`[runner] npx binary resolved: ${match}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn + MCP handshake
|
||||
spawnService(svc);
|
||||
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
|
||||
try {
|
||||
svc.tools = await initMcp(svc);
|
||||
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc.tools });
|
||||
} catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`MCP init failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message, logs: svc.logs.slice(-10) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/call") {
|
||||
const body = await readBody(req);
|
||||
const { name, tool, args } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
if (svc.status !== "running") return json(res, 503, { error: `service is ${svc.status}` });
|
||||
const result = await sendMcpRequest(svc, "tools/call", { name: tool, arguments: args || {} });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/unload") {
|
||||
const body = await readBody(req);
|
||||
const { name } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
svc.status = "stopped";
|
||||
if (svc.process) { svc.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 2000)); if (svc.process) svc.process.kill("SIGKILL"); }
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "unloaded" }); }
|
||||
services.delete(name);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
json(res, 404, { error: "not found" });
|
||||
} catch (e) {
|
||||
console.error("[runner] request error:", e);
|
||||
json(res, 500, { error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[runner] supervisor listening on :${PORT}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[runner] shutting down...");
|
||||
for (const [, svc] of services) { svc.status = "stopped"; svc.process?.kill("SIGTERM"); }
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
15
apps/telegram/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Telegram bridge for claudemesh
|
||||
# Node 22 runtime with tsx for TypeScript execution
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
20
apps/telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/telegram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"dev": "bun --hot src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.35.0",
|
||||
"ws": "^8.18.0",
|
||||
"libsodium": "^0.7.15",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/libsodium-wrappers": "^0.7.14"
|
||||
}
|
||||
}
|
||||
759
apps/telegram/src/index.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* Claudemesh ↔ Telegram Bridge
|
||||
*
|
||||
* Joins the mesh as a peer named "telegram-bridge", relays messages
|
||||
* between a Telegram chat and mesh peers.
|
||||
*
|
||||
* Telegram → Mesh:
|
||||
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
|
||||
* "/peers" → list_peers → reply with online list
|
||||
* "/broadcast hello" → send_message(to: "*", message: "hello")
|
||||
* "any text" → send_message(to: "*", message: text) (broadcast)
|
||||
*
|
||||
* Mesh → Telegram:
|
||||
* Any push message addressed to this peer → forward to Telegram chat
|
||||
*/
|
||||
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import WebSocket from "ws";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// --- Config ---
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
|
||||
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
|
||||
|
||||
if (!BOT_TOKEN) {
|
||||
console.error("TELEGRAM_BOT_TOKEN required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Load mesh config ---
|
||||
interface JoinedMesh {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
brokerUrl: string;
|
||||
}
|
||||
|
||||
function loadMeshConfig(): JoinedMesh[] {
|
||||
// Support env-based config for Docker/VPS deployment
|
||||
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||
return [{
|
||||
meshId: process.env.MESH_ID,
|
||||
memberId: process.env.MESH_MEMBER_ID,
|
||||
slug: process.env.MESH_SLUG ?? "mesh",
|
||||
name: process.env.MESH_NAME ?? "mesh",
|
||||
pubkey: process.env.MESH_PUBKEY,
|
||||
secretKey: process.env.MESH_SECRET_KEY,
|
||||
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
}];
|
||||
}
|
||||
// Fall back to config file
|
||||
const path = join(CONFIG_DIR, "config.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return config.meshes ?? [];
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
let sodiumReady = false;
|
||||
|
||||
async function ensureSodium() {
|
||||
if (!sodiumReady) {
|
||||
await sodium.ready;
|
||||
sodiumReady = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
async function generateSessionKeypair() {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: string) {
|
||||
const s = await ensureSodium();
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const sig = s.crypto_sign_detached(s.from_string(canonical), s.from_hex(secretKeyHex));
|
||||
return { timestamp, signature: s.to_hex(sig) };
|
||||
}
|
||||
|
||||
/** Decrypt a direct message envelope using crypto_box (X25519). */
|
||||
async function decryptDirect(
|
||||
nonce: string,
|
||||
ciphertext: string,
|
||||
senderPubkeyHex: string,
|
||||
recipientSecretKeyHex: string,
|
||||
): Promise<string | null> {
|
||||
const s = await ensureSodium();
|
||||
try {
|
||||
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(senderPubkeyHex));
|
||||
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(recipientSecretKeyHex));
|
||||
const nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
|
||||
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mesh WS Client (simplified) ---
|
||||
interface PeerInfo {
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
cwd?: string;
|
||||
groups?: string[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
class MeshBridge {
|
||||
private ws: WebSocket | null = null;
|
||||
private mesh: JoinedMesh;
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private onMessage: (from: string, text: string, priority: string) => void;
|
||||
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
|
||||
/** Map pubkey → {name, avatar}, populated from list_peers */
|
||||
private peerInfo = new Map<string, { name: string; avatar?: string }>();
|
||||
|
||||
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
|
||||
this.mesh = mesh;
|
||||
this.onMessage = onMessage;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const sessionKP = await generateSessionKeypair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
return this._connect();
|
||||
}
|
||||
|
||||
private _connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.mesh.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.mesh.meshId, this.mesh.memberId,
|
||||
this.mesh.pubkey, this.mesh.secretKey,
|
||||
);
|
||||
ws.send(JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.mesh.meshId,
|
||||
memberId: this.mesh.memberId,
|
||||
pubkey: this.mesh.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: DISPLAY_NAME,
|
||||
sessionId: `telegram-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
hostname: require("os").hostname(),
|
||||
peerType: "bridge",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
const helloTimeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, 10_000);
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type !== "hello_ack" && msg.type !== "ack") {
|
||||
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
clearTimeout(helloTimeout);
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push messages from peers
|
||||
if (msg.type === "push") {
|
||||
let text: string | null = null;
|
||||
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
|
||||
|
||||
// System messages (no encryption)
|
||||
if (msg.subtype === "system") {
|
||||
const event = msg.event ?? "";
|
||||
const data = msg.eventData ?? {};
|
||||
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
|
||||
else text = msg.plaintext ?? `[${event}]`;
|
||||
}
|
||||
// Encrypted direct message
|
||||
else if (senderPubkey && msg.nonce && msg.ciphertext) {
|
||||
// Try session key first, then mesh member key
|
||||
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
|
||||
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
|
||||
if (!text) text = "[could not decrypt]";
|
||||
}
|
||||
// Plaintext fallback (broadcasts, legacy)
|
||||
else if (msg.plaintext) {
|
||||
text = msg.plaintext;
|
||||
}
|
||||
// Base64 ciphertext without nonce (legacy broadcast)
|
||||
else if (msg.ciphertext && !msg.nonce) {
|
||||
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
|
||||
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
|
||||
const avatar = info?.avatar ?? "🤖";
|
||||
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
|
||||
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
|
||||
} else {
|
||||
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve pending requests
|
||||
const reqId = msg._reqId;
|
||||
if (reqId && this.resolvers.has(reqId)) {
|
||||
const r = this.resolvers.get(reqId)!;
|
||||
clearTimeout(r.timer);
|
||||
this.resolvers.delete(reqId);
|
||||
r.resolve(msg);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.connected = false;
|
||||
this.ws = null;
|
||||
if (this.reconnectTimer) return;
|
||||
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
|
||||
}, delay);
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
console.error("[mesh] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
const timer = setTimeout(() => {
|
||||
this.resolvers.delete(reqId);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
this.resolvers.set(reqId, { resolve, timer });
|
||||
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
|
||||
if (!this.ws || !this.connected) return false;
|
||||
|
||||
// For direct targets (pubkeys), use crypto_box encryption.
|
||||
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
|
||||
let nonce = "";
|
||||
let ciphertext = "";
|
||||
const isDirect = /^[0-9a-f]{64}$/.test(to);
|
||||
if (isDirect) {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
|
||||
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
|
||||
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
|
||||
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
|
||||
} else {
|
||||
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
const id = this.makeReqId();
|
||||
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec: to,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find all peers matching a display name. */
|
||||
async findPeersByName(name: string): Promise<PeerInfo[]> {
|
||||
const peers = await this.listPeers();
|
||||
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
|
||||
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
|
||||
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
try {
|
||||
const res = await fetch(`${brokerHttp}/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Mesh-Id": this.mesh.meshId,
|
||||
"X-Member-Id": this.mesh.memberId,
|
||||
"X-File-Name": fileName,
|
||||
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
|
||||
"X-Persistent": "true",
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||
if (!res.ok || !body.fileId) return null;
|
||||
return body.fileId;
|
||||
} catch (e) {
|
||||
console.error("[mesh] upload failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a download URL for a mesh file. */
|
||||
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
|
||||
const resp = await this.request({ type: "get_file", fileId });
|
||||
if (!resp?.url) return null;
|
||||
return { url: resp.url, name: resp.name ?? "file" };
|
||||
}
|
||||
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
const resp = await this.request({ type: "list_peers" });
|
||||
if (!resp?.peers) return [];
|
||||
return resp.peers.map((p: any) => {
|
||||
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
|
||||
const avatar = p.profile?.avatar;
|
||||
// Cache pubkey → info for push message attribution
|
||||
const info = { name, avatar };
|
||||
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
|
||||
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
|
||||
return {
|
||||
displayName: name,
|
||||
pubkey: p.pubkey ?? "",
|
||||
status: p.status ?? "unknown",
|
||||
summary: p.summary,
|
||||
cwd: p.cwd,
|
||||
groups: p.groups?.map((g: any) => g.name) ?? [],
|
||||
avatar: avatar,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh peer name cache. Called periodically. */
|
||||
async refreshPeerNames(): Promise<void> {
|
||||
await this.listPeers();
|
||||
}
|
||||
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve display name from peers ---
|
||||
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
|
||||
// If it starts with @, it's a group
|
||||
if (name.startsWith("@")) return name;
|
||||
// If *, broadcast
|
||||
if (name === "*") return "*";
|
||||
// Otherwise resolve as display name — the broker handles this via targetSpec
|
||||
return name;
|
||||
}
|
||||
|
||||
// --- Telegram Bot ---
|
||||
async function main() {
|
||||
const meshes = loadMeshConfig();
|
||||
if (meshes.length === 0) {
|
||||
console.error("No meshes joined — run 'claudemesh join' first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bot = new Bot(BOT_TOKEN);
|
||||
const bridges: MeshBridge[] = [];
|
||||
|
||||
// One bridge per mesh
|
||||
for (const mesh of meshes) {
|
||||
const bridge = new MeshBridge(mesh, (from, text, priority) => {
|
||||
// Forward mesh messages to all allowed Telegram chats
|
||||
const prefix = `[${mesh.slug}] ${from}`;
|
||||
const formatted = `💬 *${prefix}*\n${text}`;
|
||||
for (const chatId of ALLOWED_CHAT_IDS) {
|
||||
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
|
||||
console.error(`[tg] failed to send to ${chatId}:`, e.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.connect();
|
||||
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
|
||||
await bridge.refreshPeerNames();
|
||||
bridges.push(bridge);
|
||||
// Refresh peer names every 30s for display name resolution on pushes
|
||||
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
|
||||
} catch (e) {
|
||||
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridges.length === 0) {
|
||||
console.error("Failed to connect to any mesh");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultBridge = bridges[0]!;
|
||||
|
||||
// --- Bot commands ---
|
||||
|
||||
bot.command("peers", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const peers = await defaultBridge.listPeers();
|
||||
if (peers.length === 0) {
|
||||
await ctx.reply("No peers online.");
|
||||
return;
|
||||
}
|
||||
const lines = peers.map(p => {
|
||||
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
|
||||
const summary = p.summary ? ` — _${p.summary}_` : "";
|
||||
return `${status} *${p.displayName}*${summary}`;
|
||||
});
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// Pending messages waiting for peer selection (chatId → {message, matches})
|
||||
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
|
||||
|
||||
bot.command("dm", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
|
||||
// Find matching peers
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}" found.`);
|
||||
return;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
// Single match — send directly
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
return;
|
||||
}
|
||||
// Multiple matches — show picker with individual + all option
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
const avatar = p.avatar ?? "🤖";
|
||||
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("broadcast", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const message = ctx.match;
|
||||
if (!message) {
|
||||
await ctx.reply("Usage: /broadcast <message>");
|
||||
return;
|
||||
}
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("group", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const meshStatus = bridges.map(b =>
|
||||
`${b.isConnected() ? "🟢" : "🔴"} Connected`
|
||||
).join("\n");
|
||||
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// --- File: get a mesh file by ID ---
|
||||
bot.command("file", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const fileId = ctx.match?.trim();
|
||||
if (!fileId) {
|
||||
await ctx.reply("Usage: /file <file-id>");
|
||||
return;
|
||||
}
|
||||
const file = await defaultBridge.getFileUrl(fileId);
|
||||
if (!file) {
|
||||
await ctx.reply(`❌ File ${fileId} not found`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
await ctx.replyWithDocument(new InputFile(buf, file.name));
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) {
|
||||
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
|
||||
return;
|
||||
}
|
||||
await ctx.reply(
|
||||
"🔗 *Claudemesh Telegram Bridge*\n\n" +
|
||||
"Commands:\n" +
|
||||
"• /peers — List online peers\n" +
|
||||
"• /dm <name> <msg> — DM a specific peer\n" +
|
||||
"• /broadcast <msg> — Message all peers\n" +
|
||||
"• /group @name <msg> — Message a group\n" +
|
||||
"• /file <id> — Download a mesh file\n" +
|
||||
"• /status — Bridge connection status\n\n" +
|
||||
"Send a photo/document to share it with the mesh.\n" +
|
||||
"Or just type a message to broadcast it.",
|
||||
{ parse_mode: "Markdown" },
|
||||
);
|
||||
});
|
||||
|
||||
// Handle inline keyboard callbacks for peer selection
|
||||
bot.on("callback_query:data", async (ctx) => {
|
||||
const data = ctx.callbackQuery.data;
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId || !data.startsWith("dm:")) {
|
||||
await ctx.answerCallbackQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingDMs.get(chatId);
|
||||
if (!pending) {
|
||||
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "dm:all") {
|
||||
// Send to all matches
|
||||
let sent = 0;
|
||||
for (const p of pending.matches) {
|
||||
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
if (ok) sent++;
|
||||
}
|
||||
pendingDMs.delete(chatId);
|
||||
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
|
||||
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single selection: dm:0, dm:1, etc.
|
||||
const idx = parseInt(data.slice(3));
|
||||
const peer = pending.matches[idx];
|
||||
if (!peer) {
|
||||
await ctx.answerCallbackQuery({ text: "Invalid selection" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
pendingDMs.delete(chatId);
|
||||
const dir = peer.cwd?.split("/").pop() ?? "?";
|
||||
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
|
||||
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
|
||||
});
|
||||
|
||||
// Handle photos from Telegram → share to mesh
|
||||
bot.on("message:photo", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const photo = ctx.message.photo.at(-1); // highest resolution
|
||||
if (!photo) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(photo.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = `telegram-photo-${Date.now()}.jpg`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle documents from Telegram → share to mesh
|
||||
bot.on("message:document", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const doc = ctx.message.document;
|
||||
if (!doc) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(doc.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Default: any text without a command → broadcast
|
||||
bot.on("message:text", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.message.text;
|
||||
if (text.startsWith("/")) return; // Skip unknown commands
|
||||
|
||||
// Check for @mention pattern: "@PeerName message"
|
||||
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
||||
if (mentionMatch) {
|
||||
const target = mentionMatch[1]!;
|
||||
const message = mentionMatch[2]!;
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}"`);
|
||||
} else if (matches.length === 1) {
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
} else {
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No mention → broadcast
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
|
||||
if (!ok) await ctx.reply("❌ Not connected to mesh");
|
||||
});
|
||||
|
||||
function isAllowed(chatId: number): boolean {
|
||||
// If no chat IDs configured, allow all (dev mode)
|
||||
if (ALLOWED_CHAT_IDS.length === 0) return true;
|
||||
return ALLOWED_CHAT_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
// Start bot
|
||||
console.log("[tg] starting bot...");
|
||||
bot.start({
|
||||
onStart: () => console.log("[tg] bot running"),
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||
|
||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
|
||||
|
||||
##############################
|
||||
### CLI Sync config ###
|
||||
##############################
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts && \
|
||||
node node_modules/esbuild/install.js && \
|
||||
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
|
||||
true
|
||||
|
||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||
ENV NODE_ENV=production
|
||||
@@ -25,6 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
|
||||
# Node ESM loader that stubs .css imports during route collection.
|
||||
# Payload CMS deps import .css files that Node can't handle outside webpack.
|
||||
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
|
||||
RUN npx turbo run build --filter=web...
|
||||
|
||||
# Stage 2: runtime — standalone output only
|
||||
|
||||
33
apps/web/css-stub-loader.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Node.js ESM custom loader — stubs static asset imports as empty modules.
|
||||
*
|
||||
* Next.js 16 does route collection in raw Node ESM (not webpack/turbopack).
|
||||
* Payload CMS deps import .css, .scss, .svg, and other assets that Node
|
||||
* can't handle. This loader intercepts those and returns empty modules.
|
||||
*
|
||||
* Usage: NODE_OPTIONS="--import ./apps/web/css-stub-loader.mjs"
|
||||
*/
|
||||
|
||||
import { register } from "node:module";
|
||||
|
||||
register(
|
||||
"data:text/javascript," +
|
||||
encodeURIComponent(`
|
||||
const STYLE_RE = /\\.(css|scss|sass|less|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|otf)$/;
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (STYLE_RE.test(specifier)) {
|
||||
return { url: 'data:text/javascript,export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
|
||||
export function load(url, context, nextLoad) {
|
||||
if (STYLE_RE.test(url)) {
|
||||
return { format: 'module', source: 'export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
`),
|
||||
import.meta.url,
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { withPayload } = require("@payloadcms/next/withPayload");
|
||||
|
||||
import env from "./env.config";
|
||||
|
||||
const INTERNAL_PACKAGES = [
|
||||
@@ -85,7 +88,11 @@ const config: NextConfig = {
|
||||
"@payloadcms/db-postgres",
|
||||
"@payloadcms/db-sqlite",
|
||||
"@payloadcms/richtext-lexical",
|
||||
"@payloadcms/next",
|
||||
"@payloadcms/ui",
|
||||
"react-image-crop",
|
||||
"sharp",
|
||||
"libsodium-wrappers",
|
||||
],
|
||||
turbopack: {
|
||||
rules: {
|
||||
@@ -96,6 +103,24 @@ const config: NextConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
|
||||
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
|
||||
// loader handles those. Only process package SVGs (flags, logos).
|
||||
webpack(config) {
|
||||
const existingSvgRule = config.module.rules.find(
|
||||
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
|
||||
);
|
||||
if (existingSvgRule) {
|
||||
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
|
||||
}
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
include: /packages\/ui\//,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -130,4 +155,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: env.ANALYZE,
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(config);
|
||||
export default withPayload(withBundleAnalyzer(config));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
|
||||
BIN
apps/web/public/logo-wordmark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
14
apps/web/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import "@payloadcms/next/css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "CMS — claudemesh",
|
||||
};
|
||||
|
||||
export default function PayloadLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/* 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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} />;
|
||||
}
|
||||
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
@@ -153,14 +153,14 @@ export default function AboutPage() {
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
|
||||
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
LinkedIn
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:info@whyrating.com"
|
||||
href="mailto:alex@mourente.ai"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
|
||||
458
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Getting Started",
|
||||
description:
|
||||
"Install the CLI and launch your first peer session in two commands.",
|
||||
});
|
||||
|
||||
const STEP = ({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
cmd,
|
||||
note,
|
||||
}: {
|
||||
n: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
cmd?: string;
|
||||
note?: string;
|
||||
}) => (
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
|
||||
{n}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{cmd && (
|
||||
<pre
|
||||
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{cmd}</code>
|
||||
</pre>
|
||||
)}
|
||||
{note && (
|
||||
<p
|
||||
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const VERIFY_CHECKS = [
|
||||
"Node.js >= 20 installed",
|
||||
"claude binary on PATH",
|
||||
"~/.claudemesh/config.json parses + chmod 0600",
|
||||
"Mesh keypairs valid",
|
||||
"Broker connectivity",
|
||||
];
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
|
||||
<div
|
||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— getting started
|
||||
</div>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
From zero to meshed in two minutes
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Install the CLI and launch. Two commands — join is built into launch.
|
||||
</p>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="mt-14 mb-10">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Prerequisites
|
||||
</h2>
|
||||
<ul
|
||||
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> —{" "}
|
||||
<Link
|
||||
href="https://nodejs.org"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
nodejs.org
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
|
||||
—{" "}
|
||||
<Link
|
||||
href="https://claude.com/claude-code"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
claude.com/claude-code
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">An invite link</strong> —
|
||||
from a mesh owner, or{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
create your own mesh
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-6">
|
||||
<STEP
|
||||
n="1"
|
||||
title="Install the CLI"
|
||||
cmd="npm i -g claudemesh-cli"
|
||||
note="Requires Node.js 20+. Installs the claudemesh CLI globally."
|
||||
>
|
||||
<p>
|
||||
One command. If you get a permissions error, see{" "}
|
||||
<Link
|
||||
href="https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
npm docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<STEP
|
||||
n="2"
|
||||
title="Launch"
|
||||
cmd='claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...'
|
||||
note="--join enrolls you in the mesh (first time only). On subsequent launches, drop the --join flag."
|
||||
>
|
||||
<p>
|
||||
This does everything: verifies the invite, generates your ed25519
|
||||
keypair, enrolls with the broker, and spawns Claude Code with
|
||||
real-time peer messaging. Your keys are stored in{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
~/.claudemesh/config.json
|
||||
</code>{" "}
|
||||
(chmod 0600) — the broker never sees them.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<div
|
||||
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
next time, just:
|
||||
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
|
||||
claudemesh launch --name Alice
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Full example with all flags
|
||||
claudemesh launch \\
|
||||
--name Alice \\
|
||||
--join https://claudemesh.com/join/eyJ2IjoxLC... \\
|
||||
--role dev \\
|
||||
--groups "frontend:lead,reviewers" \\
|
||||
--message-mode push \\
|
||||
-y # skip permission confirmation`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Verify */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Verify your setup
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Run the diagnostic check — it walks through every precondition and
|
||||
prints pass/fail with fix hints:
|
||||
</p>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`$ claudemesh doctor
|
||||
claudemesh doctor (v0.8.0)
|
||||
────────────────────────────────────────────────────────────
|
||||
✓ Node.js >= 20 (v22.15.0)
|
||||
✓ claude binary on PATH
|
||||
✓ ~/.claudemesh/config.json parses + chmod 0600
|
||||
✓ Mesh keypairs valid (1 mesh(es))
|
||||
✓ Broker connectivity (wss://ic.claudemesh.com/ws)
|
||||
|
||||
All checks passed.`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Invite a teammate */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Invite a teammate
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Mesh owners generate invite links from the{" "}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
dashboard
|
||||
</Link>
|
||||
. Each link is a signed ed25519 token with a mesh ID, broker URL,
|
||||
expiry, and role (admin or member). Share via Slack, email, or
|
||||
paste in chat.
|
||||
</p>
|
||||
<p
|
||||
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
The recipient runs{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch --name Name --join <link>
|
||||
</code>{" "}
|
||||
— joins the mesh and launches in one step. No account creation
|
||||
needed. Identity is the ed25519 keypair.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite link formats */}
|
||||
<div className="mt-10">
|
||||
<h3
|
||||
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Accepted invite formats
|
||||
</h3>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Join + launch in one step (recommended)
|
||||
claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
|
||||
# Or join separately first
|
||||
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
claudemesh launch --name Alice
|
||||
|
||||
# All invite formats work with both join and --join:
|
||||
# https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
# https://claudemesh.com/en/join/eyJ2IjoxLC...
|
||||
# ic://join/eyJ2IjoxLC...
|
||||
# eyJ2IjoxLC4uLg (raw token)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Message modes */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Message modes
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
mode: "push",
|
||||
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
|
||||
when: "Default. Best for active collaboration.",
|
||||
},
|
||||
{
|
||||
mode: "inbox",
|
||||
desc: "Held until you check. You get a notification but messages queue until check_messages.",
|
||||
when: "Deep work. Check when ready.",
|
||||
},
|
||||
{
|
||||
mode: "off",
|
||||
desc: "No delivery. Tools still work — use check_messages to poll manually.",
|
||||
when: "Solo work on a shared mesh.",
|
||||
},
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.mode}
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||
>
|
||||
<code
|
||||
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
--message-mode {m.mode}
|
||||
</code>
|
||||
<p
|
||||
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.desc}
|
||||
</p>
|
||||
<p
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.when}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* With vs without launch */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
|
||||
</h2>
|
||||
<div className="grid gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>Real-time push messages from peers</li>
|
||||
<li>Native MCP entries for deployed mesh services</li>
|
||||
<li>Per-session ephemeral keypair</li>
|
||||
<li>Display name, groups, and roles</li>
|
||||
<li>Session config isolated in tmpdir</li>
|
||||
<li>MCP_TIMEOUT + output limits tuned for mesh</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
plain claude
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>All 43 MCP tools still work</li>
|
||||
<li>Messages are pull-only (check_messages)</li>
|
||||
<li>No real-time push delivery</li>
|
||||
<li>Uses member keypair (not ephemeral)</li>
|
||||
<li>No display name or group assignment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Uninstall
|
||||
</h2>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
|
||||
npm uninstall -g claudemesh-cli
|
||||
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
|
||||
<p
|
||||
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Need help? Run{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh doctor
|
||||
</code>{" "}
|
||||
to diagnose issues, or{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli/issues"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
open an issue on GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Create a mesh →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Hero } from "~/modules/marketing/home/hero";
|
||||
import { Surfaces } from "~/modules/marketing/home/surfaces";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { FAQ } from "~/modules/marketing/home/faq";
|
||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||
|
||||
// Revalidate the page every 60s so the mesh-stats counter stays fresh
|
||||
// without hammering the DB. The /api/public/stats endpoint has its own
|
||||
// 60s in-memory cache too.
|
||||
export const revalidate = 60;
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -24,14 +17,10 @@ const HomePage = () => {
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<Hero />
|
||||
<Surfaces />
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<Features />
|
||||
<MeetsYou />
|
||||
<WhatIsClaudemesh />
|
||||
<DemoDashboard />
|
||||
<BeyondTerminal />
|
||||
<Timeline />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<CallToAction />
|
||||
<MeshStats />
|
||||
|
||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Mesh {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
myRole: "admin" | "member";
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string | null;
|
||||
port: string | null;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
const ease = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated mesh node background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MeshBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Floating mesh nodes */}
|
||||
{[
|
||||
{ x: "12%", y: "18%", delay: 0, size: 3 },
|
||||
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
|
||||
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
|
||||
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
|
||||
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
|
||||
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
|
||||
].map((node, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[var(--cm-clay)]"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: node.size,
|
||||
height: node.size,
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.15, 0.4, 0.15],
|
||||
scale: [1, 1.5, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
delay: node.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Connecting lines (SVG) */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
|
||||
<line
|
||||
x1="12%"
|
||||
y1="18%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="85%"
|
||||
y1="14%"
|
||||
x2="72%"
|
||||
y2="55%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="72%"
|
||||
y1="55%"
|
||||
x2="92%"
|
||||
y2="78%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="8%"
|
||||
y1="65%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-style status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
|
||||
const colors = {
|
||||
waiting: "bg-[var(--cm-clay)]",
|
||||
syncing: "bg-amber-400",
|
||||
done: "bg-emerald-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
|
||||
const [meshes, setMeshes] = useState<Mesh[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Create-mesh form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-slug from name
|
||||
useEffect(() => {
|
||||
if (!slugDirty && newName) {
|
||||
setNewSlug(slugify(newName));
|
||||
}
|
||||
}, [newName, slugDirty]);
|
||||
|
||||
// Fetch user meshes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await handle(api.my.meshes.$get, {
|
||||
schema: getMyMeshesResponseSchema,
|
||||
})({
|
||||
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||
});
|
||||
setMeshes(data);
|
||||
setSelected(new Set(data.map((m) => m.id)));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to load your meshes.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Auto-focus name input when no meshes
|
||||
useEffect(() => {
|
||||
if (!loading && meshes.length === 0 && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [loading, meshes.length]);
|
||||
|
||||
const toggleMesh = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const status = token
|
||||
? redirected
|
||||
? "done"
|
||||
: "done"
|
||||
: syncing || creating
|
||||
? "syncing"
|
||||
: error
|
||||
? "error"
|
||||
: "waiting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mesh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const createRes = await fetch("/api/my/meshes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
}),
|
||||
});
|
||||
const res = (await createRes.json()) as
|
||||
| { id: string; slug: string }
|
||||
| { error: string };
|
||||
if (!createRes.ok || "error" in res) {
|
||||
setCreateError("error" in res ? res.error : "Failed to create mesh.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
await doSync(
|
||||
[{ id: res.id, slug: res.slug, role: "admin" as const }],
|
||||
"create",
|
||||
{ name: newName.trim(), slug: newSlug.trim() },
|
||||
);
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const doSync = async (
|
||||
meshList: Array<{ id: string; slug: string; role: string }>,
|
||||
action: "sync" | "create" = "sync",
|
||||
newMesh?: { name: string; slug: string },
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-sync-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ meshes: meshList, action, newMesh }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate token.");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const jwt = data.token as string;
|
||||
setToken(jwt);
|
||||
if (port) {
|
||||
setRedirected(true);
|
||||
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
const selectedMeshes = meshes
|
||||
.filter((m) => selected.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.isOwner ? "admin" : m.myRole,
|
||||
}));
|
||||
if (selectedMeshes.length === 0) {
|
||||
setError("Select at least one mesh to sync.");
|
||||
return;
|
||||
}
|
||||
doSync(selectedMeshes, "sync");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<StatusPulse status={status} />
|
||||
<span>
|
||||
{status === "waiting" && "awaiting sync"}
|
||||
{status === "syncing" && "generating token..."}
|
||||
{status === "done" && "synced"}
|
||||
{status === "error" && "error"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
<MeshBackdrop />
|
||||
|
||||
{/* Section tag */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— cli sync
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.08 }}
|
||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Sync with{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.16 }}
|
||||
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Link your terminal session to your account and choose which meshes to
|
||||
sync.
|
||||
</motion.p>
|
||||
|
||||
{/* Pairing code */}
|
||||
<AnimatePresence>
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.5, ease, delay: 0.24 }}
|
||||
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
|
||||
>
|
||||
{/* Terminal-style header bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
pairing verification
|
||||
</span>
|
||||
</div>
|
||||
{/* Code display */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
code:
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
{code.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Confirm this matches the code shown in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-10 space-y-3"
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
|
||||
>
|
||||
<span className="mt-0.5 text-red-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token result */}
|
||||
<AnimatePresence>
|
||||
{token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-emerald-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm font-medium text-emerald-400"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Token body */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-5">
|
||||
<p
|
||||
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected
|
||||
? "If your terminal didn\u2019t pick up the token, copy it manually:"
|
||||
: "Paste this token in your terminal when prompted:"}
|
||||
</p>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
onClick={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</div>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
) : (
|
||||
"Copy"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mesh list */}
|
||||
{!loading && !token && meshes.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<h2
|
||||
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your meshes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{meshes.map((m, i) => (
|
||||
<motion.label
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
|
||||
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
|
||||
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{selected.has(m.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(m.id)}
|
||||
onChange={() => toggleMesh(m.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{m.memberCount}{" "}
|
||||
{m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</span>
|
||||
</motion.label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selected.size === 0}
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{selected.size} of {meshes.length} selected
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No meshes — create form */}
|
||||
{!loading && !token && meshes.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
|
||||
<h2
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Create your first mesh
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh is the space where your Claude Code sessions talk to each
|
||||
other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-name"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="mesh-name"
|
||||
type="text"
|
||||
placeholder="Platform team"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-slug"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="mesh-slug"
|
||||
type="text"
|
||||
placeholder="platform-team"
|
||||
value={newSlug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true);
|
||||
setNewSlug(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
lowercase · digits · hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{createError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{createError}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create & sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer security note */}
|
||||
<AnimatePresence>
|
||||
{!token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
The sync token is valid for 15 minutes and can only be used once.
|
||||
Your ed25519 keys stay on your machine — the broker only sees
|
||||
ciphertext.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/[locale]/cli-auth/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
import { CliAuthFlow } from "./cli-auth-flow";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Sync with CLI",
|
||||
description: "Link your claudemesh CLI to your account.",
|
||||
});
|
||||
|
||||
export default async function CliAuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ code?: string; port?: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
const sp = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
if (sp.code) qs.set("code", sp.code);
|
||||
if (sp.port) qs.set("port", sp.port);
|
||||
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
|
||||
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
|
||||
const { code, port } = await searchParams;
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<CliAuthFlow
|
||||
code={code ?? null}
|
||||
port={port ?? null}
|
||||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
DashboardHeaderTitle,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Live mesh",
|
||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<LiveStreamPanel meshId={id} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
130
apps/web/src/app/api/cli-sync-token/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT signing (HS256 via Web Crypto — no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||
const str =
|
||||
typeof input === "string"
|
||||
? btoa(input)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function signJwt(
|
||||
payload: Record<string, unknown>,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
encoder.encode(`${headerB64}.${payloadB64}`),
|
||||
);
|
||||
|
||||
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler — POST /api/cli-sync-token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SyncTokenBody {
|
||||
meshes: Array<{ id: string; slug: string; role: string }>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Check auth
|
||||
const reqHeaders = new Headers(await headers());
|
||||
reqHeaders.set("x-client-platform", "web-server");
|
||||
|
||||
const session = await auth.api.getSession({ headers: reqHeaders });
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse body
|
||||
let body: SyncTokenBody;
|
||||
try {
|
||||
body = (await request.json()) as SyncTokenBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { meshes, action, newMesh } = body;
|
||||
|
||||
if (!Array.isArray(meshes)) {
|
||||
return NextResponse.json(
|
||||
{ error: "meshes must be an array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "sync" && action !== "create") {
|
||||
return NextResponse.json(
|
||||
{ error: 'action must be "sync" or "create"' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: "newMesh.name and newMesh.slug are required for create action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate meshes belong to user — fetch user's meshes via internal API
|
||||
// For now we trust the dashboard-authenticated user's selection since
|
||||
// the broker will independently verify membership when the CLI connects.
|
||||
// A full server-side ownership check can be added later.
|
||||
|
||||
// 4. Get secret
|
||||
const secret = process.env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "CLI_SYNC_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Build and sign JWT
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
meshes: meshes.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
action,
|
||||
...(action === "create" && newMesh ? { newMesh } : {}),
|
||||
jti: crypto.randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 15 * 60, // 15 minutes
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret);
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 211 B |
7
apps/web/src/app/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="4" r="2" fill="#d97757"/>
|
||||
<circle cx="4" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="20" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="12" cy="20" r="2" fill="#d97757"/>
|
||||
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* GET /install — serves a shell installer for claudemesh-cli.
|
||||
* GET /install — shell installer for claudemesh-cli.
|
||||
*
|
||||
* Intended to be piped into bash:
|
||||
* curl -fsSL https://claudemesh.com/install | 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.
|
||||
* Tracks each fetch server-side (PostHog server event + console log).
|
||||
* curl doesn't execute JS, so client-side analytics can't track this.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
const SCRIPT = `#!/usr/bin/env bash
|
||||
# claudemesh-cli installer
|
||||
# Source: https://claudemesh.com/install
|
||||
@@ -88,7 +89,41 @@ say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
|
||||
say ""
|
||||
`;
|
||||
|
||||
export function GET(): Response {
|
||||
export async function GET(): Promise<Response> {
|
||||
installFetches++;
|
||||
|
||||
// Log server-side for monitoring
|
||||
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";
|
||||
|
||||
console.log(
|
||||
`[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`,
|
||||
);
|
||||
|
||||
// PostHog server-side event (if configured)
|
||||
try {
|
||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
if (posthogKey && posthogHost) {
|
||||
fetch(`${posthogHost}/capture/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: posthogKey,
|
||||
event: "install_script_fetched",
|
||||
distinct_id: ip,
|
||||
properties: {
|
||||
user_agent: ua,
|
||||
referer,
|
||||
install_count: installFetches,
|
||||
},
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return new Response(SCRIPT, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
||||
},
|
||||
},
|
||||
marketing: {
|
||||
gettingStarted: "/getting-started",
|
||||
pricing: "/pricing",
|
||||
contact: "/contact",
|
||||
blog: {
|
||||
@@ -85,6 +86,7 @@ const pathsConfig = {
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
},
|
||||
cliAuth: "/cli-auth",
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
|
||||
@@ -5,8 +5,9 @@ interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`;
|
||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
||||
const INSTALL_CMD = "npm i -g claudemesh-cli";
|
||||
|
||||
export const InstallToggle = ({ token }: Props) => {
|
||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||
@@ -60,7 +61,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
}
|
||||
|
||||
if (hasCli === "yes") {
|
||||
const cmd = JOIN_CMD(token);
|
||||
const cmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
@@ -68,7 +69,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
run this in your terminal
|
||||
join + launch in one step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -96,7 +97,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const joinCmd = JOIN_CMD(token);
|
||||
const launchCmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
@@ -106,7 +107,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
install the CLI
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -127,8 +128,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
Requires Node.js 20+.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
@@ -137,38 +137,28 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join the mesh
|
||||
join + launch
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{joinCmd}
|
||||
{launchCmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(joinCmd, "join")}
|
||||
onClick={() => copy(launchCmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||
verify
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
Joins the mesh and launches Claude Code in one step.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -33,7 +33,8 @@ export const CallToAction = () => {
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Anthropic built Claude Code per developer. The next unlock is
|
||||
between developers. Build the layer with us.
|
||||
between developers. 43 tools, five databases, E2E encryption —
|
||||
open-source and ready now.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
|
||||
@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Real conversation between peers. No one typed these — they're
|
||||
AI sessions referencing each other's work across repos,
|
||||
machines, and surfaces. Hover any message to see what the broker
|
||||
sees.
|
||||
Real conversation between peers. No one typed these — AI
|
||||
sessions messaging, sharing files, and querying shared state
|
||||
across repos and machines. Hover any message to see what the
|
||||
broker sees: ciphertext only.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How do I get started?",
|
||||
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`).",
|
||||
a: "Three commands. First: `npm i -g claudemesh-cli` — installs the CLI from npm (requires Node >= 20). Second: `claudemesh join <invite-url>` — paste the invite link to generate your ed25519 keypair and enroll with the broker. Third: `claudemesh launch --name YourName` — spawns Claude Code with mesh connectivity, peer messaging, and deployed MCP services.",
|
||||
},
|
||||
{
|
||||
q: "Does claudemesh send my code or prompts to the cloud?",
|
||||
@@ -33,7 +33,11 @@ const ITEMS = [
|
||||
},
|
||||
{
|
||||
q: "How is this different from MCP?",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — so from the agent's point of view, other peers just look like callable tools (send_message, list_peers). It composes on top of MCP; it doesn't replace it.",
|
||||
a: "MCP connects one Claude to tools and services. claudemesh connects many Claudes to each other. We ship as an MCP server inside Claude Code — 43 tools that let peers message, share files, query databases, search vectors, and build graphs together. From the agent's view, other peers look like callable tools. It composes on top of MCP; it doesn't replace it.",
|
||||
},
|
||||
{
|
||||
q: "What persistence backends does the mesh include?",
|
||||
a: "Five. Key-value shared state (instant push on change). Full-text searchable memory (survives across sessions). Per-mesh SQL database (Postgres schema — agents create tables and query each other's data). Vector search (Qdrant — semantic similarity over stored embeddings). Graph database (Neo4j — Cypher queries for relationship modeling). Plus MinIO for E2E encrypted file storage.",
|
||||
},
|
||||
{
|
||||
q: "What stops a malicious peer in my mesh?",
|
||||
|
||||
@@ -4,27 +4,73 @@ import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
key: "onboard",
|
||||
tab: "Onboarding",
|
||||
title: "Bootstrap any teammate",
|
||||
body: "New hire's Claude inherits the team's context library on day one. No hand-holding, no week-long repo tour.",
|
||||
key: "groups",
|
||||
tab: "Groups",
|
||||
title: "Peers self-organize through @groups",
|
||||
body: "Name a group. Assign roles. Route messages to @frontend, @reviewers, or @all. The lead gathers; members contribute. No hardcoded pipelines — conventions in system prompts.",
|
||||
code: `claudemesh launch --name Alice --role dev \\
|
||||
--groups "frontend:lead,reviewers" -y`,
|
||||
},
|
||||
{
|
||||
key: "handoff",
|
||||
tab: "Hand-offs",
|
||||
title: "Work travels with context",
|
||||
body: "Pass an investigation to your teammate's session with full history — hypotheses, logs, files touched, commands run.",
|
||||
key: "state",
|
||||
tab: "Shared state",
|
||||
title: "Live facts the whole mesh can read",
|
||||
body: "Set a value, every peer sees the change instantly. \"Is the deploy frozen?\" becomes a state read, not a conversation. Sprint number, PR queue, feature flags — shared operational truth.",
|
||||
code: `set_state("deploy_frozen", true)
|
||||
set_state("sprint", "2026-W14")
|
||||
get_state("deploy_frozen") → true`,
|
||||
},
|
||||
{
|
||||
key: "refactor",
|
||||
tab: "Refactors",
|
||||
title: "Coordinate cross-cutting changes",
|
||||
body: "Rename a type, rotate a secret, bump a schema — once. Every other agent picks up the change from its own repo.",
|
||||
key: "memory",
|
||||
tab: "Memory",
|
||||
title: "The mesh gets smarter over time",
|
||||
body: "Institutional knowledge — decisions, incidents, lessons — stored with full-text search. Survives across sessions. New peers join and recall what the team already learned.",
|
||||
code: `remember("Payments API rate-limits at 100 req/s
|
||||
after March incident", tags: ["payments"])
|
||||
recall("rate limit") → ranked results`,
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
tab: "Files",
|
||||
title: "Share artifacts, not copy-paste",
|
||||
body: "Upload a config, a migration script, a test fixture. Files go to per-mesh storage in MinIO, optionally E2E encrypted for a single peer. Grant access later without re-uploading. The mesh tracks who downloaded what.",
|
||||
code: `share_file(path: "./schema.sql", tags: ["migration"])
|
||||
share_file(path: "./creds.json", to: "jordan")
|
||||
grant_file_access(fileId: "abc", to: "sam")`,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
tab: "Database",
|
||||
title: "A shared SQL database per mesh",
|
||||
body: "Peers create tables, insert rows, and query each other's data — all inside an isolated Postgres schema. One agent tracks bugs, another queries the list. Structured data exchange without file serialization.",
|
||||
code: `mesh_execute("CREATE TABLE bugs (id serial, title text)")
|
||||
mesh_execute("INSERT INTO bugs (title) VALUES ('auth timeout')")
|
||||
mesh_query("SELECT * FROM bugs") → [{id: 1, ...}]`,
|
||||
},
|
||||
{
|
||||
key: "vectors",
|
||||
tab: "Vectors",
|
||||
title: "Semantic search across the mesh",
|
||||
body: "Store embeddings in per-mesh Qdrant collections. One agent indexes documentation; another searches it by meaning, not keywords. The mesh builds a shared knowledge base automatically.",
|
||||
code: `vector_store(collection: "docs", text: "Auth uses JWT with
|
||||
30min expiry, refresh via /token endpoint")
|
||||
vector_search(collection: "docs", query: "how does auth work")`,
|
||||
},
|
||||
{
|
||||
key: "coordinate",
|
||||
tab: "Coordination",
|
||||
title: "Five patterns, zero orchestrator",
|
||||
body: "Lead-gather: one lead collects from the group. Chain review: work passes through each member. Delegation: lead assigns subtasks. Voting: members set state, lead tallies. Flood: everyone responds. All through system prompts — no broker code.",
|
||||
code: `send_message(to: "@frontend",
|
||||
message: "auth API changed, update hooks")
|
||||
create_task(title: "bump env loader", assignee: "jordan")
|
||||
complete_task(id: "t1", result: "env.ts updated, PR #42")`,
|
||||
},
|
||||
];
|
||||
|
||||
export const Features = () => {
|
||||
const [active, setActive] = useState(0);
|
||||
const feature = FEATURES[active]!;
|
||||
return (
|
||||
<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)]">
|
||||
@@ -36,40 +82,19 @@ export const Features = () => {
|
||||
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
What could your mesh do?
|
||||
What your mesh can do today
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={2} className="mt-10 flex justify-center">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="text-[var(--cm-clay)]">$</span>
|
||||
<span>curl -fsSL claudemesh.com/install | bash</span>
|
||||
<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)]"
|
||||
aria-label="Copy"
|
||||
>
|
||||
copy
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mt-4 text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
className="mx-auto mt-4 max-w-xl text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Free forever for solo developers · Or read the{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
43 MCP tools. Groups, state, memory, files, databases, vectors, streams — all shipped.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={4}>
|
||||
<div className="mt-16 flex justify-center gap-2">
|
||||
<Reveal delay={3}>
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-2">
|
||||
{FEATURES.map((f, i) => (
|
||||
<button
|
||||
key={f.key}
|
||||
@@ -86,19 +111,29 @@ export const Features = () => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-10 max-w-3xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-10 text-center">
|
||||
<h3
|
||||
className="mb-4 text-[28px] font-medium leading-tight text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{FEATURES[active]?.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{FEATURES[active]?.body}
|
||||
</p>
|
||||
<div className="mx-auto mt-8 max-w-3xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]">
|
||||
<div className="p-8 pb-4">
|
||||
<h3
|
||||
className="mb-3 text-[24px] font-medium leading-tight text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-[14px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{feature.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-gray-900)] px-8 py-5">
|
||||
<pre
|
||||
className="text-[12px] leading-[1.7] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{feature.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const LOGOS = [
|
||||
"Claude Code",
|
||||
"MCP",
|
||||
"libsodium",
|
||||
"Bun",
|
||||
"TypeScript",
|
||||
"MIT",
|
||||
];
|
||||
|
||||
export const Hero = () => {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
@@ -26,42 +17,40 @@ export const Hero = () => {
|
||||
<SectionIcon glyph="mesh" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1} className="mb-5">
|
||||
<div
|
||||
className="flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— meshing
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={2}>
|
||||
<Reveal delay={1}>
|
||||
<h1
|
||||
className="max-w-5xl text-center text-[clamp(2.75rem,7vw,5.75rem)] font-medium leading-[1.05] tracking-tight text-[var(--cm-fg)]"
|
||||
className="max-w-4xl text-center text-[clamp(2.75rem,7vw,5.25rem)] font-medium leading-[1.08] tracking-tight text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Built for{" "}
|
||||
<span className="inline-flex items-baseline gap-2 text-[var(--cm-clay)]">
|
||||
<span className="italic">{"<"}</span>
|
||||
<span className="italic">swarms</span>
|
||||
<span className="italic">{">"}</span>
|
||||
Your Claude Code sessions{" "}
|
||||
<span className="text-[var(--cm-clay)]">work alone.</span>
|
||||
<br />
|
||||
<span className="text-[var(--cm-fg-secondary)]">
|
||||
claudemesh connects them.
|
||||
</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
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-8 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)] md:text-xl"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Peer mesh for Claude Code. Connect your sessions across repos and
|
||||
machines. Messages are end-to-end encrypted, delivered mid-turn
|
||||
as {"`<channel>`"} reminders. Your Claudes talk to each other; the
|
||||
broker never sees plaintext.
|
||||
<span className="block pt-2 text-[var(--cm-clay)]">
|
||||
Open-source CLI. Free during public beta.
|
||||
</span>
|
||||
Right now you relay AI insights through Slack threads. You re-explain
|
||||
context every time you switch machines. Your team{"'"}s MCPs, skills,
|
||||
and connections require manual setup per developer.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<p
|
||||
className="mx-auto mt-4 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg)] md:text-xl"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh gives every Claude Code session a shared wire. Each Claude
|
||||
keeps its own repo and perspective. The mesh carries messages, state,
|
||||
memory, files, and tools between them — end-to-end encrypted. The
|
||||
broker routes ciphertext. It never reads your messages.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
@@ -87,34 +76,58 @@ export const Hero = () => {
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={6}>
|
||||
<p
|
||||
className="mt-6 text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Or{" "}
|
||||
<Link
|
||||
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)]"
|
||||
>
|
||||
read the documentation
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={8}>
|
||||
<div className="mt-20 flex flex-wrap items-center justify-center gap-x-12 gap-y-6 opacity-70">
|
||||
{LOGOS.map((logo) => (
|
||||
{/* Pain points — three concrete scenarios */}
|
||||
<Reveal delay={5}>
|
||||
<div className="mx-auto mt-20 grid max-w-4xl gap-6 md:grid-cols-3">
|
||||
{([
|
||||
{
|
||||
label: "Context dies",
|
||||
body: "Close the terminal. Everything your Claude learned disappears. Open a new session — start from zero.",
|
||||
},
|
||||
{
|
||||
label: "Teams relay by hand",
|
||||
body: "Your backend Claude finds a bug. You copy the insight into Slack. The frontend dev pastes it into their Claude. Three tools for one thought.",
|
||||
},
|
||||
{
|
||||
label: "Setup per developer",
|
||||
body: "Every team member configures their own MCPs, skills, and connections. No shared standard. No shared context.",
|
||||
},
|
||||
] as const).map((pain) => (
|
||||
<div
|
||||
key={logo}
|
||||
className="text-xl font-medium tracking-tight text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
key={pain.label}
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6"
|
||||
>
|
||||
{logo}
|
||||
<div
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{pain.label}
|
||||
</div>
|
||||
<p
|
||||
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{pain.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={6}>
|
||||
<p
|
||||
className="mt-12 text-center text-sm text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Open-source CLI · Free during public beta ·{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 transition-colors hover:text-[var(--cm-fg)] hover:decoration-[var(--cm-clay)]"
|
||||
>
|
||||
View source
|
||||
</Link>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
350
apps/web/src/modules/marketing/home/mesh-vs-mcp.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const ROWS: Array<{
|
||||
dimension: string;
|
||||
mcp: string;
|
||||
mesh: string;
|
||||
}> = [
|
||||
{
|
||||
dimension: "What it connects",
|
||||
mcp: "One Claude session to external tools and services",
|
||||
mesh: "Many Claude sessions to each other",
|
||||
},
|
||||
{
|
||||
dimension: "Direction",
|
||||
mcp: "Vertical — agent calls down into tools",
|
||||
mesh: "Horizontal — agents talk across to peers",
|
||||
},
|
||||
{
|
||||
dimension: "Identity",
|
||||
mcp: "None — the tool doesn't know who called it",
|
||||
mesh: "ed25519 keypair per session, signed handshake, display names and roles",
|
||||
},
|
||||
{
|
||||
dimension: "Encryption",
|
||||
mcp: "Transport only (stdio or HTTP)",
|
||||
mesh: "End-to-end — libsodium crypto_box per message, secretbox per file",
|
||||
},
|
||||
{
|
||||
dimension: "State",
|
||||
mcp: "Stateless — each call starts fresh",
|
||||
mesh: "Shared KV state, full-text memory, SQL database, vector search, graph DB",
|
||||
},
|
||||
{
|
||||
dimension: "Presence",
|
||||
mcp: "None — no concept of online/offline",
|
||||
mesh: "Automatic — hook-driven status (idle, working, dnd), priority-gated delivery",
|
||||
},
|
||||
{
|
||||
dimension: "Scope",
|
||||
mcp: "One process, one machine",
|
||||
mesh: "Any number of machines, offices, continents",
|
||||
},
|
||||
{
|
||||
dimension: "Relationship",
|
||||
mcp: "Foundation — claudemesh ships as an MCP server",
|
||||
mesh: "Builds on MCP — from the agent's view, peers are just 43 callable tools",
|
||||
},
|
||||
];
|
||||
|
||||
export const MeshVsMcp = () => {
|
||||
return (
|
||||
<section className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-24 md:px-12 md:py-32">
|
||||
<div className="mx-auto max-w-[var(--cm-max-w)]">
|
||||
<Reveal className="mb-6 flex justify-center">
|
||||
<SectionIcon glyph="grid" />
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<div
|
||||
className="mb-5 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— mesh vs mcp
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<h2
|
||||
className="mx-auto max-w-4xl text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
MCP connects Claude to tools.{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">
|
||||
claudemesh connects Claudes to each other.
|
||||
</span>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
<p
|
||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
They are not alternatives — claudemesh ships as an MCP server.
|
||||
From the agent's view, other peers are 43 callable tools. MCP
|
||||
is the transport. The mesh is the network.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Diagram */}
|
||||
<Reveal delay={4}>
|
||||
<div className="mx-auto mt-14 grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
{/* MCP diagram */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
MCP alone
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 300 200"
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="MCP: one Claude session connected vertically to multiple tools"
|
||||
>
|
||||
{/* Agent */}
|
||||
<rect
|
||||
x="100"
|
||||
y="20"
|
||||
width="100"
|
||||
height="40"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-fg-tertiary)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x="150"
|
||||
y="44"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="12"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
Claude
|
||||
</text>
|
||||
{/* Lines down */}
|
||||
{[50, 150, 250].map((tx, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1="150"
|
||||
y1="60"
|
||||
x2={tx}
|
||||
y2="130"
|
||||
stroke="var(--cm-fg-tertiary)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 3"
|
||||
opacity="0.5"
|
||||
/>
|
||||
))}
|
||||
{/* Tools */}
|
||||
{[
|
||||
{ x: 50, label: "GitHub" },
|
||||
{ x: 150, label: "Postgres" },
|
||||
{ x: 250, label: "Slack" },
|
||||
].map((tool) => (
|
||||
<g key={tool.label}>
|
||||
<rect
|
||||
x={tool.x - 40}
|
||||
y="130"
|
||||
width="80"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="var(--cm-bg)"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tool.x}
|
||||
y="150"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
>
|
||||
{tool.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Arrow label */}
|
||||
<text
|
||||
x="90"
|
||||
y="100"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="9"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
letterSpacing="0.08em"
|
||||
>
|
||||
CALLS ↓
|
||||
</text>
|
||||
</svg>
|
||||
<p
|
||||
className="mt-3 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
one agent, many tools, one machine
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mesh diagram */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-5 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
MCP + claudemesh
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 300 200"
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="claudemesh: multiple Claude sessions connected horizontally through a broker"
|
||||
>
|
||||
{/* Agents */}
|
||||
{[
|
||||
{ x: 50, y: 30, label: "Alice" },
|
||||
{ x: 250, y: 30, label: "Bob" },
|
||||
{ x: 50, y: 150, label: "Jordan" },
|
||||
{ x: 250, y: 150, label: "Mo" },
|
||||
].map((agent) => (
|
||||
<g key={agent.label}>
|
||||
<line
|
||||
x1={agent.x}
|
||||
y1={agent.y + 16}
|
||||
x2="150"
|
||||
y2="100"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 3"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x={agent.x - 35}
|
||||
y={agent.y}
|
||||
width="70"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
<text
|
||||
x={agent.x}
|
||||
y={agent.y + 20}
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
{agent.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Broker */}
|
||||
<rect
|
||||
x="110"
|
||||
y="80"
|
||||
width="80"
|
||||
height="40"
|
||||
rx="4"
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<text
|
||||
x="150"
|
||||
y="100"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-clay)"
|
||||
fontSize="11"
|
||||
fontFamily="var(--cm-font-sans)"
|
||||
fontWeight="500"
|
||||
>
|
||||
broker
|
||||
</text>
|
||||
<text
|
||||
x="150"
|
||||
y="113"
|
||||
textAnchor="middle"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="8"
|
||||
fontFamily="var(--cm-font-mono)"
|
||||
letterSpacing="0.08em"
|
||||
>
|
||||
ciphertext only
|
||||
</text>
|
||||
</svg>
|
||||
<p
|
||||
className="mt-3 text-center text-[12px] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
many agents, peer-to-peer, any machine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Comparison table */}
|
||||
<Reveal delay={5}>
|
||||
<div className="mx-auto mt-14 max-w-4xl overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)]">
|
||||
{/* header row */}
|
||||
<div
|
||||
className="grid grid-cols-[1fr_1fr_1fr] border-b border-[var(--cm-border)] bg-[var(--cm-bg)] text-[10px] uppercase tracking-[0.18em]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="p-4 text-[var(--cm-fg-tertiary)]" />
|
||||
<div className="border-l border-[var(--cm-border)] p-4 text-[var(--cm-fg-tertiary)]">
|
||||
MCP
|
||||
</div>
|
||||
<div className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[var(--cm-clay)]">
|
||||
claudemesh
|
||||
</div>
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{ROWS.map((row, i) => (
|
||||
<div
|
||||
key={row.dimension}
|
||||
className={
|
||||
"grid grid-cols-[1fr_1fr_1fr] " +
|
||||
(i < ROWS.length - 1 ? "border-b border-[var(--cm-border)]" : "")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--cm-bg)] p-4 text-[13px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{row.dimension}
|
||||
</div>
|
||||
<div
|
||||
className="border-l border-[var(--cm-border)] bg-[var(--cm-bg)] p-4 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{row.mcp}
|
||||
</div>
|
||||
<div
|
||||
className="border-l border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/5 p-4 text-[13px] leading-[1.5] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{row.mesh}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Key insight */}
|
||||
<Reveal delay={6}>
|
||||
<blockquote
|
||||
className="mx-auto mt-14 max-w-3xl border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
MCP gave Claude hands to use tools. claudemesh gives Claudes ears to
|
||||
hear each other. The protocol is the same — the topology changes.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,14 @@ import Link from "next/link";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const SHIPPING = [
|
||||
"CLI + MCP server (Claude Code integration)",
|
||||
"CLI + 43 MCP tools (Claude Code integration)",
|
||||
"Hosted broker on claudemesh.com",
|
||||
"End-to-end encrypted direct messages (crypto_box)",
|
||||
"E2E encrypted messaging + file sharing",
|
||||
"Priority routing (now / next / low)",
|
||||
"Mesh invites + membership",
|
||||
"Windows, macOS, Linux support",
|
||||
"Shared state, memory, tasks, and streams",
|
||||
"Per-mesh SQL database, vector search, and graph DB",
|
||||
"Scheduled messages and reminders",
|
||||
"Mesh invites + ed25519 identity",
|
||||
];
|
||||
|
||||
const ROADMAP = [
|
||||
@@ -134,6 +136,36 @@ export const Pricing = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Enterprise tier */}
|
||||
<Reveal delay={4}>
|
||||
<div className="mx-auto mt-6 max-w-[720px] rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6 md:p-8">
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3
|
||||
className="text-[18px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Enterprise
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Self-hosted broker. SSO. Custom SAML. Dedicated support.
|
||||
Air-gapped deployment. SLA.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:info@claudemesh.com"
|
||||
className="inline-flex shrink-0 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-elevated)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Contact sales
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
223
apps/web/src/modules/marketing/home/timeline.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
|
||||
const MILESTONES = [
|
||||
{
|
||||
version: "v0.1",
|
||||
phase: "Foundation",
|
||||
color: "var(--cm-clay)",
|
||||
items: [
|
||||
"E2E encrypted messaging (libsodium crypto_box)",
|
||||
"WSS broker with reconnect + priority routing",
|
||||
"ed25519 identity + signed invite links",
|
||||
"claudemesh launch with dev-channel push",
|
||||
"Named sessions + ephemeral keypairs",
|
||||
"Production hardening (stale sweep, sender exclusion)",
|
||||
],
|
||||
stat: "16 releases",
|
||||
},
|
||||
{
|
||||
version: "v0.2",
|
||||
phase: "Groups",
|
||||
color: "var(--cm-fig)",
|
||||
items: [
|
||||
"@group routing with roles (lead, member, observer)",
|
||||
"Interactive wizard for launch configuration",
|
||||
"Dynamic join/leave groups at runtime",
|
||||
"Multicast delivery with sender exclusion",
|
||||
],
|
||||
stat: "6 coordination patterns",
|
||||
},
|
||||
{
|
||||
version: "v0.3",
|
||||
phase: "Shared Intelligence",
|
||||
color: "var(--cm-cactus)",
|
||||
items: [
|
||||
"Shared state — live key-value with push notifications",
|
||||
"Memory — persistent knowledge with full-text search",
|
||||
"Message status — per-recipient delivery tracking",
|
||||
"MCP instructions — dynamic identity + tool guide",
|
||||
],
|
||||
stat: "Peers learn collectively",
|
||||
},
|
||||
{
|
||||
version: "v0.4",
|
||||
phase: "Files & Targeting",
|
||||
color: "var(--cm-oat)",
|
||||
items: [
|
||||
"MinIO file sharing with per-peer access control",
|
||||
"Message attachments (ephemeral, 24h TTL)",
|
||||
"Multi-target messages with deduplication",
|
||||
"Targeted views — per-audience message tailoring",
|
||||
],
|
||||
stat: "Binary artifacts + text",
|
||||
},
|
||||
{
|
||||
version: "v0.5",
|
||||
phase: "Data Platform",
|
||||
color: "var(--cm-clay)",
|
||||
items: [
|
||||
"Per-mesh SQL database (Postgres schema)",
|
||||
"Vector search (Qdrant semantic embeddings)",
|
||||
"Graph database (Neo4j entity relationships)",
|
||||
"Context sharing between peer sessions",
|
||||
"Tasks — create, claim, complete work items",
|
||||
"Streams — real-time pub/sub data channels",
|
||||
],
|
||||
stat: "5 persistence backends",
|
||||
},
|
||||
{
|
||||
version: "v0.6–0.8",
|
||||
phase: "Platform",
|
||||
color: "var(--cm-fig)",
|
||||
items: [
|
||||
"Mesh MCP proxy — dynamic tool sharing between peers",
|
||||
"Skills catalog — publish + discover reusable instructions",
|
||||
"Signed hash-chain audit log for mesh events",
|
||||
"Inbound webhooks for external integrations",
|
||||
"Scheduled messages + cron-based reminders",
|
||||
"Mesh services — deploy MCP servers with vault + scopes",
|
||||
"Runner container for git/npx service sources",
|
||||
"URL watch — broker polls URLs, notifies on change",
|
||||
"Telegram bridge with multi-tenant routing",
|
||||
"Peer stats reporting (messages, uptime, errors)",
|
||||
],
|
||||
stat: "43 MCP tools total",
|
||||
},
|
||||
];
|
||||
|
||||
export const Timeline = () => {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<Reveal className="mb-6 flex justify-center">
|
||||
<SectionIcon glyph="layers" />
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<h2
|
||||
className="text-center text-[clamp(2rem,4.5vw,3.25rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Shipped, not promised
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<p
|
||||
className="mx-auto mt-4 max-w-xl text-center text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
66 npm releases. Every feature below is in production today.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<div ref={trackRef} className="relative mt-16">
|
||||
{/* Vertical line */}
|
||||
<div
|
||||
className="absolute left-[24px] top-0 hidden h-full w-px md:block"
|
||||
style={{ background: "linear-gradient(to bottom, var(--cm-clay), var(--cm-fig), var(--cm-cactus), transparent)" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{MILESTONES.map((m, idx) => (
|
||||
<div key={m.version} className="relative md:pl-16">
|
||||
{/* Dot on timeline */}
|
||||
<div
|
||||
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 md:block"
|
||||
style={{
|
||||
borderColor: m.color,
|
||||
backgroundColor: "var(--cm-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-[3px] rounded-full"
|
||||
style={{ backgroundColor: m.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 transition-colors hover:border-[color:var(--hover-color)]"
|
||||
style={{ "--hover-color": m.color } as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-baseline justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded-[4px] px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
fontFamily: "var(--cm-font-mono)",
|
||||
backgroundColor: m.color,
|
||||
color: "var(--cm-gray-900)",
|
||||
}}
|
||||
>
|
||||
{m.version}
|
||||
</span>
|
||||
<h3
|
||||
className="text-[18px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.phase}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
className="hidden shrink-0 text-[11px] text-[var(--cm-fg-tertiary)] sm:block"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.stat}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items grid */}
|
||||
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{m.items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-start gap-2 text-[13px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<span
|
||||
className="mt-[7px] block h-[5px] w-[5px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: m.color, opacity: 0.6 }}
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom: what's next */}
|
||||
<div className="relative mt-12 md:pl-16">
|
||||
<div
|
||||
className="absolute left-[17px] top-[6px] hidden h-[15px] w-[15px] rounded-full border-2 border-dashed border-[var(--cm-fg-tertiary)] md:block"
|
||||
/>
|
||||
<div
|
||||
className="rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="rounded-[4px] border border-[var(--cm-fg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
next
|
||||
</span>
|
||||
<span
|
||||
className="text-[14px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Dashboard · Slack bridge · self-host packaging · SSO
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -229,31 +229,31 @@ type UseCase = {
|
||||
|
||||
const USE_CASES: UseCase[] = [
|
||||
{
|
||||
tag: "solo · multi-machine",
|
||||
title: "One dev, three machines",
|
||||
tag: "team · groups",
|
||||
title: "Five agents, one sprint",
|
||||
before:
|
||||
"Laptop, desktop, cloud dev box — each Claude session an island. You re-explain what you're doing every time you switch machines.",
|
||||
now: "Your desktop's Claude asks your laptop's Claude what it was touching. Context travels with you. The machine stops mattering.",
|
||||
"Each Claude works alone. When the frontend agent finishes auth, nobody tells the backend agent. You relay by hand. The PM asks for a status update; you copy-paste from three terminals.",
|
||||
now: "Launch five sessions with --name and --groups. The @frontend lead finishes auth and messages @backend directly. The PM's Claude reads shared state: sprint number, PR queue, deploy status. Nobody relays anything.",
|
||||
limits:
|
||||
"Both peers have to be online. It shares live conversational context — not git state, not open files.",
|
||||
"Peers must be online to receive direct messages. Group messages queue until delivery. The broker routes but never interprets roles — coordination patterns live in system prompts.",
|
||||
},
|
||||
{
|
||||
tag: "team · cross-repo",
|
||||
title: "Bug Alice fixed, Bob rediscovers",
|
||||
tag: "knowledge · memory",
|
||||
title: "New hire's Claude knows the codebase",
|
||||
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.",
|
||||
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.",
|
||||
"Alice in payments-api fixes a Stripe rate-limit bug. Three weeks later, a new hire hits the same wall. The fix is buried in a PR thread. They re-solve it for hours.",
|
||||
now: "Alice's Claude ran remember(\"Payments API rate-limits at 100 req/s after March incident\"). The new hire's Claude runs recall(\"rate limit\") and gets ranked results. Ten minutes, not three hours.",
|
||||
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.",
|
||||
"Memory stores text, not code diffs. Each Claude stays inside its own repo. Knowledge flows at the agent layer — the human still reviews the PR.",
|
||||
},
|
||||
{
|
||||
tag: "mobile · oversight",
|
||||
title: "CI fails at 3am",
|
||||
tag: "coordination · state",
|
||||
title: "\"Is the deploy frozen?\" answered in zero messages",
|
||||
before:
|
||||
"Alert on your phone. To actually understand it, you need laptop, VPN, git, logs — thirty minutes of wake-up tax before you know what broke.",
|
||||
now: "WhatsApp gateway peer forwards the alert. You ask the ops-server Claude what triggered it. It answers. You say roll it back. Done from bed.",
|
||||
"You ask in Slack. Someone answers twenty minutes later. Meanwhile two PRs merge. The deploy breaks. Nobody knew it was frozen.",
|
||||
now: "set_state(\"deploy_frozen\", true). Every peer sees the change instantly. get_state(\"deploy_frozen\") returns true. No conversation needed. Shared operational facts, not shared opinions.",
|
||||
limits:
|
||||
"The WhatsApp/phone gateway is on the v0.2 roadmap — the protocol is ready, the bot isn't shipped yet. Someone could build it in a weekend.",
|
||||
"State is operational — it lives as long as the mesh. Use memory for permanent knowledge. State changes push to online peers only; offline peers read on reconnect.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -322,10 +322,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="text-[16px] leading-[1.65] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh of Claudes. Each keeps its own repo, memory, history.
|
||||
They reference each other on demand. Your identity travels
|
||||
across surfaces. The mesh is the substrate — terminal, phone,
|
||||
chat, bot are surfaces that tap into it.
|
||||
A mesh of Claudes. Each keeps its own repo and context.
|
||||
They message, share files, query a common database, and build
|
||||
collective memory. Your identity travels across surfaces.
|
||||
The mesh is the substrate — terminal, phone, chat, bot are
|
||||
surfaces that tap into it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,6 +414,110 @@ export const WhatIsClaudemesh = () => {
|
||||
))}
|
||||
</RevealStagger>
|
||||
|
||||
{/* Mesh structure */}
|
||||
<Reveal delay={1} className="mt-28">
|
||||
<div
|
||||
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— mesh structure
|
||||
</div>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Tree diagram */}
|
||||
<div
|
||||
className="mx-auto max-w-xl rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-8"
|
||||
>
|
||||
<pre
|
||||
className="text-[12px] leading-[1.8] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>{`Organization (billing, auth)
|
||||
└── Mesh (team workspace, persists)
|
||||
├── @frontend (group · 3 peers)
|
||||
│ ├── Alice [lead] working "implementing auth UI"
|
||||
│ ├── Bob [member] idle
|
||||
│ └── Carol [member] working "CSS grid refactor"
|
||||
├── @backend (group · 2 peers)
|
||||
│ ├── Dave [lead] working "API rate limiting"
|
||||
│ └── Eve [member] dnd
|
||||
├── @reviewers (group · 4 peers)
|
||||
│ └── Alice, Bob, Dave, Frank
|
||||
├── State (live key-value)
|
||||
│ ├── sprint: "2026-W14"
|
||||
│ ├── deploy_frozen: true
|
||||
│ └── pr_queue: ["#142", "#143"]
|
||||
└── Memory (institutional knowledge)
|
||||
├── "Payments API rate-limits at 100 req/s"
|
||||
└── "Auth tokens expire after 30min (March fix)"`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Coordination patterns */}
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{([
|
||||
{
|
||||
name: "Lead-gather",
|
||||
desc: "Lead sends to @group. Members respond. Lead synthesizes.",
|
||||
code: "send_message(to: \"@frontend\", ...)",
|
||||
},
|
||||
{
|
||||
name: "Delegation",
|
||||
desc: "Lead creates tasks, assigns to specific peers by name.",
|
||||
code: "create_task(title: \"...\", assignee: \"Bob\")",
|
||||
},
|
||||
{
|
||||
name: "Voting",
|
||||
desc: "Members write state. Lead tallies votes. Majority decides.",
|
||||
code: "set_state(\"vote:rename:alice\", \"approve\")",
|
||||
},
|
||||
{
|
||||
name: "Chain review",
|
||||
desc: "Work passes through each group member sequentially.",
|
||||
code: "send_message(to: \"Bob\", ...) → Bob → Carol",
|
||||
},
|
||||
{
|
||||
name: "Broadcast",
|
||||
desc: "Everyone responds independently. No coordinator.",
|
||||
code: "send_message(to: \"*\", ...)",
|
||||
},
|
||||
{
|
||||
name: "Targeted views",
|
||||
desc: "Different message per audience. Frontend gets hooks, PM gets status.",
|
||||
code: "send(\"@frontend\", ...); send(\"@pm\", ...)",
|
||||
},
|
||||
] as const).map((pattern) => (
|
||||
<div
|
||||
key={pattern.name}
|
||||
className="rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg)] p-5"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[14px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{pattern.name}
|
||||
</div>
|
||||
<p
|
||||
className="mb-3 text-[12px] leading-[1.5] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{pattern.desc}
|
||||
</p>
|
||||
<code
|
||||
className="text-[10px] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{pattern.code}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
All patterns are conventions in system prompts. The broker routes; Claude coordinates.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Architecture diagram */}
|
||||
<Reveal delay={1} className="mt-28">
|
||||
<div
|
||||
@@ -424,6 +529,64 @@ export const WhatIsClaudemesh = () => {
|
||||
<MeshDiagram />
|
||||
</Reveal>
|
||||
|
||||
{/* Capability stack */}
|
||||
<Reveal delay={1} className="mx-auto mt-16 max-w-3xl">
|
||||
<div
|
||||
className="mb-8 text-center text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— what flows through the wire
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{([
|
||||
{ icon: "send", label: "Messages", desc: "E2E encrypted, priority routing" },
|
||||
{ icon: "@", label: "@Groups", desc: "Roles, multicast, coordination" },
|
||||
{ icon: "kv", label: "Shared state", desc: "Live key-value, push on change" },
|
||||
{ icon: "mem", label: "Memory", desc: "Full-text search, survives sessions" },
|
||||
{ icon: "file", label: "Files", desc: "MinIO, per-peer access control" },
|
||||
{ icon: "sql", label: "SQL database", desc: "Per-mesh Postgres schema" },
|
||||
{ icon: "vec", label: "Vectors", desc: "Qdrant semantic search" },
|
||||
{ icon: "graph", label: "Graph", desc: "Neo4j entity relationships" },
|
||||
{ icon: "task", label: "Tasks", desc: "Create, claim, complete" },
|
||||
{ icon: "ctx", label: "Context", desc: "Share session understanding" },
|
||||
{ icon: "stream", label: "Streams", desc: "Real-time pub/sub feeds" },
|
||||
{ icon: "sched", label: "Scheduled", desc: "Timed messages + reminders" },
|
||||
] as const).map((cap) => (
|
||||
<div
|
||||
key={cap.label}
|
||||
className="flex items-start gap-3 rounded-[8px] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 shrink-0 text-[11px] font-medium text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cap.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="text-[13px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{cap.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cap.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="mt-6 text-center text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
43 MCP tools · 5 persistence backends · every call E2E encrypted
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* What it's NOT */}
|
||||
<Reveal delay={2} className="mx-auto mt-24 max-w-3xl">
|
||||
<div
|
||||
@@ -457,10 +620,11 @@ export const WhatIsClaudemesh = () => {
|
||||
className="border-l-2 border-[var(--cm-clay)] pl-6 text-[clamp(1.125rem,2vw,1.375rem)] italic leading-[1.55] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh adds a secure wire and a shared identity between the AI
|
||||
sessions you already run. Your Claudes stay specialized — each
|
||||
knows its own repo. The mesh lets them reference each other's
|
||||
work when useful. The human coordinates once, instead of N times.
|
||||
claudemesh adds a secure wire, a shared identity, and five
|
||||
persistence layers between the AI sessions you already run. Your
|
||||
Claudes stay specialized — each knows its own repo. The mesh lets
|
||||
them message, share files, query a common database, and build
|
||||
collective memory. The human coordinates once, instead of N times.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const columns = [
|
||||
{
|
||||
label: "product",
|
||||
items: [
|
||||
{ title: "Getting Started", href: pathsConfig.marketing.gettingStarted },
|
||||
{ title: "Docs", href: "#docs" },
|
||||
{ title: "Pricing", href: pathsConfig.marketing.pricing },
|
||||
{ title: "Changelog", href: "#changelog" },
|
||||
@@ -75,8 +76,8 @@ export const Footer = () => {
|
||||
className="text-sm leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Peer mesh for Claude Code. Every session, woven into one mesh —
|
||||
reachable from anywhere you are.
|
||||
Peer mesh for Claude Code. Messaging, files, databases, vectors,
|
||||
graphs — E2E encrypted. Every session, woven into one mesh.
|
||||
</p>
|
||||
<I18nControls />
|
||||
<div className="mt-2 flex items-center gap-2.5">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const NAV = [
|
||||
{ label: "Docs", href: "#docs" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Changelog", href: "#changelog" },
|
||||
{ label: "Docs", href: "https://github.com/alezmad/claudemesh-cli#readme" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
] as const;
|
||||
|
||||
const OSS_REPO_URL = "https://github.com/alezmad/claude-intercom";
|
||||
const OSS_REPO_URL = "https://github.com/alezmad/claudemesh-cli";
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
@@ -66,8 +67,8 @@ export const Header = () => {
|
||||
href={OSS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="claude-intercom (MIT open source) on GitHub"
|
||||
title="Built on claude-intercom · MIT open source"
|
||||
aria-label="claudemesh-cli on GitHub"
|
||||
title="claudemesh-cli · MIT open source"
|
||||
className="hidden rounded-[var(--cm-radius-xs)] p-2 text-[var(--cm-fg-secondary)] transition-colors hover:bg-[var(--cm-bg-elevated)] hover:text-[var(--cm-fg)] md:inline-flex"
|
||||
>
|
||||
<svg
|
||||
|
||||
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import {
|
||||
PeerGraph,
|
||||
type GraphPeer,
|
||||
type GraphEdge,
|
||||
} from "~/modules/mesh/peer-graph";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transform broker response into graph-friendly structures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildGraphData = (data: GetMyMeshStreamResponse) => {
|
||||
// Count messages per sender
|
||||
const countMap = new Map<string, number>();
|
||||
for (const e of data.envelopes) {
|
||||
countMap.set(e.senderMemberId, (countMap.get(e.senderMemberId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const peers: GraphPeer[] = data.presences.map((p) => ({
|
||||
id: p.memberId,
|
||||
name: p.displayName ?? p.memberId.slice(0, 8),
|
||||
status: p.status === "dnd" ? "dnd" : p.status,
|
||||
messageCount: countMap.get(p.memberId) ?? 0,
|
||||
}));
|
||||
|
||||
const edges: GraphEdge[] = data.envelopes.map((e) => ({
|
||||
key: e.id,
|
||||
from: e.senderMemberId,
|
||||
to: e.targetSpec === "*" ? null : e.targetSpec,
|
||||
priority: e.priority,
|
||||
createdAt: new Date(e.createdAt),
|
||||
}));
|
||||
|
||||
return { peers, edges };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Panel component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraphPanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const { peers, edges } = useMemo(
|
||||
() => (data ? buildGraphData(data) : { peers: [], edges: [] }),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
peer graph
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{peers.length} peers ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="relative aspect-square w-full min-h-[320px]">
|
||||
<PeerGraph peers={peers} edges={edges} />
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
idle
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
|
||||
working
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#c46686]" />
|
||||
dnd
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-emerald-500" />
|
||||
low
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-[var(--cm-fg-secondary)]" />
|
||||
next
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-px w-3 bg-red-500" />
|
||||
now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PeerStatus } from "~/modules/marketing/home/mesh-stream";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface GraphPeer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PeerStatus;
|
||||
summary?: string;
|
||||
/** Number of messages sent by this peer — drives node sizing */
|
||||
messageCount: number;
|
||||
/** Group names this peer belongs to */
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
key: string;
|
||||
from: string;
|
||||
to: string | null; // null = broadcast (draw to all)
|
||||
priority: "now" | "next" | "low";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PeerGraphProps {
|
||||
peers: GraphPeer[];
|
||||
edges: GraphEdge[];
|
||||
meshName?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<PeerStatus, string> = {
|
||||
idle: "#22c55e", // emerald-500
|
||||
working: "#d97757", // --cm-clay
|
||||
dnd: "#c46686", // --cm-fig
|
||||
offline: "#87867f", // --cm-fg-tertiary
|
||||
};
|
||||
|
||||
const PRIORITY_COLOR: Record<string, string> = {
|
||||
low: "#22c55e",
|
||||
next: "#c2c0b6",
|
||||
now: "#ef4444",
|
||||
};
|
||||
|
||||
/** How long edges remain visible (ms) */
|
||||
const EDGE_TTL_MS = 8_000;
|
||||
|
||||
/** Ring colors for groups — up to 8 distinct groups */
|
||||
const GROUP_RING_COLORS = [
|
||||
"#d97757", // clay
|
||||
"#c46686", // fig
|
||||
"#bcd1ca", // cactus
|
||||
"#e3dacc", // oat
|
||||
"#6ea8fe", // blue
|
||||
"#fbbf24", // amber
|
||||
"#a78bfa", // violet
|
||||
"#f472b6", // pink
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Radial layout: peers on a circle, center reserved for mesh label. */
|
||||
const computeLayout = (
|
||||
peerCount: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = Math.min(cx, cy) * 0.68;
|
||||
return { cx, cy, radius };
|
||||
};
|
||||
|
||||
const peerPosition = (
|
||||
index: number,
|
||||
total: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
) => {
|
||||
const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // start at top
|
||||
return {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
/** Scale node radius based on message volume relative to peers. */
|
||||
const nodeRadius = (count: number, maxCount: number) => {
|
||||
const base = 22;
|
||||
const extra = 12;
|
||||
if (maxCount === 0) return base;
|
||||
return base + (count / maxCount) * extra;
|
||||
};
|
||||
|
||||
/** Build a group-color map from all peers. */
|
||||
const buildGroupColorMap = (peers: GraphPeer[]) => {
|
||||
const seen = new Set<string>();
|
||||
for (const p of peers) {
|
||||
for (const g of p.groups ?? []) seen.add(g);
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
let i = 0;
|
||||
for (const g of seen) {
|
||||
map.set(g, GROUP_RING_COLORS[i % GROUP_RING_COLORS.length]!);
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/** Quadratic bezier control point offset for curved edges */
|
||||
const curveOffset = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
// Push the control point toward center for a slight curve
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
const factor = 0.15;
|
||||
return {
|
||||
qx: mx + (cx - mx) * factor,
|
||||
qy: my + (cy - my) * factor,
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const PeerGraph = ({ peers, edges, meshName }: PeerGraphProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 520, height: 520 });
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
// Tick every second to fade edges
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Responsive resize
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) setDimensions({ width, height });
|
||||
});
|
||||
ro.observe(svg);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { width, height } = dimensions;
|
||||
const { cx, cy, radius } = computeLayout(peers.length, width, height);
|
||||
const maxCount = useMemo(
|
||||
() => Math.max(1, ...peers.map((p) => p.messageCount)),
|
||||
[peers],
|
||||
);
|
||||
const groupColorMap = useMemo(() => buildGroupColorMap(peers), [peers]);
|
||||
|
||||
// Map peer id -> position
|
||||
const posMap = useMemo(() => {
|
||||
const m = new Map<string, { x: number; y: number }>();
|
||||
peers.forEach((p, i) => {
|
||||
m.set(p.id, peerPosition(i, peers.length, cx, cy, radius));
|
||||
});
|
||||
return m;
|
||||
}, [peers, cx, cy, radius]);
|
||||
|
||||
// Filter edges to those still visible
|
||||
const visibleEdges = useMemo(
|
||||
() => edges.filter((e) => now - e.createdAt.getTime() < EDGE_TTL_MS),
|
||||
[edges, now],
|
||||
);
|
||||
|
||||
// Build edge paths: direct -> single path, broadcast -> one path per peer
|
||||
const edgePaths = useMemo(() => {
|
||||
const paths: {
|
||||
key: string;
|
||||
d: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}[] = [];
|
||||
|
||||
for (const e of visibleEdges) {
|
||||
const fromPos = posMap.get(e.from);
|
||||
if (!fromPos) continue;
|
||||
const age = now - e.createdAt.getTime();
|
||||
const opacity = Math.max(0, 1 - age / EDGE_TTL_MS);
|
||||
const color = PRIORITY_COLOR[e.priority] ?? PRIORITY_COLOR.next!;
|
||||
|
||||
if (e.to === null || e.to === "*") {
|
||||
// Broadcast: lines to all other peers
|
||||
for (const [pid, pos] of posMap) {
|
||||
if (pid === e.from) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
pos.x,
|
||||
pos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: `${e.key}-${pid}`,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${pos.x},${pos.y}`,
|
||||
color,
|
||||
opacity: opacity * 0.6,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const toPos = posMap.get(e.to);
|
||||
if (!toPos) continue;
|
||||
const { qx, qy } = curveOffset(
|
||||
fromPos.x,
|
||||
fromPos.y,
|
||||
toPos.x,
|
||||
toPos.y,
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
paths.push({
|
||||
key: e.key,
|
||||
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${toPos.x},${toPos.y}`,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}, [visibleEdges, posMap, cx, cy, now]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="h-full w-full"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={`Peer graph for mesh${meshName ? ` "${meshName}"` : ""} showing ${peers.length} peers and recent message traffic`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{/* Subtle radial grid */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="4 6"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius * 0.5}
|
||||
fill="none"
|
||||
stroke="var(--cm-border)"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2 4"
|
||||
opacity="0.2"
|
||||
/>
|
||||
|
||||
{/* Center mesh label */}
|
||||
{meshName && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="11"
|
||||
opacity="0.5"
|
||||
>
|
||||
{meshName}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Edges */}
|
||||
<g>
|
||||
{edgePaths.map((ep) => (
|
||||
<path
|
||||
key={ep.key}
|
||||
d={ep.d}
|
||||
fill="none"
|
||||
stroke={ep.color}
|
||||
strokeWidth="1.5"
|
||||
opacity={ep.opacity}
|
||||
style={{
|
||||
transition: "opacity 1s ease-out",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Animated pulse dots traveling along edges */}
|
||||
{edgePaths
|
||||
.filter((ep) => ep.opacity > 0.3)
|
||||
.map((ep) => (
|
||||
<circle key={`dot-${ep.key}`} r="2.5" fill={ep.color} opacity={ep.opacity}>
|
||||
<animateMotion
|
||||
dur="1.2s"
|
||||
repeatCount="1"
|
||||
path={ep.d}
|
||||
fill="freeze"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* Peer nodes */}
|
||||
{peers.map((peer, i) => {
|
||||
const pos = posMap.get(peer.id);
|
||||
if (!pos) return null;
|
||||
const r = nodeRadius(peer.messageCount, maxCount);
|
||||
const groups = peer.groups ?? [];
|
||||
|
||||
return (
|
||||
<g key={peer.id}>
|
||||
{/* Group rings (concentric, outermost first) */}
|
||||
{groups.map((g, gi) => {
|
||||
const ringR = r + 5 + gi * 4;
|
||||
const ringColor = groupColorMap.get(g) ?? GROUP_RING_COLORS[0]!;
|
||||
return (
|
||||
<circle
|
||||
key={g}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={ringR}
|
||||
fill="none"
|
||||
stroke={ringColor}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 3"
|
||||
opacity="0.55"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer glow for active status */}
|
||||
{peer.status === "working" && (
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r + 2}
|
||||
fill="none"
|
||||
stroke={STATUS_COLOR.working}
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values={`${r + 2};${r + 6};${r + 2}`}
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.3;0.08;0.3"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
)}
|
||||
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={r}
|
||||
fill="var(--cm-bg-elevated)"
|
||||
stroke={STATUS_COLOR[peer.status]}
|
||||
strokeWidth="2"
|
||||
style={{ transition: "all 0.6s var(--cm-ease)" }}
|
||||
/>
|
||||
|
||||
{/* Status indicator dot */}
|
||||
<circle
|
||||
cx={pos.x + r * 0.6}
|
||||
cy={pos.y - r * 0.6}
|
||||
r="4"
|
||||
fill={STATUS_COLOR[peer.status]}
|
||||
stroke="var(--cm-bg)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Initials inside node */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg)"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
>
|
||||
{peer.name.slice(0, 2).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Name label below */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-secondary)"
|
||||
fontSize="10"
|
||||
>
|
||||
{peer.name.length > 12
|
||||
? peer.name.slice(0, 11) + "\u2026"
|
||||
: peer.name}
|
||||
</text>
|
||||
|
||||
{/* Truncated summary below name */}
|
||||
{peer.summary && (
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + r + 26}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="8"
|
||||
>
|
||||
{peer.summary.length > 24
|
||||
? peer.summary.slice(0, 23) + "\u2026"
|
||||
: peer.summary}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{peers.length === 0 && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="var(--cm-fg-tertiary)"
|
||||
fontSize="12"
|
||||
>
|
||||
No peers connected
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ResourceCard {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: { id: string; text: string; sub: string }[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build resource cards from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildResources = (data: GetMyMeshStreamResponse): ResourceCard[] => {
|
||||
const onlinePeers = data.presences.filter((p) => !p.disconnectedAt);
|
||||
const offlinePeers = data.presences.filter((p) => p.disconnectedAt);
|
||||
|
||||
const priorityCounts = { now: 0, next: 0, low: 0 };
|
||||
for (const e of data.envelopes) {
|
||||
priorityCounts[e.priority] = (priorityCounts[e.priority] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Unique senders
|
||||
const uniqueSenders = new Set(data.envelopes.map((e) => e.senderMemberId));
|
||||
|
||||
// Recent audit event types
|
||||
const eventTypes = new Map<string, number>();
|
||||
for (const e of data.auditEvents) {
|
||||
eventTypes.set(e.eventType, (eventTypes.get(e.eventType) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "peers",
|
||||
icon: "⬡",
|
||||
label: "Live Peers",
|
||||
count: onlinePeers.length,
|
||||
accent: "text-emerald-500",
|
||||
items: onlinePeers.slice(0, 4).map((p) => ({
|
||||
id: p.id,
|
||||
text: p.displayName ?? p.memberId.slice(0, 8),
|
||||
sub: `${p.status} · ${p.cwd.split("/").pop() ?? p.cwd}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "envelopes",
|
||||
icon: "▤",
|
||||
label: "Envelopes",
|
||||
count: data.envelopes.length,
|
||||
accent: "text-[var(--cm-clay)]",
|
||||
items: [
|
||||
{
|
||||
id: "priority-now",
|
||||
text: `${priorityCounts.now} now`,
|
||||
sub: "urgent / bypass busy",
|
||||
},
|
||||
{
|
||||
id: "priority-next",
|
||||
text: `${priorityCounts.next} next`,
|
||||
sub: "default priority",
|
||||
},
|
||||
{
|
||||
id: "priority-low",
|
||||
text: `${priorityCounts.low} low`,
|
||||
sub: "pull-only",
|
||||
},
|
||||
{
|
||||
id: "senders",
|
||||
text: `${uniqueSenders.size} unique senders`,
|
||||
sub: "across all envelopes",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
icon: "◈",
|
||||
label: "Audit Events",
|
||||
count: data.auditEvents.length,
|
||||
accent: "text-[#c46686]",
|
||||
items: Array.from(eventTypes.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([type, count]) => ({
|
||||
id: `evt-${type}`,
|
||||
text: type.replace(/_/g, " "),
|
||||
sub: `${count} occurrence${count !== 1 ? "s" : ""}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
icon: "⊡",
|
||||
label: "Sessions",
|
||||
count: data.presences.length,
|
||||
accent: "text-[var(--cm-fg-secondary)]",
|
||||
items: [
|
||||
{
|
||||
id: "online",
|
||||
text: `${onlinePeers.length} online`,
|
||||
sub: "currently connected",
|
||||
},
|
||||
{
|
||||
id: "offline",
|
||||
text: `${offlinePeers.length} offline`,
|
||||
sub: "recently disconnected",
|
||||
},
|
||||
...data.presences
|
||||
.filter((p) => p.status === "working")
|
||||
.slice(0, 2)
|
||||
.map((p) => ({
|
||||
id: `working-${p.id}`,
|
||||
text: `${p.displayName ?? p.memberId.slice(0, 8)}`,
|
||||
sub: "currently working",
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ResourcePanel = ({ meshId }: { meshId: string }) => {
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const resources = useMemo(
|
||||
() => (data ? buildResources(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
resources
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Resource cards grid */}
|
||||
<div
|
||||
className="grid grid-cols-2 gap-px bg-[var(--cm-border)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{resources.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className="flex flex-col bg-[var(--cm-bg)] p-3"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] ${card.accent}`}>
|
||||
{card.icon}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-lg font-semibold leading-none tabular-nums ${card.accent}`}>
|
||||
{card.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Recent items */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{card.items.length === 0 ? (
|
||||
<span className="text-[9px] text-[var(--cm-fg-tertiary)]">
|
||||
none
|
||||
</span>
|
||||
) : (
|
||||
card.items.map((item) => (
|
||||
<div key={item.id} className="min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-[var(--cm-fg-secondary)] truncate">
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{item.sub}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span>derived from stream data</span>
|
||||
<span>read-only snapshot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import {
|
||||
getMyMeshStreamResponseSchema,
|
||||
type GetMyMeshStreamResponse,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
type: "audit" | "presence" | "envelope";
|
||||
icon: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
actor: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Build timeline from stream data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
peer_connected: "connected",
|
||||
peer_disconnected: "disconnected",
|
||||
message_sent: "msg sent",
|
||||
message_delivered: "msg delivered",
|
||||
invite_created: "invite created",
|
||||
invite_redeemed: "invite redeemed",
|
||||
member_joined: "member joined",
|
||||
member_removed: "member removed",
|
||||
state_changed: "state changed",
|
||||
};
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
peer_connected: "↑",
|
||||
peer_disconnected: "↓",
|
||||
message_sent: "→",
|
||||
message_delivered: "✓",
|
||||
invite_created: "✉",
|
||||
invite_redeemed: "★",
|
||||
member_joined: "+",
|
||||
member_removed: "−",
|
||||
state_changed: "Δ",
|
||||
};
|
||||
|
||||
const buildTimeline = (data: GetMyMeshStreamResponse): TimelineEntry[] => {
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
// Audit events → timeline entries
|
||||
for (const e of data.auditEvents) {
|
||||
entries.push({
|
||||
id: e.id,
|
||||
timestamp: new Date(e.createdAt),
|
||||
type: "audit",
|
||||
icon: EVENT_ICONS[e.eventType] ?? "•",
|
||||
label: EVENT_LABELS[e.eventType] ?? e.eventType.replace(/_/g, " "),
|
||||
detail: [
|
||||
e.actorPeerId ? `actor:${e.actorPeerId.slice(0, 8)}` : null,
|
||||
e.targetPeerId ? `target:${e.targetPeerId.slice(0, 8)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" → ") || "—",
|
||||
actor: e.actorPeerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Presence status snapshots → timeline entries (latest status per peer)
|
||||
for (const p of data.presences) {
|
||||
entries.push({
|
||||
id: `presence-${p.id}`,
|
||||
timestamp: new Date(p.statusUpdatedAt),
|
||||
type: "presence",
|
||||
icon: p.status === "idle" ? "◇" : p.status === "working" ? "◆" : "◈",
|
||||
label: `${p.displayName ?? p.memberId.slice(0, 8)} → ${p.status}`,
|
||||
detail: `via ${p.statusSource} · pid ${p.pid}`,
|
||||
actor: p.memberId,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending (newest first)
|
||||
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Format helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const fmtTime = (d: Date) =>
|
||||
d.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const TYPE_COLORS: Record<TimelineEntry["type"], string> = {
|
||||
audit: "text-[var(--cm-clay)]",
|
||||
presence: "text-emerald-500",
|
||||
envelope: "text-[#c46686]",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const StateTimelinePanel = ({ meshId }: { meshId: string }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, isFetching, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["mesh", "stream", meshId],
|
||||
queryFn: () =>
|
||||
handle(api.my.meshes[":id"].stream.$get, {
|
||||
schema: getMyMeshStreamResponseSchema,
|
||||
})({ param: { id: meshId } }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const entries = useMemo(
|
||||
() => (data ? buildTimeline(data) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const secondsAgo = dataUpdatedAt
|
||||
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
|
||||
: null;
|
||||
|
||||
// Auto-scroll to top (newest) on new data
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [entries.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
"inline-block h-2 w-2 rounded-full " +
|
||||
(isFetching
|
||||
? "bg-[var(--cm-clay)] animate-pulse"
|
||||
: "bg-emerald-500")
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
|
||||
event timeline
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
|
||||
{entries.length} events ·{" "}
|
||||
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[420px] overflow-y-auto scrollbar-thin"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||
No events recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative px-4 py-3">
|
||||
{/* Vertical spine */}
|
||||
<div className="absolute left-[27px] top-3 bottom-3 w-px bg-[var(--cm-border)]" />
|
||||
|
||||
{entries.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="group relative flex items-start gap-3 py-1.5"
|
||||
>
|
||||
{/* Node dot */}
|
||||
<div className="relative z-10 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={`text-[10px] leading-none ${TYPE_COLORS[entry.type]}`}
|
||||
>
|
||||
{entry.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[10px] text-[var(--cm-fg-tertiary)] tabular-nums">
|
||||
{fmtTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${TYPE_COLORS[entry.type]}`}>
|
||||
{entry.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-[var(--cm-fg-tertiary)] truncate">
|
||||
{entry.detail}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<span className="flex-shrink-0 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[8px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer legend */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[var(--cm-clay)]">•</span>
|
||||
audit
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-emerald-500">•</span>
|
||||
presence
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[#c46686]">•</span>
|
||||
envelope
|
||||
</span>
|
||||
<span className="mx-1 text-[var(--cm-border)]">|</span>
|
||||
<span>newest first · auto-scroll</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||