diff --git a/SKILL.md b/SKILL.md index 6d0c2cd..d508e75 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,49 +1,318 @@ --- name: thomas -description: Universal adapter between AI agents and model providers. Use thomas to discover installed AI agents on a host, wire them through a local proxy, and switch which model provider any agent uses without editing the agent's own config. +description: Local proxy that lets any AI agent on this machine (Claude Code, Codex CLI, OpenClaw, Hermes Agent) use any model provider. Use thomas to discover installed agents, see what models they're using, switch providers, and manage credentials — without editing the agent's own config. --- # thomas -`thomas` is a CLI that lets any AI agent on a host (Claude Code, Codex CLI, OpenClaw, hermes-agent) talk to any model provider through a single local proxy. +`thomas` is a personal/solo CLI on the user's machine. It is single-user — never suggest team workflows. -## When to use +**Always pass `--json` to every command** when driving thomas programmatically. Both reads (`status`, `doctor`, `list`, `providers`, `daemon status`, `proxy status`) and writes (`connect`, `disconnect`, `route`, `providers add/remove/register/unregister`, `daemon install/uninstall`, `skill install/remove`) emit the same JSON envelope. Human-formatted text is for direct terminal use only. -Use thomas when the user wants to: -- See which AI agents are installed on this machine and what credentials they have. → `thomas doctor` -- Make an agent use a different model provider than the one it was configured with. → `thomas connect ` then `thomas route ` -- Reuse credentials already on the host (e.g., the local Claude CLI keychain entry) for a different agent. -- Stop routing an agent through thomas, restoring its original configuration. → `thomas disconnect ` +## Output contract -## Commands +JSON output is wrapped in a stable envelope: -| Command | Purpose | -| --- | --- | -| `thomas doctor` | Read-only scan: which agents are installed, where their config lives, what credential sources they have. Always start here. | -| `thomas connect ` | Install a PATH shim for the agent so its requests go to thomas's proxy. Imports the agent's existing credentials by default. | -| `thomas connect --no-import` | Install shim only; do not pull credentials from the agent. | -| `thomas connect --no-proxy` | Pull credentials into thomas's store, but do not install a shim. | -| `thomas disconnect ` | Remove the shim. The agent's original config is untouched and resumes working. | -| `thomas route ` | Change which model an agent uses. Does not touch the agent. | -| `thomas list` | Current state: connected agents, configured providers, active routes, proxy status. | -| `thomas skill install ` | Install this skill into the named agent's skill directory so the agent can drive thomas for the user. | +```json +{ + "schemaVersion": 1, + "command": "status", + "generatedAt": "2026-05-03T12:00:00.000Z", + "data": { /* command-specific shape */ } +} +``` -## Key design facts (so you don't have to guess) +On error: -- thomas **never modifies the agent's own config files**. It works by putting a shim in `$HOME/.thomas/bin/` that runs earlier in PATH than the real binary, sets the right env vars, and execs the real binary. If thomas is uninstalled, the shim disappears and the original agent works as before. -- The proxy listens on `http://127.0.0.1:51168` (configurable). It exposes `/messages` for Anthropic-shaped clients and `/v1/chat/completions` for OpenAI-shaped clients. -- Credentials are stored in `~/.thomas/credentials.json` (plaintext JSON, same scheme as openclaw — supports `keyRef` for users who want to point to a vault/env var instead). -- Routes (`agent → provider/model`) live in `~/.thomas/routes.json`. +```json +{ + "schemaVersion": 1, + "command": "...", + "generatedAt": "...", + "error": { "code": "E_...", "message": "...", "remediation": "..." } +} +``` -## How to drive thomas on the user's behalf +Always check `error` first. Schemas are defined in `src/cli/output.ts` of the thomas repo — fetch that file when you need an exhaustive field list. -1. Run `thomas doctor` first to see what's on the host. Show the output to the user verbatim. -2. If the user wants to switch an agent's model, run `thomas connect ` (defaults are usually right), then `thomas route `. -3. After any `connect`, the user should restart their agent's terminal session so the new shim is on PATH. -4. If anything misbehaves, run `thomas list` to see the current state and `thomas disconnect ` to revert. +Write subcommands use dotted command names in the envelope: `"providers.add"`, `"providers.remove"`, `"providers.register"`, `"providers.unregister"`, `"daemon.install"`, `"daemon.uninstall"`, `"skill.install"`, `"skill.remove"`. Top-level writes are just `"connect"`, `"disconnect"`, `"route"`. -## Troubleshooting hints +Note: a write command targeting absent state (e.g. `disconnect` an agent that wasn't connected, `providers remove` a provider with no key) is **not an error** — it returns `data` with a `wasConnected: false` / `removed: false` flag and exit code 0. Errors (exit 1) are reserved for validation failures and unrecoverable conditions. -- "Shim not on PATH" → the user's shell rc must include `$HOME/.thomas/bin` early in `PATH`. `thomas connect` prints the line to add. -- "Proxy not running" → the shim auto-starts the proxy daemon. If it failed, `~/.thomas/proxy.log` has the reason. -- "Agent still uses old credentials" → the agent process was started before the shim was installed. Restart the agent. +## Recipes by user intent + +### "Which agents are on my machine and what models are they using?" + +```sh +thomas status --json +``` + +Read `data.agents[]`: +- `connected: false` — agent installed but not wired through thomas (using its original config) +- `connected: true, effective: {provider, model}` — using that model via thomas +- `connected: true, effective: null` — connected but no route configured + +`data.proxy.running` tells you if the thomas proxy itself is up. + +### "Make [agent] use cheap model after spending $N/day on expensive one" + +```sh +thomas policy set --primary [--at =]... [--failover-to ] --json +``` + +Example: claude-code uses Opus normally; switches to Haiku once today's spend ≥ $5; switches to DeepSeek once ≥ $10; AND if any upstream returns a retryable error (5xx/429/timeout), retry on OpenRouter: + +```sh +thomas policy set claude-code \ + --primary anthropic/claude-opus-4-7 \ + --at 5=anthropic/claude-haiku-4-5 \ + --at 10=deepseek/deepseek-chat \ + --failover-to openrouter/anthropic/claude-opus-4 --json +``` + +`--at` is for **cost** (cascade by daily spend). `--failover-to` is for **reliability** (one-shot retry on transient errors). They're independent — set either or both. + +The proxy applies this on every request: actual model used = `effective` in `thomas status --json` (not `route`). `data.policies[].currentSpendDay` shows today's spend; `currentEffective` shows what's running right now; `currentReason` explains the decision in one line. When a failover fires, the run record will have `failovers: 1` and a `failoverNote` explaining what happened — visible via `thomas explain --run `. + +To inspect: `thomas policy --json`. To remove: `thomas policy clear --json`. + +Cost is computed from `runs.jsonl` per UTC day. Models without a price entry contribute nothing — confirm pricing exists for the cascade targets via `runs --json` (records will have `spend: null` if not). + +### "What's a good model setup for [agent] under $N/day?" + +```sh +thomas recommend --agent [--budget-day ] [--preference quality|balanced|cost] --json +``` + +Returns up to 3 ranked `data.suggestions[]`. Each has: +- `rationale` — one-sentence human-readable explanation, relay verbatim +- `policy` — the concrete primary/fallback/cascade structure +- `estimatedSpendDay` — projected USD/day at the user's last 7-day volume; **`null` means there's no run history yet** (tell user to use thomas a bit, then re-run recommend) +- `applyCommand` — executable shell command (`thomas route ...` or `thomas policy set ...`); show to user before running, then exec on confirmation + +Default ordering is `balanced` (cascade first as the compromise). `quality` puts pure-premium first; `cost` puts pure-cheap first. Default budget-day is half of the projected premium daily cost — so if the user gives no budget, the cascade trigger is data-driven. + +### "Add a price for [provider/model] thomas doesn't know" or "Override the built-in price" + +```sh +thomas prices [--json] # show all known prices +thomas prices set --input --output [--json] +thomas prices unset [--json] # remove an overlay entry +``` + +Use this for **OpenRouter routes**, **vLLM endpoints**, or any model thomas's hardcoded table doesn't cover. Without an overlay entry, runs through that model log `cost: null`. With one, cascade decisions and `recommend` cost projections also start working for that model. + +`thomas prices --json`'s `data.prices[]` has `source: "builtin" | "overlay"` so the agent can tell what's authoritative. `set` returns `overridesBuiltin: true` if the user is overriding a known model (warn them — usually unintentional). `unset` only removes overlay entries; builtins are not removable. + +To make an overlay model **eligible for `thomas recommend`**, pass `--protocol openai|anthropic --tier premium|balanced|cheap`: + +```sh +thomas prices set openrouter/super-opus --input 0.5 --output 1.0 \ + --protocol anthropic --tier premium --json +``` + +Without these flags, the entry only powers cost computation. With them, `recommend` will treat it as a candidate. Common pattern: a cheap OpenRouter route to a premium model — tag it `tier=premium` and the recommender will suggest it over Anthropic's direct Opus. + +### "Why did this run cost so much / fail?" or "How is [agent] doing today?" + +```sh +thomas explain --run --json # one specific run +thomas explain --agent --json # agent's current state + today's spend +``` + +Returns `{ subject: {type, id}, narrative, facts[] }`. Use `narrative` as a one-paragraph human answer (already self-contained — relay verbatim or reword for the user). Use `facts[]` if the user wants details: each has a `kind` (`route` / `policy-applied` / `cascade` / `cost` / `error`) and a `detail` string. + +`--run` accepts a UUID prefix (8 chars usually unique enough). For `--agent`, the narrative covers: connected status, static route, active policy + cascade decision (post-spend), today's run count + spend, last error if any. Matches what the user actually asks ("how's openclaw doing"). + +### "What did my agents spend / how many tokens did they use?" + +```sh +thomas runs --json [--agent ] [--since ] [--limit ] +``` + +Returns `data.runs[]` newest-first. Each entry has `tokens.{input,output}`, `spend` (USD; **`null` means the model has no known price** — relay as "cost unknown", not "free"), `durationMs`, `status`, `modelsUsed[]`. Default limit 20. Use `--agent claude-code` for one agent, `--since 2026-05-01` for a date window. + +For "what's expensive", sum `data.runs[].spend ?? 0` after filtering null. For "how long do my Claude runs take", inspect `durationMs`. + +By default, `runs` groups HTTP requests sharing the same `X-Thomas-Run-Id` header into one logical task — `modelCalls > 1` means the agent issued multiple model calls under one run-id. Use `--per-call` to see raw HTTP-level rows. `tokens` and `spend` are sums; `failovers` is the count across all calls. + +### Sending `X-Thomas-Run-Id` from your own agent + +If you're building an agent on top of thomas (rather than driving an existing one), set `X-Thomas-Run-Id: ` on every request belonging to one user task. Thomas groups them in `runs` and `explain --run`, so cost and token totals reflect the whole task — not just one call. The id is opaque (UUID, hash of the user prompt, anything stable). Without the header thomas generates a per-request UUID and each call shows up as its own run. + +Token counts come from upstream `usage` fields. Anthropic always emits them. For OpenAI-protocol upstreams, thomas auto-injects `stream_options.include_usage = true` on streaming requests so the final SSE chunk carries token counts — the user's SDK doesn't need to set it. If a request explicitly opts out (`include_usage: false`), thomas respects that, and `tokens` will be 0 / `spend` will be null for those runs. + +### "More detail about installed agents — binaries, configs, credentials" + +```sh +thomas doctor --json [--check] +``` + +Returns `data.agents[]` with `binaryPath`, `configPath`, `connectMode` (`shim-env` vs `config-file`), `credentials[]` (keychain / file / env), `skillInstalled`. Use this when the user is troubleshooting "why isn't [agent] picked up?". + +`data.providerHealth` is `null` unless `--check` is passed; with `--check`, thomas probes each provider that has credentials (one HTTP `GET /v1/models` per provider, ~4s timeout) and returns a `ProviderProbe[]` (same schema as connect's `providerProbes[]` — see ["Wire [agent]…" section](#user-content-wire-agent-through-thomas)). Use `--check` when the user reports "thomas can't reach " or "I keep getting 401 from " — it pinpoints whether the URL or the credential is wrong. + +### "Make [agent] use [provider/model]" + +Two-step: + +```sh +thomas connect --json +thomas route / --json +``` + +`connect` returns `data` with `shimPath`, `credentialsImported[]`, `configMutated`, `snapshotPath`, `requiresShellReload`, `providerProbes[]`, and `notes[]` (relay each note to the user verbatim — they cover gotchas like the Claude Code OAuth limitation, plus any provider reachability warnings from probes). `requiresShellReload` is retained for schema stability but is now always `false`: a successful connect already implies `~/.thomas/bin` is on `$PATH` ahead of the original binary in the current shell. + +If `~/.thomas/bin` is **not** on `$PATH` (or appears after the agent's real binary), connect refuses with `error.code = "E_SHIM_NOT_ON_PATH"` and rolls back atomically — no shim, no config patch, no recorded connection. Relay `error.remediation` to the user verbatim (it includes the exact `export PATH=...` line for their shell rc) and ask them to start a new shell, then re-run `thomas connect `. + +**Provider reachability probes.** For every newly-imported provider, connect does a single `GET /v1/models` and surfaces the result as `providerProbes[]`: + +```jsonc +[{ "provider": "vllm", "ok": true, "status": 200, "url": "https://...", "latencyMs": 142 }] +[{ "provider": "vllm", "ok": false, "reason": "wrong_path", "status": 404, "url": "...", "message": "HTTP 404 at /v1/models", "latencyMs": 87 }] +[{ "provider": "vllm", "ok": false, "reason": "auth_failed", "status": 401, "url": "...", "message": "HTTP 401", "latencyMs": 95 }] +[{ "provider": "vllm", "ok": false, "reason": "unreachable", "status": null, "url": "...", "message": "fetch failed: ECONNREFUSED", "latencyMs": 12 }] +[{ "provider": "vllm", "ok": false, "reason": "other", "status": 503, "url": "...", "message": "HTTP 503", "latencyMs": 33 }] +``` + +Probes are advisory — connect does NOT fail on probe issues (a provider may be intentionally offline, e.g. a local vllm). Each `ok: false` probe is also rendered as a one-line `note` for direct relay; agents can prefer the structured field for programmatic action. Treat `wrong_path` as a strong signal the user's `originBaseUrl` is incorrect; suggest `thomas providers register --base-url `. + +`route` returns `data` with `previous` (prior route, may be null) and `current` (new route). Useful for confirming the change to the user. + +Before recommending a route, confirm the provider has credentials: run `thomas providers --json` and check `data.providers[].hasCredentials` for that provider id. If `false`, run `thomas providers add --json` first. + +### "Restore [agent] to its original setup" + +```sh +thomas disconnect --json +``` + +Removes the shim. The agent's own config is untouched; for openclaw (config-mode), thomas restores from a snapshot. Response: `{ agent, wasConnected, shimRemoved, configReverted }`. If `wasConnected: false`, no work was needed. + +### "Show me which providers I have keys for" + +```sh +thomas providers --json +``` + +Read `data.providers[]`: +- `hasCredentials: true` — user has a key (thomas does NOT return the key itself) +- `credentialSource` — `"thomas-store"` | `"env"` | `"keychain"` | `null` +- `isCustom: true` — user-registered provider (e.g. their own vLLM endpoint) + +### "Add an API key for [provider]" + +```sh +thomas providers add --json +``` + +If `` is not built-in or registered, returns `error.code = "E_PROVIDER_NOT_FOUND"`. Built-ins: `anthropic`, `openai`, `openrouter`, `kimi`, `deepseek`, `groq`. Response on success: `{ provider, replacedExisting }` — `replacedExisting: true` means the user already had a key and it was overwritten. + +To add a custom OpenAI/Anthropic-compatible endpoint first: + +```sh +thomas providers register --protocol --base-url --json +thomas providers add --json +``` + +### "Is thomas running? Make it always-on." + +- Status: `thomas proxy status --json` → `data.running` +- Persistent service: `thomas daemon install --json` → `{ platform, label, running }` (LaunchAgent on macOS, systemd user service on Linux) +- Daemon state: `thomas daemon status --json` + +### "Install the thomas skill into [agent]" + +```sh +thomas skill install --json +``` + +Currently supports claude-code (writes to `~/.claude/skills/thomas/`). Returns `{ agent, path }`. For unsupported agents returns `error.code = "E_INVALID_ARG"` with remediation pointing to manual install. + +## Driving thomas-cloud (optional SaaS) + +`thomas-cloud` is a separate hosted product for cost-cascade policies, multi-provider bundles, and run analytics. It's optional — local thomas works fine without it. When the user has a thomas-cloud account, the local CLI is the *only* way to drive it (federated design — Claude Code never holds the SaaS API key directly). + +### "Sign in to thomas-cloud" + +```sh +thomas cloud login +``` + +Interactive (no `--json`): prints a verification URL + 8-char user code, polls `/v1/devices/poll` until the user approves in their browser, persists the device token to `~/.thomas/cloud.json`. The user must visit the URL and click Approve while signed in to thomas-cloud. + +Override the SaaS endpoint with `--base-url http://localhost:8000` (local dev) or `THOMAS_CLOUD_BASE_URL` env. Default is `https://thomas.trustunknown.com`. + +### "Am I signed in? What workspace am I attached to?" + +```sh +thomas cloud whoami --json +``` + +Returns `{ loggedIn, baseUrl, workspaceId, deviceId, loggedInAt, lastSyncAt }`. Local-only — no network call. Use this when the user asks "what's my cloud state" without committing to a sync round-trip. + +### "Pull the latest policy / bundles from cloud" + +```sh +thomas cloud sync --json +``` + +Returns `{ schemaVersion, policiesCount, bundlesCount, bindingsCount, providersCount, redactRulesVersion, syncedAt }`. Hits the cloud, writes the snapshot to `~/.thomas/cloud-cache.json` (the local proxy reads from this cache for policy decisions). When v1 ships, the snapshot is empty by design — this just exercises the wiring. + +### "Sign out of cloud" + +```sh +thomas cloud logout --json +``` + +Returns `{ wasLoggedIn }`. Local-only: clears `~/.thomas/cloud.json`. Idempotent — second call returns `wasLoggedIn: false`. + +## Critical design facts + +- **thomas does not modify the agent's own config** for shim-env agents (claude-code, codex, hermes). It works by putting a wrapper at `~/.thomas/bin/` earlier on PATH that exports `*_BASE_URL` env vars and execs the real binary. +- **OpenClaw is the exception** — it doesn't read base-URL env vars, so thomas mutates `~/.openclaw/openclaw.json` additively, snapshots prior values to `~/.thomas/snapshots/openclaw.json`, and `disconnect` reverts cleanly. +- **Single-user scope.** No multi-tenant, no RBAC, no shared state. Don't suggest team workflows — explicit non-goal. +- **Claude Code OAuth tokens don't work for direct Anthropic API.** If the user only has the OAuth (from `claude login`), `thomas connect claude-code` warns them — they need an `sk-ant-` API key for actual passthrough. Run `thomas providers add anthropic sk-ant-…` after. + +## Error codes + +When `error.code` is set in JSON output, relay `error.remediation` to the user verbatim when present. + +| Code | Meaning | +|---|---| +| `E_INVALID_ARG` | Bad CLI arg | +| `E_AGENT_NOT_FOUND` | Agent id unknown to thomas (`details.known` lists valid ones) | +| `E_AGENT_NOT_INSTALLED` | Agent id valid but binary not found | +| `E_AGENT_NOT_CONNECTED` | Agent never ran `thomas connect` | +| `E_PROVIDER_NOT_FOUND` | Provider id not registered | +| `E_PROVIDER_AUTH` | Provider rejected the credentials | +| `E_PROVIDER_UNREACHABLE` | Network failure reaching the provider | +| `E_CREDENTIAL_MISSING` | Route points at a provider with no key | +| `E_PROXY_NOT_RUNNING` | thomas proxy is down — try `thomas proxy start` or `thomas daemon install` | +| `E_PORT_IN_USE` | Proxy can't bind its port | +| `E_CONFIG_CONFLICT` | Config-mode connect would clobber a non-thomas entry the user wrote | +| `E_SHIM_NOT_ON_PATH` | `~/.thomas/bin` not on `$PATH` (or shadowed by the original binary). Connect rolled back. `details.reason` is `"missing"` or `"shadowed"`; `details.binDir` and `details.pathEntries` show the gap. Relay `error.remediation` verbatim. | +| `E_SNAPSHOT_MISSING` | Disconnect can't find the snapshot to restore from | +| `E_CLOUD_NOT_LOGGED_IN` | `thomas cloud sync` (or anything that requires the device token) before `thomas cloud login` ran. Suggest `thomas cloud login`. | +| `E_CLOUD_UNAUTHORIZED` | thomas-cloud rejected the device token (revoked, deleted workspace, etc.). Suggest `thomas cloud logout && thomas cloud login` to re-auth. | +| `E_CLOUD_UNREACHABLE` | Network / DNS / 5xx from thomas-cloud. Suggest checking connectivity or `THOMAS_CLOUD_BASE_URL`. | +| `E_CLOUD_TIMEOUT` | Login user-code expired (10 min) or sync request timed out. Re-run the same command. | +| `E_INTERNAL` | Unexpected — show the message | + +Full list in `src/cli/output.ts`. + +## Troubleshooting + +| Symptom | Likely cause | Action | +|---|---|---| +| `connect` returns `E_SHIM_NOT_ON_PATH` | `~/.thomas/bin` missing from `$PATH` or shadowed | Relay `error.remediation` (it has the exact `export PATH` line + correct rc file); ask user to start a new shell and re-run `connect` | +| Proxy not running | daemon failed or never started | `thomas proxy start`, or `thomas daemon install` for persistence; check `~/.thomas/proxy.log` | +| Agent still uses old creds after connect | agent process started before the shim | restart that agent's terminal/session | +| connect succeeded but agent fails to call API | OAuth token only (no API key) | run `thomas providers add anthropic sk-ant-…` | + +## Difference between `status` and `list` + +- `status` — operational dashboard. Per-agent **effective** model (post-cascade once L3 lands), proxy state. Use this for "what's going on right now". +- `list` — configured state. All providers, all routes, supervision state. Use this for "what's wired up". + +When in doubt, start with `thomas status --json`. diff --git a/src/cloud/cache.ts b/src/cloud/cache.ts new file mode 100644 index 0000000..a7369e5 --- /dev/null +++ b/src/cloud/cache.ts @@ -0,0 +1,23 @@ +// Read / write ~/.thomas/cloud-cache.json — the snapshot pulled from /v1/sync. + +import { readJson, writeJsonAtomic } from "../config/io.js"; +import { paths } from "../config/paths.js"; +import type { CloudSnapshot } from "./types.js"; + +const EMPTY: CloudSnapshot = { + schemaVersion: 1, + policies: [], + bundles: [], + bindings: [], + providers: [], + redactRulesVersion: null, + syncedAt: "", +}; + +export async function readCache(): Promise { + return readJson(paths.cloudCache, EMPTY); +} + +export async function writeCache(snapshot: CloudSnapshot): Promise { + await writeJsonAtomic(paths.cloudCache, snapshot); +} diff --git a/src/cloud/client.ts b/src/cloud/client.ts new file mode 100644 index 0000000..c63a3ca --- /dev/null +++ b/src/cloud/client.ts @@ -0,0 +1,99 @@ +// HTTP client for thomas-cloud — single concern: assemble URLs, attach the +// device token (when present), and surface failures as ThomasErrors with +// stable codes the calling command can interpret. +// +// Stays small on purpose. No retry / backoff for v1 — if the cloud is down +// or slow, fail loud so the user knows. Background sync (PR4 candidate) can +// add retry around this. + +import { ThomasError } from "../cli/json.js"; +import type { ErrorCode } from "../cli/output.js"; + +export type CloudFetchOptions = { + baseUrl: string; + /** When set, sent as Authorization: Bearer ${deviceToken}. */ + deviceToken?: string; + /** Per-request timeout in ms. Default 10s. */ + timeoutMs?: number; +}; + +export async function cloudFetch( + path: string, + init: RequestInit, + opts: CloudFetchOptions, +): Promise { + const url = `${opts.baseUrl.replace(/\/+$/, "")}${path}`; + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + if (opts.deviceToken) { + headers.set("authorization", `Bearer ${opts.deviceToken}`); + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 10_000); + try { + return await fetch(url, { ...init, headers, signal: ctrl.signal }); + } catch (err) { + throw asThomasError(err, url); + } finally { + clearTimeout(timer); + } +} + +/** GET + parse JSON, with the auth/timeout treatment above. */ +export async function cloudGetJson( + path: string, + opts: CloudFetchOptions, +): Promise { + const resp = await cloudFetch(path, { method: "GET" }, opts); + return await readJsonOrThrow(resp, path); +} + +export async function cloudPostJson( + path: string, + body: unknown, + opts: CloudFetchOptions, +): Promise { + const resp = await cloudFetch( + path, + { method: "POST", body: JSON.stringify(body) }, + opts, + ); + return await readJsonOrThrow(resp, path); +} + +async function readJsonOrThrow(resp: Response, path: string): Promise { + if (resp.ok) return (await resp.json()) as T; + const code = mapStatusToCode(resp.status); + let detail: unknown; + try { + detail = await resp.json(); + } catch { + detail = await resp.text().catch(() => ""); + } + throw new ThomasError({ + code, + message: `${resp.status} ${resp.statusText} from ${path}`, + details: detail, + }); +} + +function mapStatusToCode(status: number): ErrorCode { + if (status === 401 || status === 403) return "E_CLOUD_UNAUTHORIZED"; + return "E_CLOUD_UNREACHABLE"; +} + +function asThomasError(err: unknown, url: string): ThomasError { + const isAbort = err instanceof Error && err.name === "AbortError"; + return new ThomasError({ + code: isAbort ? "E_CLOUD_TIMEOUT" : "E_CLOUD_UNREACHABLE", + message: isAbort + ? `request to ${url} timed out` + : `could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, + remediation: isAbort + ? "Check your network or set THOMAS_CLOUD_BASE_URL to a reachable host." + : "Verify thomas-cloud is up. For local dev, set THOMAS_CLOUD_BASE_URL=http://localhost:8000.", + }); +} diff --git a/src/cloud/device.ts b/src/cloud/device.ts new file mode 100644 index 0000000..922558f --- /dev/null +++ b/src/cloud/device.ts @@ -0,0 +1,95 @@ +// Device-code grant flow on the client side. +// +// Mirrors RFC 8628 polling shape, matched 1:1 to apps/api/app/api/devices.py: +// 1. POST /v1/devices/begin → { device_code, user_code, verification_uri, interval, expires_in } +// 2. user opens the URL, signs in, approves +// 3. POST /v1/devices/poll → 400 authorization_pending (loop) | 200 { device_token, ... } +// +// The polling loop is the only place this CLI does an interactive long-poll. +// We respect the server-provided interval; on 400 authorization_pending we +// keep going; on any other 4xx/5xx we surface immediately. + +import { ThomasError } from "../cli/json.js"; +import { cloudFetch, cloudPostJson, type CloudFetchOptions } from "./client.js"; +import type { DeviceBeginRequest, DeviceBeginResponse, DevicePollResponse } from "./types.js"; + +export type LoginProgress = + | { kind: "begun"; userCode: string; verificationUri: string; verificationUriComplete: string; intervalMs: number; expiresInMs: number } + | { kind: "still_pending" } + | { kind: "approved"; result: DevicePollResponse }; + +export async function beginDeviceLogin( + req: DeviceBeginRequest, + opts: CloudFetchOptions, +): Promise { + return cloudPostJson("/v1/devices/begin", req, opts); +} + +export type PollDeviceLoginOptions = CloudFetchOptions & { + deviceCode: string; + /** ms; defaults to begin response's interval. Floored at 1s. */ + intervalMs: number; + /** Total wall-clock budget; loop exits once exceeded. */ + expiresAt: number; + /** Called once per poll iteration so the caller can render a spinner / abort. */ + onTick?: () => boolean | void; +}; + +/** Loops until approved, expired, or onTick returns false. */ +export async function pollDeviceLogin( + opts: PollDeviceLoginOptions, +): Promise { + const interval = Math.max(1000, opts.intervalMs); + // Tiny initial delay so the user sees the URL before we start hammering. + await sleep(interval); + while (Date.now() < opts.expiresAt) { + if (opts.onTick && opts.onTick() === false) { + throw new ThomasError({ + code: "E_INTERNAL", + message: "login aborted by caller", + }); + } + const resp = await cloudFetch( + "/v1/devices/poll", + { method: "POST", body: JSON.stringify({ device_code: opts.deviceCode }) }, + opts, + ); + if (resp.ok) { + return (await resp.json()) as DevicePollResponse; + } + // 400 with detail.error == "authorization_pending" → keep waiting. + // Any other status → surface immediately. + let detail: { error?: string } | undefined; + try { + const body = (await resp.json()) as { detail?: { error?: string } }; + detail = body.detail; + } catch { + // ignore parse errors; fall through to the generic-error path + } + if (resp.status === 400 && detail?.error === "authorization_pending") { + await sleep(interval); + continue; + } + if (resp.status === 400 && detail?.error === "expired_token") { + throw new ThomasError({ + code: "E_CLOUD_TIMEOUT", + message: "device code expired before approval", + remediation: "Run `thomas cloud login` again.", + }); + } + throw new ThomasError({ + code: "E_CLOUD_UNAUTHORIZED", + message: `unexpected ${resp.status} from /v1/devices/poll`, + details: detail ?? null, + }); + } + throw new ThomasError({ + code: "E_CLOUD_TIMEOUT", + message: "login timed out before approval", + remediation: "Run `thomas cloud login` again and approve in the browser within the time limit.", + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/cloud/identity.ts b/src/cloud/identity.ts new file mode 100644 index 0000000..186a975 --- /dev/null +++ b/src/cloud/identity.ts @@ -0,0 +1,43 @@ +// Read / write ~/.thomas/cloud.json — the device token + workspace binding. +// +// The device token is a bearer secret. It's stored at 0600 (already enforced by +// io.writeJsonAtomic) and never leaves this file at runtime — clients pass it +// to fetch() via Authorization headers. + +import { unlink } from "node:fs/promises"; + +import { readJson, writeJsonAtomic } from "../config/io.js"; +import { paths } from "../config/paths.js"; +import type { CloudIdentity } from "./types.js"; + +export async function readIdentity(): Promise { + const value = await readJson(paths.cloud, null); + return value ?? undefined; +} + +export async function writeIdentity(identity: CloudIdentity): Promise { + await writeJsonAtomic(paths.cloud, identity); +} + +export async function clearIdentity(): Promise { + try { + await unlink(paths.cloud); + return true; + } catch { + return false; + } +} + +export async function updateLastSync(syncedAt: string): Promise { + const identity = await readIdentity(); + if (!identity) return; + await writeIdentity({ ...identity, lastSyncAt: syncedAt }); +} + +/** + * Default base URL for the SaaS. Override with THOMAS_CLOUD_BASE_URL for + * local dev (e.g. http://localhost:8000) or a private deployment. + */ +export function defaultBaseUrl(): string { + return process.env.THOMAS_CLOUD_BASE_URL?.replace(/\/+$/, "") ?? "https://thomas.trustunknown.com"; +} diff --git a/src/cloud/sync.ts b/src/cloud/sync.ts new file mode 100644 index 0000000..7bde13c --- /dev/null +++ b/src/cloud/sync.ts @@ -0,0 +1,47 @@ +// One-shot pull from /v1/sync. Returns the snapshot AND writes it to the +// on-disk cache as a side effect — both reads and the write are part of +// `thomas cloud sync`. + +import { ThomasError } from "../cli/json.js"; +import { writeCache } from "./cache.js"; +import { cloudGetJson, type CloudFetchOptions } from "./client.js"; +import { readIdentity, updateLastSync } from "./identity.js"; +import type { CloudSnapshot } from "./types.js"; + +type SyncWireResponse = { + schemaVersion: number; + policies: unknown[]; + bundles: unknown[]; + bindings: unknown[]; + providers: unknown[]; + redactRulesVersion: string | null; +}; + +export async function syncFromCloud(): Promise { + const identity = await readIdentity(); + if (!identity) { + throw new ThomasError({ + code: "E_CLOUD_NOT_LOGGED_IN", + message: "no cloud login on this machine", + remediation: "Run `thomas cloud login` first.", + }); + } + const opts: CloudFetchOptions = { + baseUrl: identity.baseUrl, + deviceToken: identity.deviceToken, + }; + const wire = await cloudGetJson("/v1/sync", opts); + const syncedAt = new Date().toISOString(); + const snapshot: CloudSnapshot = { + schemaVersion: 1, + policies: wire.policies ?? [], + bundles: wire.bundles ?? [], + bindings: wire.bindings ?? [], + providers: wire.providers ?? [], + redactRulesVersion: wire.redactRulesVersion ?? null, + syncedAt, + }; + await writeCache(snapshot); + await updateLastSync(syncedAt); + return snapshot; +} diff --git a/src/cloud/types.ts b/src/cloud/types.ts new file mode 100644 index 0000000..48d49b5 --- /dev/null +++ b/src/cloud/types.ts @@ -0,0 +1,47 @@ +// Wire shapes that thomas-cloud's HTTP API speaks. Mirrors the Pydantic +// models in apps/api/app/api/{devices,sync}.py. Kept in one file so changes +// to the cloud contract surface as one diff here. + +export type DeviceBeginRequest = { + label: string; + platform?: string; + thomas_version?: string; +}; + +export type DeviceBeginResponse = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + interval: number; + expires_in: number; +}; + +export type DevicePollResponse = { + device_token: string; + workspace_id: string; + device_id: string; +}; + +// Saved at ~/.thomas/cloud.json after a successful login. 0600 mode. +export type CloudIdentity = { + baseUrl: string; + deviceToken: string; + deviceId: string; + workspaceId: string; + loggedInAt: string; + // Optional, set after first /v1/sync. + lastSyncAt?: string; +}; + +// Saved at ~/.thomas/cloud-cache.json after each successful /v1/sync. +export type CloudSnapshot = { + schemaVersion: 1; + policies: unknown[]; + bundles: unknown[]; + bindings: unknown[]; + providers: unknown[]; + redactRulesVersion: string | null; + // Local timestamp of when this snapshot was pulled. Used to flag staleness. + syncedAt: string; +}; diff --git a/src/commands/cloud/login.ts b/src/commands/cloud/login.ts new file mode 100644 index 0000000..57f394c --- /dev/null +++ b/src/commands/cloud/login.ts @@ -0,0 +1,80 @@ +// `thomas cloud login` — interactive device-code grant. +// +// Login is the only `thomas cloud …` command that's intrinsically interactive +// (it long-polls the API while the user authorizes in a browser). We don't +// support `--json` here because there's no clean structured representation +// of "I'm waiting for you, refresh". The other cloud verbs (whoami / sync) +// support `--json` normally. + +import { hostname, platform } from "node:os"; + +import { beginDeviceLogin, pollDeviceLogin } from "../../cloud/device.js"; +import { defaultBaseUrl, readIdentity, writeIdentity } from "../../cloud/identity.js"; +import type { CloudIdentity } from "../../cloud/types.js"; + +export type LoginOptions = { + /** Override the default base URL (useful for local dev or private deploy). */ + baseUrl?: string; + /** Label for this device shown in the cloud UI. Defaults to hostname. */ + label?: string; +}; + +export async function cloudLogin(opts: LoginOptions = {}): Promise { + const baseUrl = opts.baseUrl ?? defaultBaseUrl(); + const label = opts.label ?? hostname(); + + const existing = await readIdentity(); + if (existing) { + process.stderr.write( + `Already logged in as device ${existing.deviceId} on ${existing.baseUrl}.\n` + + `Run \`thomas cloud logout\` first if you want to switch accounts.\n`, + ); + return 1; + } + + const begin = await beginDeviceLogin( + { + label, + platform: platform(), + thomas_version: getThomasVersion(), + }, + { baseUrl }, + ); + + process.stderr.write( + `\nTo finish signing in, open this URL in your browser:\n` + + ` ${begin.verification_uri_complete}\n\n` + + `Or visit ${begin.verification_uri} and enter:\n` + + ` ${begin.user_code}\n\n` + + `Waiting for approval (expires in ${Math.floor(begin.expires_in / 60)} min)…\n`, + ); + + const expiresAt = Date.now() + begin.expires_in * 1000; + const result = await pollDeviceLogin({ + baseUrl, + deviceCode: begin.device_code, + intervalMs: begin.interval * 1000, + expiresAt, + }); + + const identity: CloudIdentity = { + baseUrl, + deviceToken: result.device_token, + deviceId: result.device_id, + workspaceId: result.workspace_id, + loggedInAt: new Date().toISOString(), + }; + await writeIdentity(identity); + + process.stderr.write( + `\n✓ Logged in. Device ${result.device_id} attached to workspace ${result.workspace_id}.\n` + + ` Token stored at ~/.thomas/cloud.json\n` + + ` Run \`thomas cloud sync\` to pull policy + bundle config.\n`, + ); + return 0; +} + +function getThomasVersion(): string { + // package.json#version is bundled at build; in dev we read from process.env or fall back. + return process.env.THOMAS_VERSION ?? "dev"; +} diff --git a/src/commands/cloud/logout.ts b/src/commands/cloud/logout.ts new file mode 100644 index 0000000..72a376e --- /dev/null +++ b/src/commands/cloud/logout.ts @@ -0,0 +1,29 @@ +// `thomas cloud logout` — clear local cloud.json. +// +// Server-side device revocation (DELETE /v1/devices/{id}) lands once the +// API exposes that endpoint. For now, logout is purely local — the device +// token stays valid on the server but can't be used because nothing on +// this machine knows it anymore. + +import { runJson } from "../../cli/json.js"; +import type { CloudLogoutData } from "../../cli/output.js"; +import { clearIdentity, readIdentity } from "../../cloud/identity.js"; + +export async function cloudLogout(opts: { json: boolean }): Promise { + return runJson({ + command: "cloud.logout", + json: opts.json, + fetch: async (): Promise => { + const before = await readIdentity(); + const removed = await clearIdentity(); + return { wasLoggedIn: !!before && removed }; + }, + printHuman: (d) => { + if (d.wasLoggedIn) { + console.log("Logged out (local state cleared)."); + } else { + console.log("Was not logged in."); + } + }, + }); +} diff --git a/src/commands/cloud/sync.ts b/src/commands/cloud/sync.ts new file mode 100644 index 0000000..c835c43 --- /dev/null +++ b/src/commands/cloud/sync.ts @@ -0,0 +1,33 @@ +// `thomas cloud sync` — pull /v1/sync from thomas-cloud, write the snapshot +// to ~/.thomas/cloud-cache.json. v1 the snapshot is empty (the cloud doesn't +// hold any policy data yet); the local proxy already reads from this cache +// path so the wiring is exercised end-to-end even with empty results. + +import { runJson } from "../../cli/json.js"; +import type { CloudSyncData } from "../../cli/output.js"; +import { syncFromCloud } from "../../cloud/sync.js"; + +export async function cloudSync(opts: { json: boolean }): Promise { + return runJson({ + command: "cloud.sync", + json: opts.json, + fetch: async (): Promise => { + const snap = await syncFromCloud(); + return { + schemaVersion: snap.schemaVersion, + policiesCount: snap.policies.length, + bundlesCount: snap.bundles.length, + bindingsCount: snap.bindings.length, + providersCount: snap.providers.length, + redactRulesVersion: snap.redactRulesVersion, + syncedAt: snap.syncedAt, + }; + }, + printHuman: (d) => { + console.log( + `Synced at ${d.syncedAt}: ${d.policiesCount} policies, ${d.bundlesCount} bundles, ` + + `${d.bindingsCount} bindings, ${d.providersCount} providers.`, + ); + }, + }); +} diff --git a/src/commands/cloud/whoami.ts b/src/commands/cloud/whoami.ts new file mode 100644 index 0000000..0cfc63d --- /dev/null +++ b/src/commands/cloud/whoami.ts @@ -0,0 +1,47 @@ +// `thomas cloud whoami` — local-only inspection of cloud.json. +// +// Doesn't hit the network. The agent driving thomas hits this every time it +// needs to render "are we logged in?" — keeping it cheap matters. + +import { runJson } from "../../cli/json.js"; +import type { CloudWhoamiData } from "../../cli/output.js"; +import { readIdentity } from "../../cloud/identity.js"; + +export async function cloudWhoami(opts: { json: boolean }): Promise { + return runJson({ + command: "cloud.whoami", + json: opts.json, + fetch: async (): Promise => { + const identity = await readIdentity(); + if (!identity) { + return { + loggedIn: false, + baseUrl: null, + workspaceId: null, + deviceId: null, + loggedInAt: null, + lastSyncAt: null, + }; + } + return { + loggedIn: true, + baseUrl: identity.baseUrl, + workspaceId: identity.workspaceId, + deviceId: identity.deviceId, + loggedInAt: identity.loggedInAt, + lastSyncAt: identity.lastSyncAt ?? null, + }; + }, + printHuman: (d) => { + if (!d.loggedIn) { + console.log("Not logged in. Run `thomas cloud login`."); + return; + } + console.log(`Logged in to ${d.baseUrl}`); + console.log(` workspace: ${d.workspaceId}`); + console.log(` device: ${d.deviceId}`); + console.log(` logged in at: ${d.loggedInAt}`); + console.log(` last sync: ${d.lastSyncAt ?? "never (run `thomas cloud sync`)"}`); + }, + }); +} diff --git a/src/config/paths.ts b/src/config/paths.ts index 4d427f8..58ec92f 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -36,8 +36,30 @@ export const paths = { get proxyLog() { return join(thomasDir(), "proxy.log"); }, + get runs() { + return join(thomasDir(), "runs.jsonl"); + }, + get policies() { + return join(thomasDir(), "policies.json"); + }, + get prices() { + return join(thomasDir(), "prices.json"); + }, + // thomas cloud — set on `thomas cloud login`, cleared on `thomas cloud logout`. + // 0600 perms: holds the device token (exact-once value from /v1/devices/poll). + get cloud() { + return join(thomasDir(), "cloud.json"); + }, + // Snapshot pulled from /v1/sync. Read-only from local thomas's perspective — + // the source of truth lives in the SaaS. Stale tolerated when offline. + get cloudCache() { + return join(thomasDir(), "cloud-cache.json"); + }, }; export function home(...segments: string[]): string { - return join(homedir(), ...segments); + // Read process.env.HOME at call time, not homedir(). Bun caches the result + // of os.homedir() after the first call, so a test that sets process.env.HOME + // mid-process won't see the override otherwise. + return join(process.env.HOME ?? homedir(), ...segments); } diff --git a/tests/cloud.test.ts b/tests/cloud.test.ts new file mode 100644 index 0000000..a377222 --- /dev/null +++ b/tests/cloud.test.ts @@ -0,0 +1,279 @@ +// End-to-end exercise of `thomas cloud` against a fake thomas-cloud server. +// +// We spin up an HTTP server that mimics the device-code grant + /v1/sync. +// Then we drive each CLI command and check both the persisted state on disk +// and the JSON output the agent will see. + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createServer, type Server } from "node:http"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cloudLogin } from "../src/commands/cloud/login.js"; +import { cloudLogout } from "../src/commands/cloud/logout.js"; +import { cloudSync } from "../src/commands/cloud/sync.js"; +import { cloudWhoami } from "../src/commands/cloud/whoami.js"; +import { readJson } from "../src/config/io.js"; +import { paths } from "../src/config/paths.js"; +import type { CloudIdentity } from "../src/cloud/types.js"; + +import { captureStdout } from "./_util.js"; + +let dir: string; +const ORIG_THOMAS_HOME = process.env.THOMAS_HOME; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "thomas-cloud-")); + process.env.THOMAS_HOME = dir; +}); + +afterEach(async () => { + if (ORIG_THOMAS_HOME !== undefined) process.env.THOMAS_HOME = ORIG_THOMAS_HOME; + else delete process.env.THOMAS_HOME; + await rm(dir, { recursive: true, force: true }); +}); + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr !== "string") resolve(addr.port); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function readBody(req: Parameters[0]>[0]): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * Fake thomas-cloud — implements just enough of the API to drive these tests. + * The /poll endpoint returns "authorization_pending" until you call .approve() + * on the returned controller, then returns a real device_token. + */ +function fakeCloud() { + let pendingDeviceCode: string | null = null; + let approved = false; + let issuedToken = "thomas_dev_fake_" + Math.random().toString(36).slice(2); + const requests: Array<{ method: string; url: string; auth: string | null }> = []; + + const server = createServer(async (req, res) => { + requests.push({ + method: req.method ?? "", + url: req.url ?? "", + auth: (req.headers.authorization as string) ?? null, + }); + + if (req.url === "/v1/devices/begin" && req.method === "POST") { + pendingDeviceCode = "devcode_abcdef"; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + device_code: pendingDeviceCode, + user_code: "ABCD2345", + verification_uri: "http://web.example/devices", + verification_uri_complete: "http://web.example/devices?code=ABCD2345", + interval: 1, // tests want to poll fast + expires_in: 60, + }), + ); + return; + } + + if (req.url === "/v1/devices/poll" && req.method === "POST") { + const body = JSON.parse(await readBody(req)); + if (body.device_code !== pendingDeviceCode) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: { error: "invalid_grant" } })); + return; + } + if (!approved) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: { error: "authorization_pending" } })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + device_token: issuedToken, + workspace_id: "01TEST_WORKSPACE_ULID00000", + device_id: "01TEST_DEVICE_ULID0000000", + }), + ); + return; + } + + if (req.url === "/v1/sync" && req.method === "GET") { + // Require auth header. + if (!req.headers.authorization?.includes("Bearer ")) { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: { error: "unauthenticated" } })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + schemaVersion: 1, + policies: [{ id: "p-1" }], + bundles: [], + bindings: [{ agent: "claude-code", target: "anthropic/claude-haiku" }], + providers: [], + redactRulesVersion: "v0", + }), + ); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: { error: "not_found" } })); + }); + + return { + server, + approve: () => { + approved = true; + }, + issuedToken: () => issuedToken, + requests, + }; +} + +describe("thomas cloud login → whoami → sync → logout", () => { + it("walks the full happy path", async () => { + const fake = fakeCloud(); + const port = await listen(fake.server); + try { + // Approve before login starts polling — interval=1s so the first poll succeeds. + fake.approve(); + const loginExit = await cloudLogin({ + baseUrl: `http://127.0.0.1:${port}`, + label: "test-host", + }); + expect(loginExit).toBe(0); + + // cloud.json was written + expect(existsSync(paths.cloud)).toBe(true); + const identity = await readJson(paths.cloud, null); + expect(identity).not.toBeNull(); + expect(identity!.deviceToken).toBe(fake.issuedToken()); + expect(identity!.workspaceId).toBe("01TEST_WORKSPACE_ULID00000"); + + // whoami JSON + const { result, out } = await captureStdout(() => cloudWhoami({ json: true })); + expect(result).toBe(0); + const whoami = JSON.parse(out); + expect(whoami.command).toBe("cloud.whoami"); + expect(whoami.data.loggedIn).toBe(true); + expect(whoami.data.workspaceId).toBe("01TEST_WORKSPACE_ULID00000"); + expect(whoami.data.lastSyncAt).toBeNull(); + + // sync JSON — should pull the snapshot and update last_sync + const sync = await captureStdout(() => cloudSync({ json: true })); + expect(sync.result).toBe(0); + const syncBody = JSON.parse(sync.out); + expect(syncBody.command).toBe("cloud.sync"); + expect(syncBody.data.policiesCount).toBe(1); + expect(syncBody.data.bindingsCount).toBe(1); + expect(syncBody.data.redactRulesVersion).toBe("v0"); + + // cache file written + expect(existsSync(paths.cloudCache)).toBe(true); + const cache = await readJson<{ policies: unknown[]; syncedAt: string }>( + paths.cloudCache, + { policies: [], syncedAt: "" }, + ); + expect(cache.policies).toHaveLength(1); + expect(cache.syncedAt).toBeTruthy(); + + // whoami again — lastSyncAt should now be populated + const after = await captureStdout(() => cloudWhoami({ json: true })); + expect(JSON.parse(after.out).data.lastSyncAt).toBeTruthy(); + + // /v1/sync request carried Bearer auth + const syncReq = fake.requests.find((r) => r.url === "/v1/sync"); + expect(syncReq?.auth).toBe(`Bearer ${fake.issuedToken()}`); + + // logout removes cloud.json + const logout = await captureStdout(() => cloudLogout({ json: true })); + expect(logout.result).toBe(0); + expect(JSON.parse(logout.out).data.wasLoggedIn).toBe(true); + expect(existsSync(paths.cloud)).toBe(false); + + // logout again is idempotent (wasLoggedIn=false) + const logout2 = await captureStdout(() => cloudLogout({ json: true })); + expect(logout2.result).toBe(0); + expect(JSON.parse(logout2.out).data.wasLoggedIn).toBe(false); + } finally { + await closeServer(fake.server); + } + }); + + it("login errors out if already logged in", async () => { + const fake = fakeCloud(); + const port = await listen(fake.server); + try { + fake.approve(); + const first = await cloudLogin({ baseUrl: `http://127.0.0.1:${port}` }); + expect(first).toBe(0); + // Second attempt should fail without re-issuing a device code. + const before = fake.requests.length; + const second = await cloudLogin({ baseUrl: `http://127.0.0.1:${port}` }); + expect(second).toBe(1); + // Didn't hit /devices/begin a second time. + expect(fake.requests.length).toBe(before); + } finally { + await closeServer(fake.server); + } + }); + + it("sync without login surfaces E_CLOUD_NOT_LOGGED_IN", async () => { + const { result, out } = await captureStdout(() => cloudSync({ json: true })); + expect(result).toBe(1); + const parsed = JSON.parse(out); + expect(parsed.command).toBe("cloud.sync"); + expect(parsed.error.code).toBe("E_CLOUD_NOT_LOGGED_IN"); + }); + + it("whoami when not logged in returns loggedIn=false (exit 0)", async () => { + const { result, out } = await captureStdout(() => cloudWhoami({ json: true })); + expect(result).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.data.loggedIn).toBe(false); + expect(parsed.data.workspaceId).toBeNull(); + expect(parsed.data.deviceId).toBeNull(); + }); + + it("sync surfaces E_CLOUD_UNAUTHORIZED on 401", async () => { + // Server returns 401 unconditionally — simulates a revoked token. + const server = createServer((_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: { error: "unauthenticated" } })); + }); + const port = await listen(server); + try { + // Stash a fake identity directly so sync has a token to send. + const { writeIdentity } = await import("../src/cloud/identity.js"); + await writeIdentity({ + baseUrl: `http://127.0.0.1:${port}`, + deviceToken: "stale-token", + deviceId: "dev", + workspaceId: "ws", + loggedInAt: new Date().toISOString(), + }); + + const { result, out } = await captureStdout(() => cloudSync({ json: true })); + expect(result).toBe(1); + expect(JSON.parse(out).error.code).toBe("E_CLOUD_UNAUTHORIZED"); + } finally { + await closeServer(server); + } + }); +});