feat(cli): 1.5.0 — CLI-first architecture, tool-less MCP, policy engine
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

CLI becomes the API; MCP becomes a tool-less push-pipe. Bundle -42%
(250 KB → 146 KB) after stripping ~1700 lines of dead tool handlers.

- Tool-less MCP: tools/list returns []. Inbound peer messages still
  arrive as experimental.claude/channel notifications mid-turn.
- Resource-noun-verb CLI: peer list, message send, memory recall, etc.
  Legacy flat verbs (peers, send, remember) remain as aliases.
- Bundled claudemesh skill auto-installed by `claudemesh install` —
  sole CLI-discoverability surface for Claude.
- Unix-socket bridge: CLI invocations dial the push-pipe's warm WS
  (~220 ms warm vs ~600 ms cold).
- --mesh <slug> flag: connect a session to multiple meshes.
- Policy engine: every broker-touching verb runs through a YAML gate
  at ~/.claudemesh/policy.yaml (auto-created). Destructive verbs
  prompt; non-TTY auto-denies. Audit log at ~/.claudemesh/audit.log.
- --approval-mode plan|read-only|write|yolo + --policy <path>.

Spec: .artifacts/specs/2026-05-02-architecture-north-star.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 01:18:19 +01:00
parent ff551ccf3d
commit b4f457fceb
36 changed files with 3636 additions and 2833 deletions

View File

@@ -0,0 +1,331 @@
/**
* Small broker-side action verbs that previously lived only as MCP tools.
*
* These are the CLI replacements for the soft-deprecated tools
* (set_status / set_summary / set_visible / set_profile / join_group /
* leave_group / forget / message_status / mesh_clock / mesh_stats /
* ping_mesh / claim_task / complete_task).
*
* Each verb runs against ONE mesh — pick with --mesh <slug>, or let the
* picker prompt when multiple meshes are joined. This is the deliberate
* difference from the MCP tools' fan-out-across-all-meshes behavior:
* the CLI invocation model binds one connection per call.
*
* Spec: .artifacts/specs/2026-05-01-mcp-tool-surface-trim.md
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
type StateFlags = { mesh?: string; json?: boolean };
type PeerStatus = "idle" | "working" | "dnd";
/** Resolve unambiguous mesh slug for warm-path bridging. Returns null if
* the user has multiple joined meshes and didn't pick one. */
function unambiguousMesh(opts: StateFlags): string | null {
if (opts.mesh) return opts.mesh;
const config = readConfig();
return config.meshes.length === 1 ? config.meshes[0]!.slug : null;
}
// --- status ---
export async function runStatusSet(state: string, opts: StateFlags): Promise<number> {
const valid: PeerStatus[] = ["idle", "working", "dnd"];
if (!valid.includes(state as PeerStatus)) {
render.err(`Invalid status: ${state}`, `must be one of: ${valid.join(", ")}`);
return EXIT.INVALID_ARGS;
}
// Warm path
const meshSlug = unambiguousMesh(opts);
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "status_set", { status: state });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ status: state }));
else render.ok(`status set to ${bold(state)}`);
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setStatus(state as PeerStatus);
});
if (opts.json) console.log(JSON.stringify({ status: state }));
else render.ok(`status set to ${bold(state)}`);
return EXIT.SUCCESS;
}
// --- summary ---
export async function runSummary(text: string, opts: StateFlags): Promise<number> {
if (!text) {
render.err("Usage: claudemesh summary <text>");
return EXIT.INVALID_ARGS;
}
// Warm path
const meshSlug = unambiguousMesh(opts);
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "summary", { summary: text });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ summary: text }));
else render.ok("summary set", dim(text));
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setSummary(text);
});
if (opts.json) console.log(JSON.stringify({ summary: text }));
else render.ok("summary set", dim(text));
return EXIT.SUCCESS;
}
// --- visible ---
export async function runVisible(value: string | undefined, opts: StateFlags): Promise<number> {
let visible: boolean;
if (value === "true" || value === "1" || value === "yes") visible = true;
else if (value === "false" || value === "0" || value === "no") visible = false;
else {
render.err("Usage: claudemesh visible <true|false>");
return EXIT.INVALID_ARGS;
}
// Warm path
const meshSlug = unambiguousMesh(opts);
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "visible", { visible });
if (bridged !== null) {
if (bridged.ok) {
if (opts.json) console.log(JSON.stringify({ visible }));
else render.ok(visible ? "you are now visible to peers" : "you are now hidden", visible ? undefined : "direct messages still reach you");
return EXIT.SUCCESS;
}
render.err(bridged.error);
return EXIT.INTERNAL_ERROR;
}
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.setVisible(visible);
});
if (opts.json) console.log(JSON.stringify({ visible }));
else render.ok(visible ? "you are now visible to peers" : "you are now hidden", visible ? undefined : "direct messages still reach you");
return EXIT.SUCCESS;
}
// --- group ---
export async function runGroupJoin(name: string | undefined, opts: StateFlags & { role?: string }): Promise<number> {
if (!name) {
render.err("Usage: claudemesh group join @<name> [--role X]");
return EXIT.INVALID_ARGS;
}
const cleanName = name.startsWith("@") ? name.slice(1) : name;
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.joinGroup(cleanName, opts.role);
});
if (opts.json) {
console.log(JSON.stringify({ group: cleanName, role: opts.role ?? null }));
return EXIT.SUCCESS;
}
render.ok(`joined ${clay("@" + cleanName)}`, opts.role ? `as ${opts.role}` : undefined);
return EXIT.SUCCESS;
}
export async function runGroupLeave(name: string | undefined, opts: StateFlags): Promise<number> {
if (!name) {
render.err("Usage: claudemesh group leave @<name>");
return EXIT.INVALID_ARGS;
}
const cleanName = name.startsWith("@") ? name.slice(1) : name;
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.leaveGroup(cleanName);
});
if (opts.json) {
console.log(JSON.stringify({ group: cleanName, left: true }));
return EXIT.SUCCESS;
}
render.ok(`left ${clay("@" + cleanName)}`);
return EXIT.SUCCESS;
}
// --- forget ---
export async function runForget(id: string | undefined, opts: StateFlags): Promise<number> {
if (!id) {
render.err("Usage: claudemesh forget <memory-id>");
return EXIT.INVALID_ARGS;
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.forget(id);
});
if (opts.json) {
console.log(JSON.stringify({ id, forgotten: true }));
return EXIT.SUCCESS;
}
render.ok(`forgot ${dim(id.slice(0, 8))}`);
return EXIT.SUCCESS;
}
// --- msg-status ---
export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> {
if (!id) {
render.err("Usage: claudemesh msg-status <message-id>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const result = await client.messageStatus(id);
if (!result) {
if (opts.json) console.log(JSON.stringify({ id, found: false }));
else render.err(`Message ${id} not found or timed out.`);
return EXIT.NOT_FOUND;
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return EXIT.SUCCESS;
}
render.section(`message ${id.slice(0, 12)}`);
render.kv([
["target", result.targetSpec],
["delivered", result.delivered ? "yes" : "no"],
["delivered_at", result.deliveredAt ?? dim("—")],
]);
if (result.recipients.length > 0) {
render.blank();
render.heading("recipients");
for (const r of result.recipients) {
process.stdout.write(` ${bold(r.name)} ${dim(r.pubkey.slice(0, 12) + "…")} ${dim("·")} ${r.status}\n`);
}
}
return EXIT.SUCCESS;
});
}
// --- clock ---
export async function runClock(opts: StateFlags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const result = await client.getClock();
if (!result) {
if (opts.json) console.log(JSON.stringify({ error: "timed out" }));
else render.err("Clock query timed out");
return EXIT.INTERNAL_ERROR;
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return EXIT.SUCCESS;
}
const statusLabel = result.speed === 0 ? "not started" : result.paused ? "paused" : "running";
render.section(`mesh clock — ${statusLabel}`);
render.kv([
["speed", `x${result.speed}`],
["tick", String(result.tick)],
["sim_time", result.simTime],
["started_at", result.startedAt],
]);
return EXIT.SUCCESS;
});
}
// --- stats ---
export async function runStats(opts: StateFlags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const peers = await client.listPeers();
if (opts.json) {
console.log(JSON.stringify({
mesh: client.meshSlug,
peers: peers.map((p) => ({ name: p.displayName, pubkey: p.pubkey, stats: p.stats ?? null })),
}, null, 2));
return EXIT.SUCCESS;
}
render.section(client.meshSlug);
for (const p of peers) {
const s = p.stats;
if (!s) {
process.stdout.write(` ${bold(p.displayName)} ${dim("(no stats)")}\n`);
continue;
}
const up = s.uptime != null ? `${Math.floor(s.uptime / 60)}m` : "—";
process.stdout.write(
` ${bold(p.displayName)} ${dim(`in:${s.messagesIn ?? 0} out:${s.messagesOut ?? 0} tools:${s.toolCalls ?? 0} up:${up} err:${s.errors ?? 0}`)}\n`,
);
}
return EXIT.SUCCESS;
});
}
// --- ping ---
export async function runPing(opts: StateFlags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const peers = await client.listPeers();
if (opts.json) {
console.log(JSON.stringify({
mesh: client.meshSlug,
ws_status: client.status,
peers_online: peers.length,
push_buffer: client.pushHistory.length,
}, null, 2));
return EXIT.SUCCESS;
}
render.section(`ping ${client.meshSlug}`);
render.kv([
["ws_status", client.status],
["peers_online", String(peers.length)],
["push_buffer", String(client.pushHistory.length)],
]);
return EXIT.SUCCESS;
});
}
// --- task ---
export async function runTaskClaim(id: string | undefined, opts: StateFlags): Promise<number> {
if (!id) {
render.err("Usage: claudemesh task claim <id>");
return EXIT.INVALID_ARGS;
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.claimTask(id);
});
if (opts.json) {
console.log(JSON.stringify({ id, claimed: true }));
return EXIT.SUCCESS;
}
render.ok(`claimed ${dim(id.slice(0, 8))}`);
return EXIT.SUCCESS;
}
export async function runTaskComplete(id: string | undefined, result: string | undefined, opts: StateFlags): Promise<number> {
if (!id) {
render.err("Usage: claudemesh task complete <id> [result]");
return EXIT.INVALID_ARGS;
}
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.completeTask(id, result);
});
if (opts.json) {
console.log(JSON.stringify({ id, completed: true, result: result ?? null }));
return EXIT.SUCCESS;
}
render.ok(`completed ${dim(id.slice(0, 8))}`, result);
return EXIT.SUCCESS;
}

View File

@@ -8,6 +8,7 @@
*/
import { EXIT } from "~/constants/exit-codes.js";
import { render } from "~/ui/render.js";
const COMMANDS = [
"create", "new", "join", "add", "launch", "connect", "disconnect",
@@ -102,7 +103,7 @@ 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>");
render.err("Usage: claudemesh completions <bash|zsh|fish>");
return EXIT.INVALID_ARGS;
}
switch (shell.toLowerCase()) {
@@ -116,7 +117,7 @@ export async function runCompletions(shell: string | undefined): Promise<number>
process.stdout.write(fish());
return EXIT.SUCCESS;
default:
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
render.err(`Unsupported shell: ${shell}`, "use bash, zsh, or fish.");
return EXIT.INVALID_ARGS;
}
}

View File

@@ -1,22 +1,23 @@
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.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.");
render.err("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...");
render.info(dim("Requesting Telegram connect token…"));
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
@@ -32,7 +33,7 @@ export async function connectTelegram(args: string[]): Promise<void> {
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
render.err(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
@@ -46,20 +47,18 @@ export async function connectTelegram(args: string[]): Promise<void> {
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");
render.section("connect Telegram to your mesh");
render.link(deepLink);
render.blank();
render.info(dim("Open this link on your phone, or scan the QR code with your Telegram camera."));
render.blank();
// 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)");
render.info(dim("(Install qrcode-terminal for QR code display)"));
}
}

View File

@@ -4,7 +4,8 @@ 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 { render } from "~/ui/render.js";
import { bold, clay, dim, red } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
@@ -23,34 +24,34 @@ function getUserId(token: string): string {
} catch { return ""; }
}
async function isOwner(slug: string, userId: string): Promise<boolean> {
async function isOwner(slug: string, auth: { session_token: string }): Promise<boolean> {
try {
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
path: `/cli/meshes?user_id=${userId}`,
path: `/cli/meshes`,
baseUrl: BROKER_HTTP,
token: auth.session_token,
});
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
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.");
render.err("No meshes to remove.");
return EXIT.NOT_FOUND;
}
console.log("\n Select mesh to remove:\n");
render.section("select mesh to remove");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)} ${dim("(" + m.name + ")")}\n`);
});
console.log("");
const choice = await prompt(" Choice: ");
render.blank();
const choice = await prompt(` ${dim("choice:")} `);
const idx = parseInt(choice, 10) - 1;
if (idx < 0 || idx >= config.meshes.length) {
console.log(" Cancelled.");
render.info(dim("cancelled."));
return EXIT.USER_CANCELLED;
}
slug = config.meshes[idx]!.slug;
@@ -58,28 +59,27 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
const auth = getStoredToken();
const userId = auth ? getUserId(auth.session_token) : "";
const ownerCheck = userId ? await isOwner(slug, userId) : false;
const ownerCheck = auth ? await isOwner(slug, auth) : false;
// Ask what to do
if (!opts.yes) {
console.log(`\n ${bold(slug)}\n`);
render.section(slug);
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("");
process.stdout.write(` ${bold("1)")} remove from this device only ${dim("(keep on server)")}\n`);
process.stdout.write(` ${bold("2)")} ${red("delete everywhere")} ${dim("(removes for all members)")}\n`);
process.stdout.write(` ${bold("3)")} cancel\n`);
render.blank();
const choice = await prompt(" Choice [1]: ") || "1";
const choice = await prompt(` ${dim("choice [1]:")} `) || "1";
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
if (choice === "3") { render.info(dim("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: `);
render.blank();
render.warn(`this will delete ${bold(slug)} for all members.`);
const confirm = await prompt(` ${dim(`type "${slug}" to confirm:`)} `);
if (confirm.toLowerCase() !== slug.toLowerCase()) {
console.log(" Cancelled.");
render.info(dim("cancelled."));
return EXIT.USER_CANCELLED;
}
@@ -87,42 +87,39 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
await request({
path: `/cli/mesh/${slug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
token: auth?.session_token,
body: { user_id: userId },
});
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
render.ok(`deleted ${bold(slug)} from server.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${icons.cross} Server delete failed: ${msg}`);
render.err(`server delete failed: ${err instanceof Error ? err.message : String(err)}`);
}
leaveMesh(slug);
console.log(` ${green(icons.check)} Removed from local config.`);
render.ok("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.`));
process.stdout.write(` ${bold("1)")} remove from this device ${dim("(you can re-add later)")}\n`);
process.stdout.write(` ${bold("2)")} cancel\n`);
if (userId) {
render.blank();
render.warn("only the mesh owner can delete it from the server.");
}
console.log("");
render.blank();
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
const choice = await prompt(` ${dim("choice [1]:")} `) || "1";
if (choice === "2") { render.info(dim("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>`));
render.ok(`removed ${bold(slug)} from this device.`);
render.hint(`re-add anytime with: ${bold("claudemesh")} ${clay("<invite-url>")}`);
} else {
console.error(` Mesh "${slug}" not found in local config.`);
render.err(`mesh "${slug}" not found in local config.`);
}
return EXIT.SUCCESS;
}

View File

@@ -30,6 +30,8 @@ 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";
import { render } from "~/ui/render.js";
import { bold, clay, dim, yellow } from "~/ui/styles.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
@@ -149,18 +151,80 @@ function bunAvailable(): boolean {
return res.status === 0;
}
/** Is this file running from a bundled `dist/` directory? */
function isBundledFile(p: string): boolean {
// Match any file under dist/ — e.g. dist/index.js or dist/entrypoints/cli.js.
return /[/\\]dist[/\\]/.test(p);
}
/** 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;
}
// Bundled: this file IS reachable as the entry; return self.
// Source: walk up to apps/cli/src/index.ts (legacy) or fall back.
if (isBundledFile(here)) return here;
return resolve(dirname(here), "..", "index.ts");
}
/** Find the bundled `skills/` directory at install time. Walks up from
* the entry file: dist/entrypoints/cli.js → dist/ → package root → skills/. */
function resolveBundledSkillsDir(): string | null {
const here = fileURLToPath(import.meta.url);
// Bundled: <pkg>/dist/entrypoints/cli.js → walk up two levels to <pkg>
// Source: <pkg>/src/commands/install.ts → walk up two levels to <pkg>
const pkgRoot = resolve(dirname(here), "..", "..");
const skillsDir = join(pkgRoot, "skills");
if (existsSync(skillsDir)) return skillsDir;
return null;
}
/** ~/.claude/skills/ — where Claude Code looks for user-scoped skills. */
const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
/**
* Copy bundled skills into ~/.claude/skills/. Idempotent — overwrites
* existing files (so updates flow through on `claudemesh install` re-run).
* Returns the list of skill names installed.
*/
function installSkills(): string[] {
const src = resolveBundledSkillsDir();
if (!src) return [];
// Each subdirectory of skills/ is one skill (matches Claude Code convention).
const fs = require("node:fs") as typeof import("node:fs");
const installed: string[] = [];
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const srcDir = join(src, entry.name);
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
mkdirSync(dstDir, { recursive: true });
for (const file of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (!file.isFile()) continue;
copyFileSync(join(srcDir, file.name), join(dstDir, file.name));
}
installed.push(entry.name);
}
return installed;
}
/** Remove claudemesh-shipped skills from ~/.claude/skills/. Returns names removed. */
function uninstallSkills(): string[] {
const src = resolveBundledSkillsDir();
if (!src) return [];
const fs = require("node:fs") as typeof import("node:fs");
const removed: string[] = [];
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
if (existsSync(dstDir)) {
try {
fs.rmSync(dstDir, { recursive: true, force: true });
removed.push(entry.name);
} catch { /* best effort */ }
}
}
return removed;
}
/**
* Build the MCP server entry for Claude Code's config.
*
@@ -170,9 +234,7 @@ function resolveEntry(): string {
* - 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) {
if (isBundledFile(entryPath)) {
return {
command: "claudemesh",
args: ["mcp"],
@@ -374,191 +436,181 @@ function installStatusLine(): { installed: boolean } {
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const skipSkill = args.includes("--no-skill");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
render.section("claudemesh install");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
const bundled = isBundledFile(entry);
// 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",
);
if (!bundled && !bunAvailable()) {
render.err("`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}`);
render.err(`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`,
);
render.err("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);
render.ok(`MCP server "${bold(MCP_NAME)}" ${action}`);
render.kv([
["config", dim(CLAUDE_CONFIG)],
["command", dim(`${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`)],
]);
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` : ""}`,
render.ok(
`allowedTools: ${added.length} claudemesh tools pre-approved`,
unchanged > 0 ? `${unchanged} already present` : undefined,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
render.info(dim("This lets claudemesh tools run without --dangerously-skip-permissions."));
render.info(dim("Your existing allowedTools entries were preserved."));
} else {
console.log(`allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
render.ok(`allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
render.info(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`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`,
render.ok(
`Hooks registered (Stop + UserPromptSubmit)`,
`${added} added, ${unchanged} already present`,
);
} else {
console.log(`Hooks already registered (${unchanged} present)`);
render.ok(`Hooks already registered`, `${unchanged} present`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
render.info(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.)",
render.warn(
`hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
"MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.",
);
}
} else {
console.log(dim("· Hooks skipped (--no-hooks)"));
render.info(dim("· Hooks skipped (--no-hooks)"));
}
// Claude skill — discoverability replacement for the (now-empty) MCP
// tool surface. Claude reads ~/.claude/skills/claudemesh/SKILL.md on
// demand, learns every CLI verb, JSON shape, and gotcha. See spec
// 2026-05-02 commitment #6.
if (!skipSkill) {
try {
const installed = installSkills();
if (installed.length > 0) {
render.ok(
`Claude skill${installed.length === 1 ? "" : "s"} installed`,
installed.join(", "),
);
render.info(dim(` ${join(CLAUDE_SKILLS_ROOT, installed[0]!)}/SKILL.md`));
}
} catch (e) {
render.warn(`skill install failed: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
render.info(dim("· Skill install skipped (--no-skill)"));
}
// 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>`));
render.ok(`Claude Code statusLine → ${clay("claudemesh status-line")}`);
render.info(dim(" Shows: ◇ <mesh> · <online>/<total> online · <you>"));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
render.info(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
render.warn(`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.
}
} catch {}
console.log("");
console.log(yellow(bold("RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
render.blank();
render.warn(`${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")}`,
);
render.blank();
render.info(`${yellow("No meshes joined.")} To connect with peers:`);
render.info(` ${bold("claudemesh <invite-url>")}${dim(" — joins + launches in one step")}`);
render.info(` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`);
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
render.blank();
render.info(`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`));
render.blank();
render.info(dim("Optional:"));
render.info(dim(` claudemesh url-handler install # click-to-launch from email`));
render.info(dim(` claudemesh install --status-line # live peer count in Claude Code`));
render.info(dim(` claudemesh completions zsh # shell completions`));
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
render.section("claudemesh uninstall");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`MCP server "${MCP_NAME}" removed`);
render.ok(`MCP server "${bold(MCP_NAME)}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
render.info(dim(`· MCP server "${MCP_NAME}" not present`));
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`allowedTools: ${removed} claudemesh tools removed`);
render.ok(`allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
render.info(dim("· No claudemesh allowedTools to remove"));
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`Hooks removed (${removed} entries)`);
render.ok(`Hooks removed`, `${removed} entries`);
} else {
console.log("· No claudemesh hooks to remove");
render.info(dim("· No claudemesh hooks to remove"));
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`hook removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
try {
const removed = uninstallSkills();
if (removed.length > 0) {
render.ok(`Skill${removed.length === 1 ? "" : "s"} removed`, removed.join(", "));
} else {
render.info(dim("· No claudemesh skills to remove"));
}
} catch (e) {
render.warn(`skill removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
render.blank();
render.info("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

@@ -25,6 +25,7 @@ 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";
import { render } from "~/ui/render.js";
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
@@ -371,7 +372,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
render.info(tDim("Joining mesh…"));
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
@@ -398,8 +399,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
});
const { writeConfig } = await import("~/services/config/facade.js");
writeConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
render.ok(
`joined ${tBold(invite.payload.mesh_slug)}`,
enroll.alreadyMember ? "already member" : undefined,
);
}
@@ -483,7 +485,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
}
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
render.err("No meshes joined.", "Run `claudemesh join <url>` or use --join <url>.");
process.exit(1);
}
@@ -492,8 +494,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
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(", ")}`,
render.err(
`Mesh "${args.meshSlug}" not found.`,
`Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
@@ -806,9 +809,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
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.");
render.err("`claude` not found on PATH.", "Install Claude Code first.");
} else {
console.error(`failed to launch claude: ${err.message}`);
render.err(`failed to launch claude: ${err.message}`);
}
process.exit(1);
}

View File

@@ -6,20 +6,24 @@
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export function runLeave(args: string[]): void {
export function runLeave(args: string[]): number {
const slug = args[0];
if (!slug) {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
render.err("Usage: claudemesh leave <slug>");
return EXIT.INVALID_ARGS;
}
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);
render.err(`no joined mesh with slug "${slug}"`);
return EXIT.NOT_FOUND;
}
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
render.ok(`left ${bold(slug)}`, dim(`remaining: ${config.meshes.length}`));
return EXIT.SUCCESS;
}

View File

@@ -6,7 +6,8 @@ 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";
import { bold, clay, dim, green, yellow } from "~/ui/styles.js";
import { render } from "~/ui/render.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
@@ -45,26 +46,26 @@ export async function runList(): Promise<void> {
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");
render.section("no meshes yet");
render.info(`${dim("create one:")} ${bold("claudemesh create")} ${clay("<name>")}`);
render.info(`${dim("join one:")} ${bold("claudemesh")} ${clay("<invite-url>")}`);
render.blank();
return;
}
console.log("\n Your meshes:\n");
render.section(`your meshes (${allSlugs.size})`);
for (const slug of allSlugs) {
const local = config.meshes.find(m => m.slug === slug);
const server = serverMeshes.find(m => m.slug === slug);
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 roleLabel = isOwner ? clay("owner") : dim(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;
@@ -84,14 +85,14 @@ export async function runList(): Promise<void> {
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(" · ")}`);
process.stdout.write(` ${icon} ${bold(name)} ${dim(slug)}\n`);
process.stdout.write(` ${parts.join(dim(" · "))}\n`);
}
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
process.stdout.write("\n");
if (serverMeshes.some((m) => !localSlugs.has(m.slug))) {
render.hint(`${dim("○")} = server only — run ${bold("claudemesh join")} to use locally`);
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
render.hint(`config: ${dim(getConfigPath())}`);
render.blank();
}

View File

@@ -1,7 +1,8 @@
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 { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
import { URLS } from "~/constants/urls.js";
@@ -13,16 +14,17 @@ function prompt(question: string): Promise<string> {
}
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`);
render.blank();
render.info(`Paste a token from ${dim(URLS.API_BASE + "/token")}`);
render.info(dim("Generate one in your browser, then paste it here."));
render.blank();
const token = await prompt(" Token: ");
if (!token) {
console.error(` ${icons.cross} No token provided.`);
render.err("No token provided.");
return EXIT.AUTH_FAILED;
}
// Decode JWT to get user info
let user = { id: "", display_name: "", email: "" };
try {
const parts = token.split(".");
@@ -31,7 +33,7 @@ async function loginWithToken(): Promise<number> {
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.`);
render.err("Token expired.", "Generate a new one.");
return EXIT.AUTH_FAILED;
}
user = {
@@ -41,12 +43,12 @@ async function loginWithToken(): Promise<number> {
};
}
} catch {
console.error(` ${icons.cross} Invalid token format.`);
render.err("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"}.`);
render.ok(`signed in as ${bold(user.display_name || user.email || "user")}`);
return EXIT.SUCCESS;
}
@@ -55,7 +57,7 @@ async function syncMeshes(token: string): Promise<void> {
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}`);
render.ok(`synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}`, names);
}
} catch {}
}
@@ -64,55 +66,55 @@ 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("");
render.blank();
render.info(`Already signed in as ${bold(name)}.`);
render.blank();
process.stdout.write(` ${bold("1)")} Continue as ${name}\n`);
process.stdout.write(` ${bold("2)")} Sign in via browser\n`);
process.stdout.write(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}\n`);
process.stdout.write(` ${bold("4)")} Sign out\n`);
render.blank();
const choice = await prompt(" Choice [1]: ") || "1";
if (choice === "1") {
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
render.blank();
render.ok(`continuing as ${bold(name)}`);
return EXIT.SUCCESS;
}
if (choice === "4") {
clearToken();
console.log(` ${green(icons.check)} Signed out.`);
render.ok("signed out");
return EXIT.SUCCESS;
}
if (choice === "3") {
clearToken();
return loginWithToken();
}
// choice === "2" → fall through to browser login
clearToken();
console.log(` ${dim("Signing in…")}`);
render.info(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("");
render.blank();
render.heading(`${bold("claudemesh")} — sign in to connect your terminal`);
render.blank();
process.stdout.write(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}\n`);
process.stdout.write(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}\n`);
render.blank();
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}.`);
render.ok(`signed in as ${bold(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}`);
render.err(`Login failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -1,6 +1,7 @@
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 { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function newMesh(
@@ -8,16 +9,17 @@ export async function newMesh(
opts: { template?: string; description?: string; json?: boolean },
): Promise<number> {
if (!name) {
console.error(" Usage: claudemesh mesh create <name>");
render.err("Usage: claudemesh create <name>");
return EXIT.INVALID_ARGS;
}
if (!getStoredToken()) {
console.log(dim(" Not signed in — starting login…\n"));
render.info(dim("not signed in — starting login…"));
render.blank();
const { login } = await import("./login.js");
const loginResult = await login();
if (loginResult !== EXIT.SUCCESS) return loginResult;
console.log("");
render.blank();
}
try {
@@ -28,20 +30,26 @@ export async function newMesh(
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;
}
render.section(`created ${bold(result.slug)}`);
render.kv([
["id", dim(result.id)],
["role", clay("owner")],
["local", "joined"],
]);
render.blank();
render.hint(`share with: ${bold("claudemesh share")}`);
render.blank();
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.`);
render.err("A mesh with this name already exists.", "Try a different name.");
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
render.err(`Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}

View File

@@ -2,16 +2,69 @@
* `claudemesh peers` — list connected peers in the mesh.
*
* Shows all meshes by default, or filter with --mesh.
*
* Warm path: dials the per-mesh bridge socket the push-pipe holds open.
* Cold path: opens its own WS via `withMesh`. Bridge fall-through is
* transparent — output is identical.
*
* `--json` accepts an optional comma-separated field list:
* claudemesh peers --json (full record)
* claudemesh peers --json name,pubkey,status (projection)
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
json?: boolean;
/** `true`/`undefined` = full record; comma-separated string = field projection. */
json?: boolean | string;
}
interface PeerRecord {
pubkey: string;
displayName: string;
status?: string;
summary?: string;
groups: Array<{ name: string; role?: string }>;
peerType?: string;
channel?: string;
model?: string;
cwd?: string;
[k: string]: unknown;
}
/** Friendly aliases — `name` is what users will type; broker calls it `displayName`. */
const FIELD_ALIAS: Record<string, string> = {
name: "displayName",
};
function projectFields(record: PeerRecord, fields: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const sourceKey = FIELD_ALIAS[f] ?? f;
out[f] = (record as Record<string, unknown>)[sourceKey];
}
return out;
}
async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
// Try warm path first.
const bridged = await tryBridge(slug, "peers");
if (bridged && bridged.ok) {
return bridged.result as PeerRecord[];
}
// Cold path — open our own WS.
let result: PeerRecord[] = [];
await withMesh({ meshSlug: slug }, async (client) => {
const all = await client.listPeers();
const selfPubkey = client.getSessionPubkey();
result = (selfPubkey ? all.filter((p) => p.pubkey !== selfPubkey) : all) as unknown as PeerRecord[];
});
return result;
}
export async function runPeers(flags: PeersFlags): Promise<void> {
@@ -24,54 +77,62 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
process.exit(1);
}
// Field projection: --json a,b,c
const fieldList: string[] | null =
typeof flags.json === "string" && flags.json.length > 0
? flags.json.split(",").map((s) => s.trim()).filter(Boolean)
: null;
const wantsJson = flags.json !== undefined && flags.json !== false;
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
await withMesh({ meshSlug: slug }, async (client, mesh) => {
const allPeers = await client.listPeers();
const selfPubkey = client.getSessionPubkey();
const peers = selfPubkey ? allPeers.filter((p) => p.pubkey !== selfPubkey) : allPeers;
const peers = await listPeersForMesh(slug);
if (flags.json) {
allJson.push({ mesh: mesh.slug, peers });
return;
}
if (wantsJson) {
const projected = fieldList
? peers.map((p) => projectFields(p, fieldList))
: peers;
allJson.push({ mesh: slug, peers: projected });
continue;
}
render.section(`peers on ${mesh.slug} (${peers.length})`);
render.section(`peers on ${slug} (${peers.length})`);
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
return;
}
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
continue;
}
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}`);
render.info(`${statusDot} ${name}${groups}${metaStr}${pubkeyTag}${summary}`);
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
}
});
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
const pubkeyTag = dim(` · ${p.pubkey.slice(0, 16)}`);
render.info(`${statusDot} ${name}${groups}${metaStr}${pubkeyTag}${summary}`);
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
}
} catch (e) {
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
}
}
if (flags.json) {
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
if (wantsJson) {
process.stdout.write(
JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n",
);
}
}

View File

@@ -0,0 +1,584 @@
/**
* Platform CLI verbs — vector / graph / context / stream / sql / skill /
* vault / watch / webhook / task / clock. These wrap broker methods that
* previously were only callable via MCP tools.
*
* All verbs run cold-path (open own WS via `withMesh`). Bridge expansion
* for high-frequency reads (vector_search, graph_query, sql_query) lands
* in 1.3.1.
*
* Spec: .artifacts/specs/2026-05-02-architecture-north-star.md
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
type Flags = { mesh?: string; json?: boolean };
function emitJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
// ════════════════════════════════════════════════════════════════════════
// vector — embedding store + similarity search
// ════════════════════════════════════════════════════════════════════════
export async function runVectorStore(
collection: string,
text: string,
opts: Flags & { metadata?: string },
): Promise<number> {
if (!collection || !text) {
render.err("Usage: claudemesh vector store <collection> <text> [--metadata <json>]");
return EXIT.INVALID_ARGS;
}
let metadata: Record<string, unknown> | undefined;
if (opts.metadata) {
try { metadata = JSON.parse(opts.metadata) as Record<string, unknown>; }
catch { render.err("--metadata must be JSON"); return EXIT.INVALID_ARGS; }
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.vectorStore(collection, text, metadata);
if (!id) { render.err("store failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson({ id, collection });
else render.ok(`stored in ${clay(collection)}`, dim(id));
return EXIT.SUCCESS;
});
}
export async function runVectorSearch(
collection: string,
query: string,
opts: Flags & { limit?: string },
): Promise<number> {
if (!collection || !query) {
render.err("Usage: claudemesh vector search <collection> <query> [--limit N]");
return EXIT.INVALID_ARGS;
}
const limit = opts.limit ? parseInt(opts.limit, 10) : undefined;
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const hits = await client.vectorSearch(collection, query, limit);
if (opts.json) { emitJson(hits); return EXIT.SUCCESS; }
if (hits.length === 0) { render.info(dim("(no matches)")); return EXIT.SUCCESS; }
render.section(`${hits.length} match${hits.length === 1 ? "" : "es"} in ${clay(collection)}`);
for (const h of hits) {
process.stdout.write(` ${bold(h.score.toFixed(3))} ${dim(h.id.slice(0, 8))} ${h.text}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runVectorDelete(
collection: string,
id: string,
opts: Flags,
): Promise<number> {
if (!collection || !id) {
render.err("Usage: claudemesh vector delete <collection> <id>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.vectorDelete(collection, id);
if (opts.json) emitJson({ id, deleted: true });
else render.ok(`deleted ${dim(id.slice(0, 8))}`);
return EXIT.SUCCESS;
});
}
export async function runVectorCollections(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const cols = await client.listCollections();
if (opts.json) { emitJson(cols); return EXIT.SUCCESS; }
if (cols.length === 0) { render.info(dim("(no collections)")); return EXIT.SUCCESS; }
render.section(`vector collections (${cols.length})`);
for (const c of cols) process.stdout.write(` ${clay(c)}\n`);
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// graph — Cypher query / execute
// ════════════════════════════════════════════════════════════════════════
export async function runGraphQuery(cypher: string, opts: Flags): Promise<number> {
if (!cypher) { render.err("Usage: claudemesh graph query \"<cypher>\""); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const rows = await client.graphQuery(cypher);
if (opts.json) { emitJson(rows); return EXIT.SUCCESS; }
if (rows.length === 0) { render.info(dim("(no rows)")); return EXIT.SUCCESS; }
render.section(`${rows.length} row${rows.length === 1 ? "" : "s"}`);
for (const r of rows) process.stdout.write(` ${JSON.stringify(r)}\n`);
return EXIT.SUCCESS;
});
}
export async function runGraphExecute(cypher: string, opts: Flags): Promise<number> {
if (!cypher) { render.err("Usage: claudemesh graph execute \"<cypher>\""); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const rows = await client.graphExecute(cypher);
if (opts.json) { emitJson(rows); return EXIT.SUCCESS; }
render.ok("executed", `${rows.length} row${rows.length === 1 ? "" : "s"} affected`);
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// context — share work-context summaries
// ════════════════════════════════════════════════════════════════════════
export async function runContextShare(
summary: string,
opts: Flags & { files?: string; findings?: string; tags?: string },
): Promise<number> {
if (!summary) {
render.err("Usage: claudemesh context share \"<summary>\" [--files a,b] [--findings x,y] [--tags t1,t2]");
return EXIT.INVALID_ARGS;
}
const files = opts.files?.split(",").map((s) => s.trim()).filter(Boolean);
const findings = opts.findings?.split(",").map((s) => s.trim()).filter(Boolean);
const tags = opts.tags?.split(",").map((s) => s.trim()).filter(Boolean);
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.shareContext(summary, files, findings, tags);
if (opts.json) emitJson({ shared: true, summary });
else render.ok("context shared");
return EXIT.SUCCESS;
});
}
export async function runContextGet(query: string, opts: Flags): Promise<number> {
if (!query) { render.err("Usage: claudemesh context get \"<query>\""); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const ctxs = await client.getContext(query);
if (opts.json) { emitJson(ctxs); return EXIT.SUCCESS; }
if (ctxs.length === 0) { render.info(dim("(no matches)")); return EXIT.SUCCESS; }
render.section(`${ctxs.length} context${ctxs.length === 1 ? "" : "s"}`);
for (const c of ctxs) {
process.stdout.write(` ${bold(c.peerName)} ${dim("·")} ${c.updatedAt}\n`);
process.stdout.write(` ${c.summary}\n`);
if (c.tags.length) process.stdout.write(` ${dim("tags: " + c.tags.join(", "))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runContextList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const ctxs = await client.listContexts();
if (opts.json) { emitJson(ctxs); return EXIT.SUCCESS; }
if (ctxs.length === 0) { render.info(dim("(no contexts)")); return EXIT.SUCCESS; }
render.section(`shared contexts (${ctxs.length})`);
for (const c of ctxs) {
process.stdout.write(` ${bold(c.peerName)} ${dim("·")} ${c.updatedAt}\n`);
process.stdout.write(` ${c.summary}\n`);
}
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// stream — pub/sub event bus per mesh
// ════════════════════════════════════════════════════════════════════════
export async function runStreamCreate(name: string, opts: Flags): Promise<number> {
if (!name) { render.err("Usage: claudemesh stream create <name>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.createStream(name);
if (!id) { render.err("create failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson({ id, name });
else render.ok(`created ${clay(name)}`, dim(id));
return EXIT.SUCCESS;
});
}
export async function runStreamPublish(name: string, dataRaw: string, opts: Flags): Promise<number> {
if (!name || dataRaw === undefined) {
render.err("Usage: claudemesh stream publish <name> <json-or-text>");
return EXIT.INVALID_ARGS;
}
let data: unknown;
try { data = JSON.parse(dataRaw); } catch { data = dataRaw; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.publish(name, data);
if (opts.json) emitJson({ published: true, name });
else render.ok(`published to ${clay(name)}`);
return EXIT.SUCCESS;
});
}
export async function runStreamList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const streams = await client.listStreams();
if (opts.json) { emitJson(streams); return EXIT.SUCCESS; }
if (streams.length === 0) { render.info(dim("(no streams)")); return EXIT.SUCCESS; }
render.section(`streams (${streams.length})`);
for (const s of streams) {
process.stdout.write(` ${clay(s.name)} ${dim(`· ${s.subscriberCount} subscriber${s.subscriberCount === 1 ? "" : "s"} · by ${s.createdBy}`)}\n`);
}
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// sql — typed query against per-mesh tables
// ════════════════════════════════════════════════════════════════════════
export async function runSqlQuery(sql: string, opts: Flags): Promise<number> {
if (!sql) { render.err("Usage: claudemesh sql query \"<select>\""); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const result = await client.meshQuery(sql);
if (!result) { render.err("query timed out"); return EXIT.INTERNAL_ERROR; }
if (opts.json) { emitJson(result); return EXIT.SUCCESS; }
render.section(`${result.rowCount} row${result.rowCount === 1 ? "" : "s"}`);
if (result.columns.length > 0) {
process.stdout.write(` ${dim(result.columns.join(" "))}\n`);
for (const row of result.rows) {
process.stdout.write(` ${result.columns.map((c) => String(row[c] ?? "")).join(" ")}\n`);
}
}
return EXIT.SUCCESS;
});
}
export async function runSqlExecute(sql: string, opts: Flags): Promise<number> {
if (!sql) { render.err("Usage: claudemesh sql execute \"<statement>\""); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.meshExecute(sql);
if (opts.json) emitJson({ executed: true });
else render.ok("executed");
return EXIT.SUCCESS;
});
}
export async function runSqlSchema(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const tables = await client.meshSchema();
if (opts.json) { emitJson(tables); return EXIT.SUCCESS; }
if (tables.length === 0) { render.info(dim("(no tables)")); return EXIT.SUCCESS; }
render.section(`mesh tables (${tables.length})`);
for (const t of tables) {
process.stdout.write(` ${bold(t.name)}\n`);
for (const c of t.columns) {
const nullable = c.nullable ? "" : " not null";
process.stdout.write(` ${c.name} ${dim(c.type + nullable)}\n`);
}
}
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// skill — list / get / remove (publish currently goes through MCP)
// ════════════════════════════════════════════════════════════════════════
export async function runSkillList(opts: Flags & { query?: string }): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const skills = await client.listSkills(opts.query);
if (opts.json) { emitJson(skills); return EXIT.SUCCESS; }
if (skills.length === 0) { render.info(dim("(no skills)")); return EXIT.SUCCESS; }
render.section(`mesh skills (${skills.length})`);
for (const s of skills) {
process.stdout.write(` ${bold(s.name)} ${dim("· by " + s.author)}\n`);
process.stdout.write(` ${s.description}\n`);
if (s.tags.length) process.stdout.write(` ${dim("tags: " + s.tags.join(", "))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runSkillGet(name: string, opts: Flags): Promise<number> {
if (!name) { render.err("Usage: claudemesh skill get <name>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const skill = await client.getSkill(name);
if (!skill) { render.err(`skill "${name}" not found`); return EXIT.NOT_FOUND; }
if (opts.json) { emitJson(skill); return EXIT.SUCCESS; }
render.section(skill.name);
render.kv([
["author", skill.author],
["created", skill.createdAt],
["tags", skill.tags.join(", ") || dim("(none)")],
]);
render.blank();
render.info(skill.description);
render.blank();
process.stdout.write(skill.instructions + "\n");
return EXIT.SUCCESS;
});
}
export async function runSkillRemove(name: string, opts: Flags): Promise<number> {
if (!name) { render.err("Usage: claudemesh skill remove <name>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const removed = await client.removeSkill(name);
if (opts.json) emitJson({ name, removed });
else if (removed) render.ok(`removed ${bold(name)}`);
else render.err(`skill "${name}" not found`);
return removed ? EXIT.SUCCESS : EXIT.NOT_FOUND;
});
}
// ════════════════════════════════════════════════════════════════════════
// vault — encrypted per-mesh secrets list / delete (set/get need crypto)
// ════════════════════════════════════════════════════════════════════════
export async function runVaultList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const entries = await client.vaultList();
if (opts.json) { emitJson(entries); return EXIT.SUCCESS; }
if (!entries || entries.length === 0) { render.info(dim("(vault empty)")); return EXIT.SUCCESS; }
render.section(`vault (${entries.length})`);
for (const e of entries) {
const k = String((e as any)?.key ?? "?");
const t = String((e as any)?.entry_type ?? "");
process.stdout.write(` ${bold(k)} ${dim(t)}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runVaultDelete(key: string, opts: Flags): Promise<number> {
if (!key) { render.err("Usage: claudemesh vault delete <key>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const ok = await client.vaultDelete(key);
if (opts.json) emitJson({ key, deleted: ok });
else if (ok) render.ok(`deleted ${bold(key)}`);
else render.err(`vault key "${key}" not found`);
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
});
}
// ════════════════════════════════════════════════════════════════════════
// watch — URL change watchers
// ════════════════════════════════════════════════════════════════════════
export async function runWatchList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const watches = await client.watchList();
if (opts.json) { emitJson(watches); return EXIT.SUCCESS; }
if (!watches || watches.length === 0) { render.info(dim("(no watches)")); return EXIT.SUCCESS; }
render.section(`url watches (${watches.length})`);
for (const w of watches) {
const id = String((w as any).id ?? "?");
const url = String((w as any).url ?? "");
const label = (w as any).label ? ` ${dim("(" + (w as any).label + ")")}` : "";
process.stdout.write(` ${dim(id.slice(0, 8))} ${clay(url)}${label}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runUnwatch(id: string, opts: Flags): Promise<number> {
if (!id) { render.err("Usage: claudemesh watch remove <id>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const ok = await client.unwatch(id);
if (opts.json) emitJson({ id, removed: ok });
else if (ok) render.ok(`unwatched ${dim(id.slice(0, 8))}`);
else render.err(`watch "${id}" not found`);
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
});
}
// ════════════════════════════════════════════════════════════════════════
// webhook — outbound HTTP triggers
// ════════════════════════════════════════════════════════════════════════
export async function runWebhookList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const hooks = await client.listWebhooks();
if (opts.json) { emitJson(hooks); return EXIT.SUCCESS; }
if (hooks.length === 0) { render.info(dim("(no webhooks)")); return EXIT.SUCCESS; }
render.section(`webhooks (${hooks.length})`);
for (const h of hooks) {
const dot = h.active ? "●" : dim("○");
process.stdout.write(` ${dot} ${bold(h.name)} ${dim("· " + h.url)}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runWebhookDelete(name: string, opts: Flags): Promise<number> {
if (!name) { render.err("Usage: claudemesh webhook delete <name>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const ok = await client.deleteWebhook(name);
if (opts.json) emitJson({ name, deleted: ok });
else if (ok) render.ok(`deleted ${bold(name)}`);
else render.err(`webhook "${name}" not found`);
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
});
}
// ════════════════════════════════════════════════════════════════════════
// task — list / create (claim / complete already in broker-actions.ts)
// ════════════════════════════════════════════════════════════════════════
export async function runTaskList(opts: Flags & { status?: string; assignee?: string }): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const tasks = await client.listTasks(opts.status, opts.assignee);
if (opts.json) { emitJson(tasks); return EXIT.SUCCESS; }
if (tasks.length === 0) { render.info(dim("(no tasks)")); return EXIT.SUCCESS; }
render.section(`tasks (${tasks.length})`);
for (const t of tasks) {
const dot = t.status === "done" ? "●" : t.status === "claimed" ? clay("●") : dim("○");
const assignee = t.assignee ? dim(`${t.assignee}`) : "";
process.stdout.write(` ${dot} ${dim(t.id.slice(0, 8))} ${bold(t.title)}${assignee}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runTaskCreate(
title: string,
opts: Flags & { assignee?: string; priority?: string; tags?: string },
): Promise<number> {
if (!title) { render.err("Usage: claudemesh task create <title> [--assignee X] [--priority P]"); return EXIT.INVALID_ARGS; }
const tags = opts.tags?.split(",").map((s) => s.trim()).filter(Boolean);
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.createTask(title, opts.assignee, opts.priority, tags);
if (!id) { render.err("create failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson({ id, title });
else render.ok(`created ${dim(id.slice(0, 8))}`, title);
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// clock — set / pause / resume (get already in broker-actions.ts)
// ════════════════════════════════════════════════════════════════════════
export async function runClockSet(speed: string, opts: Flags): Promise<number> {
const s = parseFloat(speed);
if (!Number.isFinite(s) || s < 0) {
render.err("Usage: claudemesh clock set <speed>", "speed is a non-negative number, e.g. 1.0 = realtime, 0 = paused, 60 = 60× faster");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const r = await client.setClock(s);
if (!r) { render.err("clock set failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson(r);
else render.ok(`clock set to ${bold("x" + r.speed)}`);
return EXIT.SUCCESS;
});
}
export async function runClockPause(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const r = await client.pauseClock();
if (!r) { render.err("pause failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson(r);
else render.ok("clock paused");
return EXIT.SUCCESS;
});
}
export async function runClockResume(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const r = await client.resumeClock();
if (!r) { render.err("resume failed"); return EXIT.INTERNAL_ERROR; }
if (opts.json) emitJson(r);
else render.ok("clock resumed");
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// mesh-mcp — list deployed mesh-MCP servers, call tools, view catalog
// ════════════════════════════════════════════════════════════════════════
export async function runMeshMcpList(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const servers = await client.mcpList();
if (opts.json) { emitJson(servers); return EXIT.SUCCESS; }
if (servers.length === 0) { render.info(dim("(no mesh-MCP servers)")); return EXIT.SUCCESS; }
render.section(`mesh-MCP servers (${servers.length})`);
for (const s of servers) {
process.stdout.write(` ${bold(s.name)} ${dim("· hosted by " + s.hostedBy)}\n`);
process.stdout.write(` ${s.description}\n`);
if (s.tools.length) process.stdout.write(` ${dim("tools: " + s.tools.map((t) => t.name).join(", "))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runMeshMcpCall(
serverName: string,
toolName: string,
argsRaw: string,
opts: Flags,
): Promise<number> {
if (!serverName || !toolName) {
render.err("Usage: claudemesh mesh-mcp call <server> <tool> [json-args]");
return EXIT.INVALID_ARGS;
}
let args: Record<string, unknown> = {};
if (argsRaw) {
try { args = JSON.parse(argsRaw) as Record<string, unknown>; }
catch { render.err("args must be JSON"); return EXIT.INVALID_ARGS; }
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const r = await client.mcpCall(serverName, toolName, args);
if (r.error) {
if (opts.json) emitJson({ ok: false, error: r.error });
else render.err(r.error);
return EXIT.INTERNAL_ERROR;
}
if (opts.json) emitJson({ ok: true, result: r.result });
else process.stdout.write(JSON.stringify(r.result, null, 2) + "\n");
return EXIT.SUCCESS;
});
}
export async function runMeshMcpCatalog(opts: Flags): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const cat = await client.mcpCatalog();
if (opts.json) { emitJson(cat); return EXIT.SUCCESS; }
if (!cat || cat.length === 0) { render.info(dim("(catalog empty)")); return EXIT.SUCCESS; }
render.section(`mesh-MCP catalog (${cat.length})`);
for (const c of cat as Array<Record<string, unknown>>) {
process.stdout.write(` ${bold(String(c.name ?? "?"))} ${dim(String(c.status ?? ""))}\n`);
if (c.description) process.stdout.write(` ${String(c.description)}\n`);
}
return EXIT.SUCCESS;
});
}
// ════════════════════════════════════════════════════════════════════════
// file — list / status / delete (upload / get-by-name go through MCP for now)
// ════════════════════════════════════════════════════════════════════════
export async function runFileList(opts: Flags & { query?: string }): Promise<number> {
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const files = await client.listFiles(opts.query);
if (opts.json) { emitJson(files); return EXIT.SUCCESS; }
if (files.length === 0) { render.info(dim("(no files)")); return EXIT.SUCCESS; }
render.section(`mesh files (${files.length})`);
for (const f of files) {
const sizeKb = (f.size / 1024).toFixed(1);
process.stdout.write(` ${bold(f.name)} ${dim(`· ${sizeKb} KB · by ${f.uploadedBy}`)}\n`);
if (f.tags.length) process.stdout.write(` ${dim("tags: " + f.tags.join(", "))}\n`);
}
return EXIT.SUCCESS;
});
}
export async function runFileStatus(id: string, opts: Flags): Promise<number> {
if (!id) { render.err("Usage: claudemesh file status <file-id>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const accessors = await client.fileStatus(id);
if (opts.json) { emitJson(accessors); return EXIT.SUCCESS; }
if (accessors.length === 0) { render.info(dim("(no accesses recorded)")); return EXIT.SUCCESS; }
render.section(`accesses for ${id.slice(0, 8)}`);
for (const a of accessors) process.stdout.write(` ${bold(a.peerName)} ${dim("· " + a.accessedAt)}\n`);
return EXIT.SUCCESS;
});
}
export async function runFileDelete(id: string, opts: Flags): Promise<number> {
if (!id) { render.err("Usage: claudemesh file delete <file-id>"); return EXIT.INVALID_ARGS; }
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
await client.deleteFile(id);
if (opts.json) emitJson({ id, deleted: true });
else render.ok(`deleted ${dim(id.slice(0, 8))}`);
return EXIT.SUCCESS;
});
}

View File

@@ -1,35 +1,36 @@
import { allClients } from "~/services/broker/facade.js";
import { dim, bold } from "~/ui/styles.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } 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;
if (!query) {
render.err("Usage: claudemesh recall <query>");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const memories = await client.recall(query);
const memories = await client.recall(query);
if (opts.json) {
console.log(JSON.stringify(memories, null, 2));
return EXIT.SUCCESS;
}
if (opts.json) {
console.log(JSON.stringify(memories, null, 2));
if (memories.length === 0) {
render.info(dim("no memories found."));
return EXIT.SUCCESS;
}
render.section(`memories (${memories.length})`);
for (const m of memories) {
const tags = m.tags.length ? dim(` [${m.tags.map((t) => clay(t)).join(dim(", "))}]`) : "";
process.stdout.write(` ${bold(m.id.slice(0, 8))}${tags}\n`);
process.stdout.write(` ${m.content}\n`);
process.stdout.write(` ${dim(m.rememberedBy + " · " + new Date(m.rememberedAt).toLocaleString())}\n\n`);
}
return EXIT.SUCCESS;
}
if (memories.length === 0) {
console.log(dim("No memories found."));
return EXIT.SUCCESS;
}
for (const m of memories) {
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
console.log(` ${m.content}`);
console.log(dim(` ${m.rememberedBy} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
console.log("");
}
return EXIT.SUCCESS;
});
}

View File

@@ -1,28 +1,30 @@
import { allClients } from "~/services/broker/facade.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.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;
if (!content) {
render.err("Usage: claudemesh remember <text>");
return EXIT.INVALID_ARGS;
}
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
const id = await client.remember(content, tags);
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.remember(content, tags);
if (opts.json) {
console.log(JSON.stringify({ id, content, tags }));
return EXIT.SUCCESS;
}
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;
if (id) {
render.ok("remembered", dim(id.slice(0, 8)));
return EXIT.SUCCESS;
}
render.err("failed to store memory");
return EXIT.INTERNAL_ERROR;
});
}

View File

@@ -7,6 +7,8 @@
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
export interface RemindFlags {
mesh?: string;
@@ -35,13 +37,12 @@ function parseDeliverAt(flags: RemindFlags): number | 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
if (target <= now) target.setDate(target.getDate() + 1);
return target.getTime();
}
const ts = Date.parse(flags.at);
@@ -54,61 +55,53 @@ 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; }
if (scheduled.length === 0) { render.info(dim("No pending reminders.")); return; }
render.section(`reminders (${scheduled.length})`);
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("");
process.stdout.write(` ${bold(m.id.slice(0, 8))} ${dim("→")} ${to} ${dim("at")} ${when}\n`);
process.stdout.write(` ${dim(m.message.slice(0, 80))}\n\n`);
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
if (!id) { render.err("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); }
if (ok) render.ok(`cancelled ${bold(id.slice(0, 8))}`);
else { render.err(`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>");
render.err("Usage: claudemesh remind <message> --in <duration>");
render.info(dim(" claudemesh remind <message> --at <time>"));
render.info(dim(' claudemesh remind <message> --cron "0 */2 * * *"'));
render.info(dim(" claudemesh remind list"));
render.info(dim(" 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>');
render.err('Specify when', 'use --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)) {
@@ -117,7 +110,7 @@ export async function runRemind(
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)"}`);
render.err(`Peer "${flags.to}" not found`, `online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
@@ -127,16 +120,22 @@ export async function runRemind(
}
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 (!result) { render.err("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}`);
render.ok(
`recurring reminder set`,
`${result.scheduledId.slice(0, 8)} · ${clay(message)}${toLabel} · cron ${flags.cron} · next ${nextFire}`,
);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
render.ok(
`reminder set`,
`${result.scheduledId.slice(0, 8)} · ${clay(message)}${toLabel} at ${when}`,
);
}
});
}

View File

@@ -10,21 +10,17 @@
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.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',
);
render.err("Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>");
render.info(dim('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,
@@ -32,13 +28,11 @@ export function runSeedTestMesh(args: string[]): void {
slug,
name: `Test: ${slug}`,
pubkey,
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
secretKey: "dev-only-stub",
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\`.`,
);
render.ok(`seeded ${bold(slug)}`, dim(meshId));
render.hint(`run ${bold("claudemesh mcp")} to connect, or register with Claude Code via ${bold("claudemesh install")}`);
}

View File

@@ -6,35 +6,77 @@
* - a pubkey hex ("abc123...")
* - @group ("@flexicar")
* - * (broadcast to all)
*
* Warm path: dials the per-mesh bridge socket the push-pipe holds open
* (~5ms). Cold path: opens its own WS via `withMesh` (~300-700ms).
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { tryBridge } from "~/services/bridge/client.js";
import type { Priority } from "~/services/broker/facade.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
export interface SendFlags {
mesh?: string;
priority?: string;
json?: boolean;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
if (!to || !message) {
render.err("Usage: claudemesh send <to> <message>");
process.exit(1);
}
const priority: Priority =
flags.priority === "now" ? "now"
: flags.priority === "low" ? "low"
: "next";
// Resolve which mesh to use. With --mesh, target it directly.
// Without, use first joined mesh — same default as withMesh.
const config = readConfig();
const meshSlug =
flags.mesh ??
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
// Warm path — only when mesh is unambiguous.
if (meshSlug) {
const bridged = await tryBridge(meshSlug, "send", { to, message, priority });
if (bridged !== null) {
if (bridged.ok) {
const r = bridged.result as { messageId?: string };
if (flags.json) {
console.log(JSON.stringify({ ok: true, messageId: r.messageId, target: to }));
} else {
render.ok(`sent to ${to}`, r.messageId ? dim(r.messageId.slice(0, 8)) : undefined);
}
return;
}
// Bridge reachable but op failed — surface error, don't fall through.
if (flags.json) {
console.log(JSON.stringify({ ok: false, error: bridged.error }));
} else {
render.err(`send failed: ${bridged.error}`);
}
process.exit(1);
}
// bridged === null → bridge unreachable, fall through to cold path
}
// Cold path
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)"}`);
render.err(`Peer "${to}" not found.`, `online: ${names || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
@@ -42,9 +84,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
const result = await client.send(targetSpec, message, priority);
if (result.ok) {
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
if (flags.json) {
console.log(JSON.stringify({ ok: true, messageId: result.messageId, target: to }));
} else {
render.ok(`sent to ${to}`, result.messageId ? dim(result.messageId.slice(0, 8)) : undefined);
}
} else {
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
if (flags.json) {
console.log(JSON.stringify({ ok: false, error: result.error ?? "unknown" }));
} else {
render.err(`send failed: ${result.error ?? "unknown error"}`);
}
process.exit(1);
}
});

View File

@@ -5,6 +5,8 @@
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
export interface StateFlags {
mesh?: string;
@@ -12,14 +14,10 @@ export interface StateFlags {
}
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)`));
render.info(dim("(not set)"));
return;
}
if (flags.json) {
@@ -27,13 +25,12 @@ export async function runStateGet(flags: StateFlags, key: string): Promise<void>
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()}`));
render.info(val);
render.info(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);
@@ -43,16 +40,11 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${key} = ${JSON.stringify(parsed)}`);
render.ok(`${bold(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();
@@ -62,14 +54,15 @@ export async function runStateList(flags: StateFlags): Promise<void> {
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
render.info(dim(`No state on mesh "${mesh.slug}".`));
return;
}
render.section(`state (${entries.length})`);
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()}`));
process.stdout.write(` ${bold(e.key)}: ${val}\n`);
process.stdout.write(` ${dim(e.updatedBy + " · " + new Date(e.updatedAt).toLocaleString())}\n`);
}
});
}

View File

@@ -1,12 +1,25 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { PATHS } from "~/constants/paths.js";
import { green, icons } from "~/ui/styles.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
/** Locate the bundled `skills/` directory shipped with this package. */
function bundledSkillsDir(): string | null {
const here = fileURLToPath(import.meta.url);
const pkgRoot = join(dirname(here), "..", "..");
const skillsDir = join(pkgRoot, "skills");
return existsSync(skillsDir) ? skillsDir : null;
}
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");
@@ -15,13 +28,12 @@ export async function uninstall(): Promise<number> {
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`);
render.ok("removed MCP server", dim("~/.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");
@@ -43,15 +55,40 @@ export async function uninstall(): Promise<number> {
}
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`);
render.ok(`removed ${removedHooks} claudemesh hook${removedHooks === 1 ? "" : "s"}`, dim("settings.json"));
removed++;
}
}
} catch {}
}
// Skills shipped by claudemesh install — remove from ~/.claude/skills/.
const src = bundledSkillsDir();
if (src) {
const removedSkills: string[] = [];
try {
for (const entry of readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dst = join(CLAUDE_SKILLS_ROOT, entry.name);
if (existsSync(dst)) {
try {
rmSync(dst, { recursive: true, force: true });
removedSkills.push(entry.name);
} catch { /* best effort */ }
}
}
if (removedSkills.length > 0) {
render.ok(
`removed Claude skill${removedSkills.length === 1 ? "" : "s"}`,
removedSkills.join(", "),
);
removed++;
}
} catch { /* best effort */ }
}
if (removed === 0) {
console.log(" Nothing to remove — claudemesh was not installed.");
render.info(dim("Nothing to remove — claudemesh was not installed."));
}
return EXIT.SUCCESS;

View File

@@ -20,6 +20,8 @@ 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";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
function resolveClaudemeshBin(): string {
// argv[1] points to the running binary; prefer that over $PATH so we
@@ -80,10 +82,9 @@ EOF
// 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.");
render.warn("lsregister returned non-zero", "scheme may not activate until Finder rescans.");
}
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
console.log(` app bundle: ${appDir}`);
render.ok("registered claudemesh:// scheme on macOS", dim(appDir));
return EXIT.SUCCESS;
}
@@ -106,12 +107,11 @@ NoDisplay=true
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");
render.warn("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}`);
render.ok("registered claudemesh:// scheme on Linux", dim(desktopPath));
return EXIT.SUCCESS;
}
@@ -131,30 +131,30 @@ function installWindows(): number {
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}`);
render.warn("reg.exe import failed", `manual: double-click ${regPath}`);
return EXIT.INTERNAL_ERROR;
}
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
render.ok("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");
render.ok("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");
render.ok("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");
render.ok("removed claudemesh:// handler on Windows");
return EXIT.SUCCESS;
}
@@ -170,9 +170,9 @@ export async function runUrlHandler(action: string | undefined): Promise<number>
if (p === "linux") return uninstallLinux();
if (p === "win32") return uninstallWindows();
} else {
console.error("Usage: claudemesh url-handler <install|uninstall>");
render.err("Usage: claudemesh url-handler <install|uninstall>");
return EXIT.INVALID_ARGS;
}
console.error(`Unsupported platform: ${p}`);
render.err(`Unsupported platform: ${p}`);
return EXIT.INTERNAL_ERROR;
}

View File

@@ -14,13 +14,14 @@ import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
import { createHash } from "node:crypto";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
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--) {
@@ -40,20 +41,15 @@ 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.");
render.err("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.`);
render.err(`Mesh "${meshSlug}" not found locally.`);
return EXIT.NOT_FOUND;
}
@@ -63,7 +59,7 @@ export async function runVerify(
? 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}.`);
render.err(`No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
return EXIT.NOT_FOUND;
}
@@ -77,19 +73,16 @@ export async function runVerify(
return EXIT.SUCCESS;
}
console.log("");
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
console.log("");
render.section(`safety numbers on ${meshSlug}`);
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("");
process.stdout.write(` ${bold(p.displayName)}\n`);
process.stdout.write(` ${clay(sn)}\n`);
process.stdout.write(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}\n\n`);
}
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("");
render.hint("Compare these digits with your peer (phone, in person not chat).");
render.hint("If they match on both sides, the channel is not being intercepted.");
render.blank();
return EXIT.SUCCESS;
});
}

View File

@@ -1,5 +1,6 @@
import { whoAmI } from "~/services/auth/facade.js";
import { dim, icons } from "~/ui/styles.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function whoami(opts: { json?: boolean }): Promise<number> {
@@ -11,16 +12,19 @@ export async function whoami(opts: { json?: boolean }): Promise<number> {
}
if (!result.signed_in) {
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
render.err("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();
render.section("whoami");
render.kv([
["user", `${bold(result.user!.display_name)} ${dim(`(${result.user!.email})`)}`],
["token", `${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`],
...(result.meshes
? [["meshes", `${result.meshes.owned} owned · ${result.meshes.guest} guest`] as [string, string]]
: []),
]);
render.blank();
return EXIT.SUCCESS;
}