From b4f457fceb0b0952117fc3b2c0fe9a70df8f75f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 01:18:19 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli):=201.5.0=20=E2=80=94=20CLI-first=20ar?= =?UTF-8?q?chitecture,=20tool-less=20MCP,=20policy=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 . Spec: .artifacts/specs/2026-05-02-architecture-north-star.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-01-mcp-tool-surface-trim.md | 162 ++ .../2026-05-02-architecture-north-star.md | 234 +++ apps/cli/README.md | 6 +- apps/cli/package.json | 3 +- apps/cli/skills/claudemesh/SKILL.md | 369 +++++ apps/cli/src/cli/policy-classify.ts | 149 ++ apps/cli/src/commands/broker-actions.ts | 331 ++++ apps/cli/src/commands/completions.ts | 5 +- apps/cli/src/commands/connect-telegram.ts | 23 +- apps/cli/src/commands/delete-mesh.ts | 83 +- apps/cli/src/commands/install.ts | 270 ++-- apps/cli/src/commands/launch.ts | 19 +- apps/cli/src/commands/leave.ts | 16 +- apps/cli/src/commands/list.ts | 33 +- apps/cli/src/commands/login.ts | 60 +- apps/cli/src/commands/new.ts | 30 +- apps/cli/src/commands/peers.ts | 135 +- apps/cli/src/commands/platform-actions.ts | 584 +++++++ apps/cli/src/commands/recall.ts | 49 +- apps/cli/src/commands/remember.ts | 36 +- apps/cli/src/commands/remind.ts | 53 +- apps/cli/src/commands/seed-test-mesh.ts | 20 +- apps/cli/src/commands/send.ts | 62 +- apps/cli/src/commands/state.ts | 27 +- apps/cli/src/commands/uninstall.ts | 51 +- apps/cli/src/commands/url-handler.ts | 26 +- apps/cli/src/commands/verify.ts | 31 +- apps/cli/src/commands/whoami.ts | 20 +- apps/cli/src/entrypoints/cli.ts | 363 ++++- apps/cli/src/mcp/server.ts | 1410 +---------------- apps/cli/src/mcp/tools/definitions.ts | 1022 +----------- apps/cli/src/services/bridge/client.ts | 114 ++ apps/cli/src/services/bridge/protocol.ts | 93 ++ apps/cli/src/services/bridge/server.ts | 229 +++ apps/cli/src/services/policy/index.ts | 324 ++++ docs/roadmap.md | 27 + 36 files changed, 3636 insertions(+), 2833 deletions(-) create mode 100644 .artifacts/specs/2026-05-01-mcp-tool-surface-trim.md create mode 100644 .artifacts/specs/2026-05-02-architecture-north-star.md create mode 100644 apps/cli/skills/claudemesh/SKILL.md create mode 100644 apps/cli/src/cli/policy-classify.ts create mode 100644 apps/cli/src/commands/broker-actions.ts create mode 100644 apps/cli/src/commands/platform-actions.ts create mode 100644 apps/cli/src/services/bridge/client.ts create mode 100644 apps/cli/src/services/bridge/protocol.ts create mode 100644 apps/cli/src/services/bridge/server.ts create mode 100644 apps/cli/src/services/policy/index.ts diff --git a/.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md b/.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md new file mode 100644 index 0000000..b26ec24 --- /dev/null +++ b/.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md @@ -0,0 +1,162 @@ +--- +title: MCP tool surface trim + multi-mesh push +status: proposed +target: claudemesh-cli 1.1.0 +author: Alejandro +date: 2026-05-01 +--- + +# MCP tool surface trim + multi-mesh push + +## Problem + +Two issues with the current `claudemesh mcp` server: + +1. **80+ tools registered.** Every Claude session that has claudemesh installed pays the deferred-tool-list cost (~80 entries surfacing in `ToolSearch`). Most of those tools are CLI-verb-wrappers that already have a perfect Bash equivalent — no structured I/O is gained by exposing them as MCP tools. + +2. **Single-mesh push only.** A session launched with `claudemesh launch` opens its WS to one mesh. Peer messages from any other joined mesh arrive only if the user manually runs `claudemesh inbox`. The MCP push pipeline doesn't fan out across meshes. + +The cleanest framing: **MCP earns its keep when a tool returns structured data Claude reads. CLI is better for fire-and-forget verbs.** Today's tool surface ignores that distinction. + +## Non-goals + +- **Don't redesign the architecture as "CLI-only with a daemon."** That trades warm-WS sends (~5ms in-process) for cold Bash spawns (~300-500ms) and forces a Unix-socket bridge to recover state coherence. See discussion 2026-05-01 — the platform vision (vectors, graph, files, mesh-services) genuinely benefits from typed tool I/O. +- **Don't break MCP backward compat in 1.x.** Existing scripts calling `mcp__claudemesh__send_message` keep working until 2.0; in 1.1 they're soft-deprecated with a stderr warning. + +## Proposal + +Three patches, ship together as 1.1.0: + +### Patch 1: `--mesh ` flag on `claudemesh mcp` + +Today `claudemesh mcp` calls `readConfig()` and `startClients(config)` — connects to every mesh in `~/.claudemesh/config.json`. The `claudemesh launch` flow writes a per-session tmpdir config with one mesh, so practically the MCP server binds to one mesh per session. + +Add an explicit flag for non-launch contexts (manual `~/.claude.json` editing): + +```ts +// apps/cli/src/mcp/server.ts, near line 244 +export async function startMcpServer(): Promise { + const serviceIdx = process.argv.indexOf("--service"); + if (serviceIdx !== -1 && process.argv[serviceIdx + 1]) { + return startServiceProxy(process.argv[serviceIdx + 1]!); + } + + const meshIdx = process.argv.indexOf("--mesh"); + const onlyMesh = meshIdx !== -1 ? process.argv[meshIdx + 1] : null; + + const config = readConfig(); + if (onlyMesh) { + const before = config.meshes.length; + config.meshes = config.meshes.filter((m) => m.slug === onlyMesh); + if (config.meshes.length === 0) { + throw new Error( + `--mesh "${onlyMesh}" not found in config (have: ${ + config.meshes.map((m) => m.slug).join(", ") || "none" + })`, + ); + } + } + // ...rest unchanged +} +``` + +Enables this `~/.claude.json` pattern for users who want push from N meshes simultaneously without launching N Claude sessions: + +```json +{ + "mcpServers": { + "claudemesh:flexicar": { "command": "claudemesh", "args": ["mcp", "--mesh", "flexicar"] }, + "claudemesh:openclaw": { "command": "claudemesh", "args": ["mcp", "--mesh", "openclaw"] }, + "claudemesh:prueba1": { "command": "claudemesh", "args": ["mcp", "--mesh", "prueba1"] } + } +} +``` + +Each instance opens one WS, holds it for the session, decrypts and forwards `claude/channel` notifications independently. Channel events already carry `[meshSlug]` in `formatPush()` (server.ts:240), so Claude knows which mesh a message came from. + +**LoC:** ~10. **Risk:** very low — additive flag, default behavior unchanged. + +### Patch 2: trim 25 messaging tools from MCP surface + +Move these tools from "registered MCP tool" to "soft-deprecated CLI shim": + +| Module | Tool | CLI replacement | Rationale | +|---|---|---|---| +| messaging.ts | `send_message` | `claudemesh send [--mesh X] [--priority Y]` | Pure verb, no structured return. | +| messaging.ts | `list_peers` | `claudemesh peers --json` | One-shot, easy to parse. | +| messaging.ts | `check_messages` | `claudemesh inbox --json` | One-shot. | +| messaging.ts | `message_status` | `claudemesh msg-status ` (new) | One-shot lookup. | +| profile.ts | `set_profile` | `claudemesh profile --avatar X --bio Y ...` | Pure write. | +| profile.ts | `set_status` | `claudemesh status set ` (new) | Pure write. | +| profile.ts | `set_summary` | `claudemesh summary ` (new) | Pure write. | +| profile.ts | `set_visible` | `claudemesh visible ` (new) | Pure write. | +| groups.ts | `join_group` | `claudemesh group join @ [--role X]` (new) | Pure write. | +| groups.ts | `leave_group` | `claudemesh group leave @` (new) | Pure write. | +| state.ts | `get_state` | `claudemesh state get --json` | Already exists. | +| state.ts | `set_state` | `claudemesh state set ` | Already exists. | +| state.ts | `list_state` | `claudemesh state list --json` | Already exists. | +| memory.ts | `remember` | `claudemesh remember ` | Already exists. | +| memory.ts | `recall` | `claudemesh recall --json` | Already exists. | +| memory.ts | `forget` | `claudemesh forget ` (new) | Pure write. | +| scheduling.ts | `schedule_reminder` | `claudemesh remind --in/--at/--cron` | Already exists. | +| scheduling.ts | `list_scheduled` | `claudemesh remind list --json` | Already exists. | +| scheduling.ts | `cancel_scheduled` | `claudemesh remind cancel ` | Already exists. | +| mesh-meta.ts | `mesh_info` | `claudemesh info --json` | One-shot read. | +| mesh-meta.ts | `mesh_stats` | `claudemesh stats --json` (new) | One-shot read. | +| mesh-meta.ts | `mesh_clock` | `claudemesh clock --json` (new) | One-shot read. | +| mesh-meta.ts | `ping_mesh` | `claudemesh ping` (new) | Pure verb. | +| tasks.ts | `claim_task` / `complete_task` | `claudemesh task claim/complete ` (new) | Pure write. | + +**Keep as MCP tools (~50):** + +- **vault.ts** — `vault_set / vault_list / vault_delete` (encrypted, structured payloads). +- **vectors.ts** — `vector_store / vector_search / vector_delete` (typed embeddings, ranked results Claude reasons over). +- **graph.ts** — `graph_query / graph_execute` (returns structured graph results). +- **files.ts** — `share_file / get_file / list_files / list_peer_files / read_peer_file / grant_file_access / file_status / delete_file` (binary payloads, ACL semantics). +- **skills.ts** — `share_skill / list_skills / get_skill / remove_skill / mesh_skill_deploy` (typed skill metadata). +- **streams.ts** — `create_stream / list_streams / publish / subscribe` (event stream cursor semantics). +- **contexts.ts** — `share_context / get_context / list_contexts` (context-passing payloads). +- **mcp-registry-*.ts** — `mesh_mcp_*` (the ~14 mesh-MCP-services tools — these are platform-defining, MCP-native). +- **clock-write.ts** — `mesh_set_clock / mesh_pause_clock / mesh_resume_clock` (logical-clock writes that Claude composes with reads). +- **sql.ts** — `mesh_query / mesh_schema / mesh_execute` (typed SQL results). +- **webhooks.ts** — `create_webhook / list_webhooks / delete_webhook` (typed webhook metadata). +- **url-watch.ts** — `mesh_watch / mesh_unwatch / mesh_watches` (returns watch state). +- **tasks.ts** — `create_task / list_tasks` (typed task records — only the writes go to CLI). + +### Patch 3: tool-call → CLI shim with deprecation warning + +For the trimmed tools, keep the registration but route through the CLI: + +```ts +// apps/cli/src/mcp/tools/messaging.ts (sketch) +async function sendMessageDeprecated(args: SendMessageArgs): Promise { + process.stderr.write( + `[claudemesh] mcp__claudemesh__send_message is soft-deprecated in 1.1. ` + + `Use \`claudemesh send\` via Bash instead — it's faster and cleaner.\n`, + ); + return originalSendMessageHandler(args); // unchanged behavior +} +``` + +In 2.0 the registrations get deleted entirely. + +## Migration plan + +1. **1.1.0** — ship all three patches. Existing users see deprecation warnings; nothing breaks. +2. **1.1.x** — collect feedback. If anyone has scripts hard-wired to the deprecated tools, surface in CHANGELOG. +3. **1.2.0** (~6 weeks later) — flip deprecation warnings to "removal in 2.0" messaging. +4. **2.0.0** — delete the 25 tool registrations. ToolSearch surface drops to ~50 entries. + +## Open questions + +- **Do we need a Unix-socket bridge between CLI sends and the MCP push-pipe** so they share one WS connection per mesh per session? Probably yes for `claudemesh send` warm-path performance, but it's a separate spec — file under `socket-bridge` after this lands. +- **Should `claudemesh launch` keep writing one MCP server entry** (current behavior, default for new users) or switch to the per-mesh-N-entries pattern from Patch 1? Recommend keeping single-entry default — Patch 1 is for advanced users who manually edit `~/.claude.json`. +- **Do `mesh_mcp_*` tools really belong in the keep list?** They're MCP-on-mesh management — their bias is RPC-shaped, not stream-shaped. Provisional yes; revisit if 1.1 reduces their use. + +## Effort + +- Patch 1: ~10 LoC + 1 test. ~30 min. +- Patch 2: ~25 tool-handler refactors (registration removed, CLI verb confirmed/added). Some new verbs (`status set`, `summary`, `visible`, `group join/leave`, `forget`, `stats`, `clock`, `ping`, `task claim/complete`, `msg-status`) need wiring through to existing broker-client methods. ~150 LoC, half a day. +- Patch 3: deprecation shim per trimmed tool. ~50 LoC, 1 hour. + +**Total:** ~1 dev-day for 1.1.0. ToolSearch surface drops by ~30%, multi-mesh push works, no architectural disruption, platform tools stay typed. diff --git a/.artifacts/specs/2026-05-02-architecture-north-star.md b/.artifacts/specs/2026-05-02-architecture-north-star.md new file mode 100644 index 0000000..8808f69 --- /dev/null +++ b/.artifacts/specs/2026-05-02-architecture-north-star.md @@ -0,0 +1,234 @@ +--- +title: claudemesh North Star — CLI-first with claude/channel push-pipe +status: canonical +target: 2.0.0 +author: Alejandro +date: 2026-05-02 +supersedes: none +references: + - 2026-05-01-mcp-tool-surface-trim.md (first cut at the trim) + - SPEC.md + - docs/protocol.md +--- + +# claudemesh North Star + +## The commitment, in one sentence + +> **CLI is the canonical surface for every claudemesh operation. MCP exists for one thing: to deliver `claude/channel` push notifications mid-turn. That's the killer feature, and it's the only reason an MCP server runs at all.** + +Everything else — sending messages, listing peers, sharing files, deploying mesh-MCPs, running graph queries, scheduling jobs, publishing skills — is invoked from the CLI, by humans, scripts, cron, hooks, or by Claude itself via Bash. + +## Why this shape + +1. **Mid-turn interrupt is the differentiator.** When peer A sends to peer B, B's Claude session pauses what it's doing and reads the message immediately. That requires `claude/channel` notifications routed through an MCP transport — Claude Code only watches MCP server connections for those events. **Lose that, and claudemesh becomes another inbox-polling pattern.** Every other primitive can degrade to "delivered at next tool boundary"; this one cannot. + +2. **CLI is universal.** Bash works in scripts, hooks, cron, CI, terminals, automation, and Claude itself (via Bash tool calls). A primitive that exists as both an MCP tool and a CLI verb is double-maintenance with one calling convention nobody actually wants. + +3. **JSON-on-stdout is enough structure.** Claude reads `claudemesh peers --json` exactly as well as it reads a typed MCP tool return. The CLI man page is the schema. The "MCP gives structured I/O" advantage was real when we were paying for nothing else, but warm-WS via socket bridge (below) closes the cost gap. + +4. **Surface shrinks where it matters.** ToolSearch deferred-tool list drops from ~80 entries to ~0 entries (push-pipe registers no tools). Massive context-budget win for every Claude session. + +## Prior art (this is not novel architecture) + +The "live-state daemon + thin scriptable CLI talking via Unix socket" pattern is the canonical shape for CLIs in this category. Reviewers should not treat this as bespoke design: + +- **Docker** — `dockerd` daemon, CLI talks via `/var/run/docker.sock`. `DOCKER_HOST` env override. `docker context` for multi-daemon switching. +- **Tailscale** — `tailscaled` daemon, `tailscale` CLI via socket. Per-key ACL identity model. Same peer-mesh-with-keypairs shape as claudemesh. +- **Stripe `listen`** — long-running CLI daemon receives webhook push, forwards to local consumer. Same push-pipe-as-CLI-subcommand shape. +- **Obsidian CLI** — talks to a running Obsidian instance via REST. **Notable: ships a Claude skill (`~/.claude/skills/obsidian-cli/SKILL.md`) that documents every verb and flag for Claude consumption — replacing MCP tool introspection entirely.** + +Claudemesh's CLI-first + push-pipe + socket-bridge architecture is exactly this pattern. We are following the well-trodden path, not inventing a new one. + +## The six architectural commitments + +### 1. **MCP server is a push-pipe, full stop.** + +The MCP entrypoint (`claudemesh mcp [--mesh ]`) does exactly three things: +- Holds a WS connection to the broker for the meshes it's bound to. +- Decrypts inbound peer messages. +- Emits them as `claude/channel` notifications to the parent Claude Code session. + +It registers **zero tools**. It advertises only `experimental: { "claude/channel": {} }`. Its `tools/list` returns an empty array. There is no surface to discover, search, or call. + +One push-pipe per joined mesh, registered in `~/.claude.json` via `claudemesh install` (or auto-injected by `claudemesh launch`). The `--mesh` flag (shipped 1.0.3) makes this trivial. + +### 2. **CLI is the canonical surface for every primitive.** + +Every resource has uniform CLI verbs: + +| Resource | Verbs | +|---|---| +| peer | `claudemesh peers [--json] [--mesh X]` | +| group | `claudemesh group join/leave @ [--role X]` | +| message | `claudemesh send `, `claudemesh inbox`, `claudemesh msg-status ` | +| state | `claudemesh state get/set/list [--json]` | +| memory | `claudemesh remember/recall/forget` | +| task | `claudemesh task create/claim/complete/list` | +| file | `claudemesh file put/get/list/grant/delete` | +| vector | `claudemesh vector store/search/delete` | +| graph | `claudemesh graph query/execute/watch` | +| stream | `claudemesh stream create/publish/subscribe/list` | +| context | `claudemesh context share/get/list` | +| skill | `claudemesh skill publish/list/get/remove` | +| schedule | `claudemesh schedule msg/webhook/tool/list/cancel` | +| webhook | `claudemesh webhook create/list/delete` | +| watch | `claudemesh watch create/list/unwatch` | +| mcp | `claudemesh mesh-mcp deploy/list/call/undeploy/catalog` | +| clock | `claudemesh clock get/set/pause/resume` | +| sql | `claudemesh sql query/schema/execute` | +| vault | `claudemesh vault set/get/list/delete` | +| profile | `claudemesh profile/summary/visible/status set` | + +**Every verb supports `--json`** for structured consumption. **Every verb supports `--mesh `** for targeting (default: pick first or interactive picker). Verbs share one broker-call implementation — no duplication between CLI and MCP. + +### 3. **Warm path via Unix socket bridge** (load-bearing for 2.0). + +A push-pipe holds a live WS connection. CLI invocations should reuse that connection rather than opening their own (which costs ~300-500ms cold-start). + +Mechanism: +- On startup, push-pipe creates `~/.claudemesh/sockets/.sock` (Unix domain socket, mode 0600). +- CLI verbs that need broker round-trip first try to dial that socket. +- If alive: forward request, get response back over socket (~5ms). +- If absent / stale: open ephemeral WS, do the op, close (~300ms — fine for cron/scripts where there's no parent push-pipe). + +Push-pipe owns one WS, all ops through that WS, broker sees ONE session per mesh per host (no duplicate hellos). On crash, socket file is unlinked by `unlink` on exit handler; stale-socket detection by `connect()` ECONNREFUSED. + +This is **mandatory for 2.0** — without it, every CLI op pays cold-start, and CLI-first becomes unusably slow for tight loops. + +### 4. **JSON output is the schema, with field selection and streaming.** + +Every CLI verb has a deterministic `--json` output shape, documented in `docs/cli-schemas.md`, validated by zod parsers in tests. Claude reads `claudemesh vector search "x" --json` and gets a typed-array shape it can reason over identically to a tool return. + +**Three output modes, mandatory across every read-shaped verb** (modeled on `gh` and `gemini`): + +- `--json` — full record, all fields +- `--json ` — field-selected projection (e.g. `claudemesh peers --json name,pubkey,status`) +- `--output-format stream-json` — incremental JSONL for long-running ops (mesh-MCP calls fanning across peers, `vector search` against large indexes, `schedule list` with many entries). One object per line, Claude consumes incrementally. + +Plus convenience output: +- `--jq ` — native jq filter pipeline +- `--template '{{.field}}'` — Go template formatting + +`schema_version: "1.0"` field on every JSON output — mandatory. Bumps when shape changes. Old code paths can pin with `--schema-version=1.0`. + +### 5. **All features stay. Nothing is removed.** + +This is **not a feature trim**. Every primitive in the current 80-tool surface gets a CLI verb. Vectors, graphs, mesh-MCP, files, vault, SQL — all of it. The user-facing pitch is unchanged: "claudemesh gives your Claude session a name, a network, shared memory, shared compute, shared skills, scheduled actions." The change is *how you call it*. + +### 6. **The Claude skill IS the schema.** *(load-bearing for CLI-first to work)* + +Stripping MCP tool introspection (`tools/list`) costs Claude its discoverability. The replacement: a packaged `claudemesh` skill at `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`, documenting every verb, flag, JSON shape, and gotcha. Claude reads it on demand via the Skill tool — **not on every session, not pre-loaded into deferred-tool-list**. This is exactly how `obsidian-cli` works today and it works perfectly. + +The skill replaces three things at once: +- **Tool discovery** — Claude knows the verb-set after one Skill invocation. No `tools/list` needed. +- **Output schemas** — every JSON shape is documented in the skill, so Claude knows what to expect from `--json` without parsing TypeScript types at runtime. +- **Behavioral conventions** — the skill teaches "preview before delete," "confirm peer match before kick," "use `--mesh` for cross-mesh ops" — soft guardrails that complement the policy engine's hard rules. + +Topic-shards for size: `claudemesh` (core), `claudemesh-platform` (vault/vectors/graph/sql/mesh-mcp), `claudemesh-schedule` (cron/webhooks/watches), `claudemesh-admin` (kick/ban/grants/install). Each shard is independently loadable. + +**This is the answer to the "JSON-on-stdout is a worse schema" caveat.** It's not — when Claude has a documented skill to load, the CLI surface is *more* discoverable than 80 deferred MCP tools that bloat ToolSearch silently. + +### 7. **Pluggable policy engine, not binary `--yes`.** *(answers the Bash-blast-radius caveat)* + +Modeled on `gemini --policy / --admin-policy` and `codex --sandbox`. Replace the current binary `-y/--yes` with: + +- **`--approval-mode plan|read-only|write|yolo`** — four levels (read-only blocks all writes, plan blocks all side effects, write prompts on dangerous verbs, yolo skips all confirmation). +- **`--policy `** — YAML allow/deny rules per resource × verb × peer. Sample: + +```yaml +# ~/.claudemesh/policy.yaml +default: prompt +rules: + - resource: send + verb: "*" + decision: allow + - resource: sql + verb: execute + decision: prompt + - resource: file + verb: delete + decision: deny + - resource: mesh-mcp + verb: call + peers: ["@trusted"] + decision: allow +``` + +Policy decisions log to a tamper-evident audit file. Org admin can ship `--admin-policy` that overrides user config. **This is the real answer to "Bash carries unrestricted blast-radius once allowed" — claudemesh's own policy engine kicks in before the broker call, regardless of what shell permissions are.** + +## What this means for `claude/channel` + +When peer A's CLI runs `claudemesh send peer-B "hello"`: + +1. CLI dials `~/.claudemesh/sockets/.sock` (warm path) or opens its own WS (cold). +2. Encrypts message with peer-B's pubkey via crypto_box. +3. Broker receives `send` envelope, forwards encrypted blob to peer-B's connected push-pipe. +4. Peer-B's push-pipe decrypts and emits a `claude/channel` notification. +5. Claude Code mid-turn-injects the message as a `` reminder. +6. Claude responds immediately per the system prompt convention. + +Step 5 is the **only step that requires MCP**. Steps 1-4 are pure CLI + broker. The architecture is "CLI for everything, MCP for the one thing it's irreplaceable for." + +## Migration path from 1.1.0 + +| Version | Ships | Behavior | +|---|---|---| +| **1.2.0** | Unix socket bridge. CLI verbs auto-detect push-pipe and use warm path. **Field-selectable JSON (`--json a,b,c`)** + `--jq` + `--template` adopted. | All existing MCP tools still work. Nothing breaks. | +| **1.2.1** | Ships `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`. Includes full verb reference + output schemas + gotchas. Topic-shards (`-platform`, `-schedule`, `-admin`). | Skill auto-installs on `claudemesh install`. | +| **1.3.0** | Schedule unification (`schedule msg/webhook/tool`). All remaining missing CLI verbs (file, vector, graph, mesh-mcp, vault, sql, stream, context, skill, watch). **`--output-format stream-json`** for long-running ops. | All existing MCP tools still work. New verbs additive. | +| **1.4.0** | Resource-model rename pass — every CLI verb is ` `. Old verbs become aliases. | All existing MCP tools still work. Old CLI verbs aliased forever. | +| **1.5.0** | **Pluggable policy engine** (`--approval-mode`, `--policy`, `--admin-policy`). MCP `tools/list` shrinks to configurable allowlist (default: empty). `CLAUDEMESH_MCP_FAT=1` for users who need typed tool surface. | Default 1.5 install: MCP exposes zero tools. Push-pipe-only. Policy engine gates all writes. | +| **2.0.0** | MCP server hardcoded to push-pipe-only. Strip all tool registrations + handlers. | **Old MCP tool calls return tool-not-found.** Users must update scripts to CLI verbs. Old CLI verbs (1.4 aliases) still work. | + +## What stays exactly the same + +- Crypto: ed25519 sign + x25519 sealing + crypto_box for DMs. No change. +- Broker protocol: WS frame format, hello flow, audit log. No change. +- Membership / mesh-scope / capability grants. No change. +- Web app, dashboard, Telegram bridge, OAuth. No change. +- The platform vision (vault, vectors, graph, files, skills, mesh-MCPs, scheduled jobs). All shipped, all stay. + +## What changes for users + +- `~/.claude.json` simplifies: `"claudemesh": { "command": "claudemesh", "args": ["mcp"] }` becomes one entry per joined mesh after `claudemesh install`. Multi-mesh push works out of the box. +- ToolSearch loses ~80 deferred entries. Sessions are lighter. +- Scripts that called `mcp__claudemesh__*` get a deprecation warning in 1.x, break in 2.0 — replaced by `claudemesh --json` + `jq`. +- Claude Code system prompt for the MCP server gets shorter (no tool catalog), focused only on "RESPOND IMMEDIATELY to channel events." + +## Open questions parked for future specs + +- **Federation** — broker-to-broker encrypted relay so peers on different brokers can talk. Not in 2.0 scope. +- **Offline-with-TTL inbox** — persist `now` priority messages on broker if recipient is offline, with explicit TTL. Reasonable for 2.x. +- **Compute attribution** — when peer X invokes a mesh-MCP that peer Y deployed, who pays for broker compute / outbound calls? Pre-empts the eventual billing question. 2.x. +- **Universal hash-chained audit** — every state mutation per mesh is hash-chained, replayable, verifiable. Today only some events are; making it universal is its own spec. +- **ACP (Agent Communication Protocol) interop with Gemini CLI.** Gemini CLI exposes `--acp` for agent-to-agent comms — the same problem domain claudemesh occupies. Research question: is ACP a documented standard claudemesh can speak (making claudemesh peers and Gemini peers cross-talk in the same mesh), or is it Google-proprietary? If standard, implementing it is a major platform expansion. File as separate research spec before 2.x. + +## What this spec is NOT + +- Not a redesign of the broker. The broker stays as-is. +- Not a redesign of crypto. Crypto stays as-is. +- Not a feature deprecation. Every feature stays. +- Not optional. This is the canonical 2.0 architecture; intermediate versions migrate toward it. + +## Effort estimate to 2.0 + +Sequential, single dev (revised after caveats survey — original estimate was rosy): + +- **1.2.0** (socket bridge + field-JSON): 1-2 weeks. Socket bridge is real distributed-systems work (stale-cleanup, version negotiation, NFS/Windows edge cases) — not 2-3 days. +- **1.2.1** (claudemesh skill + topic shards): 2-3 days. Mostly content writing once schemas are documented. +- **1.3.0** (schedule unification + remaining verbs + stream-json): 1 week. Each of the ~10 missing verbs is small but adds up. +- **1.4.0** (resource-model rename + alias compat): 2-3 days. +- **1.5.0** (policy engine + MCP allowlist): 4-5 days. Policy engine is its own subsystem — parser, evaluator, audit log, admin override. +- **2.0.0** (strip tool handlers + cutover): 2 days. + +Total: **~5-6 weeks of focused work** spread over 3-4 months calendar. Each release is independently shippable; the policy engine specifically can land later than 1.5 if needed. + +## Acceptance signals — how we know it worked + +- **ToolSearch** in a freshly-installed claudemesh session shows zero `mcp__claudemesh__*` entries by default (vs ~80 today). +- **`claudemesh peers --json name,status`** projects exactly two fields, no extra noise. +- **`claudemesh send "hi"`** from a Bash call inside a Claude session round-trips in <50ms (warm path via socket bridge) on localhost-broker, <250ms on EU-from-US. +- **`Skill: claudemesh`** loaded once teaches Claude the entire mesh surface; subsequent CLI calls require no further introspection. +- **A policy file with `decision: deny` for `file delete`** blocks the call before it hits the broker, with a clear stderr explanation. +- **`claudemesh status set working` from cron** opens its own WS (no daemon), succeeds in <1s, no orphan connections on broker. diff --git a/apps/cli/README.md b/apps/cli/README.md index ae02f4d..ab9df21 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,8 @@ # claudemesh-cli -Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools. +Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill. + +> **Migration note (1.5.0):** the previous 79 MCP tools (`send_message`, `list_peers`, `remember`, …) are removed. Use the matching CLI verbs (`claudemesh send`, `claudemesh peers`, `claudemesh remember`). Run `claudemesh install` and the bundled skill teaches Claude the full surface. ## Install @@ -67,7 +69,7 @@ src/ │ ├── api/ typed HTTP client for claudemesh.com │ ├── health/ 6 diagnostic checks │ └── ... device, clipboard, spawn, telemetry, i18n, logger -├── mcp/ MCP server with 79 tools across 21 families +├── mcp/ MCP server (tool-less push-pipe; emits claude/channel notifications) ├── ui/ TUI: styles, spinner, welcome wizard, launch flow ├── constants/ exit codes, paths, URLs, timings ├── types/ API, mesh, peer interfaces diff --git a/apps/cli/package.json b/apps/cli/package.json index ab31d47..91e0bbd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.0.1", + "version": "1.5.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", @@ -24,6 +24,7 @@ }, "files": [ "dist", + "skills", "README.md", "LICENSE" ], diff --git a/apps/cli/skills/claudemesh/SKILL.md b/apps/cli/skills/claudemesh/SKILL.md new file mode 100644 index 0000000..d9ac138 --- /dev/null +++ b/apps/cli/skills/claudemesh/SKILL.md @@ -0,0 +1,369 @@ +--- +name: claudemesh +description: Use when the user asks to send a message to a peer Claude session, list mesh peers, share state across meshes, schedule cross-session reminders, or otherwise interact with claudemesh — a peer mesh runtime for Claude Code sessions. Provides the canonical reference for every `claudemesh` CLI verb, its flags, JSON output shape, and common patterns. +--- + +# claudemesh skill + +`claudemesh` is the peer mesh runtime for Claude Code sessions. Each session in a mesh has a name, identity (ed25519 keypair), and shared resources: peers, messages, state, memory, files, vectors, scheduled jobs, skills. + +**You invoke claudemesh exclusively through the `claudemesh` CLI via Bash.** There are no MCP tools to call — `tools/list` returns empty for the claudemesh MCP server. The MCP server exists only to deliver inbound peer messages as `` interrupts mid-turn. Everything else is CLI. + +## When you receive a `` message + +Respond IMMEDIATELY. Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it. + +## Performance model (warm vs cold path) + +If the parent Claude session was launched via `claudemesh launch`, an MCP push-pipe is running and holds the per-mesh WS connection. CLI invocations dial `~/.claudemesh/sockets/.sock` and reuse that warm connection (~200ms total round-trip including Node.js startup). If no push-pipe is running (cron, scripts, hooks fired outside a session), the CLI opens its own WS, which takes ~500-700ms cold. **You don't manage this** — every verb auto-detects and falls through. + +## Universal flags + +| Flag | Behavior | +|---|---| +| `--mesh ` | Target a specific mesh. Required when the user has multiple meshes joined. Default: first/only joined mesh, or interactive picker. | +| `--json` | Emit JSON instead of human-readable text. Use this when you need to parse the output. | +| `--json field1,field2` | Project specific fields (modeled on `gh --json`). Friendly aliases like `name` → `displayName` are resolved automatically. | +| `--approval-mode ` | `plan` / `read-only` deny all writes; `write` (default) prompts on destructive verbs from the policy file; `yolo` bypasses every prompt. | +| `--policy ` | Override the policy file (default `~/.claudemesh/policy.yaml`, auto-created on first run). | +| `-y` / `--yes` | Auto-approve any policy prompt. Equivalent to `--approval-mode yolo` for the current invocation. | + +## Policy & confirmation + +Every broker-touching verb runs through a policy gate before dispatch. The default policy allows reads and prompts on destructive writes (`peer kick/ban/disconnect`, `file delete`, `vector/vault delete`, `memory forget`, `skill remove`, `webhook delete`, `watch remove`, `sql/graph execute`, `mesh delete`). When you call `claudemesh` from a non-interactive context (cron, scripts, Claude's Bash tool), prompts auto-deny — pass `-y` or `--approval-mode yolo` for verbs you've vetted, or edit `~/.claudemesh/policy.yaml` to mark them `decision: allow`. Every gate decision is appended to `~/.claudemesh/audit.log` (newline-JSON). + +## Resources and verbs + +**Convention:** every operation is `claudemesh `. Legacy short forms (`send`, `peers`, `kick`, `remember`, ...) are aliases that keep working forever; prefer the resource form for new code. + +### `peer` — read connected peers + admin (kick / ban / verify) + +```bash +claudemesh peer list # human-readable (alias: peers) +claudemesh peer list --json # full record +claudemesh peer list --json name,status # field projection +claudemesh peer list --mesh openclaw --json # specific mesh + +claudemesh peer kick # end session, manual rejoin +claudemesh peer disconnect # soft, peer auto-reconnects +claudemesh peer ban # kick + revoke membership +claudemesh peer unban +claudemesh peer bans # list banned members +claudemesh peer verify [peer] # 6×5-digit safety numbers +``` + +JSON shape (per peer): +```json +{ + "displayName": "Mou", + "pubkey": "abc123...", + "status": "idle | working | dnd", + "summary": "string or null", + "groups": [{ "name": "reviewers", "role": "lead" }], + "peerType": "claude | telegram | ...", + "channel": "claude-code | api | ...", + "model": "claude-opus-4-7 | ...", + "cwd": "/path/to/working/dir or null", + "stats": { "messagesIn": 0, "messagesOut": 0, "toolCalls": 0, "errors": 0, "uptime": 1200 } +} +``` + +### `message` — send and inspect messages + +```bash +# send (alias: claudemesh send ) +claudemesh message send "message text" +claudemesh message send Mou "hi" # by display name +claudemesh message send "@reviewers" "ready for review" +claudemesh message send "*" "broadcast" +claudemesh message send

"..." --priority now # bypass busy gates +claudemesh message send

"..." --priority next # default +claudemesh message send

"..." --priority low # pull-only + +# inbox (alias: claudemesh inbox) +claudemesh message inbox +claudemesh message inbox --json + +# delivery status (alias: claudemesh msg-status ) +claudemesh message status +claudemesh message status --json +``` + +`send` JSON output: `{"ok": true, "messageId": "...", "target": "..."}`. Errors: `{"ok": false, "error": "..."}`. + +### `state` — shared per-mesh key-value store + +```bash +claudemesh state set # value can be JSON or string +claudemesh state get +claudemesh state get --json # includes updatedBy, updatedAt +claudemesh state list +claudemesh state list --json +``` + +State is broadcast to all peers when changed. Use it for shared scratch space: status flags, current focus, agreed-on values. + +### `memory` — recall-able knowledge per mesh + +```bash +claudemesh memory remember "fact text" --tags tag1,tag2 # alias: remember +claudemesh memory recall "search query" # alias: recall +claudemesh memory recall "search query" --json +claudemesh memory forget # alias: forget +``` + +Memories are searchable across the mesh. Use for shared documentation, decisions, lessons learned. + +### `task` — typed work-units claim/complete + +```bash +claudemesh task create "" --assignee <peer> --priority <p> --tags a,b +claudemesh task list [--status open|claimed|done] [--assignee <peer>] [--json] +claudemesh task claim <task-id> +claudemesh task complete <task-id> [result text] +``` + +Tasks are exact-once: claiming is atomic at broker. Use for work coordination across peers. + +### `schedule` — time-based delivery + +```bash +# one-shot or recurring (alias: claudemesh remind ...) +claudemesh schedule msg "ping" --in 30m # fires in 30 min +claudemesh schedule msg "ping" --at 15:00 # next 15:00 +claudemesh schedule msg "ping" --cron "0 9 * * *" # 9am daily +claudemesh schedule msg "to peer" --to <peer-name> +claudemesh schedule list --json +claudemesh schedule cancel <reminder-id> + +# webhook + tool schedules arrive in a later release (broker work pending). +``` + +### `profile / group` — peer presence + +```bash +claudemesh profile # view/edit your profile +claudemesh profile summary "what you're working on" # broadcast (alias: summary) +claudemesh profile status set idle|working|dnd # alias: status set +claudemesh profile visible true|false # alias: visible +claudemesh group join @reviewers --role lead +claudemesh group leave @reviewers +``` + +### `vector` — embedding store + similarity search + +```bash +claudemesh vector store <collection> "<text>" [--metadata '<json>'] +claudemesh vector search <collection> "<query>" [--limit N] [--json] +claudemesh vector delete <collection> <id> +claudemesh vector collections # list collection names +``` + +Search returns `[{id, text, score, metadata}]` ranked by cosine similarity. + +### `graph` — Cypher queries against per-mesh graph + +```bash +claudemesh graph query "MATCH (n) RETURN n LIMIT 10" # read +claudemesh graph execute "CREATE (n:Foo {x: 1})" # write +``` + +Returns rows as `[{...}, ...]`. Queries that return no rows render "(no rows)". + +### `context` — share work-context summaries between peers + +```bash +claudemesh context share "summary text" --files a.ts,b.ts --findings "x,y" --tags spec,review +claudemesh context get "search query" +claudemesh context list +``` + +Use to broadcast "what I just did and what I learned" so peers don't duplicate effort. + +### `stream` — pub/sub event bus + +```bash +claudemesh stream create <name> +claudemesh stream publish <name> '<json-or-text>' +claudemesh stream list +``` + +For event broadcasting (build-events, deploy-notifications, sensor data). Subscribers receive via push. + +### `sql` — typed SQL against per-mesh tables + +```bash +claudemesh sql query "SELECT * FROM <table>" # SELECT only +claudemesh sql execute "INSERT INTO ..." # writes +claudemesh sql schema # list tables + columns +``` + +Returns `{columns, rows, rowCount}` for queries. Each mesh has its own SQL namespace. + +### `skill` — discover + manage mesh-published Claude skills + +```bash +claudemesh skill list [search-query] +claudemesh skill get <skill-name> +claudemesh skill remove <skill-name> +``` + +Published skills appear as `/claudemesh:<name>` slash commands across all connected sessions. + +### `vault` — encrypted per-mesh secrets + +```bash +claudemesh vault list # list keys (values stay encrypted on disk) +claudemesh vault delete <key> +# claudemesh vault set/get currently goes through MCP — needs E2E crypto round-trip +``` + +### `watch` — URL change watchers + +```bash +claudemesh watch list # list active watches +claudemesh watch remove <watch-id> +# Watch creation currently via MCP `mesh_watch` — config-heavy +``` + +### `webhook` — outbound HTTP triggers + +```bash +claudemesh webhook list # list configured webhooks +claudemesh webhook delete <name> +# Webhook creation currently via MCP `create_webhook` +``` + +### `file` — shared mesh files + +```bash +claudemesh file list [search-query] # list files +claudemesh file status <file-id> # who has accessed +claudemesh file delete <file-id> +# Upload + retrieval currently via MCP `share_file` / `get_file` (binary streams) +``` + +### `mesh-mcp` — call MCP servers other peers deployed to the mesh + +```bash +claudemesh mesh-mcp list # which servers are deployed +claudemesh mesh-mcp call <server> <tool> '<json-args>' +claudemesh mesh-mcp catalog # full catalog with schemas +``` + +Mesh-deployed MCPs let peer X call a tool that peer Y maintains, without local install. + +### `clock` — mesh logical clock + +```bash +claudemesh clock # current state +claudemesh clock set <speed> # speed: 0=paused, 1=realtime, 60=60× faster +claudemesh clock pause +claudemesh clock resume +``` + +Used for simulations / tests that need a controlled time axis shared across peers. + +### `mesh` — mesh-level introspection + +```bash +claudemesh info --json # mesh overview: peers, groups, state keys, ... +claudemesh stats --json # per-peer activity counters +claudemesh clock --json # mesh logical clock (speed/tick/sim_time) +claudemesh ping --json # diagnostic — ws status, peer count, push buffer +claudemesh peers --mesh X # peers on a specific mesh +``` + +### `mesh management` — admin ops + +```bash +claudemesh list # all your meshes +claudemesh create <name> # create a new mesh +claudemesh share [email] # generate invite link +claudemesh disconnect <peer> # soft disconnect (auto-reconnects) +claudemesh kick <peer> # kick (must rejoin manually) +claudemesh ban <peer> # ban (revoked, can't rejoin) +claudemesh unban <peer> +claudemesh bans # list banned members +claudemesh delete <slug> # delete a mesh +claudemesh rename <slug> <name> +``` + +### `verify` — safety numbers (Signal-style MITM detection) + +```bash +claudemesh verify <peer> # show 6×5-digit fingerprint +claudemesh verify <peer> --json +``` + +Compare digits with the peer out-of-band (call, in person — not chat). If they match, the channel is not being intercepted. + +### `auth` — sign-in + +```bash +claudemesh login # browser or paste-token +claudemesh whoami # current identity +claudemesh logout +``` + +## Common workflows + +### "Send a message to peer X with a confirmation" +```bash +result=$(claudemesh send "X" "ping" --json) +echo "$result" | jq -r '.messageId' +``` + +### "List peers who are currently working" +```bash +claudemesh peers --json name,status | jq '[.[] | select(.status == "working")]' +``` + +### "Send to all reviewers" +```bash +claudemesh send "@reviewers" "PR ready: <url>" +``` + +### "Set my summary so peers know what I'm doing" +```bash +claudemesh summary "drafting the auth migration spec" +``` + +### "Schedule a daily ping at 9am" +```bash +claudemesh remind "morning standup time" --cron "0 9 * * *" +``` + +### "Check who I'm verified with" +```bash +claudemesh verify <peer-name> +# Compare the 6×5-digit number with peer over voice or in person. +``` + +## Gotchas + +- **`<peer-name>` resolution is case-insensitive but exact-match only.** Don't fuzzy-match. If a peer is named "Mou-2", use that exact string. Use `claudemesh peers --json name` to confirm. +- **`@group` requires the leading `@`.** Without it, claudemesh treats the string as a peer name lookup. +- **`*` means broadcast.** Use carefully — it goes to every peer on the mesh. +- **`--priority now` bypasses busy gates** (peers in DND still receive). Use only for genuine interruptions. +- **`claudemesh launch` writes a per-session config to a tmpdir.** Don't edit `~/.claudemesh/config.json` while a session is running — changes won't take effect until the next launch. +- **The `claudemesh mcp` server registers ZERO tools.** Never search ToolSearch for `mcp__claudemesh__*` — there are none. All operations go through Bash + CLI. +- **Soft-deprecated MCP tools (1.1.x).** If you previously called `mcp__claudemesh__send_message`, use `claudemesh send` via Bash instead. The deprecated tools still work in 1.x but print a stderr warning. They're removed in 2.0. +- **Field aliases in `--json`.** `name` resolves to `displayName`. Other aliases may be added in future versions; check `--json` output to confirm field names. +- **`claudemesh send` to a name that's not online** errors with the list of online peers. Use `claudemesh peers --json` first if uncertain. +- **The `--mesh <slug>` flag is required when the user has multiple meshes joined.** Without it, the CLI either picks the first mesh deterministically or shows an interactive picker (depending on context). + +## Behavioral conventions + +- **Confirm before destructive ops** (`kick`, `ban`, `delete`, `forget`). Show the user what you're about to do. +- **Preview peer-name matches before sending** when the name is ambiguous. `claudemesh peers --json name,pubkey | jq` is the right tool for disambiguation. +- **Don't broadcast (`*`) for trivial messages.** It pings every peer mid-task. Prefer DM or `@group`. +- **Don't poll `inbox`.** Messages are pushed via `<channel source="claudemesh">` automatically. Only call `inbox --json` if you suspect a buffered message is stuck. +- **Echo the messageId in JSON contexts** so the caller can `msg-status` it later. + +## Related + +- Spec: `.artifacts/specs/2026-05-02-architecture-north-star.md` (architecture rationale) +- Source: `~/Desktop/claudemesh/apps/cli/` +- Broker: `wss://ic.claudemesh.com/ws` +- Dashboard: `https://claudemesh.com/dashboard` diff --git a/apps/cli/src/cli/policy-classify.ts b/apps/cli/src/cli/policy-classify.ts new file mode 100644 index 0000000..33b9add --- /dev/null +++ b/apps/cli/src/cli/policy-classify.ts @@ -0,0 +1,149 @@ +/** + * Translate the parsed CLI invocation (command + positionals) into the + * (resource, verb, isWrite) shape that the policy engine evaluates. + * + * Returns `null` for commands that are not subject to policy gating: + * - local-only ops (help, version, list, doctor, sync, completions) + * - auth (login, logout, whoami, register) + * - setup (install, uninstall, url-handler, status-line, backup, restore) + * - launch / connect (no broker mutation by themselves) + * + * Spec: .artifacts/specs/2026-05-02-architecture-north-star.md commitment #7. + */ +export interface InvocationClass { + resource: string; + verb: string; + isWrite: boolean; +} + +/** Commands the policy engine never evaluates. Local or auth-only. */ +const SKIP = new Set([ + "", "help", "version", + "login", "register", "logout", "whoami", + "install", "uninstall", "doctor", "sync", "completions", "url-handler", "status-line", + "backup", "restore", "upgrade", "update", + "list", "ls", // local mesh list + "launch", "connect", // launches Claude — no broker write + "status", // broker connectivity diagnostic + "test", "mcp", "hook", "seed-test-mesh", + "disconnect", // duplicate alias only — top-level "disconnect" prints message +]); + +/** Verbs that mutate broker state (used by --approval-mode plan / read-only). */ +const WRITE_VERBS = new Set([ + "create", "send", "remember", "forget", "remind", "schedule", "summary", + "visible", "join", "leave", "kick", "ban", "unban", "disconnect", "delete", + "rename", "share", "invite", "store", "publish", "execute", "set", "remove", + "pause", "resume", "claim", "complete", "grant", "revoke", "block", "call", +]); + +function isWrite(verb: string): boolean { + return WRITE_VERBS.has(verb); +} + +/** + * Map (command, positionals) → invocation classification. + * The mapping mirrors the resource/verb namespace used in DEFAULT_POLICY so a + * `peer kick` rule actually matches both `peer kick` and the legacy `kick`. + */ +export function classifyInvocation(command: string, positionals: string[]): InvocationClass | null { + if (SKIP.has(command)) return null; + + const sub = positionals[0] ?? ""; + + // ── Resource-form commands ─────────────────────────────────────────────── + switch (command) { + case "peer": { + const verb = sub || "list"; + return { resource: "peer", verb, isWrite: isWrite(verb) }; + } + case "message": { + const verb = sub || "inbox"; + return { resource: "message", verb, isWrite: isWrite(verb) }; + } + case "memory": { + const verb = sub || "recall"; + return { resource: "memory", verb, isWrite: isWrite(verb) }; + } + case "profile": { + // `profile` (no sub) is read; `profile summary/visible/status set` are writes. + if (!sub) return { resource: "profile", verb: "view", isWrite: false }; + if (sub === "status") { + return positionals[1] === "set" + ? { resource: "profile", verb: "status", isWrite: true } + : { resource: "profile", verb: "view", isWrite: false }; + } + return { resource: "profile", verb: sub, isWrite: true }; + } + case "schedule": { + const verb = sub || "list"; + return { resource: "schedule", verb, isWrite: verb !== "list" }; + } + case "group": { + return { resource: "group", verb: sub || "list", isWrite: sub === "join" || sub === "leave" }; + } + case "task": { + return { resource: "task", verb: sub || "list", isWrite: isWrite(sub) }; + } + + // Platform — sub is the verb. + case "vector": case "graph": case "context": case "stream": + case "sql": case "skill": case "vault": case "watch": + case "webhook": case "file": case "mesh-mcp": case "clock": { + const verb = sub || "list"; + return { resource: command, verb, isWrite: isWrite(verb) }; + } + + case "state": { + const verb = sub === "set" ? "set" : sub === "list" ? "list" : "get"; + return { resource: "state", verb, isWrite: verb === "set" }; + } + } + + // ── Legacy / flat verb form ────────────────────────────────────────────── + switch (command) { + // Mesh management + case "create": case "new": return { resource: "mesh", verb: "create", isWrite: true }; + case "join": case "add": return { resource: "mesh", verb: "join", isWrite: true }; + case "delete": case "rm": return { resource: "mesh", verb: "delete", isWrite: true }; + case "rename": return { resource: "mesh", verb: "rename", isWrite: true }; + case "share": case "invite": return { resource: "mesh", verb: "share", isWrite: true }; + case "info": return { resource: "mesh", verb: "info", isWrite: false }; + + // Peer ops (legacy verbs) + case "peers": return { resource: "peer", verb: "list", isWrite: false }; + case "kick": return { resource: "peer", verb: "kick", isWrite: true }; + case "ban": return { resource: "peer", verb: "ban", isWrite: true }; + case "unban": return { resource: "peer", verb: "unban", isWrite: true }; + case "bans": return { resource: "peer", verb: "bans", isWrite: false }; + case "verify": return { resource: "peer", verb: "verify", isWrite: false }; + + // Messaging + case "send": return { resource: "message", verb: "send", isWrite: true }; + case "inbox": return { resource: "message", verb: "inbox", isWrite: false }; + case "msg-status": return { resource: "message", verb: "status", isWrite: false }; + + // Memory + case "remember": return { resource: "memory", verb: "remember", isWrite: true }; + case "recall": return { resource: "memory", verb: "recall", isWrite: false }; + case "forget": return { resource: "memory", verb: "forget", isWrite: true }; + case "remind": return { resource: "schedule", verb: "msg", isWrite: true }; + + // Presence + case "summary": return { resource: "profile", verb: "summary", isWrite: true }; + case "visible": return { resource: "profile", verb: "visible", isWrite: true }; + + // Diagnostics + case "stats": return { resource: "mesh", verb: "stats", isWrite: false }; + case "ping": return { resource: "mesh", verb: "ping", isWrite: false }; + + // Security + case "grant": return { resource: "grant", verb: "grant", isWrite: true }; + case "revoke": return { resource: "grant", verb: "revoke", isWrite: true }; + case "block": return { resource: "grant", verb: "block", isWrite: true }; + case "grants": return { resource: "grant", verb: "list", isWrite: false }; + } + + // Unknown command — let the dispatcher's default branch handle it. + return null; +} diff --git a/apps/cli/src/commands/broker-actions.ts b/apps/cli/src/commands/broker-actions.ts new file mode 100644 index 0000000..9357787 --- /dev/null +++ b/apps/cli/src/commands/broker-actions.ts @@ -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; +} diff --git a/apps/cli/src/commands/completions.ts b/apps/cli/src/commands/completions.ts index 8c1285a..35329ec 100644 --- a/apps/cli/src/commands/completions.ts +++ b/apps/cli/src/commands/completions.ts @@ -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; } } diff --git a/apps/cli/src/commands/connect-telegram.ts b/apps/cli/src/commands/connect-telegram.ts index 3224e27..e7233f0 100644 --- a/apps/cli/src/commands/connect-telegram.ts +++ b/apps/cli/src/commands/connect-telegram.ts @@ -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)")); } } diff --git a/apps/cli/src/commands/delete-mesh.ts b/apps/cli/src/commands/delete-mesh.ts index 814fca8..39555fc 100644 --- a/apps/cli/src/commands/delete-mesh.ts +++ b/apps/cli/src/commands/delete-mesh.ts @@ -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; } diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 80ffaad..fd239b1 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -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."); } diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index d6c899c..1516cf2 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -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); } diff --git a/apps/cli/src/commands/leave.ts b/apps/cli/src/commands/leave.ts index 190e6be..f97cecc 100644 --- a/apps/cli/src/commands/leave.ts +++ b/apps/cli/src/commands/leave.ts @@ -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; } diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 1936170..311d1c2 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -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(); } diff --git a/apps/cli/src/commands/login.ts b/apps/cli/src/commands/login.ts index 00d4117..278f98d 100644 --- a/apps/cli/src/commands/login.ts +++ b/apps/cli/src/commands/login.ts @@ -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; } } diff --git a/apps/cli/src/commands/new.ts b/apps/cli/src/commands/new.ts index 07040da..9f27a32 100644 --- a/apps/cli/src/commands/new.ts +++ b/apps/cli/src/commands/new.ts @@ -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; } diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts index 0b66d73..70a131e 100644 --- a/apps/cli/src/commands/peers.ts +++ b/apps/cli/src/commands/peers.ts @@ -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", + ); } } diff --git a/apps/cli/src/commands/platform-actions.ts b/apps/cli/src/commands/platform-actions.ts new file mode 100644 index 0000000..c603c53 --- /dev/null +++ b/apps/cli/src/commands/platform-actions.ts @@ -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; + }); +} diff --git a/apps/cli/src/commands/recall.ts b/apps/cli/src/commands/recall.ts index fc58a7a..00a2384 100644 --- a/apps/cli/src/commands/recall.ts +++ b/apps/cli/src/commands/recall.ts @@ -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; + }); } diff --git a/apps/cli/src/commands/remember.ts b/apps/cli/src/commands/remember.ts index 1f4f64c..5cf5ddb 100644 --- a/apps/cli/src/commands/remember.ts +++ b/apps/cli/src/commands/remember.ts @@ -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; + }); } diff --git a/apps/cli/src/commands/remind.ts b/apps/cli/src/commands/remind.ts index 275575e..ee1f6e0 100644 --- a/apps/cli/src/commands/remind.ts +++ b/apps/cli/src/commands/remind.ts @@ -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}`, + ); } }); } diff --git a/apps/cli/src/commands/seed-test-mesh.ts b/apps/cli/src/commands/seed-test-mesh.ts index 333cc96..ecab144 100644 --- a/apps/cli/src/commands/seed-test-mesh.ts +++ b/apps/cli/src/commands/seed-test-mesh.ts @@ -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")}`); } diff --git a/apps/cli/src/commands/send.ts b/apps/cli/src/commands/send.ts index 5f68bf4..ac9888b 100644 --- a/apps/cli/src/commands/send.ts +++ b/apps/cli/src/commands/send.ts @@ -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); } }); diff --git a/apps/cli/src/commands/state.ts b/apps/cli/src/commands/state.ts index 37fac6c..9f2efd5 100644 --- a/apps/cli/src/commands/state.ts +++ b/apps/cli/src/commands/state.ts @@ -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`); } }); } diff --git a/apps/cli/src/commands/uninstall.ts b/apps/cli/src/commands/uninstall.ts index 45dd836..2949de1 100644 --- a/apps/cli/src/commands/uninstall.ts +++ b/apps/cli/src/commands/uninstall.ts @@ -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; diff --git a/apps/cli/src/commands/url-handler.ts b/apps/cli/src/commands/url-handler.ts index 0aaba74..7fd291b 100644 --- a/apps/cli/src/commands/url-handler.ts +++ b/apps/cli/src/commands/url-handler.ts @@ -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; } diff --git a/apps/cli/src/commands/verify.ts b/apps/cli/src/commands/verify.ts index 0048852..0395d21 100644 --- a/apps/cli/src/commands/verify.ts +++ b/apps/cli/src/commands/verify.ts @@ -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; }); } diff --git a/apps/cli/src/commands/whoami.ts b/apps/cli/src/commands/whoami.ts index 915eec1..7cb8ffa 100644 --- a/apps/cli/src/commands/whoami.ts +++ b/apps/cli/src/commands/whoami.ts @@ -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; } diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 4bb0c29..94792d4 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -7,12 +7,56 @@ import { VERSION } from "~/constants/urls.js"; import { EXIT } from "~/constants/exit-codes.js"; import { renderVersion } from "~/cli/output/version.js"; import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js"; +import { classifyInvocation } from "~/cli/policy-classify.js"; +import { gate, type ApprovalMode } from "~/services/policy/index.js"; installSignalHandlers(); installErrorHandlers(); const { command, positionals, flags } = parseArgv(process.argv); +/** + * Resolve the coarse approval mode from CLI flags + env. + * --approval-mode <plan|read-only|write|yolo> explicit + * -y / --yes legacy: yolo + * $CLAUDEMESH_APPROVAL_MODE env override + * default 'write' + */ +function resolveApprovalMode(): ApprovalMode { + const raw = (flags["approval-mode"] as string | undefined) + ?? process.env.CLAUDEMESH_APPROVAL_MODE + ?? null; + if (raw === "plan" || raw === "read-only" || raw === "write" || raw === "yolo") return raw; + if (flags.y || flags.yes) return "yolo"; + return "write"; +} + +/** + * Run the policy gate before dispatching a command. Returns `true` if the + * caller should proceed; on `false`, the process should exit non-zero. + * + * Off-mesh commands (help, login, install...) classify as `null` and skip + * the gate entirely — there's no broker to mutate. + */ +async function policyGate(): Promise<boolean> { + const cls = classifyInvocation(command, positionals); + if (!cls) return true; + const mode = resolveApprovalMode(); + const yes = !!flags.y || !!flags.yes || mode === "yolo"; + const ok = await gate( + { + resource: cls.resource, + verb: cls.verb, + mesh: flags.mesh as string | undefined, + mode, + isWrite: cls.isWrite, + yes, + }, + { policyPath: flags.policy as string | undefined }, + ); + return ok; +} + const HELP = ` claudemesh — peer mesh for Claude Code sessions ${VERSION} @@ -30,24 +74,64 @@ Mesh claudemesh delete [slug] delete a mesh (alias: rm) claudemesh rename <slug> <name> rename a mesh claudemesh share [email] share mesh (invite link / send email) - claudemesh disconnect <peer> soft disconnect (peer auto-reconnects) - claudemesh kick <peer> end session (peer must manually rejoin) - claudemesh kick --stale 30m kick peers idle > duration - claudemesh kick --all kick everyone except yourself - claudemesh ban <peer> kick + permanently revoke (can't rejoin) - claudemesh unban <peer> lift a ban - claudemesh bans list banned members -Messaging - claudemesh peers see who's online - claudemesh send <to> <msg> send a message - claudemesh inbox drain pending messages +Peer (resource form, recommended) + claudemesh peer list see who's online (alias: peers) + claudemesh peer kick <p> end session (alias: kick) + claudemesh peer disconnect <p> soft disconnect (alias: disconnect) + claudemesh peer ban <p> ban + revoke (alias: ban) + claudemesh peer unban <p> lift a ban (alias: unban) + claudemesh peer bans list banned members (alias: bans) + claudemesh peer verify [p] safety numbers (alias: verify) + +Message (resource form) + claudemesh message send <to> <m> send a message (alias: send) + claudemesh message inbox drain pending (alias: inbox) + claudemesh message status <id> delivery status (alias: msg-status) + +Memory (resource form) + claudemesh memory remember <txt> store a memory (alias: remember) + claudemesh memory recall <q> search memories (alias: recall) + claudemesh memory forget <id> remove a memory (alias: forget) + +Profile / presence (resource form) + claudemesh profile view or edit profile + claudemesh profile summary <txt> broadcast summary (alias: summary) + claudemesh profile visible y|n toggle visibility (alias: visible) + claudemesh profile status set X set status idle/working/dnd (alias: status set) + claudemesh group join @<name> join a group (--role X) + claudemesh group leave @<name> leave a group + +Schedule (resource form) + claudemesh schedule msg <m> one-shot or recurring (alias: remind) + claudemesh schedule list list pending + claudemesh schedule cancel <id> remove a scheduled item + +State / mesh introspection claudemesh state get|set|list shared state - claudemesh remember <text> store a memory - claudemesh recall <query> search memories - claudemesh remind ... schedule a reminder - claudemesh profile view or edit your profile claudemesh info mesh overview + claudemesh stats per-peer activity counters + claudemesh ping diagnostic round-trip + +Tasks + claudemesh task create <title> create a new task [--assignee --priority --tags] + claudemesh task list list tasks [--status --assignee] + claudemesh task claim <id> claim an unclaimed task + claudemesh task complete <id> mark task done [result] + +Platform + claudemesh vector store|search|delete|collections embedding store + claudemesh graph query|execute "<cypher>" graph operations + claudemesh context share|get|list work-context summaries + claudemesh stream create|publish|list pub/sub event bus + claudemesh sql query|execute|schema per-mesh SQL + claudemesh skill list|get|remove mesh-published skills + claudemesh vault list|delete encrypted secrets + claudemesh watch list|remove URL change watchers + claudemesh webhook list|delete outbound HTTP triggers + claudemesh file list|status|delete shared mesh files + claudemesh mesh-mcp list|call|catalog deployed mesh-MCP servers + claudemesh clock set|pause|resume mesh logical clock Auth claudemesh login sign in (browser or paste token) @@ -79,7 +163,9 @@ Flags --help, -h show this help --json machine-readable output --mesh <slug> override mesh selection - -y, --yes skip confirmations + --approval-mode <mode> plan | read-only | write (default) | yolo + --policy <path> override policy file + -y, --yes skip confirmations (= --approval-mode yolo) -q, --quiet suppress non-essential output `; @@ -87,6 +173,10 @@ async function main(): Promise<void> { if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); } if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); } + // Policy gate — runs before any broker-touching command. Skipped for help, + // version, login/install, list, and other local-only ops via classifier. + if (!(await policyGate())) process.exit(EXIT.PERMISSION_DENIED); + // Bare command or invite URL if (!command || isInviteUrl(command)) { // `claudemesh <invite-url>` → join + launch in one step. @@ -147,8 +237,8 @@ async function main(): Promise<void> { case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; } // Messaging - case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; } - case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } + case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }); break; } + case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json }, positionals[0] ?? "", positionals.slice(1).join(" ")); break; } case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; } case "state": { const sub = positionals[0]; @@ -158,10 +248,28 @@ async function main(): Promise<void> { break; } case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; } - case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; } - case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; } + case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { mesh: flags.mesh as string, tags: flags.tags as string, json: !!flags.json })); break; } + case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "forget": { const { runForget } = await import("~/commands/broker-actions.js"); process.exit(await runForget(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; } - case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; } + // (profile case moved to resource-aliases block below for sub-command extensibility) + + // Profile / status / visibility / groups (replacing soft-deprecated MCP tools) + case "summary": { const { runSummary } = await import("~/commands/broker-actions.js"); process.exit(await runSummary(positionals.join(" "), { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "visible": { const { runVisible } = await import("~/commands/broker-actions.js"); process.exit(await runVisible(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "group": { + const sub = positionals[0]; + if (sub === "join") { const { runGroupJoin } = await import("~/commands/broker-actions.js"); process.exit(await runGroupJoin(positionals[1], { mesh: flags.mesh as string, role: flags.role as string, json: !!flags.json })); } + else if (sub === "leave") { const { runGroupLeave } = await import("~/commands/broker-actions.js"); process.exit(await runGroupLeave(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); } + else { console.error("Usage: claudemesh group <join|leave> @<name> [--role X]"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + // Mesh diagnostics + tasks + case "msg-status": { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } + case "stats": { const { runStats } = await import("~/commands/broker-actions.js"); process.exit(await runStats({ mesh: flags.mesh as string, json: !!flags.json })); break; } + case "ping": { const { runPing } = await import("~/commands/broker-actions.js"); process.exit(await runPing({ mesh: flags.mesh as string, json: !!flags.json })); break; } + // (clock + task moved to platform-actions block below for sub-command extensibility) // Auth case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; } @@ -173,7 +281,18 @@ async function main(): Promise<void> { case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; } case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; } case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; } - case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; } + case "status": { + // `claudemesh status set <state>` → set peer status (idle/working/dnd) + // `claudemesh status` (no args) → broker connectivity diagnostic + if (positionals[0] === "set") { + const { runStatusSet } = await import("~/commands/broker-actions.js"); + process.exit(await runStatusSet(positionals[1] ?? "", { mesh: flags.mesh as string, json: !!flags.json })); + } else { + const { runStatus } = await import("~/commands/status.js"); + await runStatus(); + } + break; + } case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; } // Test @@ -192,6 +311,206 @@ async function main(): Promise<void> { case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; } case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; } + // ── Resource-model aliases (1.4.0) ───────────────────────────────── + // Each `<resource> <verb>` form proxies to the existing legacy verb. + // The legacy verbs (`send`, `peers`, `kick`, `remember`, ...) keep + // working so old scripts never break. Spec: 2026-05-02 commitment #2. + + case "peer": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: flags.json as boolean | string | undefined }; + const id = positionals[1] ?? ""; + if (sub === "list") { const { runPeers } = await import("~/commands/peers.js"); await runPeers(f); } + else if (sub === "kick") { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(id, { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); } + else if (sub === "disconnect") { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(id, { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); } + else if (sub === "ban") { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(id, { mesh: flags.mesh as string })); } + else if (sub === "unban") { const { runUnban } = await import("~/commands/ban.js"); process.exit(await runUnban(id, { mesh: flags.mesh as string })); } + else if (sub === "bans") { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); } + else if (sub === "verify") { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(id || undefined, { mesh: flags.mesh as string, json: !!flags.json })); } + else { console.error("Usage: claudemesh peer <list|kick|disconnect|ban|unban|bans|verify>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + case "message": { + const sub = positionals[0]; + if (sub === "send") { const { runSend } = await import("~/commands/send.js"); await runSend({ mesh: flags.mesh as string, priority: flags.priority as string, json: !!flags.json }, positionals[1] ?? "", positionals.slice(2).join(" ")); } + else if (sub === "inbox") { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); } + else if (sub === "status") { const { runMsgStatus } = await import("~/commands/broker-actions.js"); process.exit(await runMsgStatus(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); } + else { console.error("Usage: claudemesh message <send|inbox|status>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + case "memory": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "remember") { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.slice(1).join(" "), { ...f, tags: flags.tags as string })); } + else if (sub === "recall") { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.slice(1).join(" "), f)); } + else if (sub === "forget") { const { runForget } = await import("~/commands/broker-actions.js"); process.exit(await runForget(positionals[1], f)); } + else { console.error("Usage: claudemesh memory <remember|recall|forget>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + case "profile": { + const sub = positionals[0]; + // `claudemesh profile` (no sub) → existing runProfile (interactive view/edit) + // `claudemesh profile summary "x"` → set summary + // `claudemesh profile visible true` → set visibility + // `claudemesh profile status set <state>` → set peer status + if (!sub) { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); } + else if (sub === "summary") { const { runSummary } = await import("~/commands/broker-actions.js"); process.exit(await runSummary(positionals.slice(1).join(" "), { mesh: flags.mesh as string, json: !!flags.json })); } + else if (sub === "visible") { const { runVisible } = await import("~/commands/broker-actions.js"); process.exit(await runVisible(positionals[1], { mesh: flags.mesh as string, json: !!flags.json })); } + else if (sub === "status") { + // `profile status` (no further sub) → diagnostic via runStatus + // `profile status set <state>` → set peer status + if (positionals[1] === "set") { const { runStatusSet } = await import("~/commands/broker-actions.js"); process.exit(await runStatusSet(positionals[2] ?? "", { mesh: flags.mesh as string, json: !!flags.json })); } + else { const { runStatus } = await import("~/commands/status.js"); await runStatus(); } + } + else { console.error("Usage: claudemesh profile [summary|visible|status]"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + case "schedule": { + // Aliases `remind` and its subcommands under a unified `schedule` verb. + // The unified `schedule webhook/tool` primitives need broker work and + // arrive in a later release — for now `schedule` only covers msg-style. + const sub = positionals[0]; + if (sub === "msg" || sub === "remind" || sub === undefined || sub === "list" || sub === "cancel") { + const { runRemind } = await import("~/commands/remind.js"); + // Translate `schedule msg ...` and bare `schedule list/cancel` into + // the legacy remind positional layout. + const remindPositionals = + sub === "msg" ? positionals.slice(1) + : sub === "remind" ? positionals.slice(1) + : positionals; // list / cancel / undefined + await runRemind({ mesh: flags.mesh as string, in: flags.in as string, at: flags.at as string, cron: flags.cron as string, to: flags.to as string, json: !!flags.json }, remindPositionals); + } + else if (sub === "webhook" || sub === "tool") { + console.error(` schedule ${sub} arrives in a later release — broker primitive not yet shipped`); + process.exit(EXIT.INVALID_ARGS); + } + else { console.error("Usage: claudemesh schedule <msg|list|cancel>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + + // Platform — vector / graph / context / stream / sql / skill / vault / watch / webhook / file / mesh-mcp + case "vector": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "store") { const { runVectorStore } = await import("~/commands/platform-actions.js"); process.exit(await runVectorStore(positionals[1] ?? "", positionals.slice(2).join(" "), { ...f, metadata: flags.metadata as string })); } + else if (sub === "search") { const { runVectorSearch } = await import("~/commands/platform-actions.js"); process.exit(await runVectorSearch(positionals[1] ?? "", positionals.slice(2).join(" "), { ...f, limit: flags.limit as string })); } + else if (sub === "delete") { const { runVectorDelete } = await import("~/commands/platform-actions.js"); process.exit(await runVectorDelete(positionals[1] ?? "", positionals[2] ?? "", f)); } + else if (sub === "collections") { const { runVectorCollections } = await import("~/commands/platform-actions.js"); process.exit(await runVectorCollections(f)); } + else { console.error("Usage: claudemesh vector <store|search|delete|collections>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "graph": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "query") { const { runGraphQuery } = await import("~/commands/platform-actions.js"); process.exit(await runGraphQuery(positionals.slice(1).join(" "), f)); } + else if (sub === "execute") { const { runGraphExecute } = await import("~/commands/platform-actions.js"); process.exit(await runGraphExecute(positionals.slice(1).join(" "), f)); } + else { console.error("Usage: claudemesh graph <query|execute> \"<cypher>\""); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "context": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "share") { const { runContextShare } = await import("~/commands/platform-actions.js"); process.exit(await runContextShare(positionals.slice(1).join(" "), { ...f, files: flags.files as string, findings: flags.findings as string, tags: flags.tags as string })); } + else if (sub === "get") { const { runContextGet } = await import("~/commands/platform-actions.js"); process.exit(await runContextGet(positionals.slice(1).join(" "), f)); } + else if (sub === "list") { const { runContextList } = await import("~/commands/platform-actions.js"); process.exit(await runContextList(f)); } + else { console.error("Usage: claudemesh context <share|get|list>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "stream": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "create") { const { runStreamCreate } = await import("~/commands/platform-actions.js"); process.exit(await runStreamCreate(positionals[1] ?? "", f)); } + else if (sub === "publish") { const { runStreamPublish } = await import("~/commands/platform-actions.js"); process.exit(await runStreamPublish(positionals[1] ?? "", positionals.slice(2).join(" "), f)); } + else if (sub === "list") { const { runStreamList } = await import("~/commands/platform-actions.js"); process.exit(await runStreamList(f)); } + else { console.error("Usage: claudemesh stream <create|publish|list>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "sql": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "query") { const { runSqlQuery } = await import("~/commands/platform-actions.js"); process.exit(await runSqlQuery(positionals.slice(1).join(" "), f)); } + else if (sub === "execute") { const { runSqlExecute } = await import("~/commands/platform-actions.js"); process.exit(await runSqlExecute(positionals.slice(1).join(" "), f)); } + else if (sub === "schema") { const { runSqlSchema } = await import("~/commands/platform-actions.js"); process.exit(await runSqlSchema(f)); } + else { console.error("Usage: claudemesh sql <query|execute|schema>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "skill": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runSkillList } = await import("~/commands/platform-actions.js"); process.exit(await runSkillList({ ...f, query: positionals[1] })); } + else if (sub === "get") { const { runSkillGet } = await import("~/commands/platform-actions.js"); process.exit(await runSkillGet(positionals[1] ?? "", f)); } + else if (sub === "remove") { const { runSkillRemove } = await import("~/commands/platform-actions.js"); process.exit(await runSkillRemove(positionals[1] ?? "", f)); } + else { console.error("Usage: claudemesh skill <list|get|remove>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "vault": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runVaultList } = await import("~/commands/platform-actions.js"); process.exit(await runVaultList(f)); } + else if (sub === "delete") { const { runVaultDelete } = await import("~/commands/platform-actions.js"); process.exit(await runVaultDelete(positionals[1] ?? "", f)); } + else { console.error("Usage: claudemesh vault <list|delete> (set/get currently via MCP — needs crypto)"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "watch": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runWatchList } = await import("~/commands/platform-actions.js"); process.exit(await runWatchList(f)); } + else if (sub === "remove") { const { runUnwatch } = await import("~/commands/platform-actions.js"); process.exit(await runUnwatch(positionals[1] ?? "", f)); } + else { console.error("Usage: claudemesh watch <list|remove>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "webhook": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runWebhookList } = await import("~/commands/platform-actions.js"); process.exit(await runWebhookList(f)); } + else if (sub === "delete") { const { runWebhookDelete } = await import("~/commands/platform-actions.js"); process.exit(await runWebhookDelete(positionals[1] ?? "", f)); } + else { console.error("Usage: claudemesh webhook <list|delete>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "file": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runFileList } = await import("~/commands/platform-actions.js"); process.exit(await runFileList({ ...f, query: positionals[1] })); } + else if (sub === "status") { const { runFileStatus } = await import("~/commands/platform-actions.js"); process.exit(await runFileStatus(positionals[1] ?? "", f)); } + else if (sub === "delete") { const { runFileDelete } = await import("~/commands/platform-actions.js"); process.exit(await runFileDelete(positionals[1] ?? "", f)); } + else { console.error("Usage: claudemesh file <list|status|delete>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "mesh-mcp": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "list") { const { runMeshMcpList } = await import("~/commands/platform-actions.js"); process.exit(await runMeshMcpList(f)); } + else if (sub === "call") { const { runMeshMcpCall } = await import("~/commands/platform-actions.js"); process.exit(await runMeshMcpCall(positionals[1] ?? "", positionals[2] ?? "", positionals.slice(3).join(" "), f)); } + else if (sub === "catalog") { const { runMeshMcpCatalog } = await import("~/commands/platform-actions.js"); process.exit(await runMeshMcpCatalog(f)); } + else { console.error("Usage: claudemesh mesh-mcp <list|call|catalog>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + case "clock": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "set") { const { runClockSet } = await import("~/commands/platform-actions.js"); process.exit(await runClockSet(positionals[1] ?? "", f)); } + else if (sub === "pause") { const { runClockPause } = await import("~/commands/platform-actions.js"); process.exit(await runClockPause(f)); } + else if (sub === "resume") { const { runClockResume } = await import("~/commands/platform-actions.js"); process.exit(await runClockResume(f)); } + else { const { runClock } = await import("~/commands/broker-actions.js"); process.exit(await runClock(f)); } + break; + } + + // task — extends broker-actions.ts (claim/complete) with list/create + case "task": { + const sub = positionals[0]; + const f = { mesh: flags.mesh as string, json: !!flags.json }; + if (sub === "claim") { const { runTaskClaim } = await import("~/commands/broker-actions.js"); process.exit(await runTaskClaim(positionals[1], f)); } + else if (sub === "complete") { const { runTaskComplete } = await import("~/commands/broker-actions.js"); process.exit(await runTaskComplete(positionals[1], positionals.slice(2).join(" ") || undefined, f)); } + else if (sub === "list") { const { runTaskList } = await import("~/commands/platform-actions.js"); process.exit(await runTaskList({ ...f, status: flags.status as string, assignee: flags.assignee as string })); } + else if (sub === "create") { const { runTaskCreate } = await import("~/commands/platform-actions.js"); process.exit(await runTaskCreate(positionals.slice(1).join(" "), { ...f, assignee: flags.assignee as string, priority: flags.priority as string, tags: flags.tags as string })); } + else { console.error("Usage: claudemesh task <create|list|claim|complete>"); process.exit(EXIT.INVALID_ARGS); } + break; + } + // Internal case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; } case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; } diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 6c4145d..e297e65 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -15,9 +15,13 @@ import { ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; +// CallToolRequestSchema is still imported for the mesh-service proxy mode +// further down; the main MCP server has no tools as of 1.5.0 (tool-less +// push-pipe — spec 2026-05-02 commitment #6). import { TOOLS } from "./tools/definitions.js"; import { readConfig } from "~/services/config/facade.js"; import { BrokerClient, startClients, stopAll, findClient, allClients } from "~/services/broker/facade.js"; +import { startBridgeServer, type BridgeServer } from "~/services/bridge/server.js"; import type { InboundPush } from "~/services/broker/facade.js"; import type { Priority, @@ -248,8 +252,29 @@ export async function startMcpServer(): Promise<void> { return startServiceProxy(process.argv[serviceIdx + 1]!); } + // --mesh <slug>: bind this MCP server to a single mesh from the user's + // joined-meshes config. Used for the per-mesh push-pipe pattern in + // ~/.claude.json — one MCP entry per mesh, each holds one WS, push + // notifications fan out across all meshes simultaneously. + // Default behavior (no flag): connect to every mesh in config. + const meshIdx = process.argv.indexOf("--mesh"); + const onlyMesh = meshIdx !== -1 ? process.argv[meshIdx + 1] : null; + const config = readConfig(); + if (onlyMesh) { + const available = config.meshes.map((m) => m.slug); + const filtered = config.meshes.filter((m) => m.slug === onlyMesh); + if (filtered.length === 0) { + process.stderr.write( + `[claudemesh] --mesh "${onlyMesh}" not found in config. ` + + `Joined meshes: ${available.join(", ") || "(none)"}\n`, + ); + process.exit(1); + } + config.meshes = filtered; + } + const myName = config.displayName ?? "unnamed"; const myRole = config.role ?? process.env.CLAUDEMESH_ROLE ?? null; const myGroups = (config.groups ?? []).map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ') || "none"; @@ -509,1377 +534,6 @@ Your message mode is "${messageMode}". }; }); - server.setRequestHandler(CallToolRequestSchema, async (req) => { - const { name, arguments: args } = req.params; - - // Track tool call count across all connected clients - for (const c of allClients()) { - c.incrementToolCalls(); - } - - if (config.meshes.length === 0) { - return text( - "No meshes joined. Run `claudemesh join https://claudemesh.com/join/<token>` first.", - true, - ); - } - - switch (name) { - case "send_message": { - const { to, message, priority } = (args ?? {}) as unknown as SendMessageArgs; - if (!to || !message) - return text("send_message: `to` and `message` required", true); - - // Handle multi-target: to can be string or string[] - const targets = Array.isArray(to) ? to : [to]; - const results: string[] = []; - const seen = new Set<string>(); // dedup by resolved pubkey - - for (const target of targets) { - const { client, targetSpec, error } = await resolveClient(target); - if (!client) { - results.push(`✗ ${target}: ${error ?? "no client resolved"}`); - continue; - } - if (seen.has(targetSpec)) continue; // dedup - seen.add(targetSpec); - const result = await client.send( - targetSpec, - message, - (priority ?? "next") as Priority, - ); - if (!result.ok) { - results.push(`✗ ${target}: ${result.error}`); - } else { - results.push(`✓ ${target} → ${result.messageId}`); - } - } - return text(results.join("\n")); - } - - case "list_peers": { - const { mesh_slug } = (args ?? {}) as ListPeersArgs; - const clients = mesh_slug - ? [findClient(mesh_slug)].filter(Boolean) - : allClients(); - if (clients.length === 0) - return text( - mesh_slug - ? `list_peers: no joined mesh "${mesh_slug}"` - : "list_peers: no joined meshes", - true, - ); - const sections: string[] = []; - // Keep the status-line cache fresh for Claude Code's statusLine renderer. - const statusCache: Record<string, { total: number; online: number; updatedAt: string; you?: string }> = {}; - for (const c of clients) { - const peers = await c!.listPeers(); - const header = `## ${c!.meshSlug} (${c!.status}, mesh ${c!.meshId.slice(0, 8)}…)`; - statusCache[c!.meshSlug] = { - total: peers.length, - online: peers.filter(p => p.status !== "offline").length, - updatedAt: new Date().toISOString(), - you: process.env.CLAUDEMESH_DISPLAY_NAME ?? undefined, - }; - if (peers.length === 0) { - sections.push(`${header}\nNo peers connected.`); - } else { - // Note: multiple entries with the same pubkey are the SAME member - // connected from multiple sessions (each session sends its own - // hello display_name). Annotate so the caller doesn't treat them - // as distinct people. - const pubkeyCounts = new Map<string, number>(); - for (const p of peers) pubkeyCounts.set(p.pubkey, (pubkeyCounts.get(p.pubkey) ?? 0) + 1); - const peerLines = peers.map((p) => { - const summary = p.summary ? ` — "${p.summary}"` : ""; - const groupsStr = p.groups?.length ? ` [${p.groups.map(g => `@${g.name}${g.role ? ':' + g.role : ''}`).join(', ')}]` : ""; - const meta: string[] = []; - if (p.peerType) meta.push(`type:${p.peerType}`); - if (p.channel) meta.push(`channel:${p.channel}`); - if (p.model) meta.push(`model:${p.model}`); - const metaStr = meta.length ? ` {${meta.join(", ")}}` : ""; - const cwdStr = p.cwd ? ` cwd:${p.cwd}` : ""; - const locality = p.hostname && p.hostname === require("os").hostname() ? "local" : "remote"; - const localityTag = ` [${locality}]`; - const profileAvatar = p.profile?.avatar ? `${p.profile.avatar} ` : ""; - const profileTitle = p.profile?.title ? ` (${p.profile.title})` : ""; - const hiddenTag = p.visible === false ? " [hidden]" : ""; - const sameKeyCount = pubkeyCounts.get(p.pubkey) ?? 1; - const sameKeyTag = sameKeyCount > 1 ? ` [shares key with ${sameKeyCount - 1} other session(s)]` : ""; - // pubkey prefix must be long enough for unambiguous routing via - // send_message — 16 hex chars = 64 bits of entropy, effectively - // unique within any mesh of realistic size. - return `- ${profileAvatar}**${p.displayName}**${profileTitle} [${p.status}]${localityTag}${hiddenTag}${sameKeyTag}${groupsStr}${metaStr} (pubkey: ${p.pubkey.slice(0, 16)}…)${cwdStr}${summary}`; - }); - sections.push(`${header}\n${peerLines.join("\n")}`); - } - } - // Persist the peer-cache for claudemesh status-line. Best effort. - try { - const { writeFileSync, mkdirSync, existsSync } = await import("node:fs"); - const { join: joinPath } = await import("node:path"); - const { homedir } = await import("node:os"); - const dir = joinPath(homedir(), ".claudemesh"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(joinPath(dir, "peer-cache.json"), JSON.stringify(statusCache)); - } catch { /* non-fatal */ } - return text(sections.join("\n\n")); - } - - case "message_status": { - const { id } = (args ?? {}) as { id?: string }; - if (!id) return text("message_status: `id` required", true); - const clients = allClients(); - if (!clients.length) return text("message_status: not connected", true); - // Try each connected mesh client — we don't know which mesh the - // messageId belongs to, so query all and return the first hit. - let result = null; - for (const c of clients) { - result = await c.messageStatus(id); - if (result) break; - } - if (!result) return text(`Message ${id} not found or timed out.`); - const recipientLines = result.recipients.map( - (r: { name: string; pubkey: string; status: string }) => - ` - ${r.name} (${r.pubkey.slice(0, 12)}…): ${r.status}`, - ); - return text( - `Message ${id.slice(0, 12)}… → ${result.targetSpec}\n` + - `Delivered: ${result.delivered}${result.deliveredAt ? ` at ${result.deliveredAt}` : ""}\n` + - `Recipients:\n${recipientLines.join("\n")}`, - ); - } - - case "check_messages": { - const drained: string[] = []; - for (const c of allClients()) { - const msgs = c.drainPushBuffer(); - for (const m of msgs) drained.push(formatPush(m, c.meshSlug)); - } - if (drained.length === 0) return text("No new messages."); - return text( - `${drained.length} new message(s):\n\n${drained.join("\n\n---\n\n")}`, - ); - } - - case "set_summary": { - const { summary } = (args ?? {}) as unknown as SetSummaryArgs; - if (!summary) return text("set_summary: `summary` required", true); - for (const c of allClients()) await c.setSummary(summary); - return text( - `Summary set: "${summary}" (visible to ${allClients().length} mesh(es)).`, - ); - } - - case "set_status": { - const { status } = (args ?? {}) as unknown as SetStatusArgs; - if (!status) return text("set_status: `status` required", true); - const s = status as PeerStatus; - for (const c of allClients()) await c.setStatus(s); - return text(`Status set to ${s} across ${allClients().length} mesh(es).`); - } - - case "set_visible": { - const { visible } = (args ?? {}) as { visible?: boolean }; - if (visible === undefined) return text("set_visible: `visible` required", true); - for (const c of allClients()) await c.setVisible(visible); - return text(visible ? "You are now visible to peers." : "You are now hidden. Direct messages still reach you, but you won't appear in list_peers or receive broadcasts."); - } - - case "set_profile": { - const { avatar, title, bio, capabilities } = (args ?? {}) as { avatar?: string; title?: string; bio?: string; capabilities?: string[] }; - const profile = { avatar, title, bio, capabilities }; - for (const c of allClients()) await c.setProfile(profile); - const parts: string[] = []; - if (avatar) parts.push(`Avatar: ${avatar}`); - if (title) parts.push(`Title: ${title}`); - if (bio) parts.push(`Bio: ${bio}`); - if (capabilities?.length) parts.push(`Capabilities: ${capabilities.join(", ")}`); - return text(parts.length > 0 ? `Profile updated:\n${parts.join("\n")}` : "Profile cleared."); - } - - case "join_group": { - const { name: groupName, role } = (args ?? {}) as { name?: string; role?: string }; - if (!groupName) return text("join_group: `name` required", true); - for (const c of allClients()) await c.joinGroup(groupName, role); - return text(`Joined @${groupName}${role ? ` as ${role}` : ""}`); - } - - case "leave_group": { - const { name: groupName } = (args ?? {}) as { name?: string }; - if (!groupName) return text("leave_group: `name` required", true); - for (const c of allClients()) await c.leaveGroup(groupName); - return text(`Left @${groupName}`); - } - - // --- State --- - case "set_state": { - const { key, value } = (args ?? {}) as { key?: string; value?: unknown }; - if (!key) return text("set_state: `key` required", true); - for (const c of allClients()) await c.setState(key, value); - return text(`State set: ${key} = ${JSON.stringify(value)}`); - } - case "get_state": { - const { key } = (args ?? {}) as { key?: string }; - if (!key) return text("get_state: `key` required", true); - const client = allClients()[0]; - if (!client) return text("get_state: not connected", true); - const result = await client.getState(key); - if (!result) return text(`State "${key}" not found.`); - return text(`${key} = ${JSON.stringify(result.value)} (set by ${result.updatedBy} at ${result.updatedAt})`); - } - case "list_state": { - const client = allClients()[0]; - if (!client) return text("list_state: not connected", true); - const entries = await client.listState(); - if (entries.length === 0) return text("No shared state set."); - const lines = entries.map(e => `- **${e.key}** = ${JSON.stringify(e.value)} (by ${e.updatedBy})`); - return text(lines.join("\n")); - } - - // --- Memory --- - case "remember": { - const { content, tags } = (args ?? {}) as { content?: string; tags?: string[] }; - if (!content) return text("remember: `content` required", true); - const client = allClients()[0]; - if (!client) return text("remember: not connected", true); - const id = await client.remember(content, tags); - return text(`Remembered${id ? ` (${id})` : ""}: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}"`); - } - case "recall": { - const { query } = (args ?? {}) as { query?: string }; - if (!query) return text("recall: `query` required", true); - const client = allClients()[0]; - if (!client) return text("recall: not connected", true); - const memories = await client.recall(query); - if (memories.length === 0) return text(`No memories found for "${query}".`); - const lines = memories.map(m => `- [${m.id.slice(0, 8)}] ${m.content} (by ${m.rememberedBy}, ${m.rememberedAt})`); - return text(`${memories.length} memor${memories.length === 1 ? 'y' : 'ies'}:\n${lines.join("\n")}`); - } - case "forget": { - const { id } = (args ?? {}) as { id?: string }; - if (!id) return text("forget: `id` required", true); - const client = allClients()[0]; - if (!client) return text("forget: not connected", true); - await client.forget(id); - return text(`Forgotten: ${id}`); - } - - // --- Scheduled messages --- - case "schedule_reminder": { - const sArgs = (args ?? {}) as { - message?: string; - to?: string; - deliver_at?: number; - in_seconds?: number; - cron?: string; - }; - if (!sArgs.message) return text("schedule_reminder: `message` required", true); - const client = allClients()[0]; - if (!client) return text("schedule_reminder: not connected", true); - - const isCron = !!sArgs.cron; - - let deliverAt: number; - if (isCron) { - // For cron, deliverAt is ignored by the broker — set to 0 - deliverAt = 0; - } else if (sArgs.deliver_at) { - deliverAt = Number(sArgs.deliver_at); - } else if (sArgs.in_seconds) { - deliverAt = Date.now() + Number(sArgs.in_seconds) * 1_000; - } else { - return text("schedule_reminder: provide `deliver_at` (ms timestamp), `in_seconds`, or `cron` expression", true); - } - - const isSelf = !sArgs.to; - let targetSpec: string; - if (isSelf) { - // Self-reminder: target own session pubkey - targetSpec = client.getSessionPubkey() ?? "*"; - } else { - const to = sArgs.to!; - // Resolve display name → pubkey if not a raw spec - if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) { - 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(", "); - return text(`schedule_reminder: peer "${to}" not found. Online: ${names || "(none)"}`, true); - } - targetSpec = match.pubkey; - } else { - targetSpec = to; - } - } - - const result = await client.scheduleMessage(targetSpec, sArgs.message, deliverAt, true, sArgs.cron); - if (!result) return text("schedule_reminder: broker did not acknowledge — check connection", true); - - if (isCron) { - const nextFire = new Date(result.deliverAt).toISOString(); - return text( - isSelf - ? `Recurring self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" — cron: ${sArgs.cron}, next fire: ${nextFire}` - : `Recurring reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) — cron: ${sArgs.cron}, next fire: ${nextFire}`, - ); - } - - const when = new Date(result.deliverAt).toISOString(); - return text( - isSelf - ? `Self-reminder scheduled (${result.scheduledId.slice(0, 8)}): "${sArgs.message.slice(0, 60)}" at ${when}` - : `Reminder to "${sArgs.to}" scheduled (${result.scheduledId.slice(0, 8)}) for ${when}`, - ); - } - case "list_scheduled": { - const client = allClients()[0]; - if (!client) return text("list_scheduled: not connected", true); - const scheduled = await client.listScheduled(); - if (scheduled.length === 0) return text("No pending scheduled messages."); - const lines = scheduled.map((m) => - `- [${m.id.slice(0, 8)}] → ${m.to === client.getSessionPubkey() ? "self (reminder)" : m.to} at ${new Date(m.deliverAt).toISOString()}: "${m.message.slice(0, 60)}${m.message.length > 60 ? "…" : ""}"`, - ); - return text(`${scheduled.length} scheduled:\n${lines.join("\n")}`); - } - case "cancel_scheduled": { - const client = allClients()[0]; - if (!client) return text("cancel_scheduled: not connected", true); - const { id: schedId } = (args ?? {}) as { id?: string }; - if (!schedId) return text("cancel_scheduled: `id` required", true); - const ok = await client.cancelScheduled(schedId); - return text(ok ? `Cancelled: ${schedId}` : `Not found or already fired: ${schedId}`, !ok); - } - - // --- Files --- - case "share_file": { - const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string }; - if (!filePath) return text("share_file: `path` required", true); - const { existsSync } = await import("node:fs"); - if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); - const client = allClients()[0]; - if (!client) return text("share_file: not connected", true); - - // If 'to' specified, do E2E encryption - if (fileTo) { - const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js"); - const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join, basename } = await import("node:path"); - - // Resolve target peer pubkey - const peers = await client.listPeers(); - const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo); - if (!targetPeer) { - return text(`share_file: peer not found: ${fileTo}`, true); - } - - // Read and encrypt file - const plaintext = readFileSync(filePath); - const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext)); - - // Seal Kf for target peer - const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey); - - // Seal Kf for ourselves (owner) - const myPubkey = client.getSessionPubkey(); - const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null; - - const fileKeys = [ - { peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget }, - ...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []), - ]; - - // Build combined buffer: nonce (24 bytes) + ciphertext - const { ensureSodium } = await import("~/services/crypto/keypair.js"); - const sodium = await ensureSodium(); - const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL); - const combined = new Uint8Array(nonceBytes.length + ciphertext.length); - combined.set(nonceBytes, 0); - combined.set(ciphertext, nonceBytes.length); - - const rawName = fileName ?? basename(filePath); - const baseName = basename(rawName).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255); - const tmpDir = mkdtempSync(join(tmpdir(), "cm-")); - const tmpPath = join(tmpDir, baseName); - writeFileSync(tmpPath, combined); - - try { - const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, { - name: baseName, - tags, - persistent: true, - encrypted: true, - ownerPubkey: myPubkey ?? undefined, - fileKeys, - }); - return text(`Shared (E2E encrypted): ${baseName} → ${targetPeer.displayName} (${fileId})`); - } catch (e) { - return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true); - } finally { - try { unlinkSync(tmpPath); } catch { /* ignore */ } - try { rmdirSync(tmpDir); } catch { /* ignore */ } - } - } - - // Plain (unencrypted) upload — existing code - try { - const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { - name: fileName, tags, persistent: true, - }); - return text(`Shared: ${fileName ?? filePath} (${fileId})`); - } catch (e) { - return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true); - } - } - - case "get_file": { - const { id, save_to } = (args ?? {}) as { id?: string; save_to?: string }; - if (!id || !save_to) return text("get_file: `id` and `save_to` required", true); - const client = allClients()[0]; - if (!client) return text("get_file: not connected", true); - const result = await client.getFile(id); - if (!result) return text(`get_file: file ${id} not found`, true); - - if (result.encrypted) { - const genericErr = "get_file: could not decrypt — you may not have access to this file"; - if (!result.sealedKey) return text(genericErr, true); - const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js"); - const { ensureSodium } = await import("~/services/crypto/keypair.js"); - const myPubkey = client.getSessionPubkey(); - const mySecret = client.getSessionSecretKey(); - if (!myPubkey || !mySecret) return text(genericErr, true); - - const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret); - if (!kf) return text(genericErr, true); - - const MAX_DOWNLOAD = 100 * 1024 * 1024; // 100 MB - const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) }); - if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true); - const contentLength = parseInt(resp.headers.get("content-length") ?? "0", 10); - if (contentLength > MAX_DOWNLOAD) return text(`get_file: file too large (${contentLength} bytes)`, true); - const buf = new Uint8Array(await resp.arrayBuffer()); - if (buf.length > MAX_DOWNLOAD) return text(`get_file: file too large (${buf.length} bytes)`, true); - - const sodium = await ensureSodium(); - const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; - if (buf.length < NONCE_BYTES) return text(genericErr, true); - const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL); - const ciphertext = buf.slice(NONCE_BYTES); - - const plaintext = await decryptFile(ciphertext, nonce, kf); - if (!plaintext) return text(genericErr, true); - - const { writeFileSync, mkdirSync } = await import("node:fs"); - const { dirname } = await import("node:path"); - mkdirSync(dirname(save_to), { recursive: true }); - writeFileSync(save_to, plaintext); - return text(`Downloaded and decrypted: ${result.name} → ${save_to}`); - } - - // Unencrypted — try presigned URL first, fall back to broker download proxy - let res = await fetch(result.url, { signal: AbortSignal.timeout(10_000) }).catch(() => null); - if (!res || !res.ok) { - // Presigned URL failed (internal MinIO hostname) — use broker proxy - const brokerHttp = client.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", ""); - res = await fetch(`${brokerHttp}/download/${id}?mesh=${client.meshId}`, { signal: AbortSignal.timeout(30_000) }); - } - if (!res.ok) return text(`get_file: download failed (${res.status})`, true); - const { writeFileSync, mkdirSync } = await import("node:fs"); - const { dirname } = await import("node:path"); - mkdirSync(dirname(save_to), { recursive: true }); - writeFileSync(save_to, Buffer.from(await res.arrayBuffer())); - return text(`Downloaded: ${result.name} → ${save_to}`); - } - - case "list_files": { - const { query, from } = (args ?? {}) as { query?: string; from?: string }; - const client = allClients()[0]; - if (!client) return text("list_files: not connected", true); - const files = await client.listFiles(query, from); - if (files.length === 0) return text("No files found."); - const lines = files.map(f => - `- **${f.name}** (${f.id.slice(0, 8)}…, ${f.size} bytes) by ${f.uploadedBy}${f.tags.length ? ` [${f.tags.join(", ")}]` : ""}` - ); - return text(lines.join("\n")); - } - - case "file_status": { - const { id } = (args ?? {}) as { id?: string }; - if (!id) return text("file_status: `id` required", true); - const client = allClients()[0]; - if (!client) return text("file_status: not connected", true); - const accesses = await client.fileStatus(id); - if (accesses.length === 0) return text("No one has accessed this file yet."); - const lines = accesses.map(a => `- ${a.peerName} at ${a.accessedAt}`); - return text(`Accessed by:\n${lines.join("\n")}`); - } - - case "delete_file": { - const { id } = (args ?? {}) as { id?: string }; - if (!id) return text("delete_file: `id` required", true); - const client = allClients()[0]; - if (!client) return text("delete_file: not connected", true); - await client.deleteFile(id); - return text(`Deleted: ${id}`); - } - - // --- Vectors --- - case "vector_store": { - const { collection, text: storeText, metadata } = (args ?? {}) as { collection?: string; text?: string; metadata?: Record<string, unknown> }; - if (!collection || !storeText) return text("vector_store: `collection` and `text` required", true); - const client = allClients()[0]; - if (!client) return text("vector_store: not connected", true); - const id = await client.vectorStore(collection, storeText, metadata); - return text(`Stored in ${collection}${id ? ` (${id})` : ""}`); - } - case "vector_search": { - const { collection, query, limit } = (args ?? {}) as { collection?: string; query?: string; limit?: number }; - if (!collection || !query) return text("vector_search: `collection` and `query` required", true); - const client = allClients()[0]; - if (!client) return text("vector_search: not connected", true); - const results = await client.vectorSearch(collection, query, limit); - if (results.length === 0) return text(`No results in ${collection} for "${query}".`); - const lines = results.map(r => `- [${r.id.slice(0, 8)}…] (score: ${r.score.toFixed(3)}) ${r.text.slice(0, 120)}${r.text.length > 120 ? "…" : ""}`); - return text(`${results.length} result(s) in ${collection}:\n${lines.join("\n")}`); - } - case "vector_delete": { - const { collection, id } = (args ?? {}) as { collection?: string; id?: string }; - if (!collection || !id) return text("vector_delete: `collection` and `id` required", true); - const client = allClients()[0]; - if (!client) return text("vector_delete: not connected", true); - await client.vectorDelete(collection, id); - return text(`Deleted ${id} from ${collection}`); - } - case "list_collections": { - const client = allClients()[0]; - if (!client) return text("list_collections: not connected", true); - const collections = await client.listCollections(); - if (collections.length === 0) return text("No vector collections."); - return text(`Collections:\n${collections.map(c => `- ${c}`).join("\n")}`); - } - - // --- Graph --- - case "graph_query": { - const { cypher } = (args ?? {}) as { cypher?: string }; - if (!cypher) return text("graph_query: `cypher` required", true); - const client = allClients()[0]; - if (!client) return text("graph_query: not connected", true); - const rows = await client.graphQuery(cypher); - if (rows.length === 0) return text("No results."); - return text(JSON.stringify(rows, null, 2)); - } - case "graph_execute": { - const { cypher } = (args ?? {}) as { cypher?: string }; - if (!cypher) return text("graph_execute: `cypher` required", true); - const client = allClients()[0]; - if (!client) return text("graph_execute: not connected", true); - const rows = await client.graphExecute(cypher); - return text(rows.length > 0 ? JSON.stringify(rows, null, 2) : "Executed successfully."); - } - - // --- Context --- - case "share_context": { - const { summary, files_read, key_findings, tags } = (args ?? {}) as { summary?: string; files_read?: string[]; key_findings?: string[]; tags?: string[] }; - if (!summary) return text("share_context: `summary` required", true); - const client = allClients()[0]; - if (!client) return text("share_context: not connected", true); - await client.shareContext(summary, files_read, key_findings, tags); - return text(`Context shared: "${summary.slice(0, 80)}${summary.length > 80 ? "…" : ""}"`); - } - case "get_context": { - const { query } = (args ?? {}) as { query?: string }; - if (!query) return text("get_context: `query` required", true); - const client = allClients()[0]; - if (!client) return text("get_context: not connected", true); - const contexts = await client.getContext(query); - if (contexts.length === 0) return text(`No context found for "${query}".`); - const lines = contexts.map(c => { - const files = c.filesRead.length ? `\n Files: ${c.filesRead.join(", ")}` : ""; - const findings = c.keyFindings.length ? `\n Findings: ${c.keyFindings.join("; ")}` : ""; - return `- **${c.peerName}** (${c.updatedAt}): ${c.summary}${files}${findings}`; - }); - return text(`${contexts.length} context(s):\n${lines.join("\n")}`); - } - case "list_contexts": { - const client = allClients()[0]; - if (!client) return text("list_contexts: not connected", true); - const contexts = await client.listContexts(); - if (contexts.length === 0) return text("No peer contexts shared yet."); - const lines = contexts.map(c => `- **${c.peerName}**: ${c.summary}${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`); - return text(`Peer contexts:\n${lines.join("\n")}`); - } - - // --- Tasks --- - case "create_task": { - const { title, assignee, priority, tags } = (args ?? {}) as { title?: string; assignee?: string; priority?: string; tags?: string[] }; - if (!title) return text("create_task: `title` required", true); - const client = allClients()[0]; - if (!client) return text("create_task: not connected", true); - const id = await client.createTask(title, assignee, priority, tags); - return text(`Task created${id ? ` (${id})` : ""}: "${title}"${assignee ? ` → ${assignee}` : ""}`); - } - case "claim_task": { - const { id } = (args ?? {}) as { id?: string }; - if (!id) return text("claim_task: `id` required", true); - const client = allClients()[0]; - if (!client) return text("claim_task: not connected", true); - await client.claimTask(id); - return text(`Claimed task: ${id}`); - } - case "complete_task": { - const { id, result } = (args ?? {}) as { id?: string; result?: string }; - if (!id) return text("complete_task: `id` required", true); - const client = allClients()[0]; - if (!client) return text("complete_task: not connected", true); - await client.completeTask(id, result); - return text(`Completed task: ${id}${result ? ` — ${result}` : ""}`); - } - case "list_tasks": { - const { status, assignee } = (args ?? {}) as { status?: string; assignee?: string }; - const client = allClients()[0]; - if (!client) return text("list_tasks: not connected", true); - const tasks = await client.listTasks(status, assignee); - if (tasks.length === 0) return text("No tasks found."); - const lines = tasks.map(t => `- [${t.id.slice(0, 8)}…] **${t.title}** (${t.status}, ${t.priority}) ${t.assignee ? `→ ${t.assignee}` : "unassigned"} (by ${t.createdBy})`); - return text(`${tasks.length} task(s):\n${lines.join("\n")}`); - } - - // --- Mesh Database --- - case "mesh_query": { - const { sql: querySql } = (args ?? {}) as { sql?: string }; - if (!querySql) return text("mesh_query: `sql` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_query: not connected", true); - const result = await client.meshQuery(querySql); - if (!result) return text("mesh_query: query failed or timed out", true); - if (result.rows.length === 0) return text(`Query returned 0 rows.`); - const header = `| ${result.columns.join(" | ")} |`; - const sep = `| ${result.columns.map(() => "---").join(" | ")} |`; - const rows = result.rows.map(r => `| ${result.columns.map(c => String(r[c] ?? "")).join(" | ")} |`); - return text(`${result.rowCount} row(s):\n${header}\n${sep}\n${rows.join("\n")}`); - } - case "mesh_execute": { - const { sql: execSql } = (args ?? {}) as { sql?: string }; - if (!execSql) return text("mesh_execute: `sql` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_execute: not connected", true); - await client.meshExecute(execSql); - return text(`Executed.`); - } - case "mesh_schema": { - const client = allClients()[0]; - if (!client) return text("mesh_schema: not connected", true); - const tables = await client.meshSchema(); - if (!tables || tables.length === 0) return text("No tables in mesh database."); - const lines = tables.map(t => `**${t.name}**: ${t.columns.map(c => `${c.name} (${c.type}${c.nullable ? ", nullable" : ""})`).join(", ")}`); - return text(lines.join("\n")); - } - - // --- Streams --- - case "create_stream": { - const { name: streamName } = (args ?? {}) as { name?: string }; - if (!streamName) return text("create_stream: `name` required", true); - const client = allClients()[0]; - if (!client) return text("create_stream: not connected", true); - const streamId = await client.createStream(streamName); - return text(`Stream created: ${streamName}${streamId ? ` (${streamId})` : ""}`); - } - case "publish": { - const { stream: pubStream, data: pubData } = (args ?? {}) as { stream?: string; data?: unknown }; - if (!pubStream) return text("publish: `stream` required", true); - const client = allClients()[0]; - if (!client) return text("publish: not connected", true); - await client.publish(pubStream, pubData); - return text(`Published to ${pubStream}.`); - } - case "subscribe": { - const { stream: subStream } = (args ?? {}) as { stream?: string }; - if (!subStream) return text("subscribe: `stream` required", true); - const client = allClients()[0]; - if (!client) return text("subscribe: not connected", true); - await client.subscribe(subStream); - return text(`Subscribed to ${subStream}. Data pushes will arrive as channel notifications.`); - } - case "list_streams": { - const client = allClients()[0]; - if (!client) return text("list_streams: not connected", true); - const streams = await client.listStreams(); - if (streams.length === 0) return text("No active streams."); - const lines = streams.map(s => `- **${s.name}** (${s.id.slice(0, 8)}…) by ${s.createdBy}, ${s.subscriberCount} subscriber(s)`); - return text(lines.join("\n")); - } - - case "mesh_set_clock": { - const { speed } = (args ?? {}) as { speed?: number }; - if (!speed || speed < 1 || speed > 100) return text("mesh_set_clock: speed must be 1-100", true); - const client = allClients()[0]; - if (!client) return text("mesh_set_clock: not connected", true); - const result = await client.setClock(speed); - if (!result) return text("mesh_set_clock: timed out", true); - return text([ - `**Clock set to x${result.speed}**`, - `Paused: ${result.paused}`, - `Tick: ${result.tick}`, - `Sim time: ${result.simTime}`, - `Started at: ${result.startedAt}`, - ].join("\n")); - } - - case "mesh_pause_clock": { - const client = allClients()[0]; - if (!client) return text("mesh_pause_clock: not connected", true); - const result = await client.pauseClock(); - if (!result) return text("mesh_pause_clock: timed out", true); - return text([ - "**Clock paused**", - `Speed: x${result.speed}`, - `Tick: ${result.tick}`, - `Sim time: ${result.simTime}`, - ].join("\n")); - } - - case "mesh_resume_clock": { - const client = allClients()[0]; - if (!client) return text("mesh_resume_clock: not connected", true); - const result = await client.resumeClock(); - if (!result) return text("mesh_resume_clock: timed out", true); - return text([ - "**Clock resumed**", - `Speed: x${result.speed}`, - `Tick: ${result.tick}`, - `Sim time: ${result.simTime}`, - ].join("\n")); - } - - case "mesh_clock": { - const client = allClients()[0]; - if (!client) return text("mesh_clock: not connected", true); - const result = await client.getClock(); - if (!result) return text("mesh_clock: timed out", true); - const statusLabel = result.speed === 0 ? "not started" : result.paused ? "paused" : "running"; - return text([ - `**Clock status: ${statusLabel}**`, - `Speed: x${result.speed}`, - `Tick: ${result.tick}`, - `Sim time: ${result.simTime}`, - `Started at: ${result.startedAt}`, - ].join("\n")); - } - - case "mesh_info": { - const client = allClients()[0]; - if (!client) return text("mesh_info: not connected", true); - const info = await client.meshInfo(); - if (!info) return text("mesh_info: timed out", true); - const lines = [ - `**Mesh**: ${info.mesh}`, - `**Peers**: ${info.peers}`, - `**Groups**: ${(info.groups as string[])?.join(", ") || "none"}`, - `**State keys**: ${(info.stateKeys as string[])?.join(", ") || "none"}`, - `**Memories**: ${info.memoryCount}`, - `**Files**: ${info.fileCount}`, - `**Tasks**: open=${(info.tasks as any)?.open ?? 0}, claimed=${(info.tasks as any)?.claimed ?? 0}, done=${(info.tasks as any)?.done ?? 0}`, - `**Streams**: ${(info.streams as string[])?.join(", ") || "none"}`, - `**Tables**: ${(info.tables as string[])?.join(", ") || "none"}`, - `**Your name**: ${info.yourName}`, - `**Your groups**: ${(info.yourGroups as any[])?.map((g: any) => `@${g.name}${g.role ? ':' + g.role : ''}`).join(", ") || "none"}`, - ]; - return text(lines.join("\n")); - } - - case "mesh_stats": { - const clients = allClients(); - if (clients.length === 0) return text("mesh_stats: no joined meshes", true); - const sections: string[] = []; - for (const c of clients) { - const peers = await c.listPeers(); - const header = `## ${c.meshSlug}`; - const rows = peers.map((p) => { - const s = p.stats; - if (!s) return `| ${p.displayName} | - | - | - | - | - |`; - const up = s.uptime != null ? `${Math.floor(s.uptime / 60)}m` : "-"; - return `| ${p.displayName} | ${s.messagesIn ?? 0} | ${s.messagesOut ?? 0} | ${s.toolCalls ?? 0} | ${up} | ${s.errors ?? 0} |`; - }); - sections.push( - `${header}\n| Peer | Msgs In | Msgs Out | Tool Calls | Uptime | Errors |\n|------|---------|----------|------------|--------|--------|\n${rows.join("\n")}`, - ); - } - return text(sections.join("\n\n")); - } - - // --- Skills --- - case "share_skill": { - const { - name: skillName, description: skillDesc, instructions: skillInstr, tags: skillTags, - when_to_use, allowed_tools, model, context: skillContext, agent, user_invocable, argument_hint, - } = (args ?? {}) as { - name?: string; description?: string; instructions?: string; tags?: string[]; - when_to_use?: string; allowed_tools?: string[]; model?: string; context?: string; - agent?: string; user_invocable?: boolean; argument_hint?: string; - }; - if (!skillName || !skillDesc || !skillInstr) return text("share_skill: `name`, `description`, and `instructions` required", true); - const client = allClients()[0]; - if (!client) return text("share_skill: not connected", true); - // Build manifest from optional metadata fields - const manifest: Record<string, unknown> = {}; - if (when_to_use) manifest.when_to_use = when_to_use; - if (allowed_tools?.length) manifest.allowed_tools = allowed_tools; - if (model) manifest.model = model; - if (skillContext) manifest.context = skillContext; - if (agent) manifest.agent = agent; - if (user_invocable === false) manifest.user_invocable = false; - if (argument_hint) manifest.argument_hint = argument_hint; - const result = await client.shareSkill(skillName, skillDesc, skillInstr, skillTags, Object.keys(manifest).length > 0 ? manifest : undefined); - if (!result) return text("share_skill: broker did not acknowledge", true); - // Notify prompts changed so Claude Code refreshes slash commands - server.notification({ method: "notifications/prompts/list_changed" }); - server.notification({ method: "notifications/resources/list_changed" }); - return text(`Skill "${skillName}" published to the mesh. It will appear as /claudemesh:${skillName} in Claude Code.`); - } - case "get_skill": { - const { name: gsName } = (args ?? {}) as { name?: string }; - if (!gsName) return text("get_skill: `name` required", true); - const client = allClients()[0]; - if (!client) return text("get_skill: not connected", true); - const skill = await client.getSkill(gsName); - if (!skill) return text(`Skill "${gsName}" not found in the mesh.`); - const manifest = skill.manifest as Record<string, unknown> | null | undefined; - const metaLines: string[] = []; - if (manifest) { - if (manifest.when_to_use) metaLines.push(`**When to use:** ${manifest.when_to_use}`); - if (manifest.allowed_tools) metaLines.push(`**Allowed tools:** ${(manifest.allowed_tools as string[]).join(", ")}`); - if (manifest.model) metaLines.push(`**Model:** ${manifest.model}`); - if (manifest.context) metaLines.push(`**Context:** ${manifest.context}`); - if (manifest.agent) metaLines.push(`**Agent:** ${manifest.agent}`); - } - return text( - `# Skill: ${skill.name}\n\n` + - `**Description:** ${skill.description}\n` + - `**Author:** ${skill.author}\n` + - `**Tags:** ${skill.tags.length ? skill.tags.join(", ") : "none"}\n` + - `**Created:** ${skill.createdAt}\n` + - `**Slash command:** /claudemesh:${skill.name}\n` + - (metaLines.length ? metaLines.join("\n") + "\n" : "") + - `\n---\n\n` + - `## Instructions\n\n${skill.instructions}`, - ); - } - case "list_skills": { - const { query: skillQuery } = (args ?? {}) as { query?: string }; - const client = allClients()[0]; - if (!client) return text("list_skills: not connected", true); - const skills = await client.listSkills(skillQuery); - if (skills.length === 0) return text(skillQuery ? `No skills found for "${skillQuery}".` : "No skills in the mesh yet."); - const lines = skills.map(s => - `- **${s.name}**: ${s.description}${s.tags.length ? ` [${s.tags.join(", ")}]` : ""} (by ${s.author})`, - ); - return text(`${skills.length} skill(s):\n${lines.join("\n")}`); - } - case "remove_skill": { - const { name: rsName } = (args ?? {}) as { name?: string }; - if (!rsName) return text("remove_skill: `name` required", true); - const client = allClients()[0]; - if (!client) return text("remove_skill: not connected", true); - const removed = await client.removeSkill(rsName); - if (removed) { - server.notification({ method: "notifications/prompts/list_changed" }); - server.notification({ method: "notifications/resources/list_changed" }); - } - return text(removed ? `Skill "${rsName}" removed.` : `Skill "${rsName}" not found.`, !removed); - } - - case "ping_mesh": { - const { priorities: pingPriorities } = (args ?? {}) as { priorities?: string[] }; - const toTest = (pingPriorities ?? ["now", "next"]) as Priority[]; - const client = allClients()[0]; - if (!client) return text("ping_mesh: not connected", true); - const results: string[] = []; - - // Diagnostics: connection state - results.push(`WS status: ${client.status}`); - results.push(`Mesh: ${client.meshSlug}`); - - // Check own peer status (explains priority gating) - const peers = await client.listPeers(); - const selfPeer = peers.find(p => p.displayName === myName); - results.push(`Your status: ${selfPeer?.status ?? "not found in peer list"}`); - results.push(`Peers online: ${peers.length}`); - results.push(`Push buffer: ${client.pushHistory.length} buffered`); - - // Test send→ack latency per priority (doesn't need round-trip) - for (const prio of toTest) { - const sendTime = Date.now(); - // Send to a peer if one exists, otherwise broadcast - const target = peers.find(p => p.displayName !== myName); - const sendResult = await client.send( - target?.pubkey ?? "*", - `__ping__ ${prio} from ${myName} at ${new Date().toISOString()}`, - prio, - ); - const ackTime = Date.now(); - - if (!sendResult.ok) { - results.push(`[${prio}] SEND FAILED: ${sendResult.error}`); - } else { - results.push(`[${prio}] send→ack: ${ackTime - sendTime}ms (msgId: ${sendResult.messageId?.slice(0, 12)})`); - if (prio !== "now" && selfPeer?.status === "working") { - results.push(` ⚠ peer status is "working" — broker holds "${prio}" until idle`); - } - } - } - - // Check if notification pipeline works - results.push(""); - results.push("Pipeline check:"); - results.push(` onPush handlers: active`); - results.push(` messageMode: ${messageMode}`); - results.push(` server.notification: ${messageMode === "off" ? "disabled (mode=off)" : "enabled"}`); - - return text(results.join("\n")); - } - - // --- MCP Proxy --- - case "mesh_mcp_register": { - const { server_name, description, tools: regTools, persistent: regPersistent } = (args ?? {}) as { - server_name?: string; - description?: string; - tools?: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>; - persistent?: boolean; - }; - if (!server_name || !description || !regTools?.length) - return text("mesh_mcp_register: `server_name`, `description`, and `tools` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_register: not connected", true); - const result = await client.mcpRegister(server_name, description, regTools, regPersistent); - if (!result) return text("mesh_mcp_register: broker did not acknowledge", true); - const persistLabel = regPersistent ? " (persistent — survives disconnect)" : ""; - return text(`Registered MCP server "${result.serverName}" with ${result.toolCount} tool(s)${persistLabel}. Other peers can now call its tools via mesh_tool_call.`); - } - case "mesh_mcp_list": { - const client = allClients()[0]; - if (!client) return text("mesh_mcp_list: not connected", true); - const servers = await client.mcpList(); - if (servers.length === 0) return text("No MCP servers registered in the mesh."); - const lines = servers.map((s: any) => { - const toolList = s.tools.map((t: any) => ` - **${t.name}**: ${t.description}`).join("\n"); - const status = s.online === false - ? ` [OFFLINE${s.offlineSince ? ` since ${s.offlineSince}` : ""}]` - : ""; - return `- **${s.name}** (hosted by ${s.hostedBy})${status}: ${s.description}\n${toolList}`; - }); - return text(`${servers.length} MCP server(s) in mesh:\n${lines.join("\n")}`); - } - case "mesh_tool_call": { - const { server_name: callServer, tool_name: callTool, args: callArgs } = (args ?? {}) as { - server_name?: string; - tool_name?: string; - args?: Record<string, unknown>; - }; - if (!callServer || !callTool) - return text("mesh_tool_call: `server_name` and `tool_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_tool_call: not connected", true); - const callResult = await client.mcpCall(callServer, callTool, callArgs ?? {}); - if (callResult.error) return text(`mesh_tool_call error: ${callResult.error}`, true); - return text(typeof callResult.result === "string" ? callResult.result : JSON.stringify(callResult.result, null, 2)); - } - case "mesh_mcp_remove": { - const { server_name: rmServer } = (args ?? {}) as { server_name?: string }; - if (!rmServer) return text("mesh_mcp_remove: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_remove: not connected", true); - await client.mcpUnregister(rmServer); - return text(`Unregistered MCP server "${rmServer}" from the mesh.`); - } - - case "grant_file_access": { - const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string }; - if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true); - const client = allClients()[0]; - if (!client) return text("grant_file_access: not connected", true); - - const peers = await client.listPeers(); - const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo); - if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true); - - const result = await client.getFile(fileId); - if (!result) return text("grant_file_access: file not found", true); - if (!result.encrypted) return text("grant_file_access: file is not encrypted", true); - if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true); - - const { openSealedKey, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js"); - const myPubkey = client.getSessionPubkey(); - const mySecret = client.getSessionSecretKey(); - if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true); - - const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret); - if (!kf) return text("grant_file_access: cannot decrypt your own key", true); - - const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey); - const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer); - - if (!ok) return text("grant_file_access: broker did not confirm", true); - return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`); - } - - // --- Peer file sharing --- - case "read_peer_file": { - const { peer: peerName, path: filePath } = (args ?? {}) as { peer?: string; path?: string }; - if (!peerName || !filePath) return text("read_peer_file: `peer` and `path` required", true); - const client = allClients()[0]; - if (!client) return text("read_peer_file: not connected", true); - - // Resolve peer name to pubkey - const peers = await client.listPeers(); - const nameLower = peerName.toLowerCase(); - let targetPubkey: string | null = null; - // Direct pubkey? - if (/^[0-9a-f]{64}$/.test(peerName)) { - targetPubkey = peerName; - } else { - const match = peers.find(p => p.displayName.toLowerCase() === nameLower); - if (!match) { - const partials = peers.filter(p => p.displayName.toLowerCase().includes(nameLower)); - if (partials.length === 1) { - targetPubkey = partials[0]!.pubkey; - } else { - const names = peers.map(p => p.displayName).join(", "); - return text(`read_peer_file: peer "${peerName}" not found. Online: ${names || "(none)"}`, true); - } - } else { - targetPubkey = match.pubkey; - } - } - - // Check if peer is local — hint AI to use filesystem directly - const resolvedPeer = peers.find(p => p.pubkey === targetPubkey); - const isLocal = resolvedPeer?.hostname && resolvedPeer.hostname === require("os").hostname(); - let localHint = ""; - if (isLocal && resolvedPeer?.cwd) { - const directPath = require("path").resolve(resolvedPeer.cwd, filePath); - localHint = `\n\n> **Hint:** This peer is LOCAL (same machine). Next time, read directly: \`${directPath}\` — faster, no size limit.\n\n`; - } - - const result = await client.requestFile(targetPubkey, filePath); - if (result.error) return text(`read_peer_file: ${result.error}`, true); - if (!result.content) return text("read_peer_file: empty response from peer", true); - - // Decode base64 - try { - const decoded = Buffer.from(result.content, "base64").toString("utf-8"); - return text(localHint + decoded); - } catch { - return text("read_peer_file: failed to decode file content (binary file?)", true); - } - } - - case "list_peer_files": { - const { peer: peerName, path: dirPath, pattern } = (args ?? {}) as { peer?: string; path?: string; pattern?: string }; - if (!peerName) return text("list_peer_files: `peer` required", true); - const client = allClients()[0]; - if (!client) return text("list_peer_files: not connected", true); - - // Resolve peer name to pubkey - const peers = await client.listPeers(); - const nameLower = peerName.toLowerCase(); - let targetPubkey: string | null = null; - if (/^[0-9a-f]{64}$/.test(peerName)) { - targetPubkey = peerName; - } else { - const match = peers.find(p => p.displayName.toLowerCase() === nameLower); - if (!match) { - const partials = peers.filter(p => p.displayName.toLowerCase().includes(nameLower)); - if (partials.length === 1) { - targetPubkey = partials[0]!.pubkey; - } else { - const names = peers.map(p => p.displayName).join(", "); - return text(`list_peer_files: peer "${peerName}" not found. Online: ${names || "(none)"}`, true); - } - } else { - targetPubkey = match.pubkey; - } - } - - const result = await client.requestDir(targetPubkey, dirPath ?? ".", pattern); - if (result.error) return text(`list_peer_files: ${result.error}`, true); - if (!result.entries || result.entries.length === 0) return text("No files found."); - - return text(result.entries.join("\n")); - } - - // --- Webhooks --- - case "create_webhook": { - const { name: whName } = (args ?? {}) as { name?: string }; - if (!whName) return text("create_webhook: `name` required", true); - const client = allClients()[0]; - if (!client) return text("create_webhook: not connected", true); - const wh = await client.createWebhook(whName); - if (!wh) return text("create_webhook: broker did not acknowledge — check connection", true); - return text(`Webhook **${wh.name}** created.\n\nURL: ${wh.url}\nSecret: ${wh.secret}\n\nExternal services can POST JSON to this URL. The payload will be pushed to all connected mesh peers.`); - } - case "list_webhooks": { - const client = allClients()[0]; - if (!client) return text("list_webhooks: not connected", true); - const webhooks = await client.listWebhooks(); - if (webhooks.length === 0) return text("No active webhooks."); - const lines = webhooks.map(w => `- **${w.name}** — ${w.url} (created ${w.createdAt})`); - return text(`${webhooks.length} webhook(s):\n${lines.join("\n")}`); - } - case "delete_webhook": { - const { name: delName } = (args ?? {}) as { name?: string }; - if (!delName) return text("delete_webhook: `name` required", true); - const client = allClients()[0]; - if (!client) return text("delete_webhook: not connected", true); - const ok = await client.deleteWebhook(delName); - return text(ok ? `Webhook "${delName}" deactivated.` : `Failed to deactivate webhook "${delName}".`, !ok); - } - - // --- Vault tools --- - case "vault_set": { - const { key, value, type: vType, mount_path, description } = (args ?? {}) as { - key?: string; value?: string; type?: "env" | "file"; mount_path?: string; description?: string; - }; - if (!key || !value) return text("vault_set: `key` and `value` required", true); - if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(key)) return text("vault_set: `key` must be 1-128 alphanumeric/underscore/dot/dash chars", true); - if (mount_path && (mount_path.includes("..") || mount_path.length > 512)) return text("vault_set: invalid `mount_path`", true); - if (description && description.length > 500) return text("vault_set: `description` too long (max 500 chars)", true); - const client = allClients()[0]; - if (!client) return text("vault_set: not connected", true); - const entryType = vType ?? "env"; - - // Read plaintext - let plaintextBytes: Uint8Array; - if (entryType === "file") { - const { existsSync, readFileSync } = await import("node:fs"); - if (!existsSync(value)) return text(`vault_set: file not found: ${value}`, true); - plaintextBytes = new Uint8Array(readFileSync(value)); - } else { - plaintextBytes = new TextEncoder().encode(value); - } - - // E2E encrypt: crypto_secretbox with random Kf, then seal Kf with mesh pubkey - const { encryptFile, sealKeyForPeer } = await import("~/services/crypto/file-crypto.js"); - const { ciphertext, nonce, key: kf } = await encryptFile(plaintextBytes); - const sealedKey = await sealKeyForPeer(kf, client.getMeshPubkey()); - - // Convert ciphertext to base64 for storage - const { ensureSodium } = await import("~/services/crypto/keypair.js"); - const sodium = await ensureSodium(); - const ciphertextB64 = sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL); - - const ok = await client.vaultSet(key, ciphertextB64, nonce, sealedKey, entryType, mount_path, description); - if (!ok) return text("vault_set: broker did not acknowledge", true); - return text(`Vault entry "${key}" stored (${entryType}, E2E encrypted).`); - } - case "vault_list": { - const client = allClients()[0]; - if (!client) return text("vault_list: not connected", true); - const entries = await client.vaultList(); - if (entries.length === 0) return text("Vault is empty."); - const lines = entries.map((e: any) => - `- **${e.key}** (${e.entry_type}${e.mount_path ? ` → ${e.mount_path}` : ""})${e.description ? ` — ${e.description}` : ""} (${e.updated_at})` - ); - return text(`${entries.length} vault entry(s):\n${lines.join("\n")}`); - } - case "vault_delete": { - const { key } = (args ?? {}) as { key?: string }; - if (!key) return text("vault_delete: `key` required", true); - const client = allClients()[0]; - if (!client) return text("vault_delete: not connected", true); - const ok = await client.vaultDelete(key); - return text(ok ? `Vault entry "${key}" deleted.` : `Vault entry "${key}" not found.`); - } - - // --- Service deployment tools --- - case "mesh_mcp_deploy": { - const { server_name, file_id, git_url, git_branch, npx_package, env: deployEnv, runtime, memory_mb, network_allow, scope } = (args ?? {}) as { - server_name?: string; file_id?: string; git_url?: string; git_branch?: string; - npx_package?: string; - env?: Record<string, string>; runtime?: string; memory_mb?: number; - network_allow?: string[]; scope?: unknown; - }; - if (!server_name) return text("mesh_mcp_deploy: `server_name` required", true); - if (!file_id && !git_url && !npx_package) return text("mesh_mcp_deploy: one of `file_id`, `git_url`, or `npx_package` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_deploy: not connected", true); - const source = npx_package - ? { type: "npx" as const, package: npx_package } - : file_id - ? { type: "zip" as const, file_id } - : { type: "git" as const, url: git_url!, branch: git_branch }; - - // Resolve $vault: references in env vars — decrypt client-side - const resolvedEnv: Record<string, string> = {}; - const vaultResolved: string[] = []; - if (deployEnv) { - // Collect vault keys needed - const vaultRefs: Array<{ envKey: string; vaultKey: string; isFile: boolean; mountPath?: string }> = []; - for (const [envKey, envVal] of Object.entries(deployEnv)) { - if (typeof envVal === "string" && envVal.startsWith("$vault:")) { - const parts = envVal.slice(7).split(":"); - const vaultKey = parts[0]!; - const isFile = parts[1] === "file"; - const mountPath = isFile ? parts.slice(2).join(":") : undefined; - vaultRefs.push({ envKey, vaultKey, isFile, mountPath }); - } else { - resolvedEnv[envKey] = envVal; - } - } - - // Fetch + decrypt vault entries client-side - if (vaultRefs.length > 0) { - const { openSealedKey, decryptFile } = await import("~/services/crypto/file-crypto.js"); - const { ensureSodium } = await import("~/services/crypto/keypair.js"); - const sodium = await ensureSodium(); - - const keys = vaultRefs.map(r => r.vaultKey); - const encryptedEntries = await client.vaultGet(keys); - - for (const ref of vaultRefs) { - const entry = encryptedEntries.find((e: any) => e.key === ref.vaultKey); - if (!entry) return text(`mesh_mcp_deploy: a referenced vault key was not found. Use vault_set first.`, true); - - // Decrypt: open sealed key with mesh keypair, then decrypt ciphertext - const kf = await openSealedKey(entry.sealed_key, client.getMeshPubkey(), client.getMeshSecretKey()); - if (!kf) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — wrong keypair?`, true); - - const ciphertextBytes = sodium.from_base64(entry.ciphertext, sodium.base64_variants.ORIGINAL); - const plainBytes = await decryptFile(ciphertextBytes, entry.nonce, kf); - if (!plainBytes) return text(`mesh_mcp_deploy: failed to decrypt a vault entry — data may be corrupted`, true); - - if (ref.isFile && ref.mountPath) { - // For file-type entries: the plaintext is the file content (raw bytes). - // Encode as base64 for transport, runner writes it to mountPath. - resolvedEnv[ref.envKey] = `__vault_file__:${ref.mountPath}:${sodium.to_base64(plainBytes, sodium.base64_variants.ORIGINAL)}`; - } else { - // For env-type entries: plaintext is the secret string - resolvedEnv[ref.envKey] = new TextDecoder().decode(plainBytes); - } - vaultResolved.push(ref.vaultKey); - } - } - } - - const config: Record<string, unknown> = {}; - if (Object.keys(resolvedEnv).length > 0 || (deployEnv && Object.keys(deployEnv).length > 0)) { - config.env = Object.keys(resolvedEnv).length > 0 ? resolvedEnv : deployEnv; - } - if (runtime) config.runtime = runtime; - if (memory_mb) config.memory_mb = memory_mb; - if (network_allow) config.network_allow = network_allow; - const result = await client.mcpDeploy(server_name, source, Object.keys(config).length > 0 ? config : undefined, scope); - const toolList = result.tools?.map((t: any) => ` - ${t.name}: ${t.description}`).join("\n") ?? " (pending)"; - let vaultNote = ""; - if (vaultResolved.length > 0) { - vaultNote = `\n\nVault keys resolved: ${vaultResolved.join(", ")} (decrypted client-side, sent over TLS)`; - } - return text(`Deployed "${server_name}" (status: ${result.status}).\n\nTools:\n${toolList}\n\nDefault scope: peer (private). Use mesh_mcp_scope to share.${vaultNote}`); - } - case "mesh_mcp_undeploy": { - const { server_name } = (args ?? {}) as { server_name?: string }; - if (!server_name) return text("mesh_mcp_undeploy: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_undeploy: not connected", true); - const ok = await client.mcpUndeploy(server_name); - return text(ok ? `Service "${server_name}" undeployed.` : `Failed to undeploy "${server_name}".`); - } - case "mesh_mcp_update": { - const { server_name } = (args ?? {}) as { server_name?: string }; - if (!server_name) return text("mesh_mcp_update: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_update: not connected", true); - const result = await client.mcpUpdate(server_name); - return text(`Updated "${server_name}" (status: ${result.status}).`); - } - case "mesh_mcp_logs": { - const { server_name, lines: logLines } = (args ?? {}) as { server_name?: string; lines?: number }; - if (!server_name) return text("mesh_mcp_logs: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_logs: not connected", true); - const logs = await client.mcpLogs(server_name, logLines); - if (logs.length === 0) return text(`No logs for "${server_name}".`); - return text(`Logs for "${server_name}" (${logs.length} lines):\n\`\`\`\n${logs.join("\n")}\n\`\`\``); - } - case "mesh_mcp_scope": { - const { server_name, scope } = (args ?? {}) as { server_name?: string; scope?: unknown }; - if (!server_name) return text("mesh_mcp_scope: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_scope: not connected", true); - const result = await client.mcpScope(server_name, scope); - if (scope !== undefined) { - return text(`Scope for "${server_name}" updated to: ${JSON.stringify(result.scope)}`); - } - return text(`**${server_name}** scope: ${JSON.stringify(result.scope)}\nDeployed by: ${result.deployed_by}`); - } - case "mesh_mcp_schema": { - const { server_name, tool_name } = (args ?? {}) as { server_name?: string; tool_name?: string }; - if (!server_name) return text("mesh_mcp_schema: `server_name` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_mcp_schema: not connected", true); - const tools = await client.mcpServiceSchema(server_name, tool_name); - if (tools.length === 0) return text(`No tools found for "${server_name}"${tool_name ? ` (tool: ${tool_name})` : ""}.`); - const lines = tools.map((t: any) => - `### ${t.name}\n${t.description}\n\`\`\`json\n${JSON.stringify(t.inputSchema, null, 2)}\n\`\`\`` - ); - return text(`Tools for "${server_name}":\n\n${lines.join("\n\n")}`); - } - case "mesh_mcp_catalog": { - const client = allClients()[0]; - if (!client) return text("mesh_mcp_catalog: not connected", true); - const services = await client.mcpCatalog(); - if (services.length === 0) return text("No services deployed in the mesh."); - const lines = services.map((s: any) => { - const scopeStr = typeof s.scope === "string" ? s.scope : JSON.stringify(s.scope); - return `- **${s.name}** (${s.type}, ${s.status}) — ${s.description}\n ${s.tool_count} tools | scope: ${scopeStr} | by ${s.deployed_by} | ${s.source_type}${s.runtime ? ` (${s.runtime})` : ""}`; - }); - return text(`${services.length} service(s) in mesh:\n\n${lines.join("\n")}`); - } - case "mesh_skill_deploy": { - const { file_id, git_url, git_branch } = (args ?? {}) as { file_id?: string; git_url?: string; git_branch?: string }; - if (!file_id && !git_url) return text("mesh_skill_deploy: either `file_id` or `git_url` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_skill_deploy: not connected", true); - const source = file_id - ? { type: "zip" as const, file_id } - : { type: "git" as const, url: git_url!, branch: git_branch }; - const result = await client.skillDeploy(source); - return text(`Skill "${result.name}" deployed.\nFiles: ${result.files.join(", ")}`); - } - - // --- URL Watch --- - case "mesh_watch": { - const { url, mode, extract, interval, notify_on, headers, label } = (args ?? {}) as any; - if (!url) return text("mesh_watch: `url` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_watch: not connected", true); - const result = await client.watch(url, { mode, extract, interval, notify_on, headers, label }); - if (result.error) return text(`mesh_watch: ${result.error}`, true); - return text(`Watching "${label ?? url}" (${result.mode}, every ${result.interval}s)\nWatch ID: ${result.watchId}`); - } - case "mesh_unwatch": { - const { watch_id } = (args ?? {}) as { watch_id?: string }; - if (!watch_id) return text("mesh_unwatch: `watch_id` required", true); - const client = allClients()[0]; - if (!client) return text("mesh_unwatch: not connected", true); - await client.unwatch(watch_id); - return text(`Watch ${watch_id} stopped.`); - } - case "mesh_watches": { - const client = allClients()[0]; - if (!client) return text("mesh_watches: not connected", true); - const watches = await client.watchList(); - if (watches.length === 0) return text("No active watches."); - const lines = watches.map((w: any) => - `- **${w.id}** ${w.label ? `(${w.label}) ` : ""}${w.url}\n mode: ${w.mode} | interval: ${w.interval}s | last: ${w.lastValue?.slice(0, 30) ?? "pending"} | checked: ${w.lastCheck ?? "never"}` - ); - return text(`${watches.length} active watch(es):\n\n${lines.join("\n")}`); - } - - default: - return text(`Unknown tool: ${name}`, true); - } - }); // Start MCP transport IMMEDIATELY so Claude Code discovers tools/prompts/resources // without waiting for WS connections. Tool handlers gracefully return errors when @@ -1887,9 +541,20 @@ Your message mode is "${messageMode}". const transport = new StdioServerTransport(); await server.connect(transport); + // Bridge servers — one Unix socket per connected mesh so CLI invocations + // can reuse this push-pipe's warm WS instead of opening their own + // (~5ms warm vs ~300-700ms cold). See spec 2026-05-02 commitment #3. + const bridges: BridgeServer[] = []; + // Connect to broker WS in background — don't block MCP startup. startClients(config).then(() => { wirePushHandlers().catch(() => {}); + // Start one bridge socket per connected mesh. Done after WS connect so + // the BrokerClient is in a usable state when CLI requests arrive. + for (const client of allClients()) { + const bridge = startBridgeServer(client); + if (bridge) bridges.push(bridge); + } }).catch(() => { // Connect failed — clients are in reconnecting state, push wiring still needed wirePushHandlers().catch(() => {}); @@ -2101,6 +766,9 @@ Your message mode is "${messageMode}". const shutdown = (): void => { clearInterval(keepalive); + for (const b of bridges) { + try { b.stop(); } catch {} + } stopAll(); process.exit(0); }; diff --git a/apps/cli/src/mcp/tools/definitions.ts b/apps/cli/src/mcp/tools/definitions.ts index 5ba045e..81af8a1 100644 --- a/apps/cli/src/mcp/tools/definitions.ts +++ b/apps/cli/src/mcp/tools/definitions.ts @@ -1,1020 +1,16 @@ /** * MCP tool definitions exposed to Claude Code. * - * Mirror the claude-intercom tool surface: send_message, list_peers, - * check_messages, set_summary, set_status. Tools return "not - * connected" errors until 15b wires the WS client. + * Empty in 1.5.0: claudemesh's MCP role is a tool-less push-pipe. Inbound + * peer messages arrive as `claude/channel` notifications (still wired in + * server.ts); every other action (send, list peers, profile, vector, sql, + * task, schedule…) lives behind `claudemesh <verb>` and is taught to Claude + * via the bundled skill at ~/.claude/skills/claudemesh/SKILL.md. + * + * Spec: .artifacts/specs/2026-05-02-architecture-north-star.md commitments + * #1 (CLI is the API), #6 (MCP is a tool-less push-pipe). */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -export const TOOLS: Tool[] = [ - { - name: "send_message", - description: - "Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.", - inputSchema: { - type: "object", - properties: { - to: { - oneOf: [ - { type: "string", description: "Peer name, pubkey, @group" }, - { type: "array", items: { type: "string" }, description: "Multiple targets" }, - ], - description: "Single target or array of targets", - }, - message: { type: "string", description: "Message text" }, - priority: { - type: "string", - enum: ["now", "next", "low"], - description: "Delivery priority (default: next)", - }, - }, - required: ["to", "message"], - }, - }, - { - name: "list_peers", - description: - "List peers across all joined meshes. Shows name, mesh, status (idle/working/dnd), and current summary.", - inputSchema: { - type: "object", - properties: { - mesh_slug: { - type: "string", - description: "Only list peers in this mesh (optional)", - }, - }, - }, - }, - { - name: "message_status", - description: - "Check the delivery status of a sent message. Shows whether each recipient received it.", - inputSchema: { - type: "object", - properties: { - id: { - type: "string", - description: "Message ID (returned by send_message)", - }, - }, - required: ["id"], - }, - }, - { - name: "check_messages", - description: - "Pull any undelivered messages from the broker. Normally messages arrive via push; use this to drain the queue after being offline.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "set_summary", - description: - "Set a 1–2 sentence summary of what you're working on. Visible to other peers.", - inputSchema: { - type: "object", - properties: { - summary: { type: "string", description: "1-2 sentence summary" }, - }, - required: ["summary"], - }, - }, - { - name: "set_status", - description: - "Manually override your status. `dnd` blocks everything except `now`-priority messages.", - inputSchema: { - type: "object", - properties: { - status: { - type: "string", - enum: ["idle", "working", "dnd"], - description: "Your status", - }, - }, - required: ["status"], - }, - }, - { - name: "set_visible", - description: - "Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.", - inputSchema: { - type: "object", - properties: { - visible: { - type: "boolean", - description: "true to be visible (default), false to hide", - }, - }, - required: ["visible"], - }, - }, - { - name: "set_profile", - description: - "Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.", - inputSchema: { - type: "object", - properties: { - avatar: { - type: "string", - description: "Emoji or URL for your avatar", - }, - title: { - type: "string", - description: "Short role label (e.g. 'Frontend Lead', 'DevOps')", - }, - bio: { - type: "string", - description: "One-liner about yourself", - }, - capabilities: { - type: "array", - items: { type: "string" }, - description: "What you can help with", - }, - }, - }, - }, - { - name: "join_group", - description: - "Join a group with an optional role. Other peers see your group membership in list_peers.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Group name (without @)" }, - role: { - type: "string", - description: "Your role in the group (e.g. lead, member, observer)", - }, - }, - required: ["name"], - }, - }, - { - name: "leave_group", - description: "Leave a group.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Group name (without @)" }, - }, - required: ["name"], - }, - }, - - // --- State tools --- - { - name: "set_state", - description: - "Set a shared state value visible to all peers in the mesh. Pushes a change notification.", - inputSchema: { - type: "object", - properties: { - key: { type: "string" }, - value: { description: "Any JSON value" }, - }, - required: ["key", "value"], - }, - }, - { - name: "get_state", - description: "Read a shared state value.", - inputSchema: { - type: "object", - properties: { - key: { type: "string" }, - }, - required: ["key"], - }, - }, - { - name: "list_state", - description: "List all shared state keys and values in the mesh.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Memory tools --- - { - name: "remember", - description: - "Store persistent knowledge in the mesh's shared memory. Survives across sessions.", - inputSchema: { - type: "object", - properties: { - content: { - type: "string", - description: "The knowledge to remember", - }, - tags: { - type: "array", - items: { type: "string" }, - description: "Optional categorization tags", - }, - }, - required: ["content"], - }, - }, - { - name: "recall", - description: "Search the mesh's shared memory by relevance.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - }, - required: ["query"], - }, - }, - { - name: "forget", - description: "Remove a memory from the mesh's shared knowledge.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "Memory ID to forget" }, - }, - required: ["id"], - }, - }, - - // --- File tools --- - { - name: "share_file", - description: - "Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).", - inputSchema: { - type: "object", - properties: { - path: { type: "string", description: "Local file path to share" }, - name: { - type: "string", - description: "Display name (defaults to filename)", - }, - tags: { - type: "array", - items: { type: "string" }, - description: "Tags for categorization", - }, - to: { - type: "string", - description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only", - }, - }, - required: ["path"], - }, - }, - { - name: "get_file", - description: "Download a shared file to a local path.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "File ID" }, - save_to: { - type: "string", - description: "Local path to save the file", - }, - }, - required: ["id", "save_to"], - }, - }, - { - name: "list_files", - description: "List files shared in the mesh.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search by name or tags" }, - from: { type: "string", description: "Filter by uploader name" }, - }, - }, - }, - { - name: "file_status", - description: "Check who has accessed a shared file.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "File ID" }, - }, - required: ["id"], - }, - }, - { - name: "delete_file", - description: "Remove a shared file from the mesh.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "File ID" }, - }, - required: ["id"], - }, - }, - { - name: "grant_file_access", - description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.", - inputSchema: { - type: "object", - properties: { - fileId: { type: "string", description: "File ID" }, - to: { type: "string", description: "Peer display name or pubkey hex to grant access to" }, - }, - required: ["fileId", "to"], - }, - }, - - // --- Vector tools --- - { - name: "vector_store", - description: - "Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.", - inputSchema: { - type: "object", - properties: { - collection: { type: "string", description: "Collection name" }, - text: { type: "string", description: "Text to embed and store" }, - metadata: { - type: "object", - description: "Optional metadata to attach", - }, - }, - required: ["collection", "text"], - }, - }, - { - name: "vector_search", - description: "Semantic search over stored embeddings in a collection.", - inputSchema: { - type: "object", - properties: { - collection: { type: "string", description: "Collection name" }, - query: { type: "string", description: "Search query text" }, - limit: { - type: "number", - description: "Max results (default: 10)", - }, - }, - required: ["collection", "query"], - }, - }, - { - name: "vector_delete", - description: "Remove an embedding from a collection.", - inputSchema: { - type: "object", - properties: { - collection: { type: "string", description: "Collection name" }, - id: { type: "string", description: "Embedding ID to delete" }, - }, - required: ["collection", "id"], - }, - }, - { - name: "list_collections", - description: "List vector collections in this mesh.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Graph tools --- - { - name: "graph_query", - description: - "Run a read-only Cypher query on the per-mesh Neo4j database.", - inputSchema: { - type: "object", - properties: { - cypher: { type: "string", description: "Cypher MATCH query" }, - }, - required: ["cypher"], - }, - }, - { - name: "graph_execute", - description: - "Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.", - inputSchema: { - type: "object", - properties: { - cypher: { type: "string", description: "Cypher write query" }, - }, - required: ["cypher"], - }, - }, - - // --- Mesh Database tools --- - { - name: "mesh_query", - description: - "Run a SELECT query on the per-mesh shared database.", - inputSchema: { - type: "object", - properties: { - sql: { type: "string", description: "SQL SELECT query" }, - }, - required: ["sql"], - }, - }, - { - name: "mesh_execute", - description: - "Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).", - inputSchema: { - type: "object", - properties: { - sql: { type: "string", description: "SQL statement" }, - }, - required: ["sql"], - }, - }, - { - name: "mesh_schema", - description: - "List tables and columns in the per-mesh shared database.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Stream tools --- - { - name: "create_stream", - description: - "Create a real-time data stream in the mesh.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Stream name" }, - }, - required: ["name"], - }, - }, - { - name: "publish", - description: - "Push data to a stream. Subscribers receive it in real-time.", - inputSchema: { - type: "object", - properties: { - stream: { type: "string", description: "Stream name" }, - data: { description: "Any JSON data to publish" }, - }, - required: ["stream", "data"], - }, - }, - { - name: "subscribe", - description: - "Subscribe to a stream. Data pushes arrive as channel notifications.", - inputSchema: { - type: "object", - properties: { - stream: { type: "string", description: "Stream name" }, - }, - required: ["stream"], - }, - }, - { - name: "list_streams", - description: - "List active streams in the mesh.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Context tools --- - { - name: "share_context", - description: - "Share your session understanding with the mesh. Call after exploring a codebase area.", - inputSchema: { - type: "object", - properties: { - summary: { - type: "string", - description: "Summary of what you explored/learned", - }, - files_read: { - type: "array", - items: { type: "string" }, - description: "File paths you read", - }, - key_findings: { - type: "array", - items: { type: "string" }, - description: "Key findings or insights", - }, - tags: { - type: "array", - items: { type: "string" }, - description: "Tags for categorization", - }, - }, - required: ["summary"], - }, - }, - { - name: "get_context", - description: - "Find context from peers who explored an area. Check before re-reading files another peer already analyzed.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Search query (file path, topic, etc.)", - }, - }, - required: ["query"], - }, - }, - { - name: "list_contexts", - description: "See what all peers currently know about the codebase.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Task tools --- - { - name: "create_task", - description: "Create a work item for the mesh.", - inputSchema: { - type: "object", - properties: { - title: { type: "string", description: "Task title" }, - assignee: { - type: "string", - description: "Peer name to assign (optional)", - }, - priority: { - type: "string", - enum: ["low", "normal", "high", "urgent"], - description: "Priority level (default: normal)", - }, - tags: { - type: "array", - items: { type: "string" }, - description: "Tags for categorization", - }, - }, - required: ["title"], - }, - }, - { - name: "claim_task", - description: "Claim an unclaimed task to take ownership.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "Task ID" }, - }, - required: ["id"], - }, - }, - { - name: "complete_task", - description: "Mark a task as done with an optional result summary.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "Task ID" }, - result: { - type: "string", - description: "Summary of what was done", - }, - }, - required: ["id"], - }, - }, - { - name: "list_tasks", - description: "List tasks filtered by status and/or assignee.", - inputSchema: { - type: "object", - properties: { - status: { - type: "string", - enum: ["open", "claimed", "completed"], - description: "Filter by status", - }, - assignee: { - type: "string", - description: "Filter by assignee name", - }, - }, - }, - }, - - // --- Scheduled messages --- - { - name: "schedule_reminder", - description: - "Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.", - inputSchema: { - type: "object", - properties: { - message: { type: "string", description: "Message or reminder text" }, - deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" }, - in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" }, - cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" }, - to: { - type: "string", - description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)", - }, - }, - required: ["message"], - }, - }, - { - name: "list_scheduled", - description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "cancel_scheduled", - description: "Cancel a pending scheduled message before it fires.", - inputSchema: { - type: "object", - properties: { - id: { type: "string", description: "Scheduled message ID" }, - }, - required: ["id"], - }, - }, - - // --- Mesh info --- - { - name: "mesh_info", - description: - "Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Stats --- - { - name: "mesh_stats", - description: - "View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- MCP Proxy --- - { - name: "mesh_mcp_register", - description: - "Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.", - inputSchema: { - type: "object", - properties: { - server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" }, - description: { type: "string", description: "What this MCP server does" }, - tools: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - description: { type: "string" }, - inputSchema: { type: "object", description: "JSON Schema for tool arguments" }, - }, - required: ["name", "description", "inputSchema"], - }, - description: "Tool definitions to expose", - }, - persistent: { - type: "boolean", - description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false", - }, - }, - required: ["server_name", "description", "tools"], - }, - }, - { - name: "mesh_mcp_list", - description: - "List MCP servers available in the mesh with their tools. Shows which peer hosts each server.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "mesh_tool_call", - description: - "Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.", - inputSchema: { - type: "object", - properties: { - server_name: { type: "string", description: "Name of the MCP server" }, - tool_name: { type: "string", description: "Name of the tool to call" }, - args: { type: "object", description: "Tool arguments (JSON object)" }, - }, - required: ["server_name", "tool_name"], - }, - }, - { - name: "mesh_mcp_remove", - description: - "Unregister an MCP server you previously registered with the mesh.", - inputSchema: { - type: "object", - properties: { - server_name: { type: "string", description: "Name of the MCP server to remove" }, - }, - required: ["server_name"], - }, - }, - - - // --- Simulation clock tools --- - { - name: "mesh_set_clock", - description: - "Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.", - inputSchema: { - type: "object", - properties: { - speed: { - type: "number", - description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.", - }, - }, - required: ["speed"], - }, - }, - { - name: "mesh_pause_clock", - description: - "Pause the simulation clock. Ticks stop until resumed.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "mesh_resume_clock", - description: - "Resume a paused simulation clock.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "mesh_clock", - description: - "Get current simulation clock status: speed, tick count, simulated time.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Skills --- - { - name: "share_skill", - description: - "Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." }, - description: { type: "string", description: "Short description of what the skill does" }, - instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." }, - tags: { - type: "array", - items: { type: "string" }, - description: "Tags for discoverability", - }, - when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" }, - allowed_tools: { - type: "array", - items: { type: "string" }, - description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])", - }, - model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" }, - context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" }, - agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" }, - user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" }, - argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" }, - }, - required: ["name", "description", "instructions"], - }, - }, - { - name: "get_skill", - description: - "Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Skill name to load" }, - }, - required: ["name"], - }, - }, - { - name: "list_skills", - description: - "Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search keyword (optional)" }, - }, - }, - }, - { - name: "remove_skill", - description: - "Remove a skill you published from the mesh.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Skill name to remove" }, - }, - required: ["name"], - }, - }, - - // --- Diagnostics --- - { - name: "ping_mesh", - description: - "Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.", - inputSchema: { - type: "object", - properties: { - priorities: { - type: "array", - items: { type: "string", enum: ["now", "next", "low"] }, - description: "Priorities to test (default: [\"now\", \"next\"])", - }, - }, - }, - }, - - // --- Peer file sharing --- - { - name: "read_peer_file", - description: - "Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.", - inputSchema: { - type: "object", - properties: { - peer: { type: "string", description: "Peer display name or pubkey" }, - path: { type: "string", description: "File path relative to peer's working directory" }, - }, - required: ["peer", "path"], - }, - }, - { - name: "list_peer_files", - description: - "List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.", - inputSchema: { - type: "object", - properties: { - peer: { type: "string", description: "Peer display name or pubkey" }, - path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" }, - pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" }, - }, - required: ["peer"], - }, - }, - - // --- Webhooks --- - { - name: "create_webhook", - description: - "Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')", - }, - }, - required: ["name"], - }, - }, - { - name: "list_webhooks", - description: "List active webhooks for this mesh.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "delete_webhook", - description: "Deactivate a webhook.", - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Webhook name to deactivate" }, - }, - required: ["name"], - }, - }, - - // --- Service deployment tools --- - - { - name: "mesh_mcp_deploy", - description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).", - inputSchema: { - type: "object", - properties: { - server_name: { type: "string", description: "Unique name for the server in this mesh" }, - file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" }, - git_url: { type: "string", description: "Git repo URL" }, - git_branch: { type: "string", description: "Branch to clone (default: main)" }, - npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" }, - env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." }, - runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" }, - memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" }, - network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" }, - scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" }, - }, - required: ["server_name"], - }, - }, - { - name: "mesh_mcp_undeploy", - description: "Stop and remove a managed MCP server from the mesh.", - inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] }, - }, - { - name: "mesh_mcp_update", - description: "Pull latest code and restart a git-sourced MCP server.", - inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] }, - }, - { - name: "mesh_mcp_logs", - description: "View recent logs from a managed MCP server.", - inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] }, - }, - { - name: "mesh_mcp_scope", - description: "Get or set the visibility scope of a deployed MCP server.", - inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] }, - }, - { - name: "mesh_mcp_schema", - description: "Inspect tool schemas for a deployed MCP server.", - inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] }, - }, - { - name: "mesh_mcp_catalog", - description: "List all deployed services in the mesh with status, scope, and tool count.", - inputSchema: { type: "object", properties: {} }, - }, - - // --- Skill deployment tools --- - - { - name: "mesh_skill_deploy", - description: "Deploy a multi-file skill bundle from a zip or git repo.", - inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } }, - }, - - // --- Vault tools --- - - { - name: "vault_set", - description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.", - inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] }, - }, - { - name: "vault_list", - description: "List your vault entries (keys and metadata only, no secret values).", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "vault_delete", - description: "Remove a credential from your vault.", - inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] }, - }, - - // --- URL Watch tools --- - - { - name: "mesh_watch", - description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).", - inputSchema: { - type: "object", - properties: { - url: { type: "string", description: "URL to watch" }, - mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" }, - extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" }, - interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" }, - notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" }, - headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" }, - label: { type: "string", description: "Human-readable label for this watch" }, - }, - required: ["url"], - }, - }, - { - name: "mesh_unwatch", - description: "Stop watching a URL.", - inputSchema: { - type: "object", - properties: { watch_id: { type: "string" } }, - required: ["watch_id"], - }, - }, - { - name: "mesh_watches", - description: "List your active URL watches.", - inputSchema: { type: "object", properties: {} }, - }, -]; +export const TOOLS: Tool[] = []; diff --git a/apps/cli/src/services/bridge/client.ts b/apps/cli/src/services/bridge/client.ts new file mode 100644 index 0000000..7ff63b7 --- /dev/null +++ b/apps/cli/src/services/bridge/client.ts @@ -0,0 +1,114 @@ +/** + * Bridge client — CLI invocations dial the per-mesh Unix socket the + * MCP push-pipe holds open, so they reuse its warm WS instead of opening + * a fresh one (~5ms vs ~300-700ms). + * + * Usage from a command: + * + * const result = await tryBridge(meshSlug, "send", { to, message }); + * if (result === null) { ...fall through to cold withMesh()... } + * else { ...warm path succeeded... } + * + * `tryBridge` returns null on: + * - socket file absent (no push-pipe running) + * - socket connect fails (push-pipe crashed without cleanup) + * - bridge timeout + * That null is the caller's signal to fall back to a cold WS connection + * via `withMesh`. So the bridge is purely an optimization — every verb + * still works without it. + */ + +import { createConnection } from "node:net"; +import { existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { + socketPath, + frame, + LineParser, + type BridgeRequest, + type BridgeResponse, + type BridgeVerb, +} from "./protocol.js"; + +const DEFAULT_TIMEOUT_MS = 5_000; + +/** + * Send one request and await the matching response. Returns: + * - { ok: true, result } on success + * - { ok: false, error } on bridge-reachable-but-broker-error + * - null on bridge-unreachable (caller should fall back to cold WS) + */ +export async function tryBridge( + meshSlug: string, + verb: BridgeVerb, + args: Record<string, unknown> = {}, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise<{ ok: true; result: unknown } | { ok: false; error: string } | null> { + const path = socketPath(meshSlug); + if (!existsSync(path)) return null; + + return new Promise((resolve) => { + const id = randomUUID(); + const req: BridgeRequest = { id, verb, args }; + const parser = new LineParser(); + let settled = false; + + const finish = ( + value: { ok: true; result: unknown } | { ok: false; error: string } | null, + ): void => { + if (settled) return; + settled = true; + try { socket.destroy(); } catch {} + clearTimeout(timer); + resolve(value); + }; + + const socket = createConnection({ path }); + + const timer = setTimeout(() => { + finish(null); // timeout = bridge unreachable, fall back to cold path + }, timeoutMs); + + socket.on("connect", () => { + try { + socket.write(frame(req)); + } catch { + finish(null); + } + }); + + socket.on("data", (chunk) => { + const lines = parser.feed(chunk); + for (const line of lines) { + if (!line.trim()) continue; + let res: BridgeResponse; + try { + res = JSON.parse(line) as BridgeResponse; + } catch { + continue; + } + if (res.id !== id) continue; // not our response — keep reading + if (res.ok) finish({ ok: true, result: res.result }); + else finish({ ok: false, error: res.error }); + return; + } + }); + + socket.on("error", (err) => { + // ENOENT (file disappeared between existsSync and connect), + // ECONNREFUSED (stale socket), EPERM (permission), etc. — all mean + // bridge unreachable. + const code = (err as NodeJS.ErrnoException).code; + if (code === "ECONNREFUSED" || code === "ENOENT" || code === "EPERM") { + finish(null); + } else { + finish(null); + } + }); + + socket.on("close", () => { + // If we close without a response, treat as unreachable. + finish(null); + }); + }); +} diff --git a/apps/cli/src/services/bridge/protocol.ts b/apps/cli/src/services/bridge/protocol.ts new file mode 100644 index 0000000..7d9057c --- /dev/null +++ b/apps/cli/src/services/bridge/protocol.ts @@ -0,0 +1,93 @@ +/** + * Bridge protocol — wire format between the MCP push-pipe (server) and + * CLI invocations (client) over a per-mesh Unix domain socket. + * + * Why: every CLI op should reuse the warm WS the push-pipe already holds + * (~5ms) instead of opening its own (~300-700ms cold start). The bridge is + * the load-bearing piece of the CLI-first architecture — see + * .artifacts/specs/2026-05-02-architecture-north-star.md commitment #3. + * + * Wire format: line-delimited JSON. One JSON object per "\n"-terminated line. + * Each request carries an `id` string; the response echoes it. + * + * Socket path: ~/.claudemesh/sockets/<mesh-slug>.sock (mode 0600). + * + * Connection model: persistent. A CLI invocation opens, sends one or more + * requests, reads matching responses, then closes. Multiplexing via `id` + * means concurrent CLI calls don't have to serialize on the same socket + * (though current callers all do one round-trip and exit). + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const PROTOCOL_VERSION = 1; + +/** Socket path for a given mesh. Caller is responsible for ensuring the + * parent directory exists (`~/.claudemesh/sockets/`). */ +export function socketPath(meshSlug: string): string { + return join(homedir(), ".claudemesh", "sockets", `${meshSlug}.sock`); +} + +/** Directory holding all per-mesh sockets. Created with mode 0700 on push-pipe boot. */ +export function socketDir(): string { + return join(homedir(), ".claudemesh", "sockets"); +} + +/** + * Verbs the bridge accepts. Keep this list narrow in 1.2.0 — three writes + * (send, summary, status), the read-shaped peers, plus ping for health. + * Expand in 1.3.0 once the bridge is proven. + */ +export type BridgeVerb = + | "ping" + | "peers" + | "send" + | "summary" + | "status_set" + | "visible"; + +export interface BridgeRequest { + id: string; + verb: BridgeVerb; + args?: Record<string, unknown>; +} + +export interface BridgeResponseOk { + id: string; + ok: true; + result: unknown; +} + +export interface BridgeResponseErr { + id: string; + ok: false; + error: string; +} + +export type BridgeResponse = BridgeResponseOk | BridgeResponseErr; + +/** Serialise a request/response to a single line ("\n"-terminated). */ +export function frame(obj: BridgeRequest | BridgeResponse): string { + return JSON.stringify(obj) + "\n"; +} + +/** + * Stateful line-buffered parser. Pass each chunk from the socket via + * `feed`; collect completed lines from the returned array. + */ +export class LineParser { + private buf = ""; + + feed(chunk: Buffer | string): string[] { + this.buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + const lines: string[] = []; + let nl = this.buf.indexOf("\n"); + while (nl !== -1) { + lines.push(this.buf.slice(0, nl)); + this.buf = this.buf.slice(nl + 1); + nl = this.buf.indexOf("\n"); + } + return lines; + } +} diff --git a/apps/cli/src/services/bridge/server.ts b/apps/cli/src/services/bridge/server.ts new file mode 100644 index 0000000..aaa852c --- /dev/null +++ b/apps/cli/src/services/bridge/server.ts @@ -0,0 +1,229 @@ +/** + * Bridge server — the MCP push-pipe runs one of these per connected mesh. + * + * Listens on a Unix domain socket at `~/.claudemesh/sockets/<mesh-slug>.sock`, + * accepts line-delimited JSON requests from CLI invocations, dispatches each + * request to the corresponding `BrokerClient` method, and writes the response + * back on the same line. + * + * Lifecycle: + * - `startBridgeServer(client)` is called from the MCP push-pipe boot path + * once the WS is connected (or even before — verbs that need an open WS + * will return an error). + * - On startup it `unlinks` any stale socket file (left by a crashed + * prior process), then `listen`s. + * - On shutdown (`stop()`) it closes the listener and unlinks the socket. + * + * Concurrency: each accepted connection gets its own line-buffered parser. + * Multiple in-flight requests are correlated by `id`; the server doesn't + * need to serialize because the underlying `BrokerClient` calls are + * `async` and non-blocking. + * + * Error model: malformed lines are dropped silently (don't tear down the + * socket). Unknown verbs return `{ok: false, error: "unknown verb"}`. + * Broker errors are wrapped into the `error` string. + */ + +import { createServer, type Server, type Socket } from "node:net"; +import { mkdirSync, unlinkSync, existsSync, chmodSync } from "node:fs"; +import { dirname } from "node:path"; +import type { BrokerClient } from "~/services/broker/facade.js"; +import { + socketPath, + socketDir, + frame, + LineParser, + type BridgeRequest, + type BridgeResponse, + type BridgeVerb, +} from "./protocol.js"; + +export interface BridgeServer { + stop(): void; + path: string; +} + +type PeerStatus = "idle" | "working" | "dnd"; + +/** + * Resolve a `to` string to a broker-friendly target spec. Mirrors what + * `commands/send.ts` does today — display name → pubkey, hex stays hex, + * `@group` and `*` pass through. + */ +async function resolveTarget( + client: BrokerClient, + to: string, +): Promise<{ ok: true; spec: string } | { ok: false; error: string }> { + if (to.startsWith("@") || to === "*" || /^[0-9a-f]{64}$/i.test(to)) { + return { ok: true, spec: to }; + } + const peers = await client.listPeers(); + const match = peers.find((p) => p.displayName.toLowerCase() === to.toLowerCase()); + if (!match) { + return { + ok: false, + error: `peer "${to}" not found. online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`, + }; + } + return { ok: true, spec: match.pubkey }; +} + +async function dispatch( + client: BrokerClient, + req: BridgeRequest, +): Promise<BridgeResponse> { + const args = req.args ?? {}; + try { + switch (req.verb as BridgeVerb) { + case "ping": { + const peers = await client.listPeers(); + return { + id: req.id, + ok: true, + result: { + mesh: client.meshSlug, + ws_status: client.status, + peers_online: peers.length, + push_buffer: client.pushHistory.length, + }, + }; + } + case "peers": { + const peers = await client.listPeers(); + return { id: req.id, ok: true, result: peers }; + } + case "send": { + const to = String(args.to ?? ""); + const message = String(args.message ?? ""); + const priority = (args.priority as "now" | "next" | "low" | undefined) ?? "next"; + if (!to || !message) { + return { id: req.id, ok: false, error: "send: `to` and `message` required" }; + } + const resolved = await resolveTarget(client, to); + if (!resolved.ok) return { id: req.id, ok: false, error: resolved.error }; + const result = await client.send(resolved.spec, message, priority); + if (!result.ok) { + return { id: req.id, ok: false, error: result.error ?? "send failed" }; + } + return { + id: req.id, + ok: true, + result: { messageId: result.messageId, target: resolved.spec }, + }; + } + case "summary": { + const text = String(args.summary ?? ""); + if (!text) return { id: req.id, ok: false, error: "summary: `summary` required" }; + await client.setSummary(text); + return { id: req.id, ok: true, result: { summary: text } }; + } + case "status_set": { + const state = String(args.status ?? "") as PeerStatus; + if (!["idle", "working", "dnd"].includes(state)) { + return { id: req.id, ok: false, error: "status_set: must be idle | working | dnd" }; + } + await client.setStatus(state); + return { id: req.id, ok: true, result: { status: state } }; + } + case "visible": { + const visible = Boolean(args.visible); + await client.setVisible(visible); + return { id: req.id, ok: true, result: { visible } }; + } + default: + return { id: req.id, ok: false, error: `unknown verb: ${req.verb}` }; + } + } catch (err) { + return { + id: req.id, + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function handleConnection(socket: Socket, client: BrokerClient): void { + const parser = new LineParser(); + + socket.on("data", (chunk) => { + const lines = parser.feed(chunk); + for (const line of lines) { + if (!line.trim()) continue; + let req: BridgeRequest; + try { + req = JSON.parse(line) as BridgeRequest; + } catch { + continue; + } + if (!req || typeof req !== "object" || !req.id || !req.verb) continue; + + // Fire-and-await without blocking the read loop. + void dispatch(client, req).then((res) => { + try { + socket.write(frame(res)); + } catch { + /* socket might have closed mid-flight; ignore */ + } + }); + } + }); + + socket.on("error", () => { + // Don't crash the push-pipe on per-connection errors. + }); +} + +/** + * Start the per-mesh bridge server. Returns a handle the caller stores so + * it can `stop()` on shutdown. + * + * Idempotent: if a socket file already exists, attempts to connect to it. + * If that connection succeeds, another live process owns it — return null. + * If it fails (ECONNREFUSED), the file is stale; unlink it and proceed. + */ +export function startBridgeServer(client: BrokerClient): BridgeServer | null { + const path = socketPath(client.meshSlug); + const dir = socketDir(); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Last-writer-wins: unconditionally remove any existing socket file and + // bind fresh. A live process previously holding it keeps its already- + // accepted connections (sockets aren't path-based after connect), but + // new CLI dials hit the new server. In practice this only matters when + // two `claudemesh launch` invocations target the same mesh — rare, and + // either instance serving CLI requests is fine because both speak to + // the same broker. + if (existsSync(path)) { + try { unlinkSync(path); } catch {} + } + + const server: Server = createServer((socket) => handleConnection(socket, client)); + + try { + server.listen(path); + } catch (err) { + process.stderr.write(`[claudemesh] bridge: failed to bind ${path}: ${String(err)}\n`); + return null; + } + + server.on("error", (err) => { + process.stderr.write(`[claudemesh] bridge: ${String(err)}\n`); + }); + + // Tighten permissions so other users on the host can't dial in. + try { chmodSync(path, 0o600); } catch {} + + let stopped = false; + return { + path, + stop(): void { + if (stopped) return; + stopped = true; + try { server.close(); } catch {} + try { unlinkSync(path); } catch {} + }, + }; +} diff --git a/apps/cli/src/services/policy/index.ts b/apps/cli/src/services/policy/index.ts new file mode 100644 index 0000000..7236854 --- /dev/null +++ b/apps/cli/src/services/policy/index.ts @@ -0,0 +1,324 @@ +/** + * Policy engine — gates every CLI verb's broker call behind allow/prompt/deny + * rules evaluated against a YAML config. Modeled on Gemini CLI's `--policy / + * --admin-policy` and Codex's `--sandbox` modes. + * + * Why: when claudemesh is invoked from Claude's Bash tool, the user's + * `allowedTools = ["Bash"]` setting gives Claude carte blanche over the + * CLI. The policy engine adds a second gate INSIDE claudemesh that the + * shell-permission layer can't bypass — `claudemesh file delete` can be + * `decision: deny` regardless of whether Bash is allowed. + * + * Spec: .artifacts/specs/2026-05-02-architecture-north-star.md commitment #7. + * + * Decision tree: + * 1. Parse `--approval-mode` flag → coarse mode (plan|read-only|write|yolo). + * 2. Read user policy from --policy <path> | $CLAUDEMESH_POLICY | + * ~/.claudemesh/policy.yaml (auto-created with defaults). + * 3. Read admin policy (if any) from --admin-policy | /etc/claudemesh/admin-policy.yaml. + * Admin rules win on conflict. + * 4. For an invocation `(resource, verb, mesh)`: + * a. Coarse mode: read-only/plan deny all writes outright. + * b. Match the most-specific rule (admin > user > built-in default). + * c. Apply decision: allow | prompt | deny. + * d. On `prompt`, ask interactively unless `--yes` or yolo mode. + * + * Audit log: simple newline-JSON append-only at ~/.claudemesh/audit.log. + * Hash-chained tamper-evidence is parked for 2.x. + */ + +import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { createInterface } from "node:readline"; + +export type ApprovalMode = "plan" | "read-only" | "write" | "yolo"; + +export type Decision = "allow" | "prompt" | "deny"; + +/** A single rule. Earlier rules are matched first; the first match wins. */ +export interface PolicyRule { + /** Resource name, e.g. "send", "file", "sql". `*` matches any. */ + resource: string; + /** Verb name, e.g. "delete", "execute", "list". `*` matches any. */ + verb: string; + /** Optional mesh slug filter. Omit for all meshes. */ + mesh?: string; + /** Optional peer filter (display name, @group, or *). Currently advisory. */ + peers?: string[]; + /** What to do on match. */ + decision: Decision; + /** Free-text reason surfaced when decision is `prompt` or `deny`. */ + reason?: string; +} + +export interface Policy { + default: Decision; + rules: PolicyRule[]; +} + +/** Built-in fallback if no user/admin policy exists. Sensible defaults: + * destructive writes prompt; everything else is allowed. The user's first + * run writes this file so they can edit it. */ +export const DEFAULT_POLICY: Policy = { + default: "allow", + rules: [ + // Destructive writes — prompt the human. + { resource: "peer", verb: "kick", decision: "prompt", reason: "ends a peer's session" }, + { resource: "peer", verb: "ban", decision: "prompt", reason: "permanently revokes membership" }, + { resource: "peer", verb: "disconnect", decision: "prompt", reason: "disconnects a peer" }, + { resource: "file", verb: "delete", decision: "prompt", reason: "deletes a shared file" }, + { resource: "vector", verb: "delete", decision: "prompt", reason: "removes vector entries" }, + { resource: "vault", verb: "delete", decision: "prompt", reason: "deletes encrypted secret" }, + { resource: "memory", verb: "forget", decision: "prompt", reason: "removes shared memory" }, + { resource: "skill", verb: "remove", decision: "prompt", reason: "removes published skill" }, + { resource: "webhook", verb: "delete", decision: "prompt", reason: "removes webhook integration" }, + { resource: "watch", verb: "remove", decision: "prompt", reason: "removes URL watcher" }, + { resource: "sql", verb: "execute", decision: "prompt", reason: "raw SQL write to mesh DB" }, + { resource: "graph", verb: "execute", decision: "prompt", reason: "graph mutation" }, + { resource: "mesh", verb: "delete", decision: "prompt", reason: "deletes the mesh for everyone" }, + ], +}; + +const USER_POLICY_PATH = join(homedir(), ".claudemesh", "policy.yaml"); +const AUDIT_LOG_PATH = join(homedir(), ".claudemesh", "audit.log"); + +/** + * Minimal YAML parser for our policy format. Accepts the shape: + * default: allow|prompt|deny + * rules: + * - resource: peer + * verb: kick + * mesh: flexicar # optional + * peers: ["@admin"] # optional + * decision: prompt + * reason: "..." # optional + * + * We avoid pulling in a real YAML dep (zero-dep CLI). For complex configs + * users can pre-process to JSON; we accept that too via .json extension. + */ +export function parsePolicyYaml(text: string): Policy { + // If the file is JSON, parse directly. + const trimmed = text.trim(); + if (trimmed.startsWith("{")) { + return JSON.parse(trimmed) as Policy; + } + + const policy: Policy = { default: "allow", rules: [] }; + const lines = text.split("\n"); + let cur: Partial<PolicyRule> | null = null; + const flush = (): void => { + if (cur && cur.resource && cur.verb && cur.decision) { + policy.rules.push(cur as PolicyRule); + } + cur = null; + }; + + for (const raw of lines) { + const line = raw.replace(/#.*$/, "").trimEnd(); + if (!line.trim()) continue; + + const top = line.match(/^(default):\s*(\S+)/); + if (top) { + policy.default = top[2] as Decision; + continue; + } + + if (/^rules\s*:/.test(line)) continue; + + // New rule entry: starts with " -" or "- " + if (/^\s*-\s/.test(line)) { + flush(); + cur = {}; + const m = line.match(/-\s*(\w+)\s*:\s*(.*)$/); + if (m) (cur as Record<string, unknown>)[m[1]!] = parseValue(m[2]!); + continue; + } + + // Continuation key/value within a rule: " key: value" + const kv = line.match(/^\s+(\w+)\s*:\s*(.*)$/); + if (kv && cur) { + (cur as Record<string, unknown>)[kv[1]!] = parseValue(kv[2]!); + } + } + flush(); + + return policy; +} + +function parseValue(raw: string): string | string[] | boolean | number { + const v = raw.trim(); + if (!v) return ""; + // Inline array: ["a", "b"] + if (v.startsWith("[") && v.endsWith("]")) { + return v + .slice(1, -1) + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + } + // Quoted string + const q = v.match(/^["'](.*)["']$/); + if (q) return q[1]!; + // Bools / numbers + if (v === "true") return true; + if (v === "false") return false; + if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); + return v; +} + +/** Serialise a Policy as YAML. */ +export function serializePolicyYaml(p: Policy): string { + let out = `# claudemesh policy file\n`; + out += `# Edit to change which CLI ops require confirmation or are forbidden.\n`; + out += `# Decisions: allow | prompt | deny\n`; + out += `# See: ~/.claude/skills/claudemesh/SKILL.md or claudemesh policy --help\n\n`; + out += `default: ${p.default}\n\n`; + out += `rules:\n`; + for (const r of p.rules) { + out += ` - resource: ${r.resource}\n`; + out += ` verb: ${r.verb}\n`; + if (r.mesh) out += ` mesh: ${r.mesh}\n`; + if (r.peers) out += ` peers: [${r.peers.map((p) => `"${p}"`).join(", ")}]\n`; + out += ` decision: ${r.decision}\n`; + if (r.reason) out += ` reason: "${r.reason}"\n`; + } + return out; +} + +/** Load the user's policy, creating the default on first run. */ +export function loadPolicy(opts?: { policyPath?: string; envOverride?: string }): Policy { + const path = + opts?.policyPath ?? + opts?.envOverride ?? + process.env.CLAUDEMESH_POLICY ?? + USER_POLICY_PATH; + + if (!existsSync(path)) { + // First run — write defaults so the user can discover/edit them. + if (path === USER_POLICY_PATH) { + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, serializePolicyYaml(DEFAULT_POLICY), "utf-8"); + } catch { /* best effort */ } + } + return DEFAULT_POLICY; + } + + try { + return parsePolicyYaml(readFileSync(path, "utf-8")); + } catch (e) { + process.stderr.write( + `[claudemesh] policy: failed to parse ${path}: ${e instanceof Error ? e.message : String(e)}\n`, + ); + return DEFAULT_POLICY; + } +} + +/** Match wildcards: `*` in the rule matches anything. */ +function matches(rule: string, value: string): boolean { + if (rule === "*") return true; + return rule === value; +} + +export interface CheckContext { + resource: string; + verb: string; + mesh?: string; + /** Coarse mode from --approval-mode (or default 'write'). */ + mode: ApprovalMode; + /** True if the verb is destructive (kick/ban/delete/forget/execute/etc). */ + isWrite: boolean; + /** If true, prompt-decisions are auto-approved (e.g. -y / yolo). */ + yes: boolean; +} + +export interface CheckResult { + decision: Decision; + reason?: string; + matchedRule?: PolicyRule; +} + +/** Evaluate a policy against a check context. Pure — no I/O. */ +export function evaluate(policy: Policy, ctx: CheckContext): CheckResult { + // Coarse approval-mode short-circuits. + if (ctx.mode === "yolo") return { decision: "allow", reason: "yolo mode" }; + if ((ctx.mode === "plan" || ctx.mode === "read-only") && ctx.isWrite) { + return { decision: "deny", reason: `${ctx.mode} mode forbids writes` }; + } + + for (const r of policy.rules) { + if (!matches(r.resource, ctx.resource)) continue; + if (!matches(r.verb, ctx.verb)) continue; + if (r.mesh && ctx.mesh && r.mesh !== ctx.mesh) continue; + return { decision: r.decision, reason: r.reason, matchedRule: r }; + } + return { decision: policy.default }; +} + +/** Append a one-line JSON record to ~/.claudemesh/audit.log. */ +export function audit(record: Record<string, unknown>): void { + try { + mkdirSync(dirname(AUDIT_LOG_PATH), { recursive: true }); + appendFileSync( + AUDIT_LOG_PATH, + JSON.stringify({ ts: new Date().toISOString(), ...record }) + "\n", + "utf-8", + ); + } catch { /* best effort */ } +} + +/** + * Interactive prompt for `prompt` decisions. Returns true if the user + * approves. In a non-TTY context (cron, scripts) returns false to be safe — + * the user must opt in via `--approval-mode yolo` or a `decision: allow` + * rule. + */ +export async function confirmPrompt(message: string): Promise<boolean> { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return false; + } + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + resolve(a === "y" || a === "yes"); + }); + }); +} + +/** + * One-stop check: load policy, evaluate, audit, prompt if needed. Returns + * `true` if the operation may proceed, `false` if blocked. + * + * Callers pass in `ctx` with the current invocation. They should `return` + * (or `process.exit`) when this returns false. + */ +export async function gate(ctx: CheckContext, opts?: { policyPath?: string }): Promise<boolean> { + const policy = loadPolicy(opts); + const result = evaluate(policy, ctx); + + audit({ ...ctx, decision: result.decision, reason: result.reason }); + + if (result.decision === "allow") return true; + if (result.decision === "deny") { + process.stderr.write( + `\n ✘ blocked by policy: ${ctx.resource} ${ctx.verb}` + + (result.reason ? ` — ${result.reason}` : "") + + `\n edit ${USER_POLICY_PATH} to change.\n`, + ); + return false; + } + // prompt + if (ctx.yes) return true; + const reason = result.reason ? ` — ${result.reason}` : ""; + const confirmed = await confirmPrompt( + `\n ⚠ ${ctx.resource} ${ctx.verb}${reason}. Continue?`, + ); + if (!confirmed) { + process.stderr.write(` cancelled.\n`); + audit({ ...ctx, decision: "cancelled-at-prompt" }); + } + return confirmed; +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 360a381..81b9dd9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -56,6 +56,33 @@ end-to-end crypto backup, per-peer capability grants, self-update. --- +## v1.5.0 — *shipped* + +CLI-first architecture lock-in. The CLI becomes the API; MCP becomes a +tool-less push-pipe. Spec: +`.artifacts/specs/2026-05-02-architecture-north-star.md`. + +- **Tool-less MCP** — `tools/list` returns `[]`. Inbound peer messages still + arrive as `experimental.claude/channel` notifications mid-turn. Bundle size + -42% (250 KB → 146 KB). +- **Resource-noun-verb CLI** — `peer list`, `message send`, `memory recall`, + etc. Legacy flat verbs (`peers`, `send`, `remember`) remain as aliases. +- **Bundled `claudemesh` skill** — installed to `~/.claude/skills/claudemesh/` + by `claudemesh install`. Sole CLI-discoverability surface for Claude. +- **Unix-socket bridge** — CLI invocations dial + `~/.claudemesh/sockets/<slug>.sock` to reuse the push-pipe's warm WS + (~220 ms warm vs ~600 ms cold). +- **`--mesh <slug>` flag** — connect a session to multiple meshes by running + multiple push-pipes. +- **Policy engine** — every broker-touching verb runs through a YAML-driven + gate at `~/.claudemesh/policy.yaml` (auto-created with sensible defaults). + Destructive verbs prompt; non-TTY auto-denies. Audit log at + `~/.claudemesh/audit.log`. +- **`--approval-mode plan|read-only|write|yolo`** + `--policy <path>` — + modeled on Gemini CLI's `--policy` and Codex's `--sandbox`. + +--- + ## v0.2.0 — *next* The surface layer. The protocol is ready; these are gateways + routing