feat(cli): 1.5.0 — CLI-first architecture, tool-less MCP, policy engine
CLI becomes the API; MCP becomes a tool-less push-pipe. Bundle -42% (250 KB → 146 KB) after stripping ~1700 lines of dead tool handlers. - Tool-less MCP: tools/list returns []. Inbound peer messages still arrive as experimental.claude/channel notifications mid-turn. - Resource-noun-verb CLI: peer list, message send, memory recall, etc. Legacy flat verbs (peers, send, remember) remain as aliases. - Bundled claudemesh skill auto-installed by `claudemesh install` — sole CLI-discoverability surface for Claude. - Unix-socket bridge: CLI invocations dial the push-pipe's warm WS (~220 ms warm vs ~600 ms cold). - --mesh <slug> flag: connect a session to multiple meshes. - Policy engine: every broker-touching verb runs through a YAML gate at ~/.claudemesh/policy.yaml (auto-created). Destructive verbs prompt; non-TTY auto-denies. Audit log at ~/.claudemesh/audit.log. - --approval-mode plan|read-only|write|yolo + --policy <path>. Spec: .artifacts/specs/2026-05-02-architecture-north-star.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md
Normal file
162
.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md
Normal 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.
|
||||
234
.artifacts/specs/2026-05-02-architecture-north-star.md
Normal file
234
.artifacts/specs/2026-05-02-architecture-north-star.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
369
apps/cli/skills/claudemesh/SKILL.md
Normal file
369
apps/cli/skills/claudemesh/SKILL.md
Normal 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`
|
||||
149
apps/cli/src/cli/policy-classify.ts
Normal file
149
apps/cli/src/cli/policy-classify.ts
Normal 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;
|
||||
}
|
||||
331
apps/cli/src/commands/broker-actions.ts
Normal file
331
apps/cli/src/commands/broker-actions.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Small broker-side action verbs that previously lived only as MCP tools.
|
||||
*
|
||||
* These are the CLI replacements for the soft-deprecated tools
|
||||
* (set_status / set_summary / set_visible / set_profile / join_group /
|
||||
* leave_group / forget / message_status / mesh_clock / mesh_stats /
|
||||
* ping_mesh / claim_task / complete_task).
|
||||
*
|
||||
* Each verb runs against ONE mesh — pick with --mesh <slug>, or let the
|
||||
* picker prompt when multiple meshes are joined. This is the deliberate
|
||||
* difference from the MCP tools' fan-out-across-all-meshes behavior:
|
||||
* the CLI invocation model binds one connection per call.
|
||||
*
|
||||
* Spec: .artifacts/specs/2026-05-01-mcp-tool-surface-trim.md
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { tryBridge } from "~/services/bridge/client.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
type StateFlags = { mesh?: string; json?: boolean };
|
||||
type PeerStatus = "idle" | "working" | "dnd";
|
||||
|
||||
/** Resolve unambiguous mesh slug for warm-path bridging. Returns null if
|
||||
* the user has multiple joined meshes and didn't pick one. */
|
||||
function unambiguousMesh(opts: StateFlags): string | null {
|
||||
if (opts.mesh) return opts.mesh;
|
||||
const config = readConfig();
|
||||
return config.meshes.length === 1 ? config.meshes[0]!.slug : null;
|
||||
}
|
||||
|
||||
// --- status ---
|
||||
|
||||
export async function runStatusSet(state: string, opts: StateFlags): Promise<number> {
|
||||
const valid: PeerStatus[] = ["idle", "working", "dnd"];
|
||||
if (!valid.includes(state as PeerStatus)) {
|
||||
render.err(`Invalid status: ${state}`, `must be one of: ${valid.join(", ")}`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
// Warm path
|
||||
const meshSlug = unambiguousMesh(opts);
|
||||
if (meshSlug) {
|
||||
const bridged = await tryBridge(meshSlug, "status_set", { status: state });
|
||||
if (bridged !== null) {
|
||||
if (bridged.ok) {
|
||||
if (opts.json) console.log(JSON.stringify({ status: state }));
|
||||
else render.ok(`status set to ${bold(state)}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.err(bridged.error);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.setStatus(state as PeerStatus);
|
||||
});
|
||||
if (opts.json) console.log(JSON.stringify({ status: state }));
|
||||
else render.ok(`status set to ${bold(state)}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// --- summary ---
|
||||
|
||||
export async function runSummary(text: string, opts: StateFlags): Promise<number> {
|
||||
if (!text) {
|
||||
render.err("Usage: claudemesh summary <text>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
// Warm path
|
||||
const meshSlug = unambiguousMesh(opts);
|
||||
if (meshSlug) {
|
||||
const bridged = await tryBridge(meshSlug, "summary", { summary: text });
|
||||
if (bridged !== null) {
|
||||
if (bridged.ok) {
|
||||
if (opts.json) console.log(JSON.stringify({ summary: text }));
|
||||
else render.ok("summary set", dim(text));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.err(bridged.error);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.setSummary(text);
|
||||
});
|
||||
if (opts.json) console.log(JSON.stringify({ summary: text }));
|
||||
else render.ok("summary set", dim(text));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// --- visible ---
|
||||
|
||||
export async function runVisible(value: string | undefined, opts: StateFlags): Promise<number> {
|
||||
let visible: boolean;
|
||||
if (value === "true" || value === "1" || value === "yes") visible = true;
|
||||
else if (value === "false" || value === "0" || value === "no") visible = false;
|
||||
else {
|
||||
render.err("Usage: claudemesh visible <true|false>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
// Warm path
|
||||
const meshSlug = unambiguousMesh(opts);
|
||||
if (meshSlug) {
|
||||
const bridged = await tryBridge(meshSlug, "visible", { visible });
|
||||
if (bridged !== null) {
|
||||
if (bridged.ok) {
|
||||
if (opts.json) console.log(JSON.stringify({ visible }));
|
||||
else render.ok(visible ? "you are now visible to peers" : "you are now hidden", visible ? undefined : "direct messages still reach you");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.err(bridged.error);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.setVisible(visible);
|
||||
});
|
||||
if (opts.json) console.log(JSON.stringify({ visible }));
|
||||
else render.ok(visible ? "you are now visible to peers" : "you are now hidden", visible ? undefined : "direct messages still reach you");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// --- group ---
|
||||
|
||||
export async function runGroupJoin(name: string | undefined, opts: StateFlags & { role?: string }): Promise<number> {
|
||||
if (!name) {
|
||||
render.err("Usage: claudemesh group join @<name> [--role X]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const cleanName = name.startsWith("@") ? name.slice(1) : name;
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.joinGroup(cleanName, opts.role);
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ group: cleanName, role: opts.role ?? null }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok(`joined ${clay("@" + cleanName)}`, opts.role ? `as ${opts.role}` : undefined);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runGroupLeave(name: string | undefined, opts: StateFlags): Promise<number> {
|
||||
if (!name) {
|
||||
render.err("Usage: claudemesh group leave @<name>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const cleanName = name.startsWith("@") ? name.slice(1) : name;
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.leaveGroup(cleanName);
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ group: cleanName, left: true }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok(`left ${clay("@" + cleanName)}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// --- forget ---
|
||||
|
||||
export async function runForget(id: string | undefined, opts: StateFlags): Promise<number> {
|
||||
if (!id) {
|
||||
render.err("Usage: claudemesh forget <memory-id>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.forget(id);
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, forgotten: true }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok(`forgot ${dim(id.slice(0, 8))}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// --- msg-status ---
|
||||
|
||||
export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> {
|
||||
if (!id) {
|
||||
render.err("Usage: claudemesh msg-status <message-id>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const result = await client.messageStatus(id);
|
||||
if (!result) {
|
||||
if (opts.json) console.log(JSON.stringify({ id, found: false }));
|
||||
else render.err(`Message ${id} not found or timed out.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.section(`message ${id.slice(0, 12)}…`);
|
||||
render.kv([
|
||||
["target", result.targetSpec],
|
||||
["delivered", result.delivered ? "yes" : "no"],
|
||||
["delivered_at", result.deliveredAt ?? dim("—")],
|
||||
]);
|
||||
if (result.recipients.length > 0) {
|
||||
render.blank();
|
||||
render.heading("recipients");
|
||||
for (const r of result.recipients) {
|
||||
process.stdout.write(` ${bold(r.name)} ${dim(r.pubkey.slice(0, 12) + "…")} ${dim("·")} ${r.status}\n`);
|
||||
}
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// --- clock ---
|
||||
|
||||
export async function runClock(opts: StateFlags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const result = await client.getClock();
|
||||
if (!result) {
|
||||
if (opts.json) console.log(JSON.stringify({ error: "timed out" }));
|
||||
else render.err("Clock query timed out");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
const statusLabel = result.speed === 0 ? "not started" : result.paused ? "paused" : "running";
|
||||
render.section(`mesh clock — ${statusLabel}`);
|
||||
render.kv([
|
||||
["speed", `x${result.speed}`],
|
||||
["tick", String(result.tick)],
|
||||
["sim_time", result.simTime],
|
||||
["started_at", result.startedAt],
|
||||
]);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// --- stats ---
|
||||
|
||||
export async function runStats(opts: StateFlags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
mesh: client.meshSlug,
|
||||
peers: peers.map((p) => ({ name: p.displayName, pubkey: p.pubkey, stats: p.stats ?? null })),
|
||||
}, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.section(client.meshSlug);
|
||||
for (const p of peers) {
|
||||
const s = p.stats;
|
||||
if (!s) {
|
||||
process.stdout.write(` ${bold(p.displayName)} ${dim("(no stats)")}\n`);
|
||||
continue;
|
||||
}
|
||||
const up = s.uptime != null ? `${Math.floor(s.uptime / 60)}m` : "—";
|
||||
process.stdout.write(
|
||||
` ${bold(p.displayName)} ${dim(`in:${s.messagesIn ?? 0} out:${s.messagesOut ?? 0} tools:${s.toolCalls ?? 0} up:${up} err:${s.errors ?? 0}`)}\n`,
|
||||
);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// --- ping ---
|
||||
|
||||
export async function runPing(opts: StateFlags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
mesh: client.meshSlug,
|
||||
ws_status: client.status,
|
||||
peers_online: peers.length,
|
||||
push_buffer: client.pushHistory.length,
|
||||
}, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.section(`ping ${client.meshSlug}`);
|
||||
render.kv([
|
||||
["ws_status", client.status],
|
||||
["peers_online", String(peers.length)],
|
||||
["push_buffer", String(client.pushHistory.length)],
|
||||
]);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// --- task ---
|
||||
|
||||
export async function runTaskClaim(id: string | undefined, opts: StateFlags): Promise<number> {
|
||||
if (!id) {
|
||||
render.err("Usage: claudemesh task claim <id>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.claimTask(id);
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, claimed: true }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok(`claimed ${dim(id.slice(0, 8))}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runTaskComplete(id: string | undefined, result: string | undefined, opts: StateFlags): Promise<number> {
|
||||
if (!id) {
|
||||
render.err("Usage: claudemesh task complete <id> [result]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.completeTask(id, result);
|
||||
});
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, completed: true, result: result ?? null }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
render.ok(`completed ${dim(id.slice(0, 8))}`, result);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"create", "new", "join", "add", "launch", "connect", "disconnect",
|
||||
@@ -102,7 +103,7 @@ complete -c claudemesh -l join -d 'invite url'
|
||||
|
||||
export async function runCompletions(shell: string | undefined): Promise<number> {
|
||||
if (!shell) {
|
||||
console.error("Usage: claudemesh completions <bash|zsh|fish>");
|
||||
render.err("Usage: claudemesh completions <bash|zsh|fish>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
switch (shell.toLowerCase()) {
|
||||
@@ -116,7 +117,7 @@ export async function runCompletions(shell: string | undefined): Promise<number>
|
||||
process.stdout.write(fish());
|
||||
return EXIT.SUCCESS;
|
||||
default:
|
||||
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
|
||||
render.err(`Unsupported shell: ${shell}`, "use bash, zsh, or fish.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
render.err("No meshes joined.", "Run `claudemesh join` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mesh = config.meshes[0]!;
|
||||
const linkOnly = args.includes("--link");
|
||||
|
||||
// Convert WS broker URL to HTTP
|
||||
const brokerHttp = mesh.brokerUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace("/ws", "");
|
||||
|
||||
console.log("Requesting Telegram connect token...");
|
||||
render.info(dim("Requesting Telegram connect token…"));
|
||||
|
||||
const res = await fetch(`${brokerHttp}/tg/token`, {
|
||||
method: "POST",
|
||||
@@ -32,7 +33,7 @@ export async function connectTelegram(args: string[]): Promise<void> {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
render.err(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -46,20 +47,18 @@ export async function connectTelegram(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Print QR code using simple block characters
|
||||
console.log("\n Connect Telegram to your mesh:\n");
|
||||
console.log(` ${deepLink}\n`);
|
||||
console.log(" Open this link on your phone, or scan the QR code");
|
||||
console.log(" with your Telegram camera.\n");
|
||||
render.section("connect Telegram to your mesh");
|
||||
render.link(deepLink);
|
||||
render.blank();
|
||||
render.info(dim("Open this link on your phone, or scan the QR code with your Telegram camera."));
|
||||
render.blank();
|
||||
|
||||
// Try to generate QR with qrcode-terminal if available
|
||||
try {
|
||||
const QRCode = require("qrcode-terminal");
|
||||
QRCode.generate(deepLink, { small: true }, (code: string) => {
|
||||
console.log(code);
|
||||
});
|
||||
} catch {
|
||||
// qrcode-terminal not available, link is enough
|
||||
console.log(" (Install qrcode-terminal for QR code display)");
|
||||
render.info(dim("(Install qrcode-terminal for QR code display)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim, red } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
@@ -23,34 +24,34 @@ function getUserId(token: string): string {
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async function isOwner(slug: string, userId: string): Promise<boolean> {
|
||||
async function isOwner(slug: string, auth: { session_token: string }): Promise<boolean> {
|
||||
try {
|
||||
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
path: `/cli/meshes`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
token: auth.session_token,
|
||||
});
|
||||
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
|
||||
return res.meshes?.find((m) => m.slug === slug)?.is_owner ?? false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
|
||||
const config = readConfig();
|
||||
|
||||
// Mesh picker if no slug given
|
||||
if (!slug) {
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes to remove.");
|
||||
render.err("No meshes to remove.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
console.log("\n Select mesh to remove:\n");
|
||||
render.section("select mesh to remove");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)} ${dim("(" + m.name + ")")}\n`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice: ");
|
||||
render.blank();
|
||||
const choice = await prompt(` ${dim("choice:")} `);
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
if (idx < 0 || idx >= config.meshes.length) {
|
||||
console.log(" Cancelled.");
|
||||
render.info(dim("cancelled."));
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
slug = config.meshes[idx]!.slug;
|
||||
@@ -58,28 +59,27 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
|
||||
|
||||
const auth = getStoredToken();
|
||||
const userId = auth ? getUserId(auth.session_token) : "";
|
||||
const ownerCheck = userId ? await isOwner(slug, userId) : false;
|
||||
const ownerCheck = auth ? await isOwner(slug, auth) : false;
|
||||
|
||||
// Ask what to do
|
||||
if (!opts.yes) {
|
||||
console.log(`\n ${bold(slug)}\n`);
|
||||
render.section(slug);
|
||||
|
||||
if (ownerCheck) {
|
||||
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
|
||||
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
|
||||
console.log(` ${bold("3)")} Cancel`);
|
||||
console.log("");
|
||||
process.stdout.write(` ${bold("1)")} remove from this device only ${dim("(keep on server)")}\n`);
|
||||
process.stdout.write(` ${bold("2)")} ${red("delete everywhere")} ${dim("(removes for all members)")}\n`);
|
||||
process.stdout.write(` ${bold("3)")} cancel\n`);
|
||||
render.blank();
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
const choice = await prompt(` ${dim("choice [1]:")} `) || "1";
|
||||
|
||||
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
if (choice === "3") { render.info(dim("cancelled.")); return EXIT.USER_CANCELLED; }
|
||||
|
||||
if (choice === "2") {
|
||||
// Server-side delete — require confirmation
|
||||
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
|
||||
const confirm = await prompt(` Type "${slug}" to confirm: `);
|
||||
render.blank();
|
||||
render.warn(`this will delete ${bold(slug)} for all members.`);
|
||||
const confirm = await prompt(` ${dim(`type "${slug}" to confirm:`)} `);
|
||||
if (confirm.toLowerCase() !== slug.toLowerCase()) {
|
||||
console.log(" Cancelled.");
|
||||
render.info(dim("cancelled."));
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
|
||||
@@ -87,42 +87,39 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr
|
||||
await request({
|
||||
path: `/cli/mesh/${slug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
token: auth?.session_token,
|
||||
body: { user_id: userId },
|
||||
});
|
||||
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
|
||||
render.ok(`deleted ${bold(slug)} from server.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${icons.cross} Server delete failed: ${msg}`);
|
||||
render.err(`server delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
leaveMesh(slug);
|
||||
console.log(` ${green(icons.check)} Removed from local config.`);
|
||||
render.ok("removed from local config.");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// choice === "1" — local only, fall through
|
||||
} else {
|
||||
// Not owner — can only remove locally
|
||||
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
|
||||
console.log(` ${bold("2)")} Cancel`);
|
||||
if (!ownerCheck && userId) {
|
||||
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
|
||||
process.stdout.write(` ${bold("1)")} remove from this device ${dim("(you can re-add later)")}\n`);
|
||||
process.stdout.write(` ${bold("2)")} cancel\n`);
|
||||
if (userId) {
|
||||
render.blank();
|
||||
render.warn("only the mesh owner can delete it from the server.");
|
||||
}
|
||||
console.log("");
|
||||
render.blank();
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
const choice = await prompt(` ${dim("choice [1]:")} `) || "1";
|
||||
if (choice === "2") { render.info(dim("cancelled.")); return EXIT.USER_CANCELLED; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local-only removal
|
||||
const removed = leaveMesh(slug);
|
||||
if (removed) {
|
||||
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
|
||||
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
|
||||
render.ok(`removed ${bold(slug)} from this device.`);
|
||||
render.hint(`re-add anytime with: ${bold("claudemesh")} ${clay("<invite-url>")}`);
|
||||
} else {
|
||||
console.error(` Mesh "${slug}" not found in local config.`);
|
||||
render.err(`mesh "${slug}" not found in local config.`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim, yellow } from "~/ui/styles.js";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -149,18 +151,80 @@ function bunAvailable(): boolean {
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/** Is this file running from a bundled `dist/` directory? */
|
||||
function isBundledFile(p: string): boolean {
|
||||
// Match any file under dist/ — e.g. dist/index.js or dist/entrypoints/cli.js.
|
||||
return /[/\\]dist[/\\]/.test(p);
|
||||
}
|
||||
|
||||
/** Absolute path to this CLI's entry file. */
|
||||
function resolveEntry(): string {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
// When bundled (dist/index.js), this file IS the entry → return self.
|
||||
// When running from source (src/index.ts via bun), walk up to the
|
||||
// dir + resolve index.ts.
|
||||
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
|
||||
return here;
|
||||
}
|
||||
// Bundled: this file IS reachable as the entry; return self.
|
||||
// Source: walk up to apps/cli/src/index.ts (legacy) or fall back.
|
||||
if (isBundledFile(here)) return here;
|
||||
return resolve(dirname(here), "..", "index.ts");
|
||||
}
|
||||
|
||||
/** Find the bundled `skills/` directory at install time. Walks up from
|
||||
* the entry file: dist/entrypoints/cli.js → dist/ → package root → skills/. */
|
||||
function resolveBundledSkillsDir(): string | null {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
// Bundled: <pkg>/dist/entrypoints/cli.js → walk up two levels to <pkg>
|
||||
// Source: <pkg>/src/commands/install.ts → walk up two levels to <pkg>
|
||||
const pkgRoot = resolve(dirname(here), "..", "..");
|
||||
const skillsDir = join(pkgRoot, "skills");
|
||||
if (existsSync(skillsDir)) return skillsDir;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ~/.claude/skills/ — where Claude Code looks for user-scoped skills. */
|
||||
const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
|
||||
|
||||
/**
|
||||
* Copy bundled skills into ~/.claude/skills/. Idempotent — overwrites
|
||||
* existing files (so updates flow through on `claudemesh install` re-run).
|
||||
* Returns the list of skill names installed.
|
||||
*/
|
||||
function installSkills(): string[] {
|
||||
const src = resolveBundledSkillsDir();
|
||||
if (!src) return [];
|
||||
// Each subdirectory of skills/ is one skill (matches Claude Code convention).
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const installed: string[] = [];
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const srcDir = join(src, entry.name);
|
||||
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
|
||||
mkdirSync(dstDir, { recursive: true });
|
||||
for (const file of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (!file.isFile()) continue;
|
||||
copyFileSync(join(srcDir, file.name), join(dstDir, file.name));
|
||||
}
|
||||
installed.push(entry.name);
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
/** Remove claudemesh-shipped skills from ~/.claude/skills/. Returns names removed. */
|
||||
function uninstallSkills(): string[] {
|
||||
const src = resolveBundledSkillsDir();
|
||||
if (!src) return [];
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const removed: string[] = [];
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dstDir = join(CLAUDE_SKILLS_ROOT, entry.name);
|
||||
if (existsSync(dstDir)) {
|
||||
try {
|
||||
fs.rmSync(dstDir, { recursive: true, force: true });
|
||||
removed.push(entry.name);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the MCP server entry for Claude Code's config.
|
||||
*
|
||||
@@ -170,9 +234,7 @@ function resolveEntry(): string {
|
||||
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
|
||||
*/
|
||||
function buildMcpEntry(entryPath: string): McpEntry {
|
||||
const isBundled = entryPath.endsWith("/dist/index.js") ||
|
||||
entryPath.endsWith("\\dist\\index.js");
|
||||
if (isBundled) {
|
||||
if (isBundledFile(entryPath)) {
|
||||
return {
|
||||
command: "claudemesh",
|
||||
args: ["mcp"],
|
||||
@@ -374,191 +436,181 @@ function installStatusLine(): { installed: boolean } {
|
||||
|
||||
export function runInstall(args: string[] = []): void {
|
||||
const skipHooks = args.includes("--no-hooks");
|
||||
const skipSkill = args.includes("--no-skill");
|
||||
const wantStatusLine = args.includes("--status-line");
|
||||
console.log("claudemesh install");
|
||||
console.log("------------------");
|
||||
render.section("claudemesh install");
|
||||
|
||||
const entry = resolveEntry();
|
||||
const isBundled = entry.endsWith("/dist/index.js") ||
|
||||
entry.endsWith("\\dist\\index.js");
|
||||
const bundled = isBundledFile(entry);
|
||||
|
||||
// Dev mode (running from src/) requires bun on PATH; bundled mode
|
||||
// (npm install -g) just uses node + the claudemesh bin shim.
|
||||
if (!isBundled && !bunAvailable()) {
|
||||
console.error(
|
||||
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
|
||||
);
|
||||
if (!bundled && !bunAvailable()) {
|
||||
render.err("`bun` is not on PATH.", "Install Bun first: https://bun.com");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(entry)) {
|
||||
console.error(`✗ MCP entry not found at ${entry}`);
|
||||
render.err(`MCP entry not found at ${entry}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const desired = buildMcpEntry(entry);
|
||||
const action = patchMcpServer(desired);
|
||||
|
||||
// Read-back verification.
|
||||
const verify = readClaudeConfig();
|
||||
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
|
||||
const stored = verifyServers[MCP_NAME];
|
||||
if (!stored || !entriesEqual(stored, desired)) {
|
||||
console.error(
|
||||
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
|
||||
);
|
||||
render.err("post-write verification failed", `${CLAUDE_CONFIG} may be corrupt`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ANSI color helpers — stick to 8-color set so terminals without
|
||||
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
render.ok(`MCP server "${bold(MCP_NAME)}" ${action}`);
|
||||
render.kv([
|
||||
["config", dim(CLAUDE_CONFIG)],
|
||||
["command", dim(`${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`)],
|
||||
]);
|
||||
|
||||
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
|
||||
console.log(dim(` config: ${CLAUDE_CONFIG}`));
|
||||
console.log(
|
||||
dim(
|
||||
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
render.ok(
|
||||
`allowedTools: ${added.length} claudemesh tools pre-approved`,
|
||||
unchanged > 0 ? `${unchanged} already present` : undefined,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
render.info(dim("This lets claudemesh tools run without --dangerously-skip-permissions."));
|
||||
render.info(dim("Your existing allowedTools entries were preserved."));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
render.ok(`allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
render.info(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
render.warn(`allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
const { added, unchanged } = installHooks();
|
||||
if (added > 0) {
|
||||
console.log(
|
||||
`✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`,
|
||||
render.ok(
|
||||
`Hooks registered (Stop + UserPromptSubmit)`,
|
||||
`${added} added, ${unchanged} already present`,
|
||||
);
|
||||
} else {
|
||||
console.log(`✓ Hooks already registered (${unchanged} present)`);
|
||||
render.ok(`Hooks already registered`, `${unchanged} present`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
render.info(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
console.error(
|
||||
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
|
||||
render.warn(
|
||||
`hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
"MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
render.info(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Claude skill — discoverability replacement for the (now-empty) MCP
|
||||
// tool surface. Claude reads ~/.claude/skills/claudemesh/SKILL.md on
|
||||
// demand, learns every CLI verb, JSON shape, and gotcha. See spec
|
||||
// 2026-05-02 commitment #6.
|
||||
if (!skipSkill) {
|
||||
try {
|
||||
const installed = installSkills();
|
||||
if (installed.length > 0) {
|
||||
render.ok(
|
||||
`Claude skill${installed.length === 1 ? "" : "s"} installed`,
|
||||
installed.join(", "),
|
||||
);
|
||||
render.info(dim(` ${join(CLAUDE_SKILLS_ROOT, installed[0]!)}/SKILL.md`));
|
||||
}
|
||||
} catch (e) {
|
||||
render.warn(`skill install failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
render.info(dim("· Skill install skipped (--no-skill)"));
|
||||
}
|
||||
|
||||
// Opt-in status line (shows mesh + peer count in Claude Code).
|
||||
if (wantStatusLine) {
|
||||
try {
|
||||
const { installed } = installStatusLine();
|
||||
if (installed) {
|
||||
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
|
||||
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
|
||||
render.ok(`Claude Code statusLine → ${clay("claudemesh status-line")}`);
|
||||
render.info(dim(" Shows: ◇ <mesh> · <online>/<total> online · <you>"));
|
||||
} else {
|
||||
console.log(dim("· statusLine already set to a custom command — left alone"));
|
||||
render.info(dim("· statusLine already set to a custom command — left alone"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
render.warn(`statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = readConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
render.blank();
|
||||
render.warn(`${bold("RESTART CLAUDE CODE")} ${yellow("for MCP tools to appear.")}`);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh <invite-url>")}` +
|
||||
dim(" — joins + launches in one step"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
render.blank();
|
||||
render.info(`${yellow("No meshes joined.")} To connect with peers:`);
|
||||
render.info(` ${bold("claudemesh <invite-url>")}${dim(" — joins + launches in one step")}`);
|
||||
render.info(` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
|
||||
);
|
||||
render.blank();
|
||||
render.info(`Next: ${bold("claudemesh")}${dim(" — launch with your joined mesh")}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(dim("Optional:"));
|
||||
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
|
||||
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
|
||||
console.log(dim(` claudemesh completions zsh # shell completions`));
|
||||
render.blank();
|
||||
render.info(dim("Optional:"));
|
||||
render.info(dim(` claudemesh url-handler install # click-to-launch from email`));
|
||||
render.info(dim(` claudemesh install --status-line # live peer count in Claude Code`));
|
||||
render.info(dim(` claudemesh completions zsh # shell completions`));
|
||||
}
|
||||
|
||||
export function runUninstall(): void {
|
||||
console.log("claudemesh uninstall");
|
||||
console.log("--------------------");
|
||||
render.section("claudemesh uninstall");
|
||||
|
||||
// MCP entry — only removes claudemesh, never touches other servers.
|
||||
if (removeMcpServer()) {
|
||||
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
||||
render.ok(`MCP server "${bold(MCP_NAME)}" removed`);
|
||||
} else {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
render.info(dim(`· MCP server "${MCP_NAME}" not present`));
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
render.ok(`allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
render.info(dim("· No claudemesh allowedTools to remove"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
render.warn(`allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ Hooks removed (${removed} entries)`);
|
||||
render.ok(`Hooks removed`, `${removed} entries`);
|
||||
} else {
|
||||
console.log("· No claudemesh hooks to remove");
|
||||
render.info(dim("· No claudemesh hooks to remove"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
render.warn(`hook removal failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to drop the MCP connection + hooks.");
|
||||
try {
|
||||
const removed = uninstallSkills();
|
||||
if (removed.length > 0) {
|
||||
render.ok(`Skill${removed.length === 1 ? "" : "s"} removed`, removed.join(", "));
|
||||
} else {
|
||||
render.info(dim("· No claudemesh skills to remove"));
|
||||
}
|
||||
} catch (e) {
|
||||
render.warn(`skill removal failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
render.blank();
|
||||
render.info("Restart Claude Code to drop the MCP connection + hooks.");
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js
|
||||
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
|
||||
import { openBrowser } from "~/services/spawn/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
@@ -371,7 +372,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
console.log("Joining mesh...");
|
||||
render.info(tDim("Joining mesh…"));
|
||||
const invite = await parseInviteLink(args.joinLink);
|
||||
const keypair = await generateKeypair();
|
||||
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
@@ -398,8 +399,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
});
|
||||
const { writeConfig } = await import("~/services/config/facade.js");
|
||||
writeConfig(config);
|
||||
console.log(
|
||||
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||
render.ok(
|
||||
`joined ${tBold(invite.payload.mesh_slug)}`,
|
||||
enroll.alreadyMember ? "already member" : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,7 +485,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
render.err("No meshes joined.", "Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -492,8 +494,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
if (args.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
render.err(
|
||||
`Mesh "${args.meshSlug}" not found.`,
|
||||
`Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -806,9 +809,9 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
if (result.error) {
|
||||
const err = result.error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
|
||||
render.err("`claude` not found on PATH.", "Install Claude Code first.");
|
||||
} else {
|
||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||
render.err(`failed to launch claude: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export function runLeave(args: string[]): void {
|
||||
export function runLeave(args: string[]): number {
|
||||
const slug = args[0];
|
||||
if (!slug) {
|
||||
console.error("Usage: claudemesh leave <slug>");
|
||||
process.exit(1);
|
||||
render.err("Usage: claudemesh leave <slug>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const config = readConfig();
|
||||
const before = config.meshes.length;
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
if (config.meshes.length === before) {
|
||||
console.error(`claudemesh: no joined mesh with slug "${slug}"`);
|
||||
process.exit(1);
|
||||
render.err(`no joined mesh with slug "${slug}"`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
writeConfig(config);
|
||||
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
|
||||
render.ok(`left ${bold(slug)}`, dim(`remaining: ${config.meshes.length}`));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
|
||||
import { bold, clay, dim, green, yellow } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
@@ -45,26 +46,26 @@ export async function runList(): Promise<void> {
|
||||
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
|
||||
|
||||
if (allSlugs.size === 0) {
|
||||
console.log("\n No meshes yet.\n");
|
||||
console.log(" Create one: claudemesh mesh create <name>");
|
||||
console.log(" Join one: claudemesh mesh add <invite-url>\n");
|
||||
render.section("no meshes yet");
|
||||
render.info(`${dim("create one:")} ${bold("claudemesh create")} ${clay("<name>")}`);
|
||||
render.info(`${dim("join one:")} ${bold("claudemesh")} ${clay("<invite-url>")}`);
|
||||
render.blank();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n Your meshes:\n");
|
||||
render.section(`your meshes (${allSlugs.size})`);
|
||||
|
||||
for (const slug of allSlugs) {
|
||||
const local = config.meshes.find(m => m.slug === slug);
|
||||
const server = serverMeshes.find(m => m.slug === slug);
|
||||
const local = config.meshes.find((m) => m.slug === slug);
|
||||
const server = serverMeshes.find((m) => m.slug === slug);
|
||||
|
||||
const name = server?.name ?? local?.name ?? slug;
|
||||
const role = server?.role ?? "member";
|
||||
const isOwner = server?.is_owner ?? false;
|
||||
const roleLabel = isOwner ? "owner" : role;
|
||||
const roleLabel = isOwner ? clay("owner") : dim(role);
|
||||
const memberCount = server?.member_count;
|
||||
const activePeers = server?.active_peers ?? 0;
|
||||
|
||||
// Status indicator
|
||||
const inLocal = localSlugs.has(slug);
|
||||
const inServer = serverSlugs.has(slug);
|
||||
let status: string;
|
||||
@@ -84,14 +85,14 @@ export async function runList(): Promise<void> {
|
||||
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
|
||||
const parts = [roleLabel, memberInfo, status].filter(Boolean);
|
||||
|
||||
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
|
||||
console.log(` ${parts.join(" · ")}`);
|
||||
process.stdout.write(` ${icon} ${bold(name)} ${dim(slug)}\n`);
|
||||
process.stdout.write(` ${parts.join(dim(" · "))}\n`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
|
||||
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
|
||||
process.stdout.write("\n");
|
||||
if (serverMeshes.some((m) => !localSlugs.has(m.slug))) {
|
||||
render.hint(`${dim("○")} = server only — run ${bold("claudemesh join")} to use locally`);
|
||||
}
|
||||
console.log(dim(` Config: ${getConfigPath()}`));
|
||||
console.log("");
|
||||
render.hint(`config: ${dim(getConfigPath())}`);
|
||||
render.blank();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
|
||||
import { my } from "~/services/api/facade.js";
|
||||
import { green, dim, bold, icons } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
|
||||
@@ -13,16 +14,17 @@ function prompt(question: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function loginWithToken(): Promise<number> {
|
||||
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
|
||||
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
|
||||
render.blank();
|
||||
render.info(`Paste a token from ${dim(URLS.API_BASE + "/token")}`);
|
||||
render.info(dim("Generate one in your browser, then paste it here."));
|
||||
render.blank();
|
||||
|
||||
const token = await prompt(" Token: ");
|
||||
if (!token) {
|
||||
console.error(` ${icons.cross} No token provided.`);
|
||||
render.err("No token provided.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
// Decode JWT to get user info
|
||||
let user = { id: "", display_name: "", email: "" };
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
@@ -31,7 +33,7 @@ async function loginWithToken(): Promise<number> {
|
||||
sub?: string; email?: string; name?: string; exp?: number;
|
||||
};
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
console.error(` ${icons.cross} Token expired. Generate a new one.`);
|
||||
render.err("Token expired.", "Generate a new one.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
user = {
|
||||
@@ -41,12 +43,12 @@ async function loginWithToken(): Promise<number> {
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
console.error(` ${icons.cross} Invalid token format.`);
|
||||
render.err("Invalid token format.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
storeToken({ session_token: token, user, token_source: "manual" });
|
||||
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
|
||||
render.ok(`signed in as ${bold(user.display_name || user.email || "user")}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ async function syncMeshes(token: string): Promise<void> {
|
||||
const meshes = await my.getMeshes(token);
|
||||
if (meshes.length > 0) {
|
||||
const names = meshes.map((m) => m.slug).join(", ");
|
||||
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
|
||||
render.ok(`synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}`, names);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -64,55 +66,55 @@ export async function login(): Promise<number> {
|
||||
const existing = getStoredToken();
|
||||
if (existing) {
|
||||
const name = existing.user.display_name || existing.user.email || "unknown";
|
||||
console.log(`\n Already signed in as ${bold(name)}.`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Continue as ${name}`);
|
||||
console.log(` ${bold("2)")} Sign in via browser`);
|
||||
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log(` ${bold("4)")} Sign out`);
|
||||
console.log("");
|
||||
render.blank();
|
||||
render.info(`Already signed in as ${bold(name)}.`);
|
||||
render.blank();
|
||||
process.stdout.write(` ${bold("1)")} Continue as ${name}\n`);
|
||||
process.stdout.write(` ${bold("2)")} Sign in via browser\n`);
|
||||
process.stdout.write(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}\n`);
|
||||
process.stdout.write(` ${bold("4)")} Sign out\n`);
|
||||
render.blank();
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "1") {
|
||||
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
|
||||
render.blank();
|
||||
render.ok(`continuing as ${bold(name)}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "4") {
|
||||
clearToken();
|
||||
console.log(` ${green(icons.check)} Signed out.`);
|
||||
render.ok("signed out");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "3") {
|
||||
clearToken();
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "2" → fall through to browser login
|
||||
clearToken();
|
||||
console.log(` ${dim("Signing in…")}`);
|
||||
render.info(dim("Signing in…"));
|
||||
} else {
|
||||
// Not logged in — show auth options
|
||||
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
|
||||
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log("");
|
||||
render.blank();
|
||||
render.heading(`${bold("claudemesh")} — sign in to connect your terminal`);
|
||||
render.blank();
|
||||
process.stdout.write(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}\n`);
|
||||
process.stdout.write(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}\n`);
|
||||
render.blank();
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "2") {
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "1" → fall through to browser login
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWithDeviceCode();
|
||||
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
|
||||
render.ok(`signed in as ${bold(result.user.display_name)}`);
|
||||
await syncMeshes(result.session_token);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
|
||||
render.err(`Login failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create as createMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { green, dim, icons } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function newMesh(
|
||||
@@ -8,16 +9,17 @@ export async function newMesh(
|
||||
opts: { template?: string; description?: string; json?: boolean },
|
||||
): Promise<number> {
|
||||
if (!name) {
|
||||
console.error(" Usage: claudemesh mesh create <name>");
|
||||
render.err("Usage: claudemesh create <name>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
if (!getStoredToken()) {
|
||||
console.log(dim(" Not signed in — starting login…\n"));
|
||||
render.info(dim("not signed in — starting login…"));
|
||||
render.blank();
|
||||
const { login } = await import("./login.js");
|
||||
const loginResult = await login();
|
||||
if (loginResult !== EXIT.SUCCESS) return loginResult;
|
||||
console.log("");
|
||||
render.blank();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -28,20 +30,26 @@ export async function newMesh(
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
|
||||
console.log(` ${green(icons.check)} You're the owner`);
|
||||
console.log(` ${green(icons.check)} Joined locally`);
|
||||
console.log(`\n Share with: claudemesh mesh share\n`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.section(`created ${bold(result.slug)}`);
|
||||
render.kv([
|
||||
["id", dim(result.id)],
|
||||
["role", clay("owner")],
|
||||
["local", "joined"],
|
||||
]);
|
||||
render.blank();
|
||||
render.hint(`share with: ${bold("claudemesh share")}`);
|
||||
render.blank();
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("409") || msg.includes("already exists")) {
|
||||
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
|
||||
render.err("A mesh with this name already exists.", "Try a different name.");
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
render.err(`Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,69 @@
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Shows all meshes by default, or filter with --mesh.
|
||||
*
|
||||
* Warm path: dials the per-mesh bridge socket the push-pipe holds open.
|
||||
* Cold path: opens its own WS via `withMesh`. Bridge fall-through is
|
||||
* transparent — output is identical.
|
||||
*
|
||||
* `--json` accepts an optional comma-separated field list:
|
||||
* claudemesh peers --json (full record)
|
||||
* claudemesh peers --json name,pubkey,status (projection)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { tryBridge } from "~/services/bridge/client.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim, green, yellow } from "~/ui/styles.js";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
/** `true`/`undefined` = full record; comma-separated string = field projection. */
|
||||
json?: boolean | string;
|
||||
}
|
||||
|
||||
interface PeerRecord {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
peerType?: string;
|
||||
channel?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Friendly aliases — `name` is what users will type; broker calls it `displayName`. */
|
||||
const FIELD_ALIAS: Record<string, string> = {
|
||||
name: "displayName",
|
||||
};
|
||||
|
||||
function projectFields(record: PeerRecord, fields: string[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const sourceKey = FIELD_ALIAS[f] ?? f;
|
||||
out[f] = (record as Record<string, unknown>)[sourceKey];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function listPeersForMesh(slug: string): Promise<PeerRecord[]> {
|
||||
// Try warm path first.
|
||||
const bridged = await tryBridge(slug, "peers");
|
||||
if (bridged && bridged.ok) {
|
||||
return bridged.result as PeerRecord[];
|
||||
}
|
||||
// Cold path — open our own WS.
|
||||
let result: PeerRecord[] = [];
|
||||
await withMesh({ meshSlug: slug }, async (client) => {
|
||||
const all = await client.listPeers();
|
||||
const selfPubkey = client.getSessionPubkey();
|
||||
result = (selfPubkey ? all.filter((p) => p.pubkey !== selfPubkey) : all) as unknown as PeerRecord[];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
@@ -24,32 +77,39 @@ 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;
|
||||
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}` : ""}`)
|
||||
.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
|
||||
.join(", ") +
|
||||
"]"
|
||||
: "";
|
||||
@@ -65,13 +125,14 @@ export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
render.info(`${statusDot} ${name}${groups}${metaStr}${pubkeyTag}${summary}`);
|
||||
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
|
||||
if (wantsJson) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
584
apps/cli/src/commands/platform-actions.ts
Normal file
584
apps/cli/src/commands/platform-actions.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Platform CLI verbs — vector / graph / context / stream / sql / skill /
|
||||
* vault / watch / webhook / task / clock. These wrap broker methods that
|
||||
* previously were only callable via MCP tools.
|
||||
*
|
||||
* All verbs run cold-path (open own WS via `withMesh`). Bridge expansion
|
||||
* for high-frequency reads (vector_search, graph_query, sql_query) lands
|
||||
* in 1.3.1.
|
||||
*
|
||||
* Spec: .artifacts/specs/2026-05-02-architecture-north-star.md
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
type Flags = { mesh?: string; json?: boolean };
|
||||
|
||||
function emitJson(data: unknown): void {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// vector — embedding store + similarity search
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runVectorStore(
|
||||
collection: string,
|
||||
text: string,
|
||||
opts: Flags & { metadata?: string },
|
||||
): Promise<number> {
|
||||
if (!collection || !text) {
|
||||
render.err("Usage: claudemesh vector store <collection> <text> [--metadata <json>]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
if (opts.metadata) {
|
||||
try { metadata = JSON.parse(opts.metadata) as Record<string, unknown>; }
|
||||
catch { render.err("--metadata must be JSON"); return EXIT.INVALID_ARGS; }
|
||||
}
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const id = await client.vectorStore(collection, text, metadata);
|
||||
if (!id) { render.err("store failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson({ id, collection });
|
||||
else render.ok(`stored in ${clay(collection)}`, dim(id));
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runVectorSearch(
|
||||
collection: string,
|
||||
query: string,
|
||||
opts: Flags & { limit?: string },
|
||||
): Promise<number> {
|
||||
if (!collection || !query) {
|
||||
render.err("Usage: claudemesh vector search <collection> <query> [--limit N]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const limit = opts.limit ? parseInt(opts.limit, 10) : undefined;
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const hits = await client.vectorSearch(collection, query, limit);
|
||||
if (opts.json) { emitJson(hits); return EXIT.SUCCESS; }
|
||||
if (hits.length === 0) { render.info(dim("(no matches)")); return EXIT.SUCCESS; }
|
||||
render.section(`${hits.length} match${hits.length === 1 ? "" : "es"} in ${clay(collection)}`);
|
||||
for (const h of hits) {
|
||||
process.stdout.write(` ${bold(h.score.toFixed(3))} ${dim(h.id.slice(0, 8))} ${h.text}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runVectorDelete(
|
||||
collection: string,
|
||||
id: string,
|
||||
opts: Flags,
|
||||
): Promise<number> {
|
||||
if (!collection || !id) {
|
||||
render.err("Usage: claudemesh vector delete <collection> <id>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.vectorDelete(collection, id);
|
||||
if (opts.json) emitJson({ id, deleted: true });
|
||||
else render.ok(`deleted ${dim(id.slice(0, 8))}`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runVectorCollections(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const cols = await client.listCollections();
|
||||
if (opts.json) { emitJson(cols); return EXIT.SUCCESS; }
|
||||
if (cols.length === 0) { render.info(dim("(no collections)")); return EXIT.SUCCESS; }
|
||||
render.section(`vector collections (${cols.length})`);
|
||||
for (const c of cols) process.stdout.write(` ${clay(c)}\n`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// graph — Cypher query / execute
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runGraphQuery(cypher: string, opts: Flags): Promise<number> {
|
||||
if (!cypher) { render.err("Usage: claudemesh graph query \"<cypher>\""); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const rows = await client.graphQuery(cypher);
|
||||
if (opts.json) { emitJson(rows); return EXIT.SUCCESS; }
|
||||
if (rows.length === 0) { render.info(dim("(no rows)")); return EXIT.SUCCESS; }
|
||||
render.section(`${rows.length} row${rows.length === 1 ? "" : "s"}`);
|
||||
for (const r of rows) process.stdout.write(` ${JSON.stringify(r)}\n`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runGraphExecute(cypher: string, opts: Flags): Promise<number> {
|
||||
if (!cypher) { render.err("Usage: claudemesh graph execute \"<cypher>\""); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const rows = await client.graphExecute(cypher);
|
||||
if (opts.json) { emitJson(rows); return EXIT.SUCCESS; }
|
||||
render.ok("executed", `${rows.length} row${rows.length === 1 ? "" : "s"} affected`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// context — share work-context summaries
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runContextShare(
|
||||
summary: string,
|
||||
opts: Flags & { files?: string; findings?: string; tags?: string },
|
||||
): Promise<number> {
|
||||
if (!summary) {
|
||||
render.err("Usage: claudemesh context share \"<summary>\" [--files a,b] [--findings x,y] [--tags t1,t2]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const files = opts.files?.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const findings = opts.findings?.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const tags = opts.tags?.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.shareContext(summary, files, findings, tags);
|
||||
if (opts.json) emitJson({ shared: true, summary });
|
||||
else render.ok("context shared");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runContextGet(query: string, opts: Flags): Promise<number> {
|
||||
if (!query) { render.err("Usage: claudemesh context get \"<query>\""); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const ctxs = await client.getContext(query);
|
||||
if (opts.json) { emitJson(ctxs); return EXIT.SUCCESS; }
|
||||
if (ctxs.length === 0) { render.info(dim("(no matches)")); return EXIT.SUCCESS; }
|
||||
render.section(`${ctxs.length} context${ctxs.length === 1 ? "" : "s"}`);
|
||||
for (const c of ctxs) {
|
||||
process.stdout.write(` ${bold(c.peerName)} ${dim("·")} ${c.updatedAt}\n`);
|
||||
process.stdout.write(` ${c.summary}\n`);
|
||||
if (c.tags.length) process.stdout.write(` ${dim("tags: " + c.tags.join(", "))}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runContextList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const ctxs = await client.listContexts();
|
||||
if (opts.json) { emitJson(ctxs); return EXIT.SUCCESS; }
|
||||
if (ctxs.length === 0) { render.info(dim("(no contexts)")); return EXIT.SUCCESS; }
|
||||
render.section(`shared contexts (${ctxs.length})`);
|
||||
for (const c of ctxs) {
|
||||
process.stdout.write(` ${bold(c.peerName)} ${dim("·")} ${c.updatedAt}\n`);
|
||||
process.stdout.write(` ${c.summary}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// stream — pub/sub event bus per mesh
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runStreamCreate(name: string, opts: Flags): Promise<number> {
|
||||
if (!name) { render.err("Usage: claudemesh stream create <name>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const id = await client.createStream(name);
|
||||
if (!id) { render.err("create failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson({ id, name });
|
||||
else render.ok(`created ${clay(name)}`, dim(id));
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStreamPublish(name: string, dataRaw: string, opts: Flags): Promise<number> {
|
||||
if (!name || dataRaw === undefined) {
|
||||
render.err("Usage: claudemesh stream publish <name> <json-or-text>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(dataRaw); } catch { data = dataRaw; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.publish(name, data);
|
||||
if (opts.json) emitJson({ published: true, name });
|
||||
else render.ok(`published to ${clay(name)}`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStreamList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const streams = await client.listStreams();
|
||||
if (opts.json) { emitJson(streams); return EXIT.SUCCESS; }
|
||||
if (streams.length === 0) { render.info(dim("(no streams)")); return EXIT.SUCCESS; }
|
||||
render.section(`streams (${streams.length})`);
|
||||
for (const s of streams) {
|
||||
process.stdout.write(` ${clay(s.name)} ${dim(`· ${s.subscriberCount} subscriber${s.subscriberCount === 1 ? "" : "s"} · by ${s.createdBy}`)}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// sql — typed query against per-mesh tables
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runSqlQuery(sql: string, opts: Flags): Promise<number> {
|
||||
if (!sql) { render.err("Usage: claudemesh sql query \"<select>\""); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const result = await client.meshQuery(sql);
|
||||
if (!result) { render.err("query timed out"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) { emitJson(result); return EXIT.SUCCESS; }
|
||||
render.section(`${result.rowCount} row${result.rowCount === 1 ? "" : "s"}`);
|
||||
if (result.columns.length > 0) {
|
||||
process.stdout.write(` ${dim(result.columns.join(" "))}\n`);
|
||||
for (const row of result.rows) {
|
||||
process.stdout.write(` ${result.columns.map((c) => String(row[c] ?? "")).join(" ")}\n`);
|
||||
}
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSqlExecute(sql: string, opts: Flags): Promise<number> {
|
||||
if (!sql) { render.err("Usage: claudemesh sql execute \"<statement>\""); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.meshExecute(sql);
|
||||
if (opts.json) emitJson({ executed: true });
|
||||
else render.ok("executed");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSqlSchema(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const tables = await client.meshSchema();
|
||||
if (opts.json) { emitJson(tables); return EXIT.SUCCESS; }
|
||||
if (tables.length === 0) { render.info(dim("(no tables)")); return EXIT.SUCCESS; }
|
||||
render.section(`mesh tables (${tables.length})`);
|
||||
for (const t of tables) {
|
||||
process.stdout.write(` ${bold(t.name)}\n`);
|
||||
for (const c of t.columns) {
|
||||
const nullable = c.nullable ? "" : " not null";
|
||||
process.stdout.write(` ${c.name} ${dim(c.type + nullable)}\n`);
|
||||
}
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// skill — list / get / remove (publish currently goes through MCP)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runSkillList(opts: Flags & { query?: string }): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const skills = await client.listSkills(opts.query);
|
||||
if (opts.json) { emitJson(skills); return EXIT.SUCCESS; }
|
||||
if (skills.length === 0) { render.info(dim("(no skills)")); return EXIT.SUCCESS; }
|
||||
render.section(`mesh skills (${skills.length})`);
|
||||
for (const s of skills) {
|
||||
process.stdout.write(` ${bold(s.name)} ${dim("· by " + s.author)}\n`);
|
||||
process.stdout.write(` ${s.description}\n`);
|
||||
if (s.tags.length) process.stdout.write(` ${dim("tags: " + s.tags.join(", "))}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSkillGet(name: string, opts: Flags): Promise<number> {
|
||||
if (!name) { render.err("Usage: claudemesh skill get <name>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const skill = await client.getSkill(name);
|
||||
if (!skill) { render.err(`skill "${name}" not found`); return EXIT.NOT_FOUND; }
|
||||
if (opts.json) { emitJson(skill); return EXIT.SUCCESS; }
|
||||
render.section(skill.name);
|
||||
render.kv([
|
||||
["author", skill.author],
|
||||
["created", skill.createdAt],
|
||||
["tags", skill.tags.join(", ") || dim("(none)")],
|
||||
]);
|
||||
render.blank();
|
||||
render.info(skill.description);
|
||||
render.blank();
|
||||
process.stdout.write(skill.instructions + "\n");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSkillRemove(name: string, opts: Flags): Promise<number> {
|
||||
if (!name) { render.err("Usage: claudemesh skill remove <name>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const removed = await client.removeSkill(name);
|
||||
if (opts.json) emitJson({ name, removed });
|
||||
else if (removed) render.ok(`removed ${bold(name)}`);
|
||||
else render.err(`skill "${name}" not found`);
|
||||
return removed ? EXIT.SUCCESS : EXIT.NOT_FOUND;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// vault — encrypted per-mesh secrets list / delete (set/get need crypto)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runVaultList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const entries = await client.vaultList();
|
||||
if (opts.json) { emitJson(entries); return EXIT.SUCCESS; }
|
||||
if (!entries || entries.length === 0) { render.info(dim("(vault empty)")); return EXIT.SUCCESS; }
|
||||
render.section(`vault (${entries.length})`);
|
||||
for (const e of entries) {
|
||||
const k = String((e as any)?.key ?? "?");
|
||||
const t = String((e as any)?.entry_type ?? "");
|
||||
process.stdout.write(` ${bold(k)} ${dim(t)}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runVaultDelete(key: string, opts: Flags): Promise<number> {
|
||||
if (!key) { render.err("Usage: claudemesh vault delete <key>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const ok = await client.vaultDelete(key);
|
||||
if (opts.json) emitJson({ key, deleted: ok });
|
||||
else if (ok) render.ok(`deleted ${bold(key)}`);
|
||||
else render.err(`vault key "${key}" not found`);
|
||||
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// watch — URL change watchers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runWatchList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const watches = await client.watchList();
|
||||
if (opts.json) { emitJson(watches); return EXIT.SUCCESS; }
|
||||
if (!watches || watches.length === 0) { render.info(dim("(no watches)")); return EXIT.SUCCESS; }
|
||||
render.section(`url watches (${watches.length})`);
|
||||
for (const w of watches) {
|
||||
const id = String((w as any).id ?? "?");
|
||||
const url = String((w as any).url ?? "");
|
||||
const label = (w as any).label ? ` ${dim("(" + (w as any).label + ")")}` : "";
|
||||
process.stdout.write(` ${dim(id.slice(0, 8))} ${clay(url)}${label}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runUnwatch(id: string, opts: Flags): Promise<number> {
|
||||
if (!id) { render.err("Usage: claudemesh watch remove <id>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const ok = await client.unwatch(id);
|
||||
if (opts.json) emitJson({ id, removed: ok });
|
||||
else if (ok) render.ok(`unwatched ${dim(id.slice(0, 8))}`);
|
||||
else render.err(`watch "${id}" not found`);
|
||||
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// webhook — outbound HTTP triggers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runWebhookList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const hooks = await client.listWebhooks();
|
||||
if (opts.json) { emitJson(hooks); return EXIT.SUCCESS; }
|
||||
if (hooks.length === 0) { render.info(dim("(no webhooks)")); return EXIT.SUCCESS; }
|
||||
render.section(`webhooks (${hooks.length})`);
|
||||
for (const h of hooks) {
|
||||
const dot = h.active ? "●" : dim("○");
|
||||
process.stdout.write(` ${dot} ${bold(h.name)} ${dim("· " + h.url)}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWebhookDelete(name: string, opts: Flags): Promise<number> {
|
||||
if (!name) { render.err("Usage: claudemesh webhook delete <name>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const ok = await client.deleteWebhook(name);
|
||||
if (opts.json) emitJson({ name, deleted: ok });
|
||||
else if (ok) render.ok(`deleted ${bold(name)}`);
|
||||
else render.err(`webhook "${name}" not found`);
|
||||
return ok ? EXIT.SUCCESS : EXIT.NOT_FOUND;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// task — list / create (claim / complete already in broker-actions.ts)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runTaskList(opts: Flags & { status?: string; assignee?: string }): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const tasks = await client.listTasks(opts.status, opts.assignee);
|
||||
if (opts.json) { emitJson(tasks); return EXIT.SUCCESS; }
|
||||
if (tasks.length === 0) { render.info(dim("(no tasks)")); return EXIT.SUCCESS; }
|
||||
render.section(`tasks (${tasks.length})`);
|
||||
for (const t of tasks) {
|
||||
const dot = t.status === "done" ? "●" : t.status === "claimed" ? clay("●") : dim("○");
|
||||
const assignee = t.assignee ? dim(` → ${t.assignee}`) : "";
|
||||
process.stdout.write(` ${dot} ${dim(t.id.slice(0, 8))} ${bold(t.title)}${assignee}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTaskCreate(
|
||||
title: string,
|
||||
opts: Flags & { assignee?: string; priority?: string; tags?: string },
|
||||
): Promise<number> {
|
||||
if (!title) { render.err("Usage: claudemesh task create <title> [--assignee X] [--priority P]"); return EXIT.INVALID_ARGS; }
|
||||
const tags = opts.tags?.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const id = await client.createTask(title, opts.assignee, opts.priority, tags);
|
||||
if (!id) { render.err("create failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson({ id, title });
|
||||
else render.ok(`created ${dim(id.slice(0, 8))}`, title);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// clock — set / pause / resume (get already in broker-actions.ts)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runClockSet(speed: string, opts: Flags): Promise<number> {
|
||||
const s = parseFloat(speed);
|
||||
if (!Number.isFinite(s) || s < 0) {
|
||||
render.err("Usage: claudemesh clock set <speed>", "speed is a non-negative number, e.g. 1.0 = realtime, 0 = paused, 60 = 60× faster");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const r = await client.setClock(s);
|
||||
if (!r) { render.err("clock set failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson(r);
|
||||
else render.ok(`clock set to ${bold("x" + r.speed)}`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClockPause(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const r = await client.pauseClock();
|
||||
if (!r) { render.err("pause failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson(r);
|
||||
else render.ok("clock paused");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runClockResume(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const r = await client.resumeClock();
|
||||
if (!r) { render.err("resume failed"); return EXIT.INTERNAL_ERROR; }
|
||||
if (opts.json) emitJson(r);
|
||||
else render.ok("clock resumed");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// mesh-mcp — list deployed mesh-MCP servers, call tools, view catalog
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runMeshMcpList(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const servers = await client.mcpList();
|
||||
if (opts.json) { emitJson(servers); return EXIT.SUCCESS; }
|
||||
if (servers.length === 0) { render.info(dim("(no mesh-MCP servers)")); return EXIT.SUCCESS; }
|
||||
render.section(`mesh-MCP servers (${servers.length})`);
|
||||
for (const s of servers) {
|
||||
process.stdout.write(` ${bold(s.name)} ${dim("· hosted by " + s.hostedBy)}\n`);
|
||||
process.stdout.write(` ${s.description}\n`);
|
||||
if (s.tools.length) process.stdout.write(` ${dim("tools: " + s.tools.map((t) => t.name).join(", "))}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMeshMcpCall(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
argsRaw: string,
|
||||
opts: Flags,
|
||||
): Promise<number> {
|
||||
if (!serverName || !toolName) {
|
||||
render.err("Usage: claudemesh mesh-mcp call <server> <tool> [json-args]");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
let args: Record<string, unknown> = {};
|
||||
if (argsRaw) {
|
||||
try { args = JSON.parse(argsRaw) as Record<string, unknown>; }
|
||||
catch { render.err("args must be JSON"); return EXIT.INVALID_ARGS; }
|
||||
}
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const r = await client.mcpCall(serverName, toolName, args);
|
||||
if (r.error) {
|
||||
if (opts.json) emitJson({ ok: false, error: r.error });
|
||||
else render.err(r.error);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
if (opts.json) emitJson({ ok: true, result: r.result });
|
||||
else process.stdout.write(JSON.stringify(r.result, null, 2) + "\n");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMeshMcpCatalog(opts: Flags): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const cat = await client.mcpCatalog();
|
||||
if (opts.json) { emitJson(cat); return EXIT.SUCCESS; }
|
||||
if (!cat || cat.length === 0) { render.info(dim("(catalog empty)")); return EXIT.SUCCESS; }
|
||||
render.section(`mesh-MCP catalog (${cat.length})`);
|
||||
for (const c of cat as Array<Record<string, unknown>>) {
|
||||
process.stdout.write(` ${bold(String(c.name ?? "?"))} ${dim(String(c.status ?? ""))}\n`);
|
||||
if (c.description) process.stdout.write(` ${String(c.description)}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// file — list / status / delete (upload / get-by-name go through MCP for now)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function runFileList(opts: Flags & { query?: string }): Promise<number> {
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const files = await client.listFiles(opts.query);
|
||||
if (opts.json) { emitJson(files); return EXIT.SUCCESS; }
|
||||
if (files.length === 0) { render.info(dim("(no files)")); return EXIT.SUCCESS; }
|
||||
render.section(`mesh files (${files.length})`);
|
||||
for (const f of files) {
|
||||
const sizeKb = (f.size / 1024).toFixed(1);
|
||||
process.stdout.write(` ${bold(f.name)} ${dim(`· ${sizeKb} KB · by ${f.uploadedBy}`)}\n`);
|
||||
if (f.tags.length) process.stdout.write(` ${dim("tags: " + f.tags.join(", "))}\n`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runFileStatus(id: string, opts: Flags): Promise<number> {
|
||||
if (!id) { render.err("Usage: claudemesh file status <file-id>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const accessors = await client.fileStatus(id);
|
||||
if (opts.json) { emitJson(accessors); return EXIT.SUCCESS; }
|
||||
if (accessors.length === 0) { render.info(dim("(no accesses recorded)")); return EXIT.SUCCESS; }
|
||||
render.section(`accesses for ${id.slice(0, 8)}`);
|
||||
for (const a of accessors) process.stdout.write(` ${bold(a.peerName)} ${dim("· " + a.accessedAt)}\n`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
export async function runFileDelete(id: string, opts: Flags): Promise<number> {
|
||||
if (!id) { render.err("Usage: claudemesh file delete <file-id>"); return EXIT.INVALID_ARGS; }
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
await client.deleteFile(id);
|
||||
if (opts.json) emitJson({ id, deleted: true });
|
||||
else render.ok(`deleted ${dim(id.slice(0, 8))}`);
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
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);
|
||||
|
||||
if (opts.json) {
|
||||
@@ -20,16 +20,17 @@ export async function recall(
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
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.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("");
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
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);
|
||||
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
|
||||
if (opts.json) {
|
||||
@@ -20,9 +21,10 @@ export async function remember(
|
||||
}
|
||||
|
||||
if (id) {
|
||||
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
|
||||
render.ok("remembered", dim(id.slice(0, 8)));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
console.error("\u2717 Failed to store memory");
|
||||
render.err("failed to store memory");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
@@ -35,13 +37,12 @@ function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
if (target <= now) target.setDate(target.getDate() + 1);
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
@@ -54,61 +55,53 @@ export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
if (scheduled.length === 0) { render.info(dim("No pending reminders.")); return; }
|
||||
render.section(`reminders (${scheduled.length})`);
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
process.stdout.write(` ${bold(m.id.slice(0, 8))} ${dim("→")} ${to} ${dim("at")} ${when}\n`);
|
||||
process.stdout.write(` ${dim(m.message.slice(0, 80))}\n\n`);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
if (!id) { render.err("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
if (ok) render.ok(`cancelled ${bold(id.slice(0, 8))}`);
|
||||
else { render.err(`not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
render.err("Usage: claudemesh remind <message> --in <duration>");
|
||||
render.info(dim(" claudemesh remind <message> --at <time>"));
|
||||
render.info(dim(' claudemesh remind <message> --cron "0 */2 * * *"'));
|
||||
render.info(dim(" claudemesh remind list"));
|
||||
render.info(dim(" claudemesh remind cancel <id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
render.err('Specify when', 'use --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
@@ -117,7 +110,7 @@ export async function runRemind(
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
render.err(`Peer "${flags.to}" not found`, `online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
@@ -127,16 +120,22 @@ export async function runRemind(
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
if (!result) { render.err("Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
render.ok(
|
||||
`recurring reminder set`,
|
||||
`${result.scheduledId.slice(0, 8)} · ${clay(message)} → ${toLabel} · cron ${flags.cron} · next ${nextFire}`,
|
||||
);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
render.ok(
|
||||
`reminder set`,
|
||||
`${result.scheduledId.slice(0, 8)} · ${clay(message)} → ${toLabel} at ${when}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,21 +10,17 @@
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
|
||||
export function runSeedTestMesh(args: string[]): void {
|
||||
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
|
||||
if (!brokerUrl || !meshId || !memberId || !pubkey || !slug) {
|
||||
console.error(
|
||||
"Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>",
|
||||
);
|
||||
console.error("");
|
||||
console.error(
|
||||
'Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test',
|
||||
);
|
||||
render.err("Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>");
|
||||
render.info(dim('Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test'));
|
||||
process.exit(1);
|
||||
}
|
||||
const config = readConfig();
|
||||
// Remove any prior entry with same slug (idempotent).
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
config.meshes.push({
|
||||
meshId,
|
||||
@@ -32,13 +28,11 @@ export function runSeedTestMesh(args: string[]): void {
|
||||
slug,
|
||||
name: `Test: ${slug}`,
|
||||
pubkey,
|
||||
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
|
||||
secretKey: "dev-only-stub",
|
||||
brokerUrl,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
writeConfig(config);
|
||||
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
|
||||
console.log(
|
||||
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
|
||||
);
|
||||
render.ok(`seeded ${bold(slug)}`, dim(meshId));
|
||||
render.hint(`run ${bold("claudemesh mcp")} to connect, or register with Claude Code via ${bold("claudemesh install")}`);
|
||||
}
|
||||
|
||||
@@ -6,35 +6,77 @@
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*
|
||||
* Warm path: dials the per-mesh bridge socket the push-pipe holds open
|
||||
* (~5ms). Cold path: opens its own WS via `withMesh` (~300-700ms).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { tryBridge } from "~/services/bridge/client.js";
|
||||
import type { Priority } from "~/services/broker/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
if (!to || !message) {
|
||||
render.err("Usage: claudemesh send <to> <message>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
// Resolve which mesh to use. With --mesh, target it directly.
|
||||
// Without, use first joined mesh — same default as withMesh.
|
||||
const config = readConfig();
|
||||
const meshSlug =
|
||||
flags.mesh ??
|
||||
(config.meshes.length === 1 ? config.meshes[0]!.slug : null);
|
||||
|
||||
// Warm path — only when mesh is unambiguous.
|
||||
if (meshSlug) {
|
||||
const bridged = await tryBridge(meshSlug, "send", { to, message, priority });
|
||||
if (bridged !== null) {
|
||||
if (bridged.ok) {
|
||||
const r = bridged.result as { messageId?: string };
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ ok: true, messageId: r.messageId, target: to }));
|
||||
} else {
|
||||
render.ok(`sent to ${to}`, r.messageId ? dim(r.messageId.slice(0, 8)) : undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Bridge reachable but op failed — surface error, don't fall through.
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: bridged.error }));
|
||||
} else {
|
||||
render.err(`send failed: ${bridged.error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
// bridged === null → bridge unreachable, fall through to cold path
|
||||
}
|
||||
|
||||
// Cold path
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
render.err(`Peer "${to}" not found.`, `online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
@@ -42,9 +84,17 @@ export async function runSend(flags: SendFlags, to: string, message: string): Pr
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ ok: true, messageId: result.messageId, target: to }));
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
render.ok(`sent to ${to}`, result.messageId ? dim(result.messageId.slice(0, 8)) : undefined);
|
||||
}
|
||||
} else {
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: result.error ?? "unknown" }));
|
||||
} else {
|
||||
render.err(`send failed: ${result.error ?? "unknown error"}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
@@ -12,14 +14,10 @@ export interface StateFlags {
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
render.info(dim("(not set)"));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
@@ -27,13 +25,12 @@ export async function runStateGet(flags: StateFlags, key: string): Promise<void>
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
render.info(val);
|
||||
render.info(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
@@ -43,16 +40,11 @@ export async function runStateSet(flags: StateFlags, key: string, value: string)
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
render.ok(`${bold(key)} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
@@ -62,14 +54,15 @@ export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
render.info(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
render.section(`state (${entries.length})`);
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
process.stdout.write(` ${bold(e.key)}: ${val}\n`);
|
||||
process.stdout.write(` ${dim(e.updatedBy + " · " + new Date(e.updatedAt).toLocaleString())}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PATHS } from "~/constants/paths.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const CLAUDE_SKILLS_ROOT = join(homedir(), ".claude", "skills");
|
||||
|
||||
/** Locate the bundled `skills/` directory shipped with this package. */
|
||||
function bundledSkillsDir(): string | null {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgRoot = join(dirname(here), "..", "..");
|
||||
const skillsDir = join(pkgRoot, "skills");
|
||||
return existsSync(skillsDir) ? skillsDir : null;
|
||||
}
|
||||
|
||||
export async function uninstall(): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
// Remove MCP server from ~/.claude.json
|
||||
if (existsSync(PATHS.CLAUDE_JSON)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
|
||||
@@ -15,13 +28,12 @@ export async function uninstall(): Promise<number> {
|
||||
if (servers && "claudemesh" in servers) {
|
||||
delete servers.claudemesh;
|
||||
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
|
||||
render.ok("removed MCP server", dim("~/.claude.json"));
|
||||
removed++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove only claudemesh hooks from ~/.claude/settings.json
|
||||
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
|
||||
@@ -43,15 +55,40 @@ export async function uninstall(): Promise<number> {
|
||||
}
|
||||
if (removedHooks > 0) {
|
||||
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
|
||||
render.ok(`removed ${removedHooks} claudemesh hook${removedHooks === 1 ? "" : "s"}`, dim("settings.json"));
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Skills shipped by claudemesh install — remove from ~/.claude/skills/.
|
||||
const src = bundledSkillsDir();
|
||||
if (src) {
|
||||
const removedSkills: string[] = [];
|
||||
try {
|
||||
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dst = join(CLAUDE_SKILLS_ROOT, entry.name);
|
||||
if (existsSync(dst)) {
|
||||
try {
|
||||
rmSync(dst, { recursive: true, force: true });
|
||||
removedSkills.push(entry.name);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
if (removedSkills.length > 0) {
|
||||
render.ok(
|
||||
`removed Claude skill${removedSkills.length === 1 ? "" : "s"}`,
|
||||
removedSkills.join(", "),
|
||||
);
|
||||
removed++;
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
if (removed === 0) {
|
||||
console.log(" Nothing to remove — claudemesh was not installed.");
|
||||
render.info(dim("Nothing to remove — claudemesh was not installed."));
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
|
||||
@@ -20,6 +20,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { dim } from "~/ui/styles.js";
|
||||
|
||||
function resolveClaudemeshBin(): string {
|
||||
// argv[1] points to the running binary; prefer that over $PATH so we
|
||||
@@ -80,10 +82,9 @@ EOF
|
||||
// Re-register with Launch Services so the scheme resolves here.
|
||||
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
|
||||
if (lsreg.status !== 0) {
|
||||
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
|
||||
render.warn("lsregister returned non-zero", "scheme may not activate until Finder rescans.");
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
|
||||
console.log(` app bundle: ${appDir}`);
|
||||
render.ok("registered claudemesh:// scheme on macOS", dim(appDir));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -106,12 +107,11 @@ NoDisplay=true
|
||||
|
||||
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
|
||||
if (xdg1.status !== 0) {
|
||||
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
|
||||
render.warn("xdg-mime not available — skipped mime default registration");
|
||||
}
|
||||
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
|
||||
xdg2.status ?? 0; // best effort
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
|
||||
console.log(` desktop entry: ${desktopPath}`);
|
||||
render.ok("registered claudemesh:// scheme on Linux", dim(desktopPath));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -131,30 +131,30 @@ function installWindows(): number {
|
||||
writeFileSync(regPath, lines.join("\r\n"));
|
||||
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
|
||||
if (res.status !== 0) {
|
||||
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
|
||||
render.warn("reg.exe import failed", `manual: double-click ${regPath}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
|
||||
render.ok("registered claudemesh:// scheme on Windows");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallDarwin(): number {
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on macOS");
|
||||
render.ok("removed claudemesh:// handler on macOS");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallLinux(): number {
|
||||
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
|
||||
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Linux");
|
||||
render.ok("removed claudemesh:// handler on Linux");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallWindows(): number {
|
||||
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Windows");
|
||||
render.ok("removed claudemesh:// handler on Windows");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -170,9 +170,9 @@ export async function runUrlHandler(action: string | undefined): Promise<number>
|
||||
if (p === "linux") return uninstallLinux();
|
||||
if (p === "win32") return uninstallWindows();
|
||||
} else {
|
||||
console.error("Usage: claudemesh url-handler <install|uninstall>");
|
||||
render.err("Usage: claudemesh url-handler <install|uninstall>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
console.error(`Unsupported platform: ${p}`);
|
||||
render.err(`Unsupported platform: ${p}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, clay, dim } from "~/ui/styles.js";
|
||||
|
||||
function safetyNumber(myPubkey: string, peerPubkey: string): string {
|
||||
const a = Buffer.from(myPubkey, "hex");
|
||||
const b = Buffer.from(peerPubkey, "hex");
|
||||
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
|
||||
const hash = createHash("sha256").update(lo).update(hi).digest();
|
||||
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
@@ -40,20 +41,15 @@ export async function runVerify(
|
||||
target: string | undefined,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
|
||||
|
||||
const config = readConfig();
|
||||
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||
if (!meshSlug) {
|
||||
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
|
||||
render.err("No meshes joined.", "Run `claudemesh join <url>` first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const mesh = config.meshes.find((m) => m.slug === meshSlug);
|
||||
if (!mesh) {
|
||||
console.error(` Mesh "${meshSlug}" not found locally.`);
|
||||
render.err(`Mesh "${meshSlug}" not found locally.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
@@ -63,7 +59,7 @@ export async function runVerify(
|
||||
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
|
||||
: peers;
|
||||
if (targets.length === 0) {
|
||||
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
|
||||
render.err(`No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
@@ -77,19 +73,16 @@ export async function runVerify(
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
|
||||
console.log("");
|
||||
render.section(`safety numbers on ${meshSlug}`);
|
||||
for (const p of targets) {
|
||||
const sn = safetyNumber(mesh.pubkey, p.pubkey);
|
||||
console.log(` ${bold(p.displayName)}`);
|
||||
console.log(` ${clay(sn)}`);
|
||||
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}…`)}`);
|
||||
console.log("");
|
||||
process.stdout.write(` ${bold(p.displayName)}\n`);
|
||||
process.stdout.write(` ${clay(sn)}\n`);
|
||||
process.stdout.write(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}…`)}\n\n`);
|
||||
}
|
||||
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
|
||||
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
|
||||
console.log("");
|
||||
render.hint("Compare these digits with your peer (phone, in person — not chat).");
|
||||
render.hint("If they match on both sides, the channel is not being intercepted.");
|
||||
render.blank();
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { whoAmI } from "~/services/auth/facade.js";
|
||||
import { dim, icons } from "~/ui/styles.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||
@@ -11,16 +12,19 @@ export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||
}
|
||||
|
||||
if (!result.signed_in) {
|
||||
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
|
||||
render.err("Not signed in", "Run `claudemesh login` to sign in.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
|
||||
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
|
||||
if (result.meshes) {
|
||||
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
|
||||
}
|
||||
console.log();
|
||||
render.section("whoami");
|
||||
render.kv([
|
||||
["user", `${bold(result.user!.display_name)} ${dim(`(${result.user!.email})`)}`],
|
||||
["token", `${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`],
|
||||
...(result.meshes
|
||||
? [["meshes", `${result.meshes.owned} owned · ${result.meshes.guest} guest`] as [string, string]]
|
||||
: []),
|
||||
]);
|
||||
render.blank();
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -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
114
apps/cli/src/services/bridge/client.ts
Normal file
114
apps/cli/src/services/bridge/client.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
93
apps/cli/src/services/bridge/protocol.ts
Normal file
93
apps/cli/src/services/bridge/protocol.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
229
apps/cli/src/services/bridge/server.ts
Normal file
229
apps/cli/src/services/bridge/server.ts
Normal 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 {}
|
||||
},
|
||||
};
|
||||
}
|
||||
324
apps/cli/src/services/policy/index.ts
Normal file
324
apps/cli/src/services/policy/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user