refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

View File

@@ -0,0 +1,243 @@
# CLI Wizard Architecture Refactor
**Status:** backlog
**Created:** 2026-04-10
**Source:** Reverse-engineered from `@posthog/wizard` (npm cache), applied to `apps/cli/src/commands/launch.ts`
## Why
Launch wizard has three compounding problems:
1. **Imperative branching**`launch.ts` checks account → mesh → name → role → exec in hardcoded order. Adding a screen requires touching existing code. Hard to reason about `--resume`, `--non-interactive`, and skip conditions.
2. **Terminal bleed-through on handoff** — wizard→`claude` exec corrupts Ink's TUI state (garbled word wraps, tool labels overwritten, spinner fragments fused to paths). Root cause is spread across multiple exit paths instead of one choke point.
3. **Inconsistent visual design** — ad-hoc colors per file, no central palette, no shared icon set, no shared layout primitives. Every screen reinvents status rows, centering, and spacing.
PostHog's wizard solves all three with one architectural pattern: **declarative flow pipelines + session-as-store + shared visual primitives**. This artifact captures the plan to port that pattern.
## What PostHog does (the reference)
### Flow pipeline (`flows.ts` + `router.ts`)
Each wizard flow is an array of screen entries:
```ts
export const FLOWS = {
[Flow.Wizard]: [
{ screen: Screen.Intro, isComplete: s => s.setupConfirmed },
{ screen: Screen.HealthCheck, isComplete: s => s.readinessResult !== null },
{ screen: Screen.Setup, show: needsSetup, isComplete: s => !needsSetup(s) },
{ screen: Screen.Auth, isComplete: s => s.credentials !== null },
{ screen: Screen.Run, isComplete: s => s.runPhase === RunPhase.Completed },
{ screen: Screen.Outro, isComplete: s => s.outroDismissed },
],
};
```
The router walks the array, skips entries where `show(s) === false` or `isComplete(s) === true`, and returns the first remaining entry. Zero switch statements. Zero hardcoded transitions. Adding a screen = appending an object.
### Overlay stack
Separate from the linear flow cursor. Interrupts (port conflict, auth expired, managed settings) are pushed onto `overlays[]` from anywhere and popped when dismissed. Active screen = top of overlay stack OR flow cursor. Flows never need to know about interrupts.
### Session as single source of truth
One `WizardStore` holds all session state. Screens subscribe via React 18 `useSyncExternalStore`. Completion predicates read session; imperative code writes session; the router re-resolves on every change.
### Visual primitives
- `styles.ts` — 6-color palette (`Colors`), 9-icon set (`Icons`), alignment enums (`HAlign`, `VAlign`)
- `CardLayout` — semantic centering wrapper used by every screen
- `PickerMenu` — the only selection primitive, used for every choice
- `screen-registry.ts` — maps `Screen` enum → React component
- Brand mark: three colored `█` blocks next to the wizard name on every screen header
## What claudemesh should do
### Target file layout
```
apps/cli/src/
├── commands/
│ └── launch.ts # thin entrypoint: parse flags → start TUI
└── ui/
├── styles.ts # palette, icons, alignment enums
├── store.ts # LaunchStore (session + subscribe)
├── router.ts # flow cursor + overlay stack
├── flows.ts # FLOWS = { Launch: [...], Join: [...] }
├── screen-registry.ts # Screen enum → component
├── primitives/
│ ├── CardLayout.tsx
│ ├── PickerMenu.tsx
│ ├── StatusRows.tsx # new: "Directory ✓ /claudemesh" pattern
│ ├── BrandMark.tsx # new: 3 colored squares + label
│ └── LoadingBox.tsx
└── screens/
├── WelcomeScreen.tsx
├── AccountScreen.tsx
├── MeshPickerScreen.tsx
├── NameRoleScreen.tsx
├── ConfirmScreen.tsx
└── HandoffScreen.tsx # last screen; its unmount triggers exec claude
```
### Flow definition
```ts
export const FLOWS = {
[Flow.Launch]: [
{ screen: Screen.Welcome, isComplete: s => s.welcomed },
{ screen: Screen.Account, show: s => !s.hasAccount, isComplete: s => s.hasAccount },
{ screen: Screen.MeshPicker, show: s => s.meshes.length > 1, isComplete: s => s.meshSlug !== null },
{ screen: Screen.NameRole, isComplete: s => s.displayName !== null && s.role !== null },
{ screen: Screen.Confirm, isComplete: s => s.confirmed },
{ screen: Screen.Handoff, isComplete: () => false }, // terminal screen
],
};
```
### `--resume` works for free
`--resume <id>` populates the session from saved state; every satisfied predicate auto-skips. The wizard renders only the screens that still need input. No special `--resume` branches in screen code.
### `--non-interactive` works for free
Non-interactive mode: walk the flow, for each incomplete entry check if its required session fields can be sourced from CLI flags. If yes, populate and continue. If no, **fail fast with a clear message** naming the missing flag. Never silently guess defaults.
```
$ claudemesh launch --non-interactive --name Alexis
✗ Missing --mesh (required in non-interactive mode when >1 mesh joined)
Available meshes: alexis-mou, dev, staging
```
### Overlay interrupts claudemesh needs
- `BrokerDisconnect` — WS dropped mid-wizard, retry countdown
- `InviteInvalid` — paste invite screen rejected token
- `MeshNotFound``--mesh foo` passed but not joined
- `RateLimit` — broker rate limited the CLI, backoff timer
- `UpdateAvailable` — newer CLI version on npm, non-blocking banner
### Terminal handoff choke point
The last flow entry (`Screen.Handoff`) renders a brief "Launching Claude Code…" card, then:
```ts
// apps/cli/src/ui/screens/HandoffScreen.tsx (on mount)
useEffect(() => {
(async () => {
await inkApp.unmount();
await inkApp.waitUntilExit();
resetTerminal(); // single choke point for ANSI teardown
await flushStdout();
execa('claude', claudeArgs, { stdio: 'inherit' });
})();
}, []);
```
`resetTerminal()` lives in `apps/cli/src/ui/terminal.ts`:
```ts
export function resetTerminal() {
process.stdout.write(
'\x1b[0m' + // reset SGR
'\x1b[?25h' + // show cursor
'\x1b[?1049l' + // exit alt-screen
'\x1b[?1000l' + // disable mouse tracking
'\x1b[?1002l' +
'\x1b[?1003l' +
'\x1b[?1006l' +
'\x1b[?2004l' + // disable bracketed paste
'\x1b[2J' + // clear screen
'\x1b[H' // cursor home
);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
}
```
PostHog only does SGR reset + clear + home on unmount — they don't hand off to another full-screen app, so that's enough for them. Claudemesh needs the full mode-reset because Claude Code takes over the TTY.
### Visual design system
`apps/cli/src/ui/styles.ts`:
```ts
export const Colors = {
primary: 'cyan',
accent: '#7C3AED', // claudemesh purple
title: '#4C1D95',
success: 'green',
error: 'red',
warning: 'yellow',
muted: 'gray',
} as const;
export const Icons = {
check: '✔',
cross: '✘',
warning: '⚠',
arrow: '▶',
smallArrow: '▸',
bullet: '•',
diamond: '◆',
square: '█',
} as const;
export enum HAlign { Left = 'flex-start', Center = 'center', Right = 'flex-end' }
export enum VAlign { Top = 'flex-start', Center = 'center', Bottom = 'flex-end' }
```
Every screen imports from here. No inline color strings allowed.
### Status rows pattern
Replaces the current plain-text banner:
```
██ claudemesh launch
Directory ✔ /claudemesh
Account ✔ agutierrez@mineryreport.com
Mesh ✔ alexis-mou (9 peers online)
Name ✔ Alexis
Role ▸ (pick one)
▸ Continue
Change mesh
Cancel
```
## Implementation order
| # | Impact | Effort | Scope |
|---|---|---|---|
| 1 | High | S | `ui/styles.ts` — palette + icons + alignment enums; migrate existing screens |
| 2 | High | S | `ui/primitives/StatusRows.tsx` + `BrandMark.tsx` |
| 3 | High | M | `ui/store.ts` + `ui/router.ts` + `ui/flows.ts` (flow pipeline core) |
| 4 | High | M | Refactor `launch.ts` to render through router; port existing screens |
| 5 | High | S | `HandoffScreen` + `resetTerminal()` choke point — fixes TUI bleed bug |
| 6 | High | S | Preselect "Continue" on every confirmation screen (one-keypress happy path) |
| 7 | Med | M | Overlay stack + first two overlays (`BrokerDisconnect`, `InviteInvalid`) |
| 8 | Med | M | `--non-interactive` mode using flow walker + fail-fast flag check |
| 9 | Med | S | Per-mesh/per-role `preRunNotice` extension point |
| 10| Low | L | `DissolveTransition` / `ContentSequencer` polish primitives |
Steps 15 are the atomic unit of value: they fix the bleed-through bug, establish the visual system, and unblock everything else. Should ship as one PR.
Steps 69 can each ship independently.
Step 10 is polish — defer until after v0.2.
## Open questions
- **Ink version**: current CLI uses Ink 4.x? PostHog is on Ink 5 with `useSyncExternalStore`. Check `apps/cli/package.json` before porting the store pattern — Ink 4 needs a different subscription approach.
- **React version**: `useSyncExternalStore` is React 18+. Confirm.
- **Flow granularity**: should `Join` (paste invite) be a separate flow from `Launch`, or an overlay inside `Launch`? PostHog-style: separate flow triggered from the welcome screen. Simpler.
- **Resume semantics**: does `--resume <id>` resume the *Claude* session only, or also restore the wizard's last mesh/name/role choice? If the latter, need a `~/.claudemesh/sessions/<id>.json` alongside Claude's own session file.
## References
- PostHog wizard source: `~/.npm/_npx/b48b11b34a0cada0/node_modules/@posthog/wizard/dist/src/ui/tui/`
- `start-tui.js` — Ink bootstrap + cleanup
- `router.js` — flow cursor + overlay stack
- `flows.js` — declarative pipeline definition
- `styles.js` — palette + icons
- `screens/IntroScreen.js` — reference for status rows + picker
- `primitives/CardLayout.js` — semantic centering

View File

@@ -0,0 +1,820 @@
# claudemesh v1 — Feature Inventory
**Status:** backlog reference
**Created:** 2026-04-11
**Purpose:** Exhaustive audit of what v1 ships today. **Every row in this document must still work after v2 lands.** v2 is a refactor + CLI user flows, NOT a functional rewrite; this inventory is the regression checklist.
**Source of truth**:
- `apps/cli/src/` — 22 files, ~12 k LOC (v0.10.5)
- `apps/broker/src/` — 23 files, ~11 k LOC
- `packages/db/src/schema/mesh.ts` — 1,019 lines, 23 tables
---
## 0. Summary counts
| Surface | v1 count |
|---|---|
| CLI commands (subcommands in `index.ts`) | 23 |
| MCP tools (handlers in `mcp/server.ts`) | 79 |
| Broker WS message types (dispatched in `index.ts`) | 85 |
| Broker HTTP endpoints | 18 |
| Postgres tables in `mesh` schema | 23 |
| External backend services the broker manages | 5 (Postgres, Neo4j, Qdrant, MinIO, Docker) |
| Lines of source (CLI + broker, excluding tests) | ~23,450 |
---
## 1. CLI commands
All dispatched from `apps/cli/src/index.ts`. v1 ships 23 public subcommands plus the bare-command welcome wizard.
| Command | File | Purpose | Flags / args |
|---|---|---|---|
| `claudemesh` (bare) | `commands/welcome.ts` | Interactive welcome wizard. Entry point for new users. | (none) |
| `launch` | `commands/launch.ts` (775 lines, biggest) | Spawn a Claude Code session with mesh connectivity + MCP tools | `--name`, `--role`, `--groups`, `--mesh`, `--join`, `--message-mode`, `--system-prompt`, `-y/--yes`, `-r/--resume`, `-c/--continue`, `--quiet`, + passthrough to `claude` after `--` |
| `create` | `commands/create.ts` | Create a new mesh from a template | `--template`, `--list-templates` |
| `install` | `commands/install.ts` (538 lines) | Register MCP server + status hooks with Claude Code (`~/.claude.json`, `~/.claude/settings.json`) | `--no-hooks` |
| `uninstall` | `commands/install.ts` | Remove MCP server + hooks from Claude Code config | (none) |
| `join` | `commands/join.ts` (193 lines) | Join a mesh via invite URL or token | positional `<url>` |
| `list` | `commands/list.ts` | Show joined meshes, slugs, local identities | (none) |
| `leave` | `commands/leave.ts` | Leave a joined mesh + remove its local keypair | positional `<slug>` |
| `peers` | `commands/peers.ts` | List online peers with status, summary, groups | `--mesh`, `--json` |
| `send` | `commands/send.ts` | Send a message to a peer, group, or all peers | positional `<to> <message>`, `--mesh`, `--priority` |
| `inbox` | `commands/inbox.ts` | Drain pending inbound messages | `--mesh`, `--json`, `--wait` |
| `state` | `commands/state.ts` | Get / set / list shared KV state in the mesh | positional `<action> <key> [value]`, `--mesh`, `--json` |
| `info` | `commands/info.ts` | Mesh overview: slug, broker, peer count, state keys | `--mesh`, `--json` |
| `remember` | `commands/memory.ts` | Store a persistent memory visible to all peers | positional `<content>`, `--mesh`, `--tags`, `--json` |
| `recall` | `commands/memory.ts` | Full-text search of mesh memories | positional `<query>`, `--mesh`, `--json` |
| `remind` | `commands/remind.ts` (142 lines) | Schedule a delayed message. Also: `remind list`, `remind cancel <id>` | positional `<message>`, `--in`, `--at`, `--cron`, `--to`, `--mesh`, `--json` |
| `sync` | `commands/sync.ts` | Sync meshes from the user's claudemesh.com dashboard account | `--force` |
| `profile` | `commands/profile.ts` | View or edit member profile (self or another member if admin) | `--mesh`, `--role-tag`, `--groups`, `--message-mode`, `--name`, `--member`, `--json` |
| `status` | `commands/status.ts` | Check broker connectivity for each joined mesh | (none) |
| `doctor` | `commands/doctor.ts` (212 lines) | Diagnose install, config, keypairs, PATH | 7 checks: Node >= 20, claude binary, MCP registered, hooks registered, config parses, file perms, keypairs valid |
| `mcp` | `mcp/server.ts` (2139 lines) | Start MCP server on stdio (internal — invoked by Claude Code) | (none) |
| `seed-test-mesh` | `commands/seed-test-mesh.ts` | Dev-only: inject a mesh into local config without invite flow | `<slug>`, `<broker_url>` |
| `hook` | `commands/hook.ts` | Internal: handle Claude Code hook events (status updates from session lifecycle) | stdin JSON from Claude Code |
| `connect telegram` | `commands/connect-telegram.ts` | Link a Telegram bot to a mesh | inline token prompts, calls broker `/tg/token` |
| `disconnect telegram` | `commands/disconnect-telegram.ts` | Unlink Telegram bot | (none) |
### Flag-first invocation rewrite
`apps/cli/src/index.ts` lines 339355 implement a **friction reducer**: if the user types `claudemesh --resume xxx` or any flag-first invocation, the argv is rewritten to `claudemesh launch --resume xxx` before citty parses it. This lets users skip typing `launch` for common flag-only forms.
**Must preserve in v2.** Users may depend on this. Applies to `--resume`, `--continue`, `-y`, `--mesh`, `--name`, etc.
---
## 2. MCP tools (79 total)
Defined in `apps/cli/src/mcp/tools.ts` with schemas, implemented in `apps/cli/src/mcp/server.ts` with per-tool case handlers. Each MCP tool is a RPC that the CLI's MCP server handles locally or forwards to the broker via WS.
Grouped by domain family. Every tool listed here has a working handler in v1.
### 2.1 Messaging (4)
| Tool | v1 behavior |
|---|---|
| `send_message` | Send encrypted message to peer, group, or broadcast. Supports priorities: `now` (immediate), `next` (default), `low`. Broker queues if recipient offline. |
| `list_peers` | List connected peers in the mesh with `presenceId`, `displayName`, `status`, `summary`, `groups`, `roleTag`. |
| `message_status` | Query delivery state of a sent message by `messageId`. |
| `check_messages` | Drain pending inbox messages (push mode). |
### 2.2 Profile + identity (4)
| Tool | v1 behavior |
|---|---|
| `set_summary` | Set the current peer's work summary (visible to others). |
| `set_status` | Set status: `idle`, `working`, `dnd`. Priority-ranked by source (`hook` > `manual` > `jsonl`). |
| `set_visible` | Toggle visibility. Hidden peers skip `list_peers` and broadcasts but still receive direct messages. |
| `set_profile` | Update display name, role tag, groups, avatar, title, bio, capabilities. |
### 2.3 Groups (2)
| Tool | v1 behavior |
|---|---|
| `join_group` | Join a `@group` with optional role (`lead`, `member`, or free-form). |
| `leave_group` | Leave a `@group`. |
### 2.4 State KV (3)
| Tool | v1 behavior |
|---|---|
| `set_state` | Set a key-value pair in the mesh's shared state. Broadcasts `state_change` push to all peers. |
| `get_state` | Read a value by key. |
| `list_state` | List all state keys with values, authors, timestamps. |
### 2.5 Memory (3)
| Tool | v1 behavior |
|---|---|
| `remember` | Store a text memory with optional tags. Persists across sessions. |
| `recall` | Full-text search memories by query, ranked results. |
| `forget` | Delete a memory by ID. |
### 2.6 Files (8)
| Tool | v1 behavior |
|---|---|
| `share_file` | Upload a file to MinIO. Supports `to: <peer>` for E2E encryption (symmetric key wrapped with peer pubkey), or mesh-wide sharing. Supports `persistent` vs `ephemeral` storage. |
| `get_file` | Download a file by `fileId`. Returns a presigned MinIO URL. |
| `list_files` | List files in the mesh by `scope`, `tags`, author. |
| `file_status` | Query status of a file: who downloaded, when. |
| `delete_file` | Delete a file (owner only). |
| `grant_file_access` | Add another peer as a recipient of an already-encrypted file (re-wraps symmetric key). |
| `read_peer_file` | Read a file from another peer's working directory (requires peer online + sharing). |
| `list_peer_files` | List files in a peer's shared directory (tree of names, not contents). |
### 2.7 Vectors (Qdrant) (4)
| Tool | v1 behavior |
|---|---|
| `vector_store` | Store embedding with metadata in a named collection. |
| `vector_search` | Nearest-neighbor search in a collection with `limit`. |
| `vector_delete` | Delete a vector by ID. |
| `list_collections` | List collections in the mesh's Qdrant namespace. |
### 2.8 Graph (Neo4j) (2)
| Tool | v1 behavior |
|---|---|
| `graph_query` | Read-only Cypher MATCH query on the per-mesh Neo4j database. |
| `graph_execute` | Write Cypher (CREATE/MERGE/DELETE). |
### 2.9 Shared SQL (Postgres) (3)
| Tool | v1 behavior |
|---|---|
| `mesh_query` | SELECT-only query on the per-mesh Postgres schema. |
| `mesh_execute` | DDL + DML (CREATE TABLE, INSERT, UPDATE, DELETE). |
| `mesh_schema` | List tables + columns in the mesh's schema. |
### 2.10 Streams (4)
| Tool | v1 behavior |
|---|---|
| `create_stream` | Create a named stream for live data pub-sub. |
| `publish` | Push data to a stream. Subscribers receive in real-time. |
| `subscribe` | Subscribe to a stream. Events arrive as channel notifications. |
| `list_streams` | List active streams. |
### 2.11 Contexts (3)
| Tool | v1 behavior |
|---|---|
| `share_context` | Share session understanding with the mesh (summary + files_read + key_findings + tags). |
| `get_context` | Search contexts by query (file path, topic, etc.). |
| `list_contexts` | Show what peers currently know about the codebase. |
### 2.12 Tasks (4)
| Tool | v1 behavior |
|---|---|
| `create_task` | Create a work item (title, assignee, priority, tags). |
| `claim_task` | Claim an unclaimed task. |
| `complete_task` | Mark done with optional result summary. |
| `list_tasks` | Filter by status and/or assignee. |
### 2.13 Scheduling (3)
| Tool | v1 behavior |
|---|---|
| `schedule_reminder` | One-shot (`deliver_at`, `in_seconds`) or recurring (`cron`). Delivered to self or `to`. Persists across broker restarts. |
| `list_scheduled` | List pending scheduled messages. |
| `cancel_scheduled` | Cancel by ID. |
### 2.14 Mesh metadata — read (4)
| Tool | v1 behavior |
|---|---|
| `mesh_info` | Overview: peers, groups, state, memory, files, tasks, streams, tables. |
| `mesh_stats` | Resource usage per peer: messages in/out, tool calls, uptime, errors. |
| `mesh_clock` | Simulation clock status: speed, tick count, simulated time. |
| `ping_mesh` | Test messages through the full pipeline, measure round-trip per priority. Diagnoses push delivery issues. |
### 2.15 Mesh clock — write (3)
| Tool | v1 behavior |
|---|---|
| `mesh_set_clock` | Set simulation clock speed (1100x). Peers receive heartbeat ticks at the simulated rate. |
| `mesh_pause_clock` | Pause simulation clock. |
| `mesh_resume_clock` | Resume paused clock. |
### 2.16 Skills (5)
| Tool | v1 behavior |
|---|---|
| `share_skill` | Publish a reusable skill (name + description + instructions + tags + when_to_use + allowed_tools + model + context + agent + user_invocable + argument_hint). Exposed as MCP prompts and `skill://` resources. |
| `get_skill` | Load a skill's full instructions by name. |
| `list_skills` | Browse available skills, optionally filter by keyword. |
| `remove_skill` | Remove a shared skill. |
| `mesh_skill_deploy` | Deploy a multi-file skill bundle from zip or git repo. |
### 2.17 MCP registry tier 1 — peer-hosted (4)
| Tool | v1 behavior |
|---|---|
| `mesh_mcp_register` | Register a peer's local MCP server with the mesh (server_name, description, tools schema, persistent flag). Other peers can invoke via `mesh_tool_call`. |
| `mesh_mcp_list` | List MCP servers in the mesh with their tools + hosting peer. |
| `mesh_tool_call` | Call a tool on a mesh-registered MCP server. Routes: caller → broker → hosting peer → execute → result back. 30s timeout. |
| `mesh_mcp_remove` | Unregister a peer-hosted MCP server. |
### 2.18 MCP registry tier 2 — broker-deployed (7)
| Tool | v1 behavior |
|---|---|
| `mesh_mcp_deploy` | Deploy an MCP server from zip (via `file_id`), git URL, or npx package. Runs on broker VPS in Docker sandbox. Scope: `peer` (default), `mesh`, or `{group/groups/role/peers}`. Runtime: node / python / bun. Memory, network_allow, env with `$vault:` references. |
| `mesh_mcp_undeploy` | Stop and remove a managed MCP server. |
| `mesh_mcp_update` | Pull latest + restart a git-sourced server. |
| `mesh_mcp_logs` | Tail recent logs from a managed server. |
| `mesh_mcp_scope` | Get or set visibility scope. |
| `mesh_mcp_schema` | Inspect tool schemas for a deployed server. |
| `mesh_mcp_catalog` | List all deployed services with status, scope, tool count. |
### 2.19 Vault (3)
| Tool | v1 behavior |
|---|---|
| `vault_set` | Store encrypted credential. `type: env` (string, injected as env var via `$vault:<key>`) or `type: file` (file written to `mount_path` in container). |
| `vault_list` | List vault entries (keys + metadata only, no values). |
| `vault_delete` | Remove a credential. |
### 2.20 URL watch (3)
| Tool | v1 behavior |
|---|---|
| `mesh_watch` | Watch a URL for changes. Modes: `hash` (SHA-256 body), `json` (jsonpath extract), `status` (HTTP code). Polling `interval` (min 5s). `notify_on: change \| match:<val> \| not_match:<val>`. Custom headers. |
| `mesh_unwatch` | Stop watching by `watch_id`. |
| `mesh_watches` | List active watches. |
### 2.21 Webhooks (3)
| Tool | v1 behavior |
|---|---|
| `create_webhook` | Create an inbound webhook. Returns a URL external services (GitHub, CI/CD, monitoring) can POST to. Payload becomes a mesh message to all peers. |
| `list_webhooks` | List active webhooks. |
| `delete_webhook` | Deactivate by name. |
---
## 3. Broker WS protocol
`apps/broker/src/index.ts` dispatches 85 message types over a single WebSocket endpoint (`WS_PATH`). Each WS message is a client-initiated RPC; most of the 79 MCP tools above map 1:1 to a WS message. Some additional WS messages exist for connection lifecycle + internal routing.
### 3.1 Connection lifecycle (3)
- `hello` — client authentication. Ed25519 signature over `{meshId, memberId, pubkey, timestamp}`. Broker verifies, creates presence row, replies with `hello_ack`.
- `hello_ack` — server → client, confirms authentication + sends restored peer state.
- `get_clock` — get current simulation clock state.
### 3.2 Messaging (4 WS ops)
- `send` — send a message. Envelope contains sender, recipient (peer/group/*), priority, nonce, ciphertext.
- `peer_dir_request` / `peer_dir_response` — peer-to-peer directory request (read_peer_file under the hood).
- `peer_file_request` / `peer_file_response` — peer-to-peer file read.
### 3.3 Profile + presence (5)
- `set_status`, `set_summary`, `set_visible`, `set_profile`, `set_stats`
### 3.4 Groups (2)
- `join_group`, `leave_group`
### 3.5 State KV (3)
- `set_state`, `get_state`, `list_state`
### 3.6 Memory (3)
- `remember`, `recall`, `forget`
### 3.7 Files (5)
- `get_file`, `list_files`, `file_status`, `grant_file_access`, `delete_file`
### 3.8 Vectors (3)
- `vector_store`, `vector_search`, `vector_delete`, `list_collections`
### 3.9 Graph (2)
- `graph_query`, `graph_execute`
### 3.10 Shared SQL (3)
- `mesh_query`, `mesh_execute`, `mesh_schema`
### 3.11 Streams (4)
- `create_stream`, `publish`, `subscribe`, `unsubscribe`, `list_streams`
### 3.12 Contexts (3)
- `share_context`, `get_context`, `list_contexts`
### 3.13 Tasks (4)
- `create_task`, `claim_task`, `complete_task`, `list_tasks`
### 3.14 Scheduling (3)
- `schedule`, `list_scheduled`, `cancel_scheduled`
### 3.15 Mesh metadata (3)
- `mesh_info`, `peers_list` (from `list_peers`), `message_status`
### 3.16 Simulation clock (4)
- `set_clock`, `pause_clock`, `resume_clock`, `get_clock`
### 3.17 Skills (4)
- `share_skill`, `get_skill`, `list_skills`, `remove_skill`, `skill_deploy`
### 3.18 MCP registry (11)
- `mcp_register`, `mcp_unregister`, `mcp_list`, `mcp_call`, `mcp_call_response` (peer → peer relay)
- `mcp_deploy`, `mcp_undeploy`, `mcp_update`, `mcp_logs`, `mcp_scope`, `mcp_schema`, `mcp_catalog`
### 3.19 Vault (4)
- `vault_set`, `vault_get`, `vault_list`, `vault_delete`
### 3.20 URL watch (3)
- `watch`, `unwatch`, `watch_list`
### 3.21 Webhooks (3)
- `create_webhook`, `list_webhooks`, `delete_webhook`
### 3.22 Audit (2)
- `audit_query`, `audit_verify`
---
## 4. Broker HTTP endpoints
The broker serves both WS (`/ws`) and HTTP on the same port. HTTP endpoints are listed here by (method, path) with purpose.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Health check: liveness probe |
| `GET` | `/metrics` | Prometheus metrics endpoint |
| `POST` | `/hook/set-status` | Receive hook status updates from CLI `hook` command (Claude Code session lifecycle) |
| `POST` | `/join` | Accept v1 invite join (legacy) |
| `POST` | `/invites/:code/claim` | v2 invite claim (public, unauthenticated) |
| `POST` | `/upload` | Upload a file (returns fileId, used by `share_file`) |
| `GET` | `/download/:id` | Download a file (returns content or presigned URL) |
| `POST` | `/cli-sync` | CLI sync endpoint — fetches user's meshes from `claudemesh.com` dashboard via JWT, returns mesh list |
| `POST` | `/tg/token` | Register a Telegram bot token for a mesh (connects via `connect telegram` CLI command) |
| `PATCH` | `/mesh/:id/member/:memberId` | Update a member's profile (admin or self) |
| `GET` | `/mesh/:id/members` | List mesh members |
| `PATCH` | `/mesh/:id/settings` | Update mesh-level settings (owner/admin) |
| `POST` | `/hook/:meshId/:webhookId` | Inbound webhook — external systems POST here to publish a mesh message |
| `GET` | `/test/clock` | Dev-only: simulation clock state |
| `GET` | `/test/flip` | Dev-only: test flip endpoint |
| `GET` | `/test/html` | Dev-only: test HTML endpoint |
| `WS` | `/ws` | WebSocket connection for mesh peers (all WS ops above) |
---
## 5. Database schema — `mesh` Postgres schema
23 tables in the `mesh` schema (managed via Drizzle). Defined in `packages/db/src/schema/mesh.ts`.
| Table | Purpose |
|---|---|
| `mesh.mesh` | Mesh identity. slug, name, ownerId, createdAt, settings. |
| `mesh.member` | Per-mesh member record. Stable, durable. pubkey, displayName, role, groups, joinedAt. |
| `mesh.invite` | Invite codes + metadata. |
| `mesh.pending_invite` | v2 invite handshake state (pending claim). |
| `mesh.audit_log` | Audit events per mesh. |
| `mesh.presence` | Ephemeral WS session — one row per active connection. Status, statusSource, statusUpdatedAt. |
| `mesh.message_queue` | Queued messages pending push delivery (priority ordered). |
| `mesh.pending_status` | In-flight status updates (10s TTL). |
| `mesh.state` (meshState) | Shared KV state per mesh. |
| `mesh.memory` (meshMemory) | Shared memories with full-text search. |
| `mesh.file` (meshFile) | File metadata (uploader, size, sha256, persistence, storage location). |
| `mesh.file_access` (meshFileAccess) | Per-recipient ACL on files. |
| `mesh.file_key` (meshFileKey) | Per-recipient wrapped symmetric keys for E2E encryption. |
| `mesh.context` (meshContext) | Shared context entries. |
| `mesh.task` (meshTask) | Tasks with lifecycle (open, claimed, completed, cancelled). |
| `mesh.stream` (meshStream) | Stream metadata. |
| `mesh.skill` (meshSkill) | Skill registrations (name, content, frontmatter, tags). |
| `mesh.webhook` (meshWebhook) | Inbound webhook registrations. |
| `mesh.service` (meshService) | Deployed MCP server state (container ID, scope, env, runtime, memory, logs). |
| `mesh.vault_entry` (meshVaultEntry) | Encrypted vault entries per (mesh, peer, key). |
| `mesh.scheduled_message` | Scheduled / recurring reminders (cron + one-shot). |
| `mesh.peer_state` | Per-peer state (groups, role, profile, message mode preference). |
| `mesh.telegram_bridge` | Telegram bot registration per mesh. |
---
## 6. Broker backend services
Five external services the broker manages at runtime. All currently work in v1 and ship in the default Docker Compose deployment.
| Service | Purpose | File | Per-mesh model |
|---|---|---|---|
| **Postgres** (Drizzle) | Primary data store for mesh schema. Also used for `mesh_execute` / `mesh_query` / `mesh_schema` shared-SQL tools via per-mesh schemas. | `db.ts` | Schema-per-mesh for shared SQL tools |
| **Neo4j** | Graph queries (`graph_query`, `graph_execute`). | `neo4j-client.ts` | Database-per-mesh (Enterprise) or labeled-node fallback (Community) |
| **Qdrant** | Vector embeddings + nearest-neighbor search. | `qdrant.ts` | Collection naming: `mesh_<meshId>_<collection>`, 1536-dim default, cosine distance |
| **MinIO** | File storage for `share_file` / `get_file`. | `minio.ts` | Bucket-per-mesh: `mesh-<meshId>`. Persistent + ephemeral key paths. |
| **Docker** | Runs deployed MCP servers in sandboxed containers. | `index.ts` (deploy handler) | Container-per-deployment. Read-only root, dropped caps, memory limits, network_allow. |
---
## 7. Broker core subsystems
### 7.1 Status engine (`broker.ts`, 2066 lines)
**Battle-tested status model** ported from `claude-intercom`. Rules:
- Status sources are ranked: `hook` (3) > `manual` (2) > `jsonl` (1)
- On status update:
- If status **changed** → bump everything, record new source
- If status **unchanged**, incoming source ≥ recorded → upgrade
- If status **unchanged**, incoming source < recorded:
- Recorded source still fresh → keep it (bump timestamp only)
- Recorded source stale → downgrade to honest attribution
- `HOOK_FRESHNESS_MS` window (default 60s) for "fresh" classification
- `WORKING_TTL_MS` after which `working` status reverts to `idle`
- `PENDING_TTL_MS = 10_000` for pending status cleanup
- `TTL_SWEEP_INTERVAL_MS = 15_000` for periodic cleanup
**Must preserve** — this is the correctness engine for `set_status`, `list_peers`, and Claude Code's status line.
### 7.2 Message queue + priority delivery
- Messages are stored in `mesh.message_queue` with priority (`now`, `next`, `low`)
- `now` messages bypass busy-gate and are pushed immediately
- `next` messages wait for idle peer
- `low` messages are pull-only (delivered when peer explicitly drains via `check_messages`)
- Queue is drained via `drainForMember(meshId, memberId)` on WS message arrival or manual `check_messages`
- Duplicate delivery prevention via `messageId` UUID tracking
### 7.3 Scheduled message delivery (`index.ts` in-memory + DB persistence)
- One-shot: `deliver_at` (timestamp) or `in_seconds`
- Recurring: standard 5-field cron expression
- Persists to `mesh.scheduled_message` table — survives broker restart
- On broker start, pending schedules are re-registered
- Delivery is via the normal `send_message` pipeline with `subtype: reminder`
### 7.4 URL watch subsystem (`index.ts`)
- Poller runs in-process (worker per watch)
- Modes: `hash` (SHA-256 of body), `json` (extract jsonpath value), `status` (HTTP status)
- `notify_on: change | match:<val> | not_match:<val>`
- Persists to DB so watches survive broker restart
- Min interval 5s, max 24h
### 7.5 Telegram bridge (`telegram-bridge.ts`, 1711 lines)
**Substantial subsystem.** Provides Telegram Bot API integration:
- Bot token registration per mesh via `POST /tg/token`
- Long-polling or webhook mode
- `tg:<username>` peer identity registration in the mesh's member table
- Inbound Telegram messages → mesh `send_message` events with `subtype: telegram`
- Outbound `send_message(to: "tg:<name>")` → Telegram Bot API call
- Chat-to-mesh mapping (Telegram chat_id ↔ mesh peer)
- User discovery (`connectChat`)
- Bridge row persistence in `mesh.telegram_bridge`
**This is ~18% of the broker's total source**. v2 must either:
1. Port the logic into a standalone MCP connector (`apps/mcp-telegram/`), or
2. Keep this file in the broker and wire it into the v2 architecture unchanged (my recommendation per the previous conversation — bundled into the broker image)
Either way, **every behavior documented here must still work after v2 lands**.
### 7.6 Auth + crypto (`crypto.ts`, `broker-crypto.ts`, `jwt.ts`)
- **Hello signatures**: Ed25519 signed tuple of `(meshId, memberId, pubkey, timestamp)`. Verified on every WS connection. Replay protection via timestamp window.
- **Invite verification**: canonical invite payload (`canonicalInvite`) signed by mesh owner, Ed25519 verified on claim
- **JWT**: for `/cli-sync` endpoint — the CLI obtains a JWT from `claudemesh.com` via browser flow, passes it to the broker, broker verifies and returns the user's mesh list
- **File envelopes**: client-side AES-GCM + per-recipient key wrapping (file_key table)
### 7.7 Rate limiting (`rate-limit.ts`)
- Per-peer rate limits on expensive operations
- Currently in-process (not Redis-backed)
- Enforces limits on `send`, `vector_store`, `mesh_execute`, `mesh_mcp_deploy`, etc.
### 7.8 Metrics (`metrics.ts`)
Prometheus metrics exposed at `/metrics`:
- Request counts by op type
- Latencies p50/p99
- Connection counts per mesh
- Message delivery counts by priority
- Error rates
### 7.9 Audit log (`audit.ts`)
- Every mutation is audited to `mesh.audit_log`
- Tamper-evidence via hash chaining
- Accessible via `audit_query` and `audit_verify` WS ops
### 7.10 Member API (`member-api.ts`, 284 lines)
Exports:
- `updateMemberProfile()` — used by `PATCH /mesh/:id/member/:memberId`
- `listMeshMembers()` — used by `GET /mesh/:id/members`
- `updateMeshSettings()` — used by `PATCH /mesh/:id/settings`
### 7.11 CLI sync (`cli-sync.ts`, 133 lines)
Exports `handleCliSync()` for `POST /cli-sync`. This is **already the "CLI sync meshes from dashboard" feature** — v2 will reuse this endpoint for its mesh-list refresh logic.
### 7.12 Webhook subsystem (`webhooks.ts`, 97 lines)
Handles `POST /hook/:meshId/:webhookId` inbound. Signature verification (HMAC), payload normalization, mesh message emission.
---
## 8. CLI core subsystems
### 8.1 WS client (`ws/client.ts`, 2191 lines)
**The biggest CLI file.** Implements the full WS protocol with:
- Connection management, reconnect with exponential backoff
- Message queue for offline buffering
- Request/response correlation via `_reqId`
- Ed25519 hello signature generation
- Crypto envelope wrapping for `send_message` payloads
- Push notification delivery (messages, state changes, system events)
- Per-mesh connection pooling (one WS per mesh)
### 8.2 MCP server (`mcp/server.ts`, 2139 lines)
Second biggest CLI file. Implements:
- MCP stdio transport (registered with Claude Code via `install.ts`)
- Tool registry from `mcp/tools.ts`
- Dispatch to 79 handlers (one per tool)
- WS client pooling (one connection per mesh)
- Crypto primitives for memory/state encryption
- Inline file-read helpers for `read_peer_file`
- Channel notification forwarding from broker → Claude Code via MCP elicitation
### 8.3 Crypto (`crypto/*.ts`)
- `keypair.ts` — Ed25519 keypair generation + persistence (`~/.claudemesh/keys/<mesh>.key`)
- `envelope.ts` — NaCl `crypto_box` envelope wrapping
- `file-crypto.ts` — AES-GCM file encryption + per-recipient key wrapping
- `hello-sig.ts` — Hello signature generation/verification
### 8.4 Auth + invite (`auth/*.ts`, `invite/*.ts`, `lib/invite-v2.ts`)
- `callback-listener.ts` — local HTTP server that catches browser OAuth callback (for `sync` command)
- `open-browser.ts` — cross-platform browser launcher
- `pairing-code.ts` — pairing code display
- `sync-with-broker.ts` — JWT-based sync from dashboard
- `invite/parse.ts` — parse v1 invite URLs
- `invite/enroll.ts` — enroll into a mesh from an invite
- `lib/invite-v2.ts` — v2 invite format (short-code + signed payload)
### 8.5 State + config (`state/config.ts`)
- `~/.claudemesh/config.json` read/write (mesh list, keypairs, profile defaults)
- 0600 permission enforcement
- Schema validation
### 8.6 TUI primitives (`tui/*.ts`)
- `colors.ts` — hard-coded ANSI colors
- `index.ts` — input helpers
- `screen.ts` — raw-mode screen control
- `spinner.ts` — simple spinner
### 8.7 Templates (`templates/index.ts`)
- `dev-team`, `research`, `ops-incident`, `simulation`, `personal`
- Each template seeds initial state + preset groups
### 8.8 Tests
- `__tests__/crypto-roundtrip.test.ts` — crypto round-trip verification
- `__tests__/invite-parse.test.ts` — invite URL parsing
- No integration tests against a real broker
---
## 9. Infrastructure + deployment
### 9.1 Broker runtime (`env.ts`)
Environment variables the broker expects:
- `DATABASE_URL` — Postgres connection
- `NEO4J_URL`, `NEO4J_USER`, `NEO4J_PASSWORD`
- `QDRANT_URL`
- `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_USE_SSL`
- `STATUS_TTL_SECONDS` — working status timeout
- `HOOK_FRESH_WINDOW_SECONDS` — hook source freshness window
- `TELEGRAM_BOT_TOKEN` — for bridge
- `DASHBOARD_JWT_SECRET` — for `/cli-sync` verification
- `PORT` (default 8787)
- Various feature flags
### 9.2 CLI runtime
- Node >= 20 required (checked in `doctor`)
- `claude` binary must be on PATH
- `~/.claudemesh/` directory with config + keys
- `~/.claude.json` MCP server registration
- `~/.claude/settings.json` status hooks registration
### 9.3 Deployment (Coolify/Docker Compose)
- Broker deployed via Coolify + Gitea CI on OVHcloud VPS (`ic.claudemesh.com`)
- WS endpoint: `wss://ic.claudemesh.com/ws`
- HTTP endpoint: `https://ic.claudemesh.com`
- Postgres, Neo4j, Qdrant, MinIO run as siblings in Docker Compose
- Deployed MCP sandboxes use the host Docker daemon via socket mount
---
## 10. Features not in the tool/WS surface (behavioral)
These are v1 behaviors that exist but aren't enumerated as tools. Each must still work after v2.
| Feature | Location | Notes |
|---|---|---|
| Flag-first `claudemesh --resume xxx` routing | `cli/src/index.ts` §339 | Rewrites argv to `launch --resume xxx` |
| Bare `claudemesh` → welcome wizard | `cli/src/index.ts` §334 | Runs `runWelcome()` |
| Status hook auto-registration | `commands/install.ts` | Writes to `~/.claude/settings.json` |
| Claude Code session hook handling | `commands/hook.ts` | Receives stdin JSON, posts to `/hook/set-status` |
| Per-mesh keypair directory | `crypto/keypair.ts` | `~/.claudemesh/keys/<mesh>.key` with 0600 perms |
| E2E file encryption with re-wrapping | `crypto/file-crypto.ts` + `mesh_file_key` table | `grant_file_access` re-wraps symmetric key for new recipient |
| Priority message delivery | `broker.ts` | `now` bypasses busy-gate, `next` waits for idle, `low` is pull-only |
| Hook > manual > jsonl status priority | `broker.ts` | Documented in §7.1 |
| Simulation clock for test time | `index.ts` (broker) | Peers receive heartbeat ticks at simulated rate |
| Audit log hash chaining | `audit.ts` | Tamper-evident — tools call `audit_verify` to check |
| Dashboard-CLI sync | `auth/sync-with-broker.ts` + `cli-sync.ts` | Browser JWT flow, fetches mesh list from dashboard |
| Telegram chat ↔ mesh peer mapping | `telegram-bridge.ts` | Bidirectional routing via `tg:<username>` |
| Inbound webhook payload normalization | `webhooks.ts` | External systems POST, becomes a mesh message |
| Rate limiting per peer per operation | `rate-limit.ts` | In-memory token buckets |
| Prometheus metrics | `metrics.ts` | `/metrics` endpoint |
---
## 11. Test coverage (v1)
| Test | File | Notes |
|---|---|---|
| Crypto round-trip | `apps/cli/src/__tests__/crypto-roundtrip.test.ts` | Encrypt → decrypt verification |
| Invite URL parsing | `apps/cli/src/__tests__/invite-parse.test.ts` | v1 and v2 formats |
| Broker tests | `apps/broker/tests/*.test.ts` | broker.test.ts, invite-signature.test.ts, invite-v2.test.ts, hello-signature.test.ts, rate-limit.test.ts, encoding.test.ts, dup-delivery.test.ts, metrics.test.ts, logging.test.ts, integration/health.test.ts |
**v1 test coverage is minimal for the CLI side.** 2 unit test files for 12k LOC.
Broker has ~10 test files. They cover crypto primitives, invite flow, hello signatures, rate limiting, metrics — but **not** the 85 WS message handlers comprehensively.
---
## 12. The "must preserve" list (high-priority regression checks)
If v2 breaks any of these, it's a user-facing regression:
### 12.1 First-run experience
- [ ] `claudemesh` bare command → welcome wizard
- [ ] `claudemesh install` registers MCP server + status hooks in Claude Code config
- [ ] `claudemesh join <url>` enrolls into a mesh from a v1 OR v2 invite URL
- [ ] `claudemesh launch` starts Claude Code with mesh connectivity
### 12.2 Session lifecycle
- [ ] Status hooks fire correctly on Claude Code session start/stop/pause
- [ ] `set_status` honors priority (hook > manual > jsonl)
- [ ] `list_peers` shows live status with freshness gating
- [ ] Status TTL sweeper runs every 15s
### 12.3 Messaging
- [ ] `send_message(to: peer, priority: "now")` delivers immediately
- [ ] `send_message(to: peer, priority: "next")` waits for idle
- [ ] `send_message(to: "@group")` broadcasts to group members
- [ ] `send_message(to: "*")` broadcasts to all mesh peers
- [ ] Offline recipients receive queued messages on reconnect
- [ ] Duplicate delivery is prevented by `messageId` tracking
### 12.4 Cryptographic integrity
- [ ] Ed25519 keypair generation + persistence with 0600 perms
- [ ] Hello signature verification rejects replay within timestamp window
- [ ] `send_message` envelopes are E2E encrypted (NaCl crypto_box)
- [ ] File uploads are AES-GCM encrypted with per-recipient key wrapping
- [ ] `grant_file_access` re-wraps symmetric key for a new recipient
### 12.5 All 79 MCP tools
- [ ] Every tool in §2 dispatches correctly through the CLI's MCP server
- [ ] Every tool delegates to the broker WS protocol or local handler as appropriate
- [ ] No tool returns "not implemented" or throws an unexpected error
### 12.6 Broker backends
- [ ] `mesh_query` / `mesh_execute` / `mesh_schema` work against per-mesh Postgres schema
- [ ] `graph_query` / `graph_execute` work against per-mesh Neo4j database
- [ ] `vector_store` / `vector_search` work against per-mesh Qdrant collection
- [ ] `share_file` / `get_file` work through per-mesh MinIO bucket
- [ ] `mesh_mcp_deploy` spawns a Docker container with correct scope + env + network_allow
- [ ] `vault_set` + `$vault:<key>` env injection works end-to-end for deployed MCPs
### 12.7 Scheduled + URL watch
- [ ] `schedule_reminder` with `cron` survives broker restart (persisted in DB)
- [ ] `mesh_watch` polls at the specified interval and notifies on change
- [ ] Watch state persists across broker restart
### 12.8 Telegram bridge
- [ ] `connect telegram` registers bot token via `POST /tg/token`
- [ ] Bot token is stored in `mesh.telegram_bridge`
- [ ] Inbound Telegram messages are routed as mesh messages
- [ ] `send_message(to: "tg:<username>")` routes via Telegram Bot API
- [ ] `disconnect telegram` tears down the bridge cleanly
### 12.9 Dashboard sync
- [ ] `claudemesh sync` browser flow completes and fetches mesh list
- [ ] `POST /cli-sync` with valid JWT returns user's dashboard meshes
### 12.10 Webhooks
- [ ] `create_webhook` returns a POST URL
- [ ] External POST to webhook URL becomes a mesh message
- [ ] HMAC signature validation rejects unsigned requests
- [ ] `list_webhooks` + `delete_webhook` work
### 12.11 Doctor checks
- [ ] Node >= 20 check
- [ ] `claude` binary on PATH
- [ ] MCP server registered in `~/.claude.json`
- [ ] Status hooks registered in `~/.claude/settings.json`
- [ ] `~/.claudemesh/config.json` exists + parses + 0600 perms
- [ ] Mesh keypairs valid
---
## 13. What v2 is adding (net new)
Not part of the regression list, but tracked here so we don't lose sight of the forward-looking scope.
### 13.1 New CLI features (from user's stated v2 intent)
- [ ] `claudemesh login` — device-code OAuth against claudemesh.com's Better Auth backend
- [ ] `claudemesh register` — create a new account from the CLI (via browser handoff)
- [ ] `claudemesh new` — create a mesh from the CLI against `POST /api/my/meshes` (not via templates in the CLI — via dashboard API)
- [ ] `claudemesh invite` — generate an invite from the CLI via `POST /api/my/meshes/:slug/invites`
- [ ] `claudemesh whoami` — show current identity + token source
- [ ] `claudemesh logout` — revoke server-side session + clear local credentials
### 13.2 Architecture improvements (from user's v2 intent)
- [ ] Feature-folder `services/` layer with strict facade boundaries
- [ ] ESLint + dependency-cruiser boundary enforcement
- [ ] `cli/` vs `ui/` separation (non-Ink I/O vs Ink rendering)
- [ ] `entrypoints/` folder with cli + mcp entries
- [ ] Typed error classes per service with `toDomainError` helper
- [ ] Coverage threshold enforcement in CI
### 13.3 Not in v1.0.0 scope (defer to v1.1+)
Everything from the Composer 2 review rounds that isn't Pass 1:
- Local-first SQLite source of truth (Lamport, sync daemon, publish transaction)
- Broker security hardening (role-per-mesh Postgres, Docker egress proxy, SSRF policy)
- ICU MessageFormat + per-locale budgets
- Accessibility token-signal matrix
- Tiered MCP catalog + audit process
- session_kind enum
- NFC peer_id normalization
- Write queue state machine
These stay in the `.artifacts/specs/` as reference documents. They describe a good destination. They are NOT v1.0.0 requirements.
---
## 14. Known v1 technical debt / gaps (worth noting)
These aren't features — they're places where v1 is weaker than it could be. Document here so v2 doesn't blindly port the weaknesses.
- **CLI auth is missing** — v1 has no `login` / `logout` command. All account-level operations require the web dashboard. This is what v2 is adding.
- **Imperative command branching** — `commands/launch.ts` is 775 lines with nested flag handling. Cleaner in v2's flow pipeline.
- **Minimal CLI test coverage** — 2 test files for 12k LOC. v2 should have colocated tests per service.
- **Rate limiting is in-memory only** — doesn't survive broker restart; not Redis-backed.
- **No CLI-side caching** — every `list_peers` / `mesh_info` call hits the broker. v2's local-first layer (Pass 2) addresses this.
- **Telegram bridge is a large monolithic file** (1711 lines) — legitimate complexity, but v2 may want to modularize if it touches it.
- **v1 wizard bleed-through** — `launch``claude` handoff leaves ANSI state dirty. v2's `resetTerminal()` choke point fixes this.
None of these are regressions if v2 keeps them as-is. v2 should **not** prioritize fixing them — fix them when they become a problem, not speculatively.
---
## 15. Reading this inventory
**If you're implementing v2 Phase 1** (foundation layers): every tool in §2, every WS op in §3, every HTTP endpoint in §4, every DB table in §5 must have a place in the v2 folder structure. No new semantics, no improved algorithms — just move the working code.
**If you're reviewing a v2 PR**: check it against §12 ("must preserve" list). If the PR changes the behavior of anything in that list, it's a regression and needs explicit sign-off.
**If you're writing v2 docs**: reference this document. Every feature here is user-visible and documented in v1's README / slash-command help / tool descriptions. v2 docs should mention every feature from §2 as preserved.
---
**End of inventory.**

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -0,0 +1,29 @@
{\rtf1\ansi\ansicpg1252\cocoartf2867
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\margl1440\margr1440\vieww11180\viewh8060\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
\f0\fs24 \cf0 Mesh templates for predefined roles, groups\'85\
Mesh blockchain, can it be a good addition? For what?\
Mesh webhooks, external web sockets, restful apis to be connected to the mesh (mcp)\
Mesh skills available for all ai? Like a mesh catalog of skills for sessions to get and use them?\
Inicial private mesh by default for every new user\
Mesh dashboard for situational awareness of mesh, to illustrate the peers connected, their activity, status, mesh structure\
Mesh of meshes? bridge?\
Mesh Connectors: slack, telegram, they can appear as peers? Or sth different?\
Connect humans to the mesh? Peer info to know about if human, type of channel (telegram or whatever) or llm model if ai?\
How to connect others than just claude code? The problem will be the push system I suppose\
\
Add path (pwd) where each session is being executed for them to understand how to reference files if same computer? Maybe only visible for peers on same computer?\
What if a peer on connection can make available all the project files, folders and subfolders? Direct access? So other ai can read files if needed from connected projects?\
Can we have peer stats for example about context consumption?\
Mesh notifications about new peers, new connectors, new resources? Broadcast?\
Allow group or role changes dynamically not only on mesh connection?\
Dynamic mcp that can be connected or disconnected on realtime without resetting the claude code sessions?\
Mesh templates on creation, with a predefined structure that it can be changed as well by mesh admin role? Or any? Or what idea?\
What if reminders can be just cron so ai knows exactly how to configure crons for the mesh? So broker can handle the cron creation? What about mesh heartbeats to keep ai alive?\
Sandbox for code execution, python, node, chromium, etc so any peer can connect to resources, and resources being scalable on real time if a new peer needs a sandbox?\
\
}

View File

@@ -0,0 +1,593 @@
# CLI Auth — Device Code Flow + Personal Access Tokens
**Status:** spec
**Created:** 2026-04-10
**Owner:** CLI-Dev (implementation), Orchestrator (spec)
**Target version:** v0.11.0
**Related:** `2026-04-10-anthropic-vision-meshes-invites.md`, `2026-04-10-cli-wizard-architecture-refactor.md`
## Goal
The CLI is a first-class client. From a fresh terminal, with zero prior browser interaction, a user can:
```
claudemesh login # device-code OAuth, browser handshake
claudemesh create "Platform team" # creates real mesh via /api/my/meshes
claudemesh invite --email alice@x.com # generates invite, sends email
claudemesh launch --mesh platform-team -y # spawns Claude Code in the mesh
```
For CI / scripting / non-interactive contexts, PAT works too:
```
claudemesh login --token cm_pat_abc123
claudemesh create "CI test mesh" --json | jq .id
```
This is the auth substrate that unblocks the "Anthropic vision" — every other dashboard-only feature (meshes, invites, members, billing) becomes CLI-accessible after this lands.
## Non-goals
- SSO / SAML / enterprise IdP integration (later, post-1.0)
- Refresh tokens with rotation (long-lived API keys are sufficient for v1)
- Multi-account switching (one logged-in identity per `~/.claudemesh/auth.json`)
- Device fleet management UI (single "revoke" button per token is enough for v1)
## Auth model overview
Two coexisting credential types, both backed by **Better Auth's `apiKey` plugin**:
| Type | Created via | Lifetime | Use case | Storage |
|---|---|---|---|---|
| **Device-code session token** | `claudemesh login` (OAuth-style browser handshake) | 90 days, auto-renew on use | Interactive humans on their workstation | `~/.claudemesh/auth.json` |
| **Personal access token (PAT)** | Dashboard → Settings → CLI tokens → Generate | User-chosen (30d / 90d / 1y / never), explicit revocation | CI, scripts, automation, server-side cron | Anywhere the user puts it; CLI reads from `--token` flag, env var, or `auth.json` |
Both flow through the same `Authorization: Bearer cm_<type>_<random>` header. The API doesn't care which one it gets — it just validates against the `api_key` table.
**Token format:**
- `cm_session_<32-byte base32>` — device-code sessions
- `cm_pat_<32-byte base32>` — personal access tokens
The `cm_` prefix lets us scan for leaked tokens with regex (e.g. GitHub secret scanning, internal scripts). The middle segment (`session` / `pat`) is for human readability in token lists, not for security.
## User flows
### 1. First-time login (interactive happy path)
```
$ claudemesh login
██ claudemesh login
Opening browser for authentication…
If your browser didn't open, visit:
https://claudemesh.com/cli-auth?code=ABCD-EFGH
Enter this code:
ABCD-EFGH
Waiting for confirmation… ⠋
```
In the browser:
1. User lands on `/cli-auth?code=ABCD-EFGH`
2. If not signed in, Better Auth login screen appears, then redirects back
3. User sees a confirmation card:
```
Link this CLI session?
Code: ABCD-EFGH
Device: Alejandro's MacBook Pro · darwin · arm64
Expires in 9:47
[Approve] [Deny]
```
4. User clicks Approve
CLI polls every 1.5s, sees `approved`, receives token, writes `~/.claudemesh/auth.json` with `0600`, prints:
```
✔ Authenticated as Alejandro Gutiérrez
✔ Token saved to ~/.claudemesh/auth.json
✔ Synced 3 meshes: alexis-mou, dev, claudefarm
Run claudemesh --help to get started.
```
### 2. First-time login (PAT, non-interactive)
```
$ claudemesh login --token cm_pat_abc123def456...
✔ Authenticated as Alejandro Gutiérrez (via PAT "ci-deploy")
✔ Token saved to ~/.claudemesh/auth.json
```
Or one-shot, no save:
```
$ CLAUDEMESH_TOKEN=cm_pat_abc123 claudemesh create "test"
```
### 3. Already logged in, runs a command
```
$ claudemesh create "Platform team"
✔ Created mesh platform-team (id: q5RI89Fl…)
✔ Joined locally
▸ Invite peers: claudemesh invite --mesh platform-team
```
No auth prompt — token in `auth.json` is used silently.
### 4. Token expired or revoked
```
$ claudemesh peers
✘ Authentication failed (token expired or revoked)
Run claudemesh login to re-authenticate.
```
Exit code `2`. The `auth.json` is **not** auto-deleted (user might be debugging) but the next `claudemesh login` overwrites it cleanly.
### 5. Wizard launch flow with auth integration
When `claudemesh` (bare, no auth) is run:
```
██ claudemesh
▸ Sign in (opens browser)
Paste a personal access token
Join a mesh via invite URL
Exit
```
After auth completes, the wizard transitions naturally into the launch flow (mesh picker → name → role → confirm → handoff). One uninterrupted experience from "fresh install" to "Claude Code in a mesh."
### 6. CI / non-interactive
```
# .github/workflows/test.yml
- run: |
claudemesh login --token ${{ secrets.CLAUDEMESH_PAT }}
claudemesh create "CI run $GITHUB_RUN_ID" --json > mesh.json
```
Or zero-state:
```
- env:
CLAUDEMESH_TOKEN: ${{ secrets.CLAUDEMESH_PAT }}
run: claudemesh create "CI run $GITHUB_RUN_ID" --json
```
Token resolution order: `--token` flag > `CLAUDEMESH_TOKEN` env var > `~/.claudemesh/auth.json`.
### 7. Logout
```
$ claudemesh logout
✔ Token revoked on server
✔ Removed ~/.claudemesh/auth.json
```
`logout` calls `DELETE /api/my/cli/sessions/current` to revoke server-side, then unlinks the local file. Best-effort: if the server call fails, still delete locally and warn.
## Architecture
### Backend — Better Auth `apiKey` plugin
Better Auth ships an `apiKey` plugin that handles:
- Token generation (cryptographically random)
- Hashed storage (only the hash hits the DB; raw token never persisted)
- Verification middleware (validates `Authorization: Bearer …`)
- Per-token metadata (name, scopes, expiry, last-used)
- Per-token revocation
We use it for both PAT and device-code sessions. Device-code sessions just have a marker in metadata distinguishing them from user-generated PATs.
**Wire-up:** `apps/web/src/lib/auth/index.ts` (or wherever Better Auth is initialized) adds:
```ts
import { apiKey } from "better-auth/plugins";
export const auth = betterAuth({
// …existing config
plugins: [
// …
apiKey({
enableMetadata: true,
apiKeyHeaders: ["x-api-key", "authorization"],
defaultPrefix: "cm_",
rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 100 },
}),
],
});
```
### Backend — device-code table
The `apiKey` plugin doesn't ship device-code flow out of the box. We add a small table + 4 endpoints on top.
```sql
-- packages/db/migrations/0020_cli-device-code.sql
CREATE TABLE cli_device_code (
device_code text PRIMARY KEY, -- opaque random, sent to CLI
user_code text UNIQUE NOT NULL, -- short human code: "ABCD-EFGH"
user_id text REFERENCES "user"(id), -- nullable until approved
api_key_id text REFERENCES api_key(id), -- the issued token, set on approve
device_name text NOT NULL, -- "Alejandro's MacBook Pro"
device_os text NOT NULL, -- "darwin"
device_arch text NOT NULL, -- "arm64"
ip_address text, -- for audit
user_agent text,
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'denied' | 'expired'
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- created_at + 10 min
approved_at timestamptz
);
CREATE INDEX cli_device_code_user_code_idx ON cli_device_code(user_code);
CREATE INDEX cli_device_code_status_expires_idx ON cli_device_code(status, expires_at);
```
A scheduled job (or lazy cleanup on insert) deletes rows where `status='expired'` AND `expires_at < now() - interval '7 days'`.
### Backend — endpoints
All under `apps/web/src/app/api/auth/cli/` (or wherever you keep public auth routes — these need to be **unauthed** since the CLI has no token yet).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/auth/cli/device-code` | none | CLI requests a new device code. Body: `{ device_name, device_os, device_arch }`. Returns `{ device_code, user_code, expires_at, verification_url }`. |
| `GET` | `/api/auth/cli/device-code/:device_code` | none | CLI polls for status. Returns `{ status: 'pending'|'approved'|'denied'|'expired', token?: string, user?: { id, name, email } }`. Token only present when status=approved, and only **once** (subsequent polls return approved without token). |
| `POST` | `/api/auth/cli/device-code/:user_code/approve` | session | Browser confirms. Creates an `api_key` row with metadata `{ kind: 'session', device_name, device_code }`, sets `cli_device_code.api_key_id`, status=approved. |
| `POST` | `/api/auth/cli/device-code/:user_code/deny` | session | Browser denies. Sets status=denied. |
Authed endpoints (under `/api/my/cli/`):
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/my/cli/sessions` | List active CLI sessions for the user (devices, last seen, created). |
| `DELETE` | `/api/my/cli/sessions/:id` | Revoke a specific session. |
| `POST` | `/api/my/cli/tokens` | Create a PAT. Body: `{ name, expires_in_days?, scopes? }`. Returns the raw token **once**. |
| `GET` | `/api/my/cli/tokens` | List PATs (no raw values, just metadata). |
| `DELETE` | `/api/my/cli/tokens/:id` | Revoke a PAT. |
### Backend — middleware
Existing `enforceAuth` (in `packages/api/src/utils/`) currently reads cookies. Extend it to also accept `Authorization: Bearer cm_…`:
```ts
export async function enforceAuth(ctx) {
const bearer = ctx.req.headers.get("authorization")?.replace(/^Bearer /, "");
if (bearer?.startsWith("cm_")) {
const result = await auth.api.verifyApiKey({ key: bearer });
if (result.valid) {
// record last_used_at, increment usage counter
return { user: result.user, via: "apiKey", apiKey: result.apiKey };
}
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
}
// …existing cookie-based auth
}
```
The `apiKey` plugin handles `last_used_at` updates automatically.
### Backend — web route
`apps/web/src/app/[locale]/cli-auth/page.tsx`:
- Reads `?code=ABCD-EFGH` from query string
- If no session, redirects to `/login?next=/cli-auth?code=ABCD-EFGH`
- If session, fetches device code metadata via server component, renders confirmation card
- Approve button → `POST /api/auth/cli/device-code/:user_code/approve`
- Deny button → `POST /api/auth/cli/device-code/:user_code/deny`
- After approve, shows: "✓ CLI authenticated. Return to your terminal."
Mobile-friendly. Confirmation card shows device fingerprint so the user can verify they're approving the right session.
### Backend — dashboard PAT UI
`apps/web/src/app/[locale]/dashboard/settings/cli-tokens/page.tsx`:
- List of existing PATs (name, created, last used, expires)
- "Generate new token" button → modal with name + expiry picker
- After creation, show raw token once with copy button + warning ("This token will not be shown again")
- Per-row revoke button
Reuses existing dashboard layout. Should be ~150 lines including the modal.
### CLI — file layout
```
apps/cli/src/
├── commands/
│ ├── login.ts # NEW
│ ├── logout.ts # NEW
│ ├── whoami.ts # NEW
│ ├── create.ts # rewrite to call API
│ ├── invite.ts # NEW
│ ├── sync.ts # rewrite to call API
│ └── …existing
└── lib/
├── auth-store.ts # NEW: read/write ~/.claudemesh/auth.json
├── api-client.ts # NEW: typed fetch wrapper
├── device-info.ts # NEW: collect hostname, os, arch for device-code request
└── …existing
```
### CLI — `auth-store.ts`
```ts
// ~/.claudemesh/auth.json
type AuthFile = {
version: 1;
token: string; // cm_session_… or cm_pat_…
user: { id: string; name: string; email: string };
created_at: string; // ISO
source: "device-code" | "pat" | "env";
};
```
Read priority: `--token` flag > `CLAUDEMESH_TOKEN` env > `auth.json`.
Write only on `login` success. File mode `0600`. Parent dir `0700`.
On read, if file mode is too permissive, log a warning and continue.
### CLI — `api-client.ts`
Thin wrapper over `fetch`:
```ts
export class ClaudemeshApi {
constructor(private opts: { baseUrl: string; token: string }) {}
async createMesh(input: { name: string; slug?: string }) { … }
async listMeshes() { … }
async createInvite(input: { meshId: string; email?: string; role?: string }) { … }
async listSessions() { … }
async revokeSession(id: string) { … }
async whoami() { … }
}
```
Type definitions live in `packages/api/src/contracts/cli.ts` (new file) — generated from the existing tRPC routers as plain types so the CLI doesn't need to import the whole tRPC client.
Base URL from `CLAUDEMESH_API_URL` env var, defaults to `https://claudemesh.com`. Allows local dev against `http://localhost:3000`.
### CLI — device-code login flow
```ts
// commands/login.ts
async function deviceCodeLogin() {
const device = collectDeviceInfo();
const { device_code, user_code, expires_at, verification_url } =
await api.requestDeviceCode(device);
console.log(` Opening ${verification_url}…`);
console.log(` Code: ${user_code}`);
await openBrowser(`${verification_url}?code=${user_code}`);
const spinner = ora("Waiting for confirmation").start();
const deadline = new Date(expires_at).getTime();
while (Date.now() < deadline) {
await sleep(1500);
const result = await api.pollDeviceCode(device_code);
if (result.status === "approved") {
spinner.succeed("Authenticated");
await authStore.write({ token: result.token, user: result.user, source: "device-code" });
await syncMeshes();
return;
}
if (result.status === "denied") {
spinner.fail("Denied in browser");
process.exit(1);
}
}
spinner.fail("Timed out");
process.exit(1);
}
```
Polls every 1.5s. Server returns `{ slow_down: true }` if polled too fast (rate limit at 1/sec).
## Security
1. **Tokens are hashed at rest** (Better Auth `apiKey` plugin handles this with bcrypt or argon2).
2. **Raw tokens shown to user once.** PATs in dashboard, device-code tokens via `claudemesh login` output. Never logged, never re-displayable.
3. **`auth.json` is `0600`.** CLI refuses to write if parent dir can't be made `0700`. Warns on read if mode is wider.
4. **Token prefix `cm_` enables secret scanning.** Document the regex `cm_(session|pat)_[a-z0-9]{32,}` in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks.
5. **`/api/auth/cli/device-code/:device_code` polling is rate-limited** to 1 req/sec per IP per device_code. Returns `429` with `slow_down: true` body.
6. **Device codes expire in 10 minutes.** Approved-but-unclaimed tokens stay valid (the polling endpoint still returns the token for 60 seconds after approval, then the device_code row is GC'd).
7. **Audit logging.** Every device-code approval, PAT creation, and PAT revocation emits an audit event (`auth.cli.session.created`, `auth.cli.pat.created`, etc.). Stored in existing audit log if there is one, otherwise new `audit_log` table.
8. **Session invalidation on password change.** When a user changes their password via Better Auth, all `cli_session` `api_key` rows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed).
9. **Token revocation is immediate.** `auth.api.verifyApiKey` checks DB on every request — no in-memory cache.
10. **No CSRF concern** for device-code endpoints — the unauthed ones don't act on user state, the authed ones use Better Auth's existing CSRF protection.
## Wizard UX integration
The current welcome wizard already has:
```
▸ Create account (new to claudemesh)
Sign in (existing account)
Paste an invite URL
Exit
```
After this spec lands, the welcome screen becomes:
```
██ claudemesh
▸ Sign in ← device-code OAuth
Paste an access token ← PAT path
Join via invite URL ← unchanged
Create account ← opens /register, then back to login
Exit
```
"Sign in" becomes the headline option. The current "Create account" still opens browser to `/register` but flows back through the device-code handshake instead of a custom callback.
Once authenticated, the wizard transitions to:
```
██ claudemesh launch
Account ✔ Alejandro Gutiérrez
Mesh ▸ (pick one — 3 available)
Name ✔ Alexis (from --name)
Role ▸ (pick one)
▸ Continue
Cancel
```
Status rows show what's filled and what's left. Mesh picker fetches from `GET /api/my/meshes` via the freshly minted token.
This integrates cleanly with the wizard architecture refactor in `2026-04-10-cli-wizard-architecture-refactor.md`: auth becomes one screen in the launch flow with `isComplete: s => s.user !== null`. On a fresh machine the auth screen runs; on a returning machine it's auto-skipped.
## Error handling
| Scenario | Behavior |
|---|---|
| Browser doesn't open | Print URL prominently, keep polling |
| Network down during poll | Retry with exponential backoff (1.5s → 3s → 6s, max 30s) |
| Device code expires | Print "Login timed out, run `claudemesh login` to retry", exit 1 |
| Token rejected by API | Print "Authentication failed", suggest `claudemesh login`, exit 2 |
| `auth.json` corrupted | Print "Auth file corrupted, run `claudemesh login`", exit 2 |
| `auth.json` permissions wrong | Warn, fix to `0600`, continue |
| PAT pasted to `--token` is malformed | Print "Invalid token format (expected `cm_pat_…`)", exit 1 |
| PAT pasted to `--token` is valid format but unknown | API returns 401, print "Token rejected", exit 2 |
| Two CLI instances poll simultaneously | Both get the same approved status; first to read gets the token, second gets `{ status: 'approved', token: null }` (already_claimed). Document this. |
| User clicks Approve in browser, then closes tab | CLI's poll catches it, login succeeds. The browser tab closure is irrelevant. |
| User completes login on machine A, then runs `claudemesh login` on machine B with same account | Both sessions coexist as separate `api_key` rows. `claudemesh whoami --sessions` shows both. |
## Implementation phases
Each phase ships independently and is independently testable.
### Phase 1 — Backend foundation (46 hours)
- [ ] Wire Better Auth `apiKey` plugin in `apps/web/src/lib/auth/`
- [ ] Migration `0020_cli-device-code.sql`
- [ ] Drizzle schema for `cli_device_code` in `packages/db/src/schema/auth.ts`
- [ ] Endpoints: `POST /api/auth/cli/device-code`, `GET /api/auth/cli/device-code/:device_code`, `POST /api/auth/cli/device-code/:user_code/approve`, `POST /api/auth/cli/device-code/:user_code/deny`
- [ ] Extend `enforceAuth` middleware to accept `Authorization: Bearer cm_…`
- [ ] Endpoints: `POST /api/my/cli/tokens`, `GET /api/my/cli/tokens`, `DELETE /api/my/cli/tokens/:id`, `GET /api/my/cli/sessions`, `DELETE /api/my/cli/sessions/:id`
- [ ] Unit tests for token verification and device-code state machine
### Phase 2 — Web routes (34 hours)
- [ ] `/cli-auth?code=...` page (server component + approve/deny client component)
- [ ] `/dashboard/settings/cli-tokens` page (list + create modal + revoke)
- [ ] Translations for both pages (en, es)
- [ ] E2E test: full device-code happy path with Playwright
### Phase 3 — CLI auth core (45 hours)
- [ ] `lib/device-info.ts` — collect hostname, os, arch
- [ ] `lib/auth-store.ts` — read/write `~/.claudemesh/auth.json` with mode checks
- [ ] `lib/api-client.ts` — typed fetch wrapper with bearer header
- [ ] `commands/login.ts` — device-code flow + `--token` PAT path
- [ ] `commands/logout.ts` — revoke + delete local
- [ ] `commands/whoami.ts` — print current identity + token source
- [ ] Token resolution helper (`--token` > `CLAUDEMESH_TOKEN` > `auth.json`)
- [ ] Unit tests for auth-store and token resolution
### Phase 4 — CLI commands wired to API (34 hours)
- [ ] Rewrite `commands/create.ts` to call `POST /api/my/meshes`
- [ ] New `commands/invite.ts` with `--email`, `--mesh`, `--role`, `--expires-in`
- [ ] Rewrite `commands/sync.ts` to call `GET /api/my/meshes` and reconcile local config
- [ ] Update `commands/list.ts` to show server-side meshes too
- [ ] Integration tests against staging broker + web
### Phase 5 — Wizard integration (34 hours)
- [ ] Welcome screen new options (Sign in / Paste token / Create account / Join invite)
- [ ] Auth screen as a flow step with `isComplete: s => s.user !== null`
- [ ] Status rows pattern showing auth state during launch
- [ ] First-run detection (no `auth.json`) → auto-route to login
### Phase 6 — Polish, docs, ship (23 hours)
- [ ] Update `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
- [ ] CHANGELOG entry for v0.11.0
- [ ] Telemetry events for `auth.cli.login.{start,success,fail}`
- [ ] Bump `apps/cli/package.json` to `0.11.0`
- [ ] Publish to npm
- [ ] Deploy broker / web (no broker changes, web for new routes)
**Total estimate:** 1926 hours of focused work. Realistic: 34 days with testing and review.
## Dependencies between phases
```
Phase 1 (backend) ──┬─→ Phase 2 (web routes)
└─→ Phase 3 (CLI auth core)
└─→ Phase 4 (commands)
└─→ Phase 5 (wizard)
└─→ Phase 6 (ship)
```
Phase 1 and 2 can be parallelized after the schema lands. Phase 3 needs Phase 1 endpoints live (even if on staging). Phase 4 onwards is strictly serial.
## Telemetry
Emit these events (PostHog or whatever the existing analytics are):
- `cli.login.started` — properties: `{ method: 'device-code' | 'pat' }`
- `cli.login.succeeded` — properties: `{ method, user_id }`
- `cli.login.failed` — properties: `{ method, reason }`
- `cli.logout` — properties: `{ user_id }`
- `cli.command.executed` — properties: `{ command, exit_code, duration_ms, authenticated: boolean }`
- `cli.api.error` — properties: `{ endpoint, status, error_code }`
Telemetry is **opt-out**. First run shows a one-line notice: "claudemesh collects anonymized usage telemetry. Disable with `claudemesh telemetry off`."
## Open questions
1. **Better Auth `apiKey` plugin version** — confirm it's installed and at a version that supports `enableMetadata`. Check `pnpm why better-auth` in `apps/web`.
2. **Audit log table** — does one already exist? If not, this spec adds three rows of log; not worth a new table for that. Use `console.log` with structured JSON to stderr and let the platform's log collector handle it.
3. **Email sending** — `claudemesh invite --email` requires a transactional email path. Does the web app already have one (Resend, Postmark)? If yes, reuse. If no, defer the email send to a follow-up; the invite command can still create the invite and print the URL.
4. **Token scopes** — v1 ships with no scopes; every token has full account access. Should we add `mesh:read`, `mesh:write`, `invite:create` scopes from day one, or wait? **Recommendation:** wait. YAGNI. Add when a user actually wants a read-only CI token.
5. **PAT expiry default** — 90 days? 1 year? Never? Better Auth supports all three. **Recommendation:** 1 year default, user can pick "never" with explicit warning.
6. **Mesh slug uniqueness in `claudemesh create`** — what happens if two users try to create meshes with the same slug? Existing API behavior should be tested. If it errors, the CLI should suggest `--slug platform-team-2`.
7. **`claudemesh login` when already logged in** — re-authenticate (overwrite) or error ("already logged in, run logout first")? **Recommendation:** re-authenticate silently with a one-line notice ("Replacing existing session for Alejandro").
## Acceptance criteria
For v0.11.0 to ship, all of these must be true:
- [ ] `claudemesh login` on a fresh machine (no `auth.json`) opens browser, completes device-code flow, writes `auth.json`, runs in <30 seconds end-to-end
- [ ] `claudemesh login --token cm_pat_…` works without browser
- [ ] `claudemesh logout` revokes server-side and deletes local file
- [ ] `claudemesh whoami` prints user identity and token source
- [ ] `claudemesh create "Test mesh"` creates a real mesh on the server, joins it locally, and the user can see it on the dashboard
- [ ] `claudemesh invite --email a@b.c --mesh test` creates an invite and prints the URL
- [ ] `claudemesh launch` (bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard
- [ ] Dashboard `/dashboard/settings/cli-tokens` lists, creates, and revokes PATs
- [ ] All flows work in `en` and `es`
- [ ] Existing `claudemesh launch` invocations (with token already in `auth.json`) still work without prompting
- [ ] Token in `auth.json` survives an hour of idle and continues to work (no aggressive expiry)
- [ ] Revoking a token in the dashboard makes the next CLI call fail with a clear error
- [ ] Documentation updated in `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
- [ ] CHANGELOG entry written
- [ ] Published to npm as `claudemesh-cli@0.11.0`
## What this unlocks
Once this lands, every dashboard-only feature becomes one CLI command away. Future specs that depend on this:
- `claudemesh members list` / `claudemesh members add`
- `claudemesh billing usage`
- `claudemesh mesh archive`
- `claudemesh stream subscribe` (live broker events)
- `claudemesh skill publish` (publish a skill to mesh registry)
- `claudemesh log tail` (mesh-wide audit log)
This is the foundational unlock. Everything else is incremental on top.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"sessionId":"ae5dbe38-9c56-4d07-9fb6-a38cb8a250a6","pid":4612,"acquiredAt":1776217467441}

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up:*)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up session:*)",
"Bash(npx tsx:*)",
"Bash(grep -r \"defineCommand\\\\|export const run\" /Users/agutierrez/Desktop/claudemesh/apps/cli/src/commands/*.ts)",
"Bash(pnpm build:*)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Ready to help:*)",
"Bash(pnpm publish:*)",
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Investigating dropped keystrokes in claudemesh launch:*)",
"Read(//Users/agutierrez/.claude/**)",
"Read(//private/tmp/**)",
"Bash(timeout 3 node dist/index.js mcp)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Fixed ZodError in MCP notification handler:*)",
"Bash(npm i:*)",
"Bash(claudemesh --version)",
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh:*)"
]
}
}

View File

@@ -0,0 +1,58 @@
---
name: integration-nextjs-app-router
description: PostHog integration for Next.js App Router applications
metadata:
author: PostHog
version: 1.9.5
---
# PostHog integration for Next.js App Router
This skill helps you add PostHog analytics to Next.js App Router applications.
## Workflow
Follow these steps in order to complete the integration:
1. `basic-integration-1.0-begin.md` - PostHog Setup - Begin ← **Start here**
2. `basic-integration-1.1-edit.md` - PostHog Setup - Edit
3. `basic-integration-1.2-revise.md` - PostHog Setup - Revise
4. `basic-integration-1.3-conclude.md` - PostHog Setup - Conclusion
## Reference files
- `references/EXAMPLE.md` - Next.js App Router example project code
- `references/next-js.md` - Next.js - docs
- `references/identify-users.md` - Identify users - docs
- `references/basic-integration-1.0-begin.md` - PostHog setup - begin
- `references/basic-integration-1.1-edit.md` - PostHog setup - edit
- `references/basic-integration-1.2-revise.md` - PostHog setup - revise
- `references/basic-integration-1.3-conclude.md` - PostHog setup - conclusion
The example project shows the target implementation pattern. Consult the documentation for API details.
## Key principles
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
## Framework guidelines
- For Next.js 15.3+, initialize PostHog in instrumentation-client.ts for the simplest setup
- For feature flags, use useFeatureFlagEnabled() or useFeatureFlagPayload() hooks - they handle loading states and external sync automatically
- Add analytics capture in event handlers where user actions occur, NOT in useEffect reacting to state changes
- Do NOT use useEffect for data transformation - calculate derived values during render instead
- Do NOT use useEffect to respond to user events - put that logic in the event handler itself
- Do NOT use useEffect to chain state updates - calculate all related updates together in the event handler
- Do NOT use useEffect to notify parent components - call the parent callback alongside setState in the event handler
- To reset component state when a prop changes, pass the prop as the component's key instead of using useEffect
- useEffect is ONLY for synchronizing with external systems (non-React widgets, browser APIs, network subscriptions)
## Identifying users
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
## Error tracking
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.

View File

@@ -0,0 +1,706 @@
# PostHog Next.js App Router Example Project
Repository: https://github.com/PostHog/context-mill
Path: basics/next-app-router
---
## README.md
# PostHog Next.js app router example
This is a [Next.js](https://nextjs.org) App Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
## Features
- **Product analytics**: Track user events and behaviors
- **Session replay**: Record and replay user sessions
- **Error tracking**: Capture and track errors
- **User authentication**: Demo login system with PostHog user identification
- **Server-side & Client-side tracking**: Examples of both tracking methods
- **Reverse proxy**: PostHog ingestion through Next.js rewrites
## Getting started
### 1. Install dependencies
```bash
npm install
# or
pnpm install
```
### 2. Configure environment variables
Create a `.env.local` file in the root directory:
```bash
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
Get your PostHog project token from your [PostHog project settings](https://app.posthog.com/project/settings).
### 3. Run the development server
```bash
npm run dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the app.
## Project structure
```
src/
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── login/
│ │ └── route.ts # Login API with server-side tracking
│ ├── burrito/
│ │ └── page.tsx # Demo feature page with event tracking
│ ├── profile/
│ │ └── page.tsx # User profile with error tracking demo
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Home/Login page
│ └── globals.css # Global styles
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
└── lib/
└── posthog-server.ts # Server-side PostHog client
instrumentation-client.ts # Client-side PostHog initialization
```
## Key integration points
### Client-side initialization (instrumentation-client.ts)
```typescript
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});
```
### User identification (AuthContext.tsx)
```typescript
posthog.identify(username, {
username: username,
});
```
### Event tracking (burrito/page.tsx)
```typescript
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});
```
### Error tracking (profile/page.tsx)
```typescript
posthog.captureException(error);
```
### Server-side tracking (app/api/auth/login/route.ts)
```typescript
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: { ... }
});
```
## App router differences from pages router
This example uses Next.js App Router instead of Pages Router. Key differences:
1. **File-based routing**: Pages in `src/app/` instead of `src/pages/`
2. **layout.tsx**: Root layout component wraps all pages
3. **API Routes**: Located in `src/app/api/` with `route.ts` files
4. **'use client'**: Client components need explicit directive
5. **useRouter**: From `next/navigation` instead of `next/router`
6. **Metadata**: Exported from layout/page instead of Head component
7. **Server Components**: Components are server-side by default
## Learn more
- [PostHog Documentation](https://posthog.com/docs)
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
- [PostHog Next.js Integration Guide](https://posthog.com/docs/libraries/next-js)
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new).
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
---
## .env.example
```example
# PostHog Configuration
# Get your PostHog project token from: https://app.posthog.com/project/settings
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
---
## instrumentation-client.ts
```ts
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
// Include the defaults option as required by PostHog
defaults: '2026-01-30',
// Enables capturing unhandled exceptions via Error Tracking
capture_exceptions: true,
// Turn on debug in development mode
debug: process.env.NODE_ENV === "development",
});
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.
```
---
## next.config.ts
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
```
---
## src/app/api/auth/login/route.ts
```ts
import { NextResponse } from 'next/server';
import { getPostHogClient } from '@/lib/posthog-server';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export async function POST(request: Request) {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
username: username,
isNewUser: isNewUser,
source: 'api'
}
});
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
});
return NextResponse.json({ success: true, user });
}
```
---
## src/app/burrito/page.tsx
```tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth();
const router = useRouter();
const [hasConsidered, setHasConsidered] = useState(false);
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const handleConsideration = () => {
incrementBurritoConsiderations();
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
};
return (
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
);
}
```
---
## src/app/layout.tsx
```tsx
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import Header from "@/components/Header";
export const metadata: Metadata = {
title: "Burrito Consideration App",
description: "Consider the potential of burritos",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<AuthProvider>
<Header />
<main>{children}</main>
</AuthProvider>
</body>
</html>
);
}
```
---
## src/app/page.tsx
```tsx
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
if (user) {
return (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are now logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
);
}
return (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
);
}
```
---
## src/app/profile/page.tsx
```tsx
'use client';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import posthog from 'posthog-js';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You&apos;ve considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You&apos;re getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You&apos;re becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
);
}
```
---
## src/components/Header.tsx
```tsx
'use client';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-container">
<nav>
<Link href="/">Home</Link>
{user && (
<>
<Link href="/burrito">Burrito Consideration</Link>
<Link href="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
```
---
## src/contexts/AuthContext.tsx
```tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
```
---
## src/lib/posthog-server.ts
```ts
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
}
);
posthogClient.debug(true);
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}
```
---

View File

@@ -0,0 +1,43 @@
---
title: PostHog Setup - Begin
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
---
We're making an event tracking plan for this project.
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
Look for opportunities to track client-side events.
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
- Payment/checkout completion
- Webhook handlers
- Authentication endpoints
Do not skip server-side events - they capture actions that cannot be tracked client-side.
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add: event name, event description, and the file path we want to place the event in. If events already exist, don't duplicate them; supplement them.
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
## Status
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
[STATUS] Checking project structure.
Status to report in this phase:
- Checking project structure
- Verifying PostHog dependencies
- Generating events based on project
---
**Upon completion, continue with:** [basic-integration-1.1-edit.md](basic-integration-1.1-edit.md)

View File

@@ -0,0 +1,37 @@
---
title: PostHog Setup - Edit
description: Implement PostHog event tracking in the identified files, following best practices and the example project
---
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
You should also add PostHog exception capture error tracking to these files where relevant.
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
Remember the documentation and example project resources you were provided at the beginning. Read them now.
## Status
Status to report in this phase:
- Inserting PostHog capture code
- A status message for each file whose edits you are planning, including a high level summary of changes
- A status message for each file you have edited
---
**Upon completion, continue with:** [basic-integration-1.2-revise.md](basic-integration-1.2-revise.md)

View File

@@ -0,0 +1,22 @@
---
title: PostHog Setup - Revise
description: Review and fix any errors in the PostHog integration implementation
---
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
Ensure that any components created were actually used.
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
## Status
Status to report in this phase:
- Finding and correcting errors
- Report details of any errors you fix
- Linting, building and prettying
---
**Upon completion, continue with:** [basic-integration-1.3-conclude.md](basic-integration-1.3-conclude.md)

View File

@@ -0,0 +1,38 @@
---
title: PostHog Setup - Conclusion
description: Review and fix any errors in the PostHog integration implementation
---
Use the PostHog MCP to create a new dashboard named "Analytics basics" based on the events created here. Make sure to use the exact same event names as implemented in the code. Populate it with up to five insights, with special emphasis on things like conversion funnels, churn events, and other business critical insights.
Search for a file called `.posthog-events.json` and read it for available events. Do not spawn subagents.
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, along with a list of links for the dashboard and insights created. Follow this format:
<wizard-report>
# PostHog post-wizard report
The wizard has completed a deep integration of your project. [Detailed summary of changes]
[table of events/descriptions/files]
## Next steps
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
[links]
### Agent skill
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
</wizard-report>
Upon completion, remove .posthog-events.json.
## Status
Status to report in this phase:
- Configured dashboard: [insert PostHog dashboard URL]
- Created setup report: [insert full local file path]

View File

@@ -0,0 +1,202 @@
# Identify users - Docs
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
However, in the frontend of a [web](/docs/libraries/js/features.md#capturing-events) or [mobile app](/docs/libraries/ios.md#capturing-events), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features.md#capturing-anonymous-events).
To link events to specific users, call `identify`:
PostHog AI
### Web
```javascript
posthog.identify(
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
);
```
### Android
```kotlin
PostHog.identify(
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
// optional: set additional person properties
userProperties = mapOf(
"name" to "Max Hedgehog",
"email" to "max@hedgehogmail.com"
)
)
```
### iOS
```swift
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
```
### React Native
```jsx
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
email: 'max@hedgehogmail.com', // optional: set additional person properties
name: 'Max Hedgehog'
})
```
### Dart
```dart
await Posthog().identify(
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
userProperties: {
email: "max@hedgehogmail.com", // optional: set additional person properties
name: "Max Hedgehog"
});
```
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
## How identify works
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users even across different sessions.
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
Using identify in the backend
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
## Best practices when using `identify`
### 1\. Call `identify` as soon as you're able to
In your frontend, you should call `identify` as soon as you're able to.
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
This ensures that events sent during your users' sessions are correctly associated with them.
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
### 2\. Use unique strings for distinct IDs
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
PostHog also has built-in protections to stop the most common distinct ID mistakes.
### 3\. Reset after logout
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
You can do that like so:
PostHog AI
### Web
```javascript
posthog.reset()
```
### iOS
```swift
PostHogSDK.shared.reset()
```
### Android
```kotlin
PostHog.reset()
```
### React Native
```jsx
posthog.reset()
```
### Dart
```dart
Posthog().reset()
```
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
Web
PostHog AI
```javascript
posthog.reset(true)
```
### 4\. Person profiles and properties
You'll notice that one of the parameters in the `identify` method is a `properties` object.
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
Person properties can also be set being adding a `$set` property to a event `capture` call.
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
### 5\. Use deep links between platforms
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
- Onboarding and signup flows before authentication.
- Unauthenticated web pages redirecting to authenticated mobile apps.
- Authenticated web apps prompting an app download.
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
3. When the user is redirected to the app, parse the deep link and handle the following cases:
- The user is already authenticated on the mobile app. In this case, call [`posthog.alias()`](/docs/libraries/js/features.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person.
- The user is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features.md#identifying-users) with the distinct ID from the web. Events will be associated with this distinct ID.
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
## Further reading
- [Identifying users docs](/docs/product-analytics/identify.md)
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better

View File

@@ -0,0 +1,385 @@
# Next.js - Docs
PostHog makes it easy to get data about traffic and usage of your [Next.js](https://nextjs.org/) app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Next.js app using the [React](/docs/libraries/react.md) and the [Node.js](/docs/libraries/node.md) SDKs.
> You can see a working example of this integration in our [Next.js demo app](https://github.com/PostHog/posthog-js/tree/main/playground/nextjs).
Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.
> **Try `@posthog/next` (pre-release):** A simplified Next.js integration with synchronized client/server identity, server-side flag bootstrapping, and a built-in API proxy. [Read the setup guide →](/docs/libraries/next-js/posthog-next.md)
## Prerequisites
To follow this guide along, you need:
1. A PostHog instance (either [Cloud](https://app.posthog.com/signup) or [self-hosted](/docs/self-host.md))
2. A Next.js application
## Beta: integration via LLM
Install PostHog for Next.js in seconds with our wizard by running this prompt with [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
`npx @posthog/wizard@latest`
[Learn more](/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
## Client-side setup
Install `posthog-js` using your package manager:
PostHog AI
### npm
```bash
npm install --save posthog-js
```
### Yarn
```bash
yarn add posthog-js
```
### pnpm
```bash
pnpm add posthog-js
```
### Bun
```bash
bun add posthog-js
```
Add your environment variables to your `.env.local` file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your [project settings](https://app.posthog.com/project/settings).
.env.local
PostHog AI
```shell
NEXT_PUBLIC_POSTHOG_TOKEN=<ph_project_token>
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
These values need to start with `NEXT_PUBLIC_` to be accessible on the client-side.
## Integration
Next.js provides the [`instrumentation-client.ts|js`](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client) file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:
PostHog AI
### instrumentation-client.js
```javascript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
### instrumentation-client.ts
```typescript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
Bootstrapping with `instrumentation-client`
When using `instrumentation-client`, the values you pass to `posthog.init` remain fixed for the entire session. This means bootstrapping only works if you evaluate flags **before your app renders** (for example, on the server).
If you need flag values after the app has rendered, youll want to:
- Evaluate the flag on the server and pass the value into your app, or
- Evaluate the flag in an earlier page/state, then store and re-use it when needed.
Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same `distinct_id` across client and server.
See the [bootstrapping guide](/docs/feature-flags/bootstrapping.md) for more information.
## Identifying users
> **Identifying users is required.** Call `posthog.identify('your-user-id')` after login to link events to a known user. This is what connects frontend event captures, [session replays](/docs/session-replay.md), [LLM traces](/docs/ai-engineering.md), and [error tracking](/docs/error-tracking.md) to the same person — and lets backend events link back too.
>
> See our guide on [identifying users](/docs/getting-started/identify-users.md) for how to set this up.
Set up a reverse proxy (recommended)
We recommend [setting up a reverse proxy](/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our [own managed reverse proxy service](/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using [Cloudflare](/docs/advanced/proxy/cloudflare.md), [AWS Cloudfront](/docs/advanced/proxy/cloudfront.md), and [Vercel](/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and [group them in one project](/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like [heatmaps](/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHogs requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
**EU**: `3.75.65.221`, `18.197.246.42`, `3.120.223.253`
**US**: `44.205.89.55`, `52.4.194.122`, `44.208.188.173`
These are public, stable IPs used by PostHog services (e.g., Celery tasks for snapshots).
## Accessing PostHog
Once initialized in `instrumentation-client.js|ts`, import `posthog` from `posthog-js` anywhere and call the methods you need on the `posthog` object.
JavaScript
PostHog AI
```javascript
'use client'
import posthog from 'posthog-js'
export default function Home() {
return (
<div>
<button onClick={() => posthog.capture('test_event')}>
Click me for an event
</button>
</div>
);
}
```
### Using React hooks
The [React feature flag hooks](/docs/libraries/react.md#feature-flags) work automatically when PostHog is initialized via `instrumentation-client.ts`. The hooks use the initialized posthog-js singleton:
JavaScript
PostHog AI
```javascript
'use client'
import { useFeatureFlagEnabled } from 'posthog-js/react'
export default function FeatureComponent() {
const showNewFeature = useFeatureFlagEnabled('new-feature')
return showNewFeature ? <NewFeature /> : <OldFeature />
}
```
### Usage
See the [React SDK docs](/docs/libraries/react.md) for examples of how to use:
- [`posthog-js` functions like custom event capture, user identification, and more.](/docs/libraries/react.md#using-posthog-js-functions)
- [Feature flags including variants and payloads.](/docs/libraries/react.md#feature-flags)
You can also read [the full `posthog-js` documentation](/docs/libraries/js/features.md) for all the usable functions.
## Server-side analytics
Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the [Node SDK](/docs/libraries/node.md).
First, install the `posthog-node` library:
PostHog AI
### npm
```bash
npm install posthog-node --save
```
### Yarn
```bash
yarn add posthog-node
```
### pnpm
```bash
pnpm add posthog-node
```
### Bun
```bash
bun add posthog-node
```
### Router-specific instructions
## App router
For the app router, we can initialize the `posthog-node` SDK once with a `PostHogClient` function, and import it into files.
This enables us to send events and fetch data from PostHog on the server without making client-side requests.
JavaScript
PostHog AI
```javascript
// app/posthog.js
import { PostHog } from 'posthog-node'
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
})
return posthogClient
}
```
> **Note:** Because server-side functions in Next.js can be short-lived, we set `flushAt` to `1` and `flushInterval` to `0`.
>
> - `flushAt` sets how many capture calls we should flush the queue (in one batch).
> - `flushInterval` sets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to call `await posthog.shutdown()` once done.
To use this client, we import it into our pages and call it with the `PostHogClient` function:
JavaScript
PostHog AI
```javascript
import Link from 'next/link'
import PostHogClient from '../posthog'
export default async function About() {
const posthog = PostHogClient()
const flags = await posthog.getAllFlags(
'user_distinct_id' // replace with a user's distinct ID
);
await posthog.shutdown()
return (
<main>
<h1>About</h1>
<Link href="/">Go home</Link>
{ flags['main-cta'] &&
<Link href="http://posthog.com/">Go to PostHog</Link>
}
</main>
)
}
```
## Pages router
For the pages router, we can use the `getServerSideProps` function to access PostHog on the server-side, send events, evaluate feature flags, and more.
This looks like this:
JavaScript
PostHog AI
```javascript
// pages/posts/[id].js
import { useContext, useEffect, useState } from 'react'
import { getServerSession } from "next-auth/next"
import { PostHog } from 'posthog-node'
export default function Post({ post, flags }) {
const [ctaState, setCtaState] = useState()
useEffect(() => {
if (flags) {
setCtaState(flags['blog-cta'])
}
})
return (
<div>
<h1>{post.title}</h1>
<p>By: {post.author}</p>
<p>{post.content}</p>
{ctaState &&
<p><a href="/">Go to PostHog</a></p>
}
<button onClick={likePost}>Like</button>
</div>
)
}
export async function getServerSideProps(ctx) {
const session = await getServerSession(ctx.req, ctx.res)
let flags = null
if (session) {
const client = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_TOKEN,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
}
)
flags = await client.getAllFlags(session.user.email);
client.capture({
distinctId: session.user.email,
event: 'loaded blog article',
properties: {
$current_url: ctx.req.url,
},
});
await client.shutdown()
}
const { posts } = await import('../../blog.json')
const post = posts.find((post) => post.id.toString() === ctx.params.id)
return {
props: {
post,
flags
},
}
}
```
> **Note**: Make sure to *always* call `await client.shutdown()` after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
### Server-side configuration
Next.js overrides the default `fetch` behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.
You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.
TSX
PostHog AI
```jsx
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
// ... your configuration
fetch_options: {
cache: 'force-cache', // Use Next.js cache
next_options: { // Passed to the `next` option for `fetch`
revalidate: 60, // Cache for 60 seconds
tags: ['posthog'], // Can be used with Next.js `revalidateTag` function
},
}
})
```
## Configuring a reverse proxy to PostHog
To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using [Next.js rewrites](/docs/advanced/proxy/nextjs.md), [Next.js middleware](/docs/advanced/proxy/nextjs-middleware.md), and [Vercel rewrites](/docs/advanced/proxy/vercel.md).
## Further reading
- [How to set up Next.js analytics, feature flags, and more](/tutorials/nextjs-analytics.md)
- [How to set up Next.js pages router analytics, feature flags, and more](/tutorials/nextjs-pages-analytics.md)
- [How to set up Next.js A/B tests](/tutorials/nextjs-ab-tests.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better

View File

@@ -5,7 +5,7 @@ name: Release CLI binaries
# (x64 + arm64) and attaches them to a GitHub Release. The `install.sh`
# fallback path curls these when Node isn't available.
#
# Publishing to npm is still a manual step (pnpm publish from apps/cli-v2) —
# Publishing to npm is still a manual step (pnpm publish from apps/cli) —
# this workflow only handles binary distribution.
on:
@@ -48,7 +48,7 @@ jobs:
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Compile binary
working-directory: apps/cli-v2
working-directory: apps/cli
shell: bash
run: |
mkdir -p dist/bin
@@ -64,7 +64,7 @@ jobs:
# on the build host, so skip them.
- name: Smoke test (native only)
if: matrix.target == 'darwin-arm64' || matrix.target == 'linux-x64'
working-directory: apps/cli-v2
working-directory: apps/cli
run: |
./dist/bin/claudemesh-${{ matrix.target }} --version
./dist/bin/claudemesh-${{ matrix.target }} --help | head -5
@@ -73,7 +73,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: claudemesh-${{ matrix.target }}
path: apps/cli-v2/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
path: apps/cli/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
release:
needs: build

View File

@@ -1,3 +1,3 @@
{
"geminiApiKey": "AIzaSyBblLRkmypvabqI-xJ_b2KPVA9Pswtav0M"
"geminiApiKey": "AIzaSyDJEyW5Q_OT1X4iGO_5jdVnq1BNANR7s2k"
}

View File

@@ -35,6 +35,10 @@ ENV BROKER_PORT=7900
COPY --from=deps --chown=bun:bun /deploy /app
# Copy migrations folder alongside the broker so runtime auto-migrate
# has files to apply. Workspace deploy subset drops them otherwise.
COPY --from=deps --chown=bun:bun /app/packages/db/migrations /app/migrations
EXPOSE 7900
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \

View File

@@ -26,6 +26,7 @@
"libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"postgres": "3.4.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"ws": "8.20.0",

View File

@@ -696,6 +696,12 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
if (req.method === "POST" && req.url?.startsWith("/cli/mesh/") && req.url?.endsWith("/grants")) {
const slug = req.url.slice("/cli/mesh/".length).replace("/grants", "");
handleCliMeshGrants(req, slug, res, started);
return;
}
if (req.method === "DELETE" && req.url?.startsWith("/cli/mesh/")) {
const slug = req.url.slice("/cli/mesh/".length);
handleMeshDelete(req, slug, res, started);
@@ -1836,6 +1842,28 @@ async function handleSend(
...(subtype ? { subtype } : {}),
};
// Per-peer grant enforcement — load recipient grant maps once per send.
// See .artifacts/specs/2026-04-15-per-peer-capabilities.md.
const DEFAULT_CAPS = ["read", "dm", "broadcast", "state-read"] as const;
const capNeeded: "dm" | "broadcast" = isMulticast ? "broadcast" : "dm";
const senderPubkey = conn.memberPubkey; // stable member key (survives session rotation)
// Fetch grant maps for all connected peers in this mesh in one query.
// Small (bounded by concurrent connections per mesh); acceptable per send.
const grantRows = await db
.select({ id: meshMember.id, peerGrants: meshMember.peerGrants })
.from(meshMember)
.where(eq(meshMember.meshId, conn.meshId));
const grantsByMemberId = new Map<string, Record<string, string[]>>(
grantRows.map((r) => [r.id, (r.peerGrants as Record<string, string[]>) ?? {}]),
);
function allowed(recipientMemberId: string): boolean {
const grants = grantsByMemberId.get(recipientMemberId);
if (!grants) return DEFAULT_CAPS.includes(capNeeded);
const entry = grants[senderPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(capNeeded);
return entry.includes(capNeeded);
}
for (const [pid, peer] of connections) {
if (pid === senderPresenceId) continue;
if (peer.meshId !== conn.meshId) continue;
@@ -1854,6 +1882,14 @@ async function handleSend(
continue;
}
// Per-peer capability check — silent drop if recipient hasn't granted
// `capNeeded` to this sender (Signal block semantics: sender sees
// delivered, recipient sees nothing).
if (!allowed(peer.memberId)) {
metrics.messagesDroppedByGrantTotal?.inc?.({ cap: capNeeded });
continue;
}
if (isMulticast) {
// Multicast: push directly to each connected peer. The queue
// row has one delivered_at — can only be claimed once. Direct
@@ -4319,7 +4355,12 @@ async function recoverScheduledMessages(): Promise<void> {
}
}
function main(): void {
async function main(): Promise<void> {
// Run pending migrations before the first connection is accepted.
// Exits non-zero on failure so Coolify sees a broken container.
const { runMigrationsOnStartup } = await import("./migrate");
await runMigrationsOnStartup();
const wss = new WebSocketServer({
noServer: true,
maxPayload: env.MAX_MESSAGE_BYTES,
@@ -5036,6 +5077,52 @@ import { checkPermission, getPermissions, setPermissions } from "./permissions";
import { meshPermission } from "@turbostarter/db/schema/mesh";
/** POST /cli/mesh/create — create a new mesh via CLI. */
/** POST /cli/mesh/:slug/grants — set per-peer grants for the caller's membership.
*
* Body: { user_id: string, grants: Record<peer_pubkey_hex, string[]> }
* Merges the map into the caller's mesh_member.peer_grants. Empty array
* for a specific peer = blocked. Explicit null = reset to defaults.
*/
async function handleCliMeshGrants(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
let body: { user_id: string; grants: Record<string, string[] | null> };
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body;
} catch {
writeJson(res, 400, { error: "Invalid body" });
return;
}
if (!body.user_id || !body.grants) {
writeJson(res, 400, { error: "user_id and grants required" });
return;
}
try {
const [m] = await db.select().from(mesh).where(eq(mesh.slug, slug)).limit(1);
if (!m) { writeJson(res, 404, { error: "Mesh not found" }); return; }
// Find the caller's member row.
const [member] = await db.select().from(meshMember)
.where(and(eq(meshMember.meshId, m.id), eq(meshMember.userId, body.user_id), isNull(meshMember.revokedAt)))
.limit(1);
if (!member) {
writeJson(res, 403, { error: "Not a member of this mesh" });
return;
}
const current = (member.peerGrants as Record<string, string[]>) ?? {};
const merged = { ...current };
for (const [pk, caps] of Object.entries(body.grants)) {
if (caps === null) delete merged[pk];
else merged[pk] = caps;
}
await db.update(meshMember).set({ peerGrants: merged }).where(eq(meshMember.id, member.id));
writeJson(res, 200, { ok: true, grants: merged });
log.info("mesh-grants", { route: "POST /cli/mesh/:slug/grants", slug, member_id: member.id, latency_ms: Date.now() - started });
} catch (e) {
log.error("mesh-grants", { error: e instanceof Error ? e.message : String(e) });
writeJson(res, 500, { error: "Failed to update grants" });
}
}
/** POST /cli/mesh/:slug/invite — generate an invite for a mesh. */
async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: ServerResponse, started: number): Promise<void> {
let body: { user_id: string; email?: string; expires_in?: string; role?: string };
@@ -5363,5 +5450,8 @@ async function handlePermissionsSet(req: IncomingMessage, slug: string, res: Ser
// Skip starting the HTTP/WS server when running under vitest — tests import
// claimInviteV2Core() directly and must not bind ports on module load.
if (!process.env.VITEST) {
main();
main().catch((e) => {
console.error("fatal:", e instanceof Error ? e.stack : e);
process.exit(1);
});
}

View File

@@ -90,6 +90,10 @@ export const metrics = {
"broker_messages_rejected_total",
"Messages rejected (size, auth, malformed)",
),
messagesDroppedByGrantTotal: new Counter(
"broker_messages_dropped_by_grant_total",
"Messages silently dropped because recipient didn't grant sender the required capability",
),
queueDepth: new Gauge(
"broker_queue_depth",
"Undelivered messages currently in the queue",

View File

@@ -0,0 +1,59 @@
/**
* Runtime migrations on broker startup.
*
* Runs pending drizzle migrations against DATABASE_URL before the broker
* listens. Uses pg_advisory_lock so a multi-instance deploy doesn't race.
* If migrations fail, the process exits non-zero so the orchestrator (Coolify
* healthcheck) sees the container as broken and doesn't route traffic.
*/
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { dirname, join } from "node:path";
import { existsSync, readdirSync } from "node:fs";
const LOCK_ID = 74737_73831; // "cmsh" ascii — stable magic constant
export async function runMigrationsOnStartup(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL not set — skipping auto-migrate");
return;
}
// Resolve the migrations folder — it's shipped inside @turbostarter/db's
// deploy subset in the runtime image. Dev path also works.
const candidates = [
"/app/migrations",
"/app/node_modules/@turbostarter/db/migrations",
join(process.cwd(), "..", "..", "packages", "db", "migrations"),
join(process.cwd(), "packages", "db", "migrations"),
];
const migrationsFolder = candidates.find((p) => existsSync(p));
if (!migrationsFolder) {
console.error("[migrate] migrations folder not found — skipping. Searched:", candidates);
return;
}
const count = readdirSync(migrationsFolder).filter((f) => f.endsWith(".sql")).length;
console.log(`[migrate] ${count} migration files at ${migrationsFolder}`);
const sql = postgres(url, { max: 1, onnotice: () => { /* quiet */ } });
try {
// Advisory lock so parallel instances serialise.
await sql`SELECT pg_advisory_lock(${LOCK_ID})`;
try {
const db = drizzle(sql);
const start = Date.now();
await migrate(db, { migrationsFolder });
console.log(`[migrate] ok (${Date.now() - start}ms)`);
} finally {
await sql`SELECT pg_advisory_unlock(${LOCK_ID})`;
}
} catch (e) {
console.error("[migrate] FAILED:", e instanceof Error ? e.message : e);
process.exit(1);
} finally {
await sql.end({ timeout: 5 });
}
}

View File

@@ -1,90 +0,0 @@
# 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.
## Install
```bash
npm i -g claudemesh-cli
```
## Quick start
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
## Commands
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or 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 doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Architecture
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── 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
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Development
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

View File

@@ -1,69 +0,0 @@
{
"name": "claudemesh-cli-v2",
"version": "1.0.0-alpha.31",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
"mcp",
"model-context-protocol",
"claudemesh",
"peer-messaging",
"multi-agent"
],
"author": "Alejandro Gutiérrez",
"license": "MIT",
"homepage": "https://claudemesh.com",
"repository": {
"type": "git",
"url": "https://github.com/alezmad/claudemesh.git",
"directory": "apps/cli-v2"
},
"type": "module",
"bin": {
"claudemesh": "./dist/entrypoints/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "bun build.ts",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "bun --hot src/entrypoints/cli.ts",
"start": "bun src/entrypoints/cli.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"prepublishOnly": "bun run build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.27.1",
"citty": "0.2.2",
"libsodium-wrappers": "0.7.15",
"qrcode-terminal": "0.12.0",
"ws": "8.20.0",
"zod": "4.1.13"
},
"devDependencies": {
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/qrcode-terminal": "0.12.2",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -1,65 +0,0 @@
import { readConfig } from "~/services/config/facade.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.");
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...");
const res = await fetch(`${brokerHttp}/tg/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meshId: mesh.meshId,
memberId: mesh.memberId,
pubkey: mesh.pubkey,
secretKey: mesh.secretKey,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
process.exit(1);
}
const { token, deepLink } = (await res.json()) as {
token: string;
deepLink: string;
};
if (linkOnly) {
console.log(deepLink);
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");
// 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)");
}
}

View File

@@ -1,81 +0,0 @@
/**
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
*
* Opens a connection to one mesh, runs a callback, then closes cleanly.
* The caller never deals with connect/close lifecycle.
*/
import { hostname } from "node:os";
import { createInterface } from "node:readline";
import { BrokerClient } from "~/services/broker/facade.js";
import { readConfig } from "~/services/config/facade.js";
import type { JoinedMesh } from "~/services/config/facade.js";
export interface ConnectOpts {
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
meshSlug?: string | null;
/** Display name for this session. Defaults to hostname-pid. */
displayName?: string;
/** Connect to all meshes and run fn for each. */
all?: boolean;
}
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
export async function withMesh<T>(
opts: ConnectOpts,
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
): Promise<T> {
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
let mesh: JoinedMesh;
if (opts.meshSlug) {
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
if (!found) {
console.error(
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
mesh = await pickMesh(config.meshes);
}
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
const client = new BrokerClient(mesh, { displayName });
try {
await client.connect();
const result = await fn(client, mesh);
return result;
} finally {
client.close();
}
}

View File

@@ -1,281 +0,0 @@
/**
* `claudemesh doctor` — diagnostic checks.
*
* Walks through the install + runtime preconditions and prints each
* as pass/fail with a fix hint on failure. Exit 0 if everything
* passes, 1 otherwise.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION, URLS } from "~/constants/urls.js";
interface Check {
name: string;
pass: boolean;
detail?: string;
fix?: string;
}
function checkNode(): Check {
const major = Number(process.versions.node.split(".")[0]);
return {
name: "Node.js >= 20",
pass: major >= 20,
detail: `v${process.versions.node}`,
fix: "Install Node 20 or newer (https://nodejs.org)",
};
}
function checkClaudeOnPath(): Check {
const res =
platform() === "win32"
? spawnSync("where", ["claude"])
: spawnSync("sh", ["-c", "command -v claude"]);
const onPath = res.status === 0;
const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined;
return {
name: "claude binary on PATH",
pass: onPath,
detail: location,
fix: "Install Claude Code (https://claude.com/claude-code)",
};
}
function checkMcpRegistered(): Check {
const claudeConfig = join(homedir(), ".claude.json");
if (!existsSync(claudeConfig)) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
fix: "Run `claudemesh install`",
};
}
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
const registered = Boolean(cfg.mcpServers?.["claudemesh"]);
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: registered,
fix: registered ? undefined : "Run `claudemesh install`",
};
} catch (e) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Check ~/.claude.json for JSON parse errors",
};
}
}
function checkHooksRegistered(): Check {
const settings = join(homedir(), ".claude", "settings.json");
if (!existsSync(settings)) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
fix: "Run `claudemesh install` (remove --no-hooks)",
};
}
try {
const raw = readFileSync(settings, "utf-8");
const has = raw.includes("claudemesh hook ");
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: has,
fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)",
};
} catch (e) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
function checkConfigFile(): Check {
const path = getConfigPath();
if (!existsSync(path)) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: true,
detail: "not created yet (fine — no meshes joined)",
};
}
try {
readConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
return {
name: "~/.claudemesh/config.json parses + chmod 0600",
pass: secure,
detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`,
fix: secure ? undefined : `chmod 600 ${path}`,
};
} catch (e) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Inspect or delete ~/.claudemesh/config.json and re-join",
};
}
}
function checkKeypairs(): Check {
try {
const cfg = readConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
pass: true,
detail: "no meshes joined",
};
}
for (const m of cfg.meshes) {
if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: pubkey malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: secret key malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
}
return {
name: "Mesh keypairs valid",
pass: true,
detail: `${cfg.meshes.length} mesh(es)`,
};
} catch (e) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function checkBrokerWs(): Promise<Check> {
const wsUrl = URLS.BROKER;
const start = Date.now();
try {
const WebSocket = (await import("ws")).default;
const ws = new WebSocket(wsUrl);
const result = await new Promise<Check>((resolve) => {
const timer = setTimeout(() => {
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: `timeout after 5s (${wsUrl})`,
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
});
}, 5000);
ws.once("open", () => {
clearTimeout(timer);
const latency = Date.now() - start;
try { ws.close(); } catch { /* noop */ }
resolve({
name: "Broker WebSocket reachable",
pass: true,
detail: `${latency}ms to ${wsUrl}`,
});
});
ws.once("error", (e) => {
clearTimeout(timer);
resolve({
name: "Broker WebSocket reachable",
pass: false,
detail: e.message,
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
});
});
});
return result;
} catch (e) {
return {
name: "Broker WebSocket reachable",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function checkNpmLatest(): Promise<Check> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
}
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
const up = latest === VERSION;
return {
name: "CLI up-to-date",
pass: up,
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
};
} catch {
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
}
}
export async function runDoctor(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh doctor (v${VERSION})`);
console.log("─".repeat(60));
const checks: Check[] = [
checkNode(),
checkClaudeOnPath(),
checkMcpRegistered(),
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
await checkBrokerWs(),
await checkNpmLatest(),
];
for (const c of checks) {
const mark = c.pass ? green("✓") : red("✗");
const detail = c.detail ? dim(` (${c.detail})`) : "";
console.log(`${mark} ${c.name}${detail}`);
if (!c.pass && c.fix) {
console.log(dim(`${c.fix}`));
}
}
const failing = checks.filter((c) => !c.pass);
console.log("");
if (failing.length === 0) {
console.log(green("All checks passed."));
process.exit(0);
} else {
console.log(red(`${failing.length} check(s) failed.`));
process.exit(1);
}
}

View File

@@ -1,123 +0,0 @@
/**
* `claudemesh hook <status>` — Claude Code hook handler.
*
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
* On each turn boundary, Claude Code invokes:
*
* Stop → `claudemesh hook idle`
* UserPromptSubmit → `claudemesh hook working`
*
* We read the Claude Code hook JSON payload from stdin (contains cwd +
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
* broker with {cwd, pid, status, session_id}. Each broker looks up
* its local presence row by (pid, cwd) and updates status.
*
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
* surface errors to the user. Debug logging available via
* CLAUDEMESH_HOOK_DEBUG=1.
*
* Why send to every broker? A user joined to multiple meshes has
* one presence row per mesh, each on its own broker. A turn boundary
* updates the status on every broker where this session is active.
* Brokers that don't have a matching presence just queue the signal
* in pending_status (harmless, TTL-swept).
*/
import { readConfig } from "~/services/config/facade.js";
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
function debug(msg: string): void {
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
}
/** WS URL → HTTP URL (same host, swap scheme). */
function wsToHttp(wsUrl: string): string {
try {
const u = new URL(wsUrl);
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
return `${httpScheme}//${u.host}`;
} catch {
return wsUrl;
}
}
async function readStdinJson(): Promise<Record<string, unknown>> {
if (process.stdin.isTTY) return {};
const chunks: Uint8Array[] = [];
const reader = process.stdin;
try {
for await (const chunk of reader) {
chunks.push(chunk as Uint8Array);
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
}
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) return {};
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
async function postHook(
brokerWsUrl: string,
body: Record<string, unknown>,
): Promise<void> {
const base = wsToHttp(brokerWsUrl);
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 1000);
await fetch(`${base}/hook/set-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(t));
} catch (e) {
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
}
}
export async function runHook(args: string[]): Promise<void> {
const status = args[0];
if (!status || !["idle", "working", "dnd"].includes(status)) {
// Silent no-op — we never want a hook to surface an error.
process.exit(0);
}
// Read Claude Code's stdin payload for cwd + session_id.
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
setTimeout(() => r({}), 500),
);
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
const cwd =
(typeof payload.cwd === "string" && payload.cwd) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
const sessionId =
(typeof payload.session_id === "string" && payload.session_id) || "";
// Fan out to EVERY joined mesh's broker in parallel.
let config;
try {
config = readConfig();
} catch (e) {
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
process.exit(0);
}
if (config.meshes.length === 0) {
debug("no joined meshes, nothing to do");
process.exit(0);
}
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
debug(
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
);
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
// covers them (broker resolves presence by cwd+pid regardless).
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
process.exit(0);
}

View File

@@ -1,60 +0,0 @@
/**
* `claudemesh inbox` — read pending peer messages.
*
* Connects, waits briefly for push delivery, drains the buffer, prints.
* Works best when message-mode is "inbox" or "off" (messages held at broker).
*/
import { withMesh } from "./connect.js";
import type { InboundPush } from "~/services/broker/facade.js";
export interface InboxFlags {
mesh?: string;
json?: boolean;
wait?: number;
}
function formatMessage(msg: InboundPush, useColor: boolean): string {
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
const from = msg.senderPubkey.slice(0, 8);
const time = new Date(msg.createdAt).toLocaleTimeString();
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
}
export async function runInbox(flags: InboxFlags): 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 waitMs = (flags.wait ?? 1) * 1000;
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
// Wait briefly for broker to push any held messages.
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
const messages = client.drainPushBuffer();
if (flags.json) {
console.log(JSON.stringify(messages, null, 2));
return;
}
if (messages.length === 0) {
console.log(dim(`No messages on mesh "${mesh.slug}".`));
return;
}
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
console.log("");
for (const msg of messages) {
console.log(formatMessage(msg, useColor));
console.log("");
}
});
}

View File

@@ -1,58 +0,0 @@
/**
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
*
* Useful for AI agents to orient themselves in a mesh via bash.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
export interface InfoFlags {
mesh?: string;
json?: boolean;
}
export async function runInfo(flags: InfoFlags): 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 config = readConfig();
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
const [brokerInfo, peers, state] = await Promise.all([
client.meshInfo(),
client.listPeers(),
client.listState(),
]);
const output = {
slug: mesh.slug,
meshId: mesh.meshId,
memberId: mesh.memberId,
brokerUrl: mesh.brokerUrl,
displayName: config.displayName ?? null,
peerCount: peers.length,
stateCount: state.length,
...(brokerInfo ?? {}),
};
if (flags.json) {
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
console.log(dim(` mesh: ${mesh.meshId}`));
console.log(dim(` member: ${mesh.memberId}`));
console.log(` peers: ${peers.length} connected`);
console.log(` state: ${state.length} keys`);
if (brokerInfo && typeof brokerInfo === "object") {
for (const [k, v] of Object.entries(brokerInfo)) {
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
}
}
});
}

View File

@@ -1,564 +0,0 @@
/**
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
*
* install:
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
* 2. Read ~/.claude.json (or empty object if absent).
* 3. Add/update `mcpServers.claudemesh` with the resolved entry path.
* 4. Write back with 0600 perms.
* 5. Verify via read-back, print success.
*
* uninstall:
* 1. Read ~/.claude.json (bail if missing).
* 2. Delete `mcpServers.claudemesh` if present.
* 3. Write back.
*
* Both are idempotent — re-running install is a no-op if the entry is
* already correct, and uninstall is a no-op if no entry exists.
*/
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
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";
const MCP_NAME = "claudemesh";
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
const HOOK_COMMAND_STOP = "claudemesh hook idle";
const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working";
const HOOK_MARKER = "claudemesh hook ";
type McpEntry = {
command: string;
args?: string[];
env?: Record<string, string>;
};
interface HookCommand {
type: "command";
command: string;
}
interface HookMatcher {
matcher?: string;
hooks: HookCommand[];
}
type HooksConfig = Record<string, HookMatcher[]>;
function readClaudeConfig(): Record<string, unknown> {
if (!existsSync(CLAUDE_CONFIG)) return {};
const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_CONFIG}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
/**
* Create a timestamped backup of ~/.claude.json before any write.
*/
function backupClaudeConfig(): void {
if (!existsSync(CLAUDE_CONFIG)) return;
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
mkdirSync(backupDir, { recursive: true });
const ts = Date.now();
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
copyFileSync(CLAUDE_CONFIG, dest);
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns the action taken ("added" | "updated" | "unchanged").
*/
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers =
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
if (!cfg.mcpServers) cfg.mcpServers = servers;
const existing = servers[MCP_NAME];
let action: "added" | "updated" | "unchanged";
if (!existing) {
servers[MCP_NAME] = entry;
action = "added";
} else if (entriesEqual(existing, entry)) {
return "unchanged";
} else {
servers[MCP_NAME] = entry;
action = "updated";
}
flushClaudeConfig(cfg);
return action;
}
/**
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
* Returns true if an entry was removed.
*/
function removeMcpServer(): boolean {
if (!existsSync(CLAUDE_CONFIG)) return false;
backupClaudeConfig();
const cfg = readClaudeConfig();
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
if (!servers || !(MCP_NAME in servers)) return false;
delete servers[MCP_NAME];
cfg.mcpServers = servers;
flushClaudeConfig(cfg);
return true;
}
/** Low-level write — callers must backup + merge first. */
function flushClaudeConfig(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
writeFileSync(
CLAUDE_CONFIG,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
try {
chmodSync(CLAUDE_CONFIG, 0o600);
} catch {
/* windows has no chmod */
}
}
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
function bunAvailable(): boolean {
const res =
platform() === "win32"
? spawnSync("where", ["bun"])
: spawnSync("sh", ["-c", "command -v bun"]);
return res.status === 0;
}
/** 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;
}
return resolve(dirname(here), "..", "index.ts");
}
/**
* Build the MCP server entry for Claude Code's config.
*
* Two modes:
* - Installed globally (npm i -g claudemesh-cli): use `claudemesh`
* as the command, relies on it being on PATH.
* - 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) {
return {
command: "claudemesh",
args: ["mcp"],
};
}
return {
command: "bun",
args: [entryPath, "mcp"],
};
}
function entriesEqual(a: McpEntry, b: McpEntry): boolean {
return (
a.command === b.command &&
JSON.stringify(a.args ?? []) === JSON.stringify(b.args ?? [])
);
}
function readClaudeSettings(): Record<string, unknown> {
if (!existsSync(CLAUDE_SETTINGS)) return {};
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch (e) {
throw new Error(
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
function writeClaudeSettings(obj: Record<string, unknown>): void {
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
writeFileSync(
CLAUDE_SETTINGS,
JSON.stringify(obj, null, 2) + "\n",
"utf-8",
);
}
/**
* All claudemesh MCP tool names, prefixed for allowedTools.
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
*/
const CLAUDEMESH_TOOLS = [
"mcp__claudemesh__cancel_scheduled",
"mcp__claudemesh__check_messages",
"mcp__claudemesh__claim_task",
"mcp__claudemesh__complete_task",
"mcp__claudemesh__create_stream",
"mcp__claudemesh__create_task",
"mcp__claudemesh__delete_file",
"mcp__claudemesh__file_status",
"mcp__claudemesh__forget",
"mcp__claudemesh__get_context",
"mcp__claudemesh__get_file",
"mcp__claudemesh__get_state",
"mcp__claudemesh__grant_file_access",
"mcp__claudemesh__graph_execute",
"mcp__claudemesh__graph_query",
"mcp__claudemesh__join_group",
"mcp__claudemesh__leave_group",
"mcp__claudemesh__list_collections",
"mcp__claudemesh__list_contexts",
"mcp__claudemesh__list_files",
"mcp__claudemesh__list_peers",
"mcp__claudemesh__list_scheduled",
"mcp__claudemesh__list_state",
"mcp__claudemesh__list_streams",
"mcp__claudemesh__list_tasks",
"mcp__claudemesh__mesh_execute",
"mcp__claudemesh__mesh_info",
"mcp__claudemesh__mesh_query",
"mcp__claudemesh__mesh_schema",
"mcp__claudemesh__message_status",
"mcp__claudemesh__ping_mesh",
"mcp__claudemesh__publish",
"mcp__claudemesh__recall",
"mcp__claudemesh__remember",
"mcp__claudemesh__schedule_reminder",
"mcp__claudemesh__send_message",
"mcp__claudemesh__set_state",
"mcp__claudemesh__set_status",
"mcp__claudemesh__set_summary",
"mcp__claudemesh__share_context",
"mcp__claudemesh__share_file",
"mcp__claudemesh__subscribe",
"mcp__claudemesh__vector_delete",
"mcp__claudemesh__vector_search",
"mcp__claudemesh__vector_store",
];
/**
* Pre-approve all claudemesh MCP tools in allowedTools.
* Merges into any existing list — never overwrites other entries.
* Returns which tools were added vs already present.
*/
function installAllowedTools(): { added: string[]; unchanged: number } {
const settings = readClaudeSettings();
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
if (toAdd.length > 0) {
settings.allowedTools = [...Array.from(existing), ...toAdd];
writeClaudeSettings(settings);
}
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
}
/**
* Remove claudemesh tools from allowedTools.
* Leaves all other entries intact. Returns count removed.
*/
function uninstallAllowedTools(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const existing = (settings.allowedTools as string[] | undefined) ?? [];
const toolSet = new Set(CLAUDEMESH_TOOLS);
const kept = existing.filter((t) => !toolSet.has(t));
const removed = existing.length - kept.length;
if (removed > 0) {
settings.allowedTools = kept;
writeClaudeSettings(settings);
}
return removed;
}
/**
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
* idempotent on the command string. Returns counts for reporting.
*/
function installHooks(): { added: number; unchanged: number } {
const settings = readClaudeSettings();
const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {};
let added = 0;
let unchanged = 0;
const ensure = (event: string, command: string): void => {
const list = (hooks[event] ??= []);
const alreadyPresent = list.some((entry) =>
(entry.hooks ?? []).some((h) => h.command === command),
);
if (alreadyPresent) {
unchanged += 1;
return;
}
list.push({ hooks: [{ type: "command", command }] });
added += 1;
};
ensure("Stop", HOOK_COMMAND_STOP);
ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT);
settings.hooks = hooks;
writeClaudeSettings(settings);
return { added, unchanged };
}
/**
* Remove every hook entry whose command contains "claudemesh hook "
* from ~/.claude/settings.json. Idempotent. Returns removed count.
*/
function uninstallHooks(): number {
if (!existsSync(CLAUDE_SETTINGS)) return 0;
const settings = readClaudeSettings();
const hooks = settings.hooks as HooksConfig | undefined;
if (!hooks) return 0;
let removed = 0;
for (const event of Object.keys(hooks)) {
const kept: HookMatcher[] = [];
for (const entry of hooks[event] ?? []) {
const filtered = (entry.hooks ?? []).filter(
(h) => !(h.command ?? "").includes(HOOK_MARKER),
);
removed += (entry.hooks ?? []).length - filtered.length;
if (filtered.length > 0) kept.push({ ...entry, hooks: filtered });
}
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
}
settings.hooks = hooks;
writeClaudeSettings(settings);
return removed;
}
function installStatusLine(): { installed: boolean } {
const settings = readClaudeSettings();
const cmd = `claudemesh status-line`;
const current = (settings as { statusLine?: { command?: string } }).statusLine;
// If the user has their own statusLine command, don't clobber it.
if (current?.command && !current.command.includes("claudemesh status-line")) {
return { installed: false };
}
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
type: "command",
command: cmd,
};
writeClaudeSettings(settings);
return { installed: true };
}
export function runInstall(args: string[] = []): void {
const skipHooks = args.includes("--no-hooks");
const wantStatusLine = args.includes("--status-line");
console.log("claudemesh install");
console.log("------------------");
const entry = resolveEntry();
const isBundled = entry.endsWith("/dist/index.js") ||
entry.endsWith("\\dist\\index.js");
// 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",
);
process.exit(1);
}
if (!existsSync(entry)) {
console.error(`✗ 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`,
);
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);
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` : ""}`,
);
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
console.log(dim(` Your existing allowedTools entries were preserved.`));
} else {
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
}
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
} catch (e) {
console.error(
`⚠ 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`,
);
} else {
console.log(`✓ Hooks already registered (${unchanged} present)`);
}
console.log(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.)",
);
}
} else {
console.log(dim("· Hooks skipped (--no-hooks)"));
}
// 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>`));
} else {
console.log(dim("· statusLine already set to a custom command — left alone"));
}
} catch (e) {
console.error(`⚠ 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.
}
console.log("");
console.log(yellow(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")}`,
);
} else {
console.log("");
console.log(
`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`));
}
export function runUninstall(): void {
console.log("claudemesh uninstall");
console.log("--------------------");
// MCP entry — only removes claudemesh, never touches other servers.
if (removeMcpServer()) {
console.log(`✓ MCP server "${MCP_NAME}" removed`);
} else {
console.log(`· MCP server "${MCP_NAME}" not present`);
}
// allowedTools
try {
const removed = uninstallAllowedTools();
if (removed > 0) {
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
} else {
console.log("· No claudemesh allowedTools to remove");
}
} catch (e) {
console.error(
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
// Hooks
try {
const removed = uninstallHooks();
if (removed > 0) {
console.log(`✓ Hooks removed (${removed} entries)`);
} else {
console.log("· No claudemesh hooks to remove");
}
} catch (e) {
console.error(
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
console.log("");
console.log("Restart Claude Code to drop the MCP connection + hooks.");
}

View File

@@ -1,193 +0,0 @@
/**
* `claudemesh join <invite-link-or-code>` — full join flow.
*
* Accepts either:
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
* persists mesh + fresh ed25519 identity.
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
* → parses signed payload, calls broker /join, persists.
*
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
*/
import { parseInviteLink } from "~/services/invite/facade.js";
import { enrollWithBroker } from "~/services/invite/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
import sodium from "libsodium-wrappers";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir, hostname } from "node:os";
import { env } from "~/constants/urls.js";
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
function deriveAppBaseUrl(): string {
const override = process.env.CLAUDEMESH_APP_URL;
if (override) return override.replace(/\/$/, "");
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
// For self-hosted: honour the broker host's parent domain as best-effort.
try {
const u = new URL(env.CLAUDEMESH_BROKER_URL);
const host = u.host.replace(/^ic\./, "");
const scheme = u.protocol === "wss:" ? "https:" : "http:";
return `${scheme}//${host}`;
} catch {
return "https://claudemesh.com";
}
}
async function runJoinV2(code: string): Promise<void> {
const appBaseUrl = deriveAppBaseUrl();
console.log(`Claiming invite ${code} via ${appBaseUrl}`);
let claim;
try {
claim = await claimInviteV2({ appBaseUrl, code });
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// Generate a fresh ed25519 identity for this peer. The v2 claim
// endpoint creates the member row keyed on the x25519 pubkey we sent;
// the ed25519 keypair is what the `hello` handshake and future
// envelope signing will use. Stored locally only.
const keypair = await generateKeypair();
const displayName = `${hostname()}-${process.pid}`;
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
// to match the format used everywhere else (broker stores it the
// same way in mesh.rootKey).
await sodium.ready;
const rootKeyB64 = sodium.to_base64(
claim.rootKey,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
// Persist. We don't have a mesh_slug in the v2 response — the server
// derives slug from name and slug is no longer globally unique. Use a
// stable short derivative of the mesh id so `list` / `launch --mesh`
// still have something to match on.
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
const config = readConfig();
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
config.meshes.push({
meshId: claim.meshId,
memberId: claim.memberId,
slug: fallbackSlug,
name: fallbackSlug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: env.CLAUDEMESH_BROKER_URL,
joinedAt: new Date().toISOString(),
rootKey: rootKeyB64,
inviteVersion: 2,
});
writeConfig(config);
console.log("");
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
console.log(` member id: ${claim.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-url-or-code>");
console.error("");
console.error("Examples:");
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
console.error(" claudemesh join abc12345");
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
process.exit(1);
}
// Try v2 first — short code / `/i/<code>` URL.
const v2Code = parseV2InviteInput(link);
if (v2Code) {
await runJoinV2(v2Code);
return;
}
// 1. Parse + verify signature client-side.
let invite;
try {
invite = await parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload, token } = invite;
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
// 2. Generate keypair.
const keypair = await generateKeypair();
// 3. Enroll with broker.
const displayName = `${hostname()}-${process.pid}`;
let enroll;
try {
enroll = await enrollWithBroker({
brokerWsUrl: payload.broker_url,
inviteToken: token,
invitePayload: payload,
peerPubkey: keypair.publicKey,
displayName,
});
} catch (e) {
console.error(
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// 4. Persist.
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== payload.mesh_slug,
);
config.meshes.push({
meshId: payload.mesh_id,
memberId: enroll.memberId,
slug: payload.mesh_slug,
name: payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: payload.broker_url,
joinedAt: new Date().toISOString(),
});
writeConfig(config);
// 4b. Store invite token for per-session re-enrollment (launch --name).
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
try {
mkdirSync(dirname(inviteFile), { recursive: true });
writeFileSync(inviteFile, link, "utf-8");
} catch {
// Non-fatal — launch will fall back to shared identity.
}
// 5. Report.
console.log("");
console.log(
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
);
console.log(` member id: ${enroll.memberId}`);
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}`);
console.log(` broker: ${payload.broker_url}`);
console.log(` config: ${getConfigPath()}`);
console.log("");
console.log("Restart Claude Code to pick up the new mesh.");
}

View File

@@ -1,823 +0,0 @@
// @ts-nocheck — v1 port, runtime-tested
/**
* `claudemesh launch` — spawn `claude` with peer mesh identity.
*
* Flags are defined in index.ts (citty command) — that is the source of
* truth. This file receives already-parsed flags and rawArgs.
*
* Flow:
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
* 2. If --join: run join flow first
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
* 4. Write per-session config to tmpdir (isolates mesh selection)
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
* 6. On exit: cleanup tmpdir
*/
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
import { tmpdir, hostname, homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
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";
// Flags as parsed by citty (index.ts is the source of truth for definitions).
export interface LaunchFlags {
name?: string;
role?: string;
groups?: string;
join?: string;
mesh?: string;
"message-mode"?: string;
"system-prompt"?: string;
resume?: string;
continue?: boolean;
yes?: boolean;
quiet?: boolean;
}
// --- Interactive mesh picker ---
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
if (meshes.length === 1) return meshes[0]!;
console.log("\n Select mesh:");
meshes.forEach((m, i) => {
console.log(` ${i + 1}) ${m.slug}`);
});
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(" Choice [1]: ", (answer) => {
rl.close();
const idx = parseInt(answer || "1", 10) - 1;
if (idx >= 0 && idx < meshes.length) {
resolve(meshes[idx]!);
} else {
console.error(" Invalid choice, using first mesh.");
resolve(meshes[0]!);
}
});
});
}
// --- Group string parser ---
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
function parseGroupsString(raw: string): GroupEntry[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((token) => {
const idx = token.indexOf(":");
if (idx === -1) return { name: token };
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
});
}
// --- Interactive role/groups prompts ---
function askLine(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// --- Permission confirmation ---
async function confirmPermissions(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
console.log(yellow(bold(" Autonomous mode")));
console.log("");
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
console.log(" ALL permission prompts — not just claudemesh tools.");
console.log(" Peers exchange text only — no file access, no tool calls.");
console.log("");
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
console.log("");
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve, reject) => {
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === "" || a === "y" || a === "yes") {
resolve();
} else {
console.log("\n Aborted. Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
});
});
}
// --- Banner ---
import {
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
} from "~/ui/styles.js";
import {
enterFullScreen, exitFullScreen, writeCentered, termSize,
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
} from "~/ui/screen.js";
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
interface LaunchWizardResult {
mesh: JoinedMesh;
role: string | null;
groups: GroupEntry[];
messageMode: "push" | "inbox" | "off";
skipPermissions: boolean;
}
/**
* Full-screen launch wizard — spinning logo + interactive config.
* Mesh selection, role, groups, message mode, permissions — all in one TUI.
* Falls back to plain text on non-TTY.
*/
async function runLaunchWizard(opts: {
displayName: string;
meshes: JoinedMesh[];
selectedMesh: JoinedMesh | null;
existingRole: string | null;
existingGroups: GroupEntry[];
existingMessageMode: "push" | "inbox" | "off" | null;
skipPermConfirm: boolean;
}): Promise<LaunchWizardResult> {
if (!process.stdout.isTTY) {
return {
mesh: opts.selectedMesh ?? opts.meshes[0]!,
role: opts.existingRole,
groups: opts.existingGroups,
messageMode: opts.existingMessageMode ?? "push",
skipPermissions: opts.skipPermConfirm,
};
}
const { rows } = termSize();
enterFullScreen();
drawTopBar();
// Spinning logo centered in upper portion
const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2);
const brandRow = logoTop + FRAME_HEIGHT + 1;
const subtitleRow = brandRow + 1;
const formRow = subtitleRow + 2;
writeCentered(brandRow, boldOrange("claudemesh"));
writeCentered(subtitleRow, tDim("peer mesh for Claude Code"));
const spinner = createSpinner({
render(lines) {
for (let i = 0; i < lines.length; i++) {
writeCentered(logoTop + i, lines[i]!);
}
},
interval: 70,
});
spinner.start();
// Show detected info
let row = formRow;
writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`);
row++;
writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`);
row += 2;
// Mesh selection
let mesh: JoinedMesh;
if (opts.selectedMesh) {
mesh = opts.selectedMesh;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else if (opts.meshes.length === 1) {
mesh = opts.meshes[0]!;
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
row++;
} else {
spinner.stop();
const choice = await menuSelect({
title: "Select mesh",
items: opts.meshes.map(m => m.slug),
row,
});
mesh = opts.meshes[choice]!;
// Redraw as confirmed
for (let i = 0; i < opts.meshes.length + 1; i++) {
writeCentered(row + i, " ");
}
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
spinner.start();
row++;
}
row++;
// Interactive fields
let role = opts.existingRole;
let groups = opts.existingGroups;
let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off";
// Role input
if (role === null) {
spinner.stop();
const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" });
if (answer) role = answer;
spinner.start();
row++;
} else {
writeCentered(row, `Role ${tGreen("✓")} ${role}`);
row++;
}
// Groups input
if (groups.length === 0) {
spinner.stop();
const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" });
if (answer) groups = parseGroupsString(answer);
spinner.start();
row++;
} else {
const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ");
writeCentered(row, `Groups ${tGreen("✓")} ${tags}`);
row++;
}
// Message mode selection
if (opts.existingMessageMode === null) {
row++;
spinner.stop();
const choice = await menuSelect({
title: "Message mode",
items: [
"Push (real-time, peers can interrupt)",
"Inbox (held until you check)",
"Off (tools only, no messages)",
],
row,
});
messageMode = (["push", "inbox", "off"] as const)[choice];
spinner.start();
row += 5;
} else {
writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`);
row++;
}
// Permissions confirmation
let skipPermissions = opts.skipPermConfirm;
if (!skipPermissions) {
row++;
spinner.stop();
writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,"));
writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh."));
row += 3;
const confirmed = await confirmPrompt({
message: boldOrange("Autonomous mode?"),
row,
defaultYes: true,
});
if (!confirmed) {
exitFullScreen();
console.log(" Run without autonomous mode:");
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
process.exit(0);
}
skipPermissions = true;
spinner.start();
}
// Final animation
row += 2;
writeCentered(row, tDim("Launching Claude Code..."));
await new Promise(r => setTimeout(r, 800));
spinner.stop();
exitFullScreen();
return { mesh, role, groups, messageMode, skipPermissions };
}
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const roleSuffix = role ? ` (${role})` : "";
const groupTags = groups.length
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
: "";
const rule = "─".repeat(60);
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
console.log(rule);
if (messageMode === "push") {
console.log("Peer messages arrive as <channel> reminders in real-time.");
} else if (messageMode === "inbox") {
console.log("Peer messages held in inbox. Use check_messages to read.");
} else {
console.log("Messages off. Use check_messages to poll manually.");
}
console.log("Peers send text only — they cannot call tools or read files.");
console.log(dim(`Config: ${getConfigPath()}`));
console.log(rule);
console.log("");
}
// --- Main ---
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
// Extract args that follow "--" — passed straight through to claude.
const dashIdx = rawArgs.indexOf("--");
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
// Normalise flags into the internal shape used below.
const args = {
name: flags.name ?? null,
role: flags.role ?? null,
groups: flags.groups ?? null,
joinLink: flags.join ?? null,
meshSlug: flags.mesh ?? null,
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
? flags["message-mode"] as "push" | "inbox" | "off"
: null),
systemPrompt: flags["system-prompt"] ?? null,
resume: flags.resume ?? null,
continueSession: flags.continue ?? false,
quiet: flags.quiet ?? false,
skipPermConfirm: flags.yes ?? false,
claudeArgs: claudePassthrough,
};
// 1. If --join, run join flow first.
if (args.joinLink) {
console.log("Joining mesh...");
const invite = await parseInviteLink(args.joinLink);
const keypair = await generateKeypair();
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const enroll = await enrollWithBroker({
brokerWsUrl: invite.payload.broker_url,
inviteToken: invite.token,
invitePayload: invite.payload,
peerPubkey: keypair.publicKey,
displayName,
});
const config = readConfig();
config.meshes = config.meshes.filter(
(m) => m.slug !== invite.payload.mesh_slug,
);
config.meshes.push({
meshId: invite.payload.mesh_id,
memberId: enroll.memberId,
slug: invite.payload.mesh_slug,
name: invite.payload.mesh_slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: invite.payload.broker_url,
joinedAt: new Date().toISOString(),
});
const { writeConfig } = await import("~/services/config/facade.js");
writeConfig(config);
console.log(
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
);
}
// 2. Load config, pick mesh.
const config = readConfig();
let justSynced = false;
if (config.meshes.length === 0 && !args.joinLink) {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
console.log(` Opening browser to sign in...\n`);
const opened = await openBrowser(url);
if (!opened) {
console.log(` Couldn't open browser automatically.`);
}
console.log(` ${dim(`Visit: ${url}`)}`);
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("\n Timed out waiting for sign-in.");
process.exit(1);
}
// Generate keypair and sync with broker
const { generateKeypair } = await import("~/services/crypto/facade.js");
const keypair = await generateKeypair();
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
const { syncWithBroker } = await import("~/services/auth/facade.js");
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
// Write all meshes to config
const { writeConfig } = await import("~/services/config/facade.js");
for (const m of result.meshes) {
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
}
config.accountId = result.account_id;
writeConfig(config);
justSynced = true;
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
}
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
process.exit(1);
}
// Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1)
let mesh: JoinedMesh;
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(", ")}`,
);
process.exit(1);
}
mesh = found;
} else if (config.meshes.length === 1) {
mesh = config.meshes[0]!;
} else {
// Multiple meshes — wizard will handle selection
mesh = null as unknown as JoinedMesh; // set by wizard below
}
// 3. Session identity + role/groups via TUI wizard.
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
let role: string | null = args.role;
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
// entirely and use sensible defaults (role=member, no groups, push mode).
// Same applies to `--quiet` and the post-sync path where we already picked.
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
if (!nonInteractive) {
const wizardResult = await runLaunchWizard({
displayName,
meshes: config.meshes,
selectedMesh: mesh ?? null,
existingRole: args.role,
existingGroups: parsedGroups,
existingMessageMode: args.messageMode ?? null,
skipPermConfirm: args.skipPermConfirm,
});
mesh = wizardResult.mesh;
role = wizardResult.role;
parsedGroups = wizardResult.groups;
messageMode = wizardResult.messageMode;
args.skipPermConfirm = wizardResult.skipPermissions;
} else if (!mesh) {
// No mesh picked yet + non-interactive — pick the first one deterministically.
mesh = config.meshes[0]!;
}
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
const tmpBase = tmpdir();
try {
for (const entry of readdirSync(tmpBase)) {
if (!entry.startsWith("claudemesh-")) continue;
const full = join(tmpBase, entry);
const age = Date.now() - statSync(full).mtimeMs;
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
}
} catch { /* best effort */ }
// Clean up stale mesh MCP entries from crashed sessions
try {
const claudeConfigPath = join(homedir(), ".claude.json");
if (existsSync(claudeConfigPath)) {
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
let cleaned = 0;
for (const key of Object.keys(mcpServers)) {
if (!key.startsWith("mesh:")) continue;
const meta = mcpServers[key]?._meshSession;
if (!meta?.pid) continue;
// Check if the PID is still alive
try {
process.kill(meta.pid, 0); // signal 0 = check existence
} catch {
// PID is dead — remove stale entry
delete mcpServers[key];
cleaned++;
}
}
if (cleaned > 0) {
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
}
}
} catch { /* best effort */ }
// --- Fetch deployed services for native MCP entries ---
let serviceCatalog: Array<{
name: string;
description: string;
status: string;
tools: Array<{ name: string; description: string; inputSchema: object }>;
deployed_by: string;
}> = [];
try {
const tmpClient = new BrokerClient(mesh, { displayName });
await tmpClient.connect();
// Wait briefly for hello_ack with service catalog
await new Promise(r => setTimeout(r, 2000));
serviceCatalog = tmpClient.serviceCatalog;
tmpClient.close();
} catch {
// Non-fatal — launch without native service entries
if (!args.quiet) {
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
}
}
// 4. Write session config to tmpdir (isolates mesh selection).
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
const sessionConfig: Config = {
version: 1,
meshes: [mesh],
displayName,
...(role ? { role } : {}),
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
messageMode,
};
writeFileSync(
join(tmpDir, "config.json"),
JSON.stringify(sessionConfig, null, 2) + "\n",
"utf-8",
);
// 5. Print summary banner (wizard already handled all interactive config).
if (!args.quiet) {
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
}
// --- Install native MCP entries for deployed mesh services ---
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
if (serviceCatalog.length > 0) {
const claudeConfigPath = join(homedir(), ".claude.json");
// Read-modify-write: only touch mesh:* entries in mcpServers
let claudeConfig: Record<string, unknown> = {};
try {
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
} catch {
claudeConfig = {};
}
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
// Session-scoped key: mesh:<service>:<sessionId>
const sessionTag = `${process.pid}`;
for (const svc of serviceCatalog) {
if (svc.status !== "running") continue;
const entryKey = `mesh:${svc.name}:${sessionTag}`;
const entry = {
command: "claudemesh",
args: ["mcp", "--service", svc.name],
env: {
CLAUDEMESH_CONFIG_DIR: tmpDir,
},
_meshSession: {
pid: process.pid,
meshSlug: mesh.slug,
serviceName: svc.name,
createdAt: new Date().toISOString(),
},
};
mcpServers[entryKey] = entry;
meshMcpEntries.push({ key: entryKey, entry });
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
if (!args.quiet && meshMcpEntries.length > 0) {
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
for (const { key } of meshMcpEntries) {
const svcName = key.split(":")[1];
const svc = serviceCatalog.find(s => s.name === svcName);
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
}
console.log("");
}
}
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
// Strip any user-supplied --dangerously flags to avoid duplicates.
const filtered: string[] = [];
for (let i = 0; i < args.claudeArgs.length; i++) {
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
continue;
}
filtered.push(args.claudeArgs[i]!);
}
// --dangerously-skip-permissions is only added when the user explicitly
// passes -y / --yes. Without it, claudemesh tools still work because
// `claudemesh install` pre-approves them via allowedTools in settings.json.
// This keeps permissions tight for multi-person meshes.
// Session identity: --resume reuses existing session, otherwise generate new.
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
const isResume = args.resume !== null || args.continueSession;
const claudeSessionId = isResume ? undefined : randomUUID();
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
...(args.resume ? ["--resume", args.resume] : []),
...(args.continueSession ? ["--continue"] : []),
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
...filtered,
];
// Resolve the full path to `claude` — when launched from a non-interactive
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
const isWindows = process.platform === "win32";
let claudeBin = "claude";
if (!isWindows) {
const candidates = [
join(homedir(), ".local", "bin", "claude"),
"/usr/local/bin/claude",
join(homedir(), ".claude", "bin", "claude"),
];
for (const c of candidates) {
if (existsSync(c)) { claudeBin = c; break; }
}
}
// 7. Define cleanup — runs on every exit path via process.on('exit').
// Synchronous-only (rmSync + writeFileSync) so it works inside the
// 'exit' event, which does not allow async work.
const cleanup = (): void => {
// Remove mesh MCP entries from ~/.claude.json
if (meshMcpEntries.length > 0) {
try {
const claudeConfigPath = join(homedir(), ".claude.json");
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
const mcpServers = claudeConfig.mcpServers ?? {};
for (const { key } of meshMcpEntries) {
delete mcpServers[key];
}
claudeConfig.mcpServers = mcpServers;
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
} catch { /* best effort */ }
}
// Ephemeral config dir
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch { /* best effort */ }
};
// Register cleanup on every exit path — including normal exit, uncaught
// throws, and fatal signals. process.on('exit') fires synchronously, which
// is what the rmSync + writeFileSync above need.
process.on("exit", cleanup);
// 8. Hard-reset the TTY before handing control to claude.
//
// Every interactive element in the pre-launch flow — the full-screen
// wizard (tui/screen.ts), the permission confirmation, the callback-
// listener paste prompt, the mesh picker — attaches listeners to
// process.stdin, toggles raw mode, hides the cursor, and sometimes
// enters the alt-screen. Those helpers do best-effort cleanup in their
// own finally blocks, but any leak — an orphaned 'data' listener, a
// still-raw TTY, a pending render paint — means the parent node process
// keeps competing with claude's Ink TUI for the same keystrokes and
// stdout frames. Symptoms: dropped keystrokes at the claude prompt, or
// the wizard visibly repainting on top of claude after launch.
//
// Defensive reset here is cheap and guarantees a clean TTY regardless
// of what the wizard helpers did or didn't restore.
if (process.stdin.isTTY) {
try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ }
}
process.stdin.removeAllListeners("data");
process.stdin.removeAllListeners("keypress");
process.stdin.removeAllListeners("readable");
process.stdin.pause();
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?25h"); // show cursor
process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it
}
// 9. Block-and-wait on claude with spawnSync.
//
// Why spawnSync instead of spawn + child.on('exit'):
// - spawn keeps the parent node event loop running alongside claude.
// Any stray listener, setImmediate, or async wizard tail-end can
// still fire during claude's lifetime, stealing input or painting
// over claude's TUI.
// - spawnSync blocks the parent event loop completely until claude
// exits. No listeners fire. Nothing paints. The parent is effectively
// suspended, and claude has exclusive ownership of the TTY.
//
// Signal forwarding: claude inherits the TTY process group via
// stdio: "inherit". When the user hits Ctrl-C, the terminal sends
// SIGINT to the whole group. Claude handles it (Ink unmounts, exits
// cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise
// the same signal on the parent so it dies the same way.
const result = spawnSync(claudeBin, claudeArgs, {
stdio: "inherit",
shell: isWindows,
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: tmpDir,
CLAUDEMESH_DISPLAY_NAME: displayName,
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
...(role ? { CLAUDEMESH_ROLE: role } : {}),
},
});
// 10. Handle the result. Cleanup runs automatically via process.on('exit').
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.");
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
}
process.exit(1);
}
if (result.signal) {
// Re-raise the same signal so the parent dies the same way the child did.
process.kill(process.pid, result.signal);
return;
}
process.exit(result.status ?? 0);
}

View File

@@ -1,25 +0,0 @@
/**
* `claudemesh leave <slug>` — remove a mesh from local config.
*
* Does NOT (yet) notify the broker. In 15b+ this will send a
* best-effort revoke request before removing the entry.
*/
import { readConfig, writeConfig } from "~/services/config/facade.js";
export function runLeave(args: string[]): void {
const slug = args[0];
if (!slug) {
console.error("Usage: claudemesh leave <slug>");
process.exit(1);
}
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);
}
writeConfig(config);
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
}

View File

@@ -1,104 +0,0 @@
/**
* `claudemesh mesh list` — merged view of server + local meshes.
*/
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";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface ServerMesh {
id: string;
slug: string;
name: string;
role: string;
is_owner: boolean;
member_count: number;
active_peers: number;
joined_at: string;
}
export async function runList(): Promise<void> {
const config = readConfig();
const auth = getStoredToken();
// Try to fetch from server
let serverMeshes: ServerMesh[] = [];
if (auth) {
try {
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
if (userId) {
const res = await request<{ meshes: ServerMesh[] }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
serverMeshes = res.meshes ?? [];
}
} catch {}
}
// Merge: server meshes + local-only meshes
const localSlugs = new Set(config.meshes.map(m => m.slug));
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
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");
return;
}
console.log("\n Your meshes:\n");
for (const slug of allSlugs) {
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 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;
let icon: string;
if (inLocal && inServer) {
icon = green("●");
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
} else if (inLocal && !inServer) {
icon = yellow("●");
status = yellow("local only");
} else {
icon = dim("○");
status = dim("not added locally");
}
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(" · ")}`);
}
console.log("");
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
}
console.log(dim(` Config: ${getConfigPath()}`));
console.log("");
}

View File

@@ -1,74 +0,0 @@
/**
* `claudemesh peers` — list connected peers in the mesh.
*
* Shows all meshes by default, or filter with --mesh.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { render } from "~/ui/render.js";
import { bold, dim, green, yellow } from "~/ui/styles.js";
export interface PeersFlags {
mesh?: string;
json?: boolean;
}
export async function runPeers(flags: PeersFlags): Promise<void> {
const config = readConfig();
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
if (slugs.length === 0) {
render.err("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(1);
}
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
for (const slug of slugs) {
try {
await withMesh({ meshSlug: slug }, async (client, mesh) => {
const peers = await client.listPeers();
if (flags.json) {
allJson.push({ mesh: mesh.slug, peers });
return;
}
render.section(`peers on ${mesh.slug} (${peers.length})`);
if (peers.length === 0) {
render.info(dim(" (no peers connected)"));
return;
}
for (const p of peers) {
const groups = p.groups.length
? " [" +
p.groups
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
.join(", ") +
"]"
: "";
const statusDot = p.status === "working" ? yellow("●") : green("●");
const name = bold(p.displayName);
const meta: string[] = [];
if (p.peerType) meta.push(p.peerType);
if (p.channel) meta.push(p.channel);
if (p.model) meta.push(p.model);
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
const summary = p.summary ? dim(`${p.summary}`) : "";
render.info(`${statusDot} ${name}${groups}${metaStr}${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");
}
}

View File

@@ -1,114 +0,0 @@
/**
* `claudemesh profile` — view or edit your member profile.
*
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
* on the server. Changes are pushed to active sessions in real-time.
*/
import { readConfig } from "~/services/config/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
export interface ProfileFlags {
mesh?: string;
"role-tag"?: string;
groups?: string;
"message-mode"?: string;
name?: string;
member?: string; // admin only: edit another member
json?: boolean;
}
export async function runProfile(flags: ProfileFlags): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const config = readConfig();
if (config.meshes.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
process.exit(1);
}
// Pick mesh
const mesh = flags.mesh
? config.meshes.find(m => m.slug === flags.mesh)
: config.meshes[0]!;
if (!mesh) {
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
process.exit(1);
}
// Derive broker HTTP URL from WSS URL
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
if (hasEdits) {
// PATCH member profile
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
const body: Record<string, unknown> = {};
if (flags.name !== undefined) body.displayName = flags.name;
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
if (flags.groups !== undefined) {
body.groups = flags.groups.split(",").map(s => {
const [name, role] = s.trim().split(":");
return role ? { name: name!, role } : { name: name! };
});
}
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-Member-Id": mesh.memberId,
},
body: JSON.stringify(body),
});
const result = await res.json() as Record<string, unknown>;
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.ok) {
console.log(green("✓ Profile updated"));
const member = result.member as Record<string, unknown>;
printProfile(member, dim);
} else {
console.error(`Error: ${result.error}`);
process.exit(1);
}
} else {
// GET members list, show current user's profile
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
if (!result.ok) {
console.error(`Error: ${result.error}`);
process.exit(1);
}
const me = result.members?.find(m => m.id === mesh.memberId);
if (flags.json) {
console.log(JSON.stringify(me ?? {}, null, 2));
} else if (me) {
printProfile(me, dim);
} else {
console.log("Member not found in mesh.");
}
}
}
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
const groupStr = groups?.length
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
: dim("(none)");
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
console.log(` Groups: ${groupStr}`);
console.log(` Messages: ${member.messageMode ?? "push"}`);
console.log(` Access: ${member.permission ?? "member"}`);
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
}

View File

@@ -1,142 +0,0 @@
/**
* `claudemesh remind <message> --in <duration> | --at <time>`
* `claudemesh remind list`
* `claudemesh remind cancel <id>`
*
* Human-facing interface to the broker's scheduled message delivery.
*/
import { withMesh } from "./connect.js";
export interface RemindFlags {
mesh?: string;
in?: string; // e.g. "2h", "30m", "90s"
at?: string; // ISO or HH:MM
cron?: string; // 5-field cron expression for recurring
to?: string; // default: self
json?: boolean;
}
function parseDuration(raw: string): number | null {
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
if (!m) return null;
const n = parseFloat(m[1]!);
const unit = (m[2] ?? "s").toLowerCase();
if (unit.startsWith("d")) return n * 86_400_000;
if (unit.startsWith("h")) return n * 3_600_000;
if (unit.startsWith("m")) return n * 60_000;
return n * 1_000;
}
function parseDeliverAt(flags: RemindFlags): number | null {
if (flags.in) {
const ms = parseDuration(flags.in);
if (ms === null) return 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
return target.getTime();
}
const ts = Date.parse(flags.at);
return isNaN(ts) ? null : ts;
}
return null;
}
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; }
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("");
}
});
return;
}
// claudemesh remind cancel <id>
if (action === "cancel") {
const id = positional[1];
if (!id) { console.error("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); }
});
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>");
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>');
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)) {
targetSpec = flags.to;
} else {
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)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
} else {
targetSpec = client.getSessionPubkey() ?? "*";
}
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 (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}`);
} else {
const when = new Date(result.deliverAt).toLocaleString();
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
}
});
}

View File

@@ -1,44 +0,0 @@
/**
* `claudemesh seed-test-mesh` — dev-only helper for 15b testing.
*
* Writes a locally-valid JoinedMesh entry to ~/.claudemesh/config.json
* so the MCP server can connect to a locally-running broker without
* invite-link / crypto plumbing.
*
* Usage:
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
*/
import { readConfig, writeConfig } from "~/services/config/facade.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',
);
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,
memberId,
slug,
name: `Test: ${slug}`,
pubkey,
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
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\`.`,
);
}

View File

@@ -1,51 +0,0 @@
/**
* `claudemesh send <to> <message>` — send a message to a peer or group.
*
* <to> can be:
* - a display name ("Mou")
* - a pubkey hex ("abc123...")
* - @group ("@flexicar")
* - * (broadcast to all)
*/
import { withMesh } from "./connect.js";
import type { Priority } from "~/services/broker/facade.js";
export interface SendFlags {
mesh?: string;
priority?: string;
}
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
const priority: Priority =
flags.priority === "now" ? "now"
: flags.priority === "low" ? "low"
: "next";
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)"}`);
process.exit(1);
}
targetSpec = match.pubkey;
}
const result = await client.send(targetSpec, message, priority);
if (result.ok) {
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
} else {
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
process.exit(1);
}
});
}

View File

@@ -1,75 +0,0 @@
/**
* `claudemesh state get <key>` — read a shared state value
* `claudemesh state set <key> <value>` — write a shared state value
* `claudemesh state list` — list all state entries
*/
import { withMesh } from "./connect.js";
export interface StateFlags {
mesh?: string;
json?: boolean;
}
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)`));
return;
}
if (flags.json) {
console.log(JSON.stringify(entry, null, 2));
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()}`));
});
}
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);
} catch {
parsed = value;
}
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
await client.setState(key, parsed);
console.log(`${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();
if (flags.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log(dim(`No state on mesh "${mesh.slug}".`));
return;
}
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()}`));
}
});
}

View File

@@ -1,101 +0,0 @@
/**
* `claudemesh status` — one-shot health report.
*
* Reports CLI version, config path + permissions, each joined mesh
* with broker reachability (WS handshake probe). Exit 0 if every
* mesh's broker is reachable, 1 otherwise.
*/
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { readConfig, getConfigPath } from "~/services/config/facade.js";
import { VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
latencyMs?: number;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string; latencyMs?: number }> {
return new Promise((resolve) => {
const started = Date.now();
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
const latency = Date.now() - started;
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true, latencyMs: latency });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
export async function runStatus(): Promise<void> {
render.section(`status (v${VERSION})`);
const configPath = getConfigPath();
let configPermsNote = "missing";
if (existsSync(configPath)) {
const mode = (statSync(configPath).mode & 0o777).toString(8).padStart(4, "0");
configPermsNote = mode === "0600" ? `${mode}` : `${mode} — expected 0600`;
}
render.kv([["config", configPath], ["perms", configPermsNote]]);
const config = readConfig();
if (config.meshes.length === 0) {
render.blank();
render.info("No meshes joined.");
render.hint("claudemesh <invite-url> # join + launch");
process.exit(0);
}
render.blank();
render.heading(`meshes (${config.meshes.length})`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
const probe = await probeBroker(m.brokerUrl);
const entry: MeshStatus = {
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
latencyMs: probe.latencyMs,
};
results.push(entry);
if (probe.ok) {
render.ok(`${m.slug}`, `${probe.latencyMs}ms → ${m.brokerUrl}`);
} else {
render.err(`${m.slug}`, `unreachable (${probe.error})`);
}
}
render.blank();
for (const r of results) {
render.kv([[r.slug, `${r.pubkey.slice(0, 16)}`]]);
}
const allOk = results.every((r) => r.reachable);
render.blank();
if (allOk) {
render.ok("all meshes reachable");
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
render.err(`${broken} of ${results.length} mesh(es) unreachable`);
process.exit(1);
}
}

View File

@@ -1,89 +0,0 @@
/**
* `claudemesh sync` — re-sync meshes from dashboard account.
*
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
* merges new meshes into local config.
*/
import { createInterface } from "node:readline";
import { hostname } from "node:os";
import { readConfig, writeConfig } from "~/services/config/facade.js";
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
import { openBrowser } from "~/services/spawn/facade.js";
import { generateKeypair } from "~/services/crypto/facade.js";
export async function runSync(args: { force?: boolean }): Promise<void> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const config = readConfig();
const code = generatePairingCode();
const listener = await startCallbackListener();
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
console.log(`Opening browser to sync meshes...`);
console.log(dim(`Visit: ${url}`));
await openBrowser(url);
// Race: localhost callback vs manual paste vs timeout
const manualPromise = new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question("Paste sync token (or wait for browser): ", (answer) => {
rl.close();
if (answer.trim()) resolve(answer.trim());
});
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 15 * 60_000);
});
const syncToken = await Promise.race([
listener.token,
manualPromise,
timeoutPromise,
]);
listener.close();
if (!syncToken) {
console.error("Timed out waiting for sign-in.");
process.exit(1);
}
// Use existing keypair from first mesh, or generate new
const keypair = config.meshes.length > 0
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
: await generateKeypair();
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
// Merge: add new meshes, skip duplicates
let added = 0;
for (const m of result.meshes) {
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
config.meshes.push({
meshId: m.mesh_id,
memberId: m.member_id,
slug: m.slug,
name: m.slug,
pubkey: keypair.publicKey,
secretKey: keypair.secretKey,
brokerUrl: m.broker_url,
joinedAt: new Date().toISOString(),
});
added++;
}
config.accountId = result.account_id;
writeConfig(config);
if (added > 0) {
console.log(green(`✓ Added ${added} new mesh(es)`));
} else {
console.log(`Already up to date (${config.meshes.length} meshes)`);
}
}

View File

@@ -1,72 +0,0 @@
/**
* `claudemesh` with no args + no joined meshes → unified onboarding.
*
* One flow, one keystroke per decision. Collapses the old three-branch
* picker (signup / login / join) into a linear path:
*
* 1. Already have an invite URL? → paste it, run the bare-URL join+launch.
* (no account needed — invites are self-signed capabilities)
* 2. Else: open the browser for sign-in + mesh creation at claudemesh.com
* and fall back to paste-sync when the browser hand-off lands.
*
* The branch that used to be "register" collapses into the browser flow
* (the web handles signup + mesh creation as one wizard there).
*/
import { createInterface } from "node:readline";
import { readConfig } from "~/services/config/facade.js";
import { renderWelcome } from "~/ui/welcome/index.js";
import { login } from "./login.js";
import { render } from "~/ui/render.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
import { EXIT } from "~/constants/exit-codes.js";
function prompt(q: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
});
}
export async function runWelcome(): Promise<number> {
const config = readConfig();
if (config.meshes.length > 0) return EXIT.SUCCESS;
renderWelcome();
render.info("Do you already have an invite link? (y/n) [n]");
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
if (hasInvite) {
render.blank();
render.info("Paste your invite link (claudemesh.com/i/... or claudemesh://...)");
const raw = await prompt(" > ");
if (!raw || !isInviteUrl(raw)) {
render.err("That doesn't look like a claudemesh invite URL.");
render.hint("Check your email — the link starts with https://claudemesh.com/i/");
return EXIT.INVALID_ARGS;
}
const normalised = normaliseInviteUrl(raw);
render.blank();
render.ok(`Joining via ${normalised}`);
const { runLaunch } = await import("./launch.js");
await runLaunch(
{
join: normalised,
name: process.env.USER ?? process.env.USERNAME ?? undefined,
yes: false,
},
[],
);
return EXIT.SUCCESS;
}
// No invite → browser-first sign-in + mesh creation.
render.blank();
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
render.hint("After sign-in, paste the sync token back here when prompted.");
render.blank();
return await login();
}
export { runWelcome as _stub };

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
/**
* MCP tool schemas + shared types for the CLI's MCP server.
*/
export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs {
to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string;
priority?: Priority;
}
export interface ListPeersArgs {
mesh_slug?: string; // filter to one joined mesh
}
export interface SetSummaryArgs {
summary: string;
}
export interface SetStatusArgs {
status: PeerStatus;
}
// --- Service deployment types ---
export type ServiceScope =
| "peer"
| "mesh"
| { peers: string[] }
| { group: string }
| { groups: string[] }
| { role: string };
export interface ServiceInfo {
name: string;
type: "mcp" | "skill";
description: string;
status: string;
tool_count: number;
deployed_by: string;
scope: ServiceScope;
source_type: string;
runtime?: string;
created_at: string;
}
export interface ServiceToolSchema {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface VaultEntry {
key: string;
entry_type: "env" | "file";
mount_path?: string;
description?: string;
updated_at: string;
}
export interface MeshMcpDeployArgs {
server_name: string;
file_id?: string;
git_url?: string;
git_branch?: string;
env?: Record<string, string>;
runtime?: "node" | "python" | "bun";
memory_mb?: number;
network_allow?: string[];
scope?: ServiceScope;
}
export interface VaultSetArgs {
key: string;
value: string;
type?: "env" | "file";
mount_path?: string;
description?: string;
}

View File

@@ -1,11 +0,0 @@
export interface MeshTemplate { name: string; description: string; groups: Array<{ name: string; roles: string[] }>; stateKeys: Record<string, string>; suggestedRoles: string[]; systemPromptHint: string; }
import { template as devTeam } from "./dev-team.js";
import { template as research } from "./research.js";
import { template as opsIncident } from "./ops-incident.js";
import { template as simulation } from "./simulation.js";
import { template as personal } from "./personal.js";
export const TEMPLATES: Record<string, MeshTemplate> = { "dev-team": devTeam, research, "ops-incident": opsIncident, simulation, personal };
export function listTemplates(): MeshTemplate[] { return Object.values(TEMPLATES); }
export function getTemplate(name: string): MeshTemplate | undefined { return TEMPLATES[name]; }

View File

@@ -1,15 +0,0 @@
{
"extends": "@turbostarter/tsconfig/base.json",
"compilerOptions": {
"lib": ["es2022"],
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
},
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
alias: {
"~": resolve(__dirname, "src"),
},
},
});

View File

@@ -1,83 +1,90 @@
# claudemesh-cli
Client tool for claudemesh — install once per machine, join one or more
meshes, and your Claude Code sessions can talk to peers on demand.
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.
## Install
```sh
# From npm (once published)
npm install -g claudemesh-cli
# Or from the monorepo during dev
cd apps/cli && bun link
```bash
npm i -g claudemesh-cli
```
Then register the MCP server with Claude Code:
## Quick start
```sh
claudemesh install
# prints: claude mcp add claudemesh --scope user -- claudemesh mcp
```bash
claudemesh register # create account
claudemesh new "my-team" # create a mesh
claudemesh invite # generate invite link
claudemesh # start a session
```
Run the printed command, then restart Claude Code.
## Join a mesh
```sh
claudemesh join https://claudemesh.com/join/<token>
```
## Launch Claude Code
For real-time **push messages** from peers (messages injected mid-turn
as `<channel source="claudemesh">` system reminders), launch with:
```sh
claudemesh launch
# or pass through any claude flags:
claudemesh launch --model opus
claudemesh launch --resume
```
Under the hood this runs:
```sh
claude --dangerously-load-development-channels server:claudemesh
```
Plain `claude` still works — the MCP tools are available — but incoming
messages are **pull-only** via the `check_messages` tool instead of
being pushed to Claude immediately.
The invite link is generated by whoever runs the mesh. It bundles the
mesh id, expiry, signing key, and role. Your CLI verifies it,
generates a fresh keypair, enrolls you with the broker, and persists
the result to `~/.claudemesh/config.json`.
## Commands
```sh
claudemesh install # register MCP + status hooks
claudemesh uninstall # remove MCP + status hooks
claudemesh launch [args] # launch Claude Code with push messages enabled
claudemesh join <url> # join a mesh via invite URL
claudemesh list # show joined meshes + identities
claudemesh leave <slug> # leave a mesh
claudemesh mcp # start MCP server (stdio — Claude Code only)
claudemesh --help # show usage
```
USAGE
claudemesh start a session (creates one if needed)
claudemesh <url> join a mesh from an invite link
claudemesh new create a new mesh
claudemesh invite [email] generate an invite
claudemesh list see your meshes
claudemesh rename <name> rename the current mesh
claudemesh leave [mesh] leave a mesh
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state ... get, set, or 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 doctor diagnose issues
claudemesh whoami show current identity
claudemesh status check broker connectivity
claudemesh register create account
claudemesh login sign in via browser
claudemesh logout sign out
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
```
## Env overrides
## Architecture
| Var | Default | Purpose |
| ----------------------- | ---------------------------- | ------------------------------ |
| `CLAUDEMESH_BROKER_URL` | `wss://ic.claudemesh.com/ws` | Point at a self-hosted broker |
| `CLAUDEMESH_CONFIG_DIR` | `~/.claudemesh/` | Override config location |
| `CLAUDEMESH_DEBUG` | `0` | Verbose logging |
```
src/
├── entrypoints/ CLI + MCP stdio entry points
├── cli/ argv parsing, output formatters, signal handling
├── commands/ one verb per file (29 commands)
├── services/ 17 feature-folders with facade pattern
│ ├── auth/ device-code OAuth, token storage
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
│ ├── config/ ~/.claudemesh/config.json with atomic writes
│ ├── mesh/ CRUD, join, resolve target
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
│ ├── 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
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
├── constants/ exit codes, paths, URLs, timings
├── types/ API, mesh, peer interfaces
├── utils/ levenshtein, slug, URL, format, semver, retry
├── locales/ English strings (i18n ready)
└── templates/ 5 mesh templates
```
## Status
## Development
v0.1.0 scaffold — CLI commands + MCP server shell in place. WS broker
connection, libsodium crypto, invite-link verification, and auto-install
of hooks land in subsequent steps.
```bash
pnpm install
bun run dev # hot-reload
bun run build # production build
bun run typecheck # tsc --noEmit
```
## License
MIT

Some files were not shown because too many files have changed in this diff Show More