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

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

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

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

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

View File

@@ -0,0 +1,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 <slug>` 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<void> {
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 <to> <msg> [--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 <id>` (new) | One-shot lookup. |
| profile.ts | `set_profile` | `claudemesh profile --avatar X --bio Y ...` | Pure write. |
| profile.ts | `set_status` | `claudemesh status set <state>` (new) | Pure write. |
| profile.ts | `set_summary` | `claudemesh summary <text>` (new) | Pure write. |
| profile.ts | `set_visible` | `claudemesh visible <true\|false>` (new) | Pure write. |
| groups.ts | `join_group` | `claudemesh group join @<name> [--role X]` (new) | Pure write. |
| groups.ts | `leave_group` | `claudemesh group leave @<name>` (new) | Pure write. |
| state.ts | `get_state` | `claudemesh state get <key> --json` | Already exists. |
| state.ts | `set_state` | `claudemesh state set <key> <value>` | Already exists. |
| state.ts | `list_state` | `claudemesh state list --json` | Already exists. |
| memory.ts | `remember` | `claudemesh remember <text>` | Already exists. |
| memory.ts | `recall` | `claudemesh recall <query> --json` | Already exists. |
| memory.ts | `forget` | `claudemesh forget <id>` (new) | Pure write. |
| scheduling.ts | `schedule_reminder` | `claudemesh remind <msg> --in/--at/--cron` | Already exists. |
| scheduling.ts | `list_scheduled` | `claudemesh remind list --json` | Already exists. |
| scheduling.ts | `cancel_scheduled` | `claudemesh remind cancel <id>` | 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 <id>` (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<ToolResult> {
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.

View File

@@ -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 <slug>]`) 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 @<n> [--role X]` |
| message | `claudemesh send <to> <msg>`, `claudemesh inbox`, `claudemesh msg-status <id>` |
| 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 <slug>`** 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/<mesh-slug>.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 <fields>` — 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 <expr>` — 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 <file>`** — 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/<mesh>.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 `<channel source="claudemesh" ...>` 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 `<resource> <verb>`. 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 <verb> --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 <peer> "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.

View File

@@ -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 `<channel>` 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

View File

@@ -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"
],

View File

@@ -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 `<channel source="claudemesh">` interrupts mid-turn. Everything else is CLI.
## When you receive a `<channel source="claudemesh">` 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>` 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/<mesh-slug>.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 <slug>` | 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 <mode>` | `plan` / `read-only` deny all writes; `write` (default) prompts on destructive verbs from the policy file; `yolo` bypasses every prompt. |
| `--policy <path>` | 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 <resource> <verb>`. 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 <peer> # end session, manual rejoin
claudemesh peer disconnect <peer> # soft, peer auto-reconnects
claudemesh peer ban <peer> # kick + revoke membership
claudemesh peer unban <peer>
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 <to> <msg>)
claudemesh message send <peer-name|@group|*|pubkey> "message text"
claudemesh message send Mou "hi" # by display name
claudemesh message send "@reviewers" "ready for review"
claudemesh message send "*" "broadcast"
claudemesh message send <p> "..." --priority now # bypass busy gates
claudemesh message send <p> "..." --priority next # default
claudemesh message send <p> "..." --priority low # pull-only
# inbox (alias: claudemesh inbox)
claudemesh message inbox
claudemesh message inbox --json
# delivery status (alias: claudemesh msg-status <id>)
claudemesh message status <message-id>
claudemesh message status <message-id> --json
```
`send` JSON output: `{"ok": true, "messageId": "...", "target": "..."}`. Errors: `{"ok": false, "error": "..."}`.
### `state` — shared per-mesh key-value store
```bash
claudemesh state set <key> <value> # value can be JSON or string
claudemesh state get <key>
claudemesh state get <key> --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 <memory-id> # 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 "<title>" --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`

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -1,22 +1,23 @@
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
export async function connectTelegram(args: string[]): Promise<void> {
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run 'claudemesh join' first.");
render.err("No meshes joined.", "Run `claudemesh join` first.");
process.exit(1);
}
const mesh = config.meshes[0]!;
const linkOnly = args.includes("--link");
// Convert WS broker URL to HTTP
const brokerHttp = mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
console.log("Requesting Telegram connect token...");
render.info(dim("Requesting Telegram connect token…"));
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
@@ -32,7 +33,7 @@ export async function connectTelegram(args: string[]): Promise<void> {
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
render.err(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
@@ -46,20 +47,18 @@ export async function connectTelegram(args: string[]): Promise<void> {
return;
}
// Print QR code using simple block characters
console.log("\n Connect Telegram to your mesh:\n");
console.log(` ${deepLink}\n`);
console.log(" Open this link on your phone, or scan the QR code");
console.log(" with your Telegram camera.\n");
render.section("connect Telegram to your mesh");
render.link(deepLink);
render.blank();
render.info(dim("Open this link on your phone, or scan the QR code with your Telegram camera."));
render.blank();
// Try to generate QR with qrcode-terminal if available
try {
const QRCode = require("qrcode-terminal");
QRCode.generate(deepLink, { small: true }, (code: string) => {
console.log(code);
});
} catch {
// qrcode-terminal not available, link is enough
console.log(" (Install qrcode-terminal for QR code display)");
render.info(dim("(Install qrcode-terminal for QR code display)"));
}
}

View File

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

View File

@@ -30,6 +30,8 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim, yellow } from "~/ui/styles.js";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
@@ -149,18 +151,80 @@ function bunAvailable(): boolean {
return res.status === 0;
}
/** Is this file running from a bundled `dist/` directory? */
function isBundledFile(p: string): boolean {
// Match any file under dist/ — e.g. dist/index.js or dist/entrypoints/cli.js.
return /[/\\]dist[/\\]/.test(p);
}
/** Absolute path to this CLI's entry file. */
function resolveEntry(): string {
const here = fileURLToPath(import.meta.url);
// When bundled (dist/index.js), this file IS the entry return self.
// When running from source (src/index.ts via bun), walk up to the
// dir + resolve index.ts.
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
return here;
}
// Bundled: this file IS reachable as the entry; return self.
// Source: walk up to apps/cli/src/index.ts (legacy) or fall back.
if (isBundledFile(here)) return here;
return resolve(dirname(here), "..", "index.ts");
}
/** Find the bundled `skills/` directory at install time. Walks up from
* the entry file: dist/entrypoints/cli.js → dist/ → package root → skills/. */
function resolveBundledSkillsDir(): string | null {
const here = fileURLToPath(import.meta.url);
// Bundled: <pkg>/dist/entrypoints/cli.js → walk up two levels to <pkg>
// Source: <pkg>/src/commands/install.ts → walk up two levels to <pkg>
const pkgRoot = resolve(dirname(here), "..", "..");
const skillsDir = join(pkgRoot, "skills");
if (existsSync(skillsDir)) return skillsDir;
return null;
}
/** ~/.claude/skills/ — where Claude Code looks for user-scoped skills. */
const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
/**
* Copy bundled skills into ~/.claude/skills/. Idempotent — overwrites
* existing files (so updates flow through on `claudemesh install` re-run).
* Returns the list of skill names installed.
*/
function installSkills(): string[] {
const src = resolveBundledSkillsDir();
if (!src) return [];
// Each subdirectory of skills/ is one skill (matches Claude Code convention).
const fs = require("node:fs") as typeof import("node:fs");
const installed: string[] = [];
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const srcDir = join(src, entry.name);
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
mkdirSync(dstDir, { recursive: true });
for (const file of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (!file.isFile()) continue;
copyFileSync(join(srcDir, file.name), join(dstDir, file.name));
}
installed.push(entry.name);
}
return installed;
}
/** Remove claudemesh-shipped skills from ~/.claude/skills/. Returns names removed. */
function uninstallSkills(): string[] {
const src = resolveBundledSkillsDir();
if (!src) return [];
const fs = require("node:fs") as typeof import("node:fs");
const removed: string[] = [];
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
if (existsSync(dstDir)) {
try {
fs.rmSync(dstDir, { recursive: true, force: true });
removed.push(entry.name);
} catch { /* best effort */ }
}
}
return removed;
}
/**
* Build the MCP server entry for Claude Code's config.
*
@@ -170,9 +234,7 @@ function resolveEntry(): string {
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
*/
function buildMcpEntry(entryPath: string): McpEntry {
const isBundled = entryPath.endsWith("/dist/index.js") ||
entryPath.endsWith("\\dist\\index.js");
if (isBundled) {
if (isBundledFile(entryPath)) {
return {
command: "claudemesh",
args: ["mcp"],
@@ -374,191 +436,181 @@ function installStatusLine(): { installed: boolean } {
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const skipSkill = args.includes("--no-skill");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
render.section("claudemesh install");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
const bundled = isBundledFile(entry);
// Dev mode (running from src/) requires bun on PATH; bundled mode
// (npm install -g) just uses node + the claudemesh bin shim.
if (!isBundled && !bunAvailable()) {
console.error(
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
);
if (!bundled && !bunAvailable()) {
render.err("`bun` is not on PATH.", "Install Bun first: https://bun.com");
process.exit(1);
}
if (!existsSync(entry)) {
console.error(`MCP entry not found at ${entry}`);
render.err(`MCP entry not found at ${entry}`);
process.exit(1);
}
const desired = buildMcpEntry(entry);
const action = patchMcpServer(desired);
// Read-back verification.
const verify = readClaudeConfig();
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
const stored = verifyServers[MCP_NAME];
if (!stored || !entriesEqual(stored, desired)) {
console.error(
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
);
render.err("post-write verification failed", `${CLAUDE_CONFIG} may be corrupt`);
process.exit(1);
}
// ANSI color helpers — stick to 8-color set so terminals without
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
render.ok(`MCP server "${bold(MCP_NAME)}" ${action}`);
render.kv([
["config", dim(CLAUDE_CONFIG)],
["command", dim(`${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`)],
]);
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
console.log(dim(` config: ${CLAUDE_CONFIG}`));
console.log(
dim(
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
),
);
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
// --dangerously-skip-permissions just to call mesh tools.
try {
const { added, unchanged } = installAllowedTools();
if (added.length > 0) {
console.log(
`allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
render.ok(
`allowedTools: ${added.length} claudemesh tools pre-approved`,
unchanged > 0 ? `${unchanged} already present` : undefined,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
render.info(dim("This lets claudemesh tools run without --dangerously-skip-permissions."));
render.info(dim("Your existing allowedTools entries were preserved."));
} else {
console.log(`allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
render.ok(`allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
render.info(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`);
}
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
if (!skipHooks) {
try {
const { added, unchanged } = installHooks();
if (added > 0) {
console.log(
`Hooks registered (Stop + UserPromptSubmit)${added} added, ${unchanged} already present`,
render.ok(
`Hooks registered (Stop + UserPromptSubmit)`,
`${added} added, ${unchanged} already present`,
);
} else {
console.log(`Hooks already registered (${unchanged} present)`);
render.ok(`Hooks already registered`, `${unchanged} present`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
render.info(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
);
console.error(
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
render.warn(
`hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
"MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.",
);
}
} else {
console.log(dim("· Hooks skipped (--no-hooks)"));
render.info(dim("· Hooks skipped (--no-hooks)"));
}
// Claude skill — discoverability replacement for the (now-empty) MCP
// tool surface. Claude reads ~/.claude/skills/claudemesh/SKILL.md on
// demand, learns every CLI verb, JSON shape, and gotcha. See spec
// 2026-05-02 commitment #6.
if (!skipSkill) {
try {
const installed = installSkills();
if (installed.length > 0) {
render.ok(
`Claude skill${installed.length === 1 ? "" : "s"} installed`,
installed.join(", "),
);
render.info(dim(` ${join(CLAUDE_SKILLS_ROOT, installed[0]!)}/SKILL.md`));
}
} catch (e) {
render.warn(`skill install failed: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
render.info(dim("· Skill install skipped (--no-skill)"));
}
// Opt-in status line (shows mesh + peer count in Claude Code).
if (wantStatusLine) {
try {
const { installed } = installStatusLine();
if (installed) {
console.log(`Claude Code statusLine → \`claudemesh status-line\``);
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
render.ok(`Claude Code statusLine → ${clay("claudemesh status-line")}`);
render.info(dim(" Shows: ◇ <mesh> · <online>/<total> online · <you>"));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
render.info(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
render.warn(`statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
// Check if user has any meshes joined — nudge them if not.
let hasMeshes = false;
try {
const meshConfig = readConfig();
hasMeshes = meshConfig.meshes.length > 0;
} catch {
// Config missing or corrupt — treat as no meshes.
}
} catch {}
console.log("");
console.log(yellow(bold("RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
render.blank();
render.warn(`${bold("RESTART CLAUDE CODE")} ${yellow("for MCP tools to appear.")}`);
if (!hasMeshes) {
console.log("");
console.log(yellow("No meshes joined.") + " To connect with peers:");
console.log(
` ${bold("claudemesh <invite-url>")}` +
dim(" — joins + launches in one step"),
);
console.log(
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
);
render.blank();
render.info(`${yellow("No meshes joined.")} To connect with peers:`);
render.info(` ${bold("claudemesh <invite-url>")}${dim(" — joins + launches in one step")}`);
render.info(` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`);
} else {
console.log("");
console.log(
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
);
render.blank();
render.info(`Next: ${bold("claudemesh")}${dim(" — launch with your joined mesh")}`);
}
console.log("");
console.log(dim("Optional:"));
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
console.log(dim(` claudemesh completions zsh # shell completions`));
render.blank();
render.info(dim("Optional:"));
render.info(dim(` claudemesh url-handler install # click-to-launch from email`));
render.info(dim(` claudemesh install --status-line # live peer count in Claude Code`));
render.info(dim(` claudemesh completions zsh # shell completions`));
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
render.section("claudemesh uninstall");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`MCP server "${MCP_NAME}" removed`);
render.ok(`MCP server "${bold(MCP_NAME)}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
render.info(dim(`· MCP server "${MCP_NAME}" not present`));
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`allowedTools: ${removed} claudemesh tools removed`);
render.ok(`allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
render.info(dim("· No claudemesh allowedTools to remove"));
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`Hooks removed (${removed} entries)`);
render.ok(`Hooks removed`, `${removed} entries`);
} else {
console.log("· No claudemesh hooks to remove");
render.info(dim("· No claudemesh hooks to remove"));
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
render.warn(`hook removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
try {
const removed = uninstallSkills();
if (removed.length > 0) {
render.ok(`Skill${removed.length === 1 ? "" : "s"} removed`, removed.join(", "));
} else {
render.info(dim("· No claudemesh skills to remove"));
}
} catch (e) {
render.warn(`skill removal failed: ${e instanceof Error ? e.message : String(e)}`);
}
render.blank();
render.info("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

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

View File

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

View File

@@ -6,7 +6,8 @@ import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { request } from "~/services/api/facade.js";
import { URLS } from "~/constants/urls.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
import { bold, clay, dim, green, yellow } from "~/ui/styles.js";
import { render } from "~/ui/render.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
@@ -45,26 +46,26 @@ export async function runList(): Promise<void> {
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
if (allSlugs.size === 0) {
console.log("\n No meshes yet.\n");
console.log(" Create one: claudemesh mesh create <name>");
console.log(" Join one: claudemesh mesh add <invite-url>\n");
render.section("no meshes yet");
render.info(`${dim("create one:")} ${bold("claudemesh create")} ${clay("<name>")}`);
render.info(`${dim("join one:")} ${bold("claudemesh")} ${clay("<invite-url>")}`);
render.blank();
return;
}
console.log("\n Your meshes:\n");
render.section(`your meshes (${allSlugs.size})`);
for (const slug of allSlugs) {
const local = config.meshes.find(m => m.slug === slug);
const server = serverMeshes.find(m => m.slug === slug);
const local = config.meshes.find((m) => m.slug === slug);
const server = serverMeshes.find((m) => m.slug === slug);
const name = server?.name ?? local?.name ?? slug;
const role = server?.role ?? "member";
const isOwner = server?.is_owner ?? false;
const roleLabel = isOwner ? "owner" : role;
const roleLabel = isOwner ? clay("owner") : dim(role);
const memberCount = server?.member_count;
const activePeers = server?.active_peers ?? 0;
// Status indicator
const inLocal = localSlugs.has(slug);
const inServer = serverSlugs.has(slug);
let status: string;
@@ -84,14 +85,14 @@ export async function runList(): Promise<void> {
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
const parts = [roleLabel, memberInfo, status].filter(Boolean);
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
console.log(` ${parts.join(" · ")}`);
process.stdout.write(` ${icon} ${bold(name)} ${dim(slug)}\n`);
process.stdout.write(` ${parts.join(dim(" · "))}\n`);
}
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
process.stdout.write("\n");
if (serverMeshes.some((m) => !localSlugs.has(m.slug))) {
render.hint(`${dim("○")} = server only — run ${bold("claudemesh join")} to use locally`);
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
render.hint(`config: ${dim(getConfigPath())}`);
render.blank();
}

View File

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

View File

@@ -1,6 +1,7 @@
import { create as createMesh } from "~/services/mesh/facade.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { green, dim, icons } from "~/ui/styles.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function newMesh(
@@ -8,16 +9,17 @@ export async function newMesh(
opts: { template?: string; description?: string; json?: boolean },
): Promise<number> {
if (!name) {
console.error(" Usage: claudemesh mesh create <name>");
render.err("Usage: claudemesh create <name>");
return EXIT.INVALID_ARGS;
}
if (!getStoredToken()) {
console.log(dim(" Not signed in — starting login…\n"));
render.info(dim("not signed in — starting login…"));
render.blank();
const { login } = await import("./login.js");
const loginResult = await login();
if (loginResult !== EXIT.SUCCESS) return loginResult;
console.log("");
render.blank();
}
try {
@@ -28,20 +30,26 @@ export async function newMesh(
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
} else {
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
console.log(` ${green(icons.check)} You're the owner`);
console.log(` ${green(icons.check)} Joined locally`);
console.log(`\n Share with: claudemesh mesh share\n`);
return EXIT.SUCCESS;
}
render.section(`created ${bold(result.slug)}`);
render.kv([
["id", dim(result.id)],
["role", clay("owner")],
["local", "joined"],
]);
render.blank();
render.hint(`share with: ${bold("claudemesh share")}`);
render.blank();
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("409") || msg.includes("already exists")) {
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
render.err("A mesh with this name already exists.", "Try a different name.");
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
render.err(`Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,30 @@
import { allClients } from "~/services/broker/facade.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function remember(
content: string,
opts: { mesh?: string; tags?: string; json?: boolean } = {},
): Promise<number> {
const client = allClients()[0];
if (!client) {
console.error("Not connected to any mesh.");
return EXIT.NETWORK_ERROR;
if (!content) {
render.err("Usage: claudemesh remember <text>");
return EXIT.INVALID_ARGS;
}
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
const id = await client.remember(content, tags);
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const id = await client.remember(content, tags);
if (opts.json) {
console.log(JSON.stringify({ id, content, tags }));
return EXIT.SUCCESS;
}
if (opts.json) {
console.log(JSON.stringify({ id, content, tags }));
return EXIT.SUCCESS;
}
if (id) {
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
return EXIT.SUCCESS;
}
console.error("\u2717 Failed to store memory");
return EXIT.INTERNAL_ERROR;
if (id) {
render.ok("remembered", dim(id.slice(0, 8)));
return EXIT.SUCCESS;
}
render.err("failed to store memory");
return EXIT.INTERNAL_ERROR;
});
}

View File

@@ -7,6 +7,8 @@
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
export interface RemindFlags {
mesh?: string;
@@ -35,13 +37,12 @@ function parseDeliverAt(flags: RemindFlags): number | null {
return Date.now() + ms;
}
if (flags.at) {
// Try HH:MM first
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
if (hm) {
const now = new Date();
const target = new Date(now);
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
if (target <= now) target.setDate(target.getDate() + 1);
return target.getTime();
}
const ts = Date.parse(flags.at);
@@ -54,61 +55,53 @@ export async function runRemind(
flags: RemindFlags,
positional: string[],
): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const action = positional[0];
// claudemesh remind list
if (action === "list") {
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const scheduled = await client.listScheduled();
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
if (scheduled.length === 0) { render.info(dim("No pending reminders.")); return; }
render.section(`reminders (${scheduled.length})`);
for (const m of scheduled) {
const when = new Date(m.deliverAt).toLocaleString();
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
console.log(` ${bold(m.id.slice(0, 8))} ${to} at ${when}`);
console.log(` ${dim(m.message.slice(0, 80))}`);
console.log("");
process.stdout.write(` ${bold(m.id.slice(0, 8))} ${dim("→")} ${to} ${dim("at")} ${when}\n`);
process.stdout.write(` ${dim(m.message.slice(0, 80))}\n\n`);
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
if (!id) { render.err("Usage: claudemesh remind cancel <id>"); process.exit(1); }
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const ok = await client.cancelScheduled(id);
if (ok) console.log(`✓ Cancelled ${id}`);
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
if (ok) render.ok(`cancelled ${bold(id.slice(0, 8))}`);
else { render.err(`not found or already fired: ${id}`); process.exit(1); }
});
return;
}
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
const message = action ?? positional.join(" ");
if (!message) {
console.error("Usage: claudemesh remind <message> --in <duration>");
console.error(" claudemesh remind <message> --at <time>");
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
console.error(" claudemesh remind list");
console.error(" claudemesh remind cancel <id>");
render.err("Usage: claudemesh remind <message> --in <duration>");
render.info(dim(" claudemesh remind <message> --at <time>"));
render.info(dim(' claudemesh remind <message> --cron "0 */2 * * *"'));
render.info(dim(" claudemesh remind list"));
render.info(dim(" claudemesh remind cancel <id>"));
process.exit(1);
}
const isCron = !!flags.cron;
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
if (!isCron && deliverAt === null) {
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
render.err('Specify when', 'use --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
process.exit(1);
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
// Determine target: --to flag or self
let targetSpec: string;
if (flags.to && flags.to !== "self") {
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
@@ -117,7 +110,7 @@ export async function runRemind(
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
if (!match) {
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
render.err(`Peer "${flags.to}" not found`, `online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
@@ -127,16 +120,22 @@ export async function runRemind(
}
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
if (!result) { console.error("Broker did not acknowledge — check connection"); process.exit(1); }
if (!result) { render.err("Broker did not acknowledge — check connection"); process.exit(1); }
if (flags.json) { console.log(JSON.stringify(result)); return; }
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
if (isCron) {
const nextFire = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
render.ok(
`recurring reminder set`,
`${result.scheduledId.slice(0, 8)} · ${clay(message)}${toLabel} · cron ${flags.cron} · next ${nextFire}`,
);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
render.ok(
`reminder set`,
`${result.scheduledId.slice(0, 8)} · ${clay(message)}${toLabel} at ${when}`,
);
}
});
}

View File

@@ -10,21 +10,17 @@
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
export function runSeedTestMesh(args: string[]): void {
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
if (!brokerUrl || !meshId || !memberId || !pubkey || !slug) {
console.error(
"Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>",
);
console.error("");
console.error(
'Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test',
);
render.err("Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>");
render.info(dim('Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test'));
process.exit(1);
}
const config = readConfig();
// Remove any prior entry with same slug (idempotent).
config.meshes = config.meshes.filter((m) => m.slug !== slug);
config.meshes.push({
meshId,
@@ -32,13 +28,11 @@ export function runSeedTestMesh(args: string[]): void {
slug,
name: `Test: ${slug}`,
pubkey,
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
secretKey: "dev-only-stub",
brokerUrl,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
console.log(
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
);
render.ok(`seeded ${bold(slug)}`, dim(meshId));
render.hint(`run ${bold("claudemesh mcp")} to connect, or register with Claude Code via ${bold("claudemesh install")}`);
}

View File

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

View File

@@ -5,6 +5,8 @@
*/
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, dim } from "~/ui/styles.js";
export interface StateFlags {
mesh?: string;
@@ -12,14 +14,10 @@ export interface StateFlags {
}
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
const entry = await client.getState(key);
if (!entry) {
console.log(dim(`(not set)`));
render.info(dim("(not set)"));
return;
}
if (flags.json) {
@@ -27,13 +25,12 @@ export async function runStateGet(flags: StateFlags, key: string): Promise<void>
return;
}
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
console.log(val);
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
render.info(val);
render.info(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
});
}
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
let parsed: unknown;
try {
parsed = JSON.parse(value);
@@ -43,16 +40,11 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${key} = ${JSON.stringify(parsed)}`);
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
});
}
export async function runStateList(flags: StateFlags): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const entries = await client.listState();
@@ -62,14 +54,15 @@ export async function runStateList(flags: StateFlags): Promise<void> {
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
render.info(dim(`No state on mesh "${mesh.slug}".`));
return;
}
render.section(`state (${entries.length})`);
for (const e of entries) {
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
console.log(`${bold(e.key)}: ${val}`);
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
process.stdout.write(` ${bold(e.key)}: ${val}\n`);
process.stdout.write(` ${dim(e.updatedBy + " · " + new Date(e.updatedAt).toLocaleString())}\n`);
}
});
}

View File

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

View File

@@ -20,6 +20,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { EXIT } from "~/constants/exit-codes.js";
import { render } from "~/ui/render.js";
import { dim } from "~/ui/styles.js";
function resolveClaudemeshBin(): string {
// argv[1] points to the running binary; prefer that over $PATH so we
@@ -80,10 +82,9 @@ EOF
// Re-register with Launch Services so the scheme resolves here.
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
if (lsreg.status !== 0) {
console.log("lsregister returned non-zero; scheme may not activate until Finder rescans.");
render.warn("lsregister returned non-zero", "scheme may not activate until Finder rescans.");
}
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
console.log(` app bundle: ${appDir}`);
render.ok("registered claudemesh:// scheme on macOS", dim(appDir));
return EXIT.SUCCESS;
}
@@ -106,12 +107,11 @@ NoDisplay=true
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
if (xdg1.status !== 0) {
console.log("xdg-mime not available — skipped mime default registration");
render.warn("xdg-mime not available — skipped mime default registration");
}
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
xdg2.status ?? 0; // best effort
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
console.log(` desktop entry: ${desktopPath}`);
render.ok("registered claudemesh:// scheme on Linux", dim(desktopPath));
return EXIT.SUCCESS;
}
@@ -131,30 +131,30 @@ function installWindows(): number {
writeFileSync(regPath, lines.join("\r\n"));
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
if (res.status !== 0) {
console.log(`reg.exe import failed. Manual: double-click ${regPath}`);
render.warn("reg.exe import failed", `manual: double-click ${regPath}`);
return EXIT.INTERNAL_ERROR;
}
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
render.ok("registered claudemesh:// scheme on Windows");
return EXIT.SUCCESS;
}
function uninstallDarwin(): number {
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
console.log(" ✓ Removed claudemesh:// handler on macOS");
render.ok("removed claudemesh:// handler on macOS");
return EXIT.SUCCESS;
}
function uninstallLinux(): number {
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
console.log(" ✓ Removed claudemesh:// handler on Linux");
render.ok("removed claudemesh:// handler on Linux");
return EXIT.SUCCESS;
}
function uninstallWindows(): number {
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
console.log(" ✓ Removed claudemesh:// handler on Windows");
render.ok("removed claudemesh:// handler on Windows");
return EXIT.SUCCESS;
}
@@ -170,9 +170,9 @@ export async function runUrlHandler(action: string | undefined): Promise<number>
if (p === "linux") return uninstallLinux();
if (p === "win32") return uninstallWindows();
} else {
console.error("Usage: claudemesh url-handler <install|uninstall>");
render.err("Usage: claudemesh url-handler <install|uninstall>");
return EXIT.INVALID_ARGS;
}
console.error(`Unsupported platform: ${p}`);
render.err(`Unsupported platform: ${p}`);
return EXIT.INTERNAL_ERROR;
}

View File

@@ -14,13 +14,14 @@ import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
import { createHash } from "node:crypto";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
function safetyNumber(myPubkey: string, peerPubkey: string): string {
const a = Buffer.from(myPubkey, "hex");
const b = Buffer.from(peerPubkey, "hex");
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
const hash = createHash("sha256").update(lo).update(hi).digest();
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
const bits: number[] = [];
for (let i = 0; i < 15; i++) {
for (let b = 7; b >= 0; b--) {
@@ -40,20 +41,15 @@ export async function runVerify(
target: string | undefined,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) {
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
render.err("No meshes joined.", "Run `claudemesh join <url>` first.");
return EXIT.NOT_FOUND;
}
const mesh = config.meshes.find((m) => m.slug === meshSlug);
if (!mesh) {
console.error(` Mesh "${meshSlug}" not found locally.`);
render.err(`Mesh "${meshSlug}" not found locally.`);
return EXIT.NOT_FOUND;
}
@@ -63,7 +59,7 @@ export async function runVerify(
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
: peers;
if (targets.length === 0) {
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
render.err(`No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
return EXIT.NOT_FOUND;
}
@@ -77,19 +73,16 @@ export async function runVerify(
return EXIT.SUCCESS;
}
console.log("");
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
console.log("");
render.section(`safety numbers on ${meshSlug}`);
for (const p of targets) {
const sn = safetyNumber(mesh.pubkey, p.pubkey);
console.log(` ${bold(p.displayName)}`);
console.log(` ${clay(sn)}`);
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}`);
console.log("");
process.stdout.write(` ${bold(p.displayName)}\n`);
process.stdout.write(` ${clay(sn)}\n`);
process.stdout.write(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}\n\n`);
}
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
console.log("");
render.hint("Compare these digits with your peer (phone, in person not chat).");
render.hint("If they match on both sides, the channel is not being intercepted.");
render.blank();
return EXIT.SUCCESS;
});
}

View File

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

View File

@@ -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; }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});
});
}

View File

@@ -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;
}
}

View File

@@ -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 {}
},
};
}

View File

@@ -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;
}

View File

@@ -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