220 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
b4703a482d feat(cli): bump to alpha.30 + channel message polish
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
Channel messages now render as '<sender>: <body>' with priority
+ broadcast badges in Claude Code's <channel> reminders, so the inbox
reads as a chat thread rather than bare lines.

[URGENT] alice: deploy is blocking release
bob (broadcast): team sync 15min
charlie: pr #42 lgtm

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:54:53 +01:00
Alejandro Gutiérrez
29f546abcf fix(ci): skip smoke tests for cross-compiled binaries
macos-latest = ARM64, ubuntu-latest = x64. Only darwin-arm64 and
linux-x64 binaries can execute on their build host; the others are
cross-compiled and will Exec format error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:51:25 +01:00
Alejandro Gutiérrez
5716a6ce22 chore: refresh pnpm-lock for cli-v2 qrcode-terminal dep
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:47:52 +01:00
Alejandro Gutiérrez
d37516213a chore(cli-v2): un-ignore CLI source tree for binary release workflow
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
The CLI source (242 files, ~14k lines) was gitignored during the
earlier cli→cli-v2 reorg so only the published npm package carried it.
That blocks the GitHub Actions release workflow (release-cli.yml),
which clones the repo fresh on each runner and needs the source to
compile binaries via `bun build --compile`.

Moves the gitignore from root-level to `apps/cli-v2/.gitignore` with
only the usual build artefacts excluded (node_modules, dist, .turbo,
.cache). Source is now in git at apps/cli-v2/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:45:44 +01:00
Alejandro Gutiérrez
5b69de08da fix(ci): use packageManager from package.json for pnpm setup
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:43:50 +01:00
Alejandro Gutiérrez
ccf95ff382 feat(distribution): binary release pipeline + brew + winget
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
- .github/workflows/release-cli.yml: build self-contained binaries via
  `bun build --compile` for darwin/linux/windows × x64/arm64 on every
  cli-v* tag, attach to GitHub Release with SHA256SUMS, auto-bump the
  homebrew tap on non-prerelease versions.
- packaging/homebrew/claudemesh.rb.template: formula template for the
  homebrew-claudemesh tap.
- packaging/winget/claudemesh.yaml.template: winget manifest template.
- /install script now detects absence of Node and downloads the
  platform-appropriate binary from the GitHub Release, installs to
  ~/.claudemesh/bin, and shims into ~/.local/bin — zero Node required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:42:16 +01:00
Alejandro Gutiérrez
43f2728283 docs: specs for binary distribution pipeline + per-peer capabilities
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
Capture the design for the two tier-2 items that weren't shipped inline
in alpha.28 — both require CI/infrastructure work (GitHub Actions,
Homebrew tap, winget manifest) or broker schema migration that's safer
to do as a separate PR with feature flag rollout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:34:45 +01:00
Alejandro Gutiérrez
d33b8fc43b feat(web): install.sh and InstallToggle use one-command UX
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
- /install shell script now points users at `claudemesh <invite-url>`
  (one step) instead of the split join+launch
- InstallToggle first-time panel shows single copy-block with
  install+launch on the same line
- Also advertises url-handler install and shell completions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:33:36 +01:00
Alejandro Gutiérrez
ce52fcef2d feat(invite): branded email + one-command install+launch UX
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
Email (broker):
- Rebrand mesh-invitation.tsx to match site (clay accent #d97757,
  cream fg, Anthropic Serif/Mono, dark bg). Mesh glyph in header.
- Hero CTA links to the /i/short URL landing page.
- Single one-liner 'npm i -g claudemesh-cli && claudemesh launch --join URL'
  so new users copy once, paste once, done.

Web InstallToggle:
- Replace two-step numbered list with single one-liner in the first-time
  panel. Reduces copy/paste ops from 2 to 1 and stops prescribing
  'YourName' as a literal (CLI now defaults to $USER).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:14:27 +01:00
Alejandro Gutiérrez
77ee1d0d80 feat(broker): branded react-email template for mesh invite
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
Replaces the plain-text invite email with a standalone react-email
template (apps/broker/src/emails/mesh-invitation.tsx) using
@react-email/components + Tailwind. Rendered on demand in
handleCliMeshInvite and sent as both HtmlBody and TextBody via
Postmark (or html+text via Resend).

Self-contained — no dependency on @turbostarter/email, i18n, or ui
packages. Adds react, react-dom, @react-email/components, @react-email/render
to broker deps. Enables tsconfig jsx: react-jsx and .tsx includes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:04:28 +01:00
Alejandro Gutiérrez
2f27a5eef4 feat(broker): actually send invite email via Postmark, return emailed flag
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
Broker now sends the invite email when body.email is provided and
POSTMARK_API_KEY (or RESEND_API_KEY) is configured. Returns
`emailed: boolean` so the CLI can honestly report whether the email
was sent instead of falsely claiming success on link generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:48:53 +01:00
Alejandro Gutiérrez
32851419e6 fix(broker): generate owner keys on CLI mesh create + proper invite signing
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
handleCliMeshCreate now generates ownerPubkey/ownerSecretKey/rootKey so
CLI-created meshes can issue invites. handleCliMeshInvite builds the
full signed v1 payload + v2 capability (matching createMyInvite in
packages/api) and self-heals meshes created by older broker versions
that are missing keys.

Fixes 500 on claudemesh share after CLI mesh create.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:37:16 +01:00
Alejandro Gutiérrez
e2b6e53cc1 feat(broker): add POST /cli/mesh/:slug/invite endpoint
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:10:34 +01:00
Alejandro Gutiérrez
3595fc2c4d feat(broker): add list_services and list_commands tools to telegram AI
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:20:00 +01:00
Alejandro Gutiérrez
2825ef7151 feat(broker): add conversation memory to telegram AI (10-turn window)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:09:32 +01:00
Alejandro Gutiérrez
a9858ef876 fix(broker): teach AI difference between mesh names and peer names
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:06:08 +01:00
Alejandro Gutiérrez
6836a495a4 fix(broker): switch telegram AI to HTML formatting + strip markdown
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:58:45 +01:00
Alejandro Gutiérrez
07720f8f1e feat(broker): add list_meshes tool + multilingual AI responses
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:53:03 +01:00
Alejandro Gutiérrez
f4881b21b0 feat(broker): add claude-powered telegram bot with tool calling
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:40:16 +01:00
Alejandro Gutiérrez
4561076904 fix(broker): accept pubkey in mesh create + use in member row
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:02:09 +01:00
Alejandro Gutiérrez
0d53f2ae52 fix(broker): use raw SQL for mesh create to avoid Drizzle default issues
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:57:54 +01:00
Alejandro Gutiérrez
b328e78bd3 fix(broker): import generateId for mesh create handler
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:47:35 +01:00
Alejandro Gutiérrez
23604a125e fix(broker): mesh list includes owner meshes + auto-increment slug
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:12:06 +01:00
Alejandro Gutiérrez
b680260c8d feat(broker): add POST /cli/mesh/create endpoint
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:04:41 +01:00
Alejandro Gutiérrez
b65a545ece feat(broker): add /cli/meshes endpoint for merged mesh list
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:39:08 +01:00
Alejandro Gutiérrez
d07cff788c feat: three-token auth flow (session_id + user_code + device_code)
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
- session_id (clm_sess_...) in browser URL — identifies login attempt
- user_code (ABCD-EFGH) visual confirmation — shown in both terminal and browser
- device_code (secret) — CLI polls with this, never displayed
- CLI accepts stdin paste of JWT token while polling (race)
- Web page handles both ?session= and ?code= params

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:19:08 +01:00
Alejandro Gutiérrez
bb1310167e feat: granular mesh permissions + mesh delete + share picker
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
- Drizzle schema: mesh.permission table with 11 boolean flags
- Default permissions by role (owner > admin > member)
- Broker: GET/POST /cli/mesh/:slug/permissions
- Broker: DELETE /cli/mesh/:slug (owner only, soft delete)
- Broker: permission check module (getPermissions, checkPermission, setPermissions)
- CLI: mesh share with interactive mesh picker
- CLI: mesh delete with server-side delete + confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:03:28 +01:00
Alejandro Gutiérrez
ea4e3b03bb feat: paste-token auth flow for CLI
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
- Broker: POST /cli/token generates a 30-day JWT
- Web: /token page with Generate + Copy button
- Web: /api/auth/cli/token proxies to broker
- CLI: login option 3 "Paste a token" for headless environments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:17:38 +01:00
Alejandro Gutiérrez
1a42c2ef09 chore: trigger Vercel redeploy
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:58:17 +01:00
Alejandro Gutiérrez
43b70013c5 fix: exclude cli-v2 from git to unblock Vercel builds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:53:29 +01:00
Alejandro Gutiérrez
b8d8b5469b fix: rename cli-v2 package to avoid Turborepo duplicate workspace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:46:18 +01:00
Alejandro Gutiérrez
ab7fb6bd31 chore(web): bust Vercel build cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:39:04 +01:00
Alejandro Gutiérrez
b2999878c4 fix(web): inline CSS stub loader for Vercel path resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:34:56 +01:00
Alejandro Gutiérrez
a890a1d92e fix(web): use --import instead of --experimental-loader for Vercel compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:29:52 +01:00
Alejandro Gutiérrez
80a6b8b50f fix(web): resolve Payload CMS build error with Node.js ESM loader
Payload CMS imports .css/.scss/.svg files that Node.js ESM can't handle
during page data collection. Added a custom ESM loader that stubs these
asset imports, fixing the build that has been broken since the upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:24:32 +01:00
Alejandro Gutiérrez
465ff9a10e fix(web): rewrite CLI auth login as standalone component
Remove dependency on SocialProviders/RegisterForm which need
React Query providers. Self-contained with authClient directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:06:42 +01:00
Alejandro Gutiérrez
0f46c787a7 feat(web): show authenticated user in marketing header
Header now checks session and shows avatar + name + Dashboard link
when logged in, instead of always showing Sign in / Start free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:55:33 +01:00
Alejandro Gutiérrez
a365fef170 feat(web): dedicated CLI auth page with inline login/register
No more redirect to generic /auth/login. The /cli-auth?code=XXXX page
now shows auth forms inline (Google, GitHub, email) with device code
context — like Anthropic's "Build with Claude" page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:51:18 +01:00
Alejandro Gutiérrez
ca441dae45 feat(broker): device-code auth with PostgreSQL persistence
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
- Drizzle schema: device_code + cli_session tables in mesh pgSchema
- Broker endpoints: POST /cli/device-code, GET /cli/device-code/:code,
  POST /cli/device-code/:code/approve, GET /cli/sessions
- Web app API routes now proxy to broker (no in-memory state)
- Tracks devices per user: hostname, platform, arch, last_seen, token_hash
- JWT signed with CLI_SYNC_SECRET, 30-day expiry
- Session revocation support via revokedAt column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:22:13 +01:00
Alejandro Gutiérrez
ac709dbe92 feat(web): add device-code OAuth API for CLI authentication
New API endpoints:
- POST /api/auth/cli/device-code/new — issue device code + user code
- GET /api/auth/cli/device-code/[code] — poll device code status
- POST /api/auth/cli/device-code/[code]/approve — approve by device code
- POST /api/auth/cli/device-code/approve-by-user-code — approve by user code

Updated cli-auth page to auto-approve on page load after authentication
(no manual "Approve" button click needed).

Enables `claudemesh login` and `claudemesh register` CLI commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:10:09 +01:00
Alejandro Gutiérrez
d0fbc64e7e feat(web): two-mode pricing (hosted + self-hosted) across landing
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
Rewrites pricing section from single "public beta" card to side-by-side
hosted vs self-hosted comparison reflecting the cleaner product
architecture. Enterprise sell is now concrete: "Run our Docker image,
point your CLI at it, done — your mesh never leaves your VPC."

Updates hero subtitle, CTA, FAQ, and where-mesh-fits claim card to
reinforce the two deployment modes consistently across the landing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:17:38 +01:00
Alejandro Gutiérrez
f1d35b10da fix(cli): clean TTY handoff to claude via spawnSync + defensive reset
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
Terminals spawned by `claudemesh launch` were dropping keystrokes at
claude's prompt and showing the launch wizard re-rendering on top of
claude's TUI. Two compounding causes:

1. spawn() + child.on('exit') kept the parent node event loop alive
   during claude's lifetime. Any stray readline 'data' listener or
   late render from the wizard could fire on the inherited stdin/
   stdout, stealing keystrokes or painting over claude's Ink TUI.
2. Raw mode / alt-screen / hidden cursor set by the wizard helpers
   was not reliably restored before the handoff.

Fix:
- Swap spawn for spawnSync so the parent event loop is fully blocked
  while claude runs. No listener or setImmediate can fire during
  claude's lifetime.
- Hard TTY reset right before the spawn: setRawMode(false),
  removeAllListeners on stdin, show cursor (ESC[?25h), exit alt
  screen (ESC[?1049l). Defensive — survives partial wizard cleanup.
- Move cleanup() registration to process.on('exit') so it runs
  synchronously on every exit path (normal, signal, throw).
- Preserve signal forwarding: if claude dies from a signal, re-raise
  the same signal on the parent so exit codes propagate correctly.

Bumps to v0.10.6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:38:09 +01:00
Alejandro Gutiérrez
5e97d48cd5 feat(web): animated mesh hero with peer constellation + comparison section
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
- New hero section with a live animated mesh background: three equal
  Claude Code peers in a triangle layout + six desaturated background
  peers, all rendered pixel-perfect from pure React/CSS using the exact
  Unicode characters and colors from Claude Code's own source.
- User prompts type into the bottom prompt-input box and "submit" to
  scrollback (matching real Claude Code behavior). Mesh sends fly as
  envelope icons with fading trails between peers; receivers pulse on
  arrival. Dynamic routing by peer displayName.
- Radial vignette overlay keeps the hero title crisp while letting the
  corner peers pulse visibly around the edges. Top/bottom linear fades
  bleed into adjacent sections.
- Responsive scaling via ResizeObserver: cover-fit in hero bg context,
  contain-fit for standalone use.
- Features section: added Skills, MCPs, and Commands as the first
  three tabs — the mesh's real differentiators. Updated subtitle copy.
- New "Where claudemesh fits" section positioned between Features and
  WhatIsClaudemesh: four-card comparison (vs MCP, vs subagents,
  vs OpenClaw, and the positive claim) framing claudemesh as a wire
  between Claude Code sessions, not a replacement for any of them.

All work is additive: 10 new files in apps/web/src/modules/marketing/
home/fake-claude-code/ plus hero-mesh-animation.tsx, hero-with-mesh.tsx,
and where-mesh-fits.tsx. Single edit each to features.tsx and
(marketing)/page.tsx to swap in the new hero and mount the new section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:39:24 +01:00
Alejandro Gutiérrez
c8ae6462e3 feat(web): email invite mode + ic:// removal in invite generator (wave 3)
Completes the v2 invite user experience. The generator now ships two
delivery modes behind a simple Link | Email toggle, and the vestigial
ic:// scheme is gone from every user-visible surface.

Modes
- Link (default, existing flow): mints a v2 invite, displays short URL
  + QR + CLI command. No behavioral change vs wave 2.
- Email (new): admin types a recipient email, submit dispatches through
  the POST /api/my/meshes/:id/invites/email endpoint (wave 2), which
  mints a normal v2 invite, records a pending_invite row, and stubs the
  Postmark send with a TODO. Result card shows a "✓ Invite sent to X"
  banner plus the same QR card so the admin can also share manually.

Honest UX copy on the stub:
"Email delivery is stubbed in v0.1.x — the invite is valid. Share the
link directly if needed." Avoids pretending something shipped that
hasn't.

ic:// cleanup
- inviteLink field no longer rendered or stored (still returned by the
  API for backward compatibility; just not surfaced)
- CLI command now copies `claudemesh join <code>` (falls back to
  shortUrl when code is null), matching the new v2 entry point
- Zero remaining `ic://` references in the UI

Implementation notes
- Two separate useForm instances (linkForm, emailForm) with dedicated
  resolvers and submit handlers — clearer state boundaries than
  conditional validation on a merged schema
- Mode toggle uses role="group" + aria-pressed, focus-visible ring,
  keyboard-navigable
- Email result banner is role="status" for screen readers
- RPC client has one `as any` on `(api.my.meshes[":id"].invites as any)
  .email.$post` — the endpoint IS registered server-side (wave 2) but
  the monorepo's Hono type regen is out-of-band; TODO comment marks the
  cast for removal when the RPC types catch up
- No new deps
- Component export signature unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:57:25 +01:00
Alejandro Gutiérrez
fb7a84aed6 feat: v2 invite API + CLI claim flow + CLI friction reducer (wave 2)
Wires the v2 invite protocol end-to-end from a CLI user's perspective.
Broker foundation landed in c1fa3bc; this commit is the glue between
it and the human.

API (packages/api)
- createMyInvite now mints BOTH v1 token (legacy) AND v2 capability.
  Two-phase insert: row first (to get invite.id), then UPDATE with
  signed canonical bytes stored as JSON {canonical, signature} in the
  capabilityV2 column. Broker's claim handler parses the same shape.
- canonicalInviteV2 locked to `v=2|mesh_id|invite_id|expires_at|role|
  owner_pubkey_hex` — byte-identical to apps/broker/src/crypto.ts.
- brokerHttpBase() helper rewrites wss://host/ws → https://host for
  server-to-server calls.
- POST /api/public/invites/:code/claim — thin proxy to broker;
  passes status + body through, 502 broker_unreachable on fetch fail,
  cache-control: no-store.
- POST /api/my/meshes/:id/invites/email — mints a normal v2 invite
  via createMyInvite, records a pending_invite row, calls stubbed
  sendEmailInvite (logs TODO for Postmark wiring in a later PR).
- New schemas: claimInviteInput/ResponseSchema,
  createEmailInviteInput/ResponseSchema, v2 fields on
  createMyInviteResponseSchema.
- v1 paths untouched — legacy /join/[token] and /api/public/invite/:token
  continue to work throughout v0.1.x.

CLI (apps/cli)
- New `claudemesh join <code-or-url>` subcommand.
- Accepts bare code (abc12345), short URL (claudemesh.com/i/abc12345),
  or legacy ic://join/<token>. Detects v2 vs v1 and dispatches.
- v2 path: generates fresh ephemeral x25519 keypair (separate from
  the ed25519 identity) → POST /api/public/invites/:code/claim →
  unseals sealed_root_key via crypto_box_seal_open → persists mesh
  with inviteVersion: 2 and base64url rootKey to local config.
- Signature verification skipped with TODO — v0.1.x trusts broker;
  seal-open is already authenticated.
- apps/cli/src/lib/invite-v2.ts: generateX25519Keypair, claimInviteV2,
  parseV2InviteInput.
- state/config.ts: additive rootKey?/inviteVersion? fields.

CLI friction reducer
- apps/cli/src/index.ts: flag-first invocations
  (`claudemesh --resume xxx`, `claudemesh -c`, `claudemesh -- --model
  opus`) now route through `launch` automatically. Bare `claudemesh`
  still shows welcome; known subcommands dispatch normally.
- Removes one word of cognitive load: users never type `launch`.

No schema changes. No new deps. v1 fully backward compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:35:21 +01:00
Alejandro Gutiérrez
c1fa3bcb5c feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:11 +01:00
Alejandro Gutiérrez
dbea96960f fix(broker): plain text push messages, mesh slug in push label
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:27:22 +01:00
Alejandro Gutiérrez
a022da1998 fix(broker): show mesh slugs in /meshes + /status, remove all-meshes fallback
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
- /meshes and /status now show mesh slug names instead of truncated IDs
- meshSlug cached on connect and loaded from DB join on boot
- Remove dangerous fallback that connected to ALL meshes in email flow
- BridgeRow now includes optional meshSlug field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:24:55 +01:00
Alejandro Gutiérrez
5df2664bae feat(web): rewrite hero (pain-first) + streamline page + enterprise tier
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
Hero: 'Your Claude Code sessions work alone. claudemesh connects
them.' Three pain cards (context dies, teams relay by hand, setup
per developer). Solution paragraph focuses on shared wire with
E2E encryption.

Page: removed 5 redundant sections (Surfaces, LaptopToLaptop,
MeshVsMcp, MeetsYou, BeyondTerminal, DemoDashboard). Kept the
strongest: Hero, Features, WhatIsClaudemesh, Timeline, Pricing,
FAQ, CTA, MeshStats.

Pricing: added Enterprise tier with Contact sales → info@claudemesh.com.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:00:27 +01:00
Alejandro Gutiérrez
816c42feae docs: key points for landing page + outreach copy
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:56:40 +01:00
Alejandro Gutiérrez
4c0a417b7c docs: canonical pitch in founder's voice
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:54:59 +01:00
Alejandro Gutiérrez
e6962f1454 feat(web): /install route with server-side tracking
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Restores the curl installer script at /install. Adds:
- In-memory fetch counter (visible in container logs)
- Server-side PostHog event 'install_script_fetched' with
  IP, user-agent, and referer (fire-and-forget)
- Console log per fetch for monitoring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:57:37 +01:00
Alejandro Gutiérrez
1d506f3ea5 fix(web): add libsodium-wrappers to serverExternalPackages
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
Missing from standalone output → invite creation crashes with
'Cannot find module libsodium-wrappers' in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:34:15 +01:00
Alejandro Gutiérrez
64266a75f7 fix(broker): plain text for email verification prompt (markdown parse error)
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
Masked email with asterisks broke Telegram Markdown bold syntax.
Use plain text for the code prompt message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:15:10 +01:00
Alejandro Gutiérrez
2710f354a9 fix(broker): correct libsodium import in email connect callback
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
Dynamic import returns module wrapper, need .default.ready then .default
for the actual sodium functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:09:32 +01:00
Alejandro Gutiérrez
6b55859d38 fix(broker): email connect searches userId + dashboardUserId + fallback
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
Members created by CLI don't have dashboardUserId set. Now searches
by both userId and dashboardUserId columns. Falls back to all meshes
if no member link found (bootstrap case for mesh owners).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:02:04 +01:00
Alejandro Gutiérrez
7d31cc6283 fix(broker): email connect creates bridge member with fresh keypair
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
The member table has no secretKey column (by design - keys are local).
Email verification now generates a fresh ed25519 keypair and creates
a new bridge-specific member entry for each mesh the user belongs to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:54:16 +01:00
Alejandro Gutiérrez
0403cfeb76 chore(cli): bump to v0.9.2 with connect telegram command
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:00:45 +01:00
Alejandro Gutiérrez
d8e6900072 feat(broker): email verification flow for telegram /connect
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
Users can now type /connect in the bot → enter email → receive 6-digit
code → enter code → auto-connect to all meshes linked to that email.

Supports Resend and Postmark email providers via env vars.
Rate-limited to 5 code attempts, 10-min expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:00:02 +01:00
Alejandro Gutiérrez
ed8dab8bd3 fix(web): update email to alex@mourente.ai + correct LinkedIn URL
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:44:48 +01:00
Alejandro Gutiérrez
dad51870d9 feat(broker): file upload recipient picker in telegram bridge
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
Instead of broadcasting files to all peers, the bot now uploads first
then shows an inline keyboard: individual peers, Everyone, or Keep private.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:43:55 +01:00
Alejandro Gutiérrez
a6af0f2154 security(broker): harden telegram bridge for production
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
- Validate JWT signature + expiry in /start (was only decoding, not verifying)
- Constant-time signature comparison in telegram-token.ts (prevent timing attacks)
- Rate limit /tg/token endpoint: 10 requests/hour per IP
- Grammy bot.catch() error handler (prevent unhandled rejections crashing broker)
- Cap WS reconnect attempts at 20 (prevent infinite retry loop)
- Expire stale pendingDMs entries (prevent memory leak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:20:59 +01:00
Alejandro Gutiérrez
0661e6223a fix(web): correct LinkedIn URL on about page
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00
Alejandro Gutiérrez
05e3c43e29 fix(web): scope webpack SVG loader to packages/ui only
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
Exclude app/ SVGs (icon.svg, opengraph) from @svgr/webpack —
Next.js metadata loader handles those. Only transform flag/logo
SVGs from packages/ui/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:01:00 +01:00
Alejandro Gutiérrez
e3fa6e6a5e feat(cli): register connect/disconnect telegram commands
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:44:32 +01:00
Alejandro Gutiérrez
17066b4f6c fix(web): add webpack SVG loader (TURBOPACK=0 prod builds)
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
turbopack.rules only applies to turbopack. When building with
TURBOPACK=0 (required for Payload CMS), webpack has no SVG rule.
Icons.UnitedKingdom returns an object → React #130. Adding a
webpack config rule for @svgr/webpack fixes both bundler paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:44 +01:00
Alejandro Gutiérrez
8d1685e64d fix(broker): upsert telegram bridge on reconnect (duplicate key)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:33:02 +01:00
Alejandro Gutiérrez
bb28e16c7d fix(broker): increase healthcheck start-period, catch Grammy errors
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:14:44 +01:00
Alejandro Gutiérrez
ac59d2acfe fix(broker): correct bot username claudemeshbot (no underscore)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:08:00 +01:00
Alejandro Gutiérrez
0a1af84712 fix(web): skip sherif postinstall in Docker build
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:56:42 +01:00
Alejandro Gutiérrez
18dc29aba1 feat(web): timeline section — 66 releases, every feature shipped
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
Editorial timeline with vertical track, colored phase markers,
2-column feature grids per milestone. Shows v0.1→v0.8 evolution:
Foundation → Groups → Shared Intelligence → Files → Data Platform
→ Platform. Anchored by '66 npm releases. Every feature below is
in production today.' Dashed 'next' card at bottom for roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:52 +01:00
Alejandro Gutiérrez
795217093f fix(broker): wire telegram bridge boot + token endpoint into index.ts
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:49:56 +01:00
Alejandro Gutiérrez
61b0813924 fix(broker): add grammy dependency for telegram bridge
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:43:12 +01:00
Alejandro Gutiérrez
c10337ab9f chore: update lockfile for telegram bridge deps
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:38:53 +01:00
Alejandro Gutiérrez
126bbfeb2c feat(broker+cli): multi-tenant telegram bridge with 4 entry points
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
- DB: mesh.telegram_bridge table + migration
- Broker: telegram-bridge.ts (Grammy bot + WS pool + routing)
- Broker: telegram-token.ts (JWT connect tokens)
- Broker: POST /tg/token endpoint + bridge boot on startup
- CLI: claudemesh connect/disconnect telegram commands
- Spec: docs/telegram-bridge-spec.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:03:11 +01:00
Alejandro Gutiérrez
c914f2b7db chore: update lockfile for telegram package
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 03:01:27 +01:00
Alejandro Gutiérrez
a8b9348b36 feat(broker+cli): telegram bridge and file download proxy
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:57:02 +01:00
Alejandro Gutiérrez
c3dd4efe82 feat(cli): enforce context:fork via Agent tool instruction in prompts/get
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
Claude Code's MCP prompts path doesn't support the context field
natively. When a skill has context:"fork", prepend an instruction
telling the model to use the Agent tool with the specified agent
type and model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:16:00 +01:00
Alejandro Gutiérrez
a7d9ecab15 feat(broker): add cli-sync, member-api, jwt modules + DB schema updates
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
New broker endpoints for CLI auth sync flow (POST /cli-sync),
member profile management, and mesh settings. Includes JWT
verification for dashboard-issued sync tokens. DB schema adds
member profile fields and mesh policy columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:54:50 +01:00
Alejandro Gutiérrez
d263fe0f26 fix(cli): delay welcome notification for MCP init handshake
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
Welcome was silently dropped when sent before Claude Code's
notifications/initialized. Add 2s delay after WS connects to
ensure the MCP handshake is complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:25:10 +01:00
Alejandro Gutiérrez
3226493e6d fix(cli): catch unhandled rejection in background wirePushHandlers
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:15:09 +01:00
Alejandro Gutiérrez
4cb5a97512 perf(cli): instant MCP startup — WS connects in background
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
Move startClients() to run after server.connect(), not before.
MCP server is available to Claude Code in <0.5s instead of ~30s.
Tool handlers gracefully return errors until WS is ready.
Push event wiring happens in background callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:50 +01:00
Alejandro Gutiérrez
c080bc517f fix(web): stub all static asset extensions (.svg, .png, fonts) in ESM loader
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
2026-04-09 01:09:38 +01:00
Alejandro Gutiérrez
471e88b3e6 fix(web): stub .scss/.sass/.less in addition to .css in ESM loader
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
2026-04-09 00:52:39 +01:00
Alejandro Gutiérrez
c66e3adf67 fix(web): use absolute path for CSS stub loader in Docker
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
2026-04-09 00:43:07 +01:00
Alejandro Gutiérrez
3f46a6657a fix(web): add CSS stub loader for Payload CMS route collection in Docker
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
Node ESM can't handle .css imports during Next.js route collection.
This loader intercepts .css resolutions and returns empty modules,
fixing the build for all Payload deps (richtext-lexical, react-image-crop, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:35:04 +01:00
Alejandro Gutiérrez
83ba1aa373 fix(web): restore serverExternalPackages for Payload + use --webpack for build
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
Root cause: Next.js 16 defaults to Turbopack for builds, but Payload CMS's
richtext-lexical imports .css files that fail during route collection in
Node ESM context.

Fix: add @payloadcms/richtext-lexical and @payloadcms/next back to
serverExternalPackages so Next.js skips their internal imports during
route collection. Use --webpack explicitly since Turbopack production
builds are incompatible with Payload (payloadcms/payload#14786).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 00:26:06 +01:00
Alejandro Gutiérrez
7430e4ffe0 fix(web): header nav links → real pages (docs, blog, about, changelog)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:24:33 +01:00
Alejandro Gutiérrez
d72e49b8fd fix(web): header GitHub link → claudemesh-cli repo
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:18:53 +01:00
Alejandro Gutiérrez
3f57944921 chore(cli): bump version to 0.9.0
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:01:58 +01:00
Alejandro Gutiérrez
b31aab8aeb feat(cli+broker): expose mesh skills as MCP prompts and skill:// resources
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
Claudemesh MCP server now declares prompts:{} and resources:{} capabilities.
Mesh skills auto-appear as /claudemesh:skill-name slash commands in Claude Code
via prompts/list+get, and as skill://claudemesh/{name} resources for the
upcoming MCP_SKILLS protocol. share_skill accepts optional metadata (when_to_use,
allowed_tools, model, context, agent) stored in the manifest jsonb column.
Change notifications sent on share/remove so Claude Code refreshes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:01:06 +01:00
Alejandro Gutiérrez
5db9842261 docs: add git deploy test result (45/45 pass)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:08:09 +01:00
Alejandro Gutiérrez
81e520fdbb docs: update test results — 44/44 pass, CLI 0.8.0-0.8.9
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:54:53 +01:00
Alejandro Gutiérrez
26c4502277 fix(cli): display system push messages without decryption
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
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:12:49 +01:00
Alejandro Gutiérrez
bfc62b9a72 fix(cli): display system push messages without decryption
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
System messages (watch_triggered, mcp_deployed, peer_joined, etc.)
have senderPubkey='system' with empty ciphertext. The push handler
now formats them as readable plaintext instead of failing to decrypt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:51:12 +01:00
Alejandro Gutiérrez
f8c6f9ae74 feat(broker): add test endpoints for url watch validation
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:37:23 +01:00
Alejandro Gutiérrez
3497700fad feat: url watch — broker polls URLs, notifies on change
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:29:43 +01:00
Alejandro Gutiérrez
2c156f832e docs: add test results for mesh services platform (37/37 pass)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:37:47 +01:00
Alejandro Gutiérrez
4ee810242d fix(broker): restore services in failed/crashed/restarting states too
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:30:15 +01:00
Alejandro Gutiérrez
b6224c4186 fix(broker): sync with runner on boot instead of re-deploying
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
Boot restore now checks runner /health to see what's already running,
then updates DB status to match. Fixes the bug where broker restart
marked running services as 'failed' because it tried to re-deploy
without shared source volume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:26:43 +01:00
Alejandro Gutiérrez
4c385a16cc fix(runner): use python -m for Python MCPs instead of CLI binary
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:38:31 +01:00
Alejandro Gutiérrez
4ae6a86bf6 fix(runner): retry MCP init for slow Python startup
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:37:04 +01:00
Alejandro Gutiérrez
c327c282e3 fix(runner): install mcp[cli] extras for Python MCPs
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:35:16 +01:00
Alejandro Gutiérrez
e645455b22 fix(runner): run Python venv binaries directly, not via node
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:34:29 +01:00
Alejandro Gutiérrez
45505a1635 fix(runner): fix uvx variable scoping bug
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:33:51 +01:00
Alejandro Gutiérrez
17e6361d64 fix(runner): uv venv --clear for redeployments
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:52 +01:00
Alejandro Gutiérrez
528e7e21b1 fix(runner): use uv pip install for Python venv
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:31:13 +01:00
Alejandro Gutiérrez
7b875de301 feat(runner): add uvxPackage source type for Python MCPs
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:30:30 +01:00
Alejandro Gutiérrez
8a3c96dc7c fix(runner): prefer package-matching binary over utility bins
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:28:50 +01:00
Alejandro Gutiérrez
b0634b829c fix(runner): set GIT_TERMINAL_PROMPT=0 for non-interactive clone
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:27:10 +01:00
Alejandro Gutiérrez
2bd388a5e2 fix(runner): add missing writeFileSync import
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:22:12 +01:00
Alejandro Gutiérrez
71c0767a1b feat: runner accepts git/npx sources, broker delegates extraction
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
Runner /load now accepts gitUrl, npxPackage, or sourcePath. It handles
git clone and npm install internally. Broker no longer needs shared
volume for source extraction — just tells the runner what to fetch.

CLI mesh_mcp_deploy now supports npx_package as a third source type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:18:25 +01:00
Alejandro Gutiérrez
6a3f087209 fix(runner): add unzip for bun install in Dockerfile
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:08:27 +01:00
Alejandro Gutiérrez
873f588057 feat: runner container + broker deploy pipeline
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/runner/: Dockerfile (node22 + python3 + uv + bun) + supervisor.mjs
  (HTTP API for load/call/unload/health)
- docker-compose: runner service with shared services-data volume
- Broker mcp_deploy: git clone or zip extract → runner /load → MCP spawn
- Broker mcp_call: routes managed services to runner via HTTP, falls back
  to live-proxy for peer-hosted servers
- RUNNER_URL env var for broker → runner communication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:06:43 +01:00
Alejandro Gutiérrez
070a3b7422 feat(broker): encrypt env vars at rest, restore on reboot
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
- broker-crypto.ts: AES-256-GCM encrypt/decrypt with BROKER_ENCRYPTION_KEY
- mcp_deploy stores env as _encryptedEnv in mesh.service.config (no plaintext in DB)
- boot restore: decrypts _encryptedEnv and re-spawns services via service-manager
- auto-generates ephemeral key if BROKER_ENCRYPTION_KEY not set (logs warning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:25:48 +01:00
Alejandro Gutiérrez
75ca892ea7 feat(cli): vault_get + deploy-time vault resolution
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
- Add vault_get wire message to fetch encrypted entries for client-side
  decryption
- Deploy handler resolves $vault: refs: fetches encrypted entries from
  broker, decrypts with mesh keypair locally, sends resolved env over TLS
- File-type vault entries encoded as __vault_file__:path:base64 for
  runner-side extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:16:46 +01:00
Alejandro Gutiérrez
a90046a8e3 fix(cli): e2e encrypt vault entries with libsodium
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:10:23 +01:00
Alejandro Gutiérrez
02a165dd76 feat(cli): add --resume and --continue flags to launch
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
claudemesh launch now supports:
  --resume <id> / -r  — resume a previous Claude Code session
  --continue / -c     — continue the most recent conversation

When resuming, skips generating a new session ID so the mesh peer
identity persists. The detectClaudeSessionId() fallback in ws/client.ts
picks up the existing session UUID from the .jsonl file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:57:24 +01:00
Alejandro Gutiérrez
52393429f9 feat(cli): use Claude Code session ID for mesh peer identity
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
claudemesh launch now generates a UUID and passes it to claude via
--session-id flag + CLAUDEMESH_SESSION_ID env var. The MCP server
reads this and sends it in the hello handshake.

Fallback: when launched without claudemesh launch (e.g., claude --resume),
detectClaudeSessionId() scans ~/.claude/projects/ for the most recent
.jsonl file and extracts the session UUID from the filename.

Benefits:
- Broker detects reconnections (same session = restore state)
- Multiple peers in same project dir get unique identities
- Session identity persists across --resume

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:38:44 +01:00
Alejandro Gutiérrez
9474d985ae fix(cli): add missing tool call handlers for vault + service tools
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
The Wave 3I handlers (vault_set, vault_list, vault_delete, mesh_mcp_deploy,
mesh_mcp_undeploy, mesh_mcp_update, mesh_mcp_logs, mesh_mcp_scope,
mesh_mcp_schema, mesh_mcp_catalog, mesh_skill_deploy) were lost during
the re-apply phase. Tools were registered in tools/list but returned
"Unknown tool" because the switch cases in server.ts were missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:25:18 +01:00
Alejandro Gutiérrez
643c808685 docs(web): 2-command onboarding — install + launch --join
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
Simplify getting-started to 2 steps: npm install + launch --join.
Remove "claudemesh install" section, update join page to show
launch --join as the primary flow, update invite format examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:13:21 +01:00
Alejandro Gutiérrez
2c24f667f9 refactor(web): remove install script, simplify onboarding to 3 steps
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Drop /install route (curl|bash script). Install is just `npm i -g
claudemesh-cli`. Update hero, FAQ, getting-started, and join flow to
reflect the simplified 3-step onboarding: install → join → launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:09:17 +01:00
Alejandro Gutiérrez
b0113913f2 chore: bump cli to 0.8.0
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:54:16 +01:00
Alejandro Gutiérrez
e1cafa54b3 feat: mesh services platform — deploy MCP servers, vaults, scopes
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Add the foundation for deploying and managing MCP servers on the VPS
broker, with per-peer credential vaults and visibility scopes.

Architecture:
- One Docker container per mesh with a Node supervisor
- Each MCP server runs as a child process with its own stdio pipe
- claudemesh launch installs native MCP entries in ~/.claude.json
- Mid-session deploys fall back to svc__* dynamic tools + list_changed

New components:
- DB: mesh.service + mesh.vault_entry tables, mesh.skill extensions
- Broker: 19 wire protocol types, 11 message handlers, service catalog
  in hello_ack with scope filtering, service-manager.ts (775 lines)
- CLI: 13 tool definitions, 12 WS client methods, tool call handlers,
  startServiceProxy() for native MCP proxy mode
- Launch: catalog fetch, native MCP entry install, stale sweep, cleanup,
  MCP_TIMEOUT=30s, MAX_MCP_OUTPUT_TOKENS=50k

Security: path sanitization on service names, column whitelist on
upsertService, returning()-based delete checks, vault E2E encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:53:03 +01:00
Alejandro Gutiérrez
a4f2e0aa81 feat(web): mesh structure section (tree + coordination patterns)
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
Shows the full hierarchy: Organization → Mesh → @groups → Peers
with live state + memory. Six coordination patterns below with
code snippets: lead-gather, delegation, voting, chain review,
broadcast, targeted views. Footer: 'All patterns are conventions
in system prompts. The broker routes; Claude coordinates.'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:20:31 +01:00
Alejandro Gutiérrez
cbcde4d910 feat(web): capability stack diagram below the wire
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
Shows the 12 capability categories that flow through the mesh:
messages, groups, state, memory, files, SQL, vectors, graph,
tasks, context, streams, scheduled. Each with a mono icon tag
and one-line description. Anchored by '43 MCP tools, 5
persistence backends' footer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:14:38 +01:00
Alejandro Gutiérrez
495c234159 fix(web): enable turbopack + payload by unbundling richtext-lexical
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
The CSS import error was caused by richtext-lexical being in
serverExternalPackages — Node can't require .css files. Removing
it lets Turbopack bundle it (handling CSS natively). Other payload
packages stay external (they don't import CSS).

Restores turbopack as the default production bundler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:38:56 +01:00
Alejandro Gutiérrez
42c1d02f5e docs: add game architecture vision — NPCs as data, AI on demand
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
NPCs are mesh data (skills, memory, state), not peers. One API call
per interaction, 3 coordinator peers per faction. Game connector
assembles context from mesh and calls any LLM on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:30:13 +01:00
Alejandro Gutiérrez
a33c925216 docs: add simulation controller SDK spec, replace spatial topology
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
The mesh is the communication fabric, not the simulation engine.
SimController pattern: external controller drives tick loop, computes
visibility, sends observations to peers, collects actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:38:43 +01:00
Alejandro Gutiérrez
6ab3fbbea3 fix(web): fix getting-started metadata export, use TURBOPACK=0 env for build
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
- generateMetadata instead of metadata (getMetadata returns a function)
- Use TURBOPACK=0 env prefix instead of --no-turbopack flag (not recognized in Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:37:10 +01:00
Alejandro Gutiérrez
26adbafde2 fix(web): remove --no-turbopack from build script (Docker uses ENV TURBOPACK=0)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
The --no-turbopack flag isn't recognized when Next.js runs inside the
Docker builder stage. The Dockerfile already sets ENV TURBOPACK=0 which
achieves the same effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:29:20 +01:00
Alejandro Gutiérrez
13e8ce07ac chore: bump cli to 0.7.1
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:57 +01:00
Alejandro Gutiérrez
5398ca6833 feat: make MCP server registrations persistent across peer disconnects
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
Persistent MCP servers (opt-in via `persistent: true`) survive host
disconnects — they appear as offline in mcp_list and auto-restore when
the host reconnects. Ephemeral servers (default) still clean up on
disconnect. Offline servers return a clear error on mcp_call with
time-since-disconnect info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:22:06 +01:00
Alejandro Gutiérrez
56b1cc0756 docs: split vision into changelog + clean roadmap
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
changelog-20260407.md: full implementation details for 21 features
vision-20260407.md: slimmed to shipped summary + remaining items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:55 +01:00
Alejandro Gutiérrez
fc8a7edc23 feat: persist peer session state across disconnects ("welcome back" on reconnect)
Save groups, profile, visibility, summary, display name, and cumulative
stats to a new mesh.peer_state table on disconnect. On reconnect (same
meshId + memberId), restore them automatically — hello groups take
precedence over stored groups if provided. Broadcast peer_returned
system event with last-seen time and summary to other peers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:20:20 +01:00
Alejandro Gutiérrez
e09671cdcb feat: broadcast system notifications on MCP server register/unregister
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
Peers now receive [system] notifications when MCP servers join or
leave the mesh, with tool names and hosting peer info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:58 +01:00
Alejandro Gutiérrez
32fc4a0c98 fix: align connector-slack and connector-telegram deps with workspace versions
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Sherif enforces consistent dependency versions across the monorepo.
The connectors used ^8.0.0 for ws and @types/ws while the rest used
exact 8.20.0 / 8.5.13. Also sorted dependencies alphabetically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:16:19 +01:00
Alejandro Gutiérrez
b315b31cc9 docs: add peer session persistence and MCP notification to vision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:15:49 +01:00
Alejandro Gutiérrez
21cb6efced docs: mark all implemented vision items with commit refs
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
17 of 22 items done, 2 partial. Updated all section headers and
added implementation notes with commits and timestamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:37 +01:00
Alejandro Gutiérrez
125b576e2c chore: update pnpm lockfile for connector-slack and connector-telegram deps
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
The lockfile was stale — connector-slack/package.json added 7 deps that
weren't reflected in pnpm-lock.yaml, causing frozen-lockfile builds to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:12:13 +01:00
Alejandro Gutiérrez
3641618391 docs(mcp): add file access decision guide to instructions
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Teaches AI when to use filesystem (local), read_peer_file (remote
<1MB), or share_file (persistent, no size limit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:09:09 +01:00
Alejandro Gutiérrez
a92cf6b629 feat: hint AI to use filesystem for local peer file reads
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
When read_peer_file targets a local peer (same hostname), prepend a
hint with the direct filesystem path. Still executes the relay as
fallback — AI learns the shortcut without being blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:06:27 +01:00
Alejandro Gutiérrez
2c9c8c7b6c feat: add hostname to hello + local/remote peer locality detection
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
Peers report os.hostname() in the hello handshake. list_peers shows
[local] or [remote] tag per peer. MCP instructions teach AI to read
local peers' files directly via filesystem instead of relay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:05:46 +01:00
Alejandro Gutiérrez
98fda20ab6 chore: bump cli to 0.7.0
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:01:09 +01:00
Alejandro Gutiérrez
025a53a70c docs: update vision — 17 of 23 items implemented, add telemetry idea
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:00:37 +01:00
Alejandro Gutiérrez
b55cf269a4 feat: implement inbound webhooks for external service integration
Add the webhook handler module (webhooks.ts) that verifies secrets
against the mesh.webhook table and broadcasts incoming HTTP POST
payloads to all connected mesh peers. This completes the webhook
feature whose schema, types, WS CRUD handlers, and CLI tools were
added in the previous commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:58:01 +01:00
Alejandro Gutiérrez
504111c50c feat: add read_peer_file and list_peer_files MCP tools
Wire up MCP tool handlers for the peer file sharing relay. Peers can
now read files and list directories from other peers' local filesystems
through the mesh broker. Includes name-to-pubkey resolution, base64
decode, and instructions table update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:56:42 +01:00
Alejandro Gutiérrez
05d9b56f28 feat: implement simulation clock with configurable time multiplier
Broker-driven clock that broadcasts periodic heartbeat ticks to all
peers in a mesh. Speed is configurable from x1 (real-time, 60s ticks)
to x100 (600ms ticks) for load testing simulations. Auto-pauses when
the last peer disconnects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:14 +01:00
Alejandro Gutiérrez
c8cb1e3ea5 feat: implement mesh skills catalog — peers publish and discover reusable instructions
Adds share_skill, get_skill, list_skills, and remove_skill across the full
stack (Drizzle schema, broker CRUD + WS handlers, CLI client methods, MCP
tools). Skills are mesh-scoped, unique by name, and searchable via ILIKE
on name/description/tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:55:03 +01:00
Alejandro Gutiérrez
86a258301f feat: implement signed hash-chain audit log for mesh events
Add tamper-evident audit logging where each entry includes a SHA-256
hash of the previous entry, forming a verifiable chain per mesh.
Events tracked: peer_joined, peer_left, state_set, message_sent
(never logs message content). New WS handlers: audit_query for
paginated retrieval, audit_verify for chain integrity verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:54:57 +01:00
Alejandro Gutiérrez
7e102a235b feat: add @claudemesh/sdk standalone client library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:46 +01:00
Alejandro Gutiérrez
5563f90733 feat: add @claudemesh/sdk package for non-Claude-Code clients
Standalone TypeScript SDK that any process can use to join a mesh and
send/receive messages. Implements the same WS protocol and libsodium
crypto_box encryption as the CLI, with an EventEmitter-based API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:53:22 +01:00
Alejandro Gutiérrez
b3b9972e60 feat: add peer stats reporting (messages, tool calls, uptime, errors)
Peers self-report resource usage via set_stats; stats visible in
list_peers responses and the new mesh_stats MCP tool. CLI auto-reports
every 60s and tracks messagesIn/Out, toolCalls, uptime, and errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:26 +01:00
Alejandro Gutiérrez
fe9285351b feat: add Telegram connector package for mesh-to-chat bridging
Introduces @claudemesh/connector-telegram — a standalone bridge process
that joins a mesh as peerType: "connector" and relays messages
bidirectionally between a Telegram chat and mesh peers via long polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:52:00 +01:00
Alejandro Gutiérrez
08e289a5e3 feat: implement mesh MCP proxy — dynamic tool sharing between peers
Peers can register MCP servers with the mesh and other peers can invoke
those tools through the existing claudemesh connection without restarting.

Broker: in-memory MCP registry with mcp_register/unregister/list/call
handlers, call forwarding to hosting peer with 30s timeout, and automatic
cleanup on peer disconnect.

CLI: mcpRegister/mcpUnregister/mcpList/mcpCall client methods, inbound
mcp_call_forward handler, and 4 new MCP tools (mesh_mcp_register,
mesh_mcp_list, mesh_tool_call, mesh_mcp_remove).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:54 +01:00
Alejandro Gutiérrez
7d432b3aaa feat(web): add state timeline and resource panels to live mesh dashboard
Two new panels below the existing peer graph + live stream grid:
- StateTimelinePanel: vertical timeline of audit events and presence
  status changes, auto-scrolling, sorted newest-first
- ResourcePanel: 2x2 card grid showing live peers, envelopes by
  priority, audit event breakdown, and session status

Both share the same TanStack Query cache key as the existing panels
(no extra API calls). Matches the --cm-* dark terminal aesthetic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:50:18 +01:00
Alejandro Gutiérrez
b0dc538119 feat(cli): nudge user to join a mesh when install finds none
After MCP registration and hooks setup, `claudemesh install` now checks
the config for joined meshes. If empty, it prints actionable guidance
(join command + dashboard URL) instead of the generic "Next:" line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:49:37 +01:00
Alejandro Gutiérrez
27c9d2a02c docs: add peer visibility, spatial topology, and public profiles to vision
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:41:06 +01:00
Alejandro Gutiérrez
87e0d0004d docs: mark 5 vision items as implemented with commit refs
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:37:22 +01:00
Alejandro Gutiérrez
dba0fb7b33 chore: bump cli to 0.6.9
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:36:03 +01:00
Alejandro Gutiérrez
72be651ca8 feat(cli): add --cron flag to remind command
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:40 +01:00
Alejandro Gutiérrez
db2bf3ea06 docs(protocol): add missing message types and new features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:34:15 +01:00
Alejandro Gutiérrez
e87380775f feat: add persistent cron-based recurring reminders
Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:47 +01:00
Alejandro Gutiérrez
58ba01f20f fix(cli): sync CLAUDEMESH_TOOLS with current tool definitions and sort alphabetically
Add 4 missing tools (cancel_scheduled, grant_file_access, list_scheduled,
schedule_reminder) and sort the array alphabetically for maintainability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:33:02 +01:00
Alejandro Gutiérrez
59332dc47d feat(web): add peer graph visualization to live mesh dashboard
Renders peers as SVG nodes in a radial layout with animated edges
showing real-time message traffic. Shares the same TanStack Query
cache as LiveStreamPanel (same queryKey). Side-by-side on desktop,
stacked on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:32:41 +01:00
Alejandro Gutiérrez
f34b8fbc6b docs(cli): improve --help text for clarity, concision, and consistency
Rewrite all command and argument descriptions in index.ts to follow
imperative mood, omit filler, use backtick-formatted values, and
surface key behaviors (e.g. launch spawns Claude Code with MCP,
remind supports list/cancel subactions, send accepts @group and *).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:55 +01:00
Alejandro Gutiérrez
79525af42e fix(broker): remove cron example from JSDoc that broke TSC
The "0 */2 * * *" cron example inside a /** comment caused TSC to
parse */ as end-of-comment, producing syntax errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:31 +01:00
Alejandro Gutiérrez
69e93d4b8c feat(cli): add mesh templates and claudemesh create command
Predefined mesh configurations (dev-team, research, ops-incident,
simulation, personal) let users bootstrap meshes with groups, roles,
state keys, and system prompt hints. Templates are bundled at build
time via Bun's JSON import support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:31:12 +01:00
Alejandro Gutiérrez
810f372d1c feat: add peer metadata (peerType, channel, model) and cwd to peer list
Extend the WS hello handshake with optional peerType, channel, and model
fields so peers can advertise what kind of client they are. The broker
stores these in-memory on PeerConn and returns them (along with cwd) in
the peers_list response. CLI peers command and MCP list_peers tool now
display the new metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:30:04 +01:00
Alejandro Gutiérrez
453705a4e1 feat: broadcast system notifications on peer join/leave
When a peer connects or disconnects, the broker now broadcasts a
system push (subtype: "system") to all other peers in the same mesh.
The CLI formats these as [system] channel notifications so AI sessions
can react to topology changes without polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:28:49 +01:00
Alejandro Gutiérrez
5cb4cc4fe7 feat(web): update landing page copy for full feature surface, add getting started + mesh vs MCP
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
Landing page copy was stuck at the v0.1 feature set (messaging + state + memory + groups).
The CLI now ships 43 MCP tools across 5 persistence backends. This commit brings the site
copy in sync with what's actually built.

Changes:
- Hero, features, pricing, FAQ, CTA, footer: reflect 43 tools, files, SQL, vectors, graphs
- Features section: expanded from 4 tabs to 7 (added Files, Database, Vectors)
- New /getting-started page: full install guide with correct 4-step flow
- New Mesh vs MCP section: side-by-side diagrams + 8-row comparison table
- Fix: install-toggle on /join page had `npx claudemesh@latest init` (init doesn't exist)
  → replaced with `curl -fsSL https://claudemesh.com/install | bash`
- Navigation: added Getting Started to header, footer, hero link
- COPY.md synced with all 6 capability areas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:58:22 +01:00
Alejandro Gutiérrez
eeac47c360 chore: bump cli to 0.6.8
Some checks failed
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:39:21 +01:00
Alejandro Gutiérrez
0bb9d71a26 feat: merge schedule_reminder + send_later, add subtype reminder
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
- Merge send_later into schedule_reminder (optional `to` param — omit for self-reminder)
- Add subtype?: "reminder" to WSPushMessage, WSScheduleMessage, ScheduledEntry, InboundPush
- Broker handleSend now accepts optional subtype and injects into push envelope
- deliver closure passes sm.subtype so reminders surface correctly
- MCP channel meta includes subtype field; formatPush tags [REMINDER] in check_messages
- MCP server instructions document subtype and schedule_reminder/list_scheduled/cancel_scheduled
- client.scheduleMessage accepts isReminder flag, sends subtype: "reminder" on wire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:38:41 +01:00
Alejandro Gutiérrez
3ff7a61e3f chore: update pnpm lockfile after citty + scheduled message deps
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:58:22 +01:00
Alejandro Gutiérrez
e76ade64d2 feat: scheduled messages — schedule_reminder, send_later, list_scheduled, cancel_scheduled
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
- Broker: schedule/list_scheduled/cancel_scheduled WS message types + in-memory delivery
- Client: scheduleMessage(), listScheduled(), cancelScheduled() with resolver Map pattern
- MCP: schedule_reminder, send_later, list_scheduled, cancel_scheduled tools
- CLI: claudemesh remind <msg> --in 2h | --at 15:00 | list | cancel <id>
- Types: WSScheduleMessage, WSScheduledAckMessage, WSScheduledListMessage, WSCancelScheduledAckMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:53:42 +01:00
Alejandro Gutiérrez
59848f0d3e chore(cli): v0.6.6 — correlation ID refactor (resolver Maps + _reqId)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-04-07 14:31:29 +01:00
Alejandro Gutiérrez
d0fa1c028f fix(broker): echo _reqId in all WS responses for correlation ID routing
Extract _reqId from incoming WS messages and include it in every direct
response sendToPeer call and sendError call. Clients can now match
responses to requests by ID instead of relying on FIFO ordering.
Old clients without _reqId are unaffected (field simply omitted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:28:30 +01:00
Alejandro Gutiérrez
8f925d9a9e fix(cli): correlation ID refactor — resolver Maps with _reqId + FIFO fallback
Replace all 22 resolver Array<fn> patterns with Map<reqId, {resolve, timer}>.
Outgoing messages now include _reqId; on response the broker's echoed _reqId
is used for exact matching, with FIFO fallback for brokers that don't echo it.
Add makeReqId() helper and resolveFromMap() utility. Error propagation block
updated to iterate Maps and pop the oldest entry across all queues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:25:51 +01:00
Alejandro Gutiérrez
4ce1034dcd chore(cli): v0.6.5 — all bug fixes batch
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
2026-04-07 13:12:29 +01:00
Alejandro Gutiérrez
e26a36e543 fix(broker): vector_stored type, set_state no-resp, subscribe ack
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
- vector_store sends {type:"vector_stored",id}; wrapped in try/catch
- set_state no longer sends state_result (fire-and-forget)
- subscribe sends {type:"subscribed",stream} confirmation
- remove broken myPresence lookup in mesh_info
- add WSVectorStoredMessage + WSSubscribedMessage to types union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:08:06 +01:00
Alejandro Gutiérrez
60c74d9463 fix(broker): shareContext stable upsert key + createStream atomic upsert
- shareContext: adds optional memberId param; when provided, upserts on
  (meshId, memberId) instead of (meshId, presenceId) — prevents stale
  context rows accumulating on every reconnect. Falls back to presenceId
  for legacy/anonymous connections. Also refreshes presenceId on update
  so it stays current.
- schema: adds member_id column + unique index context_mesh_member_idx
  on mesh.context table; new migration 0013_context-stable-member-key.sql.
- index.ts call site updated to pass conn.memberId as the stable key.
- createStream: replaces SELECT-then-INSERT TOCTOU race with atomic
  INSERT ... ON CONFLICT DO NOTHING RETURNING, followed by SELECT on miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:58 +01:00
Alejandro Gutiérrez
6fba9bd4eb feat(cli): fix field mismatches + error propagation
- claim_task/complete_task: send taskId not id
- graph_result: read msg.records not msg.rows
- message_status: try all mesh clients, not only first
- broker: omit state_result for set_state (fixes get_state cross-contamination)
- error handler: unblock first pending resolver on unmatched broker errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:07:25 +01:00
Alejandro Gutiérrez
5bcc1fe323 chore(cli): bump to v0.6.4 — fix get_file sealedKey bug
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
2026-04-07 12:57:20 +01:00
Alejandro Gutiérrez
e70f0ed1ff fix(broker/cli): e2e get_file owner sealedKey bug
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
broker: owner also fetches sealedKey from mesh.file_key (not skipped),
  only non-owners are blocked when key is missing
cli: explicit error when encrypted file has no sealedKey (no silent raw download)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:56:36 +01:00
Alejandro Gutiérrez
5f696f47ea feat(cli): v0.6.3 — e2e file crypto module + encrypted share_file
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
- add crypto/file-crypto.ts: encryptFile, decryptFile, sealKeyForPeer, openSealedKey
- update share_file: when to= set, encrypts file + seals key per recipient
- update get_file: decrypts if encrypted + sealedKey present
- add grant_file_access tool: re-seals Kf for a new peer without re-upload
- add getSessionPubkey/getSessionSecretKey getters on BrokerClient
- add grantFileAccess WS method on BrokerClient
- bump version to 0.6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:53:13 +01:00
Alejandro Gutiérrez
ccb9fb2a68 feat(broker/db): e2e file encryption schema + db functions
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
- add mesh.file_key table (fileId, peerPubkey, sealedKey, grantedByPubkey)
- add encrypted + ownerPubkey columns to mesh.file
- export insertFileKeys, getFileKey, grantFileKey from broker.ts
- update uploadFile/getFile/listFiles to include encrypted/ownerPubkey
- migration 0012_add-file-encryption applied to prod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:43:57 +01:00
Alejandro Gutiérrez
898c061089 feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:33:39 +01:00
Alejandro Gutiérrez
f7a6559429 feat(broker): add E2E file encryption to HTTP upload and WS handlers
- parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost
- pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after
- get_file: fetch sealedKey for non-owners, block if missing, include in response
- list_files: include encrypted field per file
- add grant_file_access WS handler so owners can seal keys for peers
- update types.ts with new message interfaces and union members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:32:46 +01:00
Alejandro Gutiérrez
579d0c3d3e chore: bump version to 0.6.0
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:21:03 +01:00
Alejandro Gutiérrez
190f5a958e refactor(cli): migrate to citty — --help generated from flag definitions
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
Replace manual switch + HELP string with citty defineCommand/runMain.
Flag definitions in index.ts are now the single source of truth for
--help output. Remove parseArgs() from launch.ts; accept citty-parsed
flags + rawArgs (-- passthrough to claude preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:19:16 +01:00
Alejandro Gutiérrez
03661e1b68 docs(cli): expand --help with all launch flags, groups hierarchy, env vars
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:04 +01:00
Alejandro Gutiérrez
d451fc296e feat: hierarchical group routing + role wiring
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
broker: expand member groups to ancestor paths at drain time (pull model)
- @flexicar message reaches peers in @flexicar/core, @flexicar/output, etc.
- Resolved at drainForMember — no DB changes, fully backward-compatible
- Any depth: flexicar/team/backend also matches @flexicar and @flexicar/team

cli: wire --role all the way through to session config + env
- Config.role field added
- launch.ts stores role in sessionConfig, passes CLAUDEMESH_ROLE env var
- mcp/server.ts includes role in identity string
- manager.ts auto-joins groups from config on WS connect (--groups flag now works)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:09:37 +01:00
Alejandro Gutiérrez
3da5d71275 fix(broker): fix share_file DB insert failures
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
- Normalise tags to Array before Drizzle insert (PgArray mapper calls
  .map() and throws if value is not a standard JS Array)
- Use uploadedByName instead of uploadedByMember FK — the X-Member-Id
  header carries the mesh slug, not a mesh.member primary key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:56:43 +01:00
Alejandro Gutiérrez
cdf335f609 fix(broker): fix MINIO_USE_SSL env coercion
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
z.coerce.boolean() treats any non-empty string as true, so MINIO_USE_SSL="false" → true.
Switch to explicit enum+transform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:38:06 +01:00
Alejandro Gutiérrez
0cd16ff358 fix: exclude sender only for broadcasts, not direct messages
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
The sender exclusion filter (excludeSenderSessionPubkey) was blocking
delivery of ALL messages from the sender, including direct messages
to other peers. Now only excludes on broadcast (target_spec = '*').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:34:09 +01:00
Alejandro Gutiérrez
3e9707276d fix: add diagnostic logging to maybePushQueuedMessages
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:21:29 +01:00
Alejandro Gutiérrez
82cfee315c fix: v0.5.9 — mesh_info returns correct display name
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:10:30 +01:00
Alejandro Gutiérrez
ab08be04a5 feat(cli): v0.5.8 — welcome notification on connect
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:07:08 +01:00
Alejandro Gutiérrez
ee585a8370 fix(cli): v0.5.7 — event loop keepalive for stdout flush
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
Release / Publish multi-arch images (push) Has been cancelled
Node.js stdout to a pipe is buffered. Without periodic event loop
activity, WS callback → server.notification() → stdout.write() may
not flush until the next I/O event. A 1s setInterval (NOT unref'd)
keeps the event loop ticking so notifications flush immediately.

This is why claude-intercom worked: its 1s HTTP poll kept the event
loop active as a side effect. Claudemesh's passive WS listener let
the event loop settle, causing stdout to buffer indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:48:41 +01:00
Alejandro Gutiérrez
1f078bf0c8 fix(web): --no-turbopack for prod build (payload CSS)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:24 +01:00
Alejandro Gutiérrez
2372032a68 fix(cli): v0.5.6 — fix ping_mesh self-send + add diagnostics
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:32:03 +01:00
Alejandro Gutiérrez
a70c5fd124 feat(cli): v0.5.5 — ping_mesh diagnostic tool
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
Release / Publish multi-arch images (push) Has been cancelled
Sends test messages to self through the full pipeline per priority
and measures round-trip timing. Reports send→ack and send→receive
latency. Detects broker priority gating (status=working holds next/low).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:27:00 +01:00
Alejandro Gutiérrez
5c62d287cf fix(cli): v0.5.4 — revert to event-driven push, add Claude Code integration spec
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
Release / Publish multi-arch images (push) Has been cancelled
Revert poll-based drain (v0.5.2 overcorrection). Claude Code source
confirms notifications are processed event-driven via React
useEffect, not polled. The WS onPush → server.notification() path
is correct.

Added section 13 to SPEC.md documenting the full Claude Code
notification pipeline, feature gates, priority gating, and common
push delivery issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:04:05 +01:00
Alejandro Gutiérrez
9ae378c2e3 fix(cli): v0.5.3 — add push delivery debug logging
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:49:49 +01:00
Alejandro Gutiérrez
7381738f0b fix(web): disable turbopack for prod build (payload CSS compat)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:28 +01:00
Alejandro Gutiérrez
8c6b0c0e07 fix(cli): v0.5.2 — poll-based push delivery (1s interval)
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
Release / Publish multi-arch images (push) Has been cancelled
Replace WS onPush→notification with timer-based buffer drain.
The old claude-intercom used 1s polling and worked reliably.
WS async callbacks may not flush stdio properly for MCP
notifications. Polling on a timer ensures consistent delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:33:26 +01:00
Alejandro Gutiérrez
ec9626503c fix(web): force-dynamic on payload admin page (build CSS error)
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:16:21 +01:00
Alejandro Gutiérrez
820ec085b2 feat(cli): v0.5.1 — message modes (push/inbox/off)
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
Release / Publish multi-arch images (push) Has been cancelled
--inbox: count-only notifications, no content in context
--no-messages: tools only, zero prompt injection risk
Default: push (real-time, current behavior)

Wizard shows mode picker when no flag provided.
MCP instructions tell Claude its current mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:53:41 +01:00
Alejandro Gutiérrez
9e6f6d7bc9 docs: add message modes + shared MCPs spec
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
Message modes: push/inbox/off for controlling prompt injection risk.
Shared MCPs: mesh-level MCP servers proxied through the broker —
install once, every peer has access. Full architecture, DB schema,
WS protocol, credential isolation, resource limits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:52:43 +01:00
Alejandro Gutiérrez
7194e7d28e chore: regenerate lockfile from scratch
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:47:26 +01:00
Alejandro Gutiérrez
0b4e389f2b feat(web): restore payload CMS (cuidecar pattern + importMap)
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
Release / Publish multi-arch images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:30:16 +01:00
Alejandro Gutiérrez
7a5f786e0c chore: sync lockfile
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
2026-04-06 14:25:19 +01:00
Alejandro Gutiérrez
10e5fdcfd1 feat(web): rewrite landing for v0.3 product (groups, state, memory)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Hero: sessions form a team with groups, state, memory — not just
messaging. Features: 4 tabs with real CLI code (groups, state,
memory, coordination patterns). Use cases: team sprint with 5
agents, new-hire knowledge transfer via recall(), deploy-frozen
via shared state. All match the shipped spec (v0.3.0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:03:10 +01:00
Alejandro Gutiérrez
cc6e56aef9 docs: final spec — vectors, graph, context, tasks, streams, databases
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
Full vision: claudemesh provisions shared infrastructure per mesh.
Peers share messages, state, memory, files, vector embeddings,
entity graphs, session context, tasks, structured databases, and
real-time streams. All through MCP tools, zero configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:00:50 +01:00
Alejandro Gutiérrez
1aaa483d60 feat: v0.4.0 — File sharing + multi-target messages
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
Release / Publish multi-arch images (push) Has been cancelled
Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.

Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.

Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:56:01 +01:00
Alejandro Gutiérrez
99d9d19079 docs: update spec with files, multi-target, views, infra vision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:48:32 +01:00
Alejandro Gutiérrez
888078876a feat: v0.3.0 — State, Memory, message_status, MCP instructions
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
Release / Publish multi-arch images (push) Has been cancelled
Phase B + C + message delivery status.

State: shared key-value store per mesh. set_state pushes changes
to all peers. get_state/list_state for reads. Peers coordinate
through shared facts instead of messages.

Memory: persistent knowledge with full-text search (tsvector).
remember/recall/forget. New peers recall context from past sessions.

message_status: check delivery status with per-recipient detail
(delivered/held/disconnected).

Multicast fix: broadcast and @group messages now push directly to
all connected peers instead of racing through queue drain.

MCP instructions: dynamic identity injection (name, groups, role),
comprehensive tool reference, group coordination guide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:29:45 +01:00
Alejandro Gutiérrez
02b1e5695f feat: v0.2.0 — Groups (@group routing, roles, wizard)
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
Release / Publish multi-arch images (push) Has been cancelled
Phase A of the claudemesh spec. Peers can now join named groups
with roles, and messages route to @group targets.

Broker:
- @group routing in fan-out (matches peer group membership)
- @all alias for broadcast
- join_group/leave_group WS messages + DB persistence
- list_peers returns group metadata
- drainForMember matches @group targetSpecs in SQL

CLI:
- join_group/leave_group MCP tools
- send_message supports @group targets
- list_peers shows group membership
- PeerInfo includes groups array
- Peer name cache for push notifications

Launch:
- --role flag (optional peer role)
- --groups flag (comma-separated, e.g. "frontend:lead,reviewers")
- Interactive wizard for role + groups when flags omitted
- Groups written to session config for broker hello

Spec: SPEC.md added with full v0.2 vision (groups, state, memory)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:06:16 +01:00
Alejandro Gutiérrez
663f800b4b fix: v0.1.16 — fix message delivery between same-member sessions
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
Release / Publish multi-arch images (push) Has been cancelled
excludeSenderMemberId blocked delivery to ALL peers sharing the
same member_id (all sessions from one join). Replaced with
excludeSenderSessionPubkey which only excludes the sender's own
session — peers with different session pubkeys receive correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:44:29 +01:00
Alejandro Gutiérrez
2557235c68 fix: v0.1.15 — production hardening (7 fixes)
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
Release / Publish multi-arch images (push) Has been cancelled
Broker:
- Sweep stale presences (3 missed pings = disconnect, 30s interval)
- Exclude sender from broadcast fan-out + queue drain

CLI:
- Decrypt fallback: try base64 plaintext if crypto_box fails
- Stable session keypair across WS reconnects
- Peer name cache (30s TTL) instead of list_peers per push
- Clean up orphaned tmpdirs from crashed sessions (>1 hour old)
- Read displayName from config file (not just env var)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:22:04 +01:00
459 changed files with 91041 additions and 2667 deletions

View File

@@ -0,0 +1,232 @@
# Anthropic Vision: Meshes & Invitations
**Status:** in progress · partial implementation 2026-04-10
**Owner:** agutierrez
**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future)
---
## Guiding principles
1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs.
2. **Secrets never appear in URLs.** Links are capabilities, not credentials.
3. **Defaults are obvious; advanced options are discoverable but hidden.**
4. **Self-service wherever possible; admins don't become gatekeepers.**
5. **Every visible action is also an auditable event.**
These mirror how Anthropic builds its own org/workspace/project model.
---
## Part 1 — Meshes
### Problem
Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state.
### Decision
**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging.
### What this enables
- Two users can both name their mesh "platform-team" with zero friction
- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh
- No "slug taken" error state exists in the product anymore
### Tradeoff explicitly accepted
Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom.
### Implementation — DONE in this spec
- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`)
- [x] Remove `slug` field from `createMyMeshInputSchema`
- [x] Remove slug field from `CreateMeshForm`
- [x] Server-side `toSlug(name)` derives slug from name automatically
- [x] Schema comment documents the non-canonical role of `slug`
### Future (optional, not in v0.1.x)
- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes.
---
## Part 2 — Invitations
### Problems with the current invite system
| # | Problem | Severity |
|---|---|---|
| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** |
| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX |
| 3 | No invite-by-email; only shareable link | 🟡 UX |
| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX |
| 5 | Landing page does not clearly preview role/consent | 🟡 UX |
| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish |
| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish |
### Severity 🔴 — the root key leak
Current canonical invite bytes:
```
v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey
```
`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL:
- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches
- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist
- A screenshot of the invite link is a compromise
- Revoking the invite does **not** rotate the key, so exposure is permanent
**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself.
### The v2 invite protocol (spec only in this doc — NOT implemented this session)
**Design goals**
1. No secret material in any user-visible string (URL, QR, paste buffer)
2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345`
3. Existing v1 invites continue to work during a deprecation window
4. Revocation is clean and immediate
5. One recipient = one root-key-delivery capability
**Flow**
```
Admin creates invite (v2):
server generates short_code (base62, 8 chars, unique)
server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability}
signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key)
canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey
NOTE: no root_key, no broker_url
returns: claudemesh.com/i/{code}
Recipient clicks the link:
web: GET /api/public/invites/code/{code}
returns {mesh_name, inviter_name, role, expires_at, member_count}
no secrets, no signature leaked
web: shows consent landing: "You are joining ACME as a Member"
recipient authenticates (sign up / log in) OR runs CLI
Recipient claims the invite:
CLI: generates session ed25519 keypair (ephemeral)
CLI: connects to broker ws://ic.claudemesh.com/ws
CLI: sends { type: "claim_invite", code, recipient_pubkey }
broker: looks up invite by code
broker: verifies signed_capability against mesh.owner_pubkey
broker: checks expires_at, max_uses vs used_count, revoked_at
broker: increments used_count, creates mesh.member row
broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey
broker: returns { sealed_root_key, mesh_id, member_id }
CLI: unseals with its secret key → has root_key
CLI: starts normal mesh traffic
Revocation:
admin sets invite.revoked_at = now()
any future claim fails at broker with invite_revoked
root_key is NOT rotated — past members keep access
(for "kick a member" semantics, use a separate member revocation, which DOES rotate the key)
```
**Properties**
- URL contains only `{code}` (8 chars base62)
- `signed_capability` lives server-side; leaks of the URL never expose the root key
- Screenshot of invite URL is harmless
- Link preview bots see nothing sensitive
- Broker DB is the source of truth for revocation
**Migration strategy (v1 → v2)**
- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows)
- `createMyInvite` generates BOTH v1 token (legacy) and v2 code
- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure
- Broker accepts both formats until v0.2.0
- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone
**Status update 2026-04-10 — v2 is now being implemented in parallel**
The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push:
- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting.
- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites.
- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`.
- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL.
v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0.
Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress).
---
### Severity 🟡 — implemented this session
#### Short invite codes (URL shortening, backward-compatible)
Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL.
**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`.
**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`.
**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target.
**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes.
**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two.
---
#### Collapsed advanced fields
The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`.
Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure.
---
### Severity 🟡 — deferred
#### Invite by email
- Requires an `invitation_email` table or equivalent pending-invites state
- Requires wire-up to email delivery (already have Postmark via turbostarter)
- Out of scope this session; fits naturally on top of v2 invite protocol
#### Consent landing redesign
- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button
- Needs a design pass
- Deferred
---
### Severity 🟢 — deferred
- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup
- Received-but-not-clicked audit — falls out of email invites for free
---
## Summary table
| Change | Status | File(s) |
|---|---|---|
| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` |
| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` |
| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` |
| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` |
| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` |
| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client |
| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery |
| Consent landing redesign | 📝 spec only | (future PR) |
| Remove `ic://` scheme | 📝 spec only | (cleanup PR) |
---
## Non-goals (for clarity)
- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough
- Not adding vanity slugs at v0.1.x — can come as a Pro tier later
- Not changing the broker wire protocol this session
- Not rewriting the CLI join flow this session
---
## Post-implementation checklist
- [x] Web builds without type errors on changed files
- [x] Migrations run on production DB (`0017` applied; `0018` after review)
- [x] No broker protocol change (backward compat verified)
- [x] Existing long-token invites continue to resolve
- [x] New invites expose `shortUrl` in the API response

View File

@@ -0,0 +1,58 @@
# CLI Distribution Pipeline
## Status
- Shell installer (`/install`): ✅ live, needs polish
- Single-binary build script (`scripts/build-binaries.ts`): ✅ written, not wired to CI
- GitHub Releases publish: ❌ not set up
- Homebrew tap: ❌ not set up
- winget manifest: ❌ not set up
## Shipped this session (alpha.28)
- `bun build --compile` script at `apps/cli-v2/scripts/build-binaries.ts` produces
`dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}` locally.
- `/install` updated to use the one-command `claudemesh <invite-url>` flow.
- `claudemesh url-handler install` registers the `claudemesh://` scheme on the three OSes.
## What's missing
### 1. GitHub Actions to build + publish binaries
```yaml
# .github/workflows/release-binaries.yml
on: { push: { tags: ['v*'] } }
jobs:
build:
strategy: { matrix: { target: [darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64] } }
steps:
- uses: oven-sh/setup-bun@v2
- run: cd apps/cli-v2 && bun install --frozen-lockfile
- run: cd apps/cli-v2 && bun run scripts/build-binaries.ts
- uses: softprops/action-gh-release@v2
with: { files: apps/cli-v2/dist/bin/* }
```
### 2. `/install` detects missing Node and downloads a binary
Current `/install` requires Node 20+. Next iteration: detect absence, curl the
right binary from GitHub Releases, drop it in `~/.claudemesh/bin/`, add to PATH.
### 3. Homebrew tap (`homebrew-claudemesh`)
Separate repo with a formula that points at the GitHub Release artifact.
Users: `brew install alezmad/claudemesh/claudemesh`. Auto-updated by the
release workflow via `brew bump-formula-pr`.
### 4. winget manifest
YAML in `microsoft/winget-pkgs` repo pointing at the Windows .exe.
### 5. Auto-update in-CLI
Already have `showUpdateNotice`. Upgrade to offer `claudemesh upgrade` that
re-runs `/install` OR downloads a new binary in place.
## Why this matters
Current state: users need Node, npm, and patience. Goal state:
```
curl -fsSL claudemesh.com/install | sh
```
…and that's it, on any OS, with or without Node.
## Priority
After tier-1 usability (done), this is the next biggest lever for adoption.
Estimate: 1-2 days for full pipeline, mostly CI config + release testing.

View File

@@ -0,0 +1,75 @@
# Per-Peer Capabilities
## Goal
Give mesh members fine-grained control over what peers can do to their
session. Today: any mesh peer can send you any message; all messages get
pushed as `<channel>` reminders. Users can't say "only @alice can send me
messages," "read-only peers," or "@bob can broadcast but not DM."
## Current state
- Mesh-level role: `admin` | `member` (only affects invite issuance)
- No per-peer filter — every peer message is delivered
- No per-peer read/write split (all peers have the same capabilities)
## Target capability model
| Capability | Meaning |
|--------------|--------------------------------------------------------|
| `read` | Peer appears in your list_peers, can see your summary |
| `dm` | Peer can send you direct messages |
| `broadcast` | Peer's group broadcasts reach you |
| `state-read` | Peer can read shared state keys |
| `state-write`| Peer can set shared state keys |
| `file-read` | Peer can read files you've shared (already exists) |
## CLI surface
```
claudemesh grant @alice dm broadcast # allow direct + broadcast
claudemesh grant @bob state-read # read-only
claudemesh revoke @alice broadcast
claudemesh grants # list current grants per peer
claudemesh block @spammer # shorthand for revoke-all
```
## Broker schema
New column on `mesh_member`:
```sql
peer_grants jsonb DEFAULT '{}'::jsonb
-- shape: { "<peer_pubkey_hex>": ["dm", "broadcast", ...] }
```
Alternative (cleaner): separate `peer_grant` table keyed on
`(member_id, target_pubkey)`.
## Enforcement point
Broker's message router (`apps/broker/src/index.ts` — send flow).
Before writing the encrypted message to the recipient's queue, check
`recipient.peer_grants[sender_pubkey]` against message kind. Drop
silently if disallowed (sender sees delivered, recipient sees nothing —
matches Signal/iMessage block semantics).
## Defaults
- Unknown peers: `read + dm` (matches current behavior — additive-safe rollout)
- Existing members: grandfathered into `read + dm + broadcast + state-read`
via a migration
- `claudemesh profile --default-grants read dm` lets users change their own default
## UI
- `claudemesh peers` renders a `[grants: dm,broadcast]` tag per peer
- `claudemesh verify` gains a `--with-grants` flag that shows the grant set
alongside the safety number (helps the "did I accidentally block them?" check)
## Crypto implications
Grants are server-enforced metadata. Not capability tokens. A malicious
broker could forward messages regardless — this is about UX trust (spam /
noise control), not protocol security. The spec is clear about this.
## Migration plan
1. Ship broker schema change (jsonb column, nullable, default `{}`).
2. Ship `grant/revoke/grants/block` CLI commands against an unused column.
3. Enable enforcement in broker behind a per-mesh feature flag.
4. Flip on for all meshes.
## Priority
Nice-to-have. The killer feature here is `block` — every mesh gets a bad
actor eventually. Ship `block` first even if the full grant system is deferred.

View File

@@ -16,3 +16,6 @@ URL="http://localhost:3000"
# Default locale of the apps, can be overridden separately in each app. # Default locale of the apps, can be overridden separately in each app.
DEFAULT_LOCALE="en" DEFAULT_LOCALE="en"
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
CLI_SYNC_SECRET="<your-cli-sync-secret>"

113
.github/workflows/release-cli.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: Release CLI binaries
# Fires on any push of a tag shaped like `cli-v1.2.3` (prerelease `-alpha.N` OK).
# Builds self-contained `bun build --compile` binaries for darwin/linux/win
# (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) —
# this workflow only handles binary distribution.
on:
push:
tags:
- "cli-v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to build (e.g. cli-v1.0.0-alpha.28)"
required: true
permissions:
contents: write # to upload release assets
jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { target: darwin-x64, bun_target: bun-darwin-x64, runner: macos-latest, ext: "" }
- { target: darwin-arm64, bun_target: bun-darwin-arm64, runner: macos-latest, ext: "" }
- { target: linux-x64, bun_target: bun-linux-x64, runner: ubuntu-latest, ext: "" }
- { target: linux-arm64, bun_target: bun-linux-arm64, runner: ubuntu-latest, ext: "" }
- { target: windows-x64, bun_target: bun-windows-x64, runner: windows-latest, ext: ".exe" }
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2"
- uses: pnpm/action-setup@v4
- name: Install workspace deps
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Compile binary
working-directory: apps/cli-v2
shell: bash
run: |
mkdir -p dist/bin
bun build --compile --minify \
--target=${{ matrix.bun_target }} \
src/entrypoints/cli.ts \
--outfile dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
# Smoke test only on native arch. macos-latest runners are ARM64 (Apple
# Silicon); ubuntu-latest is x64. Cross-compiled binaries can't execute
# 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
run: |
./dist/bin/claudemesh-${{ matrix.target }} --version
./dist/bin/claudemesh-${{ matrix.target }} --help | head -5
- name: Upload artefact
uses: actions/upload-artifact@v4
with:
name: claudemesh-${{ matrix.target }}
path: apps/cli-v2/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage binaries
run: |
mkdir -p release
find artifacts -type f -exec cp {} release/ \;
cd release && sha256sum claudemesh-* > SHA256SUMS
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
release/claudemesh-*
release/SHA256SUMS
generate_release_notes: true
fail_on_unmatched_files: true
update-homebrew:
needs: release
runs-on: macos-latest
if: github.event_name == 'push' && !contains(github.ref_name, 'alpha')
steps:
- name: Bump Homebrew tap formula
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
brew tap alezmad/claudemesh || true
brew bump-formula-pr --no-browse --no-fork \
--tag "${{ github.ref_name }}" \
--revision "${{ github.sha }}" \
alezmad/claudemesh/claudemesh || echo "formula bump skipped (no tap yet)"

4
.gitignore vendored
View File

@@ -45,6 +45,9 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
# secrets
.cli_sync_secret
# vercel # vercel
.vercel .vercel
@@ -72,3 +75,4 @@ dist/
apps/web/payload.db apps/web/payload.db
apps/web/public/media/* apps/web/public/media/*
!apps/web/public/media/.gitkeep !apps/web/public/media/.gitkeep
.env.local

30
CLAUDE.md Normal file
View File

@@ -0,0 +1,30 @@
# claudemesh
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
## Structure
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
- `apps/cli/``claudemesh-cli` npm package (CLI + MCP server)
- `apps/web/` — Marketing site + dashboard at claudemesh.com
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
## Key docs
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
- `docs/protocol.md` — Wire protocol reference
- `docs/roadmap.md` — Public roadmap (shipped + planned)
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
## Deploy
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks`
- **Web:** Vercel auto-deploy on push to GitHub
## Dev
- Monorepo: pnpm workspaces + Turborepo
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
- CLI link for local testing: `cd apps/cli && npm link`

1090
SPEC.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
EXPOSE 7900 EXPOSE 7900
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))" CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
# Non-root user (oven/bun image ships with 'bun' uid 1000) # Non-root user (oven/bun image ships with 'bun' uid 1000)

View File

@@ -15,10 +15,19 @@
}, },
"prettier": "@turbostarter/prettier-config", "prettier": "@turbostarter/prettier-config",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@qdrant/js-client-rest": "1.17.0",
"@react-email/components": "0.3.2",
"@react-email/render": "1.3.2",
"@turbostarter/db": "workspace:*", "@turbostarter/db": "workspace:*",
"@turbostarter/shared": "workspace:*", "@turbostarter/shared": "workspace:*",
"drizzle-orm": "0.44.7", "drizzle-orm": "0.44.7",
"grammy": "^1.35.0",
"libsodium-wrappers": "0.7.15", "libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"ws": "8.20.0", "ws": "8.20.0",
"zod": "catalog:" "zod": "catalog:"
}, },
@@ -28,6 +37,8 @@
"@turbostarter/tsconfig": "workspace:*", "@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*", "@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14", "@types/libsodium-wrappers": "0.7.14",
"@types/react": "19.2.0",
"@types/react-dom": "19.2.0",
"@types/ws": "8.5.13", "@types/ws": "8.5.13",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",

215
apps/broker/src/audit.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* Signed audit log with hash-chain integrity.
*
* Every significant mesh event is recorded as an append-only entry.
* Each entry's SHA-256 hash includes the previous entry's hash,
* forming a tamper-evident chain per mesh. If any row is modified
* or deleted, all subsequent hashes will fail verification.
*
* NEVER logs message content (ciphertext or plaintext) — only metadata.
*/
import { createHash } from "node:crypto";
import { asc, desc, eq, sql, and } from "drizzle-orm";
import { db } from "./db";
import { auditLog } from "@turbostarter/db/schema/mesh";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
// ---------------------------------------------------------------------------
const lastHash = new Map<string, string>();
// ---------------------------------------------------------------------------
// Core audit logging
// ---------------------------------------------------------------------------
function computeHash(
prevHash: string,
meshId: string,
eventType: string,
actorMemberId: string | null,
payload: Record<string, unknown>,
createdAt: Date,
): string {
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
return createHash("sha256").update(input).digest("hex");
}
/**
* Append an audit entry for a mesh event.
*
* Fire-and-forget safe — callers should `void audit(...)` or
* `.catch(log.warn)` to avoid blocking the hot path.
*/
export async function audit(
meshId: string,
eventType: string,
actorMemberId: string | null,
actorDisplayName: string | null,
payload: Record<string, unknown>,
): Promise<void> {
const prevHash = lastHash.get(meshId) ?? "genesis";
const createdAt = new Date();
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
try {
await db.insert(auditLog).values({
meshId,
eventType,
actorMemberId,
actorDisplayName,
payload,
prevHash,
hash,
createdAt,
});
lastHash.set(meshId, hash);
} catch (e) {
log.warn("audit log insert failed", {
mesh_id: meshId,
event_type: eventType,
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Startup: load last hash per mesh from DB
// ---------------------------------------------------------------------------
export async function loadLastHashes(): Promise<void> {
try {
// For each mesh, find the most recent audit entry by id (serial).
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
SELECT DISTINCT ON (mesh_id) mesh_id, hash
FROM mesh.audit_log
ORDER BY mesh_id, id DESC
`);
for (const row of rows) {
lastHash.set(row.mesh_id, row.hash);
}
log.info("audit: loaded last hashes", { meshes: lastHash.size });
} catch (e) {
// Table may not exist yet on first boot — that's fine.
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
error: e instanceof Error ? e.message : String(e),
});
}
}
// ---------------------------------------------------------------------------
// Chain verification
// ---------------------------------------------------------------------------
export async function verifyChain(
meshId: string,
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
const rows = await db
.select()
.from(auditLog)
.where(eq(auditLog.meshId, meshId))
.orderBy(asc(auditLog.id));
if (rows.length === 0) {
return { valid: true, entries: 0 };
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
// Verify prevHash linkage
if (row.prevHash !== expectedPrevHash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
// Recompute hash and verify
const recomputed = computeHash(
row.prevHash,
row.meshId,
row.eventType,
row.actorMemberId,
row.payload as Record<string, unknown>,
row.createdAt,
);
if (recomputed !== row.hash) {
return { valid: false, entries: rows.length, brokenAt: row.id };
}
}
return { valid: true, entries: rows.length };
}
// ---------------------------------------------------------------------------
// Query: paginated audit entries
// ---------------------------------------------------------------------------
export async function queryAuditLog(
meshId: string,
options?: { limit?: number; offset?: number; eventType?: string },
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
const conditions = [eq(auditLog.meshId, meshId)];
if (options?.eventType) {
conditions.push(eq(auditLog.eventType, options.eventType));
}
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
const [rows, countResult] = await Promise.all([
db
.select()
.from(auditLog)
.where(where)
.orderBy(desc(auditLog.id))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLog)
.where(where),
]);
return {
entries: rows.map((r) => ({
id: r.id,
eventType: r.eventType,
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
payload: r.payload as Record<string, unknown>,
hash: r.hash,
createdAt: r.createdAt.toISOString(),
})),
total: Number(countResult[0]?.count ?? 0),
};
}
// ---------------------------------------------------------------------------
// Ensure table exists (raw DDL for first-boot before migrations run)
// ---------------------------------------------------------------------------
export async function ensureAuditLogTable(): Promise<void> {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS mesh.audit_log (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
event_type TEXT NOT NULL,
actor_member_id TEXT,
actor_display_name TEXT,
payload JSONB NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
} catch (e) {
log.warn("audit: ensureAuditLogTable failed", {
error: e instanceof Error ? e.message : String(e),
});
}
}

View File

@@ -0,0 +1,68 @@
/**
* Broker-side symmetric encryption for persisting resolved env vars.
*
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
* a random key is generated and logged on first use — operator should
* persist it to survive broker restarts.
*
* This is NOT the same as peer-side E2E crypto (libsodium). This is
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { env } from "./env";
import { log } from "./logger";
const ALGO = "aes-256-gcm";
const IV_LEN = 12;
const TAG_LEN = 16;
let _key: Buffer | null = null;
function getKey(): Buffer {
if (_key) return _key;
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
} else {
_key = randomBytes(32);
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
}
return _key;
}
/**
* Encrypt a JSON-serializable value. Returns a base64 string containing
* IV + ciphertext + auth tag.
*/
export function encryptForStorage(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Pack: IV (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted]).toString("base64");
}
/**
* Decrypt a value produced by encryptForStorage. Returns the plaintext
* string, or null if decryption fails (wrong key, tampered).
*/
export function decryptFromStorage(packed: string): string | null {
try {
const key = getKey();
const buf = Buffer.from(packed, "base64");
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
} catch {
return null;
}
}

File diff suppressed because it is too large Load Diff

133
apps/broker/src/cli-sync.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* POST /cli-sync handler.
*
* Accepts a sync JWT from the dashboard, creates or finds member rows
* for each mesh in the token, and returns mesh details + member IDs.
*/
import { and, eq, isNull } from "drizzle-orm";
import { db } from "./db";
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
// Import schema tables
import {
mesh as meshTable,
meshMember as memberTable,
} from "@turbostarter/db/schema/mesh";
import { generateId } from "@turbostarter/shared/utils";
export interface CliSyncRequest {
sync_token: string;
peer_pubkey: string; // ed25519 hex (64 chars)
display_name: string;
}
export interface CliSyncResponse {
ok: true;
account_id: string;
meshes: Array<{
mesh_id: string;
slug: string;
broker_url: string;
member_id: string;
role: "admin" | "member";
}>;
}
export interface CliSyncError {
ok: false;
error: string;
}
export async function handleCliSync(
body: CliSyncRequest,
): Promise<CliSyncResponse | CliSyncError> {
// 1. Validate inputs
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
}
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
}
// 2. Verify JWT
const tokenResult = await verifySyncToken(body.sync_token);
if (!tokenResult.ok) {
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
}
const payload = tokenResult.payload;
// 3. For each mesh in the token, create or find a member row
const resultMeshes: CliSyncResponse["meshes"] = [];
for (const tokenMesh of payload.meshes) {
// Verify mesh exists and is not archived
const [m] = await db
.select({ id: meshTable.id, slug: meshTable.slug })
.from(meshTable)
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
if (!m) {
// Skip meshes that don't exist (could have been deleted)
continue;
}
// Check if this pubkey is already a member of this mesh
const [existing] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.meshId, tokenMesh.id),
eq(memberTable.peerPubkey, body.peer_pubkey),
isNull(memberTable.revokedAt),
),
);
let memberId: string;
let role: "admin" | "member";
if (existing) {
// Already a member — update dashboard link + display name
memberId = existing.id;
role = existing.role;
await db
.update(memberTable)
.set({
dashboardUserId: payload.sub,
displayName: body.display_name,
})
.where(eq(memberTable.id, existing.id));
} else {
// Create new member row
memberId = generateId();
role = tokenMesh.role;
await db.insert(memberTable).values({
id: memberId,
meshId: tokenMesh.id,
peerPubkey: body.peer_pubkey,
displayName: body.display_name,
role: tokenMesh.role,
dashboardUserId: payload.sub,
});
}
resultMeshes.push({
mesh_id: tokenMesh.id,
slug: m.slug,
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
member_id: memberId,
role,
});
}
if (resultMeshes.length === 0) {
return { ok: false, error: "no valid meshes found in sync token" };
}
return {
ok: true,
account_id: payload.sub,
meshes: resultMeshes,
};
}

View File

@@ -7,7 +7,10 @@
* current member of the claimed mesh. * current member of the claimed mesh.
*/ */
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import sodium from "libsodium-wrappers"; import sodium from "libsodium-wrappers";
import { db } from "./db";
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
let ready = false; let ready = false;
async function ensureSodium(): Promise<typeof sodium> { async function ensureSodium(): Promise<typeof sodium> {
@@ -69,6 +72,70 @@ export async function verifyEd25519(
} }
} }
/**
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
* protocol moves the root_key out of the URL entirely. Format is locked:
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
*/
export function canonicalInviteV2(p: {
mesh_id: string;
invite_id: string;
expires_at: number; // unix seconds
role: "admin" | "member";
owner_pubkey: string; // hex
}): string {
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
}
/**
* Verify an ed25519 signature over the v2 canonical invite bytes against
* the mesh owner's public key. Returns true on valid signature.
*/
export async function verifyInviteV2(params: {
canonical: string;
signatureHex: string;
ownerPubkeyHex: string;
}): Promise<boolean> {
return verifyEd25519(
params.canonical,
params.signatureHex,
params.ownerPubkeyHex,
);
}
/**
* Seal the mesh root_key to a recipient-provided x25519 public key using
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
* x25519 secret key can unseal.
*
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
*
* Returns base64url of the sealed ciphertext.
*/
export async function sealRootKeyToRecipient(params: {
rootKeyBase64url: string;
recipientX25519PubkeyBase64url: string;
}): Promise<string> {
const s = await ensureSodium();
const rootKeyBytes = s.from_base64(
params.rootKeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
const recipientPk = s.from_base64(
params.recipientX25519PubkeyBase64url,
s.base64_variants.URLSAFE_NO_PADDING,
);
if (recipientPk.length !== 32) {
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
}
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
}
export const HELLO_SKEW_MS = 60_000; export const HELLO_SKEW_MS = 60_000;
/** /**
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
return { ok: false, reason: "malformed" }; return { ok: false, reason: "malformed" };
} }
} }
// ----------------------------------------------------------------------------
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
// tests that need to exercise the logic without spinning up the broker server.
// ----------------------------------------------------------------------------
//
// capabilityV2 column is stored as JSON:
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
// "signature": "<hex ed25519 detached signature>" }
// The broker recomputes the canonical bytes from the invite row and verifies
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
// capabilityV2 === null) skip verification — the legacy path still works
// during the deprecation window.
export type InviteClaimV2Result =
| {
ok: true;
status: 200;
body: {
sealed_root_key: string;
mesh_id: string;
member_id: string;
owner_pubkey: string;
canonical_v2: string;
};
}
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
export async function claimInviteV2Core(params: {
code: string;
recipientX25519PubkeyBase64url: string;
displayName?: string;
now?: number;
}): Promise<InviteClaimV2Result> {
const now = params.now ?? Date.now();
const recipientPk = params.recipientX25519PubkeyBase64url;
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 1. Look up the invite by opaque code.
const [inv] = await db
.select()
.from(inviteTable)
.where(eq(inviteTable.code, params.code))
.limit(1);
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
// 2. Lifecycle checks: revoked → expired → exhausted.
if (inv.revokedAt) {
return { ok: false, status: 410, body: { error: "revoked" } };
}
if (inv.expiresAt.getTime() < now) {
return { ok: false, status: 410, body: { error: "expired" } };
}
if (inv.usedCount >= inv.maxUses) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 3. Load the mesh for owner_pubkey + root_key.
const [m] = await db
.select({
id: mesh.id,
ownerPubkey: mesh.ownerPubkey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
.limit(1);
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
if (!m.ownerPubkey || !m.rootKey) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 4. Compute canonical_v2 from the row (used in the response either way).
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: inv.meshId,
invite_id: inv.id,
expires_at: expiresAtUnix,
role: inv.role as "admin" | "member",
owner_pubkey: m.ownerPubkey,
});
if (inv.version === 2 && inv.capabilityV2) {
let storedCanonical: string | undefined;
let signatureHex: string | undefined;
try {
const parsed = JSON.parse(inv.capabilityV2) as {
canonical?: string;
signature?: string;
};
storedCanonical = parsed.canonical;
signatureHex = parsed.signature;
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
if (!storedCanonical || !signatureHex) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// Broker-recomputed canonical must match the signed bytes exactly.
if (storedCanonical !== canonical) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
const sigOk = await verifyInviteV2({
canonical: storedCanonical,
signatureHex,
ownerPubkeyHex: m.ownerPubkey,
});
if (!sigOk) {
return { ok: false, status: 400, body: { error: "bad_signature" } };
}
}
// v1 rows: skip signature verification (legacy path during migration).
// 5. Atomic consume: increment used_count iff still under max_uses.
const [claimed] = await db
.update(inviteTable)
.set({
usedCount: sql`${inviteTable.usedCount} + 1`,
claimedByPubkey: recipientPk,
})
.where(
and(
eq(inviteTable.id, inv.id),
lt(inviteTable.usedCount, inv.maxUses),
),
)
.returning({ id: inviteTable.id });
if (!claimed) {
return { ok: false, status: 410, body: { error: "exhausted" } };
}
// 6. Create a member row for the claimant.
const preset = (inv.preset as {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: string;
} | null) ?? {};
const displayName =
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
const [row] = await db
.insert(meshMember)
.values({
meshId: inv.meshId,
peerPubkey: recipientPk,
displayName,
role: inv.role,
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
})
.returning({ id: meshMember.id });
if (!row) {
return { ok: false, status: 400, body: { error: "malformed" } };
}
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
let sealed: string;
try {
sealed = await sealRootKeyToRecipient({
rootKeyBase64url: m.rootKey,
recipientX25519PubkeyBase64url: recipientPk,
});
} catch {
return { ok: false, status: 400, body: { error: "malformed" } };
}
return {
ok: true,
status: 200,
body: {
sealed_root_key: sealed,
mesh_id: inv.meshId,
member_id: row.id,
owner_pubkey: m.ownerPubkey,
canonical_v2: canonical,
},
};
}

View File

@@ -0,0 +1,320 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface MeshInvitationProps {
meshName: string;
inviteUrl: string;
token: string;
expiresAt: string;
appBaseUrl: string;
}
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
// Inlined here because email clients don't resolve CSS vars.
const brand = {
bg: "#141413",
bgElevated: "#1f1e1d",
bgCode: "#0f0e0d",
fg: "#faf9f5",
fgSecondary: "#c2c0b6",
fgTertiary: "#87867f",
clay: "#d97757",
clayBorder: "rgba(217, 119, 87, 0.35)",
border: "rgba(217, 119, 87, 0.2)",
serif: 'Georgia, "Times New Roman", serif',
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
sans:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
} as const;
export const MeshInvitation = ({
meshName,
inviteUrl,
token,
expiresAt,
appBaseUrl,
}: MeshInvitationProps) => {
const expiresLabel = new Date(expiresAt).toUTCString();
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
return (
<Html lang="en">
<Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
<Body
style={{
backgroundColor: brand.bg,
color: brand.fg,
fontFamily: brand.sans,
margin: 0,
padding: "40px 0",
}}
>
<Container
style={{
maxWidth: "560px",
margin: "0 auto",
padding: "0 24px",
}}
>
{/* Header — mesh glyph + wordmark */}
<Section style={{ marginBottom: "40px" }}>
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
<tr>
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="4" r="2" fill={brand.clay} />
<circle cx="4" cy="12" r="2" fill={brand.clay} />
<circle cx="20" cy="12" r="2" fill={brand.clay} />
<circle cx="12" cy="20" r="2" fill={brand.clay} />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke={brand.clay}
strokeWidth="1.2"
opacity="0.45"
fill="none"
/>
</svg>
</td>
<td style={{ verticalAlign: "middle" }}>
<Text
style={{
fontFamily: brand.serif,
fontSize: "17px",
fontWeight: 500,
letterSpacing: "-0.01em",
color: brand.fg,
margin: 0,
}}
>
claudemesh
</Text>
</td>
</tr>
</table>
</Section>
{/* Eyebrow */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.clay,
margin: "0 0 16px 0",
}}
>
— you're invited
</Text>
{/* Heading */}
<Heading
as="h1"
style={{
fontFamily: brand.serif,
fontSize: "32px",
fontWeight: 500,
lineHeight: "1.15",
letterSpacing: "-0.01em",
color: brand.fg,
margin: "0 0 20px 0",
}}
>
Join{" "}
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
{meshName}
</span>{" "}
on claudemesh
</Heading>
{/* Body prose */}
<Text
style={{
fontFamily: brand.serif,
fontSize: "16px",
lineHeight: "1.65",
color: brand.fgSecondary,
margin: "0 0 32px 0",
}}
>
claudemesh is a peer mesh for Claude Code sessions end-to-end
encrypted, keys stay on your machine. Open the link below to see
the mesh, the inviter, and the command to join.
</Text>
{/* Primary CTA */}
<Section style={{ marginBottom: "36px" }}>
<Button
href={inviteUrl}
style={{
backgroundColor: brand.clay,
color: brand.fg,
fontFamily: brand.sans,
fontSize: "15px",
fontWeight: 500,
textDecoration: "none",
padding: "14px 28px",
borderRadius: "4px",
display: "inline-block",
}}
>
Open invite
</Button>
</Section>
{/* Terminal shortcut — for the already-set-up crowd */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.fgTertiary,
margin: "0 0 12px 0",
}}
>
already have the CLI?
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.clayBorder}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
wordBreak: "break-all",
lineHeight: "1.6",
}}
>
{launchCmd}
</Text>
</Section>
{/* First-time one-liner */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.fgTertiary,
margin: "0 0 12px 0",
}}
>
first time? one command
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.border}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
lineHeight: "1.6",
wordBreak: "break-all",
}}
>
{oneLiner}
</Text>
<Text
style={{
fontFamily: brand.serif,
fontSize: "12px",
color: brand.fgTertiary,
margin: "8px 0 0 0",
}}
>
Requires Node.js 20+. Display name defaults to $USER.
</Text>
</Section>
<Hr
style={{
border: "none",
borderTop: `1px solid ${brand.border}`,
margin: "28px 0 20px 0",
}}
/>
{/* Footer meta */}
<Text
style={{
fontFamily: brand.serif,
fontSize: "13px",
lineHeight: "1.6",
color: brand.fgTertiary,
margin: "0 0 8px 0",
}}
>
Expires{" "}
<span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
If you weren't expecting this, you can ignore it.
</Text>
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
color: brand.fgTertiary,
margin: 0,
}}
>
<Link
href={appBaseUrl}
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
>
claudemesh.com
</Link>
</Text>
</Container>
</Body>
</Html>
);
};
MeshInvitation.PreviewProps = {
meshName: "prueba1",
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
expiresAt: "2026-04-22T00:51:26.181Z",
appBaseUrl: "https://claudemesh.com",
} satisfies MeshInvitationProps;
export default MeshInvitation;

View File

@@ -20,6 +20,21 @@ const envSchema = z.object({
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100), MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536), MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30), HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
QDRANT_URL: z.string().default("http://qdrant:6333"),
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
NEO4J_USER: z.string().default("neo4j"),
NEO4J_PASSWORD: z.string().default("changeme"),
RUNNER_URL: z.string().default("http://runner:7901"),
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
ANTHROPIC_API_KEY: z.string().default(""), // Claude API key for Telegram AI bot
NODE_ENV: z NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),

File diff suppressed because it is too large Load Diff

146
apps/broker/src/jwt.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* JWT verification for CLI sync tokens.
*
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
* shared secret between dashboard and broker via env var.
*
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
*/
import { env } from "./env";
// --- Types ---
export interface SyncTokenPayload {
sub: string; // dashboard user ID
email: string;
meshes: Array<{
id: string;
slug: string;
role: "admin" | "member";
}>;
action: "sync" | "create";
newMesh?: {
name: string;
slug: string;
};
jti: string; // unique token ID for replay prevention
iat: number;
exp: number;
}
// --- JTI dedup ---
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
// Sweep expired JTIs every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [jti, exp] of usedJtis) {
if (exp < now) usedJtis.delete(jti);
}
}, 5 * 60_000);
// --- Verification ---
/**
* Verify and decode a sync token JWT.
* Returns the decoded payload on success, or an error string on failure.
*/
export async function verifySyncToken(
token: string,
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
// Get shared secret from env
const secret = env.CLI_SYNC_SECRET;
if (!secret) {
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
}
try {
// Decode JWT manually (HS256)
const parts = token.split(".");
if (parts.length !== 3) {
return { ok: false, error: "malformed JWT" };
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Verify signature (HS256)
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
if (!valid) {
return { ok: false, error: "invalid signature" };
}
// Decode header — must be HS256
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
if (header.alg !== "HS256") {
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
}
// Decode payload
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
) as SyncTokenPayload;
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
return { ok: false, error: "token expired" };
}
// Check iat not in the future (30s tolerance)
if (payload.iat && payload.iat > now + 30) {
return { ok: false, error: "token issued in the future" };
}
// JTI dedup
if (!payload.jti) {
return { ok: false, error: "missing jti" };
}
if (usedJtis.has(payload.jti)) {
return { ok: false, error: "token already used" };
}
// Mark as used with expiry time
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
// Basic validation
if (!payload.sub || !payload.email) {
return { ok: false, error: "missing sub or email" };
}
if (!Array.isArray(payload.meshes)) {
return { ok: false, error: "missing meshes array" };
}
return { ok: true, payload };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// --- Helpers ---
function base64UrlDecode(input: string): Uint8Array {
// Add padding
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) base64 += "=";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,284 @@
/**
* Member profile REST API handlers.
*
* PATCH /mesh/:meshId/member/:memberId — update member profile
* GET /mesh/:meshId/members — list all members with online status
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
*
* These are standalone handler functions. Route wiring happens in index.ts.
*/
import { and, eq, isNull, sql } from "drizzle-orm";
import { db } from "./db";
import {
mesh as meshTable,
meshMember as memberTable,
presence as presenceTable,
} from "@turbostarter/db/schema/mesh";
// --- Types ---
export interface MemberProfileUpdate {
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: "push" | "inbox" | "off";
}
export interface MemberPermissionUpdate {
permission?: "admin" | "member"; // only admins can change this
}
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
interface SelfEditablePolicy {
displayName: boolean;
roleTag: boolean;
groups: boolean;
messageMode: boolean;
}
// --- Handlers ---
/**
* Update a member's profile fields.
*
* Authorization:
* - If caller is the target member: check mesh.selfEditable for each field
* - If caller is a mesh admin: allow all fields
* - permission field: admin-only always
*
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
*/
export async function updateMemberProfile(
meshId: string,
memberId: string,
callerMemberId: string, // from auth header or WS connection
updates: MemberUpdateRequest,
): Promise<
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
| { ok: false; error: string }
> {
// 1. Load mesh for selfEditable policy
const [m] = await db
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// 2. Load caller's member row to check permission
const [caller] = await db
.select({ id: memberTable.id, role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
const isAdmin = caller.role === "admin";
const isSelf = callerMemberId === memberId;
if (!isAdmin && !isSelf) {
return {
ok: false,
error: "not authorized — only admins or self can edit",
};
}
// 3. Check self-edit permissions for non-admin self-edits
const policy: SelfEditablePolicy =
(m.selfEditable as SelfEditablePolicy) ?? {
displayName: true,
roleTag: true,
groups: true,
messageMode: true,
};
const rejected: string[] = [];
if (!isAdmin && isSelf) {
if (updates.displayName !== undefined && !policy.displayName)
rejected.push("displayName");
if (updates.roleTag !== undefined && !policy.roleTag)
rejected.push("roleTag");
if (updates.groups !== undefined && !policy.groups)
rejected.push("groups");
if (updates.messageMode !== undefined && !policy.messageMode)
rejected.push("messageMode");
if (updates.permission !== undefined) rejected.push("permission");
}
if (rejected.length > 0) {
return {
ok: false,
error: `admin-managed fields: ${rejected.join(", ")}`,
};
}
// 4. Build update set
const set: Record<string, unknown> = {};
const changes: MemberProfileUpdate = {};
if (updates.displayName !== undefined) {
set.displayName = updates.displayName;
changes.displayName = updates.displayName;
}
if (updates.roleTag !== undefined) {
set.roleTag = updates.roleTag;
changes.roleTag = updates.roleTag;
}
if (updates.groups !== undefined) {
set.defaultGroups = updates.groups;
changes.groups = updates.groups;
}
if (updates.messageMode !== undefined) {
set.messageMode = updates.messageMode;
changes.messageMode = updates.messageMode;
}
if (updates.permission !== undefined && isAdmin) {
set.role = updates.permission;
}
if (Object.keys(set).length === 0) {
return { ok: false, error: "no fields to update" };
}
// 5. Update member row
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
// 6. Read back the updated member
const [updated] = await db
.select()
.from(memberTable)
.where(eq(memberTable.id, memberId));
if (!updated) return { ok: false, error: "member not found after update" };
return {
ok: true,
member: {
id: updated.id,
displayName: updated.displayName,
roleTag: updated.roleTag,
groups: updated.defaultGroups,
messageMode: updated.messageMode,
permission: updated.role,
dashboardUserId: updated.dashboardUserId,
joinedAt: updated.joinedAt,
lastSeenAt: updated.lastSeenAt,
},
changes,
};
}
/**
* List all members of a mesh with online status.
*/
export async function listMeshMembers(
meshId: string,
): Promise<
| { ok: true; members: Array<Record<string, unknown>> }
| { ok: false; error: string }
> {
// Verify mesh exists
const [m] = await db
.select({ id: meshTable.id })
.from(meshTable)
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
if (!m) return { ok: false, error: "mesh not found" };
// Get all non-revoked members
const members = await db
.select()
.from(memberTable)
.where(
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
);
// Early return for empty member list (avoids invalid SQL IN clause)
if (members.length === 0) {
return { ok: true, members: [] };
}
// Get active presences for online status
const activePresences = await db
.select({
memberId: presenceTable.memberId,
count: sql<number>`count(*)::int`,
})
.from(presenceTable)
.where(
and(
isNull(presenceTable.disconnectedAt),
sql`${presenceTable.memberId} IN (${sql.join(
members.map((m) => sql`${m.id}`),
sql`, `,
)})`,
),
)
.groupBy(presenceTable.memberId);
const onlineMap = new Map(
activePresences.map((p) => [p.memberId, p.count]),
);
return {
ok: true,
members: members.map((member) => ({
id: member.id,
displayName: member.displayName,
roleTag: member.roleTag,
groups: member.defaultGroups,
messageMode: member.messageMode,
permission: member.role,
dashboardUserId: member.dashboardUserId,
joinedAt: member.joinedAt?.toISOString(),
lastSeenAt: member.lastSeenAt?.toISOString(),
online: onlineMap.has(member.id),
sessionCount: onlineMap.get(member.id) ?? 0,
})),
};
}
/**
* Update mesh settings (currently: selfEditable policy).
* Admin-only.
*/
export async function updateMeshSettings(
meshId: string,
callerMemberId: string,
settings: { selfEditable?: SelfEditablePolicy },
): Promise<{ ok: true } | { ok: false; error: string }> {
// Check caller is admin
const [caller] = await db
.select({ role: memberTable.role })
.from(memberTable)
.where(
and(
eq(memberTable.id, callerMemberId),
eq(memberTable.meshId, meshId),
isNull(memberTable.revokedAt),
),
);
if (!caller || caller.role !== "admin") {
return { ok: false, error: "admin access required" };
}
const set: Record<string, unknown> = {};
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
if (Object.keys(set).length === 0) {
return { ok: false, error: "no settings to update" };
}
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
return { ok: true };
}

28
apps/broker/src/minio.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* MinIO client for file storage.
*
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
* a key path that encodes persistence and origin:
* - persistent: shared/{fileId}/{originalName}
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
*/
import { Client } from "minio";
import { env } from "./env";
export const minioClient = new Client({
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
});
export async function ensureBucket(name: string): Promise<void> {
const exists = await minioClient.bucketExists(name);
if (!exists) await minioClient.makeBucket(name);
}
export function meshBucketName(meshId: string): string {
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
}

View File

@@ -0,0 +1,22 @@
import neo4j from "neo4j-driver";
import { env } from "./env";
export const neo4jDriver = neo4j.driver(
env.NEO4J_URL,
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
);
export function meshDbName(meshId: string): string {
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
}
export async function ensureDatabase(name: string): Promise<void> {
const session = neo4jDriver.session({ database: "system" });
try {
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
} catch {
/* may not support multi-db in community edition — fall back to default */
} finally {
await session.close();
}
}

View File

@@ -0,0 +1,112 @@
/**
* Granular permission checks for mesh operations.
*
* If a meshPermission row exists for the member, use it.
* Otherwise, derive defaults from the member's role.
*/
import { and, eq } from "drizzle-orm";
import { db } from "./db";
import { meshPermission, meshMember, mesh, DEFAULT_PERMISSIONS } from "@turbostarter/db/schema/mesh";
import type { PermissionKey } from "@turbostarter/db/schema/mesh";
export interface ResolvedPermissions {
canInvite: boolean;
canDeployMcp: boolean;
canManageFiles: boolean;
canManageVault: boolean;
canManageWatches: boolean;
canManageWebhooks: boolean;
canWriteState: boolean;
canSend: boolean;
canUseTools: boolean;
canDeleteMesh: boolean;
canManagePermissions: boolean;
}
/**
* Get effective permissions for a member in a mesh.
* Checks for explicit permission row, falls back to role defaults.
*/
export async function getPermissions(meshId: string, memberId: string): Promise<ResolvedPermissions> {
// Get the explicit permission row if it exists
const [perm] = await db.select().from(meshPermission)
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
.limit(1);
if (perm) {
return {
canInvite: perm.canInvite,
canDeployMcp: perm.canDeployMcp,
canManageFiles: perm.canManageFiles,
canManageVault: perm.canManageVault,
canManageWatches: perm.canManageWatches,
canManageWebhooks: perm.canManageWebhooks,
canWriteState: perm.canWriteState,
canSend: perm.canSend,
canUseTools: perm.canUseTools,
canDeleteMesh: perm.canDeleteMesh,
canManagePermissions: perm.canManagePermissions,
};
}
// Fall back to role-based defaults
const [member] = await db.select().from(meshMember)
.where(eq(meshMember.id, memberId))
.limit(1);
if (!member) return DEFAULT_PERMISSIONS.member;
// Check if member is mesh owner
const [m] = await db.select().from(mesh)
.where(eq(mesh.id, meshId))
.limit(1);
if (m && m.ownerUserId && member.userId === m.ownerUserId) {
return DEFAULT_PERMISSIONS.owner;
}
return DEFAULT_PERMISSIONS[member.role] ?? DEFAULT_PERMISSIONS.member;
}
/**
* Check a single permission for a member.
* Returns true if allowed, false if denied.
*/
export async function checkPermission(
meshId: string,
memberId: string,
permission: PermissionKey,
): Promise<boolean> {
const perms = await getPermissions(meshId, memberId);
return perms[permission];
}
/**
* Set explicit permissions for a member (partial update).
* Creates the row if it doesn't exist.
*/
export async function setPermissions(
meshId: string,
memberId: string,
updates: Partial<ResolvedPermissions>,
): Promise<void> {
const [existing] = await db.select().from(meshPermission)
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
.limit(1);
if (existing) {
await db.update(meshPermission)
.set({ ...updates, updatedAt: new Date() })
.where(eq(meshPermission.id, existing.id));
} else {
// Get role defaults first, then overlay updates
const defaults = await getPermissions(meshId, memberId);
await db.insert(meshPermission).values({
meshId,
memberId,
...defaults,
...updates,
});
}
}

24
apps/broker/src/qdrant.ts Normal file
View File

@@ -0,0 +1,24 @@
import { QdrantClient } from "@qdrant/js-client-rest";
import { env } from "./env";
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
export function meshCollectionName(
meshId: string,
collection: string,
): string {
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
}
export async function ensureCollection(
name: string,
vectorSize = 1536,
): Promise<void> {
try {
await qdrant.getCollection(name);
} catch {
await qdrant.createCollection(name, {
vectors: { size: vectorSize, distance: "Cosine" },
});
}
}

View File

@@ -0,0 +1,788 @@
/**
* Service Manager — lifecycle management for mesh-deployed MCP servers.
*
* Each deployed MCP server runs as a child process with its own stdio pipe.
* The manager spawns, monitors, restarts, and routes tool calls to them.
*
* In production: child processes run inside a Docker container (one per mesh).
* In dev: child processes run directly on the broker host.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** MCP tool definition returned by tools/list. */
export interface ToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
/** Per-service deploy-time configuration. */
export interface ServiceConfig {
env?: Record<string, string>;
memory_mb?: number;
cpus?: number;
network_allow?: string[];
runtime?: "node" | "python" | "bun";
}
/** Observable lifecycle states. */
export type ServiceStatus =
| "building"
| "installing"
| "running"
| "stopped"
| "failed"
| "crashed"
| "restarting";
/** Internal bookkeeping for a spawned service. */
interface ManagedService {
name: string;
meshId: string;
process: ChildProcess | null;
tools: ToolDef[];
status: ServiceStatus;
config: ServiceConfig;
sourcePath: string;
runtime: "node" | "python" | "bun";
restartCount: number;
maxRestarts: number;
healthFailures: number;
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
pendingCalls: Map<
string,
{
resolve: (result: { result?: unknown; error?: string }) => void;
timer: NodeJS.Timeout;
}
>;
pid?: number;
startedAt?: Date;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LOG_BUFFER_SIZE = 1000;
const HEALTH_INTERVAL_MS = 30_000;
const HEALTH_TIMEOUT_MS = 5_000;
const MAX_HEALTH_FAILURES = 3;
const DEFAULT_MAX_RESTARTS = 5;
const CALL_TIMEOUT_MS = 25_000;
const SERVICES_BASE_DIR =
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
// ---------------------------------------------------------------------------
// Service registry
// ---------------------------------------------------------------------------
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
let healthTimer: NodeJS.Timer | null = null;
function serviceKey(meshId: string, name: string): string {
return `${meshId}:${name}`;
}
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
export function validateServiceName(name: string): string | null {
if (!SAFE_NAME_RE.test(name)) {
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
}
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
return "service name must not contain path separators";
}
return null; // valid
}
// ---------------------------------------------------------------------------
// Runtime detection
// ---------------------------------------------------------------------------
/**
* Detect the runtime for a service based on its source directory contents.
*
* Priority: bun (lockfile/config) > node (package.json) > python
* (pyproject.toml / requirements.txt). Falls back to node.
*/
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
if (
existsSync(join(sourcePath, "bun.lockb")) ||
existsSync(join(sourcePath, "bunfig.toml"))
) {
return "bun";
}
if (existsSync(join(sourcePath, "package.json"))) {
return "node";
}
if (
existsSync(join(sourcePath, "pyproject.toml")) ||
existsSync(join(sourcePath, "requirements.txt"))
) {
return "python";
}
return "node"; // default
}
// ---------------------------------------------------------------------------
// Entry point detection
// ---------------------------------------------------------------------------
function detectEntry(
sourcePath: string,
runtime: "node" | "python" | "bun",
): { command: string; args: string[] } {
if (runtime === "python") {
if (existsSync(join(sourcePath, "requirements.txt"))) {
for (const entry of [
"server.py",
"src/server.py",
"main.py",
"src/main.py",
]) {
if (existsSync(join(sourcePath, entry))) {
return { command: "python", args: [entry] };
}
}
}
if (existsSync(join(sourcePath, "pyproject.toml"))) {
return { command: "python", args: ["-m", "server"] };
}
return { command: "python", args: ["server.py"] };
}
// Node / Bun
const cmd = runtime === "bun" ? "bun" : "node";
if (existsSync(join(sourcePath, "package.json"))) {
try {
const pkg = JSON.parse(
readFileSync(join(sourcePath, "package.json"), "utf-8"),
);
if (pkg.main) return { command: cmd, args: [pkg.main] };
if (pkg.bin) {
const bin =
typeof pkg.bin === "string"
? pkg.bin
: (Object.values(pkg.bin)[0] as string);
if (bin) return { command: cmd, args: [bin] };
}
} catch {
/* ignore parse errors */
}
}
// Common entry points
for (const entry of [
"dist/index.js",
"src/index.js",
"src/index.ts",
"index.js",
]) {
if (existsSync(join(sourcePath, entry))) {
return { command: cmd, args: [entry] };
}
}
return { command: cmd, args: ["src/index.js"] };
}
// ---------------------------------------------------------------------------
// Install dependencies
// ---------------------------------------------------------------------------
/**
* Install dependencies for a service. Resolves on success, rejects with
* the tail of stderr on failure.
*/
export async function installDeps(
sourcePath: string,
runtime: "node" | "python" | "bun",
): Promise<void> {
return new Promise((resolve, reject) => {
let cmd: string;
let args: string[];
if (runtime === "python") {
if (existsSync(join(sourcePath, "requirements.txt"))) {
cmd = "pip";
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
} else {
cmd = "pip";
args = ["install", "--no-cache-dir", "."];
}
} else if (runtime === "bun") {
cmd = "bun";
args = ["install"];
} else {
cmd = "npm";
args = ["install", "--production", "--legacy-peer-deps"];
}
const child = spawn(cmd, args, {
cwd: sourcePath,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (d: Buffer) => {
stderr += d.toString();
});
child.on("exit", (code) => {
if (code === 0) resolve();
else
reject(
new Error(
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
),
);
});
child.on("error", reject);
});
}
// ---------------------------------------------------------------------------
// Log ring buffer
// ---------------------------------------------------------------------------
function appendLog(svc: ManagedService, line: string): void {
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
svc.logBuffer.shift();
}
}
// ---------------------------------------------------------------------------
// MCP JSON-RPC helpers
// ---------------------------------------------------------------------------
let callIdCounter = 0;
function sendMcpRequest(
svc: ManagedService,
method: string,
params?: unknown,
): Promise<{ result?: unknown; error?: string }> {
return new Promise((resolve) => {
if (!svc.process || !svc.process.stdin?.writable) {
resolve({ error: "service not running" });
return;
}
const id = `call_${++callIdCounter}`;
const request = {
jsonrpc: "2.0",
id,
method,
...(params ? { params } : {}),
};
const timer = setTimeout(() => {
svc.pendingCalls.delete(id);
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
}, CALL_TIMEOUT_MS);
svc.pendingCalls.set(id, { resolve, timer });
try {
svc.process.stdin!.write(JSON.stringify(request) + "\n");
} catch (e) {
clearTimeout(timer);
svc.pendingCalls.delete(id);
resolve({
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
});
}
});
}
// ---------------------------------------------------------------------------
// Initialize MCP server (handshake + tool discovery)
// ---------------------------------------------------------------------------
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
// MCP initialize handshake
const initResult = await sendMcpRequest(svc, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
});
if (initResult.error) {
throw new Error(`MCP initialize failed: ${initResult.error}`);
}
// Send initialized notification (no response expected)
if (svc.process?.stdin?.writable) {
svc.process.stdin.write(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}) + "\n",
);
}
// Fetch tool list
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
if (toolsResult.error) {
throw new Error(`tools/list failed: ${toolsResult.error}`);
}
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
return result?.tools ?? [];
}
// ---------------------------------------------------------------------------
// Spawn an MCP server child process
// ---------------------------------------------------------------------------
function spawnService(svc: ManagedService): void {
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...(svc.config.env ?? {}),
NODE_ENV: "production",
};
const child = spawn(command, args, {
cwd: svc.sourcePath,
stdio: ["pipe", "pipe", "pipe"],
env,
});
svc.process = child;
svc.pid = child.pid;
svc.startedAt = new Date();
svc.status = "running";
svc.healthFailures = 0;
// Read MCP JSON-RPC responses from stdout
const rl = createInterface({ input: child.stdout! });
rl.on("line", (line) => {
try {
const msg = JSON.parse(line);
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
const pending = svc.pendingCalls.get(String(msg.id))!;
clearTimeout(pending.timer);
svc.pendingCalls.delete(String(msg.id));
if (msg.error) {
pending.resolve({
error: msg.error.message ?? JSON.stringify(msg.error),
});
} else {
pending.resolve({ result: msg.result });
}
}
} catch {
// Not JSON — treat as log output
appendLog(svc, `[stdout] ${line}`);
}
});
// Capture stderr as logs
const stderrRl = createInterface({ input: child.stderr! });
stderrRl.on("line", (line) => {
appendLog(svc, `[stderr] ${line}`);
});
child.on("exit", (code, signal) => {
log.warn("service exited", {
service: svc.name,
mesh_id: svc.meshId,
code,
signal,
restarts: svc.restartCount,
});
// Reject all pending calls
for (const [, pending] of svc.pendingCalls) {
clearTimeout(pending.timer);
pending.resolve({ error: "service crashed" });
}
svc.pendingCalls.clear();
svc.process = null;
svc.pid = undefined;
// Auto-restart if under limit
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
svc.restartCount++;
svc.status = "restarting";
log.info("auto-restarting service", {
service: svc.name,
attempt: svc.restartCount,
});
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
} else if (svc.status === "running") {
svc.status = "crashed";
log.error("service max restarts exceeded", {
service: svc.name,
restarts: svc.restartCount,
});
}
});
child.on("error", (err) => {
log.error("service spawn error", {
service: svc.name,
error: err.message,
});
svc.status = "failed";
});
log.info("service spawned", {
service: svc.name,
mesh_id: svc.meshId,
pid: child.pid,
command,
args,
runtime: svc.runtime,
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Deploy (or redeploy) an MCP server.
*
* Installs dependencies, spawns the child process, runs the MCP
* initialize handshake, and returns the discovered tool list.
*/
export async function deploy(opts: {
meshId: string;
name: string;
sourcePath: string;
config: ServiceConfig;
resolvedEnv?: Record<string, string>;
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
const key = serviceKey(opts.meshId, opts.name);
// Kill existing if redeploying
const existing = services.get(key);
if (existing?.process) {
existing.process.kill("SIGTERM");
await new Promise((r) => setTimeout(r, 1000));
}
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
const svc: ManagedService = {
name: opts.name,
meshId: opts.meshId,
process: null,
tools: [],
status: "installing",
config: {
...opts.config,
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
},
sourcePath: opts.sourcePath,
runtime,
restartCount: 0,
maxRestarts: DEFAULT_MAX_RESTARTS,
healthFailures: 0,
logBuffer: [],
pendingCalls: new Map(),
};
services.set(key, svc);
// Install dependencies
try {
await installDeps(opts.sourcePath, runtime);
} catch (e) {
svc.status = "failed";
appendLog(
svc,
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
);
throw e;
}
// Spawn and initialize
spawnService(svc);
// Wait a moment for the process to start
await new Promise((r) => setTimeout(r, 500));
// Get tool list via MCP initialize handshake
try {
svc.tools = await initializeMcp(svc);
log.info("service deployed", {
service: opts.name,
mesh_id: opts.meshId,
tools: svc.tools.length,
runtime,
});
} catch (e) {
svc.status = "failed";
appendLog(
svc,
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
);
throw e;
}
return { tools: svc.tools, status: svc.status };
}
/**
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
*/
export async function undeploy(meshId: string, name: string): Promise<void> {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return;
svc.status = "stopped";
if (svc.process) {
svc.process.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
svc.process?.kill("SIGKILL");
resolve();
}, 10_000);
svc.process?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
// Reject pending calls
for (const [, pending] of svc.pendingCalls) {
clearTimeout(pending.timer);
pending.resolve({ error: "service undeployed" });
}
services.delete(key);
log.info("service undeployed", { service: name, mesh_id: meshId });
}
/**
* Route a tool call to the named service. Returns the MCP response
* payload or an error string.
*/
export async function callTool(
meshId: string,
serverName: string,
toolName: string,
args: Record<string, unknown>,
): Promise<{ result?: unknown; error?: string }> {
const key = serviceKey(meshId, serverName);
const svc = services.get(key);
if (!svc) return { error: `service "${serverName}" not found` };
if (svc.status !== "running")
return { error: `service "${serverName}" is ${svc.status}` };
if (!svc.process)
return { error: `service "${serverName}" has no running process` };
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
}
/**
* Return the last N log lines for a service (from its ring buffer).
*/
export function getLogs(meshId: string, name: string, lines = 50): string[] {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return [];
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
}
/**
* Return current status, PID, restart count, tool list, and uptime
* for a single service. Returns null if the service doesn't exist.
*/
export function getStatus(
meshId: string,
name: string,
): {
status: ServiceStatus;
pid?: number;
restartCount: number;
tools: ToolDef[];
startedAt?: string;
} | null {
const key = serviceKey(meshId, name);
const svc = services.get(key);
if (!svc) return null;
return {
status: svc.status,
pid: svc.pid,
restartCount: svc.restartCount,
tools: svc.tools,
startedAt: svc.startedAt?.toISOString(),
};
}
/**
* Return the tool definitions for a service, or an empty array if the
* service doesn't exist.
*/
export function getTools(meshId: string, name: string): ToolDef[] {
const key = serviceKey(meshId, name);
const svc = services.get(key);
return svc?.tools ?? [];
}
/**
* List all services belonging to a mesh with summary info.
*/
export function listServices(
meshId: string,
): Array<{
name: string;
status: ServiceStatus;
toolCount: number;
runtime: string;
restartCount: number;
pid?: number;
}> {
const result: Array<{
name: string;
status: ServiceStatus;
toolCount: number;
runtime: string;
restartCount: number;
pid?: number;
}> = [];
for (const [key, svc] of services) {
if (!key.startsWith(`${meshId}:`)) continue;
result.push({
name: svc.name,
status: svc.status,
toolCount: svc.tools.length,
runtime: svc.runtime,
restartCount: svc.restartCount,
pid: svc.pid,
});
}
return result;
}
// ---------------------------------------------------------------------------
// Health check loop
// ---------------------------------------------------------------------------
async function healthCheckAll(): Promise<void> {
for (const [, svc] of services) {
if (svc.status !== "running" || !svc.process) continue;
const result = await sendMcpRequest(svc, "ping", {});
if (result.error) {
svc.healthFailures++;
log.warn("health check failed", {
service: svc.name,
failures: svc.healthFailures,
error: result.error,
});
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
log.error("health check threshold exceeded, restarting", {
service: svc.name,
});
svc.process.kill("SIGTERM");
// exit handler will trigger auto-restart
}
} else {
svc.healthFailures = 0;
}
}
}
/** Start the periodic health check loop (30 s interval). No-op if already running. */
export function startHealthChecks(): void {
if (healthTimer) return;
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
}
/** Stop the periodic health check loop. */
export function stopHealthChecks(): void {
if (healthTimer) {
clearInterval(healthTimer);
healthTimer = null;
}
}
// ---------------------------------------------------------------------------
// Restore all services on broker boot
// ---------------------------------------------------------------------------
/**
* Re-deploy every persisted service record. Called once at broker startup
* to bring services back after a restart. Failures are logged but don't
* prevent other services from restoring.
*/
export async function restoreAll(
getServiceRecords: () => Promise<
Array<{
meshId: string;
name: string;
sourcePath: string;
config: ServiceConfig;
resolvedEnv?: Record<string, string>;
}>
>,
): Promise<void> {
const records = await getServiceRecords();
log.info("restoring services", { count: records.length });
for (const record of records) {
try {
await deploy({
meshId: record.meshId,
name: record.name,
sourcePath: record.sourcePath,
config: record.config,
resolvedEnv: record.resolvedEnv,
});
log.info("service restored", {
service: record.name,
mesh_id: record.meshId,
});
} catch (e) {
log.error("service restore failed", {
service: record.name,
mesh_id: record.meshId,
error: e instanceof Error ? e.message : String(e),
});
}
}
startHealthChecks();
}
// ---------------------------------------------------------------------------
// Shutdown
// ---------------------------------------------------------------------------
/**
* Gracefully shut down all running services. Stops health checks, sends
* SIGTERM to every child, waits for exit, then clears the registry.
*/
export async function shutdownAll(): Promise<void> {
stopHealthChecks();
const promises: Promise<void>[] = [];
for (const [, svc] of services) {
if (svc.process) {
svc.status = "stopped";
promises.push(undeploy(svc.meshId, svc.name));
}
}
await Promise.allSettled(promises);
services.clear();
}

View File

@@ -0,0 +1,424 @@
/**
* Claude-powered natural language processing for Telegram mesh interactions.
*
* Uses Claude Haiku 4.5 with tool calling to interpret user intent
* and map to mesh operations. Destructive/social actions require
* confirmation via Telegram inline buttons.
*/
import Anthropic from "@anthropic-ai/sdk";
import { env } from "./env";
import { log } from "./logger";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AiTool {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface AiToolCall {
name: string;
input: Record<string, unknown>;
}
export interface AiResult {
type: "text" | "tool_call" | "error";
text?: string;
toolCall?: AiToolCall;
requiresConfirmation?: boolean;
}
// ---------------------------------------------------------------------------
// Tools definition
// ---------------------------------------------------------------------------
const TOOLS: AiTool[] = [
{
name: "send_message",
description: "Send a message to a peer in the mesh. Use when the user wants to tell, ask, or communicate something to a specific person or group.",
input_schema: {
type: "object",
properties: {
to: { type: "string", description: "Peer name, @group, or * for broadcast" },
message: { type: "string", description: "The message content" },
priority: { type: "string", enum: ["now", "next", "low"], description: "Delivery priority (default: next)" },
},
required: ["to", "message"],
},
},
{
name: "list_peers",
description: "List all connected peers in the mesh. Use when user asks who's online, who's available, or what everyone is doing.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "list_meshes",
description: "List all meshes this Telegram chat is connected to. Use when user asks about their meshes, which meshes are available, or wants to see their workspace list.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "remember",
description: "Store a memory/note in the mesh's shared knowledge. Use when user wants to save information for later.",
input_schema: {
type: "object",
properties: {
content: { type: "string", description: "The content to remember" },
tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" },
},
required: ["content"],
},
},
{
name: "recall",
description: "Search the mesh's shared memory. Use when user asks about something that was previously stored.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
{
name: "get_state",
description: "Read a shared state value. Use when user asks about a specific key/variable.",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "State key to read" },
},
required: ["key"],
},
},
{
name: "set_state",
description: "Write a shared state value. Use when user wants to set/update a key.",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "State key" },
value: { type: "string", description: "Value to set" },
},
required: ["key", "value"],
},
},
{
name: "create_mesh",
description: "Create a new mesh. Use when user wants to create a new workspace/mesh.",
input_schema: {
type: "object",
properties: {
name: { type: "string", description: "Mesh name" },
},
required: ["name"],
},
},
{
name: "share_mesh",
description: "Generate an invite link or send an invite email. Use when user wants to invite someone to the mesh.",
input_schema: {
type: "object",
properties: {
email: { type: "string", description: "Email to invite (optional — if omitted, generates a link)" },
},
},
},
{
name: "list_services",
description: "List all deployed MCP services and skills in the mesh. Use when user asks about available tools, services, MCPs, skills, or capabilities.",
input_schema: {
type: "object",
properties: {},
},
},
{
name: "list_commands",
description: "Show available Telegram bot commands. Use when user asks what commands are available, what they can do, or asks for help.",
input_schema: {
type: "object",
properties: {},
},
},
];
// Actions that need user confirmation before executing
const CONFIRM_ACTIONS = new Set([
"send_message",
"create_mesh",
"share_mesh",
"set_state",
"remember",
]);
const SYSTEM_PROMPT = `You are the claudemesh Telegram assistant. You help users interact with their claudemesh peer network using natural language.
You have access to tools for mesh operations. When the user's intent maps to a tool, use it. When it's a general question or conversation, respond directly.
IMPORTANT: Always respond in the same language the user writes in. If they write in Spanish, respond in Spanish. If English, respond in English.
Key concepts:
- A MESH is a workspace/group (like "flexicar", "alexis-mou"). This Telegram chat can be connected to multiple meshes.
- A PEER is a person/agent connected to a mesh (like "Nedas", "Mou").
- When user says "send to <mesh-name>", they mean BROADCAST to all peers in that mesh. Use send_message with to="*" — the system will route to the correct mesh.
- When user says "send to <person-name>", they mean a direct message to that peer.
Rules:
- Be concise — Telegram messages should be short
- When sending messages to peers, preserve the user's tone and intent
- If the target looks like a mesh name (matches one from context), broadcast to it
- Never fabricate peer names — use list_peers to find real names
- Default to the first connected mesh if not specified`;
// ---------------------------------------------------------------------------
// AI Engine
// ---------------------------------------------------------------------------
let client: Anthropic | null = null;
function getClient(): Anthropic {
if (!client) {
const apiKey = env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
client = new Anthropic({ apiKey });
}
return client;
}
// ---------------------------------------------------------------------------
// Conversation history (per chat, rolling window)
// ---------------------------------------------------------------------------
const MAX_HISTORY = 10;
const HISTORY_TTL_MS = 30 * 60 * 1000; // 30 min
interface HistoryEntry {
role: "user" | "assistant";
content: string;
ts: number;
}
const chatHistory = new Map<number, HistoryEntry[]>();
// Clean stale histories every 10 min
setInterval(() => {
const now = Date.now();
for (const [chatId, entries] of chatHistory) {
const fresh = entries.filter(e => now - e.ts < HISTORY_TTL_MS);
if (fresh.length === 0) chatHistory.delete(chatId);
else chatHistory.set(chatId, fresh);
}
}, 10 * 60 * 1000);
function getHistory(chatId: number): HistoryEntry[] {
return chatHistory.get(chatId) ?? [];
}
function pushHistory(chatId: number, role: "user" | "assistant", content: string): void {
const entries = chatHistory.get(chatId) ?? [];
entries.push({ role, content, ts: Date.now() });
if (entries.length > MAX_HISTORY * 2) entries.splice(0, entries.length - MAX_HISTORY * 2);
chatHistory.set(chatId, entries);
}
/**
* Record a tool result in conversation history so the AI knows what happened.
*/
export function recordToolResult(chatId: number, toolName: string, resultSummary: string): void {
pushHistory(chatId, "assistant", `[Tool ${toolName} result]: ${resultSummary}`);
}
/**
* Process a natural language message through Claude and return the intent.
*/
export async function processMessage(
chatId: number,
userMessage: string,
context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] },
): Promise<AiResult> {
try {
const anthropic = getClient();
// Record user message in history
pushHistory(chatId, "user", userMessage);
const contextInfo = [
context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null,
context.userName ? `User's name: ${context.userName}` : null,
context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null,
].filter(Boolean).join(". ");
// Build message history for multi-turn context
const history = getHistory(chatId);
const messages: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const entry of history) {
// Alternate roles — Claude API requires user/assistant alternation
if (messages.length === 0 || messages[messages.length - 1]!.role !== entry.role) {
messages.push({ role: entry.role, content: entry.content });
} else {
// Same role consecutive — merge into the last message
messages[messages.length - 1]!.content += "\n" + entry.content;
}
}
// Ensure messages start with user and alternate
if (messages.length > 0 && messages[0]!.role !== "user") {
messages.shift();
}
const response = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 500,
system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""),
tools: TOOLS as Anthropic.Messages.Tool[],
messages,
});
// Check for tool use
for (const block of response.content) {
if (block.type === "tool_use") {
pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`);
return {
type: "tool_call",
toolCall: { name: block.name, input: block.input as Record<string, unknown> },
requiresConfirmation: CONFIRM_ACTIONS.has(block.name),
};
}
if (block.type === "text") {
pushHistory(chatId, "assistant", block.text);
return { type: "text", text: block.text };
}
}
return { type: "text", text: "I'm not sure how to help with that." };
} catch (err) {
log.error("telegram-ai", { error: err instanceof Error ? err.message : String(err) });
return { type: "error", text: "AI processing failed. Try a /command instead." };
}
}
/**
* Format a tool call as a human-readable confirmation message for Telegram.
*/
export function formatConfirmation(toolCall: AiToolCall): string {
const { name, input } = toolCall;
switch (name) {
case "send_message":
return `📤 <b>Send message to ${escHtml(String(input.to))}:</b>\n\n"${escHtml(String(input.message))}"\n\nPriority: ${input.priority ?? "next"}`;
case "create_mesh":
return `🔧 <b>Create mesh:</b>\n\nName: ${escHtml(String(input.name))}`;
case "share_mesh":
return input.email
? `📧 <b>Send invite to:</b>\n\n${escHtml(String(input.email))}`
: `🔗 <b>Generate invite link</b>`;
case "set_state":
return `📝 <b>Set state:</b>\n\n<code>${escHtml(String(input.key))}</code> = <code>${escHtml(String(input.value))}</code>`;
case "remember":
return `💾 <b>Remember:</b>\n\n"${escHtml(String(input.content))}"${input.tags ? `\nTags: ${(input.tags as string[]).join(", ")}` : ""}`;
default:
return `⚙️ <b>${escHtml(name)}:</b>\n\n<pre>${escHtml(JSON.stringify(input, null, 2))}</pre>`;
}
}
/**
* Format a tool result as a Telegram reply.
*/
export function formatResult(toolName: string, result: unknown): string {
switch (toolName) {
case "send_message":
return "✅ Message sent.";
case "list_peers": {
const peers = result as Array<{ displayName: string; status: string; summary?: string }>;
if (!peers || peers.length === 0) return "No peers online.";
return "👥 <b>Online peers:</b>\n\n" + peers.map(p => {
const icon = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : p.status === "dnd" ? "🔴" : "⚪";
return `${icon} <b>${escHtml(p.displayName)}</b>${p.summary ? `${escHtml(p.summary)}` : ""}`;
}).join("\n");
}
case "list_meshes": {
const meshes = result as Array<{ slug: string; peers: number }>;
if (!meshes || meshes.length === 0) return "No meshes connected. Use /connect to add one.";
return "🔗 <b>Connected meshes:</b>\n\n" + meshes.map(m =>
`• <b>${escHtml(m.slug)}</b> — ${m.peers} peer${m.peers !== 1 ? "s" : ""} online`
).join("\n");
}
case "recall": {
const memories = result as Array<{ content: string; tags: string[] }>;
if (!memories || memories.length === 0) return "No memories found.";
return "🧠 <b>Memories:</b>\n\n" + memories.map(m =>
`${escHtml(m.content)}${m.tags.length ? ` <i>[${m.tags.join(", ")}]</i>` : ""}`
).join("\n");
}
case "get_state": {
const state = result as { key: string; value: unknown } | null;
if (!state) return "Key not found.";
return `📊 <code>${escHtml(state.key)}</code> = <code>${escHtml(String(state.value))}</code>`;
}
case "remember":
return "💾 Remembered.";
case "set_state":
return "📝 State updated.";
case "create_mesh":
return "✅ Mesh created.";
case "share_mesh":
return typeof result === "string" ? `🔗 Invite: ${result}` : "✅ Invite sent.";
case "list_services": {
const services = result as Array<{ name: string; type: string; tools: number; status: string }>;
if (!services || services.length === 0) return "No services deployed in this mesh.";
return "⚙️ <b>Mesh services:</b>\n\n" + services.map(s =>
`• <b>${escHtml(s.name)}</b> (${s.type}) — ${s.tools} tool${s.tools !== 1 ? "s" : ""} [${s.status}]`
).join("\n");
}
case "list_commands":
return `📋 <b>Available commands:</b>
/connect — connect to a mesh
/disconnect — disconnect from mesh
/peers — list online peers
/meshes — list connected meshes
/dm @Name message — send direct message
/broadcast message — send to all peers
/status — connection status
/help — show help
Or just type naturally:
• "who's online?"
• "tell Nedas the API is ready"
• "list my meshes"
• "what services are available?"`;
default:
return `✅ Done: ${JSON.stringify(result)}`;
}
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export { CONFIRM_ACTIONS };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
/**
* JWT utilities for Telegram bridge connections.
*
* When a user connects their Telegram chat to a mesh, the broker generates
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
* this token to establish the connection.
*
* Pure-crypto implementation — no external JWT library.
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
*
* IMPORTANT: The JWT payload contains the member's secretKey.
* Never log the token or its decoded payload.
*/
import { createHmac } from "node:crypto";
// --- Types ---
export interface TelegramConnectPayload {
meshId: string;
meshSlug: string;
memberId: string;
pubkey: string;
secretKey: string; // ed25519 secret key — sensitive
createdBy: string; // Dashboard userId or CLI memberId
}
interface JwtClaims extends TelegramConnectPayload {
iss: string;
sub: string;
iat: number;
exp: number;
}
// --- Helpers ---
function base64url(data: string): string {
return Buffer.from(data).toString("base64url");
}
function base64urlDecode(str: string): string {
return Buffer.from(str, "base64url").toString("utf-8");
}
function sign(input: string, secret: string): string {
return createHmac("sha256", secret).update(input).digest("base64url");
}
// --- Public API ---
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
/**
* Create a signed JWT containing Telegram connect credentials.
* Expires in 15 minutes.
*/
export function generateTelegramConnectToken(
payload: TelegramConnectPayload,
secret: string,
): string {
const now = Math.floor(Date.now() / 1000);
const claims: JwtClaims = {
...payload,
iss: "claudemesh-broker",
sub: "telegram-connect",
iat: now,
exp: now + TOKEN_TTL_SECONDS,
};
const encodedPayload = base64url(JSON.stringify(claims));
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
const signature = sign(signingInput, secret);
return `${signingInput}.${signature}`;
}
/**
* Validate and decode a Telegram connect JWT.
* Returns the payload on success, or null on any failure
* (bad signature, expired, wrong subject).
*/
export function validateTelegramConnectToken(
token: string,
secret: string,
): TelegramConnectPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
// Verify signature
const signingInput = `${headerB64}.${payloadB64}`;
const expectedSignature = sign(signingInput, secret);
// Constant-time comparison to prevent timing attacks
const a = Buffer.from(signatureB64);
const b = Buffer.from(expectedSignature);
if (a.length !== b.length) return null;
const { timingSafeEqual } = require("node:crypto");
if (!timingSafeEqual(a, b)) return null;
// Verify header algorithm
const header = JSON.parse(base64urlDecode(headerB64));
if (header.alg !== "HS256") return null;
// Decode and validate claims
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
// Check subject
if (claims.sub !== "telegram-connect") return null;
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (claims.exp < now) return null;
// Check iat not in the future (30s tolerance)
if (claims.iat > now + 30) return null;
// Extract payload fields (strip JWT claims)
const {
meshId,
meshSlug,
memberId,
pubkey,
secretKey,
createdBy,
} = claims;
// Basic presence check
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
return null;
}
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
} catch {
return null;
}
}
/**
* Generate a Telegram deep link that passes the JWT as start parameter.
* Format: https://t.me/{botUsername}?start={token}
*/
export function generateDeepLink(token: string, botUsername: string): string {
return `https://t.me/${botUsername}?start=${token}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
/**
* Inbound webhook handler.
*
* External services POST JSON to `/hook/:meshId/:secret`. The broker
* verifies the secret against the mesh.webhook table, then pushes the
* payload to all connected peers in that mesh as a "webhook" push.
*/
import { eq, and } from "drizzle-orm";
import { db } from "./db";
import { meshWebhook } from "@turbostarter/db/schema/mesh";
import type { WSPushMessage } from "./types";
import { log } from "./logger";
export interface WebhookResult {
status: number;
body: { ok: boolean; delivered?: number; error?: string };
}
/**
* Look up a webhook by meshId + secret, verify it's active, then return
* the webhook name for push routing. Returns null if not found/inactive.
*/
async function findActiveWebhook(
meshId: string,
secret: string,
): Promise<{ id: string; name: string; meshId: string } | null> {
const rows = await db
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
.from(meshWebhook)
.where(
and(
eq(meshWebhook.meshId, meshId),
eq(meshWebhook.secret, secret),
eq(meshWebhook.active, true),
),
)
.limit(1);
return rows[0] ?? null;
}
/**
* Handle an inbound webhook HTTP request.
*
* @param meshId - mesh ID from the URL path
* @param secret - webhook secret from the URL path
* @param body - parsed JSON body from the request
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
* Returns the number of peers the message was delivered to.
*/
export async function handleWebhook(
meshId: string,
secret: string,
body: unknown,
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
): Promise<WebhookResult> {
try {
const webhook = await findActiveWebhook(meshId, secret);
if (!webhook) {
log.warn("webhook auth failed", { mesh_id: meshId });
return { status: 401, body: { ok: false, error: "unauthorized" } };
}
if (body === null || body === undefined || typeof body !== "object") {
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
}
const pushMsg: WSPushMessage = {
type: "push",
subtype: "webhook" as any,
event: webhook.name,
eventData: body as Record<string, unknown>,
messageId: crypto.randomUUID(),
meshId: webhook.meshId,
senderPubkey: `webhook:${webhook.name}`,
priority: "next",
nonce: "",
ciphertext: "",
createdAt: new Date().toISOString(),
};
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
log.info("webhook delivered", {
webhook_name: webhook.name,
mesh_id: webhook.meshId,
delivered,
});
return { status: 200, body: { ok: true, delivered } };
} catch (e) {
log.error("webhook handler error", {
error: e instanceof Error ? e.message : String(e),
});
return { status: 500, body: { ok: false, error: "internal error" } };
}
}

View File

@@ -0,0 +1,268 @@
/**
* v2 invite protocol — broker claim endpoint.
*
* Covers the sealed-root-key delivery flow added in
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md :
*
* - happy path: signed v2 invite claim returns a sealed root_key the
* recipient can unseal back to the mesh.rootKey column value
* - tampered signature → 400 bad_signature
* - expired invite → 410 expired
* - revoked invite → 410 revoked
* - exhausted invite (usedCount === maxUses) → 410 exhausted
* - round-trip: recipient-side crypto_box_seal_open recovers the real key
*
* Tests talk directly to claimInviteV2Core() to avoid spinning up the
* full broker HTTP server. The handler delegates to this function with
* zero extra logic, so coverage is equivalent.
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
import { eq } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "../src/db";
import { invite, mesh } from "@turbostarter/db/schema/mesh";
import { canonicalInviteV2 } from "../src/crypto";
import { claimInviteV2Core } from "../src/index";
import {
cleanupAllTestMeshes,
setupTestMesh,
type TestMesh,
} from "./helpers";
afterAll(async () => {
await cleanupAllTestMeshes();
});
beforeAll(async () => {
await sodium.ready;
});
/**
* Set a random base64url root_key on an existing test mesh. The helpers
* don't set one by default, so v2 tests prime it per-mesh here.
*/
async function primeRootKey(meshId: string): Promise<Uint8Array> {
const key = sodium.randombytes_buf(32);
const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING);
await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId));
return key;
}
/**
* Insert a signed v2 invite row. Returns the opaque short code + the
* recipient x25519 keypair the test will use to unseal.
*/
async function insertV2Invite(
m: TestMesh,
opts: {
code: string;
expiresInSec?: number;
maxUses?: number;
role?: "admin" | "member";
tamper?: boolean; // corrupt the signature
revoked?: boolean;
used?: number;
},
): Promise<{ inviteId: string; canonical: string }> {
const expiresInSec = opts.expiresInSec ?? 3600;
const expiresAt = new Date(Date.now() + expiresInSec * 1000);
const maxUses = opts.maxUses ?? 1;
const role = opts.role ?? "member";
// Insert first with a placeholder capability so we have the invite id.
const [row] = await db
.insert(invite)
.values({
meshId: m.meshId,
token: `v2-test-token-${opts.code}`,
code: opts.code,
maxUses,
usedCount: opts.used ?? 0,
role,
expiresAt,
createdBy: "test-user-integration",
version: 2,
revokedAt: opts.revoked ? new Date() : null,
})
.returning({ id: invite.id });
if (!row) throw new Error("v2 invite insert failed");
// Now compute canonical_v2 using the real invite id and sign with the
// mesh owner's ed25519 secret key.
const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
const canonical = canonicalInviteV2({
mesh_id: m.meshId,
invite_id: row.id,
expires_at: expiresAtUnix,
role,
owner_pubkey: m.ownerPubkey,
});
let signatureHex = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(m.ownerSecretKey),
),
);
if (opts.tamper) {
// Flip a single hex nibble — keeps length valid, invalidates signature.
const first = signatureHex[0] === "0" ? "1" : "0";
signatureHex = first + signatureHex.slice(1);
}
const capability = JSON.stringify({
canonical,
signature: signatureHex,
});
await db
.update(invite)
.set({ capabilityV2: capability })
.where(eq(invite.id, row.id));
return { inviteId: row.id, canonical };
}
function genRecipientX25519(): { pk: string; sk: Uint8Array } {
const kp = sodium.crypto_box_keypair();
return {
pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
sk: kp.privateKey,
};
}
describe("claimInviteV2Core — v2 invite claim", () => {
let m: TestMesh;
afterEach(async () => m && (await m.cleanup()));
test("happy path: signed v2 invite returns sealed root_key and member row", async () => {
m = await setupTestMesh("v2-ok");
const rootKeyBytes = await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
const { inviteId, canonical } = await insertV2Invite(m, { code });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.status).toBe(200);
expect(result.body.mesh_id).toBe(m.meshId);
expect(result.body.owner_pubkey).toBe(m.ownerPubkey);
expect(result.body.canonical_v2).toBe(canonical);
expect(result.body.member_id).toBeTruthy();
// Recipient unseals the sealed_root_key using its x25519 secret key.
const sealed = sodium.from_base64(
result.body.sealed_root_key,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
const recipientPkBytes = sodium.from_base64(
recipient.pk,
sodium.base64_variants.URLSAFE_NO_PADDING,
);
const opened = sodium.crypto_box_seal_open(
sealed,
recipientPkBytes,
recipient.sk,
);
expect(opened).toBeInstanceOf(Uint8Array);
expect(opened.length).toBe(32);
expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes));
// usedCount incremented and claimedByPubkey recorded.
const [updated] = await db
.select({
usedCount: invite.usedCount,
claimedByPubkey: invite.claimedByPubkey,
})
.from(invite)
.where(eq(invite.id, inviteId));
expect(updated?.usedCount).toBe(1);
expect(updated?.claimedByPubkey).toBe(recipient.pk);
});
test("tampered signature → 400 bad_signature", async () => {
m = await setupTestMesh("v2-tampered");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, tamper: true });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(400);
expect(result.body.error).toBe("bad_signature");
});
test("expired invite → 410 expired", async () => {
m = await setupTestMesh("v2-expired");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, expiresInSec: -60 });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("expired");
});
test("revoked invite → 410 revoked", async () => {
m = await setupTestMesh("v2-revoked");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, revoked: true });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("revoked");
});
test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => {
m = await setupTestMesh("v2-exhausted");
await primeRootKey(m.meshId);
const code = `c${Math.random().toString(36).slice(2, 10)}`;
await insertV2Invite(m, { code, maxUses: 1, used: 1 });
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code,
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(410);
expect(result.body.error).toBe("exhausted");
});
test("unknown code → 404 not_found", async () => {
m = await setupTestMesh("v2-404");
await primeRootKey(m.meshId);
const recipient = genRecipientX25519();
const result = await claimInviteV2Core({
code: "nonexistent",
recipientX25519PubkeyBase64url: recipient.pk,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.body.error).toBe("not_found");
});
});

View File

@@ -8,8 +8,9 @@
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
}, },
"types": ["bun-types"] "types": ["bun-types"],
"jsx": "react-jsx"
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

5
apps/cli-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.turbo/
.cache/
*.log

44
apps/cli-v2/CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Changelog
## 1.0.0-alpha.0 (2026-04-13)
### Architecture
- Complete folder restructure: `entrypoints/`, `cli/`, `commands/`, `services/` (17 feature-folders with facade pattern), `ui/`, `mcp/`, `constants/`, `types/`, `utils/`, `locales/`, `templates/`
- 212 source files, 10,900 lines
- ESM-only, Bun bundler, TypeScript strict mode
### New CLI commands
- `claudemesh register` — account creation via browser handoff
- `claudemesh login` — device-code OAuth
- `claudemesh logout` — revoke session + clear credentials
- `claudemesh whoami` — identity check with `--json` support
- `claudemesh new <name>` — create mesh from CLI (was dashboard-only)
- `claudemesh invite [email]` — generate invite from CLI (was dashboard-only)
### Ported from v1 (full feature parity)
- All 79 MCP tools
- All 85 WS message types (broker protocol unchanged)
- Welcome wizard, launch flow, install/uninstall
- Ed25519 + NaCl crypto (keypairs, crypto_box DMs, file encryption)
- Reconnect with exponential backoff
- Status priority engine, scheduled messages, URL watch
- Doctor checks, Telegram bridge connect wizard
### Security hardening (25 bugs fixed across 4 reviews)
- `execFile` instead of `exec` for browser open (command injection fix)
- ReDoS-safe pattern matching in peer file sharing
- Atomic config writes via temp file + rename
- Auth token stored with `openSync(mode: 0o600)` — no permission race
- Decryption oracle collapsed to generic error in `get_file`
- Download size limit (100MB) on file retrieval
- Path traversal protection with `realpathSync` for symlink escapes
- Callback listener double-resolve guard
- Push buffer 1MB per-message truncation
- `makeReqId` uses `crypto.randomBytes` instead of `Math.random`
- Connect guard prevents double-connect race
### Breaking changes from v0.10.x
- Flat command namespace (no `launch` subcommand, no `advanced` prefix)
- New config shape (same data, cleaner layout)
- New `--json` output format with `schema_version: "1.0"`
- New exit codes (see `constants/exit-codes.ts`)

90
apps/cli-v2/README.md Normal file
View File

@@ -0,0 +1,90 @@
# 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

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/entrypoints/cli.js";

4
apps/cli-v2/biome.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["../../biome.json"]
}

51
apps/cli-v2/build.ts Normal file
View File

@@ -0,0 +1,51 @@
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
const MAX_GZIPPED_BYTES = 1.2 * 1024 * 1024; // 1.2 MB
const result = await Bun.build({
entrypoints: [
"src/entrypoints/cli.ts",
"src/entrypoints/mcp.ts",
],
outdir: "dist/entrypoints",
target: "node",
format: "esm",
splitting: false,
sourcemap: "external",
external: [
"libsodium-wrappers",
"ws",
"@modelcontextprotocol/sdk",
],
});
if (!result.success) {
console.error("Build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
for (const output of result.outputs) {
const raw = statSync(output.path).size;
const gz = gzipSync(await Bun.file(output.path).arrayBuffer()).byteLength;
const label = output.path.replace(process.cwd() + "/", "");
console.log(` ${label} ${(raw / 1024).toFixed(0)} KB (${(gz / 1024).toFixed(0)} KB gzipped)`);
if (gz > MAX_GZIPPED_BYTES) {
console.error(`\n ERROR: ${label} exceeds 1.2 MB gzipped ceiling (${(gz / 1024).toFixed(0)} KB)`);
process.exit(1);
}
}
const { chmodSync, readFileSync, writeFileSync } = await import("node:fs");
const cliPath = "dist/entrypoints/cli.js";
const cliContent = readFileSync(cliPath, "utf-8");
if (!cliContent.startsWith("#!")) {
writeFileSync(cliPath, "#!/usr/bin/env node\n" + cliContent);
}
chmodSync(cliPath, 0o755);
console.log("\nBuild complete.");

69
apps/cli-v2/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "claudemesh-cli-v2",
"version": "1.0.0-alpha.30",
"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

@@ -0,0 +1,49 @@
/**
* Cross-platform single-binary compile.
*
* Run: bun run scripts/build-binaries.ts
* Output: dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}{.exe}
*
* Each binary bundles the CLI + Bun runtime, no Node required.
* Current caveat: native deps like libsodium-wrappers ship as JS+wasm
* so they work. `ws` falls back to its JS polyfill when uws isn't present.
*
* Intended for CI — GitHub Releases publish → install.sh / Homebrew
* pull the right tarball per platform.
*/
import { mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
const TARGETS: Array<{ name: string; target: string; ext: string }> = [
{ name: "darwin-x64", target: "bun-darwin-x64", ext: "" },
{ name: "darwin-arm64", target: "bun-darwin-arm64", ext: "" },
{ name: "linux-x64", target: "bun-linux-x64", ext: "" },
{ name: "linux-arm64", target: "bun-linux-arm64", ext: "" },
{ name: "windows-x64", target: "bun-windows-x64", ext: ".exe" },
];
mkdirSync("dist/bin", { recursive: true });
for (const { name, target, ext } of TARGETS) {
const out = `dist/bin/claudemesh-${name}${ext}`;
console.log(`${out}`);
const res = spawnSync(
"bun",
[
"build",
"--compile",
"--minify",
`--target=${target}`,
"src/entrypoints/cli.ts",
"--outfile",
out,
],
{ stdio: "inherit" },
);
if (res.status !== 0) {
console.error(` failed: ${name}`);
process.exit(1);
}
}
console.log("\nBinaries built in dist/bin/");

View File

@@ -0,0 +1,30 @@
import { defineCommand, runMain } from "citty";
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
export function parseArgv(argv: string[]): ParsedArgs {
const args = argv.slice(2);
const flags: Record<string, string | boolean | undefined> = {};
const positionals: string[] = [];
let command = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg.startsWith("--")) {
const key = arg.slice(2);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (arg.startsWith("-") && arg.length === 2) {
const key = arg.slice(1);
const next = args[i + 1];
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
} else if (!command) {
command = arg;
} else {
positionals.push(arg);
}
}
return { command, positionals, flags };
}
export { defineCommand, runMain };

View File

@@ -0,0 +1,7 @@
import { EXIT } from "~/constants/exit-codes.js";
const cleanupHooks: Array<() => void> = [];
export function onExit(fn: () => void): void { cleanupHooks.push(fn); }
export function exit(code: number = EXIT.SUCCESS): never {
for (const fn of cleanupHooks) { try { fn(); } catch {} }
process.exit(code);
}

View File

@@ -0,0 +1,12 @@
import { EXIT } from "~/constants/exit-codes.js";
import { red } from "~/ui/styles.js";
export function handleUncaughtError(err: unknown): never {
const msg = err instanceof Error ? err.message : String(err);
console.error(red("\n Fatal: " + msg + "\n"));
if (process.env.CLAUDEMESH_DEBUG === "1" && err instanceof Error && err.stack) console.error(err.stack);
process.exit(EXIT.INTERNAL_ERROR);
}
export function installErrorHandlers(): void {
process.on("uncaughtException", handleUncaughtError);
process.on("unhandledRejection", (reason) => handleUncaughtError(reason));
}

View File

@@ -0,0 +1,6 @@
import { SHOW_CURSOR } from "~/ui/styles.js";
export function installSignalHandlers(): void {
const cleanup = () => { process.stdout.write(SHOW_CURSOR); };
process.on("SIGINT", () => { cleanup(); process.exit(1); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
}

View File

@@ -0,0 +1,6 @@
import type { JoinedMesh } from "~/services/config/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderMeshList(meshes: JoinedMesh[]): string {
if (meshes.length === 0) return " No meshes joined.";
return meshes.map((m, i) => " " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.meshId.slice(0, 8) + "\u2026)")).join("\n");
}

View File

@@ -0,0 +1,11 @@
import type { PeerInfo } from "~/services/broker/facade.js";
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
const S: Record<string, (s: string) => string> = { idle: green, working: yellow, dnd: red };
export function renderPeers(peers: PeerInfo[], meshSlug: string): string {
if (peers.length === 0) return " No peers online in " + meshSlug + ".";
return peers.map(p => {
const icon = (S[p.status] ?? dim)("\u25CF");
const summary = p.summary ? dim(" \u2014 " + p.summary) : "";
return " " + icon + " " + bold(p.displayName) + summary;
}).join("\n");
}

View File

@@ -0,0 +1,3 @@
import { VERSION } from "~/constants/urls.js";
import { boldOrange } from "~/ui/styles.js";
export function renderVersion(): string { return " " + boldOrange("claudemesh") + " v" + VERSION; }

View File

@@ -0,0 +1,11 @@
import type { WhoAmIResult } from "~/services/auth/facade.js";
import { bold, dim } from "~/ui/styles.js";
export function renderWhoAmI(result: WhoAmIResult): string {
if (!result.signed_in) return " Not signed in.";
const lines = [
" Signed in as " + bold(result.user!.display_name) + " (" + result.user!.email + ")",
" Token source: " + result.token_source + " " + dim("(~/.claudemesh/auth.json)"),
];
if (result.meshes) lines.push(" Meshes: " + result.meshes.owned + " owned, " + result.meshes.guest + " guest");
return lines.join("\n");
}

View File

@@ -0,0 +1,7 @@
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
export function print(msg: string): void { process.stdout.write(msg + "\n"); }
export function printErr(msg: string): void { process.stderr.write(msg + "\n"); }
export function isQuiet(): boolean { return process.argv.includes("-q") || process.argv.includes("--quiet"); }
export function isVerbose(): boolean { return process.argv.includes("-v") || process.argv.includes("--verbose"); }
export function isJson(): boolean { return process.argv.includes("--json"); }
export function isTty(): boolean { return !!isTTY; }

View File

@@ -0,0 +1,4 @@
export function jsonOutput<T>(data: T): string {
return JSON.stringify({ schema_version: "1.0", ...data }, null, 2);
}
export function writeJson<T>(data: T): void { console.log(jsonOutput(data)); }

View File

@@ -0,0 +1,11 @@
import { checkForUpdate } from "~/services/update/facade.js";
import { dim, yellow } from "~/ui/styles.js";
export async function showUpdateNotice(currentVersion: string): Promise<void> {
try {
const info = await checkForUpdate(currentVersion);
if (info.updateAvailable) {
console.error(yellow(" Update available: " + info.current + " \u2192 " + info.latest));
console.error(dim(" Run: npm i -g claudemesh-cli"));
}
} catch {}
}

View File

@@ -0,0 +1,147 @@
/**
* `claudemesh backup` — encrypt the local config and save a portable
* recovery file. Restore later with `claudemesh restore <file>` on any
* machine to recover mesh memberships.
*
* Crypto:
* - Argon2id KDF over a user passphrase → 32-byte key
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
* passphrase is still workable but brute-force remains expensive)
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
*
* Output: a single `.claudemesh-backup` file the user can store in
* 1Password, email to themselves, etc. Zero server involvement.
*
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
* passphrases shorter than 12 characters.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createInterface } from "node:readline";
import { getConfigPath } from "~/services/config/facade.js";
import { ensureSodium } from "~/services/crypto/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
const MAGIC = Buffer.from("CMB1", "utf-8");
function readHidden(prompt: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(prompt);
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// Node readline doesn't mask by default. Turn off echo manually.
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const wasRaw = Boolean(stdin.isRaw);
if (stdin.isTTY) {
stdin.setRawMode(true);
}
let buf = "";
const onData = (chunk: Buffer): void => {
const ch = chunk.toString("utf-8");
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
stdin.removeListener("data", onData);
if (stdin.isTTY) stdin.setRawMode(wasRaw);
process.stdout.write("\n");
rl.close();
resolve(buf);
return;
}
if (ch === "\u0003") { // ctrl-c
process.exit(130);
}
if (ch === "\u007f") { // backspace
buf = buf.slice(0, -1);
return;
}
buf += ch;
};
stdin.on("data", onData);
});
}
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
return s.crypto_pwhash(
32,
pass,
salt,
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
s.crypto_pwhash_ALG_ARGON2ID13,
);
}
export async function runBackup(outPath: string | undefined): Promise<number> {
const configPath = getConfigPath();
if (!existsSync(configPath)) {
console.error(" No config found — nothing to back up. Join a mesh first.");
return EXIT.NOT_FOUND;
}
const plaintext = readFileSync(configPath);
const pass = await readHidden(" Passphrase (min 12 chars): ");
if (pass.length < 12) {
console.error(" ✗ Passphrase too short.");
return EXIT.INVALID_ARGS;
}
const confirm = await readHidden(" Confirm passphrase: ");
if (confirm !== pass) {
console.error(" ✗ Passphrases did not match.");
return EXIT.INVALID_ARGS;
}
const s = await ensureSodium();
const salt = Buffer.from(s.randombytes_buf(16));
const nonce = Buffer.from(s.randombytes_buf(24));
const key = await deriveKey(pass, salt, s);
const ciphertext = Buffer.from(
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
);
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
writeFileSync(file, blob, { mode: 0o600 });
console.log(`\n ✓ Backup saved: ${file}`);
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
return EXIT.SUCCESS;
}
export async function runRestore(inPath: string | undefined): Promise<number> {
if (!inPath) {
console.error(" Usage: claudemesh restore <backup-file>");
return EXIT.INVALID_ARGS;
}
if (!existsSync(inPath)) {
console.error(` ✗ File not found: ${inPath}`);
return EXIT.NOT_FOUND;
}
const blob = readFileSync(inPath);
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
console.error(" ✗ Not a claudemesh backup file (bad magic).");
return EXIT.INVALID_ARGS;
}
const salt = blob.subarray(4, 20);
const nonce = blob.subarray(20, 44);
const ciphertext = blob.subarray(44);
const pass = await readHidden(" Passphrase: ");
const s = await ensureSodium();
const key = await deriveKey(pass, Buffer.from(salt), s);
let plaintext: Uint8Array;
try {
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
} catch {
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
return EXIT.INTERNAL_ERROR;
}
const configPath = getConfigPath();
if (existsSync(configPath)) {
const backupOld = `${configPath}.before-restore.${Date.now()}`;
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
console.log(` ↻ Existing config saved to ${backupOld}`);
}
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
console.log(`\n ✓ Config restored to ${configPath}`);
console.log(" Run `claudemesh list` to verify your meshes.\n");
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,122 @@
/**
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
*
* Users pipe it into their shell's completion system:
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
*/
import { EXIT } from "~/constants/exit-codes.js";
const COMMANDS = [
"create", "new", "join", "add", "launch", "connect", "disconnect",
"list", "ls", "delete", "rm", "rename", "share", "invite",
"peers", "send", "inbox", "state", "info",
"remember", "recall", "remind", "profile", "status",
"login", "register", "logout", "whoami",
"install", "uninstall", "doctor", "sync",
"completions", "verify", "url-handler",
"help",
];
const FLAGS = [
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
];
function bash(): string {
return `# claudemesh bash completion
_claudemesh_complete() {
local cur prev words cword
_init_completion || return
local commands="${COMMANDS.join(" ")}"
local flags="${FLAGS.join(" ")}"
if [[ \${cword} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
case "\${cur}" in
-*)
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
return 0
;;
esac
}
complete -F _claudemesh_complete claudemesh
`;
}
function zsh(): string {
return `#compdef claudemesh
# claudemesh zsh completion
_claudemesh() {
local -a commands flags
commands=(
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
)
flags=(
${FLAGS.map((f) => ` '${f}'`).join("\n")}
)
if (( CURRENT == 2 )); then
_describe 'command' commands
return
fi
case $words[2] in
join|add|launch|connect)
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
;;
share|invite)
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
;;
*)
_values 'flag' $flags
;;
esac
}
compdef _claudemesh claudemesh
`;
}
function fish(): string {
const cmdLines = COMMANDS.map(
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
).join("\n");
return `# claudemesh fish completion
${cmdLines}
complete -c claudemesh -l help -s h -d 'show help'
complete -c claudemesh -l version -s V -d 'show version'
complete -c claudemesh -l json -d 'machine-readable output'
complete -c claudemesh -l yes -s y -d 'skip confirmations'
complete -c claudemesh -l mesh -d 'mesh slug'
complete -c claudemesh -l name -d 'display name'
complete -c claudemesh -l join -d 'invite url'
`;
}
export async function runCompletions(shell: string | undefined): Promise<number> {
if (!shell) {
console.error("Usage: claudemesh completions <bash|zsh|fish>");
return EXIT.INVALID_ARGS;
}
switch (shell.toLowerCase()) {
case "bash":
process.stdout.write(bash());
return EXIT.SUCCESS;
case "zsh":
process.stdout.write(zsh());
return EXIT.SUCCESS;
case "fish":
process.stdout.write(fish());
return EXIT.SUCCESS;
default:
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
return EXIT.INVALID_ARGS;
}
}

View File

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,81 @@
/**
* 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

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

View File

@@ -0,0 +1,281 @@
/**
* `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

@@ -0,0 +1,176 @@
/**
* `claudemesh grant / revoke / grants / block` — per-peer capability grants.
*
* Claudemesh's original threat model treats all mesh members as trusted, so
* every peer can send you messages and read your summary. These commands add
* a local filter: the broker still forwards messages, but the MCP server
* drops disallowed kinds before they reach Claude Code.
*
* Grants are stored in ~/.claudemesh/grants.json keyed on
* (mesh_slug, peer_pubkey). Default = read + dm (backwards-compatible).
* The `block` command sets an empty grant set (equivalent to revoke-all).
*
* Full grant-enforcement on the broker side is out of scope for this pass
* — see .artifacts/specs/2026-04-15-per-peer-capabilities.md for the
* server-side rollout plan. Client-side enforcement handles the 80% case
* (spam / noise) without needing a broker migration.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { readConfig } from "~/services/config/facade.js";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
export type Capability =
| "read"
| "dm"
| "broadcast"
| "state-read"
| "state-write"
| "file-read";
const ALL_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read", "state-write", "file-read"];
const DEFAULT_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read"];
type GrantStore = Record<string, Record<string, Capability[]>>; // mesh → pubkey → caps
const GRANT_FILE = join(homedir(), ".claudemesh", "grants.json");
function readGrants(): GrantStore {
if (!existsSync(GRANT_FILE)) return {};
try {
return JSON.parse(readFileSync(GRANT_FILE, "utf-8")) as GrantStore;
} catch {
return {};
}
}
function writeGrants(g: GrantStore): void {
const dir = join(homedir(), ".claudemesh");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(GRANT_FILE, JSON.stringify(g, null, 2), { mode: 0o600 });
}
function resolveCaps(input: string[]): Capability[] {
if (input.includes("all")) return [...ALL_CAPS];
return input.filter((c): c is Capability => (ALL_CAPS as string[]).includes(c));
}
async function resolvePeer(meshSlug: string, name: string): Promise<{ displayName: string; pubkey: string } | null> {
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const match = peers.find((p) => p.displayName === name || p.pubkey === name || p.pubkey.startsWith(name));
return match ? { displayName: match.displayName, pubkey: match.pubkey } : null;
});
}
function pickMesh(slug?: string): string | null {
const cfg = readConfig();
if (slug) return cfg.meshes.find((m) => m.slug === slug) ? slug : null;
return cfg.meshes[0]?.slug ?? null;
}
export async function runGrant(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh grant <peer> <capability...>");
render.hint(`Capabilities: ${ALL_CAPS.join(", ")}, all`);
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh — join one first."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = resolveCaps(caps);
if (wanted.length === 0) { render.err(`Unknown capabilities: ${caps.join(", ")}`); return EXIT.INVALID_ARGS; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const merged = Array.from(new Set([...existing, ...wanted]));
meshGrants[resolved.pubkey] = merged;
store[mesh] = meshGrants;
writeGrants(store);
render.ok(`Granted ${wanted.join(", ")} to ${resolved.displayName} on ${mesh}.`);
render.kv([["now", merged.join(", ")]]);
return EXIT.SUCCESS;
}
export async function runRevoke(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
if (!peer || caps.length === 0) {
render.err("Usage: claudemesh revoke <peer> <capability...>");
return EXIT.INVALID_ARGS;
}
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const wanted = caps.includes("all") ? ALL_CAPS.slice() : resolveCaps(caps);
const store = readGrants();
const meshGrants = store[mesh] ?? {};
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
const after = existing.filter((c) => !wanted.includes(c));
meshGrants[resolved.pubkey] = after;
store[mesh] = meshGrants;
writeGrants(store);
render.ok(`Revoked ${wanted.join(", ")} from ${resolved.displayName} on ${mesh}.`);
render.kv([["now", after.length ? after.join(", ") : "(none)"]]);
return EXIT.SUCCESS;
}
export async function runBlock(peer: string | undefined, opts: { mesh?: string } = {}): Promise<number> {
if (!peer) { render.err("Usage: claudemesh block <peer>"); return EXIT.INVALID_ARGS; }
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const resolved = await resolvePeer(mesh, peer);
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
meshGrants[resolved.pubkey] = [];
store[mesh] = meshGrants;
writeGrants(store);
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
return EXIT.SUCCESS;
}
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
const mesh = pickMesh(opts.mesh);
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
const store = readGrants();
const meshGrants = store[mesh] ?? {};
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
return EXIT.SUCCESS;
}
render.section(`grants on ${mesh}`);
const peerPubkeys = Object.keys(meshGrants);
if (peerPubkeys.length === 0) {
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
return EXIT.SUCCESS;
}
await withMesh({ meshSlug: mesh }, async (client) => {
const peers = await client.listPeers();
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
for (const [pk, caps] of Object.entries(meshGrants)) {
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}`;
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
}
});
return EXIT.SUCCESS;
}
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
const store = readGrants();
const entry = store[meshSlug]?.[peerPubkey];
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
return entry.includes(cap);
}

View File

@@ -0,0 +1,123 @@
/**
* `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

@@ -0,0 +1,60 @@
/**
* `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

@@ -0,0 +1,29 @@
export { runJoin } from "./join.js";
export { newMesh } from "./new.js";
export { invite } from "./invite.js";
export { runList } from "./list.js";
export { rename } from "./rename.js";
export { runLeave } from "./leave.js";
export { runPeers } from "./peers.js";
export { runSend } from "./send.js";
export { runInbox } from "./inbox.js";
export { runStateGet, runStateSet } from "./state.js";
export { runInfo } from "./info.js";
export { remember } from "./remember.js";
export { recall } from "./recall.js";
export { runRemind } from "./remind.js";
export { runProfile } from "./profile.js";
export { runStatus } from "./status.js";
export { runDoctor } from "./doctor.js";
export { register } from "./register.js";
export { login } from "./login.js";
export { logout } from "./logout.js";
export { whoami } from "./whoami.js";
export { runInstall } from "./install.js";
export { uninstall } from "./uninstall.js";
export { runSync } from "./sync.js";
export { runWelcome } from "./welcome.js";
export { runHook } from "./hook.js";
export { runMcp } from "./mcp.js";
export { runSeedTestMesh } from "./seed-test-mesh.js";
export { withMesh } from "./connect.js";

View File

@@ -0,0 +1,58 @@
/**
* `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

@@ -0,0 +1,564 @@
/**
* `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

@@ -0,0 +1,96 @@
import { createInterface } from "node:readline";
import { getStoredToken } from "~/services/auth/facade.js";
import { generateInvite } from "~/services/invite/generate.js";
import { readConfig } from "~/services/config/facade.js";
import { writeClipboard } from "~/services/clipboard/facade.js";
import { green, bold, dim, icons } from "~/ui/styles.js";
import { renderQrAsync } from "~/ui/qr.js";
import { EXIT } from "~/constants/exit-codes.js";
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
});
}
export async function invite(
email?: string,
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
): Promise<number> {
const auth = getStoredToken();
if (!auth) {
console.error(" Not signed in. Run `claudemesh login` first.");
return EXIT.AUTH_FAILED;
}
const config = readConfig();
if (config.meshes.length === 0) {
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
return EXIT.NOT_FOUND;
}
// Resolve which mesh to share
let meshSlug = opts.mesh;
if (!meshSlug) {
if (config.meshes.length === 1) {
meshSlug = config.meshes[0]!.slug;
} else {
// Show picker
console.log("\n Select mesh to share:\n");
config.meshes.forEach((m, i) => {
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
});
console.log("");
const choice = await prompt(" Choice [1]: ") || "1";
const idx = parseInt(choice, 10) - 1;
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
}
}
try {
const result = await generateInvite(meshSlug, {
email,
expires_in: opts.expires ?? "7d",
max_uses: opts.uses,
role: opts.role,
});
const copied = writeClipboard(result.url);
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
} else {
if (email) {
if (result.emailed) {
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
} else {
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
console.log(` ${dim("Share the link manually:")}`);
console.log(` ${result.url}`);
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
}
} else {
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
console.log(` ${result.url}`);
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
const qr = await renderQrAsync(result.url, { small: true });
console.log("");
for (const line of qr.split("\n")) console.log(` ${line}`);
}
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
}
return EXIT.SUCCESS;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("403") || msg.includes("permission")) {
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
} else {
console.error(` ${icons.cross} Failed: ${msg}`);
}
return EXIT.INTERNAL_ERROR;
}
}

View File

@@ -0,0 +1,193 @@
/**
* `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

@@ -0,0 +1,823 @@
// @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

@@ -0,0 +1,25 @@
/**
* `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

@@ -0,0 +1,104 @@
/**
* `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

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

View File

@@ -0,0 +1,22 @@
import { logout as doLogout } from "~/services/auth/facade.js";
import { green, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function logout(): Promise<number> {
try {
const { revoked } = await doLogout();
if (revoked) {
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
} else {
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
}
console.log(` ${green(icons.check)} Removed local credentials.`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
return EXIT.AUTH_FAILED;
}
}

View File

@@ -0,0 +1,9 @@
import { startMcpServer } from "~/mcp/server.js";
export async function runMcp(): Promise<never> {
await startMcpServer();
await new Promise(() => {});
process.exit(0);
}
export { runMcp as _stub };

View File

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

View File

@@ -0,0 +1,82 @@
/**
* `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";
export interface PeersFlags {
mesh?: string;
json?: boolean;
}
export async function runPeers(flags: PeersFlags): 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 green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
const config = readConfig();
// If --mesh specified, show only that one. Otherwise show all.
const slugs = flags.mesh
? [flags.mesh]
: config.meshes.map(m => m.slug);
if (slugs.length === 0) {
console.error("No meshes joined. Run `claudemesh join <url>` first.");
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;
}
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
console.log("");
if (peers.length === 0) {
console.log(dim(" No peers connected."));
} else {
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 statusIcon = 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 cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
const summary = p.summary ? dim(` ${p.summary}`) : "";
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
if (cwdStr) console.log(` ${cwdStr}`);
}
}
console.log("");
});
} catch (e) {
console.error(dim(` Could not connect to ${slug}: ${e instanceof Error ? e.message : String(e)}`));
console.log("");
}
}
if (flags.json) {
console.log(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2));
}
}

View File

@@ -0,0 +1,114 @@
/**
* `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

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

View File

@@ -0,0 +1,8 @@
import { login } from "./login.js";
// Register and login use the same device-code flow.
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
// which has a "Don't have an account? Register" link.
export async function register(): Promise<number> {
return login();
}

View File

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

View File

@@ -0,0 +1,142 @@
/**
* `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

@@ -0,0 +1,14 @@
import { rename as renameMesh } from "~/services/mesh/facade.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function rename(slug: string, newName: string): Promise<number> {
try {
await renameMesh(slug, newName);
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
return EXIT.SUCCESS;
} catch (err) {
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
return EXIT.INTERNAL_ERROR;
}
}

View File

@@ -0,0 +1,44 @@
/**
* `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

@@ -0,0 +1,51 @@
/**
* `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

@@ -0,0 +1,75 @@
/**
* `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

@@ -0,0 +1,69 @@
/**
* `claudemesh status-line` — one-line renderer for Claude Code's
* `statusLine` setting.
*
* Must be FAST (Claude Code polls it between every turn) — zero network
* I/O. Reads only local config + a peer-state cache maintained by the
* MCP server (~/.claudemesh/peer-cache.json, updated on every
* list_peers call).
*
* Output format:
* ◇ <mesh> · <online>/<total> peers · <you>
* or:
* ◇ claudemesh (not joined)
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
interface PeerCacheEntry {
total: number;
online: number;
updatedAt: string;
you?: string;
}
type PeerCache = Record<string, PeerCacheEntry>;
export async function runStatusLine(): Promise<number> {
try {
const config = readConfig();
if (config.meshes.length === 0) {
process.stdout.write("◇ claudemesh (not joined)");
return EXIT.SUCCESS;
}
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
let cache: PeerCache = {};
if (existsSync(cachePath)) {
try {
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
} catch {
// corrupt — ignore
}
}
// Pick the most-recently-used mesh if multiple.
const pick = config.meshes[0]!;
const entry = cache[pick.slug];
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
const fresh = age < 60_000; // < 1 min = live
if (entry && fresh) {
const you = entry.you ? ` · ${entry.you}` : "";
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} online${you}`);
} else if (entry) {
process.stdout.write(`${pick.slug} · ${entry.online}/${entry.total} (stale)`);
} else {
process.stdout.write(`${pick.slug} · idle`);
}
return EXIT.SUCCESS;
} catch {
// Never break the status line — just print nothing.
return EXIT.SUCCESS;
}
}

View File

@@ -0,0 +1,103 @@
/**
* `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";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
export async function runStatus(): 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 status (v${VERSION})`);
console.log("─".repeat(60));
const configPath = getConfigPath();
let configPerms = "missing";
if (existsSync(configPath)) {
const st = statSync(configPath);
const mode = (st.mode & 0o777).toString(8).padStart(4, "0");
configPerms = mode === "0600" ? `${mode}` : `${mode} ⚠ (expected 0600)`;
}
console.log(`Config: ${configPath} (${configPerms})`);
const config = readConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
process.exit(0);
}
console.log("");
console.log(`Meshes (${config.meshes.length}):`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}`);
const probe = await probeBroker(m.brokerUrl);
results.push({
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
if (probe.ok) {
console.log(green("reachable"));
} else {
console.log(red(`unreachable (${probe.error})`));
}
}
console.log("");
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
}
const allOk = results.every((r) => r.reachable);
console.log("");
if (allOk) {
console.log(green("All meshes reachable."));
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`));
process.exit(1);
}
}

View File

@@ -0,0 +1,89 @@
/**
* `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

@@ -0,0 +1,228 @@
/**
* `claudemesh test` — integration test battery against live broker.
*
* Creates a temporary mesh, runs all operations, verifies results,
* then cleans up. Safe to run repeatedly.
*/
import { getStoredToken } from "~/services/auth/facade.js";
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
import { readConfig } from "~/services/config/facade.js";
import { request } from "~/services/api/facade.js";
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
import { BrokerClient } from "~/services/broker/facade.js";
import { URLS } from "~/constants/urls.js";
import { runAllChecks } from "~/services/health/facade.js";
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
interface TestResult {
name: string;
ok: boolean;
detail: string;
ms: number;
}
const results: TestResult[] = [];
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
const start = Date.now();
try {
const detail = await fn();
results.push({ name, ok: true, detail, ms: Date.now() - start });
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
return true;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
results.push({ name, ok: false, detail, ms: Date.now() - start });
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
return false;
}
}
export async function runTest(): Promise<number> {
const started = Date.now();
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
console.log("");
console.log(` ${bold("claudemesh integration test")}`);
console.log(` ${dim("─".repeat(40))}`);
console.log("");
// --- Auth ---
const auth = getStoredToken();
if (!auth) {
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
return EXIT.AUTH_FAILED;
}
let userId = "";
try {
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
userId = payload.sub ?? "";
} catch {}
await run("auth", async () => {
if (!userId) throw new Error("invalid token");
return `signed in as ${auth.user.display_name || auth.user.email}`;
});
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
{
const checks = runAllChecks();
const failed = checks.filter(c => !c.ok);
if (failed.length > 0) {
const warns = failed.map(c => c.name).join(", ");
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
} else {
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
}
}
// --- Crypto ---
await run("crypto", async () => {
const kp = await generateKeypair();
const sig = await sign("test-message", kp.secretKey);
const valid = await verify("test-message", sig, kp.publicKey);
if (!valid) throw new Error("signature verification failed");
const tampered = await verify("tampered", sig, kp.publicKey);
if (tampered) throw new Error("tampered message should not verify");
return "keypair + sign + verify round-trip";
});
// --- Mesh create ---
let meshId = "";
const createOk = await run("create", async () => {
const result = await createMesh(meshSlug);
meshId = result.id;
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
});
if (!createOk) {
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
return EXIT.INTERNAL_ERROR;
}
// --- List ---
await run("list", async () => {
const config = readConfig();
const found = config.meshes.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not in local config");
return `found ${meshSlug} in local config`;
});
// --- Server list ---
await run("server list", async () => {
const res = await request<{ meshes: Array<{ slug: string }> }>({
path: `/cli/meshes?user_id=${userId}`,
baseUrl: BROKER_HTTP,
});
const found = res.meshes?.find(m => m.slug === meshSlug);
if (!found) throw new Error("mesh not on server");
return `found ${meshSlug} on server (${res.meshes.length} total)`;
});
// --- Connect (broker WS) ---
const config = readConfig();
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
let client: BrokerClient | null = null;
if (meshConfig) {
await run("connect", async () => {
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
await client.connect();
if (client.status !== "open") throw new Error("status: " + client.status);
return "broker connected, hello_ack received";
});
// --- Peers ---
if (client) {
await run("peers", async () => {
const peers = await client!.listPeers();
return `${peers.length} peer(s) online`;
});
// --- Send ---
await run("send", async () => {
const result = await client!.send("*", "test-battery-ping", "low");
if (!result.ok) throw new Error(result.error ?? "send failed");
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
});
// --- Remember ---
let memoryId: string | null = null;
await run("remember", async () => {
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
if (!memoryId) throw new Error("no memory ID returned");
return `stored (${memoryId.slice(0, 8)}…)`;
});
// --- Recall (postgres full-text search) ---
await run("recall", async () => {
await new Promise(r => setTimeout(r, 500));
const memories = await client!.recall("integration test battery");
if (memories.length === 0) throw new Error("no memories found");
return `${memories.length} result(s)`;
});
// --- State ---
const stateVal = "test-value-" + Date.now();
await run("state set", async () => {
await client!.setState("test-e2e-key", stateVal);
return "key written";
});
await run("state get", async () => {
await new Promise(r => setTimeout(r, 500));
const result = await client!.getState("test-e2e-key");
if (!result) throw new Error("key not found");
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
return `read back: ${String(result.value).slice(0, 20)}`;
});
// --- Clean up memory ---
if (memoryId) {
await run("forget", async () => {
await client!.forget(memoryId!);
return "memory cleaned up";
});
}
// --- Disconnect ---
await run("disconnect", async () => {
client!.close();
return "connection closed";
});
}
}
// --- Delete mesh ---
await run("delete", async () => {
// Server-side delete
await request({
path: `/cli/mesh/${meshSlug}`,
method: "DELETE",
body: { user_id: userId },
baseUrl: BROKER_HTTP,
});
leaveMesh(meshSlug);
return `deleted "${meshSlug}" from server + local`;
});
// --- Summary ---
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
const totalMs = Date.now() - started;
console.log("");
if (failed === 0) {
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
} else {
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
}
console.log("");
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
}

View File

@@ -0,0 +1,58 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { PATHS } from "~/constants/paths.js";
import { green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function uninstall(): Promise<number> {
let removed = 0;
// Remove MCP server from ~/.claude.json
if (existsSync(PATHS.CLAUDE_JSON)) {
try {
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const servers = config.mcpServers as Record<string, unknown> | undefined;
if (servers && "claudemesh" in servers) {
delete servers.claudemesh;
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
removed++;
}
} catch {}
}
// Remove only claudemesh hooks from ~/.claude/settings.json
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
try {
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
const config = JSON.parse(raw) as Record<string, unknown>;
const hooks = config.hooks as Record<string, unknown[]> | undefined;
if (hooks) {
let removedHooks = 0;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
const filtered = entries.filter((h: unknown) => {
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
return !cmd.includes("claudemesh");
});
if (filtered.length < entries.length) {
removedHooks += entries.length - filtered.length;
if (filtered.length === 0) delete hooks[event];
else hooks[event] = filtered;
}
}
if (removedHooks > 0) {
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
removed++;
}
}
} catch {}
}
if (removed === 0) {
console.log(" Nothing to remove — claudemesh was not installed.");
}
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,99 @@
/**
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
*
* Strategy:
* 1. Query npm for the latest @alpha dist-tag.
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
* npm that installed us (detected from argv[1] path walk).
* 3. Print before/after versions.
*
* For users who got the CLI via the `/install` shell flow (portable Node
* in ~/.claudemesh), we call that npm directly so nothing else on the
* system is touched.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { URLS, VERSION } from "~/constants/urls.js";
import { render } from "~/ui/render.js";
import { EXIT } from "~/constants/exit-codes.js";
async function latestAlpha(): Promise<string | null> {
try {
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
} catch {
return null;
}
}
function findNpm(): { npm: string; prefix?: string } {
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
if (existsSync(portable)) {
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
}
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
// walk up to find a sibling npm binary.
let cur = resolve(process.argv[1] ?? ".");
for (let i = 0; i < 6; i++) {
cur = dirname(cur);
const candidate = join(cur, "bin", "npm");
if (existsSync(candidate)) return { npm: candidate };
}
// Fallback to PATH.
return { npm: "npm" };
}
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
render.section("claudemesh upgrade");
render.kv([
["installed", VERSION],
["checking", "npm registry…"],
]);
const latest = await latestAlpha();
if (!latest) {
render.warn("Could not reach npm registry — skipped.");
return EXIT.SUCCESS;
}
render.kv([["latest", latest]]);
if (latest === VERSION) {
render.blank();
render.ok(`Already on latest (${latest}).`);
return EXIT.SUCCESS;
}
if (opts.check) {
render.blank();
render.warn(`Update available: ${VERSION}${latest}`);
render.hint("Run: claudemesh upgrade");
return EXIT.SUCCESS;
}
const { npm, prefix } = findNpm();
const args = ["install", "-g"];
if (prefix) args.push("--prefix", prefix);
args.push("claudemesh-cli@alpha");
render.blank();
render.info(`Updating ${VERSION}${latest}`);
render.hint(`${npm} ${args.join(" ")}`);
render.blank();
const res = spawnSync(npm, args, { stdio: "inherit" });
if (res.status !== 0) {
render.err(`npm exited with status ${res.status}`);
render.hint("Try: npm i -g claudemesh-cli@alpha");
return EXIT.INTERNAL_ERROR;
}
render.blank();
render.ok(`Upgraded to ${latest}.`);
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,178 @@
/**
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
* URL scheme handler with the OS so click-to-launch from email/web works.
*
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
* When activated, the OS opens the handler, which runs
* claudemesh https://claudemesh.com/i/<code>
* (inline join + launch path via the bare-URL dispatch in cli.ts).
*
* Platforms:
* - darwin → LSRegisterURL via a per-user .app bundle in
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
* - linux → xdg-mime default + a .desktop file in
* ~/.local/share/applications/claudemesh.desktop
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
*/
import { platform, homedir } from "node:os";
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { EXIT } from "~/constants/exit-codes.js";
function resolveClaudemeshBin(): string {
// argv[1] points to the running binary; prefer that over $PATH so we
// register the exact install the user ran.
return process.argv[1] ?? "claudemesh";
}
function installDarwin(): number {
const binPath = resolveClaudemeshBin();
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
const contents = join(appDir, "Contents");
const macOS = join(contents, "MacOS");
mkdirSync(macOS, { recursive: true });
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
<key>CFBundleName</key><string>Claudemesh</string>
<key>CFBundleExecutable</key><string>open-url</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleSignature</key><string>????</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>LSUIElement</key><true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
<key>CFBundleURLSchemes</key>
<array><string>claudemesh</string></array>
</dict>
</array>
</dict>
</plist>`;
writeFileSync(join(contents, "Info.plist"), plist);
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
// window so the user sees launch output.
const shim = `#!/bin/sh
URL="$1"
CODE=\${URL#claudemesh://}
CODE=\${CODE#i/}
CODE=\${CODE#join/}
# Open a Terminal window so the user can see claude launching
osascript <<EOF
tell application "Terminal"
activate
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
end tell
EOF
`;
const shimPath = join(macOS, "open-url");
writeFileSync(shimPath, shim);
chmodSync(shimPath, 0o755);
// Re-register with Launch Services so the scheme resolves here.
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
if (lsreg.status !== 0) {
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
}
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
console.log(` app bundle: ${appDir}`);
return EXIT.SUCCESS;
}
function installLinux(): number {
const binPath = resolveClaudemeshBin();
const appsDir = join(homedir(), ".local", "share", "applications");
mkdirSync(appsDir, { recursive: true });
const desktop = `[Desktop Entry]
Type=Application
Name=Claudemesh
Comment=Claudemesh invite handler
Exec=${binPath} %u
StartupNotify=false
Terminal=true
MimeType=x-scheme-handler/claudemesh;
NoDisplay=true
`;
const desktopPath = join(appsDir, "claudemesh.desktop");
writeFileSync(desktopPath, desktop);
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
if (xdg1.status !== 0) {
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
}
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
xdg2.status ?? 0; // best effort
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
console.log(` desktop entry: ${desktopPath}`);
return EXIT.SUCCESS;
}
function installWindows(): number {
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
const lines = [
`Windows Registry Editor Version 5.00`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
`@="URL:Claudemesh Invite"`,
`"URL Protocol"=""`,
``,
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
];
const regPath = join(homedir(), "claudemesh-handler.reg");
writeFileSync(regPath, lines.join("\r\n"));
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
if (res.status !== 0) {
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
return EXIT.INTERNAL_ERROR;
}
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
return EXIT.SUCCESS;
}
function uninstallDarwin(): number {
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
console.log(" ✓ Removed claudemesh:// handler on macOS");
return EXIT.SUCCESS;
}
function uninstallLinux(): number {
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
console.log(" ✓ Removed claudemesh:// handler on Linux");
return EXIT.SUCCESS;
}
function uninstallWindows(): number {
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
console.log(" ✓ Removed claudemesh:// handler on Windows");
return EXIT.SUCCESS;
}
export async function runUrlHandler(action: string | undefined): Promise<number> {
const act = action ?? "install";
const p = platform();
if (act === "install") {
if (p === "darwin") return installDarwin();
if (p === "linux") return installLinux();
if (p === "win32") return installWindows();
} else if (act === "uninstall" || act === "remove") {
if (p === "darwin") return uninstallDarwin();
if (p === "linux") return uninstallLinux();
if (p === "win32") return uninstallWindows();
} else {
console.error("Usage: claudemesh url-handler <install|uninstall>");
return EXIT.INVALID_ARGS;
}
console.error(`Unsupported platform: ${p}`);
return EXIT.INTERNAL_ERROR;
}

View File

@@ -0,0 +1,95 @@
/**
* `claudemesh verify [peer]` — show safety numbers for a peer.
*
* A safety number is a derived, human-readable fingerprint of the peer's
* ed25519 public key plus your own. Both parties see the same digits,
* so out-of-band comparison (call, in-person) detects MITM.
*
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
* Whatsapp pattern so users don't have to learn a new mental model.
*/
import { withMesh } from "./connect.js";
import { readConfig } from "~/services/config/facade.js";
import { EXIT } from "~/constants/exit-codes.js";
import { createHash } from "node:crypto";
function safetyNumber(myPubkey: string, peerPubkey: string): string {
const a = Buffer.from(myPubkey, "hex");
const b = Buffer.from(peerPubkey, "hex");
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
const hash = createHash("sha256").update(lo).update(hi).digest();
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
const bits: number[] = [];
for (let i = 0; i < 15; i++) {
for (let b = 7; b >= 0; b--) {
bits.push((hash[i]! >> b) & 1);
}
}
const groups: string[] = [];
for (let g = 0; g < 6; g++) {
let val = 0;
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
groups.push(String(val % 100000).padStart(5, "0"));
}
return groups.join(" ");
}
export async function runVerify(
target: string | undefined,
opts: { mesh?: string; json?: boolean } = {},
): Promise<number> {
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
const config = readConfig();
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
if (!meshSlug) {
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
return EXIT.NOT_FOUND;
}
const mesh = config.meshes.find((m) => m.slug === meshSlug);
if (!mesh) {
console.error(` Mesh "${meshSlug}" not found locally.`);
return EXIT.NOT_FOUND;
}
return await withMesh({ meshSlug }, async (client) => {
const peers = await client.listPeers();
const targets = target
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
: peers;
if (targets.length === 0) {
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
return EXIT.NOT_FOUND;
}
if (opts.json) {
console.log(JSON.stringify(targets.map((p) => ({
mesh: meshSlug,
peer: p.displayName,
pubkey: p.pubkey,
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
})), null, 2));
return EXIT.SUCCESS;
}
console.log("");
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
console.log("");
for (const p of targets) {
const sn = safetyNumber(mesh.pubkey, p.pubkey);
console.log(` ${bold(p.displayName)}`);
console.log(` ${clay(sn)}`);
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}`)}`);
console.log("");
}
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
console.log("");
return EXIT.SUCCESS;
});
}

View File

@@ -0,0 +1,72 @@
/**
* `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 };

View File

@@ -0,0 +1,26 @@
import { whoAmI } from "~/services/auth/facade.js";
import { dim, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function whoami(opts: { json?: boolean }): Promise<number> {
const result = await whoAmI();
if (opts.json) {
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
return EXIT.SUCCESS;
}
if (!result.signed_in) {
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
return EXIT.AUTH_FAILED;
}
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
if (result.meshes) {
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
}
console.log();
return EXIT.SUCCESS;
}

View File

@@ -0,0 +1,14 @@
export const EXIT = {
SUCCESS: 0,
USER_CANCELLED: 1,
AUTH_FAILED: 2,
INVALID_ARGS: 3,
NETWORK_ERROR: 4,
NOT_FOUND: 5,
ALREADY_EXISTS: 6,
PERMISSION_DENIED: 7,
INTERNAL_ERROR: 8,
CLAUDE_MISSING: 9,
} as const;
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];

View File

@@ -0,0 +1,5 @@
export { EXIT } from "./exit-codes.js";
export type { ExitCode } from "./exit-codes.js";
export { PATHS } from "./paths.js";
export { URLS } from "./urls.js";
export { TIMINGS } from "./timings.js";

View File

@@ -0,0 +1,22 @@
import { homedir } from "node:os";
import { join } from "node:path";
const home = homedir();
export const PATHS = {
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
get CONFIG_FILE() {
return join(this.CONFIG_DIR, "config.json");
},
get AUTH_FILE() {
return join(this.CONFIG_DIR, "auth.json");
},
get KEYS_DIR() {
return join(this.CONFIG_DIR, "keys");
},
get LAST_USED_FILE() {
return join(this.CONFIG_DIR, "last-used.json");
},
CLAUDE_JSON: join(home, ".claude.json"),
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
} as const;

View File

@@ -0,0 +1,11 @@
export const TIMINGS = {
DEVICE_CODE_POLL_MS: 1500,
DEVICE_CODE_TIMEOUT_MS: 5 * 60 * 1000,
WS_RECONNECT_BASE_MS: 1000,
WS_RECONNECT_MAX_MS: 30_000,
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
TELEGRAM_CONNECT_TIMEOUT_MS: 5 * 60 * 1000,
TELEGRAM_POLL_INTERVAL_MS: 2000,
API_TIMEOUT_MS: 15_000,
API_RETRY_COUNT: 2,
} as const;

View File

@@ -0,0 +1,14 @@
export const URLS = {
BROKER: process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
API_BASE: process.env.CLAUDEMESH_API_URL ?? "https://claudemesh.com",
DASHBOARD: "https://claudemesh.com/dashboard",
NPM_REGISTRY: "https://registry.npmjs.org/claudemesh-cli",
} as const;
export const VERSION = "1.0.0-alpha.27";
export const env = {
CLAUDEMESH_BROKER_URL: URLS.BROKER,
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
};

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env node
import { parseArgv } from "~/cli/argv.js";
import { installSignalHandlers } from "~/cli/handlers/signal.js";
import { installErrorHandlers } from "~/cli/handlers/error.js";
import { showUpdateNotice } from "~/cli/update-notice.js";
import { VERSION } from "~/constants/urls.js";
import { EXIT } from "~/constants/exit-codes.js";
import { renderVersion } from "~/cli/output/version.js";
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
installSignalHandlers();
installErrorHandlers();
const { command, positionals, flags } = parseArgv(process.argv);
const HELP = `
claudemesh — peer mesh for Claude Code sessions
${VERSION}
USAGE
claudemesh auto-connect to your mesh
claudemesh <invite-url> join a mesh, then launch
claudemesh launch --name <n> --join <url> join + launch in one step
Mesh
claudemesh create <name> create a new mesh
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
claudemesh list show your meshes (alias: ls)
claudemesh delete [slug] delete a mesh (alias: rm)
claudemesh rename <slug> <name> rename a mesh
claudemesh share [email] share mesh (invite link / send email)
Messaging
claudemesh peers see who's online
claudemesh send <to> <msg> send a message
claudemesh inbox drain pending messages
claudemesh state get|set|list shared state
claudemesh remember <text> store a memory
claudemesh recall <query> search memories
claudemesh remind ... schedule a reminder
claudemesh profile view or edit your profile
claudemesh info mesh overview
Auth
claudemesh login sign in (browser or paste token)
claudemesh register create account + sign in
claudemesh logout sign out
claudemesh whoami show current identity
Security
claudemesh verify [peer] show ed25519 safety numbers (SAS)
claudemesh grant <peer> <cap> grant capability (dm, broadcast, state-read, all)
claudemesh revoke <peer> <cap> revoke capability (or 'all')
claudemesh block <peer> revoke all capabilities (silent drop)
claudemesh grants list per-peer overrides for current mesh
claudemesh backup [file] encrypt config → portable recovery file
claudemesh restore <file> restore config from a backup file
Setup
claudemesh install register MCP server + hooks
claudemesh uninstall remove MCP server + hooks
claudemesh doctor diagnose issues (broker, node, claude)
claudemesh status check broker connectivity
claudemesh sync refresh mesh list from dashboard
claudemesh completions <shell> emit bash / zsh / fish completion script
claudemesh url-handler install register claudemesh:// click-to-launch
claudemesh upgrade self-update to latest alpha (rustup-style)
Flags
--version, -V show version
--help, -h show this help
--json machine-readable output
--mesh <slug> override mesh selection
-y, --yes skip confirmations
-q, --quiet suppress non-essential output
`;
async function main(): Promise<void> {
if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); }
if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); }
// Bare command or invite URL
if (!command || isInviteUrl(command)) {
// `claudemesh <invite-url>` → join + launch in one step.
// `-y` skips all interactive prompts (role=member, no groups, push mode).
if (command && isInviteUrl(command)) {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
join: normaliseInviteUrl(command),
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
const { readConfig } = await import("~/services/config/facade.js");
const config = readConfig();
if (config.meshes.length === 0) {
const { runWelcome } = await import("~/commands/welcome.js");
process.exit(await runWelcome());
}
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: flags.mesh as string | undefined,
name: flags.name as string | undefined,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string | undefined,
}, process.argv.slice(2));
return;
}
switch (command) {
case "help": { console.log(HELP); break; }
// Mesh management
case "create": case "new": { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[0] ?? "", { json: !!flags.json })); break; }
case "add": case "join": { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals); break; }
case "connect": case "launch": {
const { runLaunch } = await import("~/commands/launch.js");
await runLaunch({
mesh: positionals[0] ?? flags.mesh as string,
name: flags.name as string,
join: flags.join as string,
yes: !!flags.y || !!flags.yes,
resume: flags.resume as string,
}, process.argv.slice(2));
break;
}
case "disconnect": { console.log(" Connection closed."); process.exit(EXIT.SUCCESS); break; }
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
// Messaging
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
case "state": {
const sub = positionals[0];
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); }
else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
break;
}
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; }
case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; }
case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; }
case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; }
case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; }
// Auth
case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; }
case "register": { const { register } = await import("~/commands/register.js"); process.exit(await register()); break; }
case "logout": { const { logout } = await import("~/commands/logout.js"); process.exit(await logout()); break; }
case "whoami": { const { whoami } = await import("~/commands/whoami.js"); process.exit(await whoami({ json: !!flags.json })); break; }
// Setup
case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; }
case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; }
case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; }
case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; }
case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; }
// Test
case "test": { const { runTest } = await import("~/commands/test.js"); process.exit(await runTest()); break; }
// CLI utilities
case "completions": { const { runCompletions } = await import("~/commands/completions.js"); process.exit(await runCompletions(positionals[0])); break; }
case "verify": { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(positionals[0], { mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
case "url-handler": { const { runUrlHandler } = await import("~/commands/url-handler.js"); process.exit(await runUrlHandler(positionals[0])); break; }
case "status-line": { const { runStatusLine } = await import("~/commands/status-line.js"); process.exit(await runStatusLine()); break; }
case "backup": { const { runBackup } = await import("~/commands/backup.js"); process.exit(await runBackup(positionals[0])); break; }
case "restore": { const { runRestore } = await import("~/commands/backup.js"); process.exit(await runRestore(positionals[0])); break; }
case "upgrade": case "update": { const { runUpgrade } = await import("~/commands/upgrade.js"); process.exit(await runUpgrade({ check: !!flags.check, yes: !!flags.y || !!flags.yes })); break; }
case "grant": { const { runGrant } = await import("~/commands/grants.js"); process.exit(await runGrant(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "revoke": { const { runRevoke } = await import("~/commands/grants.js"); process.exit(await runRevoke(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; }
case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
// Internal
case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; }
case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; }
case "seed-test-mesh": { const { runSeedTestMesh } = await import("~/commands/seed-test-mesh.js"); runSeedTestMesh(positionals); break; }
default: {
console.error(` Unknown command: ${command}. Run \`claudemesh --help\` for usage.`);
process.exit(EXIT.INVALID_ARGS);
}
}
showUpdateNotice(VERSION).catch(() => {});
}
main().catch((err) => {
console.error("Fatal: " + (err instanceof Error ? err.message : String(err)));
process.exit(EXIT.INTERNAL_ERROR);
});

View File

@@ -0,0 +1,6 @@
import { startMcpServer } from "~/mcp/server.js";
startMcpServer().catch((err) => {
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : err}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,10 @@
export const en = {
welcome: "Welcome to claudemesh",
signed_in_as: "Signed in as {name}",
mesh_created: 'Created "{slug}"',
invite_copied: "Invite URL copied to clipboard",
logout_success: "Signed out",
error_network: "Could not reach {url}. Check your connection.",
error_auth: "Authentication failed. Run \`claudemesh login\` to sign in.",
error_not_found: "{resource} not found",
} as const;

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