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

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

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

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

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

View File

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