feat(cli): 1.5.0 — CLI-first architecture, tool-less MCP, policy engine
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:
331
apps/cli/src/commands/broker-actions.ts
Normal file
331
apps/cli/src/commands/broker-actions.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
584
apps/cli/src/commands/platform-actions.ts
Normal file
584
apps/cli/src/commands/platform-actions.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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")}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user