refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
30
apps/cli/src/cli/argv.ts
Normal 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
7
apps/cli/src/cli/exit.ts
Normal 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);
|
||||
}
|
||||
12
apps/cli/src/cli/handlers/error.ts
Normal file
12
apps/cli/src/cli/handlers/error.ts
Normal 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));
|
||||
}
|
||||
6
apps/cli/src/cli/handlers/signal.ts
Normal file
6
apps/cli/src/cli/handlers/signal.ts
Normal 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); });
|
||||
}
|
||||
6
apps/cli/src/cli/output/list.ts
Normal file
6
apps/cli/src/cli/output/list.ts
Normal 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");
|
||||
}
|
||||
11
apps/cli/src/cli/output/peers.ts
Normal file
11
apps/cli/src/cli/output/peers.ts
Normal 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");
|
||||
}
|
||||
3
apps/cli/src/cli/output/version.ts
Normal file
3
apps/cli/src/cli/output/version.ts
Normal 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; }
|
||||
11
apps/cli/src/cli/output/whoami.ts
Normal file
11
apps/cli/src/cli/output/whoami.ts
Normal 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");
|
||||
}
|
||||
7
apps/cli/src/cli/print.ts
Normal file
7
apps/cli/src/cli/print.ts
Normal 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; }
|
||||
4
apps/cli/src/cli/structured-io.ts
Normal file
4
apps/cli/src/cli/structured-io.ts
Normal 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)); }
|
||||
11
apps/cli/src/cli/update-notice.ts
Normal file
11
apps/cli/src/cli/update-notice.ts
Normal 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 {}
|
||||
}
|
||||
147
apps/cli/src/commands/backup.ts
Normal file
147
apps/cli/src/commands/backup.ts
Normal 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;
|
||||
}
|
||||
122
apps/cli/src/commands/completions.ts
Normal file
122
apps/cli/src/commands/completions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
128
apps/cli/src/commands/delete-mesh.ts
Normal file
128
apps/cli/src/commands/delete-mesh.ts
Normal 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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
208
apps/cli/src/commands/grants.ts
Normal file
208
apps/cli/src/commands/grants.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
apps/cli/src/commands/index.ts
Normal file
29
apps/cli/src/commands/index.ts
Normal 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";
|
||||
@@ -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([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
96
apps/cli/src/commands/invite.ts
Normal file
96
apps/cli/src/commands/invite.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
118
apps/cli/src/commands/login.ts
Normal file
118
apps/cli/src/commands/login.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/cli/src/commands/logout.ts
Normal file
22
apps/cli/src/commands/logout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
apps/cli/src/commands/mcp.ts
Normal file
9
apps/cli/src/commands/mcp.ts
Normal 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 };
|
||||
@@ -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("");
|
||||
}
|
||||
});
|
||||
}
|
||||
48
apps/cli/src/commands/new.ts
Normal file
48
apps/cli/src/commands/new.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
35
apps/cli/src/commands/recall.ts
Normal file
35
apps/cli/src/commands/recall.ts
Normal 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;
|
||||
}
|
||||
8
apps/cli/src/commands/register.ts
Normal file
8
apps/cli/src/commands/register.ts
Normal 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();
|
||||
}
|
||||
28
apps/cli/src/commands/remember.ts
Normal file
28
apps/cli/src/commands/remember.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
14
apps/cli/src/commands/rename.ts
Normal file
14
apps/cli/src/commands/rename.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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\`.`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
apps/cli/src/commands/status-line.ts
Normal file
69
apps/cli/src/commands/status-line.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)`));
|
||||
|
||||
228
apps/cli/src/commands/test.ts
Normal file
228
apps/cli/src/commands/test.ts
Normal 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;
|
||||
}
|
||||
58
apps/cli/src/commands/uninstall.ts
Normal file
58
apps/cli/src/commands/uninstall.ts
Normal 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;
|
||||
}
|
||||
99
apps/cli/src/commands/upgrade.ts
Normal file
99
apps/cli/src/commands/upgrade.ts
Normal 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;
|
||||
}
|
||||
178
apps/cli/src/commands/url-handler.ts
Normal file
178
apps/cli/src/commands/url-handler.ts
Normal 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;
|
||||
}
|
||||
95
apps/cli/src/commands/verify.ts
Normal file
95
apps/cli/src/commands/verify.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
26
apps/cli/src/commands/whoami.ts
Normal file
26
apps/cli/src/commands/whoami.ts
Normal 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;
|
||||
}
|
||||
14
apps/cli/src/constants/exit-codes.ts
Normal file
14
apps/cli/src/constants/exit-codes.ts
Normal 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];
|
||||
5
apps/cli/src/constants/index.ts
Normal file
5
apps/cli/src/constants/index.ts
Normal 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";
|
||||
22
apps/cli/src/constants/paths.ts
Normal file
22
apps/cli/src/constants/paths.ts
Normal 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;
|
||||
11
apps/cli/src/constants/timings.ts
Normal file
11
apps/cli/src/constants/timings.ts
Normal 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;
|
||||
18
apps/cli/src/constants/urls.ts
Normal file
18
apps/cli/src/constants/urls.ts
Normal 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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
200
apps/cli/src/entrypoints/cli.ts
Normal file
200
apps/cli/src/entrypoints/cli.ts
Normal 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);
|
||||
});
|
||||
6
apps/cli/src/entrypoints/mcp.ts
Normal file
6
apps/cli/src/entrypoints/mcp.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
10
apps/cli/src/locales/en.ts
Normal file
10
apps/cli/src/locales/en.ts
Normal 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;
|
||||
1
apps/cli/src/locales/index.ts
Normal file
1
apps/cli/src/locales/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { en } from "./en.js";
|
||||
1
apps/cli/src/mcp/handlers/jsonrpc.ts
Normal file
1
apps/cli/src/mcp/handlers/jsonrpc.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
1
apps/cli/src/mcp/handlers/stdio.ts
Normal file
1
apps/cli/src/mcp/handlers/stdio.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
1
apps/cli/src/mcp/middleware/error-handler.ts
Normal file
1
apps/cli/src/mcp/middleware/error-handler.ts
Normal file
@@ -0,0 +1 @@
|
||||
export function formatToolError(err: unknown): string { return err instanceof Error ? err.message : String(err); }
|
||||
3
apps/cli/src/mcp/middleware/logging.ts
Normal file
3
apps/cli/src/mcp/middleware/logging.ts
Normal 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");
|
||||
}
|
||||
2
apps/cli/src/mcp/router.ts
Normal file
2
apps/cli/src/mcp/router.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tool dispatch — server.ts handles all routing via switch statement.
|
||||
export const ROUTER_VERSION = "1.0" as const;
|
||||
@@ -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({
|
||||
|
||||
4
apps/cli/src/mcp/tools/clock-write.ts
Normal file
4
apps/cli/src/mcp/tools/clock-write.ts
Normal 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;
|
||||
4
apps/cli/src/mcp/tools/contexts.ts
Normal file
4
apps/cli/src/mcp/tools/contexts.ts
Normal 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;
|
||||
4
apps/cli/src/mcp/tools/files.ts
Normal file
4
apps/cli/src/mcp/tools/files.ts
Normal 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;
|
||||
4
apps/cli/src/mcp/tools/graph.ts
Normal file
4
apps/cli/src/mcp/tools/graph.ts
Normal 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;
|
||||
4
apps/cli/src/mcp/tools/groups.ts
Normal file
4
apps/cli/src/mcp/tools/groups.ts
Normal 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;
|
||||
21
apps/cli/src/mcp/tools/index.ts
Normal file
21
apps/cli/src/mcp/tools/index.ts
Normal 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";
|
||||
4
apps/cli/src/mcp/tools/mcp-registry-broker.ts
Normal file
4
apps/cli/src/mcp/tools/mcp-registry-broker.ts
Normal 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;
|
||||
4
apps/cli/src/mcp/tools/mcp-registry-peer.ts
Normal file
4
apps/cli/src/mcp/tools/mcp-registry-peer.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: mcp-registry-peer
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "mcp-registry-peer" as const;
|
||||
export const TOOLS = ["mesh_mcp_register", "mesh_mcp_list", "mesh_tool_call", "mesh_mcp_remove"] as const;
|
||||
4
apps/cli/src/mcp/tools/memory.ts
Normal file
4
apps/cli/src/mcp/tools/memory.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: memory
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "memory" as const;
|
||||
export const TOOLS = ["remember", "recall", "forget"] as const;
|
||||
4
apps/cli/src/mcp/tools/mesh-meta.ts
Normal file
4
apps/cli/src/mcp/tools/mesh-meta.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: mesh-meta
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "mesh-meta" as const;
|
||||
export const TOOLS = ["mesh_info", "mesh_stats", "mesh_clock", "ping_mesh"] as const;
|
||||
4
apps/cli/src/mcp/tools/messaging.ts
Normal file
4
apps/cli/src/mcp/tools/messaging.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: messaging
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "messaging" as const;
|
||||
export const TOOLS = ["send_message", "list_peers", "check_messages", "message_status"] as const;
|
||||
4
apps/cli/src/mcp/tools/profile.ts
Normal file
4
apps/cli/src/mcp/tools/profile.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: profile
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "profile" as const;
|
||||
export const TOOLS = ["set_profile", "set_status", "set_summary", "set_visible"] as const;
|
||||
4
apps/cli/src/mcp/tools/scheduling.ts
Normal file
4
apps/cli/src/mcp/tools/scheduling.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: scheduling
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "scheduling" as const;
|
||||
export const TOOLS = ["schedule_reminder", "list_scheduled", "cancel_scheduled"] as const;
|
||||
4
apps/cli/src/mcp/tools/skills.ts
Normal file
4
apps/cli/src/mcp/tools/skills.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: skills
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "skills" as const;
|
||||
export const TOOLS = ["share_skill", "get_skill", "list_skills", "remove_skill", "mesh_skill_deploy"] as const;
|
||||
4
apps/cli/src/mcp/tools/sql.ts
Normal file
4
apps/cli/src/mcp/tools/sql.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: sql
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "sql" as const;
|
||||
export const TOOLS = ["mesh_query", "mesh_execute", "mesh_schema"] as const;
|
||||
4
apps/cli/src/mcp/tools/state.ts
Normal file
4
apps/cli/src/mcp/tools/state.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: state
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "state" as const;
|
||||
export const TOOLS = ["set_state", "get_state", "list_state"] as const;
|
||||
4
apps/cli/src/mcp/tools/streams.ts
Normal file
4
apps/cli/src/mcp/tools/streams.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: streams
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "streams" as const;
|
||||
export const TOOLS = ["create_stream", "publish", "subscribe", "list_streams"] as const;
|
||||
4
apps/cli/src/mcp/tools/tasks.ts
Normal file
4
apps/cli/src/mcp/tools/tasks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: tasks
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "tasks" as const;
|
||||
export const TOOLS = ["create_task", "claim_task", "complete_task", "list_tasks"] as const;
|
||||
4
apps/cli/src/mcp/tools/url-watch.ts
Normal file
4
apps/cli/src/mcp/tools/url-watch.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: url-watch
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "url-watch" as const;
|
||||
export const TOOLS = ["mesh_watch", "mesh_unwatch", "mesh_watches"] as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user