Files
claudemesh/.artifacts/specs/2026-04-10-cli-auth-device-code-pat.md
Alejandro Gutiérrez ee12510ef1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:44:52 +01:00

26 KiB
Raw Permalink Blame History

CLI Auth — Device Code Flow + Personal Access Tokens

Status: spec Created: 2026-04-10 Owner: CLI-Dev (implementation), Orchestrator (spec) Target version: v0.11.0 Related: 2026-04-10-anthropic-vision-meshes-invites.md, 2026-04-10-cli-wizard-architecture-refactor.md

Goal

The CLI is a first-class client. From a fresh terminal, with zero prior browser interaction, a user can:

claudemesh login                          # device-code OAuth, browser handshake
claudemesh create "Platform team"          # creates real mesh via /api/my/meshes
claudemesh invite --email alice@x.com      # generates invite, sends email
claudemesh launch --mesh platform-team -y  # spawns Claude Code in the mesh

For CI / scripting / non-interactive contexts, PAT works too:

claudemesh login --token cm_pat_abc123
claudemesh create "CI test mesh" --json | jq .id

This is the auth substrate that unblocks the "Anthropic vision" — every other dashboard-only feature (meshes, invites, members, billing) becomes CLI-accessible after this lands.

Non-goals

  • SSO / SAML / enterprise IdP integration (later, post-1.0)
  • Refresh tokens with rotation (long-lived API keys are sufficient for v1)
  • Multi-account switching (one logged-in identity per ~/.claudemesh/auth.json)
  • Device fleet management UI (single "revoke" button per token is enough for v1)

Auth model overview

Two coexisting credential types, both backed by Better Auth's apiKey plugin:

Type Created via Lifetime Use case Storage
Device-code session token claudemesh login (OAuth-style browser handshake) 90 days, auto-renew on use Interactive humans on their workstation ~/.claudemesh/auth.json
Personal access token (PAT) Dashboard → Settings → CLI tokens → Generate User-chosen (30d / 90d / 1y / never), explicit revocation CI, scripts, automation, server-side cron Anywhere the user puts it; CLI reads from --token flag, env var, or auth.json

Both flow through the same Authorization: Bearer cm_<type>_<random> header. The API doesn't care which one it gets — it just validates against the api_key table.

Token format:

  • cm_session_<32-byte base32> — device-code sessions
  • cm_pat_<32-byte base32> — personal access tokens

The cm_ prefix lets us scan for leaked tokens with regex (e.g. GitHub secret scanning, internal scripts). The middle segment (session / pat) is for human readability in token lists, not for security.

User flows

1. First-time login (interactive happy path)

$ claudemesh login

  ██  claudemesh login

  Opening browser for authentication…

  If your browser didn't open, visit:
    https://claudemesh.com/cli-auth?code=ABCD-EFGH

  Enter this code:
    ABCD-EFGH

  Waiting for confirmation… ⠋

In the browser:

  1. User lands on /cli-auth?code=ABCD-EFGH
  2. If not signed in, Better Auth login screen appears, then redirects back
  3. User sees a confirmation card:
    Link this CLI session?
    Code: ABCD-EFGH
    Device: Alejandro's MacBook Pro · darwin · arm64
    Expires in 9:47
    [Approve] [Deny]
    
  4. User clicks Approve

CLI polls every 1.5s, sees approved, receives token, writes ~/.claudemesh/auth.json with 0600, prints:

  ✔ Authenticated as Alejandro Gutiérrez
  ✔ Token saved to ~/.claudemesh/auth.json
  ✔ Synced 3 meshes: alexis-mou, dev, claudefarm

  Run claudemesh --help to get started.

2. First-time login (PAT, non-interactive)

$ claudemesh login --token cm_pat_abc123def456...
  ✔ Authenticated as Alejandro Gutiérrez (via PAT "ci-deploy")
  ✔ Token saved to ~/.claudemesh/auth.json

Or one-shot, no save:

$ CLAUDEMESH_TOKEN=cm_pat_abc123 claudemesh create "test"

3. Already logged in, runs a command

$ claudemesh create "Platform team"
  ✔ Created mesh platform-team (id: q5RI89Fl…)
  ✔ Joined locally
  ▸ Invite peers: claudemesh invite --mesh platform-team

No auth prompt — token in auth.json is used silently.

4. Token expired or revoked

$ claudemesh peers
  ✘ Authentication failed (token expired or revoked)

  Run claudemesh login to re-authenticate.

Exit code 2. The auth.json is not auto-deleted (user might be debugging) but the next claudemesh login overwrites it cleanly.

5. Wizard launch flow with auth integration

When claudemesh (bare, no auth) is run:

  ██  claudemesh

  ▸ Sign in (opens browser)
    Paste a personal access token
    Join a mesh via invite URL
    Exit

After auth completes, the wizard transitions naturally into the launch flow (mesh picker → name → role → confirm → handoff). One uninterrupted experience from "fresh install" to "Claude Code in a mesh."

6. CI / non-interactive

# .github/workflows/test.yml
- run: |
    claudemesh login --token ${{ secrets.CLAUDEMESH_PAT }}
    claudemesh create "CI run $GITHUB_RUN_ID" --json > mesh.json

Or zero-state:

- env:
    CLAUDEMESH_TOKEN: ${{ secrets.CLAUDEMESH_PAT }}
  run: claudemesh create "CI run $GITHUB_RUN_ID" --json

Token resolution order: --token flag > CLAUDEMESH_TOKEN env var > ~/.claudemesh/auth.json.

7. Logout

$ claudemesh logout
  ✔ Token revoked on server
  ✔ Removed ~/.claudemesh/auth.json

logout calls DELETE /api/my/cli/sessions/current to revoke server-side, then unlinks the local file. Best-effort: if the server call fails, still delete locally and warn.

Architecture

Backend — Better Auth apiKey plugin

Better Auth ships an apiKey plugin that handles:

  • Token generation (cryptographically random)
  • Hashed storage (only the hash hits the DB; raw token never persisted)
  • Verification middleware (validates Authorization: Bearer …)
  • Per-token metadata (name, scopes, expiry, last-used)
  • Per-token revocation

We use it for both PAT and device-code sessions. Device-code sessions just have a marker in metadata distinguishing them from user-generated PATs.

Wire-up: apps/web/src/lib/auth/index.ts (or wherever Better Auth is initialized) adds:

import { apiKey } from "better-auth/plugins";

export const auth = betterAuth({
  // …existing config
  plugins: [
    // …
    apiKey({
      enableMetadata: true,
      apiKeyHeaders: ["x-api-key", "authorization"],
      defaultPrefix: "cm_",
      rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 100 },
    }),
  ],
});

Backend — device-code table

The apiKey plugin doesn't ship device-code flow out of the box. We add a small table + 4 endpoints on top.

-- packages/db/migrations/0020_cli-device-code.sql
CREATE TABLE cli_device_code (
  device_code      text PRIMARY KEY,             -- opaque random, sent to CLI
  user_code        text UNIQUE NOT NULL,         -- short human code: "ABCD-EFGH"
  user_id          text REFERENCES "user"(id),   -- nullable until approved
  api_key_id       text REFERENCES api_key(id),  -- the issued token, set on approve
  device_name      text NOT NULL,                -- "Alejandro's MacBook Pro"
  device_os        text NOT NULL,                -- "darwin"
  device_arch      text NOT NULL,                -- "arm64"
  ip_address       text,                         -- for audit
  user_agent       text,
  status           text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'denied' | 'expired'
  created_at       timestamptz NOT NULL DEFAULT now(),
  expires_at       timestamptz NOT NULL,         -- created_at + 10 min
  approved_at      timestamptz
);

CREATE INDEX cli_device_code_user_code_idx ON cli_device_code(user_code);
CREATE INDEX cli_device_code_status_expires_idx ON cli_device_code(status, expires_at);

A scheduled job (or lazy cleanup on insert) deletes rows where status='expired' AND expires_at < now() - interval '7 days'.

Backend — endpoints

All under apps/web/src/app/api/auth/cli/ (or wherever you keep public auth routes — these need to be unauthed since the CLI has no token yet).

Method Path Auth Purpose
POST /api/auth/cli/device-code none CLI requests a new device code. Body: { device_name, device_os, device_arch }. Returns { device_code, user_code, expires_at, verification_url }.
GET /api/auth/cli/device-code/:device_code none CLI polls for status. Returns `{ status: 'pending'
POST /api/auth/cli/device-code/:user_code/approve session Browser confirms. Creates an api_key row with metadata { kind: 'session', device_name, device_code }, sets cli_device_code.api_key_id, status=approved.
POST /api/auth/cli/device-code/:user_code/deny session Browser denies. Sets status=denied.

Authed endpoints (under /api/my/cli/):

Method Path Purpose
GET /api/my/cli/sessions List active CLI sessions for the user (devices, last seen, created).
DELETE /api/my/cli/sessions/:id Revoke a specific session.
POST /api/my/cli/tokens Create a PAT. Body: { name, expires_in_days?, scopes? }. Returns the raw token once.
GET /api/my/cli/tokens List PATs (no raw values, just metadata).
DELETE /api/my/cli/tokens/:id Revoke a PAT.

Backend — middleware

Existing enforceAuth (in packages/api/src/utils/) currently reads cookies. Extend it to also accept Authorization: Bearer cm_…:

export async function enforceAuth(ctx) {
  const bearer = ctx.req.headers.get("authorization")?.replace(/^Bearer /, "");
  if (bearer?.startsWith("cm_")) {
    const result = await auth.api.verifyApiKey({ key: bearer });
    if (result.valid) {
      // record last_used_at, increment usage counter
      return { user: result.user, via: "apiKey", apiKey: result.apiKey };
    }
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
  }
  // …existing cookie-based auth
}

The apiKey plugin handles last_used_at updates automatically.

Backend — web route

apps/web/src/app/[locale]/cli-auth/page.tsx:

  • Reads ?code=ABCD-EFGH from query string
  • If no session, redirects to /login?next=/cli-auth?code=ABCD-EFGH
  • If session, fetches device code metadata via server component, renders confirmation card
  • Approve button → POST /api/auth/cli/device-code/:user_code/approve
  • Deny button → POST /api/auth/cli/device-code/:user_code/deny
  • After approve, shows: "✓ CLI authenticated. Return to your terminal."

Mobile-friendly. Confirmation card shows device fingerprint so the user can verify they're approving the right session.

Backend — dashboard PAT UI

apps/web/src/app/[locale]/dashboard/settings/cli-tokens/page.tsx:

  • List of existing PATs (name, created, last used, expires)
  • "Generate new token" button → modal with name + expiry picker
  • After creation, show raw token once with copy button + warning ("This token will not be shown again")
  • Per-row revoke button

Reuses existing dashboard layout. Should be ~150 lines including the modal.

CLI — file layout

apps/cli/src/
├── commands/
│   ├── login.ts            # NEW
│   ├── logout.ts           # NEW
│   ├── whoami.ts           # NEW
│   ├── create.ts           # rewrite to call API
│   ├── invite.ts           # NEW
│   ├── sync.ts             # rewrite to call API
│   └── …existing
└── lib/
    ├── auth-store.ts       # NEW: read/write ~/.claudemesh/auth.json
    ├── api-client.ts       # NEW: typed fetch wrapper
    ├── device-info.ts      # NEW: collect hostname, os, arch for device-code request
    └── …existing

CLI — auth-store.ts

// ~/.claudemesh/auth.json
type AuthFile = {
  version: 1;
  token: string;            // cm_session_… or cm_pat_…
  user: { id: string; name: string; email: string };
  created_at: string;       // ISO
  source: "device-code" | "pat" | "env";
};

Read priority: --token flag > CLAUDEMESH_TOKEN env > auth.json. Write only on login success. File mode 0600. Parent dir 0700. On read, if file mode is too permissive, log a warning and continue.

CLI — api-client.ts

Thin wrapper over fetch:

export class ClaudemeshApi {
  constructor(private opts: { baseUrl: string; token: string }) {}

  async createMesh(input: { name: string; slug?: string }) {  }
  async listMeshes() {  }
  async createInvite(input: { meshId: string; email?: string; role?: string }) {  }
  async listSessions() {  }
  async revokeSession(id: string) {  }
  async whoami() {  }
}

Type definitions live in packages/api/src/contracts/cli.ts (new file) — generated from the existing tRPC routers as plain types so the CLI doesn't need to import the whole tRPC client.

Base URL from CLAUDEMESH_API_URL env var, defaults to https://claudemesh.com. Allows local dev against http://localhost:3000.

CLI — device-code login flow

// commands/login.ts
async function deviceCodeLogin() {
  const device = collectDeviceInfo();
  const { device_code, user_code, expires_at, verification_url } =
    await api.requestDeviceCode(device);

  console.log(`  Opening ${verification_url}…`);
  console.log(`  Code: ${user_code}`);

  await openBrowser(`${verification_url}?code=${user_code}`);

  const spinner = ora("Waiting for confirmation").start();
  const deadline = new Date(expires_at).getTime();

  while (Date.now() < deadline) {
    await sleep(1500);
    const result = await api.pollDeviceCode(device_code);
    if (result.status === "approved") {
      spinner.succeed("Authenticated");
      await authStore.write({ token: result.token, user: result.user, source: "device-code" });
      await syncMeshes();
      return;
    }
    if (result.status === "denied") {
      spinner.fail("Denied in browser");
      process.exit(1);
    }
  }
  spinner.fail("Timed out");
  process.exit(1);
}

Polls every 1.5s. Server returns { slow_down: true } if polled too fast (rate limit at 1/sec).

Security

  1. Tokens are hashed at rest (Better Auth apiKey plugin handles this with bcrypt or argon2).
  2. Raw tokens shown to user once. PATs in dashboard, device-code tokens via claudemesh login output. Never logged, never re-displayable.
  3. auth.json is 0600. CLI refuses to write if parent dir can't be made 0700. Warns on read if mode is wider.
  4. Token prefix cm_ enables secret scanning. Document the regex cm_(session|pat)_[a-z0-9]{32,} in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks.
  5. /api/auth/cli/device-code/:device_code polling is rate-limited to 1 req/sec per IP per device_code. Returns 429 with slow_down: true body.
  6. Device codes expire in 10 minutes. Approved-but-unclaimed tokens stay valid (the polling endpoint still returns the token for 60 seconds after approval, then the device_code row is GC'd).
  7. Audit logging. Every device-code approval, PAT creation, and PAT revocation emits an audit event (auth.cli.session.created, auth.cli.pat.created, etc.). Stored in existing audit log if there is one, otherwise new audit_log table.
  8. Session invalidation on password change. When a user changes their password via Better Auth, all cli_session api_key rows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed).
  9. Token revocation is immediate. auth.api.verifyApiKey checks DB on every request — no in-memory cache.
  10. No CSRF concern for device-code endpoints — the unauthed ones don't act on user state, the authed ones use Better Auth's existing CSRF protection.

Wizard UX integration

The current welcome wizard already has:

▸ Create account (new to claudemesh)
  Sign in (existing account)
  Paste an invite URL
  Exit

After this spec lands, the welcome screen becomes:

  ██  claudemesh

  ▸ Sign in            ← device-code OAuth
    Paste an access token   ← PAT path
    Join via invite URL     ← unchanged
    Create account          ← opens /register, then back to login
    Exit

"Sign in" becomes the headline option. The current "Create account" still opens browser to /register but flows back through the device-code handshake instead of a custom callback.

Once authenticated, the wizard transitions to:

  ██  claudemesh launch

  Account    ✔  Alejandro Gutiérrez
  Mesh       ▸  (pick one — 3 available)
  Name       ✔  Alexis (from --name)
  Role       ▸  (pick one)

  ▸ Continue
    Cancel

Status rows show what's filled and what's left. Mesh picker fetches from GET /api/my/meshes via the freshly minted token.

This integrates cleanly with the wizard architecture refactor in 2026-04-10-cli-wizard-architecture-refactor.md: auth becomes one screen in the launch flow with isComplete: s => s.user !== null. On a fresh machine the auth screen runs; on a returning machine it's auto-skipped.

Error handling

Scenario Behavior
Browser doesn't open Print URL prominently, keep polling
Network down during poll Retry with exponential backoff (1.5s → 3s → 6s, max 30s)
Device code expires Print "Login timed out, run claudemesh login to retry", exit 1
Token rejected by API Print "Authentication failed", suggest claudemesh login, exit 2
auth.json corrupted Print "Auth file corrupted, run claudemesh login", exit 2
auth.json permissions wrong Warn, fix to 0600, continue
PAT pasted to --token is malformed Print "Invalid token format (expected cm_pat_…)", exit 1
PAT pasted to --token is valid format but unknown API returns 401, print "Token rejected", exit 2
Two CLI instances poll simultaneously Both get the same approved status; first to read gets the token, second gets { status: 'approved', token: null } (already_claimed). Document this.
User clicks Approve in browser, then closes tab CLI's poll catches it, login succeeds. The browser tab closure is irrelevant.
User completes login on machine A, then runs claudemesh login on machine B with same account Both sessions coexist as separate api_key rows. claudemesh whoami --sessions shows both.

Implementation phases

Each phase ships independently and is independently testable.

Phase 1 — Backend foundation (46 hours)

  • Wire Better Auth apiKey plugin in apps/web/src/lib/auth/
  • Migration 0020_cli-device-code.sql
  • Drizzle schema for cli_device_code in packages/db/src/schema/auth.ts
  • Endpoints: POST /api/auth/cli/device-code, GET /api/auth/cli/device-code/:device_code, POST /api/auth/cli/device-code/:user_code/approve, POST /api/auth/cli/device-code/:user_code/deny
  • Extend enforceAuth middleware to accept Authorization: Bearer cm_…
  • Endpoints: POST /api/my/cli/tokens, GET /api/my/cli/tokens, DELETE /api/my/cli/tokens/:id, GET /api/my/cli/sessions, DELETE /api/my/cli/sessions/:id
  • Unit tests for token verification and device-code state machine

Phase 2 — Web routes (34 hours)

  • /cli-auth?code=... page (server component + approve/deny client component)
  • /dashboard/settings/cli-tokens page (list + create modal + revoke)
  • Translations for both pages (en, es)
  • E2E test: full device-code happy path with Playwright

Phase 3 — CLI auth core (45 hours)

  • lib/device-info.ts — collect hostname, os, arch
  • lib/auth-store.ts — read/write ~/.claudemesh/auth.json with mode checks
  • lib/api-client.ts — typed fetch wrapper with bearer header
  • commands/login.ts — device-code flow + --token PAT path
  • commands/logout.ts — revoke + delete local
  • commands/whoami.ts — print current identity + token source
  • Token resolution helper (--token > CLAUDEMESH_TOKEN > auth.json)
  • Unit tests for auth-store and token resolution

Phase 4 — CLI commands wired to API (34 hours)

  • Rewrite commands/create.ts to call POST /api/my/meshes
  • New commands/invite.ts with --email, --mesh, --role, --expires-in
  • Rewrite commands/sync.ts to call GET /api/my/meshes and reconcile local config
  • Update commands/list.ts to show server-side meshes too
  • Integration tests against staging broker + web

Phase 5 — Wizard integration (34 hours)

  • Welcome screen new options (Sign in / Paste token / Create account / Join invite)
  • Auth screen as a flow step with isComplete: s => s.user !== null
  • Status rows pattern showing auth state during launch
  • First-run detection (no auth.json) → auto-route to login

Phase 6 — Polish, docs, ship (23 hours)

  • Update README.md, apps/cli/README.md, docs/quickstart.md
  • CHANGELOG entry for v0.11.0
  • Telemetry events for auth.cli.login.{start,success,fail}
  • Bump apps/cli/package.json to 0.11.0
  • Publish to npm
  • Deploy broker / web (no broker changes, web for new routes)

Total estimate: 1926 hours of focused work. Realistic: 34 days with testing and review.

Dependencies between phases

Phase 1 (backend) ──┬─→ Phase 2 (web routes)
                    └─→ Phase 3 (CLI auth core)
                              │
                              └─→ Phase 4 (commands)
                                        │
                                        └─→ Phase 5 (wizard)
                                                  │
                                                  └─→ Phase 6 (ship)

Phase 1 and 2 can be parallelized after the schema lands. Phase 3 needs Phase 1 endpoints live (even if on staging). Phase 4 onwards is strictly serial.

Telemetry

Emit these events (PostHog or whatever the existing analytics are):

  • cli.login.started — properties: { method: 'device-code' | 'pat' }
  • cli.login.succeeded — properties: { method, user_id }
  • cli.login.failed — properties: { method, reason }
  • cli.logout — properties: { user_id }
  • cli.command.executed — properties: { command, exit_code, duration_ms, authenticated: boolean }
  • cli.api.error — properties: { endpoint, status, error_code }

Telemetry is opt-out. First run shows a one-line notice: "claudemesh collects anonymized usage telemetry. Disable with claudemesh telemetry off."

Open questions

  1. Better Auth apiKey plugin version — confirm it's installed and at a version that supports enableMetadata. Check pnpm why better-auth in apps/web.
  2. Audit log table — does one already exist? If not, this spec adds three rows of log; not worth a new table for that. Use console.log with structured JSON to stderr and let the platform's log collector handle it.
  3. Email sendingclaudemesh invite --email requires a transactional email path. Does the web app already have one (Resend, Postmark)? If yes, reuse. If no, defer the email send to a follow-up; the invite command can still create the invite and print the URL.
  4. Token scopes — v1 ships with no scopes; every token has full account access. Should we add mesh:read, mesh:write, invite:create scopes from day one, or wait? Recommendation: wait. YAGNI. Add when a user actually wants a read-only CI token.
  5. PAT expiry default — 90 days? 1 year? Never? Better Auth supports all three. Recommendation: 1 year default, user can pick "never" with explicit warning.
  6. Mesh slug uniqueness in claudemesh create — what happens if two users try to create meshes with the same slug? Existing API behavior should be tested. If it errors, the CLI should suggest --slug platform-team-2.
  7. claudemesh login when already logged in — re-authenticate (overwrite) or error ("already logged in, run logout first")? Recommendation: re-authenticate silently with a one-line notice ("Replacing existing session for Alejandro").

Acceptance criteria

For v0.11.0 to ship, all of these must be true:

  • claudemesh login on a fresh machine (no auth.json) opens browser, completes device-code flow, writes auth.json, runs in <30 seconds end-to-end
  • claudemesh login --token cm_pat_… works without browser
  • claudemesh logout revokes server-side and deletes local file
  • claudemesh whoami prints user identity and token source
  • claudemesh create "Test mesh" creates a real mesh on the server, joins it locally, and the user can see it on the dashboard
  • claudemesh invite --email a@b.c --mesh test creates an invite and prints the URL
  • claudemesh launch (bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard
  • Dashboard /dashboard/settings/cli-tokens lists, creates, and revokes PATs
  • All flows work in en and es
  • Existing claudemesh launch invocations (with token already in auth.json) still work without prompting
  • Token in auth.json survives an hour of idle and continues to work (no aggressive expiry)
  • Revoking a token in the dashboard makes the next CLI call fail with a clear error
  • Documentation updated in README.md, apps/cli/README.md, docs/quickstart.md
  • CHANGELOG entry written
  • Published to npm as claudemesh-cli@0.11.0

What this unlocks

Once this lands, every dashboard-only feature becomes one CLI command away. Future specs that depend on this:

  • claudemesh members list / claudemesh members add
  • claudemesh billing usage
  • claudemesh mesh archive
  • claudemesh stream subscribe (live broker events)
  • claudemesh skill publish (publish a skill to mesh registry)
  • claudemesh log tail (mesh-wide audit log)

This is the foundational unlock. Everything else is incremental on top.