@legioncodeinc/rflectr 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -5
  2. package/dist/cli.js +1 -1
  3. package/library/README.md +39 -39
  4. package/library/issues/README.md +46 -46
  5. package/library/issues/backlog/README.md +26 -26
  6. package/library/issues/completed/README.md +13 -13
  7. package/library/issues/in-work/README.md +13 -13
  8. package/library/knowledge/README.md +34 -34
  9. package/library/knowledge/private/README.md +40 -40
  10. package/library/knowledge/private/standards/documentation-framework.md +154 -154
  11. package/library/knowledge/public/README.md +49 -49
  12. package/library/notes/README.md +21 -21
  13. package/library/requirements/README.md +51 -51
  14. package/library/requirements/backlog/README.md +30 -30
  15. package/library/requirements/completed/README.md +14 -14
  16. package/library/requirements/completed/prd-002-provider-registry/prd-002-provider-registry-index.md +263 -0
  17. package/library/requirements/completed/prd-003-model-discovery-classification/prd-003-model-discovery-classification-index.md +260 -0
  18. package/library/requirements/completed/prd-004-translation-layer/prd-004-translation-layer-index.md +196 -0
  19. package/library/requirements/completed/prd-005-local-proxy-catalog-routing/prd-005-local-proxy-catalog-routing-index.md +176 -0
  20. package/library/requirements/completed/prd-006-credential-storage/prd-006-credential-storage-index.md +190 -0
  21. package/library/requirements/completed/prd-006-credential-storage/qa/.gitkeep +0 -0
  22. package/library/requirements/completed/prd-007-oauth-device-flows/prd-007-oauth-device-flows-index.md +208 -0
  23. package/library/requirements/completed/prd-008-preferences-tiers-favorites/prd-008-preferences-tiers-favorites-index.md +249 -0
  24. package/library/requirements/completed/prd-008-preferences-tiers-favorites/qa/.gitkeep +0 -0
  25. package/library/requirements/completed/prd-009-codex-integration/prd-009-codex-integration-index.md +212 -0
  26. package/library/requirements/completed/prd-009-codex-integration/qa/.gitkeep +0 -0
  27. package/library/requirements/completed/prd-010-gemini-cli-integration/prd-010-gemini-cli-integration-index.md +211 -0
  28. package/library/requirements/completed/prd-010-gemini-cli-integration/qa/.gitkeep +0 -0
  29. package/library/requirements/completed/prd-011-claude-desktop-integration/prd-011-claude-desktop-integration-index.md +228 -0
  30. package/library/requirements/completed/prd-012-server-gateway/prd-012-server-gateway-index.md +356 -0
  31. package/library/requirements/completed/prd-012-server-gateway/qa/.gitkeep +0 -0
  32. package/library/requirements/in-work/README.md +19 -19
  33. package/library/requirements/reports/README.md +31 -31
  34. package/package.json +1 -1
@@ -0,0 +1,208 @@
1
+ # PRD-007: OAuth Device & PKCE Flows *(Retroactive)*
2
+
3
+ > **Status:** Shipped
4
+ > **Priority:** —
5
+ > **Effort:** —
6
+ > **Written:** June 2026
7
+ > **Retroactive:** Yes — written after implementation (rflectr v0.2.7).
8
+ > **Source:** `src/oauth/*`, `src/registry/refresh-credentials.ts`
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ Some model providers a developer might want to point Claude Code / Codex / Gemini at do not hand out a simple API key. ChatGPT (Plus/Pro), xAI SuperGrok, and GitHub Copilot authenticate the *user account* through OAuth, not a long-lived `sk-...` secret. Because rflectr runs in a terminal with no browser redirect URI to catch a callback, it uses the OAuth 2.0 **Device Authorization Grant** (RFC 8628): rflectr asks the provider for a user code, prints a verification URL, and polls the token endpoint until the user approves in their browser.
15
+
16
+ This PRD documents the device-code login flows, the PKCE utilities that back them, the stored-credential shape, the lazy token-refresh path, and the static OAuth-derived model lists that stand in for the `/v1/models` endpoints those OAuth tokens cannot reach. It is the auth-acquisition counterpart to PRD-006 (credential storage), which owns where the resulting token lives.
17
+
18
+ Knowledge doc: [`oauth-device-flows.md`](../../../knowledge/private/auth/oauth-device-flows.md).
19
+
20
+ ---
21
+
22
+ ## What Was Built
23
+
24
+ - A shared PKCE/utility module (`src/oauth/pkce.ts`) — crypto-random verifier + SHA-256 challenge, OAuth `state` generation, and polling helpers (`positiveSecondsToMs`, `sleepMs`).
25
+ - A stored-credential contract (`src/oauth/types.ts`) — `StoredOAuthCredential`, builders/parsers, time-based and proactive-JWT expiry checks, and the `supportsNativeOAuth()` gate over the native provider set `xai | xai-oauth | openai | openai-oauth | github-copilot`.
26
+ - Three per-provider device-code flows:
27
+ - **OpenAI / ChatGPT** (`src/oauth/openai.ts`) — usercode → poll → authorization-code exchange with PKCE `code_verifier`, plus `chatgpt_account_id` extraction.
28
+ - **xAI / Grok** (`src/oauth/xai.ts`) — standard RFC 8628 device grant with `slow_down` handling.
29
+ - **GitHub Copilot** (`src/oauth/github.ts`) — GitHub device flow → `ghu_` token → second exchange for a short-lived Copilot session token.
30
+ - A single refresh dispatcher (`src/oauth/refresh.ts`) — `oauthCredentialShouldRefresh()` and `refreshStoredOAuthCredential()` routing to the right per-provider refresh.
31
+ - Integration into credential resolution (`src/env.ts`) — OAuth tokens are read from the keyring account `oauth:provider:<id>`, refreshed lazily and deduplicated per account, and written back; a stale-but-valid token is reused if refresh fails.
32
+ - Static OAuth model lists (`src/data/openai-oauth-models.ts`, `src/data/xai-oauth-models.ts`) — because the OAuth tokens are rejected by the providers' `/v1/models` endpoints.
33
+ - Placeholder/env-fallback key resolution for the refresh-models path (`src/registry/refresh-credentials.ts`).
34
+
35
+ ---
36
+
37
+ ## Goals
38
+
39
+ - Let a user sign into ChatGPT, SuperGrok, or GitHub Copilot from a terminal with no localhost callback server.
40
+ - Acquire and persist OAuth tokens in a shape that PRD-006 credential storage can read transparently (an access token comes back from `oauth:provider:<id>` just like an API key would).
41
+ - Keep short-lived access tokens fresh automatically, without re-prompting the user, by refreshing just-in-time at credential-resolution time.
42
+ - Provide a usable model list for OAuth providers even though their tokens cannot call the standard model-discovery endpoint.
43
+ - Make refresh robust to transient failures (reuse a still-valid token rather than hard-failing a launch).
44
+
45
+ ## Non-Goals
46
+
47
+ - A localhost redirect-URI / authorization-code-in-browser flow — device code is the only path (`src/oauth/openai.ts:51`, `xai.ts:115`, `github.ts:149`).
48
+ - Making GitHub Copilot usable as a *model provider*. Copilot OAuth *login* is implemented, but Copilot-as-backend is out of scope (OpenCode loads `@ai-sdk/github-copilot` from internal `@opencode-ai/core`, not a public npm factory).
49
+ - Live model discovery for OAuth providers — replaced by static seed lists (`src/data/openai-oauth-models.ts:7`, `xai-oauth-models.ts:6`).
50
+ - Owning the keyring/OS-credential-store mechanics (that is PRD-006).
51
+
52
+ ---
53
+
54
+ ## Features
55
+
56
+ | Feature | Provider(s) | Module | Notes |
57
+ | --- | --- | --- | --- |
58
+ | Device-code login (RFC 8628) | OpenAI, xAI, GitHub Copilot | `openai.ts`, `xai.ts`, `github.ts` | user code + verification URL printed, then poll |
59
+ | PKCE verifier/challenge | OpenAI (authorization-code exchange) | `pkce.ts:22` | `challenge = base64url(SHA-256(verifier))` |
60
+ | OAuth `state` generation | shared utility | `pkce.ts:28` | base64url of 32 random bytes |
61
+ | `slow_down` / `authorization_pending` poll handling | OpenAI, xAI, GitHub | all three flows | interval bumped +5 s on `slow_down` (xAI/GitHub) |
62
+ | Account-id extraction | OpenAI | `openai.ts:20` | `chatgpt_account_id` from JWT → routes to ChatGPT Codex backend |
63
+ | Two-step token exchange | GitHub Copilot | `github.ts:57` | `ghu_` → short-lived Copilot session token |
64
+ | Token refresh | OpenAI, xAI, GitHub | `refresh.ts:21` | `grant_type=refresh_token` (OpenAI/xAI), re-exchange `ghu_` (GitHub) |
65
+ | Proactive JWT-expiry refresh | native providers | `types.ts:53` | decode `exp`, refresh before expiry |
66
+ | Static OAuth model list | OpenAI, xAI | `openai-oauth-models.ts`, `xai-oauth-models.ts` | substitutes for `/v1/models` |
67
+ | Refresh-time placeholder/env-fallback key | OpenCode-imported providers | `refresh-credentials.ts:56` | env fallback for `openai`/`anthropic` |
68
+
69
+ ---
70
+
71
+ ## Architecture & Implementation
72
+
73
+ ### Why device flow
74
+
75
+ These hosts run in a terminal with no browser redirect URI to catch a callback. The Device Authorization Grant fits: request a user code, print a URL, poll until approval. `supportsNativeOAuth(providerId)` (`src/oauth/types.ts:71`) gates which providers offer this; the native set is defined in `NATIVE_OAUTH_PROVIDER_IDS` (`src/oauth/types.ts:68`). The `rflectr providers auth <id>` command drives it (`runProvidersAuth`, `src/providers-command.ts:221`).
76
+
77
+ ### Device-code flow steps (generic)
78
+
79
+ ```mermaid
80
+ sequenceDiagram
81
+ participant U as User
82
+ participant R as rflectr
83
+ participant P as Provider auth server
84
+ R->>P: request device / user code
85
+ P-->>R: user_code + verification URL + interval
86
+ R->>U: "visit <url>, enter <user_code>"
87
+ loop until approved (respect interval / slow_down)
88
+ R->>P: poll token endpoint
89
+ P-->>R: authorization_pending | slow_down | tokens
90
+ end
91
+ P-->>R: access + refresh tokens
92
+ R->>R: store StoredOAuthCredential at keyring account oauth:provider:<id>
93
+ ```
94
+
95
+ The shared `runXxxDeviceCodeFlow(onDeviceCode, opts?)` signature (`openai.ts:51`, `xai.ts:115`, `github.ts:149`) calls back with `{ url, userCode }` so the CLI can print the prompt, then polls. `opts.sleep` / `opts.now` are injectable for tests. Each poller computes a `deadline` from the device response `expires_in` (defaulting to 5 min for OpenAI/xAI, 15 min for GitHub) and a per-iteration `intervalMs` from the response `interval`, capping each sleep at the remaining time (`xai.ts:64`, `github.ts:101`, `openai.ts:77`).
96
+
97
+ ### Per-provider differences
98
+
99
+ | Aspect | OpenAI / ChatGPT | xAI / Grok | GitHub Copilot |
100
+ | --- | --- | --- | --- |
101
+ | Client id | `app_EMoamEEZ73f0CkXaXp7hrann` (`openai.ts:9`) | `b1a00492-073a-47ea-816f-4c329264a828` (`xai.ts:9`) | `Iv1.b507a08c87ecfe98` — VS Code Copilot extension id (`github.ts:13`) |
102
+ | Device endpoint | `…/api/accounts/deviceauth/usercode` (`openai.ts:58`) | `https://auth.x.ai/oauth2/device/code` (`xai.ts:11`) | `https://github.com/login/device/code` (`github.ts:14`) |
103
+ | Poll endpoint | `…/api/accounts/deviceauth/token` (`openai.ts:82`) | `https://auth.x.ai/oauth2/token` (`xai.ts:10`) | `https://github.com/login/oauth/access_token` (`github.ts:15`) |
104
+ | Grant on poll | proprietary device-auth, then `authorization_code` exchange at `/oauth/token` with PKCE `code_verifier` (`openai.ts:96`) | `urn:ietf:params:oauth:grant-type:device_code` (`xai.ts:12`) | `urn:ietf:params:oauth:grant-type:device_code` (`github.ts:114`) |
105
+ | Scope | (none on device request; `client_id` only) | `openid profile email offline_access grok-cli:access api:access` (`xai.ts:13`) | `copilot` (`github.ts:17`) |
106
+ | PKCE used | Yes — `code_verifier` returned by device-auth, sent on exchange (`openai.ts:104`) | No (standard device grant) | No (standard device grant) |
107
+ | Pending signal | HTTP 403/404 from token poll (`openai.ts:114`) | `error: authorization_pending` JSON (`xai.ts:84`) | `error: authorization_pending` JSON (`github.ts:132`) |
108
+ | `slow_down` handling | (margin only; no interval bump) | interval += 5 s (`xai.ts:88`) | interval += 5 s (`github.ts:136`) |
109
+ | Second exchange | none | none | `ghu_` → Copilot session token at `…/copilot_internal/v2/token` (`github.ts:57`) |
110
+ | Stored `refresh` field | provider refresh token | provider refresh token | the long-lived `ghu_` token itself (`github.ts:91`) |
111
+ | Account id | `chatgpt_account_id` from JWT (`openai.ts:20`) | — | — |
112
+
113
+ The GitHub two-step is the quirk to remember: the standard device flow yields a long-lived `ghu_` access token, then `exchangeForCopilotToken(ghuToken)` GETs `…/copilot_internal/v2/token` to mint a **short-lived** Copilot session token (`github.ts:57`). Because the session token is what's used at inference time, the **stored `refresh` field is the `ghu_` itself** (`github.ts:91`); `refreshGithubCopilotToken()` just re-exchanges it (`github.ts:87`).
114
+
115
+ ### PKCE & utilities (`src/oauth/pkce.ts`)
116
+
117
+ - `generatePkce()` → `{ verifier, challenge }` with `challenge = base64url(SHA-256(verifier))` and a 64-char verifier from the unreserved-char set (`pkce.ts:22`).
118
+ - `generateOAuthState()` → base64url of 32 random bytes (`pkce.ts:28`).
119
+ - `generateRandomString(length)` — crypto-random from `A-Za-z0-9-._~` (`pkce.ts:10`).
120
+ - `positiveSecondsToMs(value, defaultMs)`, `sleepMs(ms)` — polling helpers (`pkce.ts:34`, `pkce.ts:39`).
121
+
122
+ ### Stored credential shape (`src/oauth/types.ts`)
123
+
124
+ `StoredOAuthCredential` aliases OpenCode's `OpencodeOAuthCredential` (`types.ts:7`) — `{ type: 'oauth', access, refresh, expires, accountId? }`. It is serialized to JSON and written to the keyring account `oauth:provider:<id>` (`oauthProviderKeyringAccount`, `src/env.ts:100`). Helpers:
125
+
126
+ - `tokensToStoredCredential(tokens, existingRefresh?, accountId?)` — builds it from a token response, preserving the existing refresh token when the provider didn't return a new one, and defaulting `expires` to now + 1 h when `expires_in` is absent (`types.ts:16`).
127
+ - `parseStoredOAuthCredential(raw)` — parse + validate from the keyring (`types.ts:30`).
128
+ - `oauthCredentialNeedsRefresh(cred, skewMs?)` — true when `expires <= now + skew` (default 120 s, `types.ts:48`).
129
+ - `accessTokenIsExpiring(token, skewMs?)` — best-effort decode of the JWT `exp` claim; opaque tokens return `false` (no proactive refresh) (`types.ts:53`).
130
+
131
+ ### Refresh orchestration (`src/oauth/refresh.ts` + `src/env.ts`)
132
+
133
+ `oauthCredentialShouldRefresh(cred, providerId)` is true when `oauthCredentialNeedsRefresh(cred)` (time-based) **or**, for native providers, `accessTokenIsExpiring(cred.access)` (proactive JWT check) (`refresh.ts:11`). `refreshStoredOAuthCredential(providerId, cred)` dispatches to `refreshOpenAiAccessToken` / `refreshXaiAccessToken` / `refreshGithubCopilotToken` and rebuilds a `StoredOAuthCredential` via `tokensToStoredCredential`, throwing if no refresh token is present (`refresh.ts:21`).
134
+
135
+ Refresh runs lazily at credential-resolution time. `readProviderSecret()` (`src/env.ts:324`) detects a JSON OAuth blob at an `oauth:provider:*` account and routes to `refreshOAuthKeyringAccount()` (`src/env.ts:290`), which:
136
+
137
+ - Deduplicates per keyring account via an in-flight `Map` (`src/env.ts:296`, `oauthRefreshInflight` at `src/env.ts:109`) so concurrent launches don't double-refresh.
138
+ - Writes the refreshed credential back to the keyring (`src/env.ts:307`).
139
+ - **On refresh failure**, reuses the existing access token if it hasn't yet expired (`src/env.ts:311`), otherwise rethrows.
140
+
141
+ The decoded *access token* (not the JSON blob) is what `resolveProviderCredential()` ultimately returns to callers (`decodeProviderSecret`, `src/env.ts:274`), so OAuth providers look identical to API-key providers downstream.
142
+
143
+ ### OAuth-derived model lists (PRD-003 cross-reference)
144
+
145
+ OAuth tokens cannot call the providers' `/v1/models`:
146
+
147
+ - **OpenAI** — the ChatGPT OAuth backend (`chatgpt.com/backend-api/codex`) has no `GET /v1/models`, and `auth.openai.com` access tokens are rejected by `api.openai.com/v1/models` (`openai-oauth-models.ts:7`). `buildOpenAiOAuthModels()` returns a static seed list of GPT/o-series models with `npm '@ai-sdk/openai'` (`openai-oauth-models.ts:56`). `CHATGPT_CODEX_UNSUPPORTED_MODELS` filters out models the Codex backend explicitly rejects (currently `gpt-5.5-fast`) (`openai-oauth-models.ts:34`).
148
+ - **xAI** — the SuperGrok OAuth JWT is rejected by `api.x.ai/v1/models` with 401, so `buildXaiOAuthModels()` is a fallback seed list with `npm '@ai-sdk/xai'` (`xai-oauth-models.ts:6`, `xai-oauth-models.ts:40`).
149
+
150
+ Both lists are consumed by the registry refresh path (`src/registry/refresh-models.ts:186`, `:226`).
151
+
152
+ ### Refresh-time key resolution (`src/registry/refresh-credentials.ts`)
153
+
154
+ OpenCode imports often carry placeholder keys (`anything`, `ollama`, `none`, …) because OAuth/env supplies the real credential at runtime. `isPlaceholderProviderKey()` / `isLikelyPlaceholderKey()` detect those (`refresh-credentials.ts:25`, `:30`), and `resolveRefreshCredential()` falls back to an env var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) when the stored key looks like a placeholder (`refresh-credentials.ts:56`).
155
+
156
+ ---
157
+
158
+ ## Acceptance Criteria
159
+
160
+ - [x] Device-code login is implemented for OpenAI, xAI, and GitHub Copilot with a `{ url, userCode }` callback for the CLI prompt (`openai.ts:51`, `xai.ts:115`, `github.ts:149`).
161
+ - [x] PKCE verifier/challenge generation exists and the OpenAI flow exchanges the authorization code with a `code_verifier` (`pkce.ts:22`, `openai.ts:104`).
162
+ - [x] Pollers honor the device `interval`, handle `authorization_pending` and `slow_down` (interval +5 s where applicable), and time out at the device `expires_in` deadline (`xai.ts:70`, `github.ts:107`, `openai.ts:81`).
163
+ - [x] The GitHub two-step exchange mints a short-lived Copilot session token and stores the `ghu_` token as the refresh field (`github.ts:57`, `github.ts:91`).
164
+ - [x] OpenAI account id (`chatgpt_account_id`) is extracted from the JWT for backend routing (`openai.ts:20`).
165
+ - [x] `StoredOAuthCredential` is built/parsed/validated and persisted to keyring account `oauth:provider:<id>` (`types.ts:16`, `types.ts:30`, `env.ts:100`).
166
+ - [x] Refresh is dispatched per provider and is both time-based and proactively JWT-based for native providers (`refresh.ts:11`, `refresh.ts:21`, `types.ts:53`).
167
+ - [x] Refresh is deduplicated per keyring account, written back, and falls back to a stale-but-valid token on failure (`env.ts:290`, `env.ts:307`, `env.ts:311`).
168
+ - [x] Static OAuth model lists exist for OpenAI and xAI, with Codex-unsupported models filtered (`openai-oauth-models.ts:56`, `xai-oauth-models.ts:40`, `openai-oauth-models.ts:34`).
169
+ - [x] Placeholder-key detection with env fallback is implemented for the refresh-models path (`refresh-credentials.ts:25`, `refresh-credentials.ts:56`).
170
+ - [x] Flows are unit-tested with injectable `sleep`/`now` (`tests/oauth.test.ts`, `tests/oauth-openai.test.ts`, `tests/oauth-github.test.ts`).
171
+
172
+ ---
173
+
174
+ ## Files
175
+
176
+ | File | Role |
177
+ | --- | --- |
178
+ | `src/oauth/pkce.ts` | PKCE verifier/challenge, OAuth `state`, polling helpers |
179
+ | `src/oauth/types.ts` | `StoredOAuthCredential`, builders/parsers, expiry checks, `supportsNativeOAuth` |
180
+ | `src/oauth/openai.ts` | OpenAI ChatGPT device-code flow + PKCE exchange + account-id + refresh |
181
+ | `src/oauth/xai.ts` | xAI SuperGrok device-code flow + refresh |
182
+ | `src/oauth/github.ts` | GitHub Copilot device flow + two-step Copilot-token exchange + refresh |
183
+ | `src/oauth/refresh.ts` | Single refresh dispatcher (`oauthCredentialShouldRefresh`, `refreshStoredOAuthCredential`) |
184
+ | `src/registry/refresh-credentials.ts` | Placeholder-key detection + env-fallback for refresh-models |
185
+ | `src/data/openai-oauth-models.ts` | Static ChatGPT-OAuth model seed list + Codex-unsupported filter |
186
+ | `src/data/xai-oauth-models.ts` | Static SuperGrok-OAuth model fallback seed list |
187
+ | `src/env.ts` (integration) | OAuth read/refresh/write-back at `oauth:provider:<id>`, dedup, stale-token fallback |
188
+ | `src/providers-command.ts` (integration) | `rflectr providers auth <id>` entry point |
189
+
190
+ ---
191
+
192
+ ## Risks & Known Limitations
193
+
194
+ - **OAuth providers with no stored key are silently skipped in some local-provider paths.** `normalizeProviders` skips providers whose `key` field is empty, which is exactly how OAuth-only providers (configured via browser login, no `sk-...`) appear. This is documented as by-design in the project notes; rflectr's own native OAuth path (`oauth:provider:<id>`) is the supported way to use these accounts.
195
+ - **GitHub Copilot works as a login but not as a model provider** — OpenCode loads `@ai-sdk/github-copilot` from internal `@opencode-ai/core`, not a public npm factory rflectr can ship.
196
+ - **No live model discovery for OAuth providers** — model lists are static seeds (`src/data/*-oauth-models.ts`) and must be hand-updated as providers change offerings. Codex-rejected models are filtered by a hard-coded set (`CHATGPT_CODEX_UNSUPPORTED_MODELS`).
197
+ - **Proactive refresh only works for JWT access tokens** — `accessTokenIsExpiring()` returns `false` for opaque tokens, so those rely solely on the time-based `expires` field (`types.ts:53`).
198
+ - **`::ts::`-style or other separator collisions** are not a concern here, but the `ghu_`-as-refresh design means a revoked GitHub OAuth grant surfaces only at the next Copilot-token exchange.
199
+ - **Best-effort error reuse** — if refresh fails and the access token is already expired, the launch fails hard (`env.ts:312`); there is no interactive re-auth prompt at launch time.
200
+
201
+ ---
202
+
203
+ ## Related
204
+
205
+ - [`oauth-device-flows.md`](../../../knowledge/private/auth/oauth-device-flows.md) — knowledge doc this PRD is grounded in.
206
+ - [PRD-006 — Credential Storage & API Key Management](../prd-006-credential-storage/prd-006-credential-storage-index.md) — owns the OS keyring / credential store where OAuth tokens live.
207
+ - [PRD-002 — Provider Registry](../prd-002-provider-registry/prd-002-provider-registry-index.md) — how an OAuth provider is registered and resolved (`authRef` = `keyring:oauth:provider:<id>`).
208
+ - [PRD-003 — Model Discovery & Classification](../prd-003-model-discovery-classification/prd-003-model-discovery-classification-index.md) — consumes the static OAuth model seed lists.
@@ -0,0 +1,249 @@
1
+ # PRD-008: Preferences, Subscription Tiers & Favorites *(Retroactive)*
2
+
3
+ > **Status:** Shipped
4
+ > **Priority:** —
5
+ > **Effort:** —
6
+ > **Written:** June 2026
7
+ > **Retroactive:** Yes — written after implementation (rflectr v0.2.7).
8
+ > **Source:** `src/config.ts`, `src/favorites.ts`, `src/favorites-resolver.ts`, `src/prompts.ts`
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ `rflectr` keeps a small, durable layer of user state so the CLI feels personal across sessions: the last backend/model/provider you launched, recently used models per provider, a curated list of favorite models for the mid-session `/model` switch menu, and per-command `server` settings. All of it lives in a single JSON file under a per-user app-home directory, resolved with an override hook and migrated transparently from two earlier locations.
15
+
16
+ On top of that state sit three interaction systems:
17
+
18
+ 1. **Preferences store** — read/write helpers around `~/.rflectr/config.json`, with legacy-path migration and a `--dry-run` write-skip contract.
19
+ 2. **Subscription / Zen tier filtering** — a `RegistrySubscriptionFilter` (`free` / `zen` / `go`) carried on the OpenCode Zen registry stub that scopes which cloud models surface; seeded as `free` during first-run.
20
+ 3. **Favorites** — a manager command (`rflectr models`) plus a resolver shared with the Codex catalog, capped at `MAX_MODEL_CATALOG` (20), and a large-catalog UX (search + pagination) that keeps long model lists navigable.
21
+
22
+ This PRD documents what was built, grounded in the shipped code.
23
+
24
+ ---
25
+
26
+ ## What Was Built
27
+
28
+ - A single app-home directory (`~/.rflectr`, override via `RFLECTR_HOME`) holding `config.json` and sibling files, created `0o700` / files `0o600` (`src/paths.ts:28`, `src/config.ts:63`).
29
+ - `loadPreferences()` / `savePreferences()` with a shallow per-key merge so callers pass only what changed (`src/config.ts:67`, `src/config.ts:85`).
30
+ - `recordLaunchSelection(agent, providerId, modelId, prefs)` — updates the per-agent `last*` fields and prepends to `recentModelsByProvider` (deduped, capped at 3) (`src/config.ts:101`).
31
+ - Two-stage legacy migration: dotfile dir (`~/.opencode-starter`) then OS `conf` store, both one-directional and idempotent (`src/config.ts:17`, `src/config.ts:34`).
32
+ - `lastProvider === 'opencode'` normalized to `'zen'` on read (`src/config.ts:70`).
33
+ - Pure favorites algebra: `isFavorite`, `addFavorite` (duplicate / cap results), `removeFavorite` (`src/favorites.ts`).
34
+ - The `rflectr models` favorites manager: list / add (global search or browse-by-provider, multi-select) / remove, single save on Done (`src/cli.ts:534`).
35
+ - A surface-agnostic favorites resolver shared by Claude and Codex (`src/favorites-resolver.ts`).
36
+ - Recent-models hint at the top of the local-model picker, with a "Browse all models →" escape hatch (`src/prompts.ts:305`).
37
+ - Large-catalog UX: search at `MODEL_SEARCH_THRESHOLD = 25`, pagination at `MODEL_PAGE_SIZE = 15` (`src/prompts.ts:22`).
38
+ - Inline, never-dead-end first-run wizard that ends in launch or explicit cancel (`src/first-run.ts:36`).
39
+
40
+ ---
41
+
42
+ ## Goals
43
+
44
+ - Persist enough state that repeat launches pre-select the user's prior choice rather than starting cold.
45
+ - Make a curated multi-model `/model` switch menu possible without re-picking every session.
46
+ - Keep one canonical config location, surviving the project's two historical renames without user action.
47
+ - Honor `--dry-run` as a true no-write simulation of a fresh first run.
48
+ - Keep long provider model lists usable (search / paginate) instead of dumping 100+ entries into one prompt.
49
+
50
+ ## Non-Goals
51
+
52
+ - Touching `~/.claude/settings.json` — launch config is env-var-only (see [PRD-001](../prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md)).
53
+ - Storing secrets in `config.json` — API keys live in the OS credential store ([PRD-006](../prd-006-credential-storage/) — folder pending). The server password is migrated *out* of `config.json` into the keyring on first read (`src/config.ts:131`).
54
+ - Cloud-syncing preferences across machines (the file is per-user, per-host).
55
+
56
+ ---
57
+
58
+ ## Features
59
+
60
+ | # | Feature | Entry point | Notes |
61
+ |---|---------|-------------|-------|
62
+ | F1 | App-home resolution + override | `getAppHome()` (`src/paths.ts:28`) | `RFLECTR_HOME` (or deprecated `OPENCODE_STARTER_HOME`) wins, else `~/.rflectr` |
63
+ | F2 | Preferences load/save (shallow merge) | `loadPreferences` / `savePreferences` (`src/config.ts:67`) | Pass only changed keys |
64
+ | F3 | Record launch selection | `recordLaunchSelection` (`src/config.ts:101`) | Per-agent `last*` + recent list (max 3, dedup) |
65
+ | F4 | Legacy migration (2 stages) | `ensureConfigMigrated` (`src/config.ts:34`) | Dotfile dir → OS conf store; idempotent |
66
+ | F5 | Subscription/Zen tier filter | `RegistrySubscriptionFilter` (`src/registry/types.ts:7`) | `free` / `zen` / `go` on the Zen stub |
67
+ | F6 | Favorites algebra | `addFavorite` / `removeFavorite` (`src/favorites.ts`) | Cap at `MAX_MODEL_CATALOG` (20) |
68
+ | F7 | Favorites manager command | `runModelsCommand` (`src/cli.ts:534`) | `rflectr models`; saves once on Done |
69
+ | F8 | Favorites resolver (shared) | `resolveFavorite` / `buildFavoritesList` (`src/favorites-resolver.ts:52`) | Stale favorites dropped silently |
70
+ | F9 | Recent-models picker | `pickLocalModel` (`src/prompts.ts:305`) | Recent shown first + "Browse all" |
71
+ | F10 | Large-catalog search/pagination | `selectModelWithSearch` / `selectLargeCatalog` (`src/prompts.ts:246`) | Thresholds 25 / 15 |
72
+ | F11 | First-run wizard | `runFirstRunWizard` (`src/first-run.ts:36`) | Zen quick-start / import / providers |
73
+
74
+ ---
75
+
76
+ ## Architecture & Implementation
77
+
78
+ ### Config store schema
79
+
80
+ `config.json` is parsed into `UserPreferences` (`src/types.ts:72`). Unknown keys are tolerated; everything is optional.
81
+
82
+ | Field | Type | Purpose | Cite |
83
+ |-------|------|---------|------|
84
+ | `lastBackend` | `'zen' \| 'go'` | Last cloud backend launched | `src/types.ts:73` |
85
+ | `lastModel` | `string` | Last Claude Code model (pre-selects wizard) | `src/types.ts:74` |
86
+ | `lastProvider` | `string` | Last Claude Code provider (`'opencode'`→`'zen'` on read) | `src/config.ts:70` |
87
+ | `lastCodexProvider` / `lastCodexModel` | `string` | Last Codex selection | `src/types.ts:76` |
88
+ | `lastGeminiProvider` / `lastGeminiModel` | `string` | Last Gemini selection | `src/types.ts:78` |
89
+ | `recentModelsByProvider` | `Record<string,string[]>` | Up to 3 recent model ids per provider | `src/types.ts:80` |
90
+ | `favoriteModels` | `FavoriteModel[]` | `{ providerId, modelId }`, max 20 | `src/types.ts:81` |
91
+ | `server` | object | `savedPassword`, `exposedProviders`, `maskGatewayIds`, `favoritesOnly` | `src/types.ts:82` |
92
+
93
+ **Write path.** `writeConfig()` creates the parent dir `0o700` and writes the file `0o600` with a trailing newline (`src/config.ts:61`). `savePreferences()` is a key-by-key conditional merge — every field is copied through only when `!== undefined`, so partial updates never clobber siblings (`src/config.ts:85`).
94
+
95
+ > **Note on `subscriptionTier`.** Earlier drafts of the design (and `CLAUDE.md`) describe a `subscriptionTier` (`free`/`zen`/`go`/`both`) preference field. In the shipped v0.2.7 code that field is **not** part of `UserPreferences`, `loadPreferences()`, or `savePreferences()`. The tier concept survives instead as `RegistrySubscriptionFilter` on the registry's Zen provider stub (see *Tier behavior* below). This PRD documents the code as shipped.
96
+
97
+ ### App home & override
98
+
99
+ `getAppHome(env)` returns `resolveAppHomeOverride(env)` when `RFLECTR_HOME`/`OPENCODE_STARTER_HOME` is set (trimmed, non-empty), else `~/.rflectr` (`src/paths.ts:23`, `src/paths.ts:28`). Sibling path helpers (`getConfigPath`, `getProvidersPath`, `getLogsPath`, `getVertexModelsPath`) all derive from it (`src/paths.ts:38`).
100
+
101
+ ### Legacy migration
102
+
103
+ `readConfig()` runs `ensureConfigMigrated()` before every read (`src/config.ts:56`). Both stages bail the instant `~/.rflectr/config.json` already exists, making migration idempotent:
104
+
105
+ 1. `ensureAppHomeMigrated()` copies `~/.opencode-starter/config.json` (and `vertex-models.json` alongside) into the new home (`src/config.ts:17`).
106
+ 2. `ensureConfigMigrated()` then copies the even-older OS `conf` file (`getLegacyConfPath`, platform-specific — `src/paths.ts:54`) and best-effort renames the source to `…​.migrated` (`src/config.ts:49`).
107
+
108
+ ### Tier behavior
109
+
110
+ The Zen registry stub carries an optional `subscriptionFilter: RegistrySubscriptionFilter` (`src/registry/types.ts:7`, `src/registry/builtins.ts:7`). First-run seeds it as `'free'` via `zenRegistryStub('free')` (`src/first-run.ts:31`). The filter scopes which OpenCode cloud models surface; combined Zen+Go lists track the originating backend per model so the correct base URL is set per selection (see [PRD-003](../prd-003-model-discovery-classification/) for model-discovery merge and `sourceBackend`).
111
+
112
+ | Filter | Behavior |
113
+ |--------|----------|
114
+ | `free` | Zen free models only — seeded default on first-run (`src/first-run.ts:31`) |
115
+ | `zen` | Zen backend models |
116
+ | `go` | OpenCode Go (paid) backend models |
117
+
118
+ `lastBackend` (`'zen' \| 'go'`) records the most recently launched cloud backend independently of the filter (`src/types.ts:73`). The two cloud backends are defined in `BACKENDS` (`src/constants.ts:9`); their `baseUrl` must **not** include `/v1` (the Anthropic SDK appends `/v1/messages`).
119
+
120
+ ### Favorites flow
121
+
122
+ Pure algebra (`src/favorites.ts`):
123
+
124
+ - `isFavorite(list, fav)` — `providerId` + `modelId` equality.
125
+ - `addFavorite(list, fav, max=MAX_MODEL_CATALOG)` returns `{ ok:false, reason:'duplicate' }`, `{ ok:false, reason:'cap' }`, or `{ ok:true, list }` (`src/favorites.ts:14`).
126
+ - `removeFavorite(list, fav)` — filtered copy (`src/favorites.ts:24`).
127
+
128
+ Manager (`runModelsCommand`, `src/cli.ts:534`): fetches the provider catalog, builds a `providerId:modelId → label` lookup, then loops a menu where each favorite row removes-on-select, `+ Add a model →` offers global cross-provider search (`pickGlobalFavoriteModel`, `src/favorites-picker.ts:85`) or browse-by-provider multi-select, and `Done` exits. State is held in memory and written **once** on exit via `savePreferences({ favoriteModels })` only when `favoritesDirty` (`src/cli.ts:728`). The cap is enforced both in the UI (`atCap` disables Add, `src/cli.ts:579`) and in `addFavorite`.
129
+
130
+ **Resolution shared with Codex.** `resolveFavorite(fav, ctx)` (`src/favorites-resolver.ts:52`) is route-shape-agnostic — each surface (Claude / Codex / Server) builds its own `ResolveContext`. Zen/Go favorites resolve against `zenModels`/`goModels` + `zenGoApiKey` and carry `sourceBackend`; registry favorites resolve via `ctx.findLocalModel` and are dropped when `ctx.agent` blacklists them via `shouldHideModel` (`src/favorites-resolver.ts:73`). `buildFavoritesList(starting, favorites, ctx, max=20)` dedups (starting model + favorites), caps at `max`, and returns `{ resolved, droppedFavorites }` — **stale / unavailable favorites are silently skipped** (`src/favorites-resolver.ts:87`). Resolved favorites become catalog routes (see [PRD-005](../prd-005-local-proxy-catalog-routing/)); the Codex catalog consumes the same resolver (see [PRD-009](../prd-009-codex-integration/prd-009-codex-integration-index.md)).
131
+
132
+ ### Recent models per provider
133
+
134
+ On launch, `recordLaunchSelection()` prepends the chosen model id to `recentModelsByProvider[providerId]`, dedupes, and slices to `MAX_RECENT_MODELS = 3` (`src/config.ts:99`, `src/config.ts:107`). `pickLocalModel()` reads them back, showing up to 3 with a `'recent'` hint plus a "Browse all models →" option; when there are none it goes straight to the full browse (`src/prompts.ts:311`).
135
+
136
+ ### Large-catalog UX
137
+
138
+ `selectModelWithSearch()` shows a flat list when `models.length <= MODEL_SEARCH_THRESHOLD` (25), otherwise delegates to `selectLargeCatalog()` which offers search vs paginated browse at `MODEL_PAGE_SIZE` (15) per page (`src/prompts.ts:246`, `src/prompts.ts:155`). `pickModelFromPagedList()` provides prev/next paging and computes the initial page from a target model id (`src/prompts.ts:87`). Search tokenizes the query on whitespace + punctuation with AND logic across id/name/brand (`src/prompts.ts:52`).
139
+
140
+ ### First-run
141
+
142
+ `needsFirstRunSetup()` returns true only when the registry is empty **and** no Zen/Go key is stored (`src/first-run.ts:21`). `runFirstRunWizard()` offers Zen quick-start (collect key → seed `zenRegistryStub('free')`), import-from-OpenCode, or set-up-your-own-provider — and every branch terminates in `continue` (launch) or explicit `cancel`, never a dead end (`src/first-run.ts:36`).
143
+
144
+ ### `--dry-run`
145
+
146
+ All preference writes are gated on the caller passing `dryRun`. In dry-run, favorites load as `[]` (`src/cli.ts:761`) and the launch path skips `recordLaunchSelection`, so a fresh first-run is fully simulated without mutating `config.json`.
147
+
148
+ ---
149
+
150
+ ## Data Shapes
151
+
152
+ ```ts
153
+ // src/types.ts:67
154
+ export interface FavoriteModel {
155
+ providerId: string;
156
+ modelId: string;
157
+ }
158
+
159
+ // src/types.ts:72
160
+ export interface UserPreferences {
161
+ lastBackend?: 'zen' | 'go';
162
+ lastModel?: string;
163
+ lastProvider?: string;
164
+ lastCodexProvider?: string;
165
+ lastCodexModel?: string;
166
+ lastGeminiProvider?: string;
167
+ lastGeminiModel?: string;
168
+ recentModelsByProvider?: Record<string, string[]>;
169
+ favoriteModels?: FavoriteModel[];
170
+ server?: {
171
+ savedPassword?: string;
172
+ exposedProviders?: string[];
173
+ maskGatewayIds?: boolean;
174
+ favoritesOnly?: boolean;
175
+ };
176
+ }
177
+
178
+ // src/registry/types.ts:7
179
+ export type RegistrySubscriptionFilter = 'free' | 'zen' | 'go';
180
+
181
+ // src/favorites-resolver.ts:8
182
+ export interface ResolvedFavorite {
183
+ providerId: string;
184
+ providerName: string;
185
+ model: LocalProviderModel | ServerModelInfo;
186
+ apiKey: string;
187
+ sourceBackend?: 'zen' | 'go';
188
+ }
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Acceptance Criteria
194
+
195
+ - [x] App home resolves from `RFLECTR_HOME` (or deprecated `OPENCODE_STARTER_HOME`), else `~/.rflectr` (`src/paths.ts:23`).
196
+ - [x] Config dir created `0o700`, file written `0o600` with trailing newline (`src/config.ts:61`).
197
+ - [x] `savePreferences()` shallow-merges, copying only defined keys (`src/config.ts:85`).
198
+ - [x] `recordLaunchSelection()` sets per-agent `last*` and prepends to `recentModelsByProvider`, deduped and capped at 3 (`src/config.ts:101`).
199
+ - [x] Legacy migration runs both stages, is idempotent, and renames the old conf file best-effort (`src/config.ts:34`).
200
+ - [x] `lastProvider === 'opencode'` is normalized to `'zen'` on read (`src/config.ts:70`).
201
+ - [x] Zen registry stub carries a `subscriptionFilter`, seeded `'free'` at first-run (`src/first-run.ts:31`, `src/registry/builtins.ts:7`).
202
+ - [x] `addFavorite` rejects duplicates and enforces the `MAX_MODEL_CATALOG` (20) cap (`src/favorites.ts:14`).
203
+ - [x] `rflectr models` saves favorites once on Done, only when dirty (`src/cli.ts:728`).
204
+ - [x] Favorites resolver drops stale/unavailable favorites silently and is shared across Claude/Codex/Server (`src/favorites-resolver.ts:87`).
205
+ - [x] `buildFavoritesList` dedups starting model + favorites and caps at `max` (`src/favorites-resolver.ts:103`).
206
+ - [x] `pickLocalModel` surfaces up to 3 recent models with a "Browse all" escape hatch (`src/prompts.ts:311`).
207
+ - [x] Lists above `MODEL_SEARCH_THRESHOLD` (25) switch to search/paginated browse at `MODEL_PAGE_SIZE` (15) (`src/prompts.ts:257`).
208
+ - [x] First-run wizard never dead-ends; every path returns `continue` or `cancel` (`src/first-run.ts:36`).
209
+ - [x] `--dry-run` loads favorites as `[]` and skips all preference writes (`src/cli.ts:761`).
210
+
211
+ ---
212
+
213
+ ## Files
214
+
215
+ | File | Role |
216
+ |------|------|
217
+ | `src/config.ts` | Read/write preferences, legacy migration, server-password keyring move, recent-models recording |
218
+ | `src/paths.ts` | App-home resolution, override hook, sibling path helpers, legacy conf path |
219
+ | `src/types.ts` | `UserPreferences`, `FavoriteModel` shapes |
220
+ | `src/favorites.ts` | Pure favorites algebra (`isFavorite`/`addFavorite`/`removeFavorite`) |
221
+ | `src/favorites-picker.ts` | Global cross-provider favorite search (`pickGlobalFavoriteModel`) |
222
+ | `src/favorites-resolver.ts` | Surface-agnostic favorite → route resolution, shared with Codex |
223
+ | `src/prompts.ts` | `pickLocalModel`, recent-models, large-catalog search/pagination |
224
+ | `src/first-run.ts` | Inline never-dead-end first-run wizard |
225
+ | `src/cli.ts` | `runModelsCommand` favorites manager; launch-time recent/favorite wiring |
226
+ | `src/constants.ts` | `BACKENDS`, `MAX_MODEL_CATALOG` |
227
+ | `src/registry/builtins.ts` | Zen/Go registry stubs carrying `subscriptionFilter` |
228
+ | `src/registry/types.ts` | `RegistrySubscriptionFilter` type |
229
+
230
+ ---
231
+
232
+ ## Risks & Known Limitations
233
+
234
+ - **`subscriptionTier` drift:** the field named in `CLAUDE.md` is not present in shipped code; tier filtering is registry-stub-based (`subscriptionFilter`). Documentation that references a `subscriptionTier` preference key is stale.
235
+ - **Stale favorites are invisible:** unavailable favorites (provider removed, model retired) are silently dropped at resolution time; in the `rflectr models` list they render as `★ <id> — provider gone` (`src/cli.ts:575`) but are not auto-pruned from `config.json`.
236
+ - **No schema versioning:** `config.json` has no version field; forward/backward compat relies on all keys being optional and unknown keys being tolerated.
237
+ - **Per-host only:** preferences are not synced across machines.
238
+ - **Best-effort legacy rename:** if renaming the old conf file to `…​.migrated` fails (permissions), the copy still succeeds but the stale source remains (`src/config.ts:51`).
239
+ - **Context window in switch-menu mode** reflects the launch model, not live `/model` switches — a downstream limitation of the catalog/gateway path (see [PRD-005](../prd-005-local-proxy-catalog-routing/)), not the preferences layer.
240
+
241
+ ---
242
+
243
+ ## Related
244
+
245
+ - **Knowledge:** [`preferences-config.md`](../../../knowledge/private/data/preferences-config.md)
246
+ - [PRD-001 — CLI Core & Launch Orchestration](../prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md) — launch flow that reads/writes these preferences
247
+ - [PRD-003 — Model Discovery & Classification](../prd-003-model-discovery-classification/) — tier-scoped model lists, `sourceBackend` merge
248
+ - [PRD-005 — Local Proxy & Catalog Routing](../prd-005-local-proxy-catalog-routing/) — catalog routes built from resolved favorites
249
+ - [PRD-009 — Codex Integration](../prd-009-codex-integration/prd-009-codex-integration-index.md) — Codex favorites catalog using the shared resolver