From d37516213a5dbd6dd7ae3e0bc472a828ad9e9936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:45:44 +0100 Subject: [PATCH] chore(cli-v2): un-ignore CLI source tree for binary release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 - apps/cli-v2/.gitignore | 5 + apps/cli-v2/CHANGELOG.md | 44 + apps/cli-v2/README.md | 90 + apps/cli-v2/bin/claudemesh | 2 + apps/cli-v2/biome.json | 4 + apps/cli-v2/build.ts | 51 + apps/cli-v2/package.json | 69 + apps/cli-v2/scripts/build-binaries.ts | 49 + apps/cli-v2/src/cli/argv.ts | 30 + apps/cli-v2/src/cli/exit.ts | 7 + apps/cli-v2/src/cli/handlers/error.ts | 12 + apps/cli-v2/src/cli/handlers/signal.ts | 6 + apps/cli-v2/src/cli/output/list.ts | 6 + apps/cli-v2/src/cli/output/peers.ts | 11 + apps/cli-v2/src/cli/output/version.ts | 3 + apps/cli-v2/src/cli/output/whoami.ts | 11 + apps/cli-v2/src/cli/print.ts | 7 + apps/cli-v2/src/cli/structured-io.ts | 4 + apps/cli-v2/src/cli/update-notice.ts | 11 + apps/cli-v2/src/commands/backup.ts | 147 ++ apps/cli-v2/src/commands/completions.ts | 122 + apps/cli-v2/src/commands/connect-telegram.ts | 65 + apps/cli-v2/src/commands/connect.ts | 81 + apps/cli-v2/src/commands/delete-mesh.ts | 128 + apps/cli-v2/src/commands/doctor.ts | 281 +++ apps/cli-v2/src/commands/grants.ts | 176 ++ apps/cli-v2/src/commands/hook.ts | 123 + apps/cli-v2/src/commands/inbox.ts | 60 + apps/cli-v2/src/commands/index.ts | 29 + apps/cli-v2/src/commands/info.ts | 58 + apps/cli-v2/src/commands/install.ts | 564 +++++ apps/cli-v2/src/commands/invite.ts | 96 + apps/cli-v2/src/commands/join.ts | 193 ++ apps/cli-v2/src/commands/launch.ts | 823 ++++++ apps/cli-v2/src/commands/leave.ts | 25 + apps/cli-v2/src/commands/list.ts | 104 + apps/cli-v2/src/commands/login.ts | 118 + apps/cli-v2/src/commands/logout.ts | 22 + apps/cli-v2/src/commands/mcp.ts | 9 + apps/cli-v2/src/commands/new.ts | 48 + apps/cli-v2/src/commands/peers.ts | 82 + apps/cli-v2/src/commands/profile.ts | 114 + apps/cli-v2/src/commands/recall.ts | 35 + apps/cli-v2/src/commands/register.ts | 8 + apps/cli-v2/src/commands/remember.ts | 28 + apps/cli-v2/src/commands/remind.ts | 142 ++ apps/cli-v2/src/commands/rename.ts | 14 + apps/cli-v2/src/commands/seed-test-mesh.ts | 44 + apps/cli-v2/src/commands/send.ts | 51 + apps/cli-v2/src/commands/state.ts | 75 + apps/cli-v2/src/commands/status-line.ts | 69 + apps/cli-v2/src/commands/status.ts | 103 + apps/cli-v2/src/commands/sync.ts | 89 + apps/cli-v2/src/commands/test.ts | 228 ++ apps/cli-v2/src/commands/uninstall.ts | 58 + apps/cli-v2/src/commands/upgrade.ts | 99 + apps/cli-v2/src/commands/url-handler.ts | 178 ++ apps/cli-v2/src/commands/verify.ts | 95 + apps/cli-v2/src/commands/welcome.ts | 72 + apps/cli-v2/src/commands/whoami.ts | 26 + apps/cli-v2/src/constants/exit-codes.ts | 14 + apps/cli-v2/src/constants/index.ts | 5 + apps/cli-v2/src/constants/paths.ts | 22 + apps/cli-v2/src/constants/timings.ts | 11 + apps/cli-v2/src/constants/urls.ts | 14 + apps/cli-v2/src/entrypoints/cli.ts | 200 ++ apps/cli-v2/src/entrypoints/mcp.ts | 6 + apps/cli-v2/src/locales/en.ts | 10 + apps/cli-v2/src/locales/index.ts | 1 + apps/cli-v2/src/mcp/handlers/jsonrpc.ts | 1 + apps/cli-v2/src/mcp/handlers/stdio.ts | 1 + .../src/mcp/middleware/error-handler.ts | 1 + apps/cli-v2/src/mcp/middleware/logging.ts | 3 + apps/cli-v2/src/mcp/router.ts | 2 + apps/cli-v2/src/mcp/server.ts | 2184 ++++++++++++++++ apps/cli-v2/src/mcp/tools/clock-write.ts | 4 + apps/cli-v2/src/mcp/tools/contexts.ts | 4 + apps/cli-v2/src/mcp/tools/definitions.ts | 1020 ++++++++ apps/cli-v2/src/mcp/tools/files.ts | 4 + apps/cli-v2/src/mcp/tools/graph.ts | 4 + apps/cli-v2/src/mcp/tools/groups.ts | 4 + apps/cli-v2/src/mcp/tools/index.ts | 21 + .../src/mcp/tools/mcp-registry-broker.ts | 4 + .../cli-v2/src/mcp/tools/mcp-registry-peer.ts | 4 + apps/cli-v2/src/mcp/tools/memory.ts | 4 + apps/cli-v2/src/mcp/tools/mesh-meta.ts | 4 + apps/cli-v2/src/mcp/tools/messaging.ts | 4 + apps/cli-v2/src/mcp/tools/profile.ts | 4 + apps/cli-v2/src/mcp/tools/scheduling.ts | 4 + apps/cli-v2/src/mcp/tools/skills.ts | 4 + apps/cli-v2/src/mcp/tools/sql.ts | 4 + apps/cli-v2/src/mcp/tools/state.ts | 4 + apps/cli-v2/src/mcp/tools/streams.ts | 4 + apps/cli-v2/src/mcp/tools/tasks.ts | 4 + apps/cli-v2/src/mcp/tools/url-watch.ts | 4 + apps/cli-v2/src/mcp/tools/vault.ts | 4 + apps/cli-v2/src/mcp/tools/vectors.ts | 4 + apps/cli-v2/src/mcp/tools/webhooks.ts | 4 + apps/cli-v2/src/mcp/types.ts | 81 + apps/cli-v2/src/services/api/client.ts | 70 + apps/cli-v2/src/services/api/errors.ts | 37 + apps/cli-v2/src/services/api/facade.ts | 5 + apps/cli-v2/src/services/api/index.ts | 1 + apps/cli-v2/src/services/api/my.ts | 60 + apps/cli-v2/src/services/api/public.ts | 46 + .../src/services/auth/callback-listener.ts | 70 + apps/cli-v2/src/services/auth/client.ts | 51 + .../src/services/auth/dashboard-sync.ts | 50 + apps/cli-v2/src/services/auth/device-code.ts | 132 + apps/cli-v2/src/services/auth/errors.ts | 18 + apps/cli-v2/src/services/auth/facade.ts | 16 + .../src/services/auth/implementation.ts | 5 + apps/cli-v2/src/services/auth/index.ts | 1 + apps/cli-v2/src/services/auth/schemas.ts | 21 + apps/cli-v2/src/services/auth/token-store.ts | 30 + apps/cli-v2/src/services/broker/envelope.ts | 6 + apps/cli-v2/src/services/broker/errors.ts | 13 + apps/cli-v2/src/services/broker/facade.ts | 8 + apps/cli-v2/src/services/broker/hello-sig.ts | 17 + .../src/services/broker/implementation.ts | 2 + apps/cli-v2/src/services/broker/index.ts | 1 + apps/cli-v2/src/services/broker/manager.ts | 47 + apps/cli-v2/src/services/broker/schemas.ts | 28 + apps/cli-v2/src/services/broker/ws-client.ts | 2221 +++++++++++++++++ apps/cli-v2/src/services/clipboard/facade.ts | 1 + apps/cli-v2/src/services/clipboard/index.ts | 1 + apps/cli-v2/src/services/clipboard/read.ts | 45 + apps/cli-v2/src/services/config/facade.ts | 9 + apps/cli-v2/src/services/config/index.ts | 1 + apps/cli-v2/src/services/config/read.ts | 31 + apps/cli-v2/src/services/config/schemas.ts | 31 + apps/cli-v2/src/services/config/write.ts | 51 + apps/cli-v2/src/services/crypto/box.ts | 59 + apps/cli-v2/src/services/crypto/facade.ts | 55 + .../cli-v2/src/services/crypto/file-crypto.ts | 65 + apps/cli-v2/src/services/crypto/index.ts | 1 + apps/cli-v2/src/services/crypto/keypair.ts | 25 + apps/cli-v2/src/services/crypto/random.ts | 11 + apps/cli-v2/src/services/device/facade.ts | 2 + apps/cli-v2/src/services/device/index.ts | 1 + apps/cli-v2/src/services/device/info.ts | 19 + .../services/health/check-claude-binary.ts | 8 + .../src/services/health/check-config-perms.ts | 19 + .../services/health/check-hooks-registered.ts | 19 + .../services/health/check-keypairs-valid.ts | 31 + .../services/health/check-mcp-registered.ts | 19 + .../src/services/health/check-node-version.ts | 7 + apps/cli-v2/src/services/health/facade.ts | 27 + apps/cli-v2/src/services/health/index.ts | 1 + apps/cli-v2/src/services/health/types.ts | 5 + apps/cli-v2/src/services/i18n/facade.ts | 3 + apps/cli-v2/src/services/i18n/format.ts | 13 + apps/cli-v2/src/services/i18n/index.ts | 1 + apps/cli-v2/src/services/i18n/resolve.ts | 5 + apps/cli-v2/src/services/invite/claim.ts | 1 + apps/cli-v2/src/services/invite/enroll.ts | 58 + apps/cli-v2/src/services/invite/errors.ts | 13 + apps/cli-v2/src/services/invite/facade.ts | 10 + apps/cli-v2/src/services/invite/generate.ts | 27 + .../src/services/invite/implementation.ts | 4 + apps/cli-v2/src/services/invite/index.ts | 1 + apps/cli-v2/src/services/invite/parse-url.ts | 8 + apps/cli-v2/src/services/invite/parse-v1.ts | 227 ++ apps/cli-v2/src/services/invite/schemas.ts | 8 + apps/cli-v2/src/services/invite/send-email.ts | 5 + apps/cli-v2/src/services/invite/v2.ts | 217 ++ apps/cli-v2/src/services/lifecycle/facade.ts | 1 + apps/cli-v2/src/services/lifecycle/index.ts | 1 + .../src/services/lifecycle/service-manager.ts | 32 + apps/cli-v2/src/services/logger/facade.ts | 1 + apps/cli-v2/src/services/logger/index.ts | 1 + apps/cli-v2/src/services/logger/logger.ts | 22 + apps/cli-v2/src/services/mesh/client.ts | 2 + apps/cli-v2/src/services/mesh/create.ts | 43 + apps/cli-v2/src/services/mesh/errors.ts | 6 + apps/cli-v2/src/services/mesh/facade.ts | 7 + .../src/services/mesh/implementation.ts | 6 + apps/cli-v2/src/services/mesh/index.ts | 1 + apps/cli-v2/src/services/mesh/join.ts | 18 + apps/cli-v2/src/services/mesh/leave.ts | 5 + apps/cli-v2/src/services/mesh/list.ts | 6 + apps/cli-v2/src/services/mesh/rename.ts | 8 + .../src/services/mesh/resolve-target.ts | 16 + apps/cli-v2/src/services/mesh/schemas.ts | 2 + apps/cli-v2/src/services/spawn/browser.ts | 25 + apps/cli-v2/src/services/spawn/claude.ts | 37 + apps/cli-v2/src/services/spawn/facade.ts | 3 + apps/cli-v2/src/services/spawn/index.ts | 1 + apps/cli-v2/src/services/state/facade.ts | 2 + apps/cli-v2/src/services/state/index.ts | 1 + apps/cli-v2/src/services/state/last-used.ts | 20 + apps/cli-v2/src/services/state/schemas.ts | 6 + apps/cli-v2/src/services/telemetry/emit.ts | 12 + apps/cli-v2/src/services/telemetry/facade.ts | 2 + apps/cli-v2/src/services/telemetry/index.ts | 1 + apps/cli-v2/src/services/telemetry/opt-out.ts | 17 + apps/cli-v2/src/services/update/check.ts | 35 + apps/cli-v2/src/services/update/facade.ts | 2 + apps/cli-v2/src/services/update/index.ts | 1 + apps/cli-v2/src/templates/dev-team.ts | 35 + apps/cli-v2/src/templates/index.ts | 11 + apps/cli-v2/src/templates/ops-incident.ts | 28 + apps/cli-v2/src/templates/personal.ts | 17 + apps/cli-v2/src/templates/research.ts | 27 + apps/cli-v2/src/templates/simulation.ts | 27 + apps/cli-v2/src/types/api.ts | 11 + apps/cli-v2/src/types/index.ts | 3 + apps/cli-v2/src/types/mesh.ts | 5 + apps/cli-v2/src/types/peer.ts | 3 + apps/cli-v2/src/ui/index.ts | 2 + apps/cli-v2/src/ui/launch/LaunchFlow.ts | 12 + apps/cli-v2/src/ui/launch/index.ts | 1 + apps/cli-v2/src/ui/logo-spinner.ts | 17 + apps/cli-v2/src/ui/qr.ts | 63 + apps/cli-v2/src/ui/render.ts | 83 + apps/cli-v2/src/ui/screen.ts | 114 + apps/cli-v2/src/ui/spinner.ts | 87 + apps/cli-v2/src/ui/styles.ts | 44 + apps/cli-v2/src/ui/telegram/ConnectWizard.ts | 17 + apps/cli-v2/src/ui/welcome/LoginStep.ts | 3 + apps/cli-v2/src/ui/welcome/MeshPickerStep.ts | 10 + apps/cli-v2/src/ui/welcome/RegisterStep.ts | 3 + apps/cli-v2/src/ui/welcome/WelcomeScreen.ts | 15 + apps/cli-v2/src/ui/welcome/index.ts | 4 + apps/cli-v2/src/utils/format.ts | 11 + apps/cli-v2/src/utils/index.ts | 6 + apps/cli-v2/src/utils/levenshtein.ts | 18 + apps/cli-v2/src/utils/retry.ts | 16 + apps/cli-v2/src/utils/semver.ts | 7 + apps/cli-v2/src/utils/slug.ts | 3 + apps/cli-v2/src/utils/url.ts | 30 + apps/cli-v2/tests/golden/version.test.ts | 12 + apps/cli-v2/tests/golden/whoami.test.ts | 15 + apps/cli-v2/tests/unit/argv.test.ts | 42 + apps/cli-v2/tests/unit/config.test.ts | 48 + .../tests/unit/crypto-roundtrip.test.ts | 84 + apps/cli-v2/tests/unit/exit-codes.test.ts | 13 + apps/cli-v2/tests/unit/health.test.ts | 24 + apps/cli-v2/tests/unit/templates.test.ts | 30 + apps/cli-v2/tests/unit/utils.test.ts | 51 + apps/cli-v2/tsconfig.json | 15 + apps/cli-v2/vitest.config.ts | 11 + 243 files changed, 14507 insertions(+), 1 deletion(-) create mode 100644 apps/cli-v2/.gitignore create mode 100644 apps/cli-v2/CHANGELOG.md create mode 100644 apps/cli-v2/README.md create mode 100644 apps/cli-v2/bin/claudemesh create mode 100644 apps/cli-v2/biome.json create mode 100644 apps/cli-v2/build.ts create mode 100644 apps/cli-v2/package.json create mode 100644 apps/cli-v2/scripts/build-binaries.ts create mode 100644 apps/cli-v2/src/cli/argv.ts create mode 100644 apps/cli-v2/src/cli/exit.ts create mode 100644 apps/cli-v2/src/cli/handlers/error.ts create mode 100644 apps/cli-v2/src/cli/handlers/signal.ts create mode 100644 apps/cli-v2/src/cli/output/list.ts create mode 100644 apps/cli-v2/src/cli/output/peers.ts create mode 100644 apps/cli-v2/src/cli/output/version.ts create mode 100644 apps/cli-v2/src/cli/output/whoami.ts create mode 100644 apps/cli-v2/src/cli/print.ts create mode 100644 apps/cli-v2/src/cli/structured-io.ts create mode 100644 apps/cli-v2/src/cli/update-notice.ts create mode 100644 apps/cli-v2/src/commands/backup.ts create mode 100644 apps/cli-v2/src/commands/completions.ts create mode 100644 apps/cli-v2/src/commands/connect-telegram.ts create mode 100644 apps/cli-v2/src/commands/connect.ts create mode 100644 apps/cli-v2/src/commands/delete-mesh.ts create mode 100644 apps/cli-v2/src/commands/doctor.ts create mode 100644 apps/cli-v2/src/commands/grants.ts create mode 100644 apps/cli-v2/src/commands/hook.ts create mode 100644 apps/cli-v2/src/commands/inbox.ts create mode 100644 apps/cli-v2/src/commands/index.ts create mode 100644 apps/cli-v2/src/commands/info.ts create mode 100644 apps/cli-v2/src/commands/install.ts create mode 100644 apps/cli-v2/src/commands/invite.ts create mode 100644 apps/cli-v2/src/commands/join.ts create mode 100644 apps/cli-v2/src/commands/launch.ts create mode 100644 apps/cli-v2/src/commands/leave.ts create mode 100644 apps/cli-v2/src/commands/list.ts create mode 100644 apps/cli-v2/src/commands/login.ts create mode 100644 apps/cli-v2/src/commands/logout.ts create mode 100644 apps/cli-v2/src/commands/mcp.ts create mode 100644 apps/cli-v2/src/commands/new.ts create mode 100644 apps/cli-v2/src/commands/peers.ts create mode 100644 apps/cli-v2/src/commands/profile.ts create mode 100644 apps/cli-v2/src/commands/recall.ts create mode 100644 apps/cli-v2/src/commands/register.ts create mode 100644 apps/cli-v2/src/commands/remember.ts create mode 100644 apps/cli-v2/src/commands/remind.ts create mode 100644 apps/cli-v2/src/commands/rename.ts create mode 100644 apps/cli-v2/src/commands/seed-test-mesh.ts create mode 100644 apps/cli-v2/src/commands/send.ts create mode 100644 apps/cli-v2/src/commands/state.ts create mode 100644 apps/cli-v2/src/commands/status-line.ts create mode 100644 apps/cli-v2/src/commands/status.ts create mode 100644 apps/cli-v2/src/commands/sync.ts create mode 100644 apps/cli-v2/src/commands/test.ts create mode 100644 apps/cli-v2/src/commands/uninstall.ts create mode 100644 apps/cli-v2/src/commands/upgrade.ts create mode 100644 apps/cli-v2/src/commands/url-handler.ts create mode 100644 apps/cli-v2/src/commands/verify.ts create mode 100644 apps/cli-v2/src/commands/welcome.ts create mode 100644 apps/cli-v2/src/commands/whoami.ts create mode 100644 apps/cli-v2/src/constants/exit-codes.ts create mode 100644 apps/cli-v2/src/constants/index.ts create mode 100644 apps/cli-v2/src/constants/paths.ts create mode 100644 apps/cli-v2/src/constants/timings.ts create mode 100644 apps/cli-v2/src/constants/urls.ts create mode 100644 apps/cli-v2/src/entrypoints/cli.ts create mode 100644 apps/cli-v2/src/entrypoints/mcp.ts create mode 100644 apps/cli-v2/src/locales/en.ts create mode 100644 apps/cli-v2/src/locales/index.ts create mode 100644 apps/cli-v2/src/mcp/handlers/jsonrpc.ts create mode 100644 apps/cli-v2/src/mcp/handlers/stdio.ts create mode 100644 apps/cli-v2/src/mcp/middleware/error-handler.ts create mode 100644 apps/cli-v2/src/mcp/middleware/logging.ts create mode 100644 apps/cli-v2/src/mcp/router.ts create mode 100644 apps/cli-v2/src/mcp/server.ts create mode 100644 apps/cli-v2/src/mcp/tools/clock-write.ts create mode 100644 apps/cli-v2/src/mcp/tools/contexts.ts create mode 100644 apps/cli-v2/src/mcp/tools/definitions.ts create mode 100644 apps/cli-v2/src/mcp/tools/files.ts create mode 100644 apps/cli-v2/src/mcp/tools/graph.ts create mode 100644 apps/cli-v2/src/mcp/tools/groups.ts create mode 100644 apps/cli-v2/src/mcp/tools/index.ts create mode 100644 apps/cli-v2/src/mcp/tools/mcp-registry-broker.ts create mode 100644 apps/cli-v2/src/mcp/tools/mcp-registry-peer.ts create mode 100644 apps/cli-v2/src/mcp/tools/memory.ts create mode 100644 apps/cli-v2/src/mcp/tools/mesh-meta.ts create mode 100644 apps/cli-v2/src/mcp/tools/messaging.ts create mode 100644 apps/cli-v2/src/mcp/tools/profile.ts create mode 100644 apps/cli-v2/src/mcp/tools/scheduling.ts create mode 100644 apps/cli-v2/src/mcp/tools/skills.ts create mode 100644 apps/cli-v2/src/mcp/tools/sql.ts create mode 100644 apps/cli-v2/src/mcp/tools/state.ts create mode 100644 apps/cli-v2/src/mcp/tools/streams.ts create mode 100644 apps/cli-v2/src/mcp/tools/tasks.ts create mode 100644 apps/cli-v2/src/mcp/tools/url-watch.ts create mode 100644 apps/cli-v2/src/mcp/tools/vault.ts create mode 100644 apps/cli-v2/src/mcp/tools/vectors.ts create mode 100644 apps/cli-v2/src/mcp/tools/webhooks.ts create mode 100644 apps/cli-v2/src/mcp/types.ts create mode 100644 apps/cli-v2/src/services/api/client.ts create mode 100644 apps/cli-v2/src/services/api/errors.ts create mode 100644 apps/cli-v2/src/services/api/facade.ts create mode 100644 apps/cli-v2/src/services/api/index.ts create mode 100644 apps/cli-v2/src/services/api/my.ts create mode 100644 apps/cli-v2/src/services/api/public.ts create mode 100644 apps/cli-v2/src/services/auth/callback-listener.ts create mode 100644 apps/cli-v2/src/services/auth/client.ts create mode 100644 apps/cli-v2/src/services/auth/dashboard-sync.ts create mode 100644 apps/cli-v2/src/services/auth/device-code.ts create mode 100644 apps/cli-v2/src/services/auth/errors.ts create mode 100644 apps/cli-v2/src/services/auth/facade.ts create mode 100644 apps/cli-v2/src/services/auth/implementation.ts create mode 100644 apps/cli-v2/src/services/auth/index.ts create mode 100644 apps/cli-v2/src/services/auth/schemas.ts create mode 100644 apps/cli-v2/src/services/auth/token-store.ts create mode 100644 apps/cli-v2/src/services/broker/envelope.ts create mode 100644 apps/cli-v2/src/services/broker/errors.ts create mode 100644 apps/cli-v2/src/services/broker/facade.ts create mode 100644 apps/cli-v2/src/services/broker/hello-sig.ts create mode 100644 apps/cli-v2/src/services/broker/implementation.ts create mode 100644 apps/cli-v2/src/services/broker/index.ts create mode 100644 apps/cli-v2/src/services/broker/manager.ts create mode 100644 apps/cli-v2/src/services/broker/schemas.ts create mode 100644 apps/cli-v2/src/services/broker/ws-client.ts create mode 100644 apps/cli-v2/src/services/clipboard/facade.ts create mode 100644 apps/cli-v2/src/services/clipboard/index.ts create mode 100644 apps/cli-v2/src/services/clipboard/read.ts create mode 100644 apps/cli-v2/src/services/config/facade.ts create mode 100644 apps/cli-v2/src/services/config/index.ts create mode 100644 apps/cli-v2/src/services/config/read.ts create mode 100644 apps/cli-v2/src/services/config/schemas.ts create mode 100644 apps/cli-v2/src/services/config/write.ts create mode 100644 apps/cli-v2/src/services/crypto/box.ts create mode 100644 apps/cli-v2/src/services/crypto/facade.ts create mode 100644 apps/cli-v2/src/services/crypto/file-crypto.ts create mode 100644 apps/cli-v2/src/services/crypto/index.ts create mode 100644 apps/cli-v2/src/services/crypto/keypair.ts create mode 100644 apps/cli-v2/src/services/crypto/random.ts create mode 100644 apps/cli-v2/src/services/device/facade.ts create mode 100644 apps/cli-v2/src/services/device/index.ts create mode 100644 apps/cli-v2/src/services/device/info.ts create mode 100644 apps/cli-v2/src/services/health/check-claude-binary.ts create mode 100644 apps/cli-v2/src/services/health/check-config-perms.ts create mode 100644 apps/cli-v2/src/services/health/check-hooks-registered.ts create mode 100644 apps/cli-v2/src/services/health/check-keypairs-valid.ts create mode 100644 apps/cli-v2/src/services/health/check-mcp-registered.ts create mode 100644 apps/cli-v2/src/services/health/check-node-version.ts create mode 100644 apps/cli-v2/src/services/health/facade.ts create mode 100644 apps/cli-v2/src/services/health/index.ts create mode 100644 apps/cli-v2/src/services/health/types.ts create mode 100644 apps/cli-v2/src/services/i18n/facade.ts create mode 100644 apps/cli-v2/src/services/i18n/format.ts create mode 100644 apps/cli-v2/src/services/i18n/index.ts create mode 100644 apps/cli-v2/src/services/i18n/resolve.ts create mode 100644 apps/cli-v2/src/services/invite/claim.ts create mode 100644 apps/cli-v2/src/services/invite/enroll.ts create mode 100644 apps/cli-v2/src/services/invite/errors.ts create mode 100644 apps/cli-v2/src/services/invite/facade.ts create mode 100644 apps/cli-v2/src/services/invite/generate.ts create mode 100644 apps/cli-v2/src/services/invite/implementation.ts create mode 100644 apps/cli-v2/src/services/invite/index.ts create mode 100644 apps/cli-v2/src/services/invite/parse-url.ts create mode 100644 apps/cli-v2/src/services/invite/parse-v1.ts create mode 100644 apps/cli-v2/src/services/invite/schemas.ts create mode 100644 apps/cli-v2/src/services/invite/send-email.ts create mode 100644 apps/cli-v2/src/services/invite/v2.ts create mode 100644 apps/cli-v2/src/services/lifecycle/facade.ts create mode 100644 apps/cli-v2/src/services/lifecycle/index.ts create mode 100644 apps/cli-v2/src/services/lifecycle/service-manager.ts create mode 100644 apps/cli-v2/src/services/logger/facade.ts create mode 100644 apps/cli-v2/src/services/logger/index.ts create mode 100644 apps/cli-v2/src/services/logger/logger.ts create mode 100644 apps/cli-v2/src/services/mesh/client.ts create mode 100644 apps/cli-v2/src/services/mesh/create.ts create mode 100644 apps/cli-v2/src/services/mesh/errors.ts create mode 100644 apps/cli-v2/src/services/mesh/facade.ts create mode 100644 apps/cli-v2/src/services/mesh/implementation.ts create mode 100644 apps/cli-v2/src/services/mesh/index.ts create mode 100644 apps/cli-v2/src/services/mesh/join.ts create mode 100644 apps/cli-v2/src/services/mesh/leave.ts create mode 100644 apps/cli-v2/src/services/mesh/list.ts create mode 100644 apps/cli-v2/src/services/mesh/rename.ts create mode 100644 apps/cli-v2/src/services/mesh/resolve-target.ts create mode 100644 apps/cli-v2/src/services/mesh/schemas.ts create mode 100644 apps/cli-v2/src/services/spawn/browser.ts create mode 100644 apps/cli-v2/src/services/spawn/claude.ts create mode 100644 apps/cli-v2/src/services/spawn/facade.ts create mode 100644 apps/cli-v2/src/services/spawn/index.ts create mode 100644 apps/cli-v2/src/services/state/facade.ts create mode 100644 apps/cli-v2/src/services/state/index.ts create mode 100644 apps/cli-v2/src/services/state/last-used.ts create mode 100644 apps/cli-v2/src/services/state/schemas.ts create mode 100644 apps/cli-v2/src/services/telemetry/emit.ts create mode 100644 apps/cli-v2/src/services/telemetry/facade.ts create mode 100644 apps/cli-v2/src/services/telemetry/index.ts create mode 100644 apps/cli-v2/src/services/telemetry/opt-out.ts create mode 100644 apps/cli-v2/src/services/update/check.ts create mode 100644 apps/cli-v2/src/services/update/facade.ts create mode 100644 apps/cli-v2/src/services/update/index.ts create mode 100644 apps/cli-v2/src/templates/dev-team.ts create mode 100644 apps/cli-v2/src/templates/index.ts create mode 100644 apps/cli-v2/src/templates/ops-incident.ts create mode 100644 apps/cli-v2/src/templates/personal.ts create mode 100644 apps/cli-v2/src/templates/research.ts create mode 100644 apps/cli-v2/src/templates/simulation.ts create mode 100644 apps/cli-v2/src/types/api.ts create mode 100644 apps/cli-v2/src/types/index.ts create mode 100644 apps/cli-v2/src/types/mesh.ts create mode 100644 apps/cli-v2/src/types/peer.ts create mode 100644 apps/cli-v2/src/ui/index.ts create mode 100644 apps/cli-v2/src/ui/launch/LaunchFlow.ts create mode 100644 apps/cli-v2/src/ui/launch/index.ts create mode 100644 apps/cli-v2/src/ui/logo-spinner.ts create mode 100644 apps/cli-v2/src/ui/qr.ts create mode 100644 apps/cli-v2/src/ui/render.ts create mode 100644 apps/cli-v2/src/ui/screen.ts create mode 100644 apps/cli-v2/src/ui/spinner.ts create mode 100644 apps/cli-v2/src/ui/styles.ts create mode 100644 apps/cli-v2/src/ui/telegram/ConnectWizard.ts create mode 100644 apps/cli-v2/src/ui/welcome/LoginStep.ts create mode 100644 apps/cli-v2/src/ui/welcome/MeshPickerStep.ts create mode 100644 apps/cli-v2/src/ui/welcome/RegisterStep.ts create mode 100644 apps/cli-v2/src/ui/welcome/WelcomeScreen.ts create mode 100644 apps/cli-v2/src/ui/welcome/index.ts create mode 100644 apps/cli-v2/src/utils/format.ts create mode 100644 apps/cli-v2/src/utils/index.ts create mode 100644 apps/cli-v2/src/utils/levenshtein.ts create mode 100644 apps/cli-v2/src/utils/retry.ts create mode 100644 apps/cli-v2/src/utils/semver.ts create mode 100644 apps/cli-v2/src/utils/slug.ts create mode 100644 apps/cli-v2/src/utils/url.ts create mode 100644 apps/cli-v2/tests/golden/version.test.ts create mode 100644 apps/cli-v2/tests/golden/whoami.test.ts create mode 100644 apps/cli-v2/tests/unit/argv.test.ts create mode 100644 apps/cli-v2/tests/unit/config.test.ts create mode 100644 apps/cli-v2/tests/unit/crypto-roundtrip.test.ts create mode 100644 apps/cli-v2/tests/unit/exit-codes.test.ts create mode 100644 apps/cli-v2/tests/unit/health.test.ts create mode 100644 apps/cli-v2/tests/unit/templates.test.ts create mode 100644 apps/cli-v2/tests/unit/utils.test.ts create mode 100644 apps/cli-v2/tsconfig.json create mode 100644 apps/cli-v2/vitest.config.ts diff --git a/.gitignore b/.gitignore index 94ec0e3..2d31c47 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,3 @@ apps/web/payload.db apps/web/public/media/* !apps/web/public/media/.gitkeep .env.local -apps/cli-v2/ diff --git a/apps/cli-v2/.gitignore b/apps/cli-v2/.gitignore new file mode 100644 index 0000000..cf2e491 --- /dev/null +++ b/apps/cli-v2/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.turbo/ +.cache/ +*.log diff --git a/apps/cli-v2/CHANGELOG.md b/apps/cli-v2/CHANGELOG.md new file mode 100644 index 0000000..4ad6e0d --- /dev/null +++ b/apps/cli-v2/CHANGELOG.md @@ -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 ` — 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`) diff --git a/apps/cli-v2/README.md b/apps/cli-v2/README.md new file mode 100644 index 0000000..ae02f4d --- /dev/null +++ b/apps/cli-v2/README.md @@ -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 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 rename the current mesh + claudemesh leave [mesh] leave a mesh + claudemesh peers see who's online + + claudemesh send send a message + claudemesh inbox drain pending messages + claudemesh state ... get, set, or list shared state + claudemesh remember store a memory + claudemesh recall 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 diff --git a/apps/cli-v2/bin/claudemesh b/apps/cli-v2/bin/claudemesh new file mode 100644 index 0000000..3eb59d1 --- /dev/null +++ b/apps/cli-v2/bin/claudemesh @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/entrypoints/cli.js"; diff --git a/apps/cli-v2/biome.json b/apps/cli-v2/biome.json new file mode 100644 index 0000000..6ad292f --- /dev/null +++ b/apps/cli-v2/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../../biome.json"] +} diff --git a/apps/cli-v2/build.ts b/apps/cli-v2/build.ts new file mode 100644 index 0000000..0ac9701 --- /dev/null +++ b/apps/cli-v2/build.ts @@ -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."); diff --git a/apps/cli-v2/package.json b/apps/cli-v2/package.json new file mode 100644 index 0000000..b93d289 --- /dev/null +++ b/apps/cli-v2/package.json @@ -0,0 +1,69 @@ +{ + "name": "claudemesh-cli-v2", + "version": "1.0.0-alpha.29", + "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:" + } +} diff --git a/apps/cli-v2/scripts/build-binaries.ts b/apps/cli-v2/scripts/build-binaries.ts new file mode 100644 index 0000000..aca42fd --- /dev/null +++ b/apps/cli-v2/scripts/build-binaries.ts @@ -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/"); diff --git a/apps/cli-v2/src/cli/argv.ts b/apps/cli-v2/src/cli/argv.ts new file mode 100644 index 0000000..10084ec --- /dev/null +++ b/apps/cli-v2/src/cli/argv.ts @@ -0,0 +1,30 @@ +import { defineCommand, runMain } from "citty"; + +export interface ParsedArgs { command: string; positionals: string[]; flags: Record; } + +export function parseArgv(argv: string[]): ParsedArgs { + const args = argv.slice(2); + const flags: Record = {}; + 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 }; diff --git a/apps/cli-v2/src/cli/exit.ts b/apps/cli-v2/src/cli/exit.ts new file mode 100644 index 0000000..2b4a2ea --- /dev/null +++ b/apps/cli-v2/src/cli/exit.ts @@ -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); +} diff --git a/apps/cli-v2/src/cli/handlers/error.ts b/apps/cli-v2/src/cli/handlers/error.ts new file mode 100644 index 0000000..95d580a --- /dev/null +++ b/apps/cli-v2/src/cli/handlers/error.ts @@ -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)); +} diff --git a/apps/cli-v2/src/cli/handlers/signal.ts b/apps/cli-v2/src/cli/handlers/signal.ts new file mode 100644 index 0000000..135c739 --- /dev/null +++ b/apps/cli-v2/src/cli/handlers/signal.ts @@ -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); }); +} diff --git a/apps/cli-v2/src/cli/output/list.ts b/apps/cli-v2/src/cli/output/list.ts new file mode 100644 index 0000000..572501b --- /dev/null +++ b/apps/cli-v2/src/cli/output/list.ts @@ -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"); +} diff --git a/apps/cli-v2/src/cli/output/peers.ts b/apps/cli-v2/src/cli/output/peers.ts new file mode 100644 index 0000000..9a40a2a --- /dev/null +++ b/apps/cli-v2/src/cli/output/peers.ts @@ -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> = { 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"); +} diff --git a/apps/cli-v2/src/cli/output/version.ts b/apps/cli-v2/src/cli/output/version.ts new file mode 100644 index 0000000..3d2f916 --- /dev/null +++ b/apps/cli-v2/src/cli/output/version.ts @@ -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; } diff --git a/apps/cli-v2/src/cli/output/whoami.ts b/apps/cli-v2/src/cli/output/whoami.ts new file mode 100644 index 0000000..3596b63 --- /dev/null +++ b/apps/cli-v2/src/cli/output/whoami.ts @@ -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"); +} diff --git a/apps/cli-v2/src/cli/print.ts b/apps/cli-v2/src/cli/print.ts new file mode 100644 index 0000000..09fb0df --- /dev/null +++ b/apps/cli-v2/src/cli/print.ts @@ -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; } diff --git a/apps/cli-v2/src/cli/structured-io.ts b/apps/cli-v2/src/cli/structured-io.ts new file mode 100644 index 0000000..ea6f30b --- /dev/null +++ b/apps/cli-v2/src/cli/structured-io.ts @@ -0,0 +1,4 @@ +export function jsonOutput(data: T): string { + return JSON.stringify({ schema_version: "1.0", ...data }, null, 2); +} +export function writeJson(data: T): void { console.log(jsonOutput(data)); } diff --git a/apps/cli-v2/src/cli/update-notice.ts b/apps/cli-v2/src/cli/update-notice.ts new file mode 100644 index 0000000..106e93f --- /dev/null +++ b/apps/cli-v2/src/cli/update-notice.ts @@ -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 { + 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 {} +} diff --git a/apps/cli-v2/src/commands/backup.ts b/apps/cli-v2/src/commands/backup.ts new file mode 100644 index 0000000..aae02ef --- /dev/null +++ b/apps/cli-v2/src/commands/backup.ts @@ -0,0 +1,147 @@ +/** + * `claudemesh backup` — encrypt the local config and save a portable + * recovery file. Restore later with `claudemesh restore ` 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 { + 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>): Promise { + 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 { + 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 { + if (!inPath) { + console.error(" Usage: claudemesh restore "); + 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; +} diff --git a/apps/cli-v2/src/commands/completions.ts b/apps/cli-v2/src/commands/completions.ts new file mode 100644 index 0000000..8c1285a --- /dev/null +++ b/apps/cli-v2/src/commands/completions.ts @@ -0,0 +1,122 @@ +/** + * `claudemesh completions ` — 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 { + if (!shell) { + console.error("Usage: claudemesh completions "); + 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; + } +} diff --git a/apps/cli-v2/src/commands/connect-telegram.ts b/apps/cli-v2/src/commands/connect-telegram.ts new file mode 100644 index 0000000..3224e27 --- /dev/null +++ b/apps/cli-v2/src/commands/connect-telegram.ts @@ -0,0 +1,65 @@ +import { readConfig } from "~/services/config/facade.js"; + +export async function connectTelegram(args: string[]): Promise { + 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)"); + } +} diff --git a/apps/cli-v2/src/commands/connect.ts b/apps/cli-v2/src/commands/connect.ts new file mode 100644 index 0000000..6a6f7cd --- /dev/null +++ b/apps/cli-v2/src/commands/connect.ts @@ -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 { + 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( + opts: ConnectOpts, + fn: (client: BrokerClient, mesh: JoinedMesh) => Promise, +): Promise { + const config = readConfig(); + if (config.meshes.length === 0) { + console.error("No meshes joined. Run `claudemesh join ` 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(); + } +} diff --git a/apps/cli-v2/src/commands/delete-mesh.ts b/apps/cli-v2/src/commands/delete-mesh.ts new file mode 100644 index 0000000..814fca8 --- /dev/null +++ b/apps/cli-v2/src/commands/delete-mesh.ts @@ -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 { + 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 { + 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 { + 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 `)); + } else { + console.error(` Mesh "${slug}" not found in local config.`); + } + return EXIT.SUCCESS; +} diff --git a/apps/cli-v2/src/commands/doctor.ts b/apps/cli-v2/src/commands/doctor.ts new file mode 100644 index 0000000..c14edfa --- /dev/null +++ b/apps/cli-v2/src/commands/doctor.ts @@ -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; + }; + 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 { + 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((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 { + 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 { + 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); + } +} diff --git a/apps/cli-v2/src/commands/grants.ts b/apps/cli-v2/src/commands/grants.ts new file mode 100644 index 0000000..9ce24b0 --- /dev/null +++ b/apps/cli-v2/src/commands/grants.ts @@ -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>; // 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 { + if (!peer || caps.length === 0) { + render.err("Usage: claudemesh grant "); + 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 { + if (!peer || caps.length === 0) { + render.err("Usage: claudemesh revoke "); + 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 { + if (!peer) { render.err("Usage: claudemesh block "); 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 { + 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); +} diff --git a/apps/cli-v2/src/commands/hook.ts b/apps/cli-v2/src/commands/hook.ts new file mode 100644 index 0000000..a3aa932 --- /dev/null +++ b/apps/cli-v2/src/commands/hook.ts @@ -0,0 +1,123 @@ +/** + * `claudemesh hook ` — 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> { + 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; + } catch { + return {}; + } +} + +async function postHook( + brokerWsUrl: string, + body: Record, +): Promise { + 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 { + 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>((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); +} diff --git a/apps/cli-v2/src/commands/inbox.ts b/apps/cli-v2/src/commands/inbox.ts new file mode 100644 index 0000000..406e310 --- /dev/null +++ b/apps/cli-v2/src/commands/inbox.ts @@ -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 { + 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((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(""); + } + }); +} diff --git a/apps/cli-v2/src/commands/index.ts b/apps/cli-v2/src/commands/index.ts new file mode 100644 index 0000000..ca1d020 --- /dev/null +++ b/apps/cli-v2/src/commands/index.ts @@ -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"; diff --git a/apps/cli-v2/src/commands/info.ts b/apps/cli-v2/src/commands/info.ts new file mode 100644 index 0000000..801497b --- /dev/null +++ b/apps/cli-v2/src/commands/info.ts @@ -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 { + 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)}`)); + } + } + }); +} diff --git a/apps/cli-v2/src/commands/install.ts b/apps/cli-v2/src/commands/install.ts new file mode 100644 index 0000000..80ffaad --- /dev/null +++ b/apps/cli-v2/src/commands/install.ts @@ -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; +}; + +interface HookCommand { + type: "command"; + command: string; +} +interface HookMatcher { + matcher?: string; + hooks: HookCommand[]; +} +type HooksConfig = Record; + +function readClaudeConfig(): Record { + if (!existsSync(CLAUDE_CONFIG)) return {}; + const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } 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) ?? {}); + 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 | 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): 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 `. + */ +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 { + if (!existsSync(CLAUDE_SETTINGS)) return {}; + const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } catch (e) { + throw new Error( + `failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`, + ); + } +} + +function writeClaudeSettings(obj: Record): 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((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; + 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: ◇ · / online · `)); + } 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 ")}` + + 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."); +} diff --git a/apps/cli-v2/src/commands/invite.ts b/apps/cli-v2/src/commands/invite.ts new file mode 100644 index 0000000..61b6b2c --- /dev/null +++ b/apps/cli-v2/src/commands/invite.ts @@ -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 { + 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 { + 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 `."); + 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; + } +} diff --git a/apps/cli-v2/src/commands/join.ts b/apps/cli-v2/src/commands/join.ts new file mode 100644 index 0000000..36edd65 --- /dev/null +++ b/apps/cli-v2/src/commands/join.ts @@ -0,0 +1,193 @@ +/** + * `claudemesh join ` — full join flow. + * + * Accepts either: + * - v2 short invite: `claudemesh.com/i/` or bare `` + * → POSTs to /api/public/invites/:code/claim, unseals root_key, + * persists mesh + fresh ed25519 identity. + * - v1 legacy invite: `ic://join/` or `https://.../join/` + * → 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 { + 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 { + const link = args[0]; + if (!link) { + console.error("Usage: claudemesh join "); + 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/` 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."); +} diff --git a/apps/cli-v2/src/commands/launch.ts b/apps/cli-v2/src/commands/launch.ts new file mode 100644 index 0000000..d6c899c --- /dev/null +++ b/apps/cli-v2/src/commands/launch.ts @@ -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 { + 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 { + 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 { + 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 { + 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 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 { + // 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 `)}\n`); + + // Race: localhost callback vs manual paste vs timeout + const manualPromise = new Promise((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((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 ` or use --join ."); + 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 = {}; + try { + claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); + } catch { + claudeConfig = {}; + } + + const mcpServers = (claudeConfig.mcpServers ?? {}) as Record; + + // Session-scoped key: mesh:: + 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); +} diff --git a/apps/cli-v2/src/commands/leave.ts b/apps/cli-v2/src/commands/leave.ts new file mode 100644 index 0000000..190e6be --- /dev/null +++ b/apps/cli-v2/src/commands/leave.ts @@ -0,0 +1,25 @@ +/** + * `claudemesh leave ` — 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 "); + 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}`); +} diff --git a/apps/cli-v2/src/commands/list.ts b/apps/cli-v2/src/commands/list.ts new file mode 100644 index 0000000..14240ba --- /dev/null +++ b/apps/cli-v2/src/commands/list.ts @@ -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 { + 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 "); + console.log(" Join one: claudemesh mesh add \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(""); +} diff --git a/apps/cli-v2/src/commands/login.ts b/apps/cli-v2/src/commands/login.ts new file mode 100644 index 0000000..00d4117 --- /dev/null +++ b/apps/cli-v2/src/commands/login.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/cli-v2/src/commands/logout.ts b/apps/cli-v2/src/commands/logout.ts new file mode 100644 index 0000000..e8ee31d --- /dev/null +++ b/apps/cli-v2/src/commands/logout.ts @@ -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 { + 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; + } +} diff --git a/apps/cli-v2/src/commands/mcp.ts b/apps/cli-v2/src/commands/mcp.ts new file mode 100644 index 0000000..be2203e --- /dev/null +++ b/apps/cli-v2/src/commands/mcp.ts @@ -0,0 +1,9 @@ +import { startMcpServer } from "~/mcp/server.js"; + +export async function runMcp(): Promise { + await startMcpServer(); + await new Promise(() => {}); + process.exit(0); +} + +export { runMcp as _stub }; diff --git a/apps/cli-v2/src/commands/new.ts b/apps/cli-v2/src/commands/new.ts new file mode 100644 index 0000000..07040da --- /dev/null +++ b/apps/cli-v2/src/commands/new.ts @@ -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 { + if (!name) { + console.error(" Usage: claudemesh mesh create "); + 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; + } +} diff --git a/apps/cli-v2/src/commands/peers.ts b/apps/cli-v2/src/commands/peers.ts new file mode 100644 index 0000000..ab23c7e --- /dev/null +++ b/apps/cli-v2/src/commands/peers.ts @@ -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 { + 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 ` 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)); + } +} diff --git a/apps/cli-v2/src/commands/profile.ts b/apps/cli-v2/src/commands/profile.ts new file mode 100644 index 0000000..15d9017 --- /dev/null +++ b/apps/cli-v2/src/commands/profile.ts @@ -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 { + 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 ` 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 = {}; + 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; + 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; + 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>; 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, 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 ?? ""))}`); +} diff --git a/apps/cli-v2/src/commands/recall.ts b/apps/cli-v2/src/commands/recall.ts new file mode 100644 index 0000000..fc58a7a --- /dev/null +++ b/apps/cli-v2/src/commands/recall.ts @@ -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 { + 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; +} diff --git a/apps/cli-v2/src/commands/register.ts b/apps/cli-v2/src/commands/register.ts new file mode 100644 index 0000000..2cf62c5 --- /dev/null +++ b/apps/cli-v2/src/commands/register.ts @@ -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 { + return login(); +} diff --git a/apps/cli-v2/src/commands/remember.ts b/apps/cli-v2/src/commands/remember.ts new file mode 100644 index 0000000..1f4f64c --- /dev/null +++ b/apps/cli-v2/src/commands/remember.ts @@ -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 { + 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; +} diff --git a/apps/cli-v2/src/commands/remind.ts b/apps/cli-v2/src/commands/remind.ts new file mode 100644 index 0000000..275575e --- /dev/null +++ b/apps/cli-v2/src/commands/remind.ts @@ -0,0 +1,142 @@ +/** + * `claudemesh remind --in | --at