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;
|
||||
}
|
||||
Reference in New Issue
Block a user