From 7e102a235b03280302008846cb2884e5fa98d1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:53:46 +0100 Subject: [PATCH] feat: add @claudemesh/sdk standalone client library Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/README.md | 100 +++++++ packages/sdk/package.json | 19 ++ packages/sdk/src/client.ts | 564 +++++++++++++++++++++++++++++++++++++ packages/sdk/src/crypto.ts | 136 +++++++++ packages/sdk/src/index.ts | 9 + packages/sdk/src/types.ts | 64 +++++ packages/sdk/tsconfig.json | 13 + 7 files changed, 905 insertions(+) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/client.ts create mode 100644 packages/sdk/src/crypto.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/tsconfig.json diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..af1957b --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,100 @@ +# @claudemesh/sdk + +Lightweight TypeScript SDK for connecting any process to a claudemesh mesh. Handles WebSocket connections, ed25519 authentication, crypto_box encryption, and auto-reconnect. + +## Installation + +```bash +pnpm add @claudemesh/sdk +``` + +## Usage + +```typescript +import { MeshClient, generateKeyPair } from "@claudemesh/sdk"; + +const keys = generateKeyPair(); +const client = new MeshClient({ + brokerUrl: "wss://ic.claudemesh.com/ws", + meshId: "your-mesh-id", + memberId: "your-member-id", + pubkey: keys.publicKey, + secretKey: keys.secretKey, + displayName: "My Bot", + peerType: "connector", + channel: "custom", +}); + +await client.connect(); + +// Listen for messages +client.on("message", (msg) => { + console.log(`From ${msg.senderPubkey}: ${msg.plaintext}`); +}); + +// Listen for peer events +client.on("peer_joined", (peer) => { + console.log(`${peer.displayName} joined`); +}); + +client.on("peer_left", (peer) => { + console.log(`${peer.displayName} left`); +}); + +// Send a message (by display name or pubkey) +await client.send("Alice", "Hello from SDK!"); + +// Broadcast to all peers +await client.broadcast("Hello everyone!"); + +// List connected peers +const peers = await client.listPeers(); + +// Shared state +await client.setState("build_status", "passing"); +const value = await client.getState("build_status"); + +// Clean up +client.disconnect(); +``` + +## API + +### `generateKeyPair()` + +Returns `Promise<{ publicKey: string; secretKey: string }>` -- an ed25519 keypair with hex-encoded keys. + +### `new MeshClient(opts)` + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `brokerUrl` | `string` | yes | WebSocket URL of the broker | +| `meshId` | `string` | yes | Mesh to join | +| `memberId` | `string` | yes | Your member ID within the mesh | +| `pubkey` | `string` | yes | Ed25519 public key (hex) | +| `secretKey` | `string` | yes | Ed25519 secret key (hex) | +| `displayName` | `string` | no | Name visible to other peers | +| `peerType` | `"ai" \| "human" \| "connector"` | no | Defaults to `"connector"` | +| `channel` | `string` | no | Channel identifier | +| `debug` | `boolean` | no | Log debug info to stderr | + +### Methods + +- `connect(): Promise` -- Open connection and authenticate +- `disconnect(): void` -- Close connection +- `send(to, message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to peer name, pubkey, `*`, or `@group` +- `broadcast(message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to all peers +- `listPeers(): Promise` -- List connected peers +- `getState(key): Promise` -- Read shared state +- `setState(key, value): Promise` -- Write shared state +- `setSummary(summary): Promise` -- Set session summary +- `setStatus(status): Promise` -- Set status (`idle`, `working`, `dnd`) + +### Events + +- `"message"` -- Inbound message received +- `"connected"` -- WebSocket authenticated +- `"disconnected"` -- WebSocket closed +- `"peer_joined"` -- A peer connected to the mesh +- `"peer_left"` -- A peer disconnected +- `"state_change"` -- Shared state was updated by a peer diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000..dc16a76 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,19 @@ +{ + "name": "@claudemesh/sdk", + "version": "0.1.0", + "description": "SDK for connecting any process to a claudemesh mesh", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "dependencies": { + "libsodium-wrappers": "0.7.15", + "ws": "8.20.0" + }, + "devDependencies": { + "@types/ws": "8.5.13", + "typescript": "catalog:" + } +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 0000000..dc978dc --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,564 @@ +/** + * MeshClient -- lightweight WebSocket client for connecting any process + * to a claudemesh mesh. Handles: + * - hello handshake + ack + * - send / ack / push message flow + * - auto-reconnect with exponential backoff + * - crypto_box encryption for direct messages + * - EventEmitter interface for messages, connection, and peer events + */ + +import { EventEmitter } from "node:events"; +import { randomBytes } from "node:crypto"; +import WebSocket from "ws"; +import { + signHello, + generateKeyPair, + encryptDirect, + decryptDirect, + isDirectTarget, +} from "./crypto.js"; +import type { + MeshClientOptions, + PeerInfo, + InboundMessage, + Priority, + ConnStatus, +} from "./types.js"; + +interface PendingSend { + id: string; + targetSpec: string; + priority: Priority; + nonce: string; + ciphertext: string; + resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void; +} + +const MAX_QUEUED = 100; +const HELLO_ACK_TIMEOUT_MS = 5_000; +const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + +export interface MeshClientEvents { + message: [msg: InboundMessage]; + connected: []; + disconnected: []; + peer_joined: [peer: PeerInfo]; + peer_left: [peer: PeerInfo]; + state_change: [change: { key: string; value: unknown; updatedBy: string }]; +} + +export class MeshClient extends EventEmitter { + private ws: WebSocket | null = null; + private _status: ConnStatus = "closed"; + private pendingSends = new Map(); + private outbound: Array<() => void> = []; + private closed = false; + private reconnectAttempt = 0; + private helloTimer: NodeJS.Timeout | null = null; + private reconnectTimer: NodeJS.Timeout | null = null; + + // Session keypair (generated on first connect, reused across reconnects) + private sessionPubkey: string | null = null; + private sessionSecretKey: string | null = null; + + // Request-response resolvers + private listPeersResolvers = new Map< + string, + { resolve: (peers: PeerInfo[]) => void; timer: NodeJS.Timeout } + >(); + private stateResolvers = new Map< + string, + { + resolve: ( + result: { + key: string; + value: unknown; + updatedBy: string; + updatedAt: string; + } | null, + ) => void; + timer: NodeJS.Timeout; + } + >(); + + constructor(private opts: MeshClientOptions) { + super(); + } + + /** Current connection status. */ + get status(): ConnStatus { + return this._status; + } + + /** Session public key hex (null before first connect). */ + get pubkey(): string | null { + return this.sessionPubkey; + } + + /** Open the WebSocket, send hello, resolve when hello_ack received. */ + async connect(): Promise { + if (this.closed) throw new Error("client is closed"); + this._status = "connecting"; + const ws = new WebSocket(this.opts.brokerUrl); + this.ws = ws; + + return new Promise((resolve, reject) => { + const onOpen = async (): Promise => { + this.debug("ws open -> generating session keypair + signing hello"); + try { + if (!this.sessionPubkey) { + const sessionKP = await generateKeyPair(); + this.sessionPubkey = sessionKP.publicKey; + this.sessionSecretKey = sessionKP.secretKey; + } + + const { timestamp, signature } = await signHello( + this.opts.meshId, + this.opts.memberId, + this.opts.pubkey, + this.opts.secretKey, + ); + ws.send( + JSON.stringify({ + type: "hello", + meshId: this.opts.meshId, + memberId: this.opts.memberId, + pubkey: this.opts.pubkey, + sessionPubkey: this.sessionPubkey, + displayName: this.opts.displayName, + sessionId: `sdk-${process.pid}-${Date.now()}`, + pid: process.pid, + peerType: this.opts.peerType ?? "connector", + channel: this.opts.channel ?? "sdk", + timestamp, + signature, + }), + ); + } catch (e) { + reject( + new Error( + `hello sign failed: ${e instanceof Error ? e.message : e}`, + ), + ); + return; + } + this.helloTimer = setTimeout(() => { + this.debug("hello_ack timeout"); + ws.close(); + reject(new Error("hello_ack timeout")); + }, HELLO_ACK_TIMEOUT_MS); + }; + + const onMessage = (raw: WebSocket.RawData): void => { + let msg: Record; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + if (msg.type === "hello_ack") { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + this._status = "open"; + this.reconnectAttempt = 0; + this.flushOutbound(); + this.emit("connected"); + resolve(); + return; + } + this.handleServerMessage(msg); + }; + + const onClose = (): void => { + if (this.helloTimer) clearTimeout(this.helloTimer); + this.helloTimer = null; + const wasOpen = this._status === "open" || this._status === "reconnecting"; + this.ws = null; + if (!wasOpen && this._status === "connecting") { + reject(new Error("ws closed before hello_ack")); + } + if (!this.closed) { + this.emit("disconnected"); + this.scheduleReconnect(); + } else { + this._status = "closed"; + this.emit("disconnected"); + } + }; + + const onError = (err: Error): void => { + this.debug(`ws error: ${err.message}`); + }; + + ws.on("open", onOpen); + ws.on("message", onMessage); + ws.on("close", onClose); + ws.on("error", onError); + }); + } + + /** Gracefully close the connection. */ + disconnect(): void { + this.closed = true; + if (this.helloTimer) clearTimeout(this.helloTimer); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.ws) { + try { + this.ws.close(); + } catch { + /* ignore */ + } + } + this._status = "closed"; + } + + // --- Messaging --- + + /** + * Send a message to a peer. `to` can be: + * - A hex pubkey (64 chars) for encrypted direct message + * - A display name (resolved via listPeers) + * - "*" for broadcast + * - "@groupname" for group message + */ + async send( + to: string, + message: string, + priority: Priority = "next", + ): Promise<{ ok: boolean; messageId?: string; error?: string }> { + // Resolve display name to pubkey for direct encryption + let targetSpec = to; + if (!isDirectTarget(to) && to !== "*" && !to.startsWith("@") && !to.startsWith("#")) { + const peers = await this.listPeers(); + const match = peers.find( + (p) => p.displayName.toLowerCase() === to.toLowerCase(), + ); + if (match) { + targetSpec = match.pubkey; + } + // If no match found, send as-is and let the broker resolve + } + + const id = randomBytes(8).toString("hex"); + let nonce: string; + let ciphertext: string; + + if (isDirectTarget(targetSpec)) { + const env = await encryptDirect( + message, + targetSpec, + this.sessionSecretKey ?? this.opts.secretKey, + ); + nonce = env.nonce; + ciphertext = env.ciphertext; + } else { + nonce = randomBytes(24).toString("base64"); + ciphertext = Buffer.from(message, "utf-8").toString("base64"); + } + + return new Promise((resolve) => { + if (this.pendingSends.size >= MAX_QUEUED) { + resolve({ ok: false, error: "outbound queue full" }); + return; + } + this.pendingSends.set(id, { + id, + targetSpec, + priority, + nonce, + ciphertext, + resolve, + }); + const dispatch = (): void => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send( + JSON.stringify({ + type: "send", + id, + targetSpec, + priority, + nonce, + ciphertext, + }), + ); + }; + if (this._status === "open") dispatch(); + else { + if (this.outbound.length >= MAX_QUEUED) { + this.pendingSends.delete(id); + resolve({ ok: false, error: "outbound queue full" }); + return; + } + this.outbound.push(dispatch); + } + setTimeout(() => { + if (this.pendingSends.has(id)) { + this.pendingSends.delete(id); + resolve({ ok: false, error: "ack timeout" }); + } + }, 10_000); + }); + } + + /** Broadcast a message to all peers in the mesh. */ + async broadcast( + message: string, + priority: Priority = "next", + ): Promise<{ ok: boolean; messageId?: string; error?: string }> { + return this.send("*", message, priority); + } + + // --- Peers --- + + /** Request the list of connected peers from the broker. */ + async listPeers(): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return []; + return new Promise((resolve) => { + const reqId = this.makeReqId(); + this.listPeersResolvers.set(reqId, { + resolve, + timer: setTimeout(() => { + if (this.listPeersResolvers.delete(reqId)) resolve([]); + }, 5_000), + }); + this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId })); + }); + } + + // --- State --- + + /** Read a shared state value. */ + async getState( + key: string, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return null; + return new Promise((resolve) => { + const reqId = this.makeReqId(); + this.stateResolvers.set(reqId, { + resolve: (result) => resolve(result ? String(result.value) : null), + timer: setTimeout(() => { + if (this.stateResolvers.delete(reqId)) resolve(null); + }, 5_000), + }); + this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId })); + }); + } + + /** Set a shared state value visible to all peers. */ + async setState(key: string, value: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type: "set_state", key, value })); + } + + // --- Summary / Status --- + + /** Update this session's summary visible to other peers. */ + async setSummary(summary: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type: "set_summary", summary })); + } + + /** Override connection status visible to peers. */ + async setStatus(status: "idle" | "working" | "dnd"): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ type: "set_status", status })); + } + + // --- Internals --- + + private makeReqId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); + } + + private flushOutbound(): void { + const queued = this.outbound.slice(); + this.outbound.length = 0; + for (const send of queued) send(); + } + + private scheduleReconnect(): void { + this._status = "reconnecting"; + const delay = + BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!; + this.reconnectAttempt += 1; + this.debug(`reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => { + if (this.closed) return; + this.connect().catch((e) => { + this.debug( + `reconnect failed: ${e instanceof Error ? e.message : e}`, + ); + }); + }, delay); + } + + private handleServerMessage(msg: Record): void { + const reqId = msg._reqId as string | undefined; + + if (msg.type === "ack") { + const pending = this.pendingSends.get(String(msg.id ?? "")); + if (pending) { + pending.resolve({ + ok: true, + messageId: String(msg.messageId ?? ""), + }); + this.pendingSends.delete(pending.id); + } + return; + } + + if (msg.type === "peers_list") { + const peers = (msg.peers as PeerInfo[]) ?? []; + this.resolveFromMap(this.listPeersResolvers, reqId, peers); + return; + } + + if (msg.type === "push") { + void this.handlePush(msg); + return; + } + + if (msg.type === "state_result") { + if (msg.key) { + this.resolveFromMap(this.stateResolvers, reqId, { + key: String(msg.key), + value: msg.value, + updatedBy: String(msg.updatedBy ?? ""), + updatedAt: String(msg.updatedAt ?? ""), + }); + } else { + this.resolveFromMap(this.stateResolvers, reqId, null); + } + return; + } + + if (msg.type === "state_change") { + this.emit("state_change", { + key: String(msg.key ?? ""), + value: msg.value, + updatedBy: String(msg.updatedBy ?? ""), + }); + return; + } + + if (msg.type === "error") { + this.debug(`broker error: ${msg.code} ${msg.message}`); + const id = msg.id ? String(msg.id) : null; + if (id) { + const pending = this.pendingSends.get(id); + if (pending) { + pending.resolve({ + ok: false, + error: `${msg.code}: ${msg.message}`, + }); + this.pendingSends.delete(id); + } + } + return; + } + } + + private async handlePush(msg: Record): Promise { + const nonce = String(msg.nonce ?? ""); + const ciphertext = String(msg.ciphertext ?? ""); + const senderPubkey = String(msg.senderPubkey ?? ""); + + const kind: InboundMessage["kind"] = senderPubkey ? "direct" : "unknown"; + let plaintext: string | null = null; + + // Try crypto_box decryption for direct messages + if (senderPubkey && nonce && ciphertext) { + plaintext = await decryptDirect( + { nonce, ciphertext }, + senderPubkey, + this.sessionSecretKey ?? this.opts.secretKey, + ); + } + + // Broadcast/channel fallback: base64 UTF-8 decode + if (plaintext === null && ciphertext && !senderPubkey) { + try { + plaintext = Buffer.from(ciphertext, "base64").toString("utf-8"); + } catch { + plaintext = null; + } + } + + // Last resort: try base64 decode even for direct (handles broadcasts + // and key mismatches gracefully) + if (plaintext === null && ciphertext) { + try { + const decoded = Buffer.from(ciphertext, "base64").toString("utf-8"); + if ( + /^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) && + decoded.length > 0 + ) { + plaintext = decoded; + } + } catch { + plaintext = null; + } + } + + const push: InboundMessage = { + messageId: String(msg.messageId ?? ""), + meshId: String(msg.meshId ?? ""), + senderPubkey, + priority: (msg.priority as Priority) ?? "next", + nonce, + ciphertext, + createdAt: String(msg.createdAt ?? ""), + receivedAt: new Date().toISOString(), + plaintext, + kind, + ...(msg.subtype + ? { subtype: msg.subtype as "reminder" | "system" } + : {}), + ...(msg.event ? { event: String(msg.event) } : {}), + ...(msg.eventData + ? { eventData: msg.eventData as Record } + : {}), + }; + + this.emit("message", push); + + // Emit peer_joined / peer_left convenience events + if (push.event === "peer_joined" && push.eventData) { + this.emit("peer_joined", push.eventData as unknown as PeerInfo); + } + if (push.event === "peer_left" && push.eventData) { + this.emit("peer_left", push.eventData as unknown as PeerInfo); + } + } + + private resolveFromMap( + map: Map void; timer: NodeJS.Timeout }>, + reqId: string | undefined, + value: T, + ): boolean { + let entry = reqId ? map.get(reqId) : undefined; + if (!entry) { + // Fallback: oldest pending (FIFO, for brokers that don't echo _reqId) + const first = map.entries().next().value as + | [string, { resolve: (v: T) => void; timer: NodeJS.Timeout }] + | undefined; + if (first) { + entry = first[1]; + map.delete(first[0]); + } + } else { + map.delete(reqId!); + } + if (entry) { + clearTimeout(entry.timer); + entry.resolve(value); + return true; + } + return false; + } + + private debug(msg: string): void { + if (this.opts.debug) console.error(`[claudemesh-sdk] ${msg}`); + } +} diff --git a/packages/sdk/src/crypto.ts b/packages/sdk/src/crypto.ts new file mode 100644 index 0000000..863ae2b --- /dev/null +++ b/packages/sdk/src/crypto.ts @@ -0,0 +1,136 @@ +/** + * Cryptographic primitives for the claudemesh SDK. + * + * Uses libsodium-wrappers for ed25519 keypair generation, hello signing, + * and crypto_box direct-message encryption. This matches the CLI's crypto + * implementation exactly, ensuring wire-level compatibility. + */ + +import sodium from "libsodium-wrappers"; + +let ready = false; + +async function ensureSodium(): Promise { + if (!ready) { + await sodium.ready; + ready = true; + } + return sodium; +} + +/** An ed25519 keypair with hex-encoded keys. */ +export interface Ed25519Keypair { + /** 32-byte public key, hex-encoded. */ + publicKey: string; + /** 64-byte secret key (seed || publicKey), hex-encoded. */ + secretKey: string; +} + +/** Generate a fresh ed25519 keypair for use as mesh identity. */ +export async function generateKeyPair(): Promise { + const s = await ensureSodium(); + const kp = s.crypto_sign_keypair(); + return { + publicKey: s.to_hex(kp.publicKey), + secretKey: s.to_hex(kp.privateKey), + }; +} + +/** + * Sign a hello handshake message. + * + * Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}` + * Must match the broker's `canonicalHello()` exactly. + */ +export async function signHello( + meshId: string, + memberId: string, + pubkey: string, + secretKeyHex: string, +): Promise<{ timestamp: number; signature: 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) }; +} + +/** Encrypted envelope wire format. */ +export interface Envelope { + nonce: string; // base64 + ciphertext: string; // base64 +} + +const HEX_PUBKEY = /^[0-9a-f]{64}$/; + +/** Check whether a targetSpec is a hex pubkey (direct message target). */ +export function isDirectTarget(targetSpec: string): boolean { + return HEX_PUBKEY.test(targetSpec); +} + +/** + * Encrypt a plaintext message for a single recipient using crypto_box. + * + * Ed25519 keys are converted to X25519 on the fly for Diffie-Hellman. + */ +export async function encryptDirect( + message: string, + recipientPubkeyHex: string, + senderSecretKeyHex: string, +): Promise { + const s = await ensureSodium(); + const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519( + s.from_hex(recipientPubkeyHex), + ); + const senderSec = s.crypto_sign_ed25519_sk_to_curve25519( + s.from_hex(senderSecretKeyHex), + ); + const nonce = s.randombytes_buf(s.crypto_box_NONCEBYTES); + const ciphertext = s.crypto_box_easy( + s.from_string(message), + nonce, + recipientPub, + senderSec, + ); + return { + nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL), + ciphertext: s.to_base64(ciphertext, s.base64_variants.ORIGINAL), + }; +} + +/** + * Decrypt an inbound envelope from a known sender using crypto_box_open. + * Returns null if decryption fails. + */ +export async function decryptDirect( + envelope: Envelope, + senderPubkeyHex: string, + recipientSecretKeyHex: string, +): Promise { + 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 nonce = s.from_base64(envelope.nonce, s.base64_variants.ORIGINAL); + const ciphertext = s.from_base64( + envelope.ciphertext, + s.base64_variants.ORIGINAL, + ); + const plain = s.crypto_box_open_easy( + ciphertext, + nonce, + senderPub, + recipientSec, + ); + return s.to_string(plain); + } catch { + return null; + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000..8e08c7b --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,9 @@ +export { MeshClient } from "./client.js"; +export { generateKeyPair } from "./crypto.js"; +export type { + PeerInfo, + InboundMessage, + Priority, + ConnStatus, + MeshClientOptions, +} from "./types.js"; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000..edea625 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,64 @@ +/** Priority levels for message delivery. */ +export type Priority = "now" | "next" | "low"; + +/** Connection status of the client. */ +export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting"; + +/** Information about a connected peer. */ +export interface PeerInfo { + pubkey: string; + displayName: string; + status: string; + summary: string | null; + groups: Array<{ name: string; role?: string }>; + sessionId: string; + connectedAt: string; + cwd?: string; + peerType?: "ai" | "human" | "connector"; + channel?: string; + model?: string; +} + +/** An inbound message received from the broker. */ +export interface InboundMessage { + messageId: string; + meshId: string; + senderPubkey: string; + priority: Priority; + nonce: string; + ciphertext: string; + createdAt: string; + receivedAt: string; + /** Decrypted plaintext. null if decryption failed or broadcast. */ + plaintext: string | null; + /** Message kind: "direct" (crypto_box), "broadcast", "channel", or "unknown". */ + kind: "direct" | "broadcast" | "channel" | "unknown"; + /** Optional semantic tag. */ + subtype?: "reminder" | "system"; + /** Machine-readable event name (e.g. "peer_joined", "peer_left"). */ + event?: string; + /** Structured payload for the event. */ + eventData?: Record; +} + +/** Options for constructing a MeshClient. */ +export interface MeshClientOptions { + /** WebSocket URL of the broker (e.g. "wss://ic.claudemesh.com/ws"). */ + brokerUrl: string; + /** Mesh ID to join. */ + meshId: string; + /** Member ID within the mesh. */ + memberId: string; + /** Ed25519 public key (hex). Used for signing the hello handshake. */ + pubkey: string; + /** Ed25519 secret key (hex). Used for signing and encryption. */ + secretKey: string; + /** Display name visible to other peers. */ + displayName?: string; + /** Peer type: "ai", "human", or "connector". Defaults to "connector". */ + peerType?: "ai" | "human" | "connector"; + /** Channel identifier (e.g. "claude-code", "custom"). */ + channel?: string; + /** Enable debug logging to stderr. */ + debug?: boolean; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000..0d486e6 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +}