chore(cli-v2): un-ignore CLI source tree for binary release workflow
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -76,4 +76,3 @@ apps/web/payload.db
|
||||
apps/web/public/media/*
|
||||
!apps/web/public/media/.gitkeep
|
||||
.env.local
|
||||
apps/cli-v2/
|
||||
|
||||
5
apps/cli-v2/.gitignore
vendored
Normal file
5
apps/cli-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.cache/
|
||||
*.log
|
||||
44
apps/cli-v2/CHANGELOG.md
Normal file
44
apps/cli-v2/CHANGELOG.md
Normal 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
90
apps/cli-v2/README.md
Normal 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
|
||||
2
apps/cli-v2/bin/claudemesh
Normal file
2
apps/cli-v2/bin/claudemesh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "../dist/entrypoints/cli.js";
|
||||
4
apps/cli-v2/biome.json
Normal file
4
apps/cli-v2/biome.json
Normal 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
51
apps/cli-v2/build.ts
Normal 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
69
apps/cli-v2/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
49
apps/cli-v2/scripts/build-binaries.ts
Normal file
49
apps/cli-v2/scripts/build-binaries.ts
Normal 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/");
|
||||
30
apps/cli-v2/src/cli/argv.ts
Normal file
30
apps/cli-v2/src/cli/argv.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineCommand, runMain } from "citty";
|
||||
|
||||
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
|
||||
|
||||
export function parseArgv(argv: string[]): ParsedArgs {
|
||||
const args = argv.slice(2);
|
||||
const flags: Record<string, string | boolean | undefined> = {};
|
||||
const positionals: string[] = [];
|
||||
let command = "";
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
||||
} else if (arg.startsWith("-") && arg.length === 2) {
|
||||
const key = arg.slice(1);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
||||
} else if (!command) {
|
||||
command = arg;
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
return { command, positionals, flags };
|
||||
}
|
||||
|
||||
export { defineCommand, runMain };
|
||||
7
apps/cli-v2/src/cli/exit.ts
Normal file
7
apps/cli-v2/src/cli/exit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
const cleanupHooks: Array<() => void> = [];
|
||||
export function onExit(fn: () => void): void { cleanupHooks.push(fn); }
|
||||
export function exit(code: number = EXIT.SUCCESS): never {
|
||||
for (const fn of cleanupHooks) { try { fn(); } catch {} }
|
||||
process.exit(code);
|
||||
}
|
||||
12
apps/cli-v2/src/cli/handlers/error.ts
Normal file
12
apps/cli-v2/src/cli/handlers/error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { red } from "~/ui/styles.js";
|
||||
export function handleUncaughtError(err: unknown): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(red("\n Fatal: " + msg + "\n"));
|
||||
if (process.env.CLAUDEMESH_DEBUG === "1" && err instanceof Error && err.stack) console.error(err.stack);
|
||||
process.exit(EXIT.INTERNAL_ERROR);
|
||||
}
|
||||
export function installErrorHandlers(): void {
|
||||
process.on("uncaughtException", handleUncaughtError);
|
||||
process.on("unhandledRejection", (reason) => handleUncaughtError(reason));
|
||||
}
|
||||
6
apps/cli-v2/src/cli/handlers/signal.ts
Normal file
6
apps/cli-v2/src/cli/handlers/signal.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SHOW_CURSOR } from "~/ui/styles.js";
|
||||
export function installSignalHandlers(): void {
|
||||
const cleanup = () => { process.stdout.write(SHOW_CURSOR); };
|
||||
process.on("SIGINT", () => { cleanup(); process.exit(1); });
|
||||
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
||||
}
|
||||
6
apps/cli-v2/src/cli/output/list.ts
Normal file
6
apps/cli-v2/src/cli/output/list.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
export function renderMeshList(meshes: JoinedMesh[]): string {
|
||||
if (meshes.length === 0) return " No meshes joined.";
|
||||
return meshes.map((m, i) => " " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.meshId.slice(0, 8) + "\u2026)")).join("\n");
|
||||
}
|
||||
11
apps/cli-v2/src/cli/output/peers.ts
Normal file
11
apps/cli-v2/src/cli/output/peers.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PeerInfo } from "~/services/broker/facade.js";
|
||||
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
|
||||
const S: Record<string, (s: string) => string> = { idle: green, working: yellow, dnd: red };
|
||||
export function renderPeers(peers: PeerInfo[], meshSlug: string): string {
|
||||
if (peers.length === 0) return " No peers online in " + meshSlug + ".";
|
||||
return peers.map(p => {
|
||||
const icon = (S[p.status] ?? dim)("\u25CF");
|
||||
const summary = p.summary ? dim(" \u2014 " + p.summary) : "";
|
||||
return " " + icon + " " + bold(p.displayName) + summary;
|
||||
}).join("\n");
|
||||
}
|
||||
3
apps/cli-v2/src/cli/output/version.ts
Normal file
3
apps/cli-v2/src/cli/output/version.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
import { boldOrange } from "~/ui/styles.js";
|
||||
export function renderVersion(): string { return " " + boldOrange("claudemesh") + " v" + VERSION; }
|
||||
11
apps/cli-v2/src/cli/output/whoami.ts
Normal file
11
apps/cli-v2/src/cli/output/whoami.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { WhoAmIResult } from "~/services/auth/facade.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
export function renderWhoAmI(result: WhoAmIResult): string {
|
||||
if (!result.signed_in) return " Not signed in.";
|
||||
const lines = [
|
||||
" Signed in as " + bold(result.user!.display_name) + " (" + result.user!.email + ")",
|
||||
" Token source: " + result.token_source + " " + dim("(~/.claudemesh/auth.json)"),
|
||||
];
|
||||
if (result.meshes) lines.push(" Meshes: " + result.meshes.owned + " owned, " + result.meshes.guest + " guest");
|
||||
return lines.join("\n");
|
||||
}
|
||||
7
apps/cli-v2/src/cli/print.ts
Normal file
7
apps/cli-v2/src/cli/print.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
export function print(msg: string): void { process.stdout.write(msg + "\n"); }
|
||||
export function printErr(msg: string): void { process.stderr.write(msg + "\n"); }
|
||||
export function isQuiet(): boolean { return process.argv.includes("-q") || process.argv.includes("--quiet"); }
|
||||
export function isVerbose(): boolean { return process.argv.includes("-v") || process.argv.includes("--verbose"); }
|
||||
export function isJson(): boolean { return process.argv.includes("--json"); }
|
||||
export function isTty(): boolean { return !!isTTY; }
|
||||
4
apps/cli-v2/src/cli/structured-io.ts
Normal file
4
apps/cli-v2/src/cli/structured-io.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function jsonOutput<T>(data: T): string {
|
||||
return JSON.stringify({ schema_version: "1.0", ...data }, null, 2);
|
||||
}
|
||||
export function writeJson<T>(data: T): void { console.log(jsonOutput(data)); }
|
||||
11
apps/cli-v2/src/cli/update-notice.ts
Normal file
11
apps/cli-v2/src/cli/update-notice.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { checkForUpdate } from "~/services/update/facade.js";
|
||||
import { dim, yellow } from "~/ui/styles.js";
|
||||
export async function showUpdateNotice(currentVersion: string): Promise<void> {
|
||||
try {
|
||||
const info = await checkForUpdate(currentVersion);
|
||||
if (info.updateAvailable) {
|
||||
console.error(yellow(" Update available: " + info.current + " \u2192 " + info.latest));
|
||||
console.error(dim(" Run: npm i -g claudemesh-cli"));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
147
apps/cli-v2/src/commands/backup.ts
Normal file
147
apps/cli-v2/src/commands/backup.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* `claudemesh backup` — encrypt the local config and save a portable
|
||||
* recovery file. Restore later with `claudemesh restore <file>` on any
|
||||
* machine to recover mesh memberships.
|
||||
*
|
||||
* Crypto:
|
||||
* - Argon2id KDF over a user passphrase → 32-byte key
|
||||
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
|
||||
* passphrase is still workable but brute-force remains expensive)
|
||||
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
|
||||
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
|
||||
*
|
||||
* Output: a single `.claudemesh-backup` file the user can store in
|
||||
* 1Password, email to themselves, etc. Zero server involvement.
|
||||
*
|
||||
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
|
||||
* passphrases shorter than 12 characters.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { getConfigPath } from "~/services/config/facade.js";
|
||||
import { ensureSodium } from "~/services/crypto/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const MAGIC = Buffer.from("CMB1", "utf-8");
|
||||
|
||||
function readHidden(prompt: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(prompt);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
// Node readline doesn't mask by default. Turn off echo manually.
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const wasRaw = Boolean(stdin.isRaw);
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(true);
|
||||
}
|
||||
let buf = "";
|
||||
const onData = (chunk: Buffer): void => {
|
||||
const ch = chunk.toString("utf-8");
|
||||
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
||||
stdin.removeListener("data", onData);
|
||||
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
resolve(buf);
|
||||
return;
|
||||
}
|
||||
if (ch === "\u0003") { // ctrl-c
|
||||
process.exit(130);
|
||||
}
|
||||
if (ch === "\u007f") { // backspace
|
||||
buf = buf.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
buf += ch;
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
|
||||
return s.crypto_pwhash(
|
||||
32,
|
||||
pass,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runBackup(outPath: string | undefined): Promise<number> {
|
||||
const configPath = getConfigPath();
|
||||
if (!existsSync(configPath)) {
|
||||
console.error(" No config found — nothing to back up. Join a mesh first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const plaintext = readFileSync(configPath);
|
||||
|
||||
const pass = await readHidden(" Passphrase (min 12 chars): ");
|
||||
if (pass.length < 12) {
|
||||
console.error(" ✗ Passphrase too short.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const confirm = await readHidden(" Confirm passphrase: ");
|
||||
if (confirm !== pass) {
|
||||
console.error(" ✗ Passphrases did not match.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
const s = await ensureSodium();
|
||||
const salt = Buffer.from(s.randombytes_buf(16));
|
||||
const nonce = Buffer.from(s.randombytes_buf(24));
|
||||
const key = await deriveKey(pass, salt, s);
|
||||
const ciphertext = Buffer.from(
|
||||
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
|
||||
);
|
||||
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
|
||||
|
||||
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
|
||||
writeFileSync(file, blob, { mode: 0o600 });
|
||||
console.log(`\n ✓ Backup saved: ${file}`);
|
||||
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runRestore(inPath: string | undefined): Promise<number> {
|
||||
if (!inPath) {
|
||||
console.error(" Usage: claudemesh restore <backup-file>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
if (!existsSync(inPath)) {
|
||||
console.error(` ✗ File not found: ${inPath}`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const blob = readFileSync(inPath);
|
||||
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
|
||||
console.error(" ✗ Not a claudemesh backup file (bad magic).");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const salt = blob.subarray(4, 20);
|
||||
const nonce = blob.subarray(20, 44);
|
||||
const ciphertext = blob.subarray(44);
|
||||
|
||||
const pass = await readHidden(" Passphrase: ");
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(pass, Buffer.from(salt), s);
|
||||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
|
||||
} catch {
|
||||
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
if (existsSync(configPath)) {
|
||||
const backupOld = `${configPath}.before-restore.${Date.now()}`;
|
||||
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
|
||||
console.log(` ↻ Existing config saved to ${backupOld}`);
|
||||
}
|
||||
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
|
||||
console.log(`\n ✓ Config restored to ${configPath}`);
|
||||
console.log(" Run `claudemesh list` to verify your meshes.\n");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
122
apps/cli-v2/src/commands/completions.ts
Normal file
122
apps/cli-v2/src/commands/completions.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
|
||||
*
|
||||
* Users pipe it into their shell's completion system:
|
||||
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
|
||||
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
|
||||
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
|
||||
*/
|
||||
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"create", "new", "join", "add", "launch", "connect", "disconnect",
|
||||
"list", "ls", "delete", "rm", "rename", "share", "invite",
|
||||
"peers", "send", "inbox", "state", "info",
|
||||
"remember", "recall", "remind", "profile", "status",
|
||||
"login", "register", "logout", "whoami",
|
||||
"install", "uninstall", "doctor", "sync",
|
||||
"completions", "verify", "url-handler",
|
||||
"help",
|
||||
];
|
||||
|
||||
const FLAGS = [
|
||||
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
|
||||
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
|
||||
];
|
||||
|
||||
function bash(): string {
|
||||
return `# claudemesh bash completion
|
||||
_claudemesh_complete() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="${COMMANDS.join(" ")}"
|
||||
local flags="${FLAGS.join(" ")}"
|
||||
|
||||
if [[ \${cword} -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "\${cur}" in
|
||||
-*)
|
||||
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
complete -F _claudemesh_complete claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function zsh(): string {
|
||||
return `#compdef claudemesh
|
||||
# claudemesh zsh completion
|
||||
|
||||
_claudemesh() {
|
||||
local -a commands flags
|
||||
commands=(
|
||||
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
|
||||
)
|
||||
flags=(
|
||||
${FLAGS.map((f) => ` '${f}'`).join("\n")}
|
||||
)
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' commands
|
||||
return
|
||||
fi
|
||||
|
||||
case $words[2] in
|
||||
join|add|launch|connect)
|
||||
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
|
||||
;;
|
||||
share|invite)
|
||||
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
|
||||
;;
|
||||
*)
|
||||
_values 'flag' $flags
|
||||
;;
|
||||
esac
|
||||
}
|
||||
compdef _claudemesh claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function fish(): string {
|
||||
const cmdLines = COMMANDS.map(
|
||||
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
|
||||
).join("\n");
|
||||
return `# claudemesh fish completion
|
||||
${cmdLines}
|
||||
complete -c claudemesh -l help -s h -d 'show help'
|
||||
complete -c claudemesh -l version -s V -d 'show version'
|
||||
complete -c claudemesh -l json -d 'machine-readable output'
|
||||
complete -c claudemesh -l yes -s y -d 'skip confirmations'
|
||||
complete -c claudemesh -l mesh -d 'mesh slug'
|
||||
complete -c claudemesh -l name -d 'display name'
|
||||
complete -c claudemesh -l join -d 'invite url'
|
||||
`;
|
||||
}
|
||||
|
||||
export async function runCompletions(shell: string | undefined): Promise<number> {
|
||||
if (!shell) {
|
||||
console.error("Usage: claudemesh completions <bash|zsh|fish>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
switch (shell.toLowerCase()) {
|
||||
case "bash":
|
||||
process.stdout.write(bash());
|
||||
return EXIT.SUCCESS;
|
||||
case "zsh":
|
||||
process.stdout.write(zsh());
|
||||
return EXIT.SUCCESS;
|
||||
case "fish":
|
||||
process.stdout.write(fish());
|
||||
return EXIT.SUCCESS;
|
||||
default:
|
||||
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
}
|
||||
65
apps/cli-v2/src/commands/connect-telegram.ts
Normal file
65
apps/cli-v2/src/commands/connect-telegram.ts
Normal 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)");
|
||||
}
|
||||
}
|
||||
81
apps/cli-v2/src/commands/connect.ts
Normal file
81
apps/cli-v2/src/commands/connect.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
128
apps/cli-v2/src/commands/delete-mesh.ts
Normal file
128
apps/cli-v2/src/commands/delete-mesh.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
function getUserId(token: string): string {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
return payload.sub ?? "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async function isOwner(slug: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
|
||||
const config = readConfig();
|
||||
|
||||
// Mesh picker if no slug given
|
||||
if (!slug) {
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes to remove.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
console.log("\n Select mesh to remove:\n");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice: ");
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
if (idx < 0 || idx >= config.meshes.length) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
slug = config.meshes[idx]!.slug;
|
||||
}
|
||||
|
||||
const auth = getStoredToken();
|
||||
const userId = auth ? getUserId(auth.session_token) : "";
|
||||
const ownerCheck = userId ? await isOwner(slug, userId) : false;
|
||||
|
||||
// Ask what to do
|
||||
if (!opts.yes) {
|
||||
console.log(`\n ${bold(slug)}\n`);
|
||||
|
||||
if (ownerCheck) {
|
||||
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
|
||||
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
|
||||
console.log(` ${bold("3)")} Cancel`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
|
||||
if (choice === "2") {
|
||||
// Server-side delete — require confirmation
|
||||
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
|
||||
const confirm = await prompt(` Type "${slug}" to confirm: `);
|
||||
if (confirm.toLowerCase() !== slug.toLowerCase()) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
|
||||
try {
|
||||
await request({
|
||||
path: `/cli/mesh/${slug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${icons.cross} Server delete failed: ${msg}`);
|
||||
}
|
||||
|
||||
leaveMesh(slug);
|
||||
console.log(` ${green(icons.check)} Removed from local config.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// choice === "1" — local only, fall through
|
||||
} else {
|
||||
// Not owner — can only remove locally
|
||||
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
|
||||
console.log(` ${bold("2)")} Cancel`);
|
||||
if (!ownerCheck && userId) {
|
||||
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local-only removal
|
||||
const removed = leaveMesh(slug);
|
||||
if (removed) {
|
||||
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
|
||||
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
|
||||
} else {
|
||||
console.error(` Mesh "${slug}" not found in local config.`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
281
apps/cli-v2/src/commands/doctor.ts
Normal file
281
apps/cli-v2/src/commands/doctor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
176
apps/cli-v2/src/commands/grants.ts
Normal file
176
apps/cli-v2/src/commands/grants.ts
Normal 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);
|
||||
}
|
||||
123
apps/cli-v2/src/commands/hook.ts
Normal file
123
apps/cli-v2/src/commands/hook.ts
Normal 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);
|
||||
}
|
||||
60
apps/cli-v2/src/commands/inbox.ts
Normal file
60
apps/cli-v2/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.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("");
|
||||
}
|
||||
});
|
||||
}
|
||||
29
apps/cli-v2/src/commands/index.ts
Normal file
29
apps/cli-v2/src/commands/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export { runJoin } from "./join.js";
|
||||
export { newMesh } from "./new.js";
|
||||
export { invite } from "./invite.js";
|
||||
export { runList } from "./list.js";
|
||||
export { rename } from "./rename.js";
|
||||
export { runLeave } from "./leave.js";
|
||||
export { runPeers } from "./peers.js";
|
||||
export { runSend } from "./send.js";
|
||||
export { runInbox } from "./inbox.js";
|
||||
export { runStateGet, runStateSet } from "./state.js";
|
||||
export { runInfo } from "./info.js";
|
||||
export { remember } from "./remember.js";
|
||||
export { recall } from "./recall.js";
|
||||
export { runRemind } from "./remind.js";
|
||||
export { runProfile } from "./profile.js";
|
||||
export { runStatus } from "./status.js";
|
||||
export { runDoctor } from "./doctor.js";
|
||||
export { register } from "./register.js";
|
||||
export { login } from "./login.js";
|
||||
export { logout } from "./logout.js";
|
||||
export { whoami } from "./whoami.js";
|
||||
export { runInstall } from "./install.js";
|
||||
export { uninstall } from "./uninstall.js";
|
||||
export { runSync } from "./sync.js";
|
||||
export { runWelcome } from "./welcome.js";
|
||||
export { runHook } from "./hook.js";
|
||||
export { runMcp } from "./mcp.js";
|
||||
export { runSeedTestMesh } from "./seed-test-mesh.js";
|
||||
export { withMesh } from "./connect.js";
|
||||
58
apps/cli-v2/src/commands/info.ts
Normal file
58
apps/cli-v2/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||
*
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.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)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
564
apps/cli-v2/src/commands/install.ts
Normal file
564
apps/cli-v2/src/commands/install.ts
Normal 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.");
|
||||
}
|
||||
96
apps/cli-v2/src/commands/invite.ts
Normal file
96
apps/cli-v2/src/commands/invite.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { generateInvite } from "~/services/invite/generate.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { writeClipboard } from "~/services/clipboard/facade.js";
|
||||
import { green, bold, dim, icons } from "~/ui/styles.js";
|
||||
import { renderQrAsync } from "~/ui/qr.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function invite(
|
||||
email?: string,
|
||||
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.error(" Not signed in. Run `claudemesh login` first.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
// Resolve which mesh to share
|
||||
let meshSlug = opts.mesh;
|
||||
if (!meshSlug) {
|
||||
if (config.meshes.length === 1) {
|
||||
meshSlug = config.meshes[0]!.slug;
|
||||
} else {
|
||||
// Show picker
|
||||
console.log("\n Select mesh to share:\n");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateInvite(meshSlug, {
|
||||
email,
|
||||
expires_in: opts.expires ?? "7d",
|
||||
max_uses: opts.uses,
|
||||
role: opts.role,
|
||||
});
|
||||
|
||||
const copied = writeClipboard(result.url);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
|
||||
} else {
|
||||
if (email) {
|
||||
if (result.emailed) {
|
||||
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
|
||||
} else {
|
||||
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
|
||||
console.log(` ${dim("Share the link manually:")}`);
|
||||
console.log(` ${result.url}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
|
||||
console.log(` ${result.url}`);
|
||||
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
|
||||
const qr = await renderQrAsync(result.url, { small: true });
|
||||
console.log("");
|
||||
for (const line of qr.split("\n")) console.log(` ${line}`);
|
||||
}
|
||||
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("403") || msg.includes("permission")) {
|
||||
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
|
||||
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
193
apps/cli-v2/src/commands/join.ts
Normal file
193
apps/cli-v2/src/commands/join.ts
Normal 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.");
|
||||
}
|
||||
823
apps/cli-v2/src/commands/launch.ts
Normal file
823
apps/cli-v2/src/commands/launch.ts
Normal 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);
|
||||
}
|
||||
25
apps/cli-v2/src/commands/leave.ts
Normal file
25
apps/cli-v2/src/commands/leave.ts
Normal 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}`);
|
||||
}
|
||||
104
apps/cli-v2/src/commands/list.ts
Normal file
104
apps/cli-v2/src/commands/list.ts
Normal 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("");
|
||||
}
|
||||
118
apps/cli-v2/src/commands/login.ts
Normal file
118
apps/cli-v2/src/commands/login.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
|
||||
import { my } from "~/services/api/facade.js";
|
||||
import { green, dim, bold, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithToken(): Promise<number> {
|
||||
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
|
||||
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
|
||||
|
||||
const token = await prompt(" Token: ");
|
||||
if (!token) {
|
||||
console.error(` ${icons.cross} No token provided.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
// Decode JWT to get user info
|
||||
let user = { id: "", display_name: "", email: "" };
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts[1]) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
|
||||
sub?: string; email?: string; name?: string; exp?: number;
|
||||
};
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
console.error(` ${icons.cross} Token expired. Generate a new one.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
user = {
|
||||
id: payload.sub ?? "",
|
||||
display_name: payload.name ?? payload.email ?? "",
|
||||
email: payload.email ?? "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
console.error(` ${icons.cross} Invalid token format.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
storeToken({ session_token: token, user, token_source: "manual" });
|
||||
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
async function syncMeshes(token: string): Promise<void> {
|
||||
try {
|
||||
const meshes = await my.getMeshes(token);
|
||||
if (meshes.length > 0) {
|
||||
const names = meshes.map((m) => m.slug).join(", ");
|
||||
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function login(): Promise<number> {
|
||||
const existing = getStoredToken();
|
||||
if (existing) {
|
||||
const name = existing.user.display_name || existing.user.email || "unknown";
|
||||
console.log(`\n Already signed in as ${bold(name)}.`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Continue as ${name}`);
|
||||
console.log(` ${bold("2)")} Sign in via browser`);
|
||||
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log(` ${bold("4)")} Sign out`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "1") {
|
||||
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "4") {
|
||||
clearToken();
|
||||
console.log(` ${green(icons.check)} Signed out.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "3") {
|
||||
clearToken();
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "2" → fall through to browser login
|
||||
clearToken();
|
||||
console.log(` ${dim("Signing in…")}`);
|
||||
} else {
|
||||
// Not logged in — show auth options
|
||||
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
|
||||
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "2") {
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "1" → fall through to browser login
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWithDeviceCode();
|
||||
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
|
||||
await syncMeshes(result.session_token);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
22
apps/cli-v2/src/commands/logout.ts
Normal file
22
apps/cli-v2/src/commands/logout.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { logout as doLogout } from "~/services/auth/facade.js";
|
||||
import { green, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function logout(): Promise<number> {
|
||||
try {
|
||||
const { revoked } = await doLogout();
|
||||
|
||||
if (revoked) {
|
||||
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
|
||||
} else {
|
||||
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
|
||||
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
|
||||
}
|
||||
console.log(` ${green(icons.check)} Removed local credentials.`);
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
9
apps/cli-v2/src/commands/mcp.ts
Normal file
9
apps/cli-v2/src/commands/mcp.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { startMcpServer } from "~/mcp/server.js";
|
||||
|
||||
export async function runMcp(): Promise<never> {
|
||||
await startMcpServer();
|
||||
await new Promise(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { runMcp as _stub };
|
||||
48
apps/cli-v2/src/commands/new.ts
Normal file
48
apps/cli-v2/src/commands/new.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create as createMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { green, dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function newMesh(
|
||||
name: string,
|
||||
opts: { template?: string; description?: string; json?: boolean },
|
||||
): Promise<number> {
|
||||
if (!name) {
|
||||
console.error(" Usage: claudemesh mesh create <name>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
if (!getStoredToken()) {
|
||||
console.log(dim(" Not signed in — starting login…\n"));
|
||||
const { login } = await import("./login.js");
|
||||
const loginResult = await login();
|
||||
if (loginResult !== EXIT.SUCCESS) return loginResult;
|
||||
console.log("");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createMesh(name, {
|
||||
template: opts.template,
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
|
||||
console.log(` ${green(icons.check)} You're the owner`);
|
||||
console.log(` ${green(icons.check)} Joined locally`);
|
||||
console.log(`\n Share with: claudemesh mesh share\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("409") || msg.includes("already exists")) {
|
||||
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
82
apps/cli-v2/src/commands/peers.ts
Normal file
82
apps/cli-v2/src/commands/peers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
114
apps/cli-v2/src/commands/profile.ts
Normal file
114
apps/cli-v2/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { 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 ?? ""))}`);
|
||||
}
|
||||
35
apps/cli-v2/src/commands/recall.ts
Normal file
35
apps/cli-v2/src/commands/recall.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { dim, bold } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function recall(
|
||||
query: string,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
8
apps/cli-v2/src/commands/register.ts
Normal file
8
apps/cli-v2/src/commands/register.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { login } from "./login.js";
|
||||
|
||||
// Register and login use the same device-code flow.
|
||||
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
|
||||
// which has a "Don't have an account? Register" link.
|
||||
export async function register(): Promise<number> {
|
||||
return login();
|
||||
}
|
||||
28
apps/cli-v2/src/commands/remember.ts
Normal file
28
apps/cli-v2/src/commands/remember.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function remember(
|
||||
content: string,
|
||||
opts: { mesh?: string; tags?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const id = await client.remember(content, tags);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
console.error("\u2717 Failed to store memory");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
142
apps/cli-v2/src/commands/remind.ts
Normal file
142
apps/cli-v2/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
14
apps/cli-v2/src/commands/rename.ts
Normal file
14
apps/cli-v2/src/commands/rename.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { rename as renameMesh } from "~/services/mesh/facade.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function rename(slug: string, newName: string): Promise<number> {
|
||||
try {
|
||||
await renameMesh(slug, newName);
|
||||
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
44
apps/cli-v2/src/commands/seed-test-mesh.ts
Normal file
44
apps/cli-v2/src/commands/seed-test-mesh.ts
Normal 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\`.`,
|
||||
);
|
||||
}
|
||||
51
apps/cli-v2/src/commands/send.ts
Normal file
51
apps/cli-v2/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli-v2/src/commands/state.ts
Normal file
75
apps/cli-v2/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.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()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
69
apps/cli-v2/src/commands/status-line.ts
Normal file
69
apps/cli-v2/src/commands/status-line.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* `claudemesh status-line` — one-line renderer for Claude Code's
|
||||
* `statusLine` setting.
|
||||
*
|
||||
* Must be FAST (Claude Code polls it between every turn) — zero network
|
||||
* I/O. Reads only local config + a peer-state cache maintained by the
|
||||
* MCP server (~/.claudemesh/peer-cache.json, updated on every
|
||||
* list_peers call).
|
||||
*
|
||||
* Output format:
|
||||
* ◇ <mesh> · <online>/<total> peers · <you>
|
||||
* or:
|
||||
* ◇ claudemesh (not joined)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
interface PeerCacheEntry {
|
||||
total: number;
|
||||
online: number;
|
||||
updatedAt: string;
|
||||
you?: string;
|
||||
}
|
||||
|
||||
type PeerCache = Record<string, PeerCacheEntry>;
|
||||
|
||||
export async function runStatusLine(): Promise<number> {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
process.stdout.write("◇ claudemesh (not joined)");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
|
||||
let cache: PeerCache = {};
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
|
||||
} catch {
|
||||
// corrupt — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the most-recently-used mesh if multiple.
|
||||
const pick = config.meshes[0]!;
|
||||
const entry = cache[pick.slug];
|
||||
|
||||
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
|
||||
const fresh = age < 60_000; // < 1 min = live
|
||||
|
||||
if (entry && fresh) {
|
||||
const you = entry.you ? ` · ${entry.you}` : "";
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} online${you}`);
|
||||
} else if (entry) {
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} (stale)`);
|
||||
} else {
|
||||
process.stdout.write(`◇ ${pick.slug} · idle`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
} catch {
|
||||
// Never break the status line — just print nothing.
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
}
|
||||
103
apps/cli-v2/src/commands/status.ts
Normal file
103
apps/cli-v2/src/commands/status.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
89
apps/cli-v2/src/commands/sync.ts
Normal file
89
apps/cli-v2/src/commands/sync.ts
Normal 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)`);
|
||||
}
|
||||
}
|
||||
228
apps/cli-v2/src/commands/test.ts
Normal file
228
apps/cli-v2/src/commands/test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* `claudemesh test` — integration test battery against live broker.
|
||||
*
|
||||
* Creates a temporary mesh, runs all operations, verifies results,
|
||||
* then cleans up. Safe to run repeatedly.
|
||||
*/
|
||||
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { runAllChecks } from "~/services/health/facade.js";
|
||||
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
ms: number;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const detail = await fn();
|
||||
results.push({ name, ok: true, detail, ms: Date.now() - start });
|
||||
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
results.push({ name, ok: false, detail, ms: Date.now() - start });
|
||||
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTest(): Promise<number> {
|
||||
const started = Date.now();
|
||||
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
|
||||
|
||||
console.log("");
|
||||
console.log(` ${bold("claudemesh integration test")}`);
|
||||
console.log(` ${dim("─".repeat(40))}`);
|
||||
console.log("");
|
||||
|
||||
// --- Auth ---
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch {}
|
||||
|
||||
await run("auth", async () => {
|
||||
if (!userId) throw new Error("invalid token");
|
||||
return `signed in as ${auth.user.display_name || auth.user.email}`;
|
||||
});
|
||||
|
||||
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
|
||||
{
|
||||
const checks = runAllChecks();
|
||||
const failed = checks.filter(c => !c.ok);
|
||||
if (failed.length > 0) {
|
||||
const warns = failed.map(c => c.name).join(", ");
|
||||
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
|
||||
} else {
|
||||
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
await run("crypto", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const sig = await sign("test-message", kp.secretKey);
|
||||
const valid = await verify("test-message", sig, kp.publicKey);
|
||||
if (!valid) throw new Error("signature verification failed");
|
||||
const tampered = await verify("tampered", sig, kp.publicKey);
|
||||
if (tampered) throw new Error("tampered message should not verify");
|
||||
return "keypair + sign + verify round-trip";
|
||||
});
|
||||
|
||||
// --- Mesh create ---
|
||||
let meshId = "";
|
||||
const createOk = await run("create", async () => {
|
||||
const result = await createMesh(meshSlug);
|
||||
meshId = result.id;
|
||||
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
if (!createOk) {
|
||||
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
await run("list", async () => {
|
||||
const config = readConfig();
|
||||
const found = config.meshes.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not in local config");
|
||||
return `found ${meshSlug} in local config`;
|
||||
});
|
||||
|
||||
// --- Server list ---
|
||||
await run("server list", async () => {
|
||||
const res = await request<{ meshes: Array<{ slug: string }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
const found = res.meshes?.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not on server");
|
||||
return `found ${meshSlug} on server (${res.meshes.length} total)`;
|
||||
});
|
||||
|
||||
// --- Connect (broker WS) ---
|
||||
const config = readConfig();
|
||||
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
|
||||
let client: BrokerClient | null = null;
|
||||
|
||||
if (meshConfig) {
|
||||
await run("connect", async () => {
|
||||
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
|
||||
await client.connect();
|
||||
if (client.status !== "open") throw new Error("status: " + client.status);
|
||||
return "broker connected, hello_ack received";
|
||||
});
|
||||
|
||||
// --- Peers ---
|
||||
if (client) {
|
||||
await run("peers", async () => {
|
||||
const peers = await client!.listPeers();
|
||||
return `${peers.length} peer(s) online`;
|
||||
});
|
||||
|
||||
// --- Send ---
|
||||
await run("send", async () => {
|
||||
const result = await client!.send("*", "test-battery-ping", "low");
|
||||
if (!result.ok) throw new Error(result.error ?? "send failed");
|
||||
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Remember ---
|
||||
let memoryId: string | null = null;
|
||||
await run("remember", async () => {
|
||||
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
|
||||
if (!memoryId) throw new Error("no memory ID returned");
|
||||
return `stored (${memoryId.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Recall (postgres full-text search) ---
|
||||
await run("recall", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const memories = await client!.recall("integration test battery");
|
||||
if (memories.length === 0) throw new Error("no memories found");
|
||||
return `${memories.length} result(s)`;
|
||||
});
|
||||
|
||||
// --- State ---
|
||||
const stateVal = "test-value-" + Date.now();
|
||||
await run("state set", async () => {
|
||||
await client!.setState("test-e2e-key", stateVal);
|
||||
return "key written";
|
||||
});
|
||||
|
||||
await run("state get", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const result = await client!.getState("test-e2e-key");
|
||||
if (!result) throw new Error("key not found");
|
||||
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
|
||||
return `read back: ${String(result.value).slice(0, 20)}…`;
|
||||
});
|
||||
|
||||
// --- Clean up memory ---
|
||||
if (memoryId) {
|
||||
await run("forget", async () => {
|
||||
await client!.forget(memoryId!);
|
||||
return "memory cleaned up";
|
||||
});
|
||||
}
|
||||
|
||||
// --- Disconnect ---
|
||||
await run("disconnect", async () => {
|
||||
client!.close();
|
||||
return "connection closed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete mesh ---
|
||||
await run("delete", async () => {
|
||||
// Server-side delete
|
||||
await request({
|
||||
path: `/cli/mesh/${meshSlug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
leaveMesh(meshSlug);
|
||||
return `deleted "${meshSlug}" from server + local`;
|
||||
});
|
||||
|
||||
// --- Summary ---
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const totalMs = Date.now() - started;
|
||||
|
||||
console.log("");
|
||||
if (failed === 0) {
|
||||
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
} else {
|
||||
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
|
||||
}
|
||||
58
apps/cli-v2/src/commands/uninstall.ts
Normal file
58
apps/cli-v2/src/commands/uninstall.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { PATHS } from "~/constants/paths.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function uninstall(): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
// Remove MCP server from ~/.claude.json
|
||||
if (existsSync(PATHS.CLAUDE_JSON)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const servers = config.mcpServers as Record<string, unknown> | undefined;
|
||||
if (servers && "claudemesh" in servers) {
|
||||
delete servers.claudemesh;
|
||||
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
|
||||
removed++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove only claudemesh hooks from ~/.claude/settings.json
|
||||
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const hooks = config.hooks as Record<string, unknown[]> | undefined;
|
||||
if (hooks) {
|
||||
let removedHooks = 0;
|
||||
for (const [event, entries] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const filtered = entries.filter((h: unknown) => {
|
||||
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
|
||||
return !cmd.includes("claudemesh");
|
||||
});
|
||||
if (filtered.length < entries.length) {
|
||||
removedHooks += entries.length - filtered.length;
|
||||
if (filtered.length === 0) delete hooks[event];
|
||||
else hooks[event] = filtered;
|
||||
}
|
||||
}
|
||||
if (removedHooks > 0) {
|
||||
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (removed === 0) {
|
||||
console.log(" Nothing to remove — claudemesh was not installed.");
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
99
apps/cli-v2/src/commands/upgrade.ts
Normal file
99
apps/cli-v2/src/commands/upgrade.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query npm for the latest @alpha dist-tag.
|
||||
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
|
||||
* npm that installed us (detected from argv[1] path walk).
|
||||
* 3. Print before/after versions.
|
||||
*
|
||||
* For users who got the CLI via the `/install` shell flow (portable Node
|
||||
* in ~/.claudemesh), we call that npm directly so nothing else on the
|
||||
* system is touched.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { URLS, VERSION } from "~/constants/urls.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
async function latestAlpha(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
|
||||
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findNpm(): { npm: string; prefix?: string } {
|
||||
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
|
||||
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
|
||||
if (existsSync(portable)) {
|
||||
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
|
||||
}
|
||||
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
|
||||
// walk up to find a sibling npm binary.
|
||||
let cur = resolve(process.argv[1] ?? ".");
|
||||
for (let i = 0; i < 6; i++) {
|
||||
cur = dirname(cur);
|
||||
const candidate = join(cur, "bin", "npm");
|
||||
if (existsSync(candidate)) return { npm: candidate };
|
||||
}
|
||||
// Fallback to PATH.
|
||||
return { npm: "npm" };
|
||||
}
|
||||
|
||||
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
|
||||
render.section("claudemesh upgrade");
|
||||
render.kv([
|
||||
["installed", VERSION],
|
||||
["checking", "npm registry…"],
|
||||
]);
|
||||
|
||||
const latest = await latestAlpha();
|
||||
if (!latest) {
|
||||
render.warn("Could not reach npm registry — skipped.");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.kv([["latest", latest]]);
|
||||
|
||||
if (latest === VERSION) {
|
||||
render.blank();
|
||||
render.ok(`Already on latest (${latest}).`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
render.blank();
|
||||
render.warn(`Update available: ${VERSION} → ${latest}`);
|
||||
render.hint("Run: claudemesh upgrade");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const { npm, prefix } = findNpm();
|
||||
const args = ["install", "-g"];
|
||||
if (prefix) args.push("--prefix", prefix);
|
||||
args.push("claudemesh-cli@alpha");
|
||||
|
||||
render.blank();
|
||||
render.info(`Updating ${VERSION} → ${latest}…`);
|
||||
render.hint(`${npm} ${args.join(" ")}`);
|
||||
render.blank();
|
||||
|
||||
const res = spawnSync(npm, args, { stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
render.err(`npm exited with status ${res.status}`);
|
||||
render.hint("Try: npm i -g claudemesh-cli@alpha");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
render.blank();
|
||||
render.ok(`Upgraded to ${latest}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
178
apps/cli-v2/src/commands/url-handler.ts
Normal file
178
apps/cli-v2/src/commands/url-handler.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
|
||||
* URL scheme handler with the OS so click-to-launch from email/web works.
|
||||
*
|
||||
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
|
||||
* When activated, the OS opens the handler, which runs
|
||||
* claudemesh https://claudemesh.com/i/<code>
|
||||
* (inline join + launch path via the bare-URL dispatch in cli.ts).
|
||||
*
|
||||
* Platforms:
|
||||
* - darwin → LSRegisterURL via a per-user .app bundle in
|
||||
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
|
||||
* - linux → xdg-mime default + a .desktop file in
|
||||
* ~/.local/share/applications/claudemesh.desktop
|
||||
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
|
||||
*/
|
||||
|
||||
import { platform, homedir } from "node:os";
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function resolveClaudemeshBin(): string {
|
||||
// argv[1] points to the running binary; prefer that over $PATH so we
|
||||
// register the exact install the user ran.
|
||||
return process.argv[1] ?? "claudemesh";
|
||||
}
|
||||
|
||||
function installDarwin(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
const contents = join(appDir, "Contents");
|
||||
const macOS = join(contents, "MacOS");
|
||||
mkdirSync(macOS, { recursive: true });
|
||||
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
|
||||
<key>CFBundleName</key><string>Claudemesh</string>
|
||||
<key>CFBundleExecutable</key><string>open-url</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleSignature</key><string>????</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array><string>claudemesh</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`;
|
||||
writeFileSync(join(contents, "Info.plist"), plist);
|
||||
|
||||
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
|
||||
// window so the user sees launch output.
|
||||
const shim = `#!/bin/sh
|
||||
URL="$1"
|
||||
CODE=\${URL#claudemesh://}
|
||||
CODE=\${CODE#i/}
|
||||
CODE=\${CODE#join/}
|
||||
# Open a Terminal window so the user can see claude launching
|
||||
osascript <<EOF
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
|
||||
end tell
|
||||
EOF
|
||||
`;
|
||||
const shimPath = join(macOS, "open-url");
|
||||
writeFileSync(shimPath, shim);
|
||||
chmodSync(shimPath, 0o755);
|
||||
|
||||
// Re-register with Launch Services so the scheme resolves here.
|
||||
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
|
||||
if (lsreg.status !== 0) {
|
||||
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
|
||||
console.log(` app bundle: ${appDir}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installLinux(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appsDir = join(homedir(), ".local", "share", "applications");
|
||||
mkdirSync(appsDir, { recursive: true });
|
||||
const desktop = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Claudemesh
|
||||
Comment=Claudemesh invite handler
|
||||
Exec=${binPath} %u
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
MimeType=x-scheme-handler/claudemesh;
|
||||
NoDisplay=true
|
||||
`;
|
||||
const desktopPath = join(appsDir, "claudemesh.desktop");
|
||||
writeFileSync(desktopPath, desktop);
|
||||
|
||||
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
|
||||
if (xdg1.status !== 0) {
|
||||
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
|
||||
}
|
||||
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
|
||||
xdg2.status ?? 0; // best effort
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
|
||||
console.log(` desktop entry: ${desktopPath}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installWindows(): number {
|
||||
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
|
||||
const lines = [
|
||||
`Windows Registry Editor Version 5.00`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
|
||||
`@="URL:Claudemesh Invite"`,
|
||||
`"URL Protocol"=""`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
|
||||
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
|
||||
];
|
||||
const regPath = join(homedir(), "claudemesh-handler.reg");
|
||||
writeFileSync(regPath, lines.join("\r\n"));
|
||||
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
|
||||
if (res.status !== 0) {
|
||||
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallDarwin(): number {
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on macOS");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallLinux(): number {
|
||||
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
|
||||
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Linux");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallWindows(): number {
|
||||
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Windows");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runUrlHandler(action: string | undefined): Promise<number> {
|
||||
const act = action ?? "install";
|
||||
const p = platform();
|
||||
if (act === "install") {
|
||||
if (p === "darwin") return installDarwin();
|
||||
if (p === "linux") return installLinux();
|
||||
if (p === "win32") return installWindows();
|
||||
} else if (act === "uninstall" || act === "remove") {
|
||||
if (p === "darwin") return uninstallDarwin();
|
||||
if (p === "linux") return uninstallLinux();
|
||||
if (p === "win32") return uninstallWindows();
|
||||
} else {
|
||||
console.error("Usage: claudemesh url-handler <install|uninstall>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
console.error(`Unsupported platform: ${p}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
95
apps/cli-v2/src/commands/verify.ts
Normal file
95
apps/cli-v2/src/commands/verify.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `claudemesh verify [peer]` — show safety numbers for a peer.
|
||||
*
|
||||
* A safety number is a derived, human-readable fingerprint of the peer's
|
||||
* ed25519 public key plus your own. Both parties see the same digits,
|
||||
* so out-of-band comparison (call, in-person) detects MITM.
|
||||
*
|
||||
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
|
||||
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
|
||||
* Whatsapp pattern so users don't have to learn a new mental model.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function safetyNumber(myPubkey: string, peerPubkey: string): string {
|
||||
const a = Buffer.from(myPubkey, "hex");
|
||||
const b = Buffer.from(peerPubkey, "hex");
|
||||
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
|
||||
const hash = createHash("sha256").update(lo).update(hi).digest();
|
||||
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
bits.push((hash[i]! >> b) & 1);
|
||||
}
|
||||
}
|
||||
const groups: string[] = [];
|
||||
for (let g = 0; g < 6; g++) {
|
||||
let val = 0;
|
||||
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
|
||||
groups.push(String(val % 100000).padStart(5, "0"));
|
||||
}
|
||||
return groups.join(" ");
|
||||
}
|
||||
|
||||
export async function runVerify(
|
||||
target: string | undefined,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
|
||||
|
||||
const config = readConfig();
|
||||
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||
if (!meshSlug) {
|
||||
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const mesh = config.meshes.find((m) => m.slug === meshSlug);
|
||||
if (!mesh) {
|
||||
console.error(` Mesh "${meshSlug}" not found locally.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
return await withMesh({ meshSlug }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const targets = target
|
||||
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
|
||||
: peers;
|
||||
if (targets.length === 0) {
|
||||
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(targets.map((p) => ({
|
||||
mesh: meshSlug,
|
||||
peer: p.displayName,
|
||||
pubkey: p.pubkey,
|
||||
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
|
||||
})), null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
|
||||
console.log("");
|
||||
for (const p of targets) {
|
||||
const sn = safetyNumber(mesh.pubkey, p.pubkey);
|
||||
console.log(` ${bold(p.displayName)}`);
|
||||
console.log(` ${clay(sn)}`);
|
||||
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}…`)}`);
|
||||
console.log("");
|
||||
}
|
||||
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
|
||||
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
|
||||
console.log("");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
72
apps/cli-v2/src/commands/welcome.ts
Normal file
72
apps/cli-v2/src/commands/welcome.ts
Normal 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 };
|
||||
26
apps/cli-v2/src/commands/whoami.ts
Normal file
26
apps/cli-v2/src/commands/whoami.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { whoAmI } from "~/services/auth/facade.js";
|
||||
import { dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||
const result = await whoAmI();
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (!result.signed_in) {
|
||||
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
|
||||
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
|
||||
if (result.meshes) {
|
||||
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
14
apps/cli-v2/src/constants/exit-codes.ts
Normal file
14
apps/cli-v2/src/constants/exit-codes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const EXIT = {
|
||||
SUCCESS: 0,
|
||||
USER_CANCELLED: 1,
|
||||
AUTH_FAILED: 2,
|
||||
INVALID_ARGS: 3,
|
||||
NETWORK_ERROR: 4,
|
||||
NOT_FOUND: 5,
|
||||
ALREADY_EXISTS: 6,
|
||||
PERMISSION_DENIED: 7,
|
||||
INTERNAL_ERROR: 8,
|
||||
CLAUDE_MISSING: 9,
|
||||
} as const;
|
||||
|
||||
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
||||
5
apps/cli-v2/src/constants/index.ts
Normal file
5
apps/cli-v2/src/constants/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { EXIT } from "./exit-codes.js";
|
||||
export type { ExitCode } from "./exit-codes.js";
|
||||
export { PATHS } from "./paths.js";
|
||||
export { URLS } from "./urls.js";
|
||||
export { TIMINGS } from "./timings.js";
|
||||
22
apps/cli-v2/src/constants/paths.ts
Normal file
22
apps/cli-v2/src/constants/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const home = homedir();
|
||||
|
||||
export const PATHS = {
|
||||
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
|
||||
get CONFIG_FILE() {
|
||||
return join(this.CONFIG_DIR, "config.json");
|
||||
},
|
||||
get AUTH_FILE() {
|
||||
return join(this.CONFIG_DIR, "auth.json");
|
||||
},
|
||||
get KEYS_DIR() {
|
||||
return join(this.CONFIG_DIR, "keys");
|
||||
},
|
||||
get LAST_USED_FILE() {
|
||||
return join(this.CONFIG_DIR, "last-used.json");
|
||||
},
|
||||
CLAUDE_JSON: join(home, ".claude.json"),
|
||||
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
|
||||
} as const;
|
||||
11
apps/cli-v2/src/constants/timings.ts
Normal file
11
apps/cli-v2/src/constants/timings.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TIMINGS = {
|
||||
DEVICE_CODE_POLL_MS: 1500,
|
||||
DEVICE_CODE_TIMEOUT_MS: 5 * 60 * 1000,
|
||||
WS_RECONNECT_BASE_MS: 1000,
|
||||
WS_RECONNECT_MAX_MS: 30_000,
|
||||
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
|
||||
TELEGRAM_CONNECT_TIMEOUT_MS: 5 * 60 * 1000,
|
||||
TELEGRAM_POLL_INTERVAL_MS: 2000,
|
||||
API_TIMEOUT_MS: 15_000,
|
||||
API_RETRY_COUNT: 2,
|
||||
} as const;
|
||||
14
apps/cli-v2/src/constants/urls.ts
Normal file
14
apps/cli-v2/src/constants/urls.ts
Normal 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",
|
||||
};
|
||||
200
apps/cli-v2/src/entrypoints/cli.ts
Normal file
200
apps/cli-v2/src/entrypoints/cli.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgv } from "~/cli/argv.js";
|
||||
import { installSignalHandlers } from "~/cli/handlers/signal.js";
|
||||
import { installErrorHandlers } from "~/cli/handlers/error.js";
|
||||
import { showUpdateNotice } from "~/cli/update-notice.js";
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { renderVersion } from "~/cli/output/version.js";
|
||||
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
|
||||
|
||||
installSignalHandlers();
|
||||
installErrorHandlers();
|
||||
|
||||
const { command, positionals, flags } = parseArgv(process.argv);
|
||||
|
||||
const HELP = `
|
||||
claudemesh — peer mesh for Claude Code sessions
|
||||
${VERSION}
|
||||
|
||||
USAGE
|
||||
claudemesh auto-connect to your mesh
|
||||
claudemesh <invite-url> join a mesh, then launch
|
||||
claudemesh launch --name <n> --join <url> join + launch in one step
|
||||
|
||||
Mesh
|
||||
claudemesh create <name> create a new mesh
|
||||
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
|
||||
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
|
||||
claudemesh list show your meshes (alias: ls)
|
||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||
claudemesh rename <slug> <name> rename a mesh
|
||||
claudemesh share [email] share mesh (invite link / send email)
|
||||
|
||||
Messaging
|
||||
claudemesh peers see who's online
|
||||
claudemesh send <to> <msg> send a message
|
||||
claudemesh inbox drain pending messages
|
||||
claudemesh state get|set|list shared state
|
||||
claudemesh remember <text> store a memory
|
||||
claudemesh recall <query> search memories
|
||||
claudemesh remind ... schedule a reminder
|
||||
claudemesh profile view or edit your profile
|
||||
claudemesh info mesh overview
|
||||
|
||||
Auth
|
||||
claudemesh login sign in (browser or paste token)
|
||||
claudemesh register create account + sign in
|
||||
claudemesh logout sign out
|
||||
claudemesh whoami show current identity
|
||||
|
||||
Security
|
||||
claudemesh verify [peer] show ed25519 safety numbers (SAS)
|
||||
claudemesh grant <peer> <cap> grant capability (dm, broadcast, state-read, all)
|
||||
claudemesh revoke <peer> <cap> revoke capability (or 'all')
|
||||
claudemesh block <peer> revoke all capabilities (silent drop)
|
||||
claudemesh grants list per-peer overrides for current mesh
|
||||
claudemesh backup [file] encrypt config → portable recovery file
|
||||
claudemesh restore <file> restore config from a backup file
|
||||
|
||||
Setup
|
||||
claudemesh install register MCP server + hooks
|
||||
claudemesh uninstall remove MCP server + hooks
|
||||
claudemesh doctor diagnose issues (broker, node, claude)
|
||||
claudemesh status check broker connectivity
|
||||
claudemesh sync refresh mesh list from dashboard
|
||||
claudemesh completions <shell> emit bash / zsh / fish completion script
|
||||
claudemesh url-handler install register claudemesh:// click-to-launch
|
||||
claudemesh upgrade self-update to latest alpha (rustup-style)
|
||||
|
||||
Flags
|
||||
--version, -V show version
|
||||
--help, -h show this help
|
||||
--json machine-readable output
|
||||
--mesh <slug> override mesh selection
|
||||
-y, --yes skip confirmations
|
||||
-q, --quiet suppress non-essential output
|
||||
`;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); }
|
||||
if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); }
|
||||
|
||||
// Bare command or invite URL
|
||||
if (!command || isInviteUrl(command)) {
|
||||
// `claudemesh <invite-url>` → join + launch in one step.
|
||||
// `-y` skips all interactive prompts (role=member, no groups, push mode).
|
||||
if (command && isInviteUrl(command)) {
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: flags.mesh as string | undefined,
|
||||
name: flags.name as string | undefined,
|
||||
join: normaliseInviteUrl(command),
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string | undefined,
|
||||
}, process.argv.slice(2));
|
||||
return;
|
||||
}
|
||||
const { readConfig } = await import("~/services/config/facade.js");
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
const { runWelcome } = await import("~/commands/welcome.js");
|
||||
process.exit(await runWelcome());
|
||||
}
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: flags.mesh as string | undefined,
|
||||
name: flags.name as string | undefined,
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string | undefined,
|
||||
}, process.argv.slice(2));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "help": { console.log(HELP); break; }
|
||||
|
||||
// Mesh management
|
||||
case "create": case "new": { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[0] ?? "", { json: !!flags.json })); break; }
|
||||
case "add": case "join": { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals); break; }
|
||||
case "connect": case "launch": {
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: positionals[0] ?? flags.mesh as string,
|
||||
name: flags.name as string,
|
||||
join: flags.join as string,
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string,
|
||||
}, process.argv.slice(2));
|
||||
break;
|
||||
}
|
||||
case "disconnect": { console.log(" Connection closed."); process.exit(EXIT.SUCCESS); break; }
|
||||
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
|
||||
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
|
||||
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
|
||||
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||
|
||||
// Messaging
|
||||
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
|
||||
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
|
||||
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
||||
case "state": {
|
||||
const sub = positionals[0];
|
||||
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
|
||||
else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); }
|
||||
else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
|
||||
break;
|
||||
}
|
||||
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; }
|
||||
case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; }
|
||||
case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; }
|
||||
case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; }
|
||||
case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; }
|
||||
|
||||
// Auth
|
||||
case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; }
|
||||
case "register": { const { register } = await import("~/commands/register.js"); process.exit(await register()); break; }
|
||||
case "logout": { const { logout } = await import("~/commands/logout.js"); process.exit(await logout()); break; }
|
||||
case "whoami": { const { whoami } = await import("~/commands/whoami.js"); process.exit(await whoami({ json: !!flags.json })); break; }
|
||||
|
||||
// Setup
|
||||
case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; }
|
||||
case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; }
|
||||
case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; }
|
||||
case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; }
|
||||
case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; }
|
||||
|
||||
// Test
|
||||
case "test": { const { runTest } = await import("~/commands/test.js"); process.exit(await runTest()); break; }
|
||||
|
||||
// CLI utilities
|
||||
case "completions": { const { runCompletions } = await import("~/commands/completions.js"); process.exit(await runCompletions(positionals[0])); break; }
|
||||
case "verify": { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(positionals[0], { mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
|
||||
case "url-handler": { const { runUrlHandler } = await import("~/commands/url-handler.js"); process.exit(await runUrlHandler(positionals[0])); break; }
|
||||
case "status-line": { const { runStatusLine } = await import("~/commands/status-line.js"); process.exit(await runStatusLine()); break; }
|
||||
case "backup": { const { runBackup } = await import("~/commands/backup.js"); process.exit(await runBackup(positionals[0])); break; }
|
||||
case "restore": { const { runRestore } = await import("~/commands/backup.js"); process.exit(await runRestore(positionals[0])); break; }
|
||||
case "upgrade": case "update": { const { runUpgrade } = await import("~/commands/upgrade.js"); process.exit(await runUpgrade({ check: !!flags.check, yes: !!flags.y || !!flags.yes })); break; }
|
||||
case "grant": { const { runGrant } = await import("~/commands/grants.js"); process.exit(await runGrant(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "revoke": { const { runRevoke } = await import("~/commands/grants.js"); process.exit(await runRevoke(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
|
||||
|
||||
// Internal
|
||||
case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; }
|
||||
case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; }
|
||||
case "seed-test-mesh": { const { runSeedTestMesh } = await import("~/commands/seed-test-mesh.js"); runSeedTestMesh(positionals); break; }
|
||||
|
||||
default: {
|
||||
console.error(` Unknown command: ${command}. Run \`claudemesh --help\` for usage.`);
|
||||
process.exit(EXIT.INVALID_ARGS);
|
||||
}
|
||||
}
|
||||
|
||||
showUpdateNotice(VERSION).catch(() => {});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal: " + (err instanceof Error ? err.message : String(err)));
|
||||
process.exit(EXIT.INTERNAL_ERROR);
|
||||
});
|
||||
6
apps/cli-v2/src/entrypoints/mcp.ts
Normal file
6
apps/cli-v2/src/entrypoints/mcp.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { startMcpServer } from "~/mcp/server.js";
|
||||
|
||||
startMcpServer().catch((err) => {
|
||||
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
10
apps/cli-v2/src/locales/en.ts
Normal file
10
apps/cli-v2/src/locales/en.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const en = {
|
||||
welcome: "Welcome to claudemesh",
|
||||
signed_in_as: "Signed in as {name}",
|
||||
mesh_created: 'Created "{slug}"',
|
||||
invite_copied: "Invite URL copied to clipboard",
|
||||
logout_success: "Signed out",
|
||||
error_network: "Could not reach {url}. Check your connection.",
|
||||
error_auth: "Authentication failed. Run \`claudemesh login\` to sign in.",
|
||||
error_not_found: "{resource} not found",
|
||||
} as const;
|
||||
1
apps/cli-v2/src/locales/index.ts
Normal file
1
apps/cli-v2/src/locales/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { en } from "./en.js";
|
||||
1
apps/cli-v2/src/mcp/handlers/jsonrpc.ts
Normal file
1
apps/cli-v2/src/mcp/handlers/jsonrpc.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
1
apps/cli-v2/src/mcp/handlers/stdio.ts
Normal file
1
apps/cli-v2/src/mcp/handlers/stdio.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
1
apps/cli-v2/src/mcp/middleware/error-handler.ts
Normal file
1
apps/cli-v2/src/mcp/middleware/error-handler.ts
Normal file
@@ -0,0 +1 @@
|
||||
export function formatToolError(err: unknown): string { return err instanceof Error ? err.message : String(err); }
|
||||
3
apps/cli-v2/src/mcp/middleware/logging.ts
Normal file
3
apps/cli-v2/src/mcp/middleware/logging.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function logToolCall(toolName: string, durationMs: number): void {
|
||||
if (process.env.CLAUDEMESH_DEBUG === "1") process.stderr.write("[mcp] " + toolName + " (" + durationMs + "ms)\n");
|
||||
}
|
||||
2
apps/cli-v2/src/mcp/router.ts
Normal file
2
apps/cli-v2/src/mcp/router.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tool dispatch — server.ts handles all routing via switch statement.
|
||||
export const ROUTER_VERSION = "1.0" as const;
|
||||
2184
apps/cli-v2/src/mcp/server.ts
Normal file
2184
apps/cli-v2/src/mcp/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
apps/cli-v2/src/mcp/tools/clock-write.ts
Normal file
4
apps/cli-v2/src/mcp/tools/clock-write.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: clock-write
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "clock-write" as const;
|
||||
export const TOOLS = ["mesh_set_clock", "mesh_pause_clock", "mesh_resume_clock"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/contexts.ts
Normal file
4
apps/cli-v2/src/mcp/tools/contexts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: contexts
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "contexts" as const;
|
||||
export const TOOLS = ["share_context", "get_context", "list_contexts"] as const;
|
||||
1020
apps/cli-v2/src/mcp/tools/definitions.ts
Normal file
1020
apps/cli-v2/src/mcp/tools/definitions.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
apps/cli-v2/src/mcp/tools/files.ts
Normal file
4
apps/cli-v2/src/mcp/tools/files.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: files
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "files" as const;
|
||||
export const TOOLS = ["share_file", "get_file", "list_files", "file_status", "delete_file", "grant_file_access", "read_peer_file", "list_peer_files"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/graph.ts
Normal file
4
apps/cli-v2/src/mcp/tools/graph.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: graph
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "graph" as const;
|
||||
export const TOOLS = ["graph_query", "graph_execute"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/groups.ts
Normal file
4
apps/cli-v2/src/mcp/tools/groups.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: groups
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "groups" as const;
|
||||
export const TOOLS = ["join_group", "leave_group"] as const;
|
||||
21
apps/cli-v2/src/mcp/tools/index.ts
Normal file
21
apps/cli-v2/src/mcp/tools/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { FAMILY as memoryFamily, TOOLS as memoryTools } from "./memory.js";
|
||||
export { FAMILY as stateFamily, TOOLS as stateTools } from "./state.js";
|
||||
export { FAMILY as messagingFamily, TOOLS as messagingTools } from "./messaging.js";
|
||||
export { FAMILY as profileFamily, TOOLS as profileTools } from "./profile.js";
|
||||
export { FAMILY as groupsFamily, TOOLS as groupsTools } from "./groups.js";
|
||||
export { FAMILY as filesFamily, TOOLS as filesTools } from "./files.js";
|
||||
export { FAMILY as vectorsFamily, TOOLS as vectorsTools } from "./vectors.js";
|
||||
export { FAMILY as graphFamily, TOOLS as graphTools } from "./graph.js";
|
||||
export { FAMILY as sqlFamily, TOOLS as sqlTools } from "./sql.js";
|
||||
export { FAMILY as streamsFamily, TOOLS as streamsTools } from "./streams.js";
|
||||
export { FAMILY as contextsFamily, TOOLS as contextsTools } from "./contexts.js";
|
||||
export { FAMILY as tasksFamily, TOOLS as tasksTools } from "./tasks.js";
|
||||
export { FAMILY as schedulingFamily, TOOLS as schedulingTools } from "./scheduling.js";
|
||||
export { FAMILY as meshMetaFamily, TOOLS as meshMetaTools } from "./mesh-meta.js";
|
||||
export { FAMILY as clockWriteFamily, TOOLS as clockWriteTools } from "./clock-write.js";
|
||||
export { FAMILY as skillsFamily, TOOLS as skillsTools } from "./skills.js";
|
||||
export { FAMILY as mcpRegistryPeerFamily, TOOLS as mcpRegistryPeerTools } from "./mcp-registry-peer.js";
|
||||
export { FAMILY as mcpRegistryBrokerFamily, TOOLS as mcpRegistryBrokerTools } from "./mcp-registry-broker.js";
|
||||
export { FAMILY as vaultFamily, TOOLS as vaultTools } from "./vault.js";
|
||||
export { FAMILY as urlWatchFamily, TOOLS as urlWatchTools } from "./url-watch.js";
|
||||
export { FAMILY as webhooksFamily, TOOLS as webhooksTools } from "./webhooks.js";
|
||||
4
apps/cli-v2/src/mcp/tools/mcp-registry-broker.ts
Normal file
4
apps/cli-v2/src/mcp/tools/mcp-registry-broker.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: mcp-registry-broker
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "mcp-registry-broker" as const;
|
||||
export const TOOLS = ["mesh_mcp_deploy", "mesh_mcp_undeploy", "mesh_mcp_update", "mesh_mcp_logs", "mesh_mcp_scope", "mesh_mcp_schema", "mesh_mcp_catalog"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/mcp-registry-peer.ts
Normal file
4
apps/cli-v2/src/mcp/tools/mcp-registry-peer.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: mcp-registry-peer
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "mcp-registry-peer" as const;
|
||||
export const TOOLS = ["mesh_mcp_register", "mesh_mcp_list", "mesh_tool_call", "mesh_mcp_remove"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/memory.ts
Normal file
4
apps/cli-v2/src/mcp/tools/memory.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: memory
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "memory" as const;
|
||||
export const TOOLS = ["remember", "recall", "forget"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/mesh-meta.ts
Normal file
4
apps/cli-v2/src/mcp/tools/mesh-meta.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: mesh-meta
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "mesh-meta" as const;
|
||||
export const TOOLS = ["mesh_info", "mesh_stats", "mesh_clock", "ping_mesh"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/messaging.ts
Normal file
4
apps/cli-v2/src/mcp/tools/messaging.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: messaging
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "messaging" as const;
|
||||
export const TOOLS = ["send_message", "list_peers", "check_messages", "message_status"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/profile.ts
Normal file
4
apps/cli-v2/src/mcp/tools/profile.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: profile
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "profile" as const;
|
||||
export const TOOLS = ["set_profile", "set_status", "set_summary", "set_visible"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/scheduling.ts
Normal file
4
apps/cli-v2/src/mcp/tools/scheduling.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: scheduling
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "scheduling" as const;
|
||||
export const TOOLS = ["schedule_reminder", "list_scheduled", "cancel_scheduled"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/skills.ts
Normal file
4
apps/cli-v2/src/mcp/tools/skills.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: skills
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "skills" as const;
|
||||
export const TOOLS = ["share_skill", "get_skill", "list_skills", "remove_skill", "mesh_skill_deploy"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/sql.ts
Normal file
4
apps/cli-v2/src/mcp/tools/sql.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: sql
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "sql" as const;
|
||||
export const TOOLS = ["mesh_query", "mesh_execute", "mesh_schema"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/state.ts
Normal file
4
apps/cli-v2/src/mcp/tools/state.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: state
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "state" as const;
|
||||
export const TOOLS = ["set_state", "get_state", "list_state"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/streams.ts
Normal file
4
apps/cli-v2/src/mcp/tools/streams.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: streams
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "streams" as const;
|
||||
export const TOOLS = ["create_stream", "publish", "subscribe", "list_streams"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/tasks.ts
Normal file
4
apps/cli-v2/src/mcp/tools/tasks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: tasks
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "tasks" as const;
|
||||
export const TOOLS = ["create_task", "claim_task", "complete_task", "list_tasks"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/url-watch.ts
Normal file
4
apps/cli-v2/src/mcp/tools/url-watch.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// MCP tool family: url-watch
|
||||
// Handlers in mcp/server.ts; this file defines the family for the spec's folder structure.
|
||||
export const FAMILY = "url-watch" as const;
|
||||
export const TOOLS = ["mesh_watch", "mesh_unwatch", "mesh_watches"] as const;
|
||||
4
apps/cli-v2/src/mcp/tools/vault.ts
Normal file
4
apps/cli-v2/src/mcp/tools/vault.ts
Normal 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;
|
||||
4
apps/cli-v2/src/mcp/tools/vectors.ts
Normal file
4
apps/cli-v2/src/mcp/tools/vectors.ts
Normal 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;
|
||||
4
apps/cli-v2/src/mcp/tools/webhooks.ts
Normal file
4
apps/cli-v2/src/mcp/tools/webhooks.ts
Normal 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;
|
||||
81
apps/cli-v2/src/mcp/types.ts
Normal file
81
apps/cli-v2/src/mcp/types.ts
Normal 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
Reference in New Issue
Block a user