refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

5
apps/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.turbo/
.cache/
*.log

44
apps/cli/CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Changelog
## 1.0.0-alpha.0 (2026-04-13)
### Architecture
- Complete folder restructure: `entrypoints/`, `cli/`, `commands/`, `services/` (17 feature-folders with facade pattern), `ui/`, `mcp/`, `constants/`, `types/`, `utils/`, `locales/`, `templates/`
- 212 source files, 10,900 lines
- ESM-only, Bun bundler, TypeScript strict mode
### New CLI commands
- `claudemesh register` — account creation via browser handoff
- `claudemesh login` — device-code OAuth
- `claudemesh logout` — revoke session + clear credentials
- `claudemesh whoami` — identity check with `--json` support
- `claudemesh new <name>` — create mesh from CLI (was dashboard-only)
- `claudemesh invite [email]` — generate invite from CLI (was dashboard-only)
### Ported from v1 (full feature parity)
- All 79 MCP tools
- All 85 WS message types (broker protocol unchanged)
- Welcome wizard, launch flow, install/uninstall
- Ed25519 + NaCl crypto (keypairs, crypto_box DMs, file encryption)
- Reconnect with exponential backoff
- Status priority engine, scheduled messages, URL watch
- Doctor checks, Telegram bridge connect wizard
### Security hardening (25 bugs fixed across 4 reviews)
- `execFile` instead of `exec` for browser open (command injection fix)
- ReDoS-safe pattern matching in peer file sharing
- Atomic config writes via temp file + rename
- Auth token stored with `openSync(mode: 0o600)` — no permission race
- Decryption oracle collapsed to generic error in `get_file`
- Download size limit (100MB) on file retrieval
- Path traversal protection with `realpathSync` for symlink escapes
- Callback listener double-resolve guard
- Push buffer 1MB per-message truncation
- `makeReqId` uses `crypto.randomBytes` instead of `Math.random`
- Connect guard prevents double-connect race
### Breaking changes from v0.10.x
- Flat command namespace (no `launch` subcommand, no `advanced` prefix)
- New config shape (same data, cleaner layout)
- New `--json` output format with `schema_version: "1.0"`
- New exit codes (see `constants/exit-codes.ts`)

View File

@@ -1,83 +1,90 @@
# claudemesh-cli
Client tool for claudemesh — install once per machine, join one or more
meshes, and your Claude Code sessions can talk to peers on demand.
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
## Install
```sh
# From npm (once published)
npm install -g claudemesh-cli
# Or from the monorepo during dev
cd apps/cli && bun link
```bash
npm i -g claudemesh-cli
```
Then register the MCP server with Claude Code:
## Quick start
```sh
claudemesh install
# prints: claude mcp add claudemesh --scope user -- claudemesh mcp
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
Run the printed command, then restart Claude Code.
## Join a mesh
```sh
claudemesh join https://claudemesh.com/join/<token>
```
## Launch Claude Code
For real-time **push messages** from peers (messages injected mid-turn
as `<channel source="claudemesh">` system reminders), launch with:
```sh
claudemesh launch
# or pass through any claude flags:
claudemesh launch --model opus
claudemesh launch --resume
```
Under the hood this runs:
```sh
claude --dangerously-load-development-channels server:claudemesh
```
Plain `claude` still works — the MCP tools are available — but incoming
messages are **pull-only** via the `check_messages` tool instead of
being pushed to Claude immediately.
The invite link is generated by whoever runs the mesh. It bundles the
mesh id, expiry, signing key, and role. Your CLI verifies it,
generates a fresh keypair, enrolls you with the broker, and persists
the result to `~/.claudemesh/config.json`.
## Commands
```sh
claudemesh install # register MCP + status hooks
claudemesh uninstall # remove MCP + status hooks
claudemesh launch [args] # launch Claude Code with push messages enabled
claudemesh join <url> # join a mesh via invite URL
claudemesh list # show joined meshes + identities
claudemesh leave <slug> # leave a mesh
claudemesh mcp # start MCP server (stdio — Claude Code only)
claudemesh --help # show usage
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Env overrides
## Architecture
| Var | Default | Purpose |
| ----------------------- | ---------------------------- | ------------------------------ |
| `CLAUDEMESH_BROKER_URL` | `wss://ic.claudemesh.com/ws` | Point at a self-hosted broker |
| `CLAUDEMESH_CONFIG_DIR` | `~/.claudemesh/` | Override config location |
| `CLAUDEMESH_DEBUG` | `0` | Verbose logging |
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── api/ typed HTTP client for claudemesh.com
│ ├── health/ 6 diagnostic checks
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
├── mcp/ MCP server with 79 tools across 21 families
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Status
## Development
v0.1.0 scaffold — CLI commands + MCP server shell in place. WS broker
connection, libsodium crypto, invite-link verification, and auto-install
of hooks land in subsequent steps.
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

2
apps/cli/bin/claudemesh Normal file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/entrypoints/cli.js";

4
apps/cli/biome.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["../../biome.json"]
}

59
apps/cli/build.ts Normal file
View File

@@ -0,0 +1,59 @@
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
const MAX_GZIPPED_BYTES = 1.2 * 1024 * 1024; // 1.2 MB
// Inject the version from package.json at build time so VERSION can never
// drift from what's published. Bun's `define` is a source-text replacement,
// equivalent to `--define` in esbuild / a webpack DefinePlugin.
const pkgVersion = ((await Bun.file("package.json").json()) as { version: string }).version;
const result = await Bun.build({
entrypoints: [
"src/entrypoints/cli.ts",
"src/entrypoints/mcp.ts",
],
outdir: "dist/entrypoints",
target: "node",
format: "esm",
splitting: false,
sourcemap: "external",
define: {
__CLAUDEMESH_VERSION__: JSON.stringify(pkgVersion),
},
external: [
"libsodium-wrappers",
"ws",
"@modelcontextprotocol/sdk",
],
});
if (!result.success) {
console.error("Build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
for (const output of result.outputs) {
const raw = statSync(output.path).size;
const gz = gzipSync(await Bun.file(output.path).arrayBuffer()).byteLength;
const label = output.path.replace(process.cwd() + "/", "");
console.log(` ${label} ${(raw / 1024).toFixed(0)} KB (${(gz / 1024).toFixed(0)} KB gzipped)`);
if (gz > MAX_GZIPPED_BYTES) {
console.error(`\n ERROR: ${label} exceeds 1.2 MB gzipped ceiling (${(gz / 1024).toFixed(0)} KB)`);
process.exit(1);
}
}
const { chmodSync, readFileSync, writeFileSync } = await import("node:fs");
const cliPath = "dist/entrypoints/cli.js";
const cliContent = readFileSync(cliPath, "utf-8");
if (!cliContent.startsWith("#!")) {
writeFileSync(cliPath, "#!/usr/bin/env node\n" + cliContent);
}
chmodSync(cliPath, 0o755);
console.log("\nBuild complete.");

View File

@@ -1,3 +0,0 @@
import baseConfig from "@turbostarter/eslint-config/base";
export default baseConfig;

View File

@@ -1,7 +1,7 @@
{
"name": "claudemesh-cli",
"version": "0.10.6",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"version": "1.0.0-alpha.31",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
"mcp",
@@ -20,7 +20,7 @@
},
"type": "module",
"bin": {
"claudemesh": "./dist/index.js"
"claudemesh": "./dist/entrypoints/cli.js"
},
"files": [
"dist",
@@ -31,10 +31,10 @@
"access": "public"
},
"scripts": {
"build": "bun build src/index.ts --target=node --outfile dist/index.js --banner \"#!/usr/bin/env node\" && chmod +x dist/index.js",
"build": "bun build.ts",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/index.ts",
"start": "bun src/index.ts",
"dev": "bun --hot src/entrypoints/cli.ts",
"start": "bun src/entrypoints/cli.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
@@ -49,6 +49,7 @@
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"qrcode-terminal": "0.12.0",
"ws": "8.20.0",
"zod": "4.1.13"
},
@@ -58,6 +59,7 @@
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/qrcode-terminal": "0.12.2",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",

View File

@@ -0,0 +1,49 @@
/**
* Cross-platform single-binary compile.
*
* Run: bun run scripts/build-binaries.ts
* Output: dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}{.exe}
*
* Each binary bundles the CLI + Bun runtime, no Node required.
* Current caveat: native deps like libsodium-wrappers ship as JS+wasm
* so they work. `ws` falls back to its JS polyfill when uws isn't present.
*
* Intended for CI — GitHub Releases publish → install.sh / Homebrew
* pull the right tarball per platform.
*/
import { mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
const TARGETS: Array<{ name: string; target: string; ext: string }> = [
{ name: "darwin-x64", target: "bun-darwin-x64", ext: "" },
{ name: "darwin-arm64", target: "bun-darwin-arm64", ext: "" },
{ name: "linux-x64", target: "bun-linux-x64", ext: "" },
{ name: "linux-arm64", target: "bun-linux-arm64", ext: "" },
{ name: "windows-x64", target: "bun-windows-x64", ext: ".exe" },
];
mkdirSync("dist/bin", { recursive: true });
for (const { name, target, ext } of TARGETS) {
const out = `dist/bin/claudemesh-${name}${ext}`;
console.log(`${out}`);
const res = spawnSync(
"bun",
[
"build",
"--compile",
"--minify",
`--target=${target}`,
"src/entrypoints/cli.ts",
"--outfile",
out,
],
{ stdio: "inherit" },
);
if (res.status !== 0) {
console.error(` failed: ${name}`);
process.exit(1);
}
}
console.log("\nBinaries built in dist/bin/");

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env bun
/**
* Full join → connect → send round-trip.
*
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
* Creates a fresh invite link, runs the join command, connects with
* the newly-generated member identity, sends a message to peer B,
* asserts receipt.
*/
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
// ESM imports hoist above statements, so we can't set process.env
// after the `import { env }` side effect has already run.
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
import { loadConfig, getConfigPath } from "../src/state/config";
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
console.error(
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
);
process.exit(1);
}
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
stdio: "ignore",
});
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerB: { memberId: string; pubkey: string; secretKey: string };
};
async function main(): Promise<void> {
// 1. Build invite.
const link = execSync("bun scripts/make-invite.ts").toString().trim();
console.log("[rt] invite:", link.slice(0, 60) + "…");
// 2. Run `claudemesh join` with the same CONFIG_DIR.
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
},
}).toString();
console.log("[rt] join output (tail):");
console.log(
joinOut
.split("\n")
.slice(-7)
.map((l) => " " + l)
.join("\n"),
);
// 3. Load the fresh config and connect as the new peer.
console.log(`[rt] loading config from: ${getConfigPath()}`);
const config = loadConfig();
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
const joined = config.meshes.find((m) => m.slug === "smoke-test");
if (!joined) throw new Error("smoke-test mesh not found in config");
const joinedMesh: JoinedMesh = joined;
console.log(
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}`,
);
// 4. Connect also as peer-B (the target) so we can observe receipt.
// Uses the real keypair from the seed (needed for crypto_box decrypt).
const targetMesh: JoinedMesh = {
...joinedMesh,
memberId: seed.peerB.memberId,
slug: "rt-join-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
const joiner = new BrokerClient(joinedMesh);
const target = new BrokerClient(targetMesh);
let received = "";
target.onPush((m) => {
received = m.plaintext ?? "";
console.log(`[rt] target got: "${received}"`);
});
await Promise.all([joiner.connect(), target.connect()]);
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
const res = await joiner.send(
seed.peerB.pubkey,
"sent-by-newly-joined-peer",
"now",
);
console.log("[rt] send result:", res);
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
joiner.close();
target.close();
if (!res.ok) {
console.error("✗ FAIL: send did not ack");
process.exit(1);
}
if (received !== "sent-by-newly-joined-peer") {
console.error(`✗ FAIL: receive mismatch: "${received}"`);
process.exit(1);
}
console.log("✓ join → connect → send → receive FLOW PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bun
/**
* Emit the signed invite link produced by the broker's seed-test-mesh.
*
* The seed script (apps/broker/scripts/seed-test-mesh.ts) creates a
* mesh with an owner keypair and a signed invite row, then writes
* both into /tmp/cli-seed.json. We just echo its inviteLink here so
* downstream test scripts can pipe it.
*/
import { readFileSync } from "node:fs";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
inviteLink: string;
};
if (!seed.inviteLink) {
console.error(
"seed missing inviteLink — re-run apps/broker/scripts/seed-test-mesh.ts",
);
process.exit(1);
}
console.log(seed.inviteLink);

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env bun
/**
* End-to-end round-trip: two BrokerClient instances talking via the
* broker. Runs against a live broker + seeded DB.
*
* Reads /tmp/cli-seed.json (output of broker's scripts/seed-test-mesh.ts),
* connects peer A and peer B, sends a message from A to B, waits for
* the push on B, asserts receipt + sender pubkey.
*/
import { readFileSync } from "node:fs";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const meshA: JoinedMesh = {
meshId: seed.meshId,
memberId: seed.peerA.memberId,
slug: "rt-a",
name: "roundtrip-a",
pubkey: seed.peerA.pubkey,
secretKey: seed.peerA.secretKey,
brokerUrl,
joinedAt: new Date().toISOString(),
};
const meshB: JoinedMesh = {
...meshA,
memberId: seed.peerB.memberId,
slug: "rt-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
async function main(): Promise<void> {
const a = new BrokerClient(meshA, { debug: true });
const b = new BrokerClient(meshB, { debug: true });
let received: string | null = null;
let receivedSender: string | null = null;
b.onPush((msg) => {
received = msg.plaintext;
receivedSender = msg.senderPubkey;
console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}`);
});
console.log("[rt] connecting A + B…");
await Promise.all([a.connect(), b.connect()]);
console.log(`[rt] A: ${a.status}, B: ${b.status}`);
console.log("[rt] A → B …");
const result = await a.send(seed.peerB.pubkey, "hello from A", "now");
console.log("[rt] send result:", result);
// Wait up to 3s for the push to land.
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
a.close();
b.close();
if (!received) {
console.error("✗ FAIL: no push received");
process.exit(1);
}
if (received !== "hello from A") {
console.error(`✗ FAIL: body mismatch: "${received}"`);
process.exit(1);
}
if (receivedSender !== seed.peerA.pubkey) {
console.error(`✗ FAIL: sender mismatch: "${receivedSender}"`);
process.exit(1);
}
console.log("✓ round-trip PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
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";

View File

@@ -1,33 +0,0 @@
/**
* 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));
});
}

View File

@@ -1,17 +0,0 @@
/**
* 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("");
}

View File

@@ -1,83 +0,0 @@
/**
* 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(/\/$/, "");
}

30
apps/cli/src/cli/argv.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineCommand, runMain } from "citty";
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
export function parseArgv(argv: string[]): ParsedArgs {
const args = argv.slice(2);
const flags: Record<string, string | boolean | undefined> = {};
const positionals: string[] = [];
let command = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (arg.startsWith("-") && arg.length === 2) {
const key = arg.slice(1);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (!command) {
command = arg;
} else {
positionals.push(arg);
}
}
return { command, positionals, flags };
}
export { defineCommand, runMain };

7
apps/cli/src/cli/exit.ts Normal file
View File

@@ -0,0 +1,7 @@
import { EXIT } from "~/constants/exit-codes.js";
const cleanupHooks: Array<() => void> = [];
export function onExit(fn: () => void): void { cleanupHooks.push(fn); }
export function exit(code: number = EXIT.SUCCESS): never {
for (const fn of cleanupHooks) { try { fn(); } catch {} }
process.exit(code);
}

View File

@@ -0,0 +1,12 @@
import { EXIT } from "~/constants/exit-codes.js";
import { red } from "~/ui/styles.js";
export function handleUncaughtError(err: unknown): never {
const msg = err instanceof Error ? err.message : String(err);
console.error(red("\n Fatal: " + msg + "\n"));
if (process.env.CLAUDEMESH_DEBUG === "1" && err instanceof Error && err.stack) console.error(err.stack);
process.exit(EXIT.INTERNAL_ERROR);
}
export function installErrorHandlers(): void {
process.on("uncaughtException", handleUncaughtError);
process.on("unhandledRejection", (reason) => handleUncaughtError(reason));
}

View File

@@ -0,0 +1,6 @@
import { SHOW_CURSOR } from "~/ui/styles.js";
export function installSignalHandlers(): void {
const cleanup = () => { process.stdout.write(SHOW_CURSOR); };
process.on("SIGINT", () => { cleanup(); process.exit(1); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
}

View File

@@ -0,0 +1,6 @@
import type { JoinedMesh } from "~/services/config/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderMeshList(meshes: JoinedMesh[]): string {
if (meshes.length === 0) return " No meshes joined.";
return meshes.map((m, i) => " " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.meshId.slice(0, 8) + "\u2026)")).join("\n");
}

View File

@@ -0,0 +1,11 @@
import type { PeerInfo } from "~/services/broker/facade.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
const S: Record<string, (s: string) => string> = { idle: green, working: yellow, dnd: red };
export function renderPeers(peers: PeerInfo[], meshSlug: string): string {
if (peers.length === 0) return " No peers online in " + meshSlug + ".";
return peers.map(p => {
const icon = (S[p.status] ?? dim)("\u25CF");
const summary = p.summary ? dim(" \u2014 " + p.summary) : "";
return " " + icon + " " + bold(p.displayName) + summary;
}).join("\n");
}

View File

@@ -0,0 +1,3 @@
import { VERSION } from "~/constants/urls.js";
import { boldOrange } from "~/ui/styles.js";
export function renderVersion(): string { return " " + boldOrange("claudemesh") + " v" + VERSION; }

View File

@@ -0,0 +1,11 @@
import type { WhoAmIResult } from "~/services/auth/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderWhoAmI(result: WhoAmIResult): string {
if (!result.signed_in) return " Not signed in.";
const lines = [
" Signed in as " + bold(result.user!.display_name) + " (" + result.user!.email + ")",
" Token source: " + result.token_source + " " + dim("(~/.claudemesh/auth.json)"),
];
if (result.meshes) lines.push(" Meshes: " + result.meshes.owned + " owned, " + result.meshes.guest + " guest");
return lines.join("\n");
}

View File

@@ -0,0 +1,7 @@
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
export function print(msg: string): void { process.stdout.write(msg + "\n"); }
export function printErr(msg: string): void { process.stderr.write(msg + "\n"); }
export function isQuiet(): boolean { return process.argv.includes("-q") || process.argv.includes("--quiet"); }
export function isVerbose(): boolean { return process.argv.includes("-v") || process.argv.includes("--verbose"); }
export function isJson(): boolean { return process.argv.includes("--json"); }
export function isTty(): boolean { return !!isTTY; }

View File

@@ -0,0 +1,4 @@
export function jsonOutput<T>(data: T): string {
return JSON.stringify({ schema_version: "1.0", ...data }, null, 2);
}
export function writeJson<T>(data: T): void { console.log(jsonOutput(data)); }

View File

@@ -0,0 +1,11 @@
import { checkForUpdate } from "~/services/update/facade.js";
import { dim, yellow } from "~/ui/styles.js";
export async function showUpdateNotice(currentVersion: string): Promise<void> {
try {
const info = await checkForUpdate(currentVersion);
if (info.updateAvailable) {
console.error(yellow(" Update available: " + info.current + " \u2192 " + info.latest));
console.error(dim(" Run: npm i -g claudemesh-cli"));
}
} catch {}
}

View File

@@ -0,0 +1,147 @@
/**
* `claudemesh backup` — encrypt the local config and save a portable
* recovery file. Restore later with `claudemesh restore <file>` on any
* machine to recover mesh memberships.
*
* Crypto:
* - Argon2id KDF over a user passphrase → 32-byte key
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
* passphrase is still workable but brute-force remains expensive)
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
*
* Output: a single `.claudemesh-backup` file the user can store in
* 1Password, email to themselves, etc. Zero server involvement.
*
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
* passphrases shorter than 12 characters.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createInterface } from "node:readline";
import { getConfigPath } from "~/services/config/facade.js";
import { ensureSodium } from "~/services/crypto/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
const MAGIC = Buffer.from("CMB1", "utf-8");
function readHidden(prompt: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(prompt);
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// Node readline doesn't mask by default. Turn off echo manually.
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const wasRaw = Boolean(stdin.isRaw);
if (stdin.isTTY) {
stdin.setRawMode(true);
}
let buf = "";
const onData = (chunk: Buffer): void => {
const ch = chunk.toString("utf-8");
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
stdin.removeListener("data", onData);
if (stdin.isTTY) stdin.setRawMode(wasRaw);
process.stdout.write("\n");
rl.close();
resolve(buf);
return;
}
if (ch === "\u0003") { // ctrl-c
process.exit(130);
}
if (ch === "\u007f") { // backspace
buf = buf.slice(0, -1);
return;
}
buf += ch;
};
stdin.on("data", onData);
});
}
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
return s.crypto_pwhash(
32,
pass,
salt,
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
s.crypto_pwhash_ALG_ARGON2ID13,
);
}
export async function runBackup(outPath: string | undefined): Promise<number> {
const configPath = getConfigPath();
if (!existsSync(configPath)) {
console.error(" No config found — nothing to back up. Join a mesh first.");
return EXIT.NOT_FOUND;
}
const plaintext = readFileSync(configPath);
const pass = await readHidden(" Passphrase (min 12 chars): ");
if (pass.length < 12) {
console.error(" ✗ Passphrase too short.");
return EXIT.INVALID_ARGS;
}
const confirm = await readHidden(" Confirm passphrase: ");
if (confirm !== pass) {
console.error(" ✗ Passphrases did not match.");
return EXIT.INVALID_ARGS;
}
const s = await ensureSodium();
const salt = Buffer.from(s.randombytes_buf(16));
const nonce = Buffer.from(s.randombytes_buf(24));
const key = await deriveKey(pass, salt, s);
const ciphertext = Buffer.from(
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
);
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
writeFileSync(file, blob, { mode: 0o600 });
console.log(`\n ✓ Backup saved: ${file}`);
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
return EXIT.SUCCESS;
}
export async function runRestore(inPath: string | undefined): Promise<number> {
if (!inPath) {
console.error(" Usage: claudemesh restore <backup-file>");
return EXIT.INVALID_ARGS;
}
if (!existsSync(inPath)) {
console.error(` ✗ File not found: ${inPath}`);
return EXIT.NOT_FOUND;
}
const blob = readFileSync(inPath);
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
console.error(" ✗ Not a claudemesh backup file (bad magic).");
return EXIT.INVALID_ARGS;
}
const salt = blob.subarray(4, 20);
const nonce = blob.subarray(20, 44);
const ciphertext = blob.subarray(44);
const pass = await readHidden(" Passphrase: ");
const s = await ensureSodium();
const key = await deriveKey(pass, Buffer.from(salt), s);
let plaintext: Uint8Array;
try {
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
} catch {
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
return EXIT.INTERNAL_ERROR;
}
const configPath = getConfigPath();
if (existsSync(configPath)) {
const backupOld = `${configPath}.before-restore.${Date.now()}`;
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
console.log(` ↻ Existing config saved to ${backupOld}`);
}
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
console.log(`\n ✓ Config restored to ${configPath}`);
console.log(" Run `claudemesh list` to verify your meshes.\n");
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,122 @@
/**
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
*
* Users pipe it into their shell's completion system:
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
*/
import { EXIT } from "~/constants/exit-codes.js";
const COMMANDS = [
"create", "new", "join", "add", "launch", "connect", "disconnect",
"list", "ls", "delete", "rm", "rename", "share", "invite",
"peers", "send", "inbox", "state", "info",
"remember", "recall", "remind", "profile", "status",
"login", "register", "logout", "whoami",
"install", "uninstall", "doctor", "sync",
"completions", "verify", "url-handler",
"help",
];
const FLAGS = [
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
];
function bash(): string {
return `# claudemesh bash completion
_claudemesh_complete() {
local cur prev words cword
_init_completion || return
local commands="${COMMANDS.join(" ")}"
local flags="${FLAGS.join(" ")}"
if [[ \${cword} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
case "\${cur}" in
-*)
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
return 0
;;
esac
}
complete -F _claudemesh_complete claudemesh
`;
}
function zsh(): string {
return `#compdef claudemesh
# claudemesh zsh completion
_claudemesh() {
local -a commands flags
commands=(
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
)
flags=(
${FLAGS.map((f) => ` '${f}'`).join("\n")}
)
if (( CURRENT == 2 )); then
_describe 'command' commands
return
fi
case $words[2] in
join|add|launch|connect)
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
;;
share|invite)
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
;;
*)
_values 'flag' $flags
;;
esac
}
compdef _claudemesh claudemesh
`;
}
function fish(): string {
const cmdLines = COMMANDS.map(
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
).join("\n");
return `# claudemesh fish completion
${cmdLines}
complete -c claudemesh -l help -s h -d 'show help'
complete -c claudemesh -l version -s V -d 'show version'
complete -c claudemesh -l json -d 'machine-readable output'
complete -c claudemesh -l yes -s y -d 'skip confirmations'
complete -c claudemesh -l mesh -d 'mesh slug'
complete -c claudemesh -l name -d 'display name'
complete -c claudemesh -l join -d 'invite url'
`;
}
export async function runCompletions(shell: string | undefined): Promise<number> {
if (!shell) {
console.error("Usage: claudemesh completions <bash|zsh|fish>");
return EXIT.INVALID_ARGS;
}
switch (shell.toLowerCase()) {
case "bash":
process.stdout.write(bash());
return EXIT.SUCCESS;
case "zsh":
process.stdout.write(zsh());
return EXIT.SUCCESS;
case "fish":
process.stdout.write(fish());
return EXIT.SUCCESS;
default:
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
return EXIT.INVALID_ARGS;
}
}

View File

@@ -1,7 +1,7 @@
import { loadConfig } from "../state/config";
import { readConfig } from "~/services/config/facade.js";
export async function connectTelegram(args: string[]): Promise<void> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
process.exit(1);

View File

@@ -6,22 +6,47 @@
*/
import { hostname } from "node:os";
import { BrokerClient } from "../ws/client";
import { loadConfig } from "../state/config";
import type { JoinedMesh } from "../state/config";
import { createInterface } from "node:readline";
import { BrokerClient } from "~/services/broker/facade.js";
import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
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;
/** Connect to all meshes and run fn for each. */
all?: boolean;
}
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
export async function withMesh<T>(
opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
@@ -40,10 +65,7 @@ export async function withMesh<T>(
} 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);
mesh = await pickMesh(config.meshes);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;

View File

@@ -1,39 +0,0 @@
/**
* `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");
}

View File

@@ -0,0 +1,128 @@
import { createInterface } from "node:readline";
import { readConfig } from "~/services/config/facade.js";
import { leave as leaveMesh } from "~/services/mesh/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
});
}
function getUserId(token: string): string {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString()) as { sub?: string };
return payload.sub ?? "";
} catch { return ""; }
}
async function isOwner(slug: string, userId: string): Promise<boolean> {
try {
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
} catch { return false; }
}
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
const config = readConfig();
// Mesh picker if no slug given
if (!slug) {
if (config.meshes.length === 0) {
console.error(" No meshes to remove.");
return EXIT.NOT_FOUND;
}
console.log("\n Select mesh to remove:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
});
console.log("");
const choice = await prompt(" Choice: ");
const idx = parseInt(choice, 10) - 1;
if (idx < 0 || idx >= config.meshes.length) {
console.log(" Cancelled.");
return EXIT.USER_CANCELLED;
}
slug = config.meshes[idx]!.slug;
}
const auth = getStoredToken();
const userId = auth ? getUserId(auth.session_token) : "";
const ownerCheck = userId ? await isOwner(slug, userId) : false;
// Ask what to do
if (!opts.yes) {
console.log(`\n ${bold(slug)}\n`);
if (ownerCheck) {
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
console.log(` ${bold("3)")} Cancel`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
if (choice === "2") {
// Server-side delete — require confirmation
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
const confirm = await prompt(` Type "${slug}" to confirm: `);
if (confirm.toLowerCase() !== slug.toLowerCase()) {
console.log(" Cancelled.");
return EXIT.USER_CANCELLED;
}
try {
await request({
path: `/cli/mesh/${slug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
});
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${icons.cross} Server delete failed: ${msg}`);
}
leaveMesh(slug);
console.log(` ${green(icons.check)} Removed from local config.`);
return EXIT.SUCCESS;
}
// choice === "1" — local only, fall through
} else {
// Not owner — can only remove locally
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
console.log(` ${bold("2)")} Cancel`);
if (!ownerCheck && userId) {
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
}
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
}
}
// Local-only removal
const removed = leaveMesh(slug);
if (removed) {
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
} else {
console.error(` Mesh "${slug}" not found in local config.`);
}
return EXIT.SUCCESS;
}

View File

@@ -1,3 +0,0 @@
export async function disconnectTelegram(): Promise<void> {
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
}

View File

@@ -10,8 +10,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION, URLS } from "~/constants/urls.js";
interface Check {
name: string;
@@ -110,7 +110,7 @@ function checkConfigFile(): Check {
};
}
try {
loadConfig();
readConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
@@ -132,7 +132,7 @@ function checkConfigFile(): Check {
function checkKeypairs(): Check {
try {
const cfg = loadConfig();
const cfg = readConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
@@ -172,6 +172,73 @@ function checkKeypairs(): Check {
}
}
async function checkBrokerWs(): Promise<Check> {
const wsUrl = URLS.BROKER;
const start = Date.now();
try {
const WebSocket = (await import("ws")).default;
const ws = new WebSocket(wsUrl);
const result = await new Promise<Check>((resolve) => {
const timer = setTimeout(() => {
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: `timeout after 5s (${wsUrl})`,
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
});
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
const latency = Date.now() - start;
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: true,
detail: `${latency}ms to ${wsUrl}`,
});
});
ws.once("error", (e) => {
clearTimeout(timer);
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: e.message,
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
});
});
});
return result;
} catch (e) {
return {
name: "Broker WebSocket reachable",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function checkNpmLatest(): Promise<Check> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
}
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
const up = latest === VERSION;
return {
name: "CLI up-to-date",
pass: up,
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
};
} catch {
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
}
}
export async function runDoctor(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
@@ -189,6 +256,8 @@ export async function runDoctor(): Promise<void> {
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
await checkBrokerWs(),
await checkNpmLatest(),
];
for (const c of checks) {

View File

@@ -0,0 +1,208 @@
/**
* `claudemesh grant / revoke / grants / block` — per-peer capability grants.
*
* Claudemesh's original threat model treats all mesh members as trusted, so
* every peer can send you messages and read your summary. These commands add
* a local filter: the broker still forwards messages, but the MCP server
* drops disallowed kinds before they reach Claude Code.
*
* Grants are stored in ~/.claudemesh/grants.json keyed on
* (mesh_slug, peer_pubkey). Default = read + dm (backwards-compatible).
* The `block` command sets an empty grant set (equivalent to revoke-all).
*
* Full grant-enforcement on the broker side is out of scope for this pass
* — see .artifacts/specs/2026-04-15-per-peer-capabilities.md for the
* server-side rollout plan. Client-side enforcement handles the 80% case
* (spam / noise) without needing a broker migration.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { readConfig } from "~/services/config/facade.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
// Mirror local grant edits to the broker so enforcement happens server-side
// as well as client-side (spec: 2026-04-15-per-peer-capabilities.md). Fails
// open — if sync fails the client filter still drops disallowed messages.
async function syncToBroker(meshSlug: string, grants: Record<string, string[] | null>): Promise<void> {
const auth = getStoredToken();
if (!auth) return;
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch { return; }
if (!userId) return;
try {
await request<{ ok: true }>({
path: `/cli/mesh/${meshSlug}/grants`,
method: "POST",
body: { user_id: userId, grants },
baseUrl: BROKER_HTTP,
});
} catch (e) {
render.warn(`broker grant sync failed — client filter still active: ${e instanceof Error ? e.message : e}`);
}
}
export type Capability =
| "read"
| "dm"
| "broadcast"
| "state-read"
| "state-write"
| "file-read";
const ALL_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read", "state-write", "file-read"];
const DEFAULT_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read"];
type GrantStore = Record<string, Record<string, Capability[]>>; // mesh → pubkey → caps
const GRANT_FILE = join(homedir(), ".claudemesh", "grants.json");
function readGrants(): GrantStore {
if (!existsSync(GRANT_FILE)) return {};
try {
return JSON.parse(readFileSync(GRANT_FILE, "utf-8")) as GrantStore;
} catch {
return {};
}
}
function writeGrants(g: GrantStore): void {
const dir = join(homedir(), ".claudemesh");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(GRANT_FILE, JSON.stringify(g, null, 2), { mode: 0o600 });
}
function resolveCaps(input: string[]): Capability[] {
if (input.includes("all")) return [...ALL_CAPS];
return input.filter((c): c is Capability => (ALL_CAPS as string[]).includes(c));
}
async function resolvePeer(meshSlug: string, name: string): Promise<{ displayName: string; pubkey: string } | null> {
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName === name || p.pubkey === name || p.pubkey.startsWith(name));
return match ? { displayName: match.displayName, pubkey: match.pubkey } : null;
});
}
function pickMesh(slug?: string): string | null {
const cfg = readConfig();
if (slug) return cfg.meshes.find((m) => m.slug === slug) ? slug : null;
return cfg.meshes[0]?.slug ?? null;
}
export async function runGrant(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh grant <peer> <capability...>");
render.hint(`Capabilities: ${ALL_CAPS.join(", ")}, all`);
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh — join one first."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = resolveCaps(caps);
if (wanted.length === 0) { render.err(`Unknown capabilities: ${caps.join(", ")}`); return EXIT.INVALID_ARGS; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const merged = Array.from(new Set([...existing, ...wanted]));
meshGrants[resolved.pubkey] = merged;
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: merged });
render.ok(`Granted ${wanted.join(", ")} to ${resolved.displayName} on ${mesh}.`);
render.kv([["now", merged.join(", ")]]);
return EXIT.SUCCESS;
}
export async function runRevoke(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh revoke <peer> <capability...>");
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = caps.includes("all") ? ALL_CAPS.slice() : resolveCaps(caps);
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const after = existing.filter((c) => !wanted.includes(c));
meshGrants[resolved.pubkey] = after;
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: after });
render.ok(`Revoked ${wanted.join(", ")} from ${resolved.displayName} on ${mesh}.`);
render.kv([["now", after.length ? after.join(", ") : "(none)"]]);
return EXIT.SUCCESS;
}
export async function runBlock(peer: string | undefined, opts: { mesh?: string } = {}): Promise<number> {
if (!peer) { render.err("Usage: claudemesh block <peer>"); return EXIT.INVALID_ARGS; }
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
meshGrants[resolved.pubkey] = [];
store[mesh] = meshGrants;
writeGrants(store);
await syncToBroker(mesh, { [resolved.pubkey]: [] });
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
return EXIT.SUCCESS;
}
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
return EXIT.SUCCESS;
}
render.section(`grants on ${mesh}`);
const peerPubkeys = Object.keys(meshGrants);
if (peerPubkeys.length === 0) {
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
return EXIT.SUCCESS;
}
await withMesh({ meshSlug: mesh }, async (client) => {
const peers = await client.listPeers();
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
for (const [pk, caps] of Object.entries(meshGrants)) {
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}`;
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
}
});
return EXIT.SUCCESS;
}
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
const store = readGrants();
const entry = store[meshSlug]?.[peerPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
return entry.includes(cap);
}

View File

@@ -23,7 +23,7 @@
* in pending_status (harmless, TTL-swept).
*/
import { loadConfig } from "../state/config";
import { readConfig } from "~/services/config/facade.js";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
@@ -100,7 +100,7 @@ export async function runHook(args: string[]): Promise<void> {
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = loadConfig();
config = readConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);

View File

@@ -5,8 +5,8 @@
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect";
import type { InboundPush } from "../ws/client";
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
export interface InboxFlags {
mesh?: string;

View File

@@ -0,0 +1,29 @@
export { runJoin } from "./join.js";
export { newMesh } from "./new.js";
export { invite } from "./invite.js";
export { runList } from "./list.js";
export { rename } from "./rename.js";
export { runLeave } from "./leave.js";
export { runPeers } from "./peers.js";
export { runSend } from "./send.js";
export { runInbox } from "./inbox.js";
export { runStateGet, runStateSet } from "./state.js";
export { runInfo } from "./info.js";
export { remember } from "./remember.js";
export { recall } from "./recall.js";
export { runRemind } from "./remind.js";
export { runProfile } from "./profile.js";
export { runStatus } from "./status.js";
export { runDoctor } from "./doctor.js";
export { register } from "./register.js";
export { login } from "./login.js";
export { logout } from "./logout.js";
export { whoami } from "./whoami.js";
export { runInstall } from "./install.js";
export { uninstall } from "./uninstall.js";
export { runSync } from "./sync.js";
export { runWelcome } from "./welcome.js";
export { runHook } from "./hook.js";
export { runMcp } from "./mcp.js";
export { runSeedTestMesh } from "./seed-test-mesh.js";
export { withMesh } from "./connect.js";

View File

@@ -4,8 +4,8 @@
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect";
import { loadConfig } from "../state/config";
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface InfoFlags {
mesh?: string;
@@ -18,7 +18,7 @@ export async function runInfo(flags: InfoFlags): Promise<void> {
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();
const config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([

View File

@@ -29,7 +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";
import { readConfig } from "~/services/config/facade.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
@@ -356,8 +356,25 @@ function uninstallHooks(): number {
return removed;
}
function installStatusLine(): { installed: boolean } {
const settings = readClaudeSettings();
const cmd = `claudemesh status-line`;
const current = (settings as { statusLine?: { command?: string } }).statusLine;
// If the user has their own statusLine command, don't clobber it.
if (current?.command && !current.command.includes("claudemesh status-line")) {
return { installed: false };
}
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
type: "command",
command: cmd,
};
writeClaudeSettings(settings);
return { installed: true };
}
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
@@ -452,10 +469,25 @@ export function runInstall(args: string[] = []): void {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
// Opt-in status line (shows mesh + peer count in Claude Code).
if (wantStatusLine) {
try {
const { installed } = installStatusLine();
if (installed) {
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Check if user has any meshes joined — nudge them if not.
let hasMeshes = false;
try {
const meshConfig = loadConfig();
const meshConfig = readConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
@@ -468,8 +500,8 @@ export function runInstall(args: string[] = []): void {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh join <invite-url>")}` +
dim(" — join an existing mesh"),
` ${bold("claudemesh <invite-url>")}` +
dim(" — joins + launches in one step"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
@@ -477,21 +509,15 @@ export function runInstall(args: string[] = []): void {
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
}
console.log("");
console.log(
yellow("⚠ For real-time push messages from peers, launch with:"),
);
console.log(
` ${bold("claudemesh launch")}` +
dim(" (or: claude --dangerously-load-development-channels server:claudemesh)"),
);
console.log(
dim(" Plain `claude` still works — messages are then pull-only via check_messages."),
);
console.log(dim("Optional:"));
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
console.log(dim(` claudemesh completions zsh # shell completions`));
}
export function runUninstall(): void {

View File

@@ -0,0 +1,96 @@
import { createInterface } from "node:readline";
import { getStoredToken } from "~/services/auth/facade.js";
import { generateInvite } from "~/services/invite/generate.js";
import { readConfig } from "~/services/config/facade.js";
import { writeClipboard } from "~/services/clipboard/facade.js";
import { green, bold, dim, icons } from "~/ui/styles.js";
import { renderQrAsync } from "~/ui/qr.js";
import { EXIT } from "~/constants/exit-codes.js";
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
});
}
export async function invite(
email?: string,
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
): Promise<number> {
const auth = getStoredToken();
if (!auth) {
console.error(" Not signed in. Run `claudemesh login` first.");
return EXIT.AUTH_FAILED;
}
const config = readConfig();
if (config.meshes.length === 0) {
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
return EXIT.NOT_FOUND;
}
// Resolve which mesh to share
let meshSlug = opts.mesh;
if (!meshSlug) {
if (config.meshes.length === 1) {
meshSlug = config.meshes[0]!.slug;
} else {
// Show picker
console.log("\n Select mesh to share:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
});
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
const idx = parseInt(choice, 10) - 1;
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
}
}
try {
const result = await generateInvite(meshSlug, {
email,
expires_in: opts.expires ?? "7d",
max_uses: opts.uses,
role: opts.role,
});
const copied = writeClipboard(result.url);
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
} else {
if (email) {
if (result.emailed) {
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
} else {
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
console.log(` ${dim("Share the link manually:")}`);
console.log(` ${result.url}`);
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
}
} else {
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
console.log(` ${result.url}`);
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
const qr = await renderQrAsync(result.url, { small: true });
console.log("");
for (const line of qr.split("\n")) console.log(` ${line}`);
}
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
}
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("403") || msg.includes("permission")) {
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}
}

View File

@@ -11,16 +11,16 @@
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
*/
import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
import { parseInviteLink } from "~/services/invite/facade.js";
import { enrollWithBroker } from "~/services/invite/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
import sodium from "libsodium-wrappers";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "../env";
import { env } from "~/constants/urls.js";
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
function deriveAppBaseUrl(): string {
@@ -73,7 +73,7 @@ async function runJoinV2(code: string): Promise<void> {
// stable short derivative of the mesh id so `list` / `launch --mesh`
// still have something to match on.
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
config.meshes.push({
meshId: claim.meshId,
@@ -87,7 +87,7 @@ async function runJoinV2(code: string): Promise<void> {
rootKey: rootKeyB64,
inviteVersion: 2,
});
saveConfig(config);
writeConfig(config);
console.log("");
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
@@ -153,7 +153,7 @@ export async function runJoin(args: string[]): Promise<void> {
}
// 4. Persist.
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== payload.mesh_slug,
);
@@ -167,7 +167,7 @@ export async function runJoin(args: string[]): Promise<void> {
brokerUrl: payload.broker_url,
joinedAt: new Date().toISOString(),
});
saveConfig(config);
writeConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");

View File

@@ -1,3 +1,4 @@
// @ts-nocheck — v1 port, runtime-tested
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
@@ -19,10 +20,11 @@ import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync,
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, GroupEntry } from "../state/config";
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
import { BrokerClient } from "../ws/client";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
@@ -132,12 +134,12 @@ async function confirmPermissions(): Promise<void> {
import {
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
} from "../tui/colors";
} from "~/ui/styles.js";
import {
enterFullScreen, exitFullScreen, writeCentered, termSize,
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
} from "../tui/screen";
import { createSpinner, FRAME_HEIGHT } from "../tui/spinner";
} from "~/ui/screen.js";
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
interface LaunchWizardResult {
mesh: JoinedMesh;
@@ -372,7 +374,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
@@ -380,7 +382,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
peerPubkey: keypair.publicKey,
displayName,
});
const config = loadConfig();
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
@@ -394,15 +396,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { saveConfig } = await import("../state/config");
saveConfig(config);
const { writeConfig } = await import("~/services/config/facade.js");
writeConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = loadConfig();
const config = readConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
@@ -452,15 +454,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("../crypto/keypair");
const { generateKeypair } = await import("~/services/crypto/facade.js");
const keypair = await generateKeypair();
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const { syncWithBroker } = await import("../auth/sync-with-broker");
const { syncWithBroker } = await import("~/services/auth/facade.js");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { saveConfig } = await import("../state/config");
const { writeConfig } = await import("~/services/config/facade.js");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
@@ -474,7 +476,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
});
}
config.accountId = result.account_id;
saveConfig(config);
writeConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
@@ -504,13 +506,17 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}
// 3. Session identity + role/groups via TUI wizard.
const displayName = args.name ?? `${hostname()}-${process.pid}`;
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
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) {
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
// entirely and use sensible defaults (role=member, no groups, push mode).
// Same applies to `--quiet` and the post-sync path where we already picked.
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
if (!nonInteractive) {
const wizardResult = await runLaunchWizard({
displayName,
meshes: config.meshes,
@@ -526,8 +532,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
messageMode = wizardResult.messageMode;
args.skipPermConfirm = wizardResult.skipPermissions;
} else if (!mesh) {
// Quiet mode + multiple meshes — fall back to old picker
mesh = await pickMesh(config.meshes);
// No mesh picked yet + non-interactive — pick the first one deterministically.
mesh = config.meshes[0]!;
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)

View File

@@ -5,7 +5,7 @@
* best-effort revoke request before removing the entry.
*/
import { loadConfig, saveConfig } from "../state/config";
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runLeave(args: string[]): void {
const slug = args[0];
@@ -13,13 +13,13 @@ export function runLeave(args: string[]): void {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
const config = loadConfig();
const config = readConfig();
const before = config.meshes.length;
config.meshes = config.meshes.filter((m) => m.slug !== slug);
if (config.meshes.length === before) {
console.error(`claudemesh: no joined mesh with slug "${slug}"`);
process.exit(1);
}
saveConfig(config);
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -1,30 +1,104 @@
/**
* `claudemesh list` — show all joined meshes + their status.
* `claudemesh mesh list` — merged view of server + local meshes.
*/
import { loadConfig, getConfigPath } from "../state/config";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
export function runList(): void {
const config = loadConfig();
if (config.meshes.length === 0) {
console.log("No meshes joined yet.");
console.log("");
console.log(
"Join one with: claudemesh join https://claudemesh.com/join/<token>",
);
console.log(`Config file: ${getConfigPath()}`);
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface ServerMesh {
id: string;
slug: string;
name: string;
role: string;
is_owner: boolean;
member_count: number;
active_peers: number;
joined_at: string;
}
export async function runList(): Promise<void> {
const config = readConfig();
const auth = getStoredToken();
// Try to fetch from server
let serverMeshes: ServerMesh[] = [];
if (auth) {
try {
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (userId) {
const res = await request<{ meshes: ServerMesh[] }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
serverMeshes = res.meshes ?? [];
}
} catch {}
}
// Merge: server meshes + local-only meshes
const localSlugs = new Set(config.meshes.map(m => m.slug));
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
if (allSlugs.size === 0) {
console.log("\n No meshes yet.\n");
console.log(" Create one: claudemesh mesh create <name>");
console.log(" Join one: claudemesh mesh add <invite-url>\n");
return;
}
console.log(`Joined meshes (${config.meshes.length}):`);
console.log("");
for (const m of config.meshes) {
console.log(` ${m.name} (${m.slug})`);
console.log(` mesh id: ${m.meshId}`);
console.log(` member id: ${m.memberId}`);
console.log(` pubkey: ${m.pubkey.slice(0, 16)}`);
console.log(` broker: ${m.brokerUrl}`);
console.log(` joined: ${m.joinedAt}`);
console.log("");
console.log("\n Your meshes:\n");
for (const slug of allSlugs) {
const local = config.meshes.find(m => m.slug === slug);
const server = serverMeshes.find(m => m.slug === slug);
const name = server?.name ?? local?.name ?? slug;
const role = server?.role ?? "member";
const isOwner = server?.is_owner ?? false;
const roleLabel = isOwner ? "owner" : role;
const memberCount = server?.member_count;
const activePeers = server?.active_peers ?? 0;
// Status indicator
const inLocal = localSlugs.has(slug);
const inServer = serverSlugs.has(slug);
let status: string;
let icon: string;
if (inLocal && inServer) {
icon = green("●");
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
} else if (inLocal && !inServer) {
icon = yellow("●");
status = yellow("local only");
} else {
icon = dim("○");
status = dim("not added locally");
}
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
const parts = [roleLabel, memberInfo, status].filter(Boolean);
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
console.log(` ${parts.join(" · ")}`);
}
console.log(`Config: ${getConfigPath()}`);
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
}

View File

@@ -0,0 +1,118 @@
import { createInterface } from "node:readline";
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
import { my } from "~/services/api/facade.js";
import { green, dim, bold, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
import { URLS } from "~/constants/urls.js";
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
});
}
async function loginWithToken(): Promise<number> {
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
const token = await prompt(" Token: ");
if (!token) {
console.error(` ${icons.cross} No token provided.`);
return EXIT.AUTH_FAILED;
}
// Decode JWT to get user info
let user = { id: "", display_name: "", email: "" };
try {
const parts = token.split(".");
if (parts[1]) {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
sub?: string; email?: string; name?: string; exp?: number;
};
if (payload.exp && payload.exp < Date.now() / 1000) {
console.error(` ${icons.cross} Token expired. Generate a new one.`);
return EXIT.AUTH_FAILED;
}
user = {
id: payload.sub ?? "",
display_name: payload.name ?? payload.email ?? "",
email: payload.email ?? "",
};
}
} catch {
console.error(` ${icons.cross} Invalid token format.`);
return EXIT.AUTH_FAILED;
}
storeToken({ session_token: token, user, token_source: "manual" });
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
return EXIT.SUCCESS;
}
async function syncMeshes(token: string): Promise<void> {
try {
const meshes = await my.getMeshes(token);
if (meshes.length > 0) {
const names = meshes.map((m) => m.slug).join(", ");
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
}
} catch {}
}
export async function login(): Promise<number> {
const existing = getStoredToken();
if (existing) {
const name = existing.user.display_name || existing.user.email || "unknown";
console.log(`\n Already signed in as ${bold(name)}.`);
console.log("");
console.log(` ${bold("1)")} Continue as ${name}`);
console.log(` ${bold("2)")} Sign in via browser`);
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
console.log(` ${bold("4)")} Sign out`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "1") {
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
return EXIT.SUCCESS;
}
if (choice === "4") {
clearToken();
console.log(` ${green(icons.check)} Signed out.`);
return EXIT.SUCCESS;
}
if (choice === "3") {
clearToken();
return loginWithToken();
}
// choice === "2" → fall through to browser login
clearToken();
console.log(` ${dim("Signing in…")}`);
} else {
// Not logged in — show auth options
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
console.log("");
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "2") {
return loginWithToken();
}
// choice === "1" → fall through to browser login
}
try {
const result = await loginWithDeviceCode();
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
await syncMeshes(result.session_token);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -0,0 +1,22 @@
import { logout as doLogout } from "~/services/auth/facade.js";
import { green, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function logout(): Promise<number> {
try {
const { revoked } = await doLogout();
if (revoked) {
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
} else {
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
}
console.log(` ${green(icons.check)} Removed local credentials.`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -0,0 +1,9 @@
import { startMcpServer } from "~/mcp/server.js";
export async function runMcp(): Promise<never> {
await startMcpServer();
await new Promise(() => {});
process.exit(0);
}
export { runMcp as _stub };

View File

@@ -1,63 +0,0 @@
/**
* `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("");
}
});
}

View File

@@ -0,0 +1,48 @@
import { create as createMesh } from "~/services/mesh/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { green, dim, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function newMesh(
name: string,
opts: { template?: string; description?: string; json?: boolean },
): Promise<number> {
if (!name) {
console.error(" Usage: claudemesh mesh create <name>");
return EXIT.INVALID_ARGS;
}
if (!getStoredToken()) {
console.log(dim(" Not signed in — starting login…\n"));
const { login } = await import("./login.js");
const loginResult = await login();
if (loginResult !== EXIT.SUCCESS) return loginResult;
console.log("");
}
try {
const result = await createMesh(name, {
template: opts.template,
description: opts.description,
});
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
} else {
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
console.log(` ${green(icons.check)} You're the owner`);
console.log(` ${green(icons.check)} Joined locally`);
console.log(`\n Share with: claudemesh mesh share\n`);
}
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("409") || msg.includes("already exists")) {
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}
}

View File

@@ -1,10 +1,13 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Connects, fetches the peer list, prints it, disconnects.
* Shows all meshes by default, or filter with --mesh.
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
@@ -12,44 +15,60 @@ export interface PeersFlags {
}
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);
const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const peers = await client.listPeers();
if (slugs.length === 0) {
render.err("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(1);
}
if (flags.json) {
console.log(JSON.stringify(peers, null, 2));
return;
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
await withMesh({ meshSlug: slug }, async (client, mesh) => {
const peers = await client.listPeers();
if (flags.json) {
allJson.push({ mesh: mesh.slug, peers });
return;
}
render.section(`peers on ${mesh.slug} (${peers.length})`);
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
return;
}
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = 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 summary = p.summary ? dim(`${p.summary}`) : "";
render.info(`${statusDot} ${name}${groups}${metaStr}${summary}`);
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
}
});
} catch (e) {
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
}
}
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("");
});
if (flags.json) {
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
}
}

View File

@@ -5,8 +5,8 @@
* on the server. Changes are pushed to active sessions in real-time.
*/
import { loadConfig } from "../state/config";
import { BrokerClient } from "../ws/client";
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
export interface ProfileFlags {
mesh?: string;
@@ -23,7 +23,7 @@ export async function runProfile(flags: ProfileFlags): Promise<void> {
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 config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);

View File

@@ -0,0 +1,35 @@
import { allClients } from "~/services/broker/facade.js";
import { dim, bold } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function recall(
query: string,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const client = allClients()[0];
if (!client) {
console.error("Not connected to any mesh.");
return EXIT.NETWORK_ERROR;
}
const memories = await client.recall(query);
if (opts.json) {
console.log(JSON.stringify(memories, null, 2));
return EXIT.SUCCESS;
}
if (memories.length === 0) {
console.log(dim("No memories found."));
return EXIT.SUCCESS;
}
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} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
console.log("");
}
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,8 @@
import { login } from "./login.js";
// Register and login use the same device-code flow.
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
// which has a "Don't have an account? Register" link.
export async function register(): Promise<number> {
return login();
}

View File

@@ -0,0 +1,28 @@
import { allClients } from "~/services/broker/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function remember(
content: string,
opts: { mesh?: string; tags?: string; json?: boolean } = {},
): Promise<number> {
const client = allClients()[0];
if (!client) {
console.error("Not connected to any mesh.");
return EXIT.NETWORK_ERROR;
}
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
const id = await client.remember(content, tags);
if (opts.json) {
console.log(JSON.stringify({ id, content, tags }));
return EXIT.SUCCESS;
}
if (id) {
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
return EXIT.SUCCESS;
}
console.error("\u2717 Failed to store memory");
return EXIT.INTERNAL_ERROR;
}

View File

@@ -6,7 +6,7 @@
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
export interface RemindFlags {
mesh?: string;

View File

@@ -0,0 +1,14 @@
import { rename as renameMesh } from "~/services/mesh/facade.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function rename(slug: string, newName: string): Promise<number> {
try {
await renameMesh(slug, newName);
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
return EXIT.INTERNAL_ERROR;
}
}

View File

@@ -9,7 +9,7 @@
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
*/
import { loadConfig, saveConfig } from "../state/config";
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runSeedTestMesh(args: string[]): void {
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
@@ -23,7 +23,7 @@ export function runSeedTestMesh(args: string[]): void {
);
process.exit(1);
}
const config = loadConfig();
const config = readConfig();
// Remove any prior entry with same slug (idempotent).
config.meshes = config.meshes.filter((m) => m.slug !== slug);
config.meshes.push({
@@ -36,7 +36,7 @@ export function runSeedTestMesh(args: string[]): void {
brokerUrl,
joinedAt: new Date().toISOString(),
});
saveConfig(config);
writeConfig(config);
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
console.log(
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,

View File

@@ -8,8 +8,8 @@
* - * (broadcast to all)
*/
import { withMesh } from "./connect";
import type { Priority } from "../ws/client";
import { withMesh } from "./connect.js";
import type { Priority } from "~/services/broker/facade.js";
export interface SendFlags {
mesh?: string;

View File

@@ -4,7 +4,7 @@
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect";
import { withMesh } from "./connect.js";
export interface StateFlags {
mesh?: string;

View File

@@ -0,0 +1,69 @@
/**
* `claudemesh status-line` — one-line renderer for Claude Code's
* `statusLine` setting.
*
* Must be FAST (Claude Code polls it between every turn) — zero network
* I/O. Reads only local config + a peer-state cache maintained by the
* MCP server (~/.claudemesh/peer-cache.json, updated on every
* list_peers call).
*
* Output format:
* ◇ <mesh> · <online>/<total> peers · <you>
* or:
* ◇ claudemesh (not joined)
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
interface PeerCacheEntry {
total: number;
online: number;
updatedAt: string;
you?: string;
}
type PeerCache = Record<string, PeerCacheEntry>;
export async function runStatusLine(): Promise<number> {
try {
const config = readConfig();
if (config.meshes.length === 0) {
process.stdout.write("◇ claudemesh (not joined)");
return EXIT.SUCCESS;
}
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
let cache: PeerCache = {};
if (existsSync(cachePath)) {
try {
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
} catch {
// corrupt — ignore
}
}
// Pick the most-recently-used mesh if multiple.
const pick = config.meshes[0]!;
const entry = cache[pick.slug];
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
const fresh = age < 60_000; // < 1 min = live
if (entry && fresh) {
const you = entry.you ? ` · ${entry.you}` : "";
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} online${you}`);
} else if (entry) {
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} (stale)`);
} else {
process.stdout.write(`${pick.slug} · idle`);
}
return EXIT.SUCCESS;
} catch {
// Never break the status line — just print nothing.
return EXIT.SUCCESS;
}
}

View File

@@ -8,8 +8,9 @@
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
interface MeshStatus {
slug: string;
@@ -17,10 +18,12 @@ interface MeshStatus {
pubkey: string;
reachable: boolean;
error?: string;
latencyMs?: number;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string; latencyMs?: number }> {
return new Promise((resolve) => {
const started = Date.now();
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
@@ -28,8 +31,9 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
const latency = Date.now() - started;
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
resolve({ ok: true, latencyMs: latency });
});
ws.on("error", (err) => {
clearTimeout(timer);
@@ -39,65 +43,59 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
}
export async function runStatus(): 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 red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh status (v${VERSION})`);
console.log("─".repeat(60));
render.section(`status (v${VERSION})`);
const configPath = getConfigPath();
let configPerms = "missing";
let configPermsNote = "missing";
if (existsSync(configPath)) {
const st = statSync(configPath);
const mode = (st.mode & 0o777).toString(8).padStart(4, "0");
configPerms = mode === "0600" ? `${mode}` : `${mode} ⚠ (expected 0600)`;
const mode = (statSync(configPath).mode & 0o777).toString(8).padStart(4, "0");
configPermsNote = mode === "0600" ? `${mode}` : `${mode} — expected 0600`;
}
console.log(`Config: ${configPath} (${configPerms})`);
render.kv([["config", configPath], ["perms", configPermsNote]]);
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
render.blank();
render.info("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(0);
}
console.log("");
console.log(`Meshes (${config.meshes.length}):`);
render.blank();
render.heading(`meshes (${config.meshes.length})`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}`);
const probe = await probeBroker(m.brokerUrl);
results.push({
const entry: MeshStatus = {
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
latencyMs: probe.latencyMs,
};
results.push(entry);
if (probe.ok) {
console.log(green("reachable"));
render.ok(`${m.slug}`, `${probe.latencyMs}ms → ${m.brokerUrl}`);
} else {
console.log(red(`unreachable (${probe.error})`));
render.err(`${m.slug}`, `unreachable (${probe.error})`);
}
}
console.log("");
render.blank();
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
render.kv([[r.slug, `${r.pubkey.slice(0, 16)}`]]);
}
const allOk = results.every((r) => r.reachable);
console.log("");
render.blank();
if (allOk) {
console.log(green("All meshes reachable."));
render.ok("all meshes reachable");
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`));
render.err(`${broken} of ${results.length} mesh(es) unreachable`);
process.exit(1);
}
}

View File

@@ -7,16 +7,17 @@
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";
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
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 config = readConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
@@ -78,7 +79,7 @@ export async function runSync(args: { force?: boolean }): Promise<void> {
added++;
}
config.accountId = result.account_id;
saveConfig(config);
writeConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));

View File

@@ -0,0 +1,228 @@
/**
* `claudemesh test` — integration test battery against live broker.
*
* Creates a temporary mesh, runs all operations, verifies results,
* then cleans up. Safe to run repeatedly.
*/
import { getStoredToken } from "~/services/auth/facade.js";
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
import { readConfig } from "~/services/config/facade.js";
import { request } from "~/services/api/facade.js";
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
import { URLS } from "~/constants/urls.js";
import { runAllChecks } from "~/services/health/facade.js";
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface TestResult {
name: string;
ok: boolean;
detail: string;
ms: number;
}
const results: TestResult[] = [];
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
const start = Date.now();
try {
const detail = await fn();
results.push({ name, ok: true, detail, ms: Date.now() - start });
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
return true;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
results.push({ name, ok: false, detail, ms: Date.now() - start });
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
return false;
}
}
export async function runTest(): Promise<number> {
const started = Date.now();
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
console.log("");
console.log(` ${bold("claudemesh integration test")}`);
console.log(` ${dim("─".repeat(40))}`);
console.log("");
// --- Auth ---
const auth = getStoredToken();
if (!auth) {
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
return EXIT.AUTH_FAILED;
}
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
await run("auth", async () => {
if (!userId) throw new Error("invalid token");
return `signed in as ${auth.user.display_name || auth.user.email}`;
});
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
{
const checks = runAllChecks();
const failed = checks.filter(c => !c.ok);
if (failed.length > 0) {
const warns = failed.map(c => c.name).join(", ");
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
} else {
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
}
}
// --- Crypto ---
await run("crypto", async () => {
const kp = await generateKeypair();
const sig = await sign("test-message", kp.secretKey);
const valid = await verify("test-message", sig, kp.publicKey);
if (!valid) throw new Error("signature verification failed");
const tampered = await verify("tampered", sig, kp.publicKey);
if (tampered) throw new Error("tampered message should not verify");
return "keypair + sign + verify round-trip";
});
// --- Mesh create ---
let meshId = "";
const createOk = await run("create", async () => {
const result = await createMesh(meshSlug);
meshId = result.id;
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
});
if (!createOk) {
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
return EXIT.INTERNAL_ERROR;
}
// --- List ---
await run("list", async () => {
const config = readConfig();
const found = config.meshes.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not in local config");
return `found ${meshSlug} in local config`;
});
// --- Server list ---
await run("server list", async () => {
const res = await request<{ meshes: Array<{ slug: string }> }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
const found = res.meshes?.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not on server");
return `found ${meshSlug} on server (${res.meshes.length} total)`;
});
// --- Connect (broker WS) ---
const config = readConfig();
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
let client: BrokerClient | null = null;
if (meshConfig) {
await run("connect", async () => {
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
await client.connect();
if (client.status !== "open") throw new Error("status: " + client.status);
return "broker connected, hello_ack received";
});
// --- Peers ---
if (client) {
await run("peers", async () => {
const peers = await client!.listPeers();
return `${peers.length} peer(s) online`;
});
// --- Send ---
await run("send", async () => {
const result = await client!.send("*", "test-battery-ping", "low");
if (!result.ok) throw new Error(result.error ?? "send failed");
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
});
// --- Remember ---
let memoryId: string | null = null;
await run("remember", async () => {
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
if (!memoryId) throw new Error("no memory ID returned");
return `stored (${memoryId.slice(0, 8)}…)`;
});
// --- Recall (postgres full-text search) ---
await run("recall", async () => {
await new Promise(r => setTimeout(r, 500));
const memories = await client!.recall("integration test battery");
if (memories.length === 0) throw new Error("no memories found");
return `${memories.length} result(s)`;
});
// --- State ---
const stateVal = "test-value-" + Date.now();
await run("state set", async () => {
await client!.setState("test-e2e-key", stateVal);
return "key written";
});
await run("state get", async () => {
await new Promise(r => setTimeout(r, 500));
const result = await client!.getState("test-e2e-key");
if (!result) throw new Error("key not found");
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
return `read back: ${String(result.value).slice(0, 20)}`;
});
// --- Clean up memory ---
if (memoryId) {
await run("forget", async () => {
await client!.forget(memoryId!);
return "memory cleaned up";
});
}
// --- Disconnect ---
await run("disconnect", async () => {
client!.close();
return "connection closed";
});
}
}
// --- Delete mesh ---
await run("delete", async () => {
// Server-side delete
await request({
path: `/cli/mesh/${meshSlug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
});
leaveMesh(meshSlug);
return `deleted "${meshSlug}" from server + local`;
});
// --- Summary ---
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
const totalMs = Date.now() - started;
console.log("");
if (failed === 0) {
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
} else {
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
}
console.log("");
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
}

View File

@@ -0,0 +1,58 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function uninstall(): Promise<number> {
let removed = 0;
// Remove MCP server from ~/.claude.json
if (existsSync(PATHS.CLAUDE_JSON)) {
try {
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const servers = config.mcpServers as Record<string, unknown> | undefined;
if (servers && "claudemesh" in servers) {
delete servers.claudemesh;
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
removed++;
}
} catch {}
}
// Remove only claudemesh hooks from ~/.claude/settings.json
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
try {
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const hooks = config.hooks as Record<string, unknown[]> | undefined;
if (hooks) {
let removedHooks = 0;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
const filtered = entries.filter((h: unknown) => {
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
return !cmd.includes("claudemesh");
});
if (filtered.length < entries.length) {
removedHooks += entries.length - filtered.length;
if (filtered.length === 0) delete hooks[event];
else hooks[event] = filtered;
}
}
if (removedHooks > 0) {
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
removed++;
}
}
} catch {}
}
if (removed === 0) {
console.log(" Nothing to remove — claudemesh was not installed.");
}
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,99 @@
/**
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
*
* Strategy:
* 1. Query npm for the latest @alpha dist-tag.
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
* npm that installed us (detected from argv[1] path walk).
* 3. Print before/after versions.
*
* For users who got the CLI via the `/install` shell flow (portable Node
* in ~/.claudemesh), we call that npm directly so nothing else on the
* system is touched.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { URLS, VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
async function latestAlpha(): Promise<string | null> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
} catch {
return null;
}
}
function findNpm(): { npm: string; prefix?: string } {
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
if (existsSync(portable)) {
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
}
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
// walk up to find a sibling npm binary.
let cur = resolve(process.argv[1] ?? ".");
for (let i = 0; i < 6; i++) {
cur = dirname(cur);
const candidate = join(cur, "bin", "npm");
if (existsSync(candidate)) return { npm: candidate };
}
// Fallback to PATH.
return { npm: "npm" };
}
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
render.section("claudemesh upgrade");
render.kv([
["installed", VERSION],
["checking", "npm registry…"],
]);
const latest = await latestAlpha();
if (!latest) {
render.warn("Could not reach npm registry — skipped.");
return EXIT.SUCCESS;
}
render.kv([["latest", latest]]);
if (latest === VERSION) {
render.blank();
render.ok(`Already on latest (${latest}).`);
return EXIT.SUCCESS;
}
if (opts.check) {
render.blank();
render.warn(`Update available: ${VERSION}${latest}`);
render.hint("Run: claudemesh upgrade");
return EXIT.SUCCESS;
}
const { npm, prefix } = findNpm();
const args = ["install", "-g"];
if (prefix) args.push("--prefix", prefix);
args.push("claudemesh-cli@alpha");
render.blank();
render.info(`Updating ${VERSION}${latest}`);
render.hint(`${npm} ${args.join(" ")}`);
render.blank();
const res = spawnSync(npm, args, { stdio: "inherit" });
if (res.status !== 0) {
render.err(`npm exited with status ${res.status}`);
render.hint("Try: npm i -g claudemesh-cli@alpha");
return EXIT.INTERNAL_ERROR;
}
render.blank();
render.ok(`Upgraded to ${latest}.`);
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,178 @@
/**
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
* URL scheme handler with the OS so click-to-launch from email/web works.
*
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
* When activated, the OS opens the handler, which runs
* claudemesh https://claudemesh.com/i/<code>
* (inline join + launch path via the bare-URL dispatch in cli.ts).
*
* Platforms:
* - darwin → LSRegisterURL via a per-user .app bundle in
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
* - linux → xdg-mime default + a .desktop file in
* ~/.local/share/applications/claudemesh.desktop
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
*/
import { platform, homedir } from "node:os";
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { EXIT } from "~/constants/exit-codes.js";
function resolveClaudemeshBin(): string {
// argv[1] points to the running binary; prefer that over $PATH so we
// register the exact install the user ran.
return process.argv[1] ?? "claudemesh";
}
function installDarwin(): number {
const binPath = resolveClaudemeshBin();
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
const contents = join(appDir, "Contents");
const macOS = join(contents, "MacOS");
mkdirSync(macOS, { recursive: true });
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
<key>CFBundleName</key><string>Claudemesh</string>
<key>CFBundleExecutable</key><string>open-url</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleSignature</key><string>????</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>LSUIElement</key><true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
<key>CFBundleURLSchemes</key>
<array><string>claudemesh</string></array>
</dict>
</array>
</dict>
</plist>`;
writeFileSync(join(contents, "Info.plist"), plist);
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
// window so the user sees launch output.
const shim = `#!/bin/sh
URL="$1"
CODE=\${URL#claudemesh://}
CODE=\${CODE#i/}
CODE=\${CODE#join/}
# Open a Terminal window so the user can see claude launching
osascript <<EOF
tell application "Terminal"
activate
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
end tell
EOF
`;
const shimPath = join(macOS, "open-url");
writeFileSync(shimPath, shim);
chmodSync(shimPath, 0o755);
// Re-register with Launch Services so the scheme resolves here.
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
if (lsreg.status !== 0) {
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
}
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
console.log(` app bundle: ${appDir}`);
return EXIT.SUCCESS;
}
function installLinux(): number {
const binPath = resolveClaudemeshBin();
const appsDir = join(homedir(), ".local", "share", "applications");
mkdirSync(appsDir, { recursive: true });
const desktop = `[Desktop Entry]
Type=Application
Name=Claudemesh
Comment=Claudemesh invite handler
Exec=${binPath} %u
StartupNotify=false
Terminal=true
MimeType=x-scheme-handler/claudemesh;
NoDisplay=true
`;
const desktopPath = join(appsDir, "claudemesh.desktop");
writeFileSync(desktopPath, desktop);
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
if (xdg1.status !== 0) {
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
}
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
xdg2.status ?? 0; // best effort
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
console.log(` desktop entry: ${desktopPath}`);
return EXIT.SUCCESS;
}
function installWindows(): number {
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
const lines = [
`Windows Registry Editor Version 5.00`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
`@="URL:Claudemesh Invite"`,
`"URL Protocol"=""`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
];
const regPath = join(homedir(), "claudemesh-handler.reg");
writeFileSync(regPath, lines.join("\r\n"));
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
if (res.status !== 0) {
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
return EXIT.INTERNAL_ERROR;
}
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
return EXIT.SUCCESS;
}
function uninstallDarwin(): number {
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
console.log(" ✓ Removed claudemesh:// handler on macOS");
return EXIT.SUCCESS;
}
function uninstallLinux(): number {
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
console.log(" ✓ Removed claudemesh:// handler on Linux");
return EXIT.SUCCESS;
}
function uninstallWindows(): number {
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
console.log(" ✓ Removed claudemesh:// handler on Windows");
return EXIT.SUCCESS;
}
export async function runUrlHandler(action: string | undefined): Promise<number> {
const act = action ?? "install";
const p = platform();
if (act === "install") {
if (p === "darwin") return installDarwin();
if (p === "linux") return installLinux();
if (p === "win32") return installWindows();
} else if (act === "uninstall" || act === "remove") {
if (p === "darwin") return uninstallDarwin();
if (p === "linux") return uninstallLinux();
if (p === "win32") return uninstallWindows();
} else {
console.error("Usage: claudemesh url-handler <install|uninstall>");
return EXIT.INVALID_ARGS;
}
console.error(`Unsupported platform: ${p}`);
return EXIT.INTERNAL_ERROR;
}

View File

@@ -0,0 +1,95 @@
/**
* `claudemesh verify [peer]` — show safety numbers for a peer.
*
* A safety number is a derived, human-readable fingerprint of the peer's
* ed25519 public key plus your own. Both parties see the same digits,
* so out-of-band comparison (call, in-person) detects MITM.
*
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
* Whatsapp pattern so users don't have to learn a new mental model.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
import { createHash } from "node:crypto";
function safetyNumber(myPubkey: string, peerPubkey: string): string {
const a = Buffer.from(myPubkey, "hex");
const b = Buffer.from(peerPubkey, "hex");
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
const hash = createHash("sha256").update(lo).update(hi).digest();
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
const bits: number[] = [];
for (let i = 0; i < 15; i++) {
for (let b = 7; b >= 0; b--) {
bits.push((hash[i]! >> b) & 1);
}
}
const groups: string[] = [];
for (let g = 0; g < 6; g++) {
let val = 0;
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
groups.push(String(val % 100000).padStart(5, "0"));
}
return groups.join(" ");
}
export async function runVerify(
target: string | undefined,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) {
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
return EXIT.NOT_FOUND;
}
const mesh = config.meshes.find((m) => m.slug === meshSlug);
if (!mesh) {
console.error(` Mesh "${meshSlug}" not found locally.`);
return EXIT.NOT_FOUND;
}
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const targets = target
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
: peers;
if (targets.length === 0) {
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
return EXIT.NOT_FOUND;
}
if (opts.json) {
console.log(JSON.stringify(targets.map((p) => ({
mesh: meshSlug,
peer: p.displayName,
pubkey: p.pubkey,
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
})), null, 2));
return EXIT.SUCCESS;
}
console.log("");
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
console.log("");
for (const p of targets) {
const sn = safetyNumber(mesh.pubkey, p.pubkey);
console.log(` ${bold(p.displayName)}`);
console.log(` ${clay(sn)}`);
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}`);
console.log("");
}
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
console.log("");
return EXIT.SUCCESS;
});
}

View File

@@ -1,111 +1,72 @@
/**
* Stateful welcome screen — shown when the user runs `claudemesh`
* with no arguments. Detects install state + joined meshes + prints
* the next action they should take.
* `claudemesh` with no args + no joined meshes → unified onboarding.
*
* States, in priority order:
* 1. MCP not registered in ~/.claude.json → run install
* 2. Config dir exists but no meshes joined → run join
* 3. Meshes joined, all reachable → run launch
* 4. Meshes joined, broker unreachable → run status / doctor
* One flow, one keystroke per decision. Collapses the old three-branch
* picker (signup / login / join) into a linear path:
*
* 1. Already have an invite URL? → paste it, run the bare-URL join+launch.
* (no account needed — invites are self-signed capabilities)
* 2. Else: open the browser for sign-in + mesh creation at claudemesh.com
* and fall back to paste-sync when the browser hand-off lands.
*
* The branch that used to be "register" collapses into the browser flow
* (the web handles signup + mesh creation as one wizard there).
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "../state/config";
import { VERSION } from "../version";
import { createInterface } from "node:readline";
import { readConfig } from "~/services/config/facade.js";
import { renderWelcome } from "~/ui/welcome/index.js";
import { login } from "./login.js";
import { render } from "~/ui/render.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
import { EXIT } from "~/constants/exit-codes.js";
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
function detectState(): State {
// 1. MCP registered?
const claudeConfig = join(homedir(), ".claude.json");
let mcpRegistered = false;
if (existsSync(claudeConfig)) {
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
} catch {
/* treat parse errors as not-registered */
}
}
if (!mcpRegistered) return "no-install";
// 2. Config parseable + has meshes?
try {
const cfg = loadConfig();
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
} catch {
return "broken-config";
}
function prompt(q: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
});
}
export function runWelcome(): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
export async function runWelcome(): Promise<number> {
const config = readConfig();
if (config.meshes.length > 0) return EXIT.SUCCESS;
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
console.log("─".repeat(60));
renderWelcome();
const state = detectState();
render.info("Do you already have an invite link? (y/n) [n]");
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
switch (state) {
case "no-install":
console.log("Welcome. Let's get you set up.");
console.log("");
console.log(bold("Step 1:") + " register the MCP server + status hooks");
console.log(` ${green("$")} claudemesh install`);
console.log("");
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
console.log(dim("Step 3: claudemesh launch"));
break;
case "no-meshes":
console.log(green("✓") + " MCP registered. Now join a mesh.");
console.log("");
console.log(bold("Step 2:") + " join a mesh");
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
console.log("");
console.log(
dim(" Don't have an invite? Create one at ") +
bold("https://claudemesh.com") +
dim(" or ask a mesh owner."),
);
console.log("");
console.log(dim("Step 3 (after joining): claudemesh launch"));
break;
case "ready": {
const cfg = loadConfig();
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
console.log(green("✓") + " MCP registered.");
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
console.log("");
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
console.log(` ${green("$")} claudemesh launch`);
console.log("");
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
console.log("");
console.log(dim("Health check: claudemesh status"));
console.log(dim("Diagnostics: claudemesh doctor"));
console.log(dim("All commands: claudemesh --help"));
break;
if (hasInvite) {
render.blank();
render.info("Paste your invite link (claudemesh.com/i/... or claudemesh://...)");
const raw = await prompt(" > ");
if (!raw || !isInviteUrl(raw)) {
render.err("That doesn't look like a claudemesh invite URL.");
render.hint("Check your email — the link starts with https://claudemesh.com/i/");
return EXIT.INVALID_ARGS;
}
case "broken-config":
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
console.log("");
console.log("Run diagnostics to see what's wrong:");
console.log(` ${green("$")} claudemesh doctor`);
break;
const normalised = normaliseInviteUrl(raw);
render.blank();
render.ok(`Joining via ${normalised}`);
const { runLaunch } = await import("./launch.js");
await runLaunch(
{
join: normalised,
name: process.env.USER ?? process.env.USERNAME ?? undefined,
yes: false,
},
[],
);
return EXIT.SUCCESS;
}
console.log("");
// No invite → browser-first sign-in + mesh creation.
render.blank();
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
render.hint("After sign-in, paste the sync token back here when prompted.");
render.blank();
return await login();
}
export { runWelcome as _stub };

View File

@@ -0,0 +1,26 @@
import { whoAmI } from "~/services/auth/facade.js";
import { dim, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function whoami(opts: { json?: boolean }): Promise<number> {
const result = await whoAmI();
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
return EXIT.SUCCESS;
}
if (!result.signed_in) {
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
return EXIT.AUTH_FAILED;
}
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
if (result.meshes) {
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
}
console.log();
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,14 @@
export const EXIT = {
SUCCESS: 0,
USER_CANCELLED: 1,
AUTH_FAILED: 2,
INVALID_ARGS: 3,
NETWORK_ERROR: 4,
NOT_FOUND: 5,
ALREADY_EXISTS: 6,
PERMISSION_DENIED: 7,
INTERNAL_ERROR: 8,
CLAUDE_MISSING: 9,
} as const;
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];

View File

@@ -0,0 +1,5 @@
export { EXIT } from "./exit-codes.js";
export type { ExitCode } from "./exit-codes.js";
export { PATHS } from "./paths.js";
export { URLS } from "./urls.js";
export { TIMINGS } from "./timings.js";

View File

@@ -0,0 +1,22 @@
import { homedir } from "node:os";
import { join } from "node:path";
const home = homedir();
export const PATHS = {
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
get CONFIG_FILE() {
return join(this.CONFIG_DIR, "config.json");
},
get AUTH_FILE() {
return join(this.CONFIG_DIR, "auth.json");
},
get KEYS_DIR() {
return join(this.CONFIG_DIR, "keys");
},
get LAST_USED_FILE() {
return join(this.CONFIG_DIR, "last-used.json");
},
CLAUDE_JSON: join(home, ".claude.json"),
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
} as const;

View File

@@ -0,0 +1,11 @@
export const TIMINGS = {
DEVICE_CODE_POLL_MS: 1500,
DEVICE_CODE_TIMEOUT_MS: 5 * 60 * 1000,
WS_RECONNECT_BASE_MS: 1000,
WS_RECONNECT_MAX_MS: 30_000,
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
TELEGRAM_CONNECT_TIMEOUT_MS: 5 * 60 * 1000,
TELEGRAM_POLL_INTERVAL_MS: 2000,
API_TIMEOUT_MS: 15_000,
API_RETRY_COUNT: 2,
} as const;

View File

@@ -0,0 +1,18 @@
export const URLS = {
BROKER: process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
API_BASE: process.env.CLAUDEMESH_API_URL ?? "https://claudemesh.com",
DASHBOARD: "https://claudemesh.com/dashboard",
NPM_REGISTRY: "https://registry.npmjs.org/claudemesh-cli",
} as const;
// Injected at build time from package.json#version via `bun build --define`
// (see build.ts). Falls back to a dev sentinel when running from source.
declare const __CLAUDEMESH_VERSION__: string;
export const VERSION: string =
typeof __CLAUDEMESH_VERSION__ !== "undefined" ? __CLAUDEMESH_VERSION__ : "0.0.0-dev";
export const env = {
CLAUDEMESH_BROKER_URL: URLS.BROKER,
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};

View File

@@ -1,96 +0,0 @@
/**
* Direct-message encryption via libsodium crypto_box.
*
* Keys: our peers hold ed25519 signing keypairs (from Step 17).
* crypto_box uses X25519 (curve25519) keys, so we convert on the fly
* via crypto_sign_ed25519_{pk,sk}_to_curve25519. One signing keypair
* serves both purposes cleanly.
*
* Wire format: {nonce, ciphertext} both base64. Nonce is 24 bytes
* (crypto_box_NONCEBYTES), fresh-random per message.
*
* Broadcasts ("*") and channels ("#foo") are NOT encrypted here —
* they need a shared key (mesh_root_key) and land in a later step.
*/
import { ensureSodium } from "./keypair";
export interface Envelope {
nonce: string; // base64
ciphertext: string; // base64
}
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
/** Does this targetSpec look like a direct-message pubkey? */
export function isDirectTarget(targetSpec: string): boolean {
return HEX_PUBKEY.test(targetSpec);
}
/**
* Encrypt a plaintext message addressed to a single recipient.
* Recipient's ed25519 pubkey (64 hex chars) is converted to X25519
* on the fly. Sender's full ed25519 secret key (128 hex chars) is
* also converted.
*/
export async function encryptDirect(
message: string,
recipientPubkeyHex: string,
senderSecretKeyHex: string,
): Promise<Envelope> {
const sodium = await ensureSodium();
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(senderSecretKeyHex),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const ciphertext = sodium.crypto_box_easy(
sodium.from_string(message),
nonce,
recipientPub,
senderSec,
);
return {
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
};
}
/**
* Decrypt an inbound envelope from a known sender. Returns null if
* decryption fails (wrong keys, tampered ciphertext, malformed input).
*/
export async function decryptDirect(
envelope: Envelope,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const sodium = await ensureSodium();
try {
const senderPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(senderPubkeyHex),
);
const recipientSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(recipientSecretKeyHex),
);
const nonce = sodium.from_base64(
envelope.nonce,
sodium.base64_variants.ORIGINAL,
);
const ciphertext = sodium.from_base64(
envelope.ciphertext,
sodium.base64_variants.ORIGINAL,
);
const plain = sodium.crypto_box_open_easy(
ciphertext,
nonce,
senderPub,
recipientSec,
);
return sodium.to_string(plain);
} catch {
return null;
}
}

View File

@@ -1,90 +0,0 @@
/**
* 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;
}
}

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env node
import { parseArgv } from "~/cli/argv.js";
import { installSignalHandlers } from "~/cli/handlers/signal.js";
import { installErrorHandlers } from "~/cli/handlers/error.js";
import { showUpdateNotice } from "~/cli/update-notice.js";
import { VERSION } from "~/constants/urls.js";
import { EXIT } from "~/constants/exit-codes.js";
import { renderVersion } from "~/cli/output/version.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
installSignalHandlers();
installErrorHandlers();
const { command, positionals, flags } = parseArgv(process.argv);
const HELP = `
claudemesh — peer mesh for Claude Code sessions
${VERSION}
USAGE
claudemesh auto-connect to your mesh
claudemesh <invite-url> join a mesh, then launch
claudemesh launch --name <n> --join <url> join + launch in one step
Mesh
claudemesh create <name> create a new mesh
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
claudemesh list show your meshes (alias: ls)
claudemesh delete [slug] delete a mesh (alias: rm)
claudemesh rename <slug> <name> rename a mesh
claudemesh share [email] share mesh (invite link / send email)
Messaging
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state get|set|list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh info mesh overview
Auth
claudemesh login sign in (browser or paste token)
claudemesh register create account + sign in
claudemesh logout sign out
claudemesh whoami show current identity
Security
claudemesh verify [peer] show ed25519 safety numbers (SAS)
claudemesh grant <peer> <cap> grant capability (dm, broadcast, state-read, all)
claudemesh revoke <peer> <cap> revoke capability (or 'all')
claudemesh block <peer> revoke all capabilities (silent drop)
claudemesh grants list per-peer overrides for current mesh
claudemesh backup [file] encrypt config → portable recovery file
claudemesh restore <file> restore config from a backup file
Setup
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
claudemesh doctor diagnose issues (broker, node, claude)
claudemesh status check broker connectivity
claudemesh sync refresh mesh list from dashboard
claudemesh completions <shell> emit bash / zsh / fish completion script
claudemesh url-handler install register claudemesh:// click-to-launch
claudemesh upgrade self-update to latest alpha (rustup-style)
Flags
--version, -V show version
--help, -h show this help
--json machine-readable output
--mesh <slug> override mesh selection
-y, --yes skip confirmations
-q, --quiet suppress non-essential output
`;
async function main(): Promise<void> {
if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); }
if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); }
// Bare command or invite URL
if (!command || isInviteUrl(command)) {
// `claudemesh <invite-url>` → join + launch in one step.
// `-y` skips all interactive prompts (role=member, no groups, push mode).
if (command && isInviteUrl(command)) {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
join: normaliseInviteUrl(command),
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
const { readConfig } = await import("~/services/config/facade.js");
const config = readConfig();
if (config.meshes.length === 0) {
const { runWelcome } = await import("~/commands/welcome.js");
process.exit(await runWelcome());
}
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
switch (command) {
case "help": { console.log(HELP); break; }
// Mesh management
case "create": case "new": { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[0] ?? "", { json: !!flags.json })); break; }
case "add": case "join": { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals); break; }
case "connect": case "launch": {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: positionals[0] ?? flags.mesh as string,
name: flags.name as string,
join: flags.join as string,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string,
}, process.argv.slice(2));
break;
}
case "disconnect": { console.log(" Connection closed."); process.exit(EXIT.SUCCESS); break; }
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
case "state": {
const sub = positionals[0];
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); }
else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
break;
}
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; }
case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; }
case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; }
case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; }
case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; }
// Auth
case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; }
case "register": { const { register } = await import("~/commands/register.js"); process.exit(await register()); break; }
case "logout": { const { logout } = await import("~/commands/logout.js"); process.exit(await logout()); break; }
case "whoami": { const { whoami } = await import("~/commands/whoami.js"); process.exit(await whoami({ json: !!flags.json })); break; }
// Setup
case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; }
case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; }
case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; }
case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; }
case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; }
// Test
case "test": { const { runTest } = await import("~/commands/test.js"); process.exit(await runTest()); break; }
// CLI utilities
case "completions": { const { runCompletions } = await import("~/commands/completions.js"); process.exit(await runCompletions(positionals[0])); break; }
case "verify": { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(positionals[0], { mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
case "url-handler": { const { runUrlHandler } = await import("~/commands/url-handler.js"); process.exit(await runUrlHandler(positionals[0])); break; }
case "status-line": { const { runStatusLine } = await import("~/commands/status-line.js"); process.exit(await runStatusLine()); break; }
case "backup": { const { runBackup } = await import("~/commands/backup.js"); process.exit(await runBackup(positionals[0])); break; }
case "restore": { const { runRestore } = await import("~/commands/backup.js"); process.exit(await runRestore(positionals[0])); break; }
case "upgrade": case "update": { const { runUpgrade } = await import("~/commands/upgrade.js"); process.exit(await runUpgrade({ check: !!flags.check, yes: !!flags.y || !!flags.yes })); break; }
case "grant": { const { runGrant } = await import("~/commands/grants.js"); process.exit(await runGrant(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "revoke": { const { runRevoke } = await import("~/commands/grants.js"); process.exit(await runRevoke(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; }
case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
// Internal
case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; }
case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; }
case "seed-test-mesh": { const { runSeedTestMesh } = await import("~/commands/seed-test-mesh.js"); runSeedTestMesh(positionals); break; }
default: {
console.error(` Unknown command: ${command}. Run \`claudemesh --help\` for usage.`);
process.exit(EXIT.INVALID_ARGS);
}
}
showUpdateNotice(VERSION).catch(() => {});
}
main().catch((err) => {
console.error("Fatal: " + (err instanceof Error ? err.message : String(err)));
process.exit(EXIT.INTERNAL_ERROR);
});

View File

@@ -0,0 +1,6 @@
import { startMcpServer } from "~/mcp/server.js";
startMcpServer().catch((err) => {
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : err}\n`);
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
/**
* CLI environment config.
*
* Read once at startup. Overridable via env vars so users can point
* at a self-hosted broker or a staging instance without rebuilding.
*/
export interface CliEnv {
CLAUDEMESH_BROKER_URL: string;
CLAUDEMESH_CONFIG_DIR: string | undefined;
CLAUDEMESH_DEBUG: boolean;
}
export function loadEnv(): CliEnv {
return {
CLAUDEMESH_BROKER_URL:
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};
}
export const env = loadEnv();

View File

@@ -1,357 +0,0 @@
/**
* 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
*/
import { defineCommand, runMain } from "citty";
import { startMcpServer } from "./mcp/server";
import { runInstall, runUninstall } from "./commands/install";
import { runJoin } from "./commands/join";
import { runList } from "./commands/list";
import { runLeave } from "./commands/leave";
import { runSeedTestMesh } from "./commands/seed-test-mesh";
import { runHook } from "./commands/hook";
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 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); }
},
}),
},
async run() {
await runWelcome();
},
});
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
// every flag-only form as implicit `launch`.
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
// Flags citty handles on the root command — must not be rewritten to `launch`.
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
const argv = process.argv.slice(2);
const first = argv[0];
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
// Starts with a flag, or an unknown bareword → treat as launch args.
// (Unknown barewords that look like typos would otherwise hit citty's
// "unknown command" path; forwarding to launch lets claude surface the
// error if it's a real claude flag, and launch's own parser rejects junk.)
process.argv.splice(2, 0, "launch");
}
runMain(main);

View File

@@ -0,0 +1,10 @@
export const en = {
welcome: "Welcome to claudemesh",
signed_in_as: "Signed in as {name}",
mesh_created: 'Created "{slug}"',
invite_copied: "Invite URL copied to clipboard",
logout_success: "Signed out",
error_network: "Could not reach {url}. Check your connection.",
error_auth: "Authentication failed. Run \`claudemesh login\` to sign in.",
error_not_found: "{resource} not found",
} as const;

View File

@@ -0,0 +1 @@
export { en } from "./en.js";

View File

@@ -0,0 +1 @@
export { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

View File

@@ -0,0 +1 @@
export { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

View File

@@ -0,0 +1 @@
export function formatToolError(err: unknown): string { return err instanceof Error ? err.message : String(err); }

View File

@@ -0,0 +1,3 @@
export function logToolCall(toolName: string, durationMs: number): void {
if (process.env.CLAUDEMESH_DEBUG === "1") process.stderr.write("[mcp] " + toolName + " (" + durationMs + "ms)\n");
}

View File

@@ -0,0 +1,2 @@
// Tool dispatch — server.ts handles all routing via switch statement.
export const ROUTER_VERSION = "1.0" as const;

View File

@@ -15,9 +15,10 @@ import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { TOOLS } from "./tools";
import { loadConfig } from "../state/config";
import { startClients, stopAll, findClient, allClients } from "../ws/manager";
import { TOOLS } from "./tools/definitions.js";
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient, startClients, stopAll, findClient, allClients } from "~/services/broker/facade.js";
import type { InboundPush } from "~/services/broker/facade.js";
import type {
Priority,
PeerStatus,
@@ -25,9 +26,7 @@ import type {
SetStatusArgs,
SetSummaryArgs,
ListPeersArgs,
} from "./types";
import { BrokerClient } from "../ws/client";
import type { InboundPush } from "../ws/client";
} from "./types.js";
/** Compute a human-readable relative time string from an ISO timestamp. */
function relativeTime(isoStr: string): string {
@@ -105,6 +104,7 @@ async function resolveClient(to: string): Promise<{
p.displayName.toLowerCase().includes(nameLower),
);
if (partials.length === 1) {
process.stderr.write(`[claudemesh] resolved "${target}" → "${partials[0]!.displayName}" (partial match)\n`);
return { client: c, targetSpec: partials[0]!.pubkey };
}
}
@@ -155,7 +155,7 @@ export async function startMcpServer(): Promise<void> {
return startServiceProxy(process.argv[serviceIdx + 1]!);
}
const config = loadConfig();
const config = readConfig();
const myName = config.displayName ?? "unnamed";
const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null;
@@ -433,7 +433,7 @@ Your message mode is "${messageMode}".
switch (name) {
case "send_message": {
const { to, message, priority } = (args ?? {}) as SendMessageArgs;
const { to, message, priority } = (args ?? {}) as unknown as SendMessageArgs;
if (!to || !message)
return text("send_message: `to` and `message` required", true);
@@ -477,9 +477,17 @@ Your message mode is "${messageMode}".
true,
);
const sections: string[] = [];
// Keep the status-line cache fresh for Claude Code's statusLine renderer.
const statusCache: Record<string, { total: number; online: number; updatedAt: string; you?: string }> = {};
for (const c of clients) {
const peers = await c!.listPeers();
const header = `## ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`;
statusCache[c!.meshSlug] = {
total: peers.length,
online: peers.filter(p => p.status !== "offline").length,
updatedAt: new Date().toISOString(),
you: process.env.CLAUDEMESH_DISPLAY_NAME ?? undefined,
};
if (peers.length === 0) {
sections.push(`${header}\nNo peers connected.`);
} else {
@@ -502,6 +510,15 @@ Your message mode is "${messageMode}".
sections.push(`${header}\n${peerLines.join("\n")}`);
}
}
// Persist the peer-cache for claudemesh status-line. Best effort.
try {
const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
const { join: joinPath } = await import("node:path");
const { homedir } = await import("node:os");
const dir = joinPath(homedir(), ".claudemesh");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(joinPath(dir, "peer-cache.json"), JSON.stringify(statusCache));
} catch { /* non-fatal */ }
return text(sections.join("\n\n"));
}
@@ -542,7 +559,7 @@ Your message mode is "${messageMode}".
}
case "set_summary": {
const { summary } = (args ?? {}) as SetSummaryArgs;
const { summary } = (args ?? {}) as unknown as SetSummaryArgs;
if (!summary) return text("set_summary: `summary` required", true);
for (const c of allClients()) await c.setSummary(summary);
return text(
@@ -551,7 +568,7 @@ Your message mode is "${messageMode}".
}
case "set_status": {
const { status } = (args ?? {}) as SetStatusArgs;
const { status } = (args ?? {}) as unknown as SetStatusArgs;
if (!status) return text("set_status: `status` required", true);
const s = status as PeerStatus;
for (const c of allClients()) await c.setStatus(s);
@@ -654,6 +671,8 @@ Your message mode is "${messageMode}".
cron?: string;
};
if (!sArgs.message) return text("schedule_reminder: `message` required", true);
const client = allClients()[0];
if (!client) return text("schedule_reminder: not connected", true);
const isCron = !!sArgs.cron;
@@ -710,6 +729,8 @@ Your message mode is "${messageMode}".
);
}
case "list_scheduled": {
const client = allClients()[0];
if (!client) return text("list_scheduled: not connected", true);
const scheduled = await client.listScheduled();
if (scheduled.length === 0) return text("No pending scheduled messages.");
const lines = scheduled.map((m) =>
@@ -718,6 +739,8 @@ Your message mode is "${messageMode}".
return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`);
}
case "cancel_scheduled": {
const client = allClients()[0];
if (!client) return text("cancel_scheduled: not connected", true);
const { id: schedId } = (args ?? {}) as { id?: string };
if (!schedId) return text("cancel_scheduled: `id` required", true);
const ok = await client.cancelScheduled(schedId);
@@ -735,7 +758,7 @@ Your message mode is "${messageMode}".
// If 'to' specified, do E2E encryption
if (fileTo) {
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join, basename } = await import("node:path");
@@ -764,14 +787,15 @@ Your message mode is "${messageMode}".
];
// Build combined buffer: nonce (24 bytes) + ciphertext
const { ensureSodium } = await import("../crypto/keypair");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL);
const combined = new Uint8Array(nonceBytes.length + ciphertext.length);
combined.set(nonceBytes, 0);
combined.set(ciphertext, nonceBytes.length);
const baseName = fileName ?? basename(filePath);
const rawName = fileName ?? basename(filePath);
const baseName = basename(rawName).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255);
const tmpDir = mkdtempSync(join(tmpdir(), "cm-"));
const tmpPath = join(tmpDir, baseName);
writeFileSync(tmpPath, combined);
@@ -814,32 +838,33 @@ Your message mode is "${messageMode}".
if (!result) return text(`get_file: file ${id} not found`, true);
if (result.encrypted) {
if (!result.sealedKey) return text("get_file: encrypted file — no decryption key available for your session", true);
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const genericErr = "get_file: could not decrypt — you may not have access to this file";
if (!result.sealedKey) return text(genericErr, true);
const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) {
return text("get_file: no session keypair — cannot decrypt", true);
}
if (!myPubkey || !mySecret) return text(genericErr, true);
const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret);
if (!kf) return text("get_file: failed to open sealed key", true);
if (!kf) return text(genericErr, true);
// Download file bytes from presigned URL
const MAX_DOWNLOAD = 100 * 1024 * 1024; // 100 MB
const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true);
const contentLength = parseInt(resp.headers.get("content-length") ?? "0", 10);
if (contentLength > MAX_DOWNLOAD) return text(`get_file: file too large (${contentLength} bytes)`, true);
const buf = new Uint8Array(await resp.arrayBuffer());
if (buf.length > MAX_DOWNLOAD) return text(`get_file: file too large (${buf.length} bytes)`, true);
// Wire format: first 24 bytes = nonce, rest = ciphertext
const sodium = await ensureSodium();
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES;
if (buf.length < NONCE_BYTES) return text(genericErr, true);
const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL);
const ciphertext = buf.slice(NONCE_BYTES);
const plaintext = await decryptFile(ciphertext, nonce, kf);
if (!plaintext) return text("get_file: decryption failed", true);
if (!plaintext) return text(genericErr, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
@@ -852,7 +877,7 @@ Your message mode is "${messageMode}".
let res = await fetch(result.url, { signal: AbortSignal.timeout(10_000) }).catch(() => null);
if (!res || !res.ok) {
// Presigned URL failed (internal MinIO hostname) — use broker proxy
const brokerHttp = client.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
const brokerHttp = client.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
res = await fetch(`${brokerHttp}/download/${id}?mesh=${client.meshId}`, { signal: AbortSignal.timeout(30_000) });
}
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
@@ -1379,7 +1404,7 @@ Your message mode is "${messageMode}".
if (!result.encrypted) return text("grant_file_access: file is not encrypted", true);
if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true);
const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto");
const { openSealedKey, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const myPubkey = client.getSessionPubkey();
const mySecret = client.getSessionSecretKey();
if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true);
@@ -1512,6 +1537,9 @@ Your message mode is "${messageMode}".
key?: string; value?: string; type?: "env" | "file"; mount_path?: string; description?: string;
};
if (!key || !value) return text("vault_set: `key` and `value` required", true);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(key)) return text("vault_set: `key` must be 1-128 alphanumeric/underscore/dot/dash chars", true);
if (mount_path && (mount_path.includes("..") || mount_path.length > 512)) return text("vault_set: invalid `mount_path`", true);
if (description && description.length > 500) return text("vault_set: `description` too long (max 500 chars)", true);
const client = allClients()[0];
if (!client) return text("vault_set: not connected", true);
const entryType = vType ?? "env";
@@ -1527,12 +1555,12 @@ Your message mode is "${messageMode}".
}
// E2E encrypt: crypto_secretbox with random Kf, then seal Kf with mesh pubkey
const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto");
const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js");
const { ciphertext, nonce, key: kf } = await encryptFile(plaintextBytes);
const sealedKey = await sealKeyForPeer(kf, client.getMeshPubkey());
// Convert ciphertext to base64 for storage
const { ensureSodium } = await import("../crypto/keypair");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const ciphertextB64 = sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
@@ -1597,8 +1625,8 @@ Your message mode is "${messageMode}".
// Fetch + decrypt vault entries client-side
if (vaultRefs.length > 0) {
const { openSealedKey, decryptFile } = await import("../crypto/file-crypto");
const { ensureSodium } = await import("../crypto/keypair");
const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js");
const { ensureSodium } = await import("~/services/crypto/keypair.js");
const sodium = await ensureSodium();
const keys = vaultRefs.map(r => r.vaultKey);
@@ -1606,15 +1634,15 @@ Your message mode is "${messageMode}".
for (const ref of vaultRefs) {
const entry = encryptedEntries.find((e: any) => e.key === ref.vaultKey);
if (!entry) return text(`mesh_mcp_deploy: vault key "${ref.vaultKey}" not found. Use vault_set first.`, true);
if (!entry) return text(`mesh_mcp_deploy: a referenced vault key was not found. Use vault_set first.`, true);
// Decrypt: open sealed key with mesh keypair, then decrypt ciphertext
const kf = await openSealedKey(entry.sealed_key, client.getMeshPubkey(), client.getMeshSecretKey());
if (!kf) return text(`mesh_mcp_deploy: failed to decrypt vault key "${ref.vaultKey}" — wrong keypair?`, true);
if (!kf) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — wrong keypair?`, true);
const ciphertextBytes = sodium.from_base64(entry.ciphertext, sodium.base64_variants.ORIGINAL);
const plainBytes = await decryptFile(ciphertextBytes, entry.nonce, kf);
if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt vault entry "${ref.vaultKey}" — corrupted?`, true);
if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — data may be corrupted`, true);
if (ref.isFile && ref.mountPath) {
// For file-type entries: the plaintext is the file content (raw bytes).
@@ -1826,7 +1854,7 @@ Your message mode is "${messageMode}".
event: eventName,
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
...(Object.keys(data).length > 0 ? { eventData: data } : {}),
...(Object.keys(data).length > 0 ? { eventData: JSON.stringify(data) } : {}),
},
},
});
@@ -1842,6 +1870,18 @@ Your message mode is "${messageMode}".
? await resolvePeerName(client, fromPubkey)
: "unknown";
// Per-peer capability check — drop silently if sender lacks `dm`.
if (fromPubkey) {
try {
const { isAllowed } = await import("~/commands/grants.js");
const kindCap = msg.kind === "broadcast" ? "broadcast" : "dm";
if (!isAllowed(client.meshSlug, fromPubkey, kindCap)) {
process.stderr.write(`[claudemesh] dropped ${kindCap} from ${fromName} (not granted)\n`);
return;
}
} catch { /* fail-open on grant-read errors — don't break delivery */ }
}
if (messageMode === "inbox") {
try {
await server.notification({
@@ -1855,8 +1895,13 @@ Your message mode is "${messageMode}".
return;
}
// push mode — full content
const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
// push mode — full content. Format the content so it reads as a
// first-class chat message even though Claude Code renders it as a
// <channel> reminder: sender attribution + priority badge + body.
const body = msg.plaintext ?? decryptFailedWarning(fromPubkey);
const prioBadge = msg.priority === "now" ? "[URGENT] " : msg.priority === "low" ? "[low] " : "";
const kindBadge = msg.kind === "broadcast" ? " (broadcast)" : "";
const content = `${prioBadge}${fromName}${kindBadge}: ${body}`;
try {
await server.notification({
method: "notifications/claude/channel",
@@ -1875,7 +1920,7 @@ Your message mode is "${messageMode}".
},
},
});
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${content.slice(0, 60)}\n`);
process.stderr.write(`[claudemesh] pushed: from=${fromName} content=${body.slice(0, 60)}\n`);
} catch (pushErr) {
process.stderr.write(`[claudemesh] push FAILED: ${pushErr}\n`);
}
@@ -1970,7 +2015,7 @@ Your message mode is "${messageMode}".
* Code will not auto-restart it.
*/
async function startServiceProxy(serviceName: string): Promise<void> {
const config = loadConfig();
const config = readConfig();
if (config.meshes.length === 0) {
process.stderr.write(`[mesh:${serviceName}] no meshes joined\n`);
process.exit(1);
@@ -2035,13 +2080,13 @@ async function startServiceProxy(serviceName: string): Promise<void> {
const args = req.params.arguments ?? {};
// Wait for broker reconnection if needed
if (client.status !== "open") {
if ((client.status as string) !== "open") {
let waited = 0;
while (client.status !== "open" && waited < 10_000) {
while ((client.status as string) !== "open" && waited < 10_000) {
await new Promise((r) => setTimeout(r, 500));
waited += 500;
}
if (client.status !== "open") {
if ((client.status as string) !== "open") {
return {
content: [
{
@@ -2105,7 +2150,7 @@ async function startServiceProxy(serviceName: string): Promise<void> {
// Refresh tools
const newTools = (push.eventData as any)?.tools;
if (Array.isArray(newTools)) {
tools = newTools;
tools = newTools as typeof tools;
// Notify Claude Code that tools changed
server
.notification({

View File

@@ -0,0 +1,4 @@
// MCP tool family: clock-write
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "clock-write" as const;
export const TOOLS = ["mesh_set_clock", "mesh_pause_clock", "mesh_resume_clock"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: contexts
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "contexts" as const;
export const TOOLS = ["share_context", "get_context", "list_contexts"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: files
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "files" as const;
export const TOOLS = ["share_file", "get_file", "list_files", "file_status", "delete_file", "grant_file_access", "read_peer_file", "list_peer_files"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: graph
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "graph" as const;
export const TOOLS = ["graph_query", "graph_execute"] as const;

View File

@@ -0,0 +1,4 @@
// MCP tool family: groups
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "groups" as const;
export const TOOLS = ["join_group", "leave_group"] as const;

View File

@@ -0,0 +1,21 @@
export { FAMILY as memoryFamily, TOOLS as memoryTools } from "./memory.js";
export { FAMILY as stateFamily, TOOLS as stateTools } from "./state.js";
export { FAMILY as messagingFamily, TOOLS as messagingTools } from "./messaging.js";
export { FAMILY as profileFamily, TOOLS as profileTools } from "./profile.js";
export { FAMILY as groupsFamily, TOOLS as groupsTools } from "./groups.js";
export { FAMILY as filesFamily, TOOLS as filesTools } from "./files.js";
export { FAMILY as vectorsFamily, TOOLS as vectorsTools } from "./vectors.js";
export { FAMILY as graphFamily, TOOLS as graphTools } from "./graph.js";
export { FAMILY as sqlFamily, TOOLS as sqlTools } from "./sql.js";
export { FAMILY as streamsFamily, TOOLS as streamsTools } from "./streams.js";
export { FAMILY as contextsFamily, TOOLS as contextsTools } from "./contexts.js";
export { FAMILY as tasksFamily, TOOLS as tasksTools } from "./tasks.js";
export { FAMILY as schedulingFamily, TOOLS as schedulingTools } from "./scheduling.js";
export { FAMILY as meshMetaFamily, TOOLS as meshMetaTools } from "./mesh-meta.js";
export { FAMILY as clockWriteFamily, TOOLS as clockWriteTools } from "./clock-write.js";
export { FAMILY as skillsFamily, TOOLS as skillsTools } from "./skills.js";
export { FAMILY as mcpRegistryPeerFamily, TOOLS as mcpRegistryPeerTools } from "./mcp-registry-peer.js";
export { FAMILY as mcpRegistryBrokerFamily, TOOLS as mcpRegistryBrokerTools } from "./mcp-registry-broker.js";
export { FAMILY as vaultFamily, TOOLS as vaultTools } from "./vault.js";
export { FAMILY as urlWatchFamily, TOOLS as urlWatchTools } from "./url-watch.js";
export { FAMILY as webhooksFamily, TOOLS as webhooksTools } from "./webhooks.js";

View File

@@ -0,0 +1,4 @@
// MCP tool family: mcp-registry-broker
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
export const FAMILY = "mcp-registry-broker" as const;
export const TOOLS = ["mesh_mcp_deploy", "mesh_mcp_undeploy", "mesh_mcp_update", "mesh_mcp_logs", "mesh_mcp_scope", "mesh_mcp_schema", "mesh_mcp_catalog"] as const;

Some files were not shown because too many files have changed in this diff Show More