chore(cli-v2): un-ignore CLI source tree for binary release workflow
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

The CLI source (242 files, ~14k lines) was gitignored during the
earlier cli→cli-v2 reorg so only the published npm package carried it.
That blocks the GitHub Actions release workflow (release-cli.yml),
which clones the repo fresh on each runner and needs the source to
compile binaries via `bun build --compile`.

Moves the gitignore from root-level to `apps/cli-v2/.gitignore` with
only the usual build artefacts excluded (node_modules, dist, .turbo,
.cache). Source is now in git at apps/cli-v2/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 02:45:44 +01:00
parent 5b69de08da
commit d37516213a
243 changed files with 14507 additions and 1 deletions

1
.gitignore vendored
View File

@@ -76,4 +76,3 @@ apps/web/payload.db
apps/web/public/media/* apps/web/public/media/*
!apps/web/public/media/.gitkeep !apps/web/public/media/.gitkeep
.env.local .env.local
apps/cli-v2/

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

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

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

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

90
apps/cli-v2/README.md Normal file
View File

@@ -0,0 +1,90 @@
# claudemesh-cli
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
## Install
```bash
npm i -g claudemesh-cli
```
## Quick start
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
## Commands
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Architecture
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── api/ typed HTTP client for claudemesh.com
│ ├── health/ 6 diagnostic checks
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
├── mcp/ MCP server with 79 tools across 21 families
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Development
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

View File

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

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

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

51
apps/cli-v2/build.ts Normal file
View File

@@ -0,0 +1,51 @@
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
const MAX_GZIPPED_BYTES = 1.2 * 1024 * 1024; // 1.2 MB
const result = await Bun.build({
entrypoints: [
"src/entrypoints/cli.ts",
"src/entrypoints/mcp.ts",
],
outdir: "dist/entrypoints",
target: "node",
format: "esm",
splitting: false,
sourcemap: "external",
external: [
"libsodium-wrappers",
"ws",
"@modelcontextprotocol/sdk",
],
});
if (!result.success) {
console.error("Build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
for (const output of result.outputs) {
const raw = statSync(output.path).size;
const gz = gzipSync(await Bun.file(output.path).arrayBuffer()).byteLength;
const label = output.path.replace(process.cwd() + "/", "");
console.log(` ${label} ${(raw / 1024).toFixed(0)} KB (${(gz / 1024).toFixed(0)} KB gzipped)`);
if (gz > MAX_GZIPPED_BYTES) {
console.error(`\n ERROR: ${label} exceeds 1.2 MB gzipped ceiling (${(gz / 1024).toFixed(0)} KB)`);
process.exit(1);
}
}
const { chmodSync, readFileSync, writeFileSync } = await import("node:fs");
const cliPath = "dist/entrypoints/cli.js";
const cliContent = readFileSync(cliPath, "utf-8");
if (!cliContent.startsWith("#!")) {
writeFileSync(cliPath, "#!/usr/bin/env node\n" + cliContent);
}
chmodSync(cliPath, 0o755);
console.log("\nBuild complete.");

69
apps/cli-v2/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "claudemesh-cli-v2",
"version": "1.0.0-alpha.29",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
"mcp",
"model-context-protocol",
"claudemesh",
"peer-messaging",
"multi-agent"
],
"author": "Alejandro Gutiérrez",
"license": "MIT",
"homepage": "https://claudemesh.com",
"repository": {
"type": "git",
"url": "https://github.com/alezmad/claudemesh.git",
"directory": "apps/cli-v2"
},
"type": "module",
"bin": {
"claudemesh": "./dist/entrypoints/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bun build.ts",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/entrypoints/cli.ts",
"start": "bun src/entrypoints/cli.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"qrcode-terminal": "0.12.0",
"ws": "8.20.0",
"zod": "4.1.13"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/qrcode-terminal": "0.12.2",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

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

View File

@@ -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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import { readConfig } from "~/services/config/facade.js";
export async function connectTelegram(args: string[]): Promise<void> {
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
process.exit(1);
}
const mesh = config.meshes[0]!;
const linkOnly = args.includes("--link");
// Convert WS broker URL to HTTP
const brokerHttp = mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
console.log("Requesting Telegram connect token...");
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meshId: mesh.meshId,
memberId: mesh.memberId,
pubkey: mesh.pubkey,
secretKey: mesh.secretKey,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
const { token, deepLink } = (await res.json()) as {
token: string;
deepLink: string;
};
if (linkOnly) {
console.log(deepLink);
return;
}
// Print QR code using simple block characters
console.log("\n Connect Telegram to your mesh:\n");
console.log(` ${deepLink}\n`);
console.log(" Open this link on your phone, or scan the QR code");
console.log(" with your Telegram camera.\n");
// Try to generate QR with qrcode-terminal if available
try {
const QRCode = require("qrcode-terminal");
QRCode.generate(deepLink, { small: true }, (code: string) => {
console.log(code);
});
} catch {
// qrcode-terminal not available, link is enough
console.log(" (Install qrcode-terminal for QR code display)");
}
}

View File

@@ -0,0 +1,81 @@
/**
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
*
* Opens a connection to one mesh, runs a callback, then closes cleanly.
* The caller never deals with connect/close lifecycle.
*/
import { hostname } from "node:os";
import { 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 = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
let mesh: JoinedMesh;
if (opts.meshSlug) {
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
if (!found) {
console.error(
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
mesh = await pickMesh(config.meshes);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
const client = new BrokerClient(mesh, { displayName });
try {
await client.connect();
const result = await fn(client, mesh);
return result;
} finally {
client.close();
}
}

View File

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

View File

@@ -0,0 +1,281 @@
/**
* `claudemesh doctor` — diagnostic checks.
*
* Walks through the install + runtime preconditions and prints each
* as pass/fail with a fix hint on failure. Exit 0 if everything
* passes, 1 otherwise.
*/
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 { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION, URLS } from "~/constants/urls.js";
interface Check {
name: string;
pass: boolean;
detail?: string;
fix?: string;
}
function checkNode(): Check {
const major = Number(process.versions.node.split(".")[0]);
return {
name: "Node.js >= 20",
pass: major >= 20,
detail: `v${process.versions.node}`,
fix: "Install Node 20 or newer (https://nodejs.org)",
};
}
function checkClaudeOnPath(): Check {
const res =
platform() === "win32"
? spawnSync("where", ["claude"])
: spawnSync("sh", ["-c", "command -v claude"]);
const onPath = res.status === 0;
const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined;
return {
name: "claude binary on PATH",
pass: onPath,
detail: location,
fix: "Install Claude Code (https://claude.com/claude-code)",
};
}
function checkMcpRegistered(): Check {
const claudeConfig = join(homedir(), ".claude.json");
if (!existsSync(claudeConfig)) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
fix: "Run `claudemesh install`",
};
}
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
const registered = Boolean(cfg.mcpServers?.["claudemesh"]);
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: registered,
fix: registered ? undefined : "Run `claudemesh install`",
};
} catch (e) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Check ~/.claude.json for JSON parse errors",
};
}
}
function checkHooksRegistered(): Check {
const settings = join(homedir(), ".claude", "settings.json");
if (!existsSync(settings)) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
fix: "Run `claudemesh install` (remove --no-hooks)",
};
}
try {
const raw = readFileSync(settings, "utf-8");
const has = raw.includes("claudemesh hook ");
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: has,
fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)",
};
} catch (e) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
function checkConfigFile(): Check {
const path = getConfigPath();
if (!existsSync(path)) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: true,
detail: "not created yet (fine — no meshes joined)",
};
}
try {
readConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
return {
name: "~/.claudemesh/config.json parses + chmod 0600",
pass: secure,
detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`,
fix: secure ? undefined : `chmod 600 ${path}`,
};
} catch (e) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Inspect or delete ~/.claudemesh/config.json and re-join",
};
}
}
function checkKeypairs(): Check {
try {
const cfg = readConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
pass: true,
detail: "no meshes joined",
};
}
for (const m of cfg.meshes) {
if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: pubkey malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: secret key malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
}
return {
name: "Mesh keypairs valid",
pass: true,
detail: `${cfg.meshes.length} mesh(es)`,
};
} catch (e) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
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;
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 doctor (v${VERSION})`);
console.log("─".repeat(60));
const checks: Check[] = [
checkNode(),
checkClaudeOnPath(),
checkMcpRegistered(),
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
await checkBrokerWs(),
await checkNpmLatest(),
];
for (const c of checks) {
const mark = c.pass ? green("✓") : red("✗");
const detail = c.detail ? dim(` (${c.detail})`) : "";
console.log(`${mark} ${c.name}${detail}`);
if (!c.pass && c.fix) {
console.log(dim(`${c.fix}`));
}
}
const failing = checks.filter((c) => !c.pass);
console.log("");
if (failing.length === 0) {
console.log(green("All checks passed."));
process.exit(0);
} else {
console.log(red(`${failing.length} check(s) failed.`));
process.exit(1);
}
}

View File

@@ -0,0 +1,176 @@
/**
* `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";
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);
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);
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);
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
return EXIT.SUCCESS;
}
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
return EXIT.SUCCESS;
}
render.section(`grants on ${mesh}`);
const peerPubkeys = Object.keys(meshGrants);
if (peerPubkeys.length === 0) {
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
return EXIT.SUCCESS;
}
await withMesh({ meshSlug: mesh }, async (client) => {
const peers = await client.listPeers();
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
for (const [pk, caps] of Object.entries(meshGrants)) {
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}`;
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
}
});
return EXIT.SUCCESS;
}
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
const store = readGrants();
const entry = store[meshSlug]?.[peerPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
return entry.includes(cap);
}

View File

@@ -0,0 +1,123 @@
/**
* `claudemesh hook <status>` — Claude Code hook handler.
*
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
* On each turn boundary, Claude Code invokes:
*
* Stop → `claudemesh hook idle`
* UserPromptSubmit → `claudemesh hook working`
*
* We read the Claude Code hook JSON payload from stdin (contains cwd +
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
* broker with {cwd, pid, status, session_id}. Each broker looks up
* its local presence row by (pid, cwd) and updates status.
*
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
* surface errors to the user. Debug logging available via
* CLAUDEMESH_HOOK_DEBUG=1.
*
* Why send to every broker? A user joined to multiple meshes has
* one presence row per mesh, each on its own broker. A turn boundary
* updates the status on every broker where this session is active.
* Brokers that don't have a matching presence just queue the signal
* in pending_status (harmless, TTL-swept).
*/
import { readConfig } from "~/services/config/facade.js";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
function debug(msg: string): void {
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
}
/** WS URL → HTTP URL (same host, swap scheme). */
function wsToHttp(wsUrl: string): string {
try {
const u = new URL(wsUrl);
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
return `${httpScheme}//${u.host}`;
} catch {
return wsUrl;
}
}
async function readStdinJson(): Promise<Record<string, unknown>> {
if (process.stdin.isTTY) return {};
const chunks: Uint8Array[] = [];
const reader = process.stdin;
try {
for await (const chunk of reader) {
chunks.push(chunk as Uint8Array);
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
}
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) return {};
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
async function postHook(
brokerWsUrl: string,
body: Record<string, unknown>,
): Promise<void> {
const base = wsToHttp(brokerWsUrl);
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 1000);
await fetch(`${base}/hook/set-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(t));
} catch (e) {
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
}
}
export async function runHook(args: string[]): Promise<void> {
const status = args[0];
if (!status || !["idle", "working", "dnd"].includes(status)) {
// Silent no-op — we never want a hook to surface an error.
process.exit(0);
}
// Read Claude Code's stdin payload for cwd + session_id.
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
setTimeout(() => r({}), 500),
);
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
const cwd =
(typeof payload.cwd === "string" && payload.cwd) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
const sessionId =
(typeof payload.session_id === "string" && payload.session_id) || "";
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = readConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);
}
if (config.meshes.length === 0) {
debug("no joined meshes, nothing to do");
process.exit(0);
}
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
debug(
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
);
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
// covers them (broker resolves presence by cwd+pid regardless).
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
process.exit(0);
}

View File

@@ -0,0 +1,60 @@
/**
* `claudemesh inbox` — read pending peer messages.
*
* Connects, waits briefly for push delivery, drains the buffer, prints.
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
export interface InboxFlags {
mesh?: string;
json?: boolean;
wait?: number;
}
function formatMessage(msg: InboundPush, useColor: boolean): string {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
}
export async function runInbox(flags: InboxFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const waitMs = (flags.wait ?? 1) * 1000;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
// Wait briefly for broker to push any held messages.
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer();
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return;
}
if (messages.length === 0) {
console.log(dim(`No messages on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
console.log("");
for (const msg of messages) {
console.log(formatMessage(msg, useColor));
console.log("");
}
});
}

View File

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

View File

@@ -0,0 +1,58 @@
/**
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
*
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface InfoFlags {
mesh?: string;
json?: boolean;
}
export async function runInfo(flags: InfoFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([
client.meshInfo(),
client.listPeers(),
client.listState(),
]);
const output = {
slug: mesh.slug,
meshId: mesh.meshId,
memberId: mesh.memberId,
brokerUrl: mesh.brokerUrl,
displayName: config.displayName ?? null,
peerCount: peers.length,
stateCount: state.length,
...(brokerInfo ?? {}),
};
if (flags.json) {
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
console.log(dim(` mesh: ${mesh.meshId}`));
console.log(dim(` member: ${mesh.memberId}`));
console.log(` peers: ${peers.length} connected`);
console.log(` state: ${state.length} keys`);
if (brokerInfo && typeof brokerInfo === "object") {
for (const [k, v] of Object.entries(brokerInfo)) {
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
}
}
});
}

View File

@@ -0,0 +1,564 @@
/**
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
*
* install:
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
* 2. Read ~/.claude.json (or empty object if absent).
* 3. Add/update `mcpServers.claudemesh` with the resolved entry path.
* 4. Write back with 0600 perms.
* 5. Verify via read-back, print success.
*
* uninstall:
* 1. Read ~/.claude.json (bail if missing).
* 2. Delete `mcpServers.claudemesh` if present.
* 3. Write back.
*
* Both are idempotent — re-running install is a no-op if the entry is
* already correct, and uninstall is a no-op if no entry exists.
*/
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
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 { readConfig } from "~/services/config/facade.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
const HOOK_COMMAND_STOP = "claudemesh hook idle";
const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working";
const HOOK_MARKER = "claudemesh hook ";
type McpEntry = {
command: string;
args?: string[];
env?: Record<string, string>;
};
interface HookCommand {
type: "command";
command: string;
}
interface HookMatcher {
matcher?: string;
hooks: HookCommand[];
}
type HooksConfig = Record<string, HookMatcher[]>;
function readClaudeConfig(): Record<string, unknown> {
if (!existsSync(CLAUDE_CONFIG)) return {};
const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_CONFIG}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
/**
* Create a timestamped backup of ~/.claude.json before any write.
*/
function backupClaudeConfig(): void {
if (!existsSync(CLAUDE_CONFIG)) return;
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
mkdirSync(backupDir, { recursive: true });
const ts = Date.now();
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
copyFileSync(CLAUDE_CONFIG, dest);
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns the action taken ("added" | "updated" | "unchanged").
*/
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers =
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
if (!cfg.mcpServers) cfg.mcpServers = servers;
const existing = servers[MCP_NAME];
let action: "added" | "updated" | "unchanged";
if (!existing) {
servers[MCP_NAME] = entry;
action = "added";
} else if (entriesEqual(existing, entry)) {
return "unchanged";
} else {
servers[MCP_NAME] = entry;
action = "updated";
}
flushClaudeConfig(cfg);
return action;
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns true if an entry was removed.
*/
function removeMcpServer(): boolean {
if (!existsSync(CLAUDE_CONFIG)) return false;
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
if (!servers || !(MCP_NAME in servers)) return false;
delete servers[MCP_NAME];
cfg.mcpServers = servers;
flushClaudeConfig(cfg);
return true;
}
/** Low-level write — callers must backup + merge first. */
function flushClaudeConfig(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
writeFileSync(
CLAUDE_CONFIG,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
try {
chmodSync(CLAUDE_CONFIG, 0o600);
} catch {
/* windows has no chmod */
}
}
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
function bunAvailable(): boolean {
const res =
platform() === "win32"
? spawnSync("where", ["bun"])
: spawnSync("sh", ["-c", "command -v bun"]);
return res.status === 0;
}
/** Absolute path to this CLI's entry file. */
function resolveEntry(): string {
const here = fileURLToPath(import.meta.url);
// When bundled (dist/index.js), this file IS the entry → return self.
// When running from source (src/index.ts via bun), walk up to the
// dir + resolve index.ts.
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
return here;
}
return resolve(dirname(here), "..", "index.ts");
}
/**
* Build the MCP server entry for Claude Code's config.
*
* Two modes:
* - Installed globally (npm i -g claudemesh-cli): use `claudemesh`
* as the command, relies on it being on PATH.
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
*/
function buildMcpEntry(entryPath: string): McpEntry {
const isBundled = entryPath.endsWith("/dist/index.js") ||
entryPath.endsWith("\\dist\\index.js");
if (isBundled) {
return {
command: "claudemesh",
args: ["mcp"],
};
}
return {
command: "bun",
args: [entryPath, "mcp"],
};
}
function entriesEqual(a: McpEntry, b: McpEntry): boolean {
return (
a.command === b.command &&
JSON.stringify(a.args ?? []) === JSON.stringify(b.args ?? [])
);
}
function readClaudeSettings(): Record<string, unknown> {
if (!existsSync(CLAUDE_SETTINGS)) return {};
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
function writeClaudeSettings(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
writeFileSync(
CLAUDE_SETTINGS,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
}
/**
* All claudemesh MCP tool names, prefixed for allowedTools.
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
*/
const CLAUDEMESH_TOOLS = [
"mcp__claudemesh__cancel_scheduled",
"mcp__claudemesh__check_messages",
"mcp__claudemesh__claim_task",
"mcp__claudemesh__complete_task",
"mcp__claudemesh__create_stream",
"mcp__claudemesh__create_task",
"mcp__claudemesh__delete_file",
"mcp__claudemesh__file_status",
"mcp__claudemesh__forget",
"mcp__claudemesh__get_context",
"mcp__claudemesh__get_file",
"mcp__claudemesh__get_state",
"mcp__claudemesh__grant_file_access",
"mcp__claudemesh__graph_execute",
"mcp__claudemesh__graph_query",
"mcp__claudemesh__join_group",
"mcp__claudemesh__leave_group",
"mcp__claudemesh__list_collections",
"mcp__claudemesh__list_contexts",
"mcp__claudemesh__list_files",
"mcp__claudemesh__list_peers",
"mcp__claudemesh__list_scheduled",
"mcp__claudemesh__list_state",
"mcp__claudemesh__list_streams",
"mcp__claudemesh__list_tasks",
"mcp__claudemesh__mesh_execute",
"mcp__claudemesh__mesh_info",
"mcp__claudemesh__mesh_query",
"mcp__claudemesh__mesh_schema",
"mcp__claudemesh__message_status",
"mcp__claudemesh__ping_mesh",
"mcp__claudemesh__publish",
"mcp__claudemesh__recall",
"mcp__claudemesh__remember",
"mcp__claudemesh__schedule_reminder",
"mcp__claudemesh__send_message",
"mcp__claudemesh__set_state",
"mcp__claudemesh__set_status",
"mcp__claudemesh__set_summary",
"mcp__claudemesh__share_context",
"mcp__claudemesh__share_file",
"mcp__claudemesh__subscribe",
"mcp__claudemesh__vector_delete",
"mcp__claudemesh__vector_search",
"mcp__claudemesh__vector_store",
];
/**
* Pre-approve all claudemesh MCP tools in allowedTools.
* Merges into any existing list — never overwrites other entries.
* Returns which tools were added vs already present.
*/
function installAllowedTools(): { added: string[]; unchanged: number } {
const settings = readClaudeSettings();
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
if (toAdd.length > 0) {
settings.allowedTools = [...Array.from(existing), ...toAdd];
writeClaudeSettings(settings);
}
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
}
/**
* Remove claudemesh tools from allowedTools.
* Leaves all other entries intact. Returns count removed.
*/
function uninstallAllowedTools(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const existing = (settings.allowedTools as string[] | undefined) ?? [];
const toolSet = new Set(CLAUDEMESH_TOOLS);
const kept = existing.filter((t) => !toolSet.has(t));
const removed = existing.length - kept.length;
if (removed > 0) {
settings.allowedTools = kept;
writeClaudeSettings(settings);
}
return removed;
}
/**
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
* idempotent on the command string. Returns counts for reporting.
*/
function installHooks(): { added: number; unchanged: number } {
const settings = readClaudeSettings();
const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {};
let added = 0;
let unchanged = 0;
const ensure = (event: string, command: string): void => {
const list = (hooks[event] ??= []);
const alreadyPresent = list.some((entry) =>
(entry.hooks ?? []).some((h) => h.command === command),
);
if (alreadyPresent) {
unchanged += 1;
return;
}
list.push({ hooks: [{ type: "command", command }] });
added += 1;
};
ensure("Stop", HOOK_COMMAND_STOP);
ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT);
settings.hooks = hooks;
writeClaudeSettings(settings);
return { added, unchanged };
}
/**
* Remove every hook entry whose command contains "claudemesh hook "
* from ~/.claude/settings.json. Idempotent. Returns removed count.
*/
function uninstallHooks(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const hooks = settings.hooks as HooksConfig | undefined;
if (!hooks) return 0;
let removed = 0;
for (const event of Object.keys(hooks)) {
const kept: HookMatcher[] = [];
for (const entry of hooks[event] ?? []) {
const filtered = (entry.hooks ?? []).filter(
(h) => !(h.command ?? "").includes(HOOK_MARKER),
);
removed += (entry.hooks ?? []).length - filtered.length;
if (filtered.length > 0) kept.push({ ...entry, hooks: filtered });
}
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
}
settings.hooks = hooks;
writeClaudeSettings(settings);
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("------------------");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
// Dev mode (running from src/) requires bun on PATH; bundled mode
// (npm install -g) just uses node + the claudemesh bin shim.
if (!isBundled && !bunAvailable()) {
console.error(
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
);
process.exit(1);
}
if (!existsSync(entry)) {
console.error(`✗ MCP entry not found at ${entry}`);
process.exit(1);
}
const desired = buildMcpEntry(entry);
const action = patchMcpServer(desired);
// Read-back verification.
const verify = readClaudeConfig();
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
const stored = verifyServers[MCP_NAME];
if (!stored || !entriesEqual(stored, desired)) {
console.error(
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
);
process.exit(1);
}
// ANSI color helpers — stick to 8-color set so terminals without
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
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 yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
console.log(dim(` config: ${CLAUDE_CONFIG}`));
console.log(
dim(
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
),
);
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
// --dangerously-skip-permissions just to call mesh tools.
try {
const { added, unchanged } = installAllowedTools();
if (added.length > 0) {
console.log(
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
} else {
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
if (!skipHooks) {
try {
const { added, unchanged } = installHooks();
if (added > 0) {
console.log(
`✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`,
);
} else {
console.log(`✓ Hooks already registered (${unchanged} present)`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
);
console.error(
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
);
}
} else {
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 = readConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
}
console.log("");
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
if (!hasMeshes) {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh <invite-url>")}` +
dim(" — joins + launches in one step"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
);
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
}
console.log("");
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 {
console.log("claudemesh uninstall");
console.log("--------------------");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`✓ MCP server "${MCP_NAME}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`✓ Hooks removed (${removed} entries)`);
} else {
console.log("· No claudemesh hooks to remove");
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

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

View File

@@ -0,0 +1,193 @@
/**
* `claudemesh join <invite-link-or-code>` — full join flow.
*
* Accepts either:
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
* persists mesh + fresh ed25519 identity.
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
* → parses signed payload, calls broker /join, persists.
*
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
*/
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 "~/constants/urls.js";
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
function deriveAppBaseUrl(): string {
const override = process.env.CLAUDEMESH_APP_URL;
if (override) return override.replace(/\/$/, "");
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
// For self-hosted: honour the broker host's parent domain as best-effort.
try {
const u = new URL(env.CLAUDEMESH_BROKER_URL);
const host = u.host.replace(/^ic\./, "");
const scheme = u.protocol === "wss:" ? "https:" : "http:";
return `${scheme}//${host}`;
} catch {
return "https://claudemesh.com";
}
}
async function runJoinV2(code: string): Promise<void> {
const appBaseUrl = deriveAppBaseUrl();
console.log(`Claiming invite ${code} via ${appBaseUrl}`);
let claim;
try {
claim = await claimInviteV2({ appBaseUrl, code });
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// Generate a fresh ed25519 identity for this peer. The v2 claim
// endpoint creates the member row keyed on the x25519 pubkey we sent;
// the ed25519 keypair is what the `hello` handshake and future
// envelope signing will use. Stored locally only.
const keypair = await generateKeypair();
const displayName = `${hostname()}-${process.pid}`;
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
// to match the format used everywhere else (broker stores it the
// same way in mesh.rootKey).
await sodium.ready;
const rootKeyB64 = sodium.to_base64(
claim.rootKey,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
// Persist. We don't have a mesh_slug in the v2 response — the server
// derives slug from name and slug is no longer globally unique. Use a
// 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 = readConfig();
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
config.meshes.push({
meshId: claim.meshId,
memberId: claim.memberId,
slug: fallbackSlug,
name: fallbackSlug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: env.CLAUDEMESH_BROKER_URL,
joinedAt: new Date().toISOString(),
rootKey: rootKeyB64,
inviteVersion: 2,
});
writeConfig(config);
console.log("");
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
console.log(` member id: ${claim.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-url-or-code>");
console.error("");
console.error("Examples:");
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
console.error(" claudemesh join abc12345");
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
process.exit(1);
}
// Try v2 first — short code / `/i/<code>` URL.
const v2Code = parseV2InviteInput(link);
if (v2Code) {
await runJoinV2(v2Code);
return;
}
// 1. Parse + verify signature client-side.
let invite;
try {
invite = await parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload, token } = invite;
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
// 2. Generate keypair.
const keypair = await generateKeypair();
// 3. Enroll with broker.
const displayName = `${hostname()}-${process.pid}`;
let enroll;
try {
enroll = await enrollWithBroker({
brokerWsUrl: payload.broker_url,
inviteToken: token,
invitePayload: payload,
peerPubkey: keypair.publicKey,
displayName,
});
} catch (e) {
console.error(
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// 4. Persist.
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== payload.mesh_slug,
);
config.meshes.push({
meshId: payload.mesh_id,
memberId: enroll.memberId,
slug: payload.mesh_slug,
name: payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: payload.broker_url,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 5. Report.
console.log("");
console.log(
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
);
console.log(` member id: ${enroll.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${payload.broker_url}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}

View File

@@ -0,0 +1,823 @@
// @ts-nocheck — v1 port, runtime-tested
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow:
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
import { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { 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 {
name?: string;
role?: string;
groups?: string;
join?: string;
mesh?: string;
"message-mode"?: string;
"system-prompt"?: string;
resume?: string;
continue?: boolean;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
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]!);
}
});
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
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 yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
console.log(" ALL permission prompts — not just claudemesh tools.");
console.log(" Peers exchange text only — no file access, no tool calls.");
console.log("");
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === "" || a === "y" || a === "yes") {
resolve();
} else {
console.log("\n Aborted. Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
});
});
}
// --- Banner ---
import {
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
} from "~/ui/styles.js";
import {
enterFullScreen, exitFullScreen, writeCentered, termSize,
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
} from "~/ui/screen.js";
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
interface LaunchWizardResult {
mesh: JoinedMesh;
role: string | null;
groups: GroupEntry[];
messageMode: "push" | "inbox" | "off";
skipPermissions: boolean;
}
/**
* Full-screen launch wizard — spinning logo + interactive config.
* Mesh selection, role, groups, message mode, permissions — all in one TUI.
* Falls back to plain text on non-TTY.
*/
async function runLaunchWizard(opts: {
displayName: string;
meshes: JoinedMesh[];
selectedMesh: JoinedMesh | null;
existingRole: string | null;
existingGroups: GroupEntry[];
existingMessageMode: "push" | "inbox" | "off" | null;
skipPermConfirm: boolean;
}): Promise<LaunchWizardResult> {
if (!process.stdout.isTTY) {
return {
mesh: opts.selectedMesh ?? opts.meshes[0]!,
role: opts.existingRole,
groups: opts.existingGroups,
messageMode: opts.existingMessageMode ?? "push",
skipPermissions: opts.skipPermConfirm,
};
}
const { rows } = termSize();
enterFullScreen();
drawTopBar();
// Spinning logo centered in upper portion
const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2);
const brandRow = logoTop + FRAME_HEIGHT + 1;
const subtitleRow = brandRow + 1;
const formRow = subtitleRow + 2;
writeCentered(brandRow, boldOrange("claudemesh"));
writeCentered(subtitleRow, tDim("peer mesh for Claude Code"));
const spinner = createSpinner({
render(lines) {
for (let i = 0; i < lines.length; i++) {
writeCentered(logoTop + i, lines[i]!);
}
},
interval: 70,
});
spinner.start();
// Show detected info
let row = formRow;
writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`);
row++;
writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`);
row += 2;
// Mesh selection
let mesh: JoinedMesh;
if (opts.selectedMesh) {
mesh = opts.selectedMesh;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else if (opts.meshes.length === 1) {
mesh = opts.meshes[0]!;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else {
spinner.stop();
const choice = await menuSelect({
title: "Select mesh",
items: opts.meshes.map(m => m.slug),
row,
});
mesh = opts.meshes[choice]!;
// Redraw as confirmed
for (let i = 0; i < opts.meshes.length + 1; i++) {
writeCentered(row + i, " ");
}
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
spinner.start();
row++;
}
row++;
// Interactive fields
let role = opts.existingRole;
let groups = opts.existingGroups;
let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off";
// Role input
if (role === null) {
spinner.stop();
const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" });
if (answer) role = answer;
spinner.start();
row++;
} else {
writeCentered(row, `Role ${tGreen("✓")} ${role}`);
row++;
}
// Groups input
if (groups.length === 0) {
spinner.stop();
const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" });
if (answer) groups = parseGroupsString(answer);
spinner.start();
row++;
} else {
const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ");
writeCentered(row, `Groups ${tGreen("✓")} ${tags}`);
row++;
}
// Message mode selection
if (opts.existingMessageMode === null) {
row++;
spinner.stop();
const choice = await menuSelect({
title: "Message mode",
items: [
"Push (real-time, peers can interrupt)",
"Inbox (held until you check)",
"Off (tools only, no messages)",
],
row,
});
messageMode = (["push", "inbox", "off"] as const)[choice];
spinner.start();
row += 5;
} else {
writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`);
row++;
}
// Permissions confirmation
let skipPermissions = opts.skipPermConfirm;
if (!skipPermissions) {
row++;
spinner.stop();
writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,"));
writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh."));
row += 3;
const confirmed = await confirmPrompt({
message: boldOrange("Autonomous mode?"),
row,
defaultYes: true,
});
if (!confirmed) {
exitFullScreen();
console.log(" Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
skipPermissions = true;
spinner.start();
}
// Final animation
row += 2;
writeCentered(row, tDim("Launching Claude Code..."));
await new Promise(r => setTimeout(r, 800));
spinner.stop();
exitFullScreen();
return { mesh, role, groups, messageMode, skipPermissions };
}
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule);
if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
console.log("");
}
// --- Main ---
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
// Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
resume: flags.resume ?? null,
continueSession: flags.continue ?? false,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
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 = readConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
console.log(` Opening browser to sign in...\n`);
const opened = await openBrowser(url);
if (!opened) {
console.log(` Couldn't open browser automatically.`);
}
console.log(` ${dim(`Visit: ${url}`)}`);
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("\n Timed out waiting for sign-in.");
process.exit(1);
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("~/services/crypto/facade.js");
const keypair = await generateKeypair();
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const { syncWithBroker } = await import("~/services/auth/facade.js");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { writeConfig } = await import("~/services/config/facade.js");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
}
config.accountId = result.account_id;
writeConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
}
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
process.exit(1);
}
// Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1)
let mesh: JoinedMesh;
if (args.meshSlug) {
const found = config.meshes.find((m) => m.slug === args.meshSlug);
if (!found) {
console.error(
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
// Multiple meshes — wizard will handle selection
mesh = null as unknown as JoinedMesh; // set by wizard below
}
// 3. Session identity + role/groups via TUI wizard.
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";
// `-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,
selectedMesh: mesh ?? null,
existingRole: args.role,
existingGroups: parsedGroups,
existingMessageMode: args.messageMode ?? null,
skipPermConfirm: args.skipPermConfirm,
});
mesh = wizardResult.mesh;
role = wizardResult.role;
parsedGroups = wizardResult.groups;
messageMode = wizardResult.messageMode;
args.skipPermConfirm = wizardResult.skipPermissions;
} else if (!mesh) {
// 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)
const tmpBase = tmpdir();
try {
for (const entry of readdirSync(tmpBase)) {
if (!entry.startsWith("claudemesh-")) continue;
const full = join(tmpBase, entry);
const age = Date.now() - statSync(full).mtimeMs;
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
}
} catch { /* best effort */ }
// Clean up stale mesh MCP entries from crashed sessions
try {
const claudeConfigPath = join(homedir(), ".claude.json");
if (existsSync(claudeConfigPath)) {
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
let cleaned = 0;
for (const key of Object.keys(mcpServers)) {
if (!key.startsWith("mesh:")) continue;
const meta = mcpServers[key]?._meshSession;
if (!meta?.pid) continue;
// Check if the PID is still alive
try {
process.kill(meta.pid, 0); // signal 0 = check existence
} catch {
// PID is dead — remove stale entry
delete mcpServers[key];
cleaned++;
}
}
if (cleaned > 0) {
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
}
}
} catch { /* best effort */ }
// --- Fetch deployed services for native MCP entries ---
let serviceCatalog: Array<{
name: string;
description: string;
status: string;
tools: Array<{ name: string; description: string; inputSchema: object }>;
deployed_by: string;
}> = [];
try {
const tmpClient = new BrokerClient(mesh, { displayName });
await tmpClient.connect();
// Wait briefly for hello_ack with service catalog
await new Promise(r => setTimeout(r, 2000));
serviceCatalog = tmpClient.serviceCatalog;
tmpClient.close();
} catch {
// Non-fatal — launch without native service entries
if (!args.quiet) {
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
}
}
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
}
// --- Install native MCP entries for deployed mesh services ---
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
if (serviceCatalog.length > 0) {
const claudeConfigPath = join(homedir(), ".claude.json");
// Read-modify-write: only touch mesh:* entries in mcpServers
let claudeConfig: Record<string, unknown> = {};
try {
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
} catch {
claudeConfig = {};
}
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
// Session-scoped key: mesh:<service>:<sessionId>
const sessionTag = `${process.pid}`;
for (const svc of serviceCatalog) {
if (svc.status !== "running") continue;
const entryKey = `mesh:${svc.name}:${sessionTag}`;
const entry = {
command: "claudemesh",
args: ["mcp", "--service", svc.name],
env: {
CLAUDEMESH_CONFIG_DIR: tmpDir,
},
_meshSession: {
pid: process.pid,
meshSlug: mesh.slug,
serviceName: svc.name,
createdAt: new Date().toISOString(),
},
};
mcpServers[entryKey] = entry;
meshMcpEntries.push({ key: entryKey, entry });
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
if (!args.quiet && meshMcpEntries.length > 0) {
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
for (const { key } of meshMcpEntries) {
const svcName = key.split(":")[1];
const svc = serviceCatalog.find(s => s.name === svcName);
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
}
console.log("");
}
}
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
// Strip any user-supplied --dangerously flags to avoid duplicates.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
continue;
}
filtered.push(args.claudeArgs[i]!);
}
// --dangerously-skip-permissions is only added when the user explicitly
// passes -y / --yes. Without it, claudemesh tools still work because
// `claudemesh install` pre-approves them via allowedTools in settings.json.
// This keeps permissions tight for multi-person meshes.
// Session identity: --resume reuses existing session, otherwise generate new.
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
...(args.resume ? ["--resume", args.resume] : []),
...(args.continueSession ? ["--continue"] : []),
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
// Resolve the full path to `claude` — when launched from a non-interactive
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
const isWindows = process.platform === "win32";
let claudeBin = "claude";
if (!isWindows) {
const candidates = [
join(homedir(), ".local", "bin", "claude"),
"/usr/local/bin/claude",
join(homedir(), ".claude", "bin", "claude"),
];
for (const c of candidates) {
if (existsSync(c)) { claudeBin = c; break; }
}
}
// 7. Define cleanup — runs on every exit path via process.on('exit').
// Synchronous-only (rmSync + writeFileSync) so it works inside the
// 'exit' event, which does not allow async work.
const cleanup = (): void => {
// Remove mesh MCP entries from ~/.claude.json
if (meshMcpEntries.length > 0) {
try {
const claudeConfigPath = join(homedir(), ".claude.json");
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
for (const { key } of meshMcpEntries) {
delete mcpServers[key];
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ }
}
// Ephemeral config dir
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch { /* best effort */ }
};
// Register cleanup on every exit path — including normal exit, uncaught
// throws, and fatal signals. process.on('exit') fires synchronously, which
// is what the rmSync + writeFileSync above need.
process.on("exit", cleanup);
// 8. Hard-reset the TTY before handing control to claude.
//
// Every interactive element in the pre-launch flow — the full-screen
// wizard (tui/screen.ts), the permission confirmation, the callback-
// listener paste prompt, the mesh picker — attaches listeners to
// process.stdin, toggles raw mode, hides the cursor, and sometimes
// enters the alt-screen. Those helpers do best-effort cleanup in their
// own finally blocks, but any leak — an orphaned 'data' listener, a
// still-raw TTY, a pending render paint — means the parent node process
// keeps competing with claude's Ink TUI for the same keystrokes and
// stdout frames. Symptoms: dropped keystrokes at the claude prompt, or
// the wizard visibly repainting on top of claude after launch.
//
// Defensive reset here is cheap and guarantees a clean TTY regardless
// of what the wizard helpers did or didn't restore.
if (process.stdin.isTTY) {
try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ }
}
process.stdin.removeAllListeners("data");
process.stdin.removeAllListeners("keypress");
process.stdin.removeAllListeners("readable");
process.stdin.pause();
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?25h"); // show cursor
process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it
}
// 9. Block-and-wait on claude with spawnSync.
//
// Why spawnSync instead of spawn + child.on('exit'):
// - spawn keeps the parent node event loop running alongside claude.
// Any stray listener, setImmediate, or async wizard tail-end can
// still fire during claude's lifetime, stealing input or painting
// over claude's TUI.
// - spawnSync blocks the parent event loop completely until claude
// exits. No listeners fire. Nothing paints. The parent is effectively
// suspended, and claude has exclusive ownership of the TTY.
//
// Signal forwarding: claude inherits the TTY process group via
// stdio: "inherit". When the user hits Ctrl-C, the terminal sends
// SIGINT to the whole group. Claude handles it (Ink unmounts, exits
// cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise
// the same signal on the parent so it dies the same way.
const result = spawnSync(claudeBin, claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});
// 10. Handle the result. Cleanup runs automatically via process.on('exit').
if (result.error) {
const err = result.error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
}
process.exit(1);
}
if (result.signal) {
// Re-raise the same signal so the parent dies the same way the child did.
process.kill(process.pid, result.signal);
return;
}
process.exit(result.status ?? 0);
}

View File

@@ -0,0 +1,25 @@
/**
* `claudemesh leave <slug>` — remove a mesh from local config.
*
* Does NOT (yet) notify the broker. In 15b+ this will send a
* best-effort revoke request before removing the entry.
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runLeave(args: string[]): void {
const slug = args[0];
if (!slug) {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
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);
}
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -0,0 +1,104 @@
/**
* `claudemesh mesh list` — merged view of server + local meshes.
*/
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";
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("\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("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Shows all meshes by default, or filter with --mesh.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface PeersFlags {
mesh?: string;
json?: boolean;
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const config = readConfig();
// If --mesh specified, show only that one. Otherwise show all.
const slugs = flags.mesh
? [flags.mesh]
: config.meshes.map(m => m.slug);
if (slugs.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
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;
}
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
console.log("");
if (peers.length === 0) {
console.log(dim(" No peers connected."));
} else {
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 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("");
});
} catch (e) {
console.error(dim(` Could not connect to ${slug}: ${e instanceof Error ? e.message : String(e)}`));
console.log("");
}
}
if (flags.json) {
console.log(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2));
}
}

View File

@@ -0,0 +1,114 @@
/**
* `claudemesh profile` — view or edit your member profile.
*
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
* on the server. Changes are pushed to active sessions in real-time.
*/
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
export interface ProfileFlags {
mesh?: string;
"role-tag"?: string;
groups?: string;
"message-mode"?: string;
name?: string;
member?: string; // admin only: edit another member
json?: boolean;
}
export async function runProfile(flags: ProfileFlags): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
// Pick mesh
const mesh = flags.mesh
? config.meshes.find(m => m.slug === flags.mesh)
: config.meshes[0]!;
if (!mesh) {
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
process.exit(1);
}
// Derive broker HTTP URL from WSS URL
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
if (hasEdits) {
// PATCH member profile
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
const body: Record<string, unknown> = {};
if (flags.name !== undefined) body.displayName = flags.name;
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
if (flags.groups !== undefined) {
body.groups = flags.groups.split(",").map(s => {
const [name, role] = s.trim().split(":");
return role ? { name: name!, role } : { name: name! };
});
}
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Member-Id": mesh.memberId,
},
body: JSON.stringify(body),
});
const result = await res.json() as Record<string, unknown>;
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.ok) {
console.log(green("✓ Profile updated"));
const member = result.member as Record<string, unknown>;
printProfile(member, dim);
} else {
console.error(`Error: ${result.error}`);
process.exit(1);
}
} else {
// GET members list, show current user's profile
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
if (!result.ok) {
console.error(`Error: ${result.error}`);
process.exit(1);
}
const me = result.members?.find(m => m.id === mesh.memberId);
if (flags.json) {
console.log(JSON.stringify(me ?? {}, null, 2));
} else if (me) {
printProfile(me, dim);
} else {
console.log("Member not found in mesh.");
}
}
}
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
const groupStr = groups?.length
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
: dim("(none)");
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
console.log(` Groups: ${groupStr}`);
console.log(` Messages: ${member.messageMode ?? "push"}`);
console.log(` Access: ${member.permission ?? "member"}`);
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
/**
* `claudemesh remind <message> --in <duration> | --at <time>`
* `claudemesh remind list`
* `claudemesh remind cancel <id>`
*
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect.js";
export interface RemindFlags {
mesh?: string;
in?: string; // e.g. "2h", "30m", "90s"
at?: string; // ISO or HH:MM
cron?: string; // 5-field cron expression for recurring
to?: string; // default: self
json?: boolean;
}
function parseDuration(raw: string): number | null {
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
if (!m) return null;
const n = parseFloat(m[1]!);
const unit = (m[2] ?? "s").toLowerCase();
if (unit.startsWith("d")) return n * 86_400_000;
if (unit.startsWith("h")) return n * 3_600_000;
if (unit.startsWith("m")) return n * 60_000;
return n * 1_000;
}
function parseDeliverAt(flags: RemindFlags): number | null {
if (flags.in) {
const ms = parseDuration(flags.in);
if (ms === null) return null;
return Date.now() + ms;
}
if (flags.at) {
// Try HH:MM first
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
if (hm) {
const now = new Date();
const target = new Date(now);
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
return target.getTime();
}
const ts = Date.parse(flags.at);
return isNaN(ts) ? null : ts;
}
return null;
}
export async function runRemind(
flags: RemindFlags,
positional: string[],
): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const action = positional[0];
// claudemesh remind list
if (action === "list") {
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const scheduled = await client.listScheduled();
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
for (const m of scheduled) {
const when = new Date(m.deliverAt).toLocaleString();
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
console.log(` ${bold(m.id.slice(0, 8))}${to} at ${when}`);
console.log(` ${dim(m.message.slice(0, 80))}`);
console.log("");
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const ok = await client.cancelScheduled(id);
if (ok) console.log(`✓ Cancelled ${id}`);
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
});
return;
}
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
const message = action ?? positional.join(" ");
if (!message) {
console.error("Usage: claudemesh remind <message> --in <duration>");
console.error(" claudemesh remind <message> --at <time>");
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
console.error(" claudemesh remind list");
console.error(" claudemesh remind cancel <id>");
process.exit(1);
}
const isCron = !!flags.cron;
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
if (!isCron && deliverAt === null) {
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
process.exit(1);
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Determine target: --to flag or self
let targetSpec: string;
if (flags.to && flags.to !== "self") {
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
targetSpec = flags.to;
} else {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
if (!match) {
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
} else {
targetSpec = client.getSessionPubkey() ?? "*";
}
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
if (flags.json) { console.log(JSON.stringify(result)); return; }
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
if (isCron) {
const nextFire = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
}
});
}

View File

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

View File

@@ -0,0 +1,44 @@
/**
* `claudemesh seed-test-mesh` — dev-only helper for 15b testing.
*
* Writes a locally-valid JoinedMesh entry to ~/.claudemesh/config.json
* so the MCP server can connect to a locally-running broker without
* invite-link / crypto plumbing.
*
* Usage:
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runSeedTestMesh(args: string[]): void {
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
if (!brokerUrl || !meshId || !memberId || !pubkey || !slug) {
console.error(
"Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>",
);
console.error("");
console.error(
'Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test',
);
process.exit(1);
}
const config = readConfig();
// Remove any prior entry with same slug (idempotent).
config.meshes = config.meshes.filter((m) => m.slug !== slug);
config.meshes.push({
meshId,
memberId,
slug,
name: `Test: ${slug}`,
pubkey,
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
brokerUrl,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
console.log(
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
);
}

View File

@@ -0,0 +1,51 @@
/**
* `claudemesh send <to> <message>` — send a message to a peer or group.
*
* <to> can be:
* - a display name ("Mou")
* - a pubkey hex ("abc123...")
* - @group ("@flexicar")
* - * (broadcast to all)
*/
import { withMesh } from "./connect.js";
import type { Priority } from "~/services/broker/facade.js";
export interface SendFlags {
mesh?: string;
priority?: string;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
const priority: Priority =
flags.priority === "now" ? "now"
: flags.priority === "low" ? "low"
: "next";
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Resolve display name → pubkey for direct messages.
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
let targetSpec = to;
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
// Treat as display name — look up pubkey via list_peers.
const peers = await client.listPeers();
const match = peers.find(
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
);
if (!match) {
const names = peers.map((p) => p.displayName).join(", ");
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
const result = await client.send(targetSpec, message, priority);
if (result.ok) {
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
} else {
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,75 @@
/**
* `claudemesh state get <key>` — read a shared state value
* `claudemesh state set <key> <value>` — write a shared state value
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect.js";
export interface StateFlags {
mesh?: string;
json?: boolean;
}
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const entry = await client.getState(key);
if (!entry) {
console.log(dim(`(not set)`));
return;
}
if (flags.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
console.log(val);
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
});
}
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${key} = ${JSON.stringify(parsed)}`);
});
}
export async function runStateList(flags: StateFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const entries = await client.listState();
if (flags.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
return;
}
for (const e of entries) {
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
console.log(`${bold(e.key)}: ${val}`);
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
}
});
}

View File

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

View File

@@ -0,0 +1,103 @@
/**
* `claudemesh status` — one-shot health report.
*
* Reports CLI version, config path + permissions, each joined mesh
* with broker reachability (WS handshake probe). Exit 0 if every
* mesh's broker is reachable, 1 otherwise.
*/
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
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));
const configPath = getConfigPath();
let configPerms = "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)`;
}
console.log(`Config: ${configPath} (${configPerms})`);
const config = readConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
process.exit(0);
}
console.log("");
console.log(`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({
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
if (probe.ok) {
console.log(green("reachable"));
} else {
console.log(red(`unreachable (${probe.error})`));
}
}
console.log("");
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
}
const allOk = results.every((r) => r.reachable);
console.log("");
if (allOk) {
console.log(green("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.`));
process.exit(1);
}
}

View File

@@ -0,0 +1,89 @@
/**
* `claudemesh sync` — re-sync meshes from dashboard account.
*
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
* merges new meshes into local config.
*/
import { createInterface } from "node:readline";
import { hostname } from "node:os";
import { 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 = readConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`Opening browser to sync meshes...`);
console.log(dim(`Visit: ${url}`));
await openBrowser(url);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question("Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("Timed out waiting for sign-in.");
process.exit(1);
}
// Use existing keypair from first mesh, or generate new
const keypair = config.meshes.length > 0
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
: await generateKeypair();
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
// Merge: add new meshes, skip duplicates
let added = 0;
for (const m of result.meshes) {
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
added++;
}
config.accountId = result.account_id;
writeConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));
} else {
console.log(`Already up to date (${config.meshes.length} meshes)`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
/**
* `claudemesh` with no args + no joined meshes → unified onboarding.
*
* 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 { 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";
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 async function runWelcome(): Promise<number> {
const config = readConfig();
if (config.meshes.length > 0) return EXIT.SUCCESS;
renderWelcome();
render.info("Do you already have an invite link? (y/n) [n]");
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
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;
}
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;
}
// No invite → browser-first sign-in + mesh creation.
render.blank();
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
render.hint("After sign-in, paste the sync token back here when prompted.");
render.blank();
return await login();
}
export { runWelcome as _stub };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
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;
export const VERSION = "1.0.0-alpha.27";
export const env = {
CLAUDEMESH_BROKER_URL: URLS.BROKER,
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
/**
* MCP tool schemas + shared types for the CLI's MCP server.
*/
export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string;
priority?: Priority;
}
export interface ListPeersArgs {
mesh_slug?: string; // filter to one joined mesh
}
export interface SetSummaryArgs {
summary: string;
}
export interface SetStatusArgs {
status: PeerStatus;
}
// --- Service deployment types ---
export type ServiceScope =
| "peer"
| "mesh"
| { peers: string[] }
| { group: string }
| { groups: string[] }
| { role: string };
export interface ServiceInfo {
name: string;
type: "mcp" | "skill";
description: string;
status: string;
tool_count: number;
deployed_by: string;
scope: ServiceScope;
source_type: string;
runtime?: string;
created_at: string;
}
export interface ServiceToolSchema {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface VaultEntry {
key: string;
entry_type: "env" | "file";
mount_path?: string;
description?: string;
updated_at: string;
}
export interface MeshMcpDeployArgs {
server_name: string;
file_id?: string;
git_url?: string;
git_branch?: string;
env?: Record<string, string>;
runtime?: "node" | "python" | "bun";
memory_mb?: number;
network_allow?: string[];
scope?: ServiceScope;
}
export interface VaultSetArgs {
key: string;
value: string;
type?: "env" | "file";
mount_path?: string;
description?: string;
}

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