opencode-anthropic-fix 0.0.16 → 0.0.18

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.
@@ -1,221 +0,0 @@
1
- # CLI Enhancements: Full Account Management
2
-
3
- **Status:** Complete
4
- **Created:** 2026-02-09
5
- **Completed:** 2026-02-09
6
-
7
- ---
8
-
9
- ## Problem Statement
10
-
11
- The plugin CLI (`opencode-anthropic-auth`) can **manage** existing accounts (enable, disable, remove, switch, reset) but cannot **authenticate** new ones or re-authenticate broken ones. The only way to add an account is through `opencode auth login` → selecting "Claude Pro/Max" → browser OAuth flow inside the OpenCode TUI.
12
-
13
- This creates friction in several scenarios:
14
-
15
- 1. **Disabled account recovery** — Account #1 was disabled due to a token refresh failure. To fix it, you'd have to `remove` it via CLI, then re-login via `opencode auth login`, which is the exact workflow we want to avoid.
16
- 2. **No re-authentication** — There's no way to get fresh tokens for an existing broken account without deleting and re-adding it.
17
- 3. **No proper logout** — The `remove` command just deletes local data without revoking tokens server-side.
18
- 4. **Incomplete standalone tool** — The CLI should be a self-contained account management tool that doesn't depend on the OpenCode TUI for any operation.
19
-
20
- ## Solution Overview
21
-
22
- Add four new commands to `cli.mjs` that give full account lifecycle management:
23
-
24
- | Command | Alias | Description |
25
- | -------------- | ----- | -------------------------------------------------- |
26
- | `login` | `ln` | Add a new account via browser OAuth flow |
27
- | `logout <N>` | `lo` | Revoke tokens + remove a specific account |
28
- | `logout --all` | — | Revoke all tokens + clear all accounts |
29
- | `reauth <N>` | `ra` | Re-authenticate an account with fresh OAuth tokens |
30
- | `refresh <N>` | `rf` | Attempt token refresh without browser (quick fix) |
31
-
32
- ### Architecture Change
33
-
34
- Extract the OAuth `authorize()` and `exchange()` functions from `index.mjs` into a new shared module `lib/oauth.mjs`, plus add a new `revoke()` function. Both `index.mjs` (plugin) and `cli.mjs` (standalone) import from this shared module.
35
-
36
- ```
37
- lib/oauth.mjs (NEW)
38
- ├── authorize(mode) — Generate PKCE + build auth URL
39
- ├── exchange(code, verifier) — Exchange auth code for tokens
40
- └── revoke(refreshToken) — Revoke a refresh token (best-effort)
41
- ```
42
-
43
- ## Design Decisions
44
-
45
- | Decision | Choice | Rationale |
46
- | --------------------- | -------------------- | ----------------------------------------------------------------------- |
47
- | Reauth matching | Replace at index | Simpler UX; user explicitly chooses which slot to replace |
48
- | Logout behavior | Revoke + remove | Clean break; user can re-add via `login` |
49
- | Token refresh command | Yes, add `refresh` | Quick fix for expired access tokens without browser round-trip |
50
- | Browser opening | Auto-open + show URL | Best UX: tries `open`/`xdg-open`/`start`, always prints URL as fallback |
51
-
52
- ## Detailed Command Specifications
53
-
54
- ### `login` (alias: `ln`)
55
-
56
- **Flow:**
57
-
58
- 1. Call `authorize("max")` to get PKCE URL + verifier
59
- 2. Auto-open the URL in the user's default browser
60
- 3. Print the URL to terminal as fallback
61
- 4. Prompt user to paste the authorization code (readline)
62
- 5. Call `exchange(code, verifier)` to get tokens
63
- 6. Deduplicate: check if an account with the same refresh token already exists
64
- 7. Add the account to `anthropic-accounts.json`, save, print confirmation
65
-
66
- **Edge cases:**
67
-
68
- - If exchange fails, print error and exit with code 1
69
- - If max accounts (10) reached, print error
70
- - If duplicate refresh token, update existing account instead of adding
71
-
72
- ### `logout <N>` (alias: `lo <N>`)
73
-
74
- **Flow:**
75
-
76
- 1. Validate index N exists
77
- 2. Attempt `revoke(account.refreshToken)` — best-effort, won't block on failure
78
- 3. Remove the account from array (splice)
79
- 4. Adjust `activeIndex` if needed (same logic as existing `remove` command)
80
- 5. Save + print confirmation
81
-
82
- **Flags:**
83
-
84
- - `--force` — Skip confirmation prompt (same as `remove`)
85
- - `--all` — Logout all accounts
86
-
87
- ### `logout --all`
88
-
89
- **Flow:**
90
-
91
- 1. Iterate all accounts, attempt `revoke()` for each
92
- 2. Write explicit empty storage `{ version: 1, accounts: [], activeIndex: 0 }`
93
- 3. Print confirmation with count of revoked accounts
94
-
95
- **Important behavior:** empty storage is treated as authoritative by `AccountManager.load()`, so plugin instances stay logged out and do not re-bootstrap from fallback auth when the accounts file explicitly contains zero accounts.
96
-
97
- ### `reauth <N>` (alias: `ra <N>`)
98
-
99
- **Flow:**
100
-
101
- 1. Validate index N exists
102
- 2. Run full OAuth flow (same as `login` steps 1-5)
103
- 3. Replace the account at index N with the new tokens
104
- 4. Preserve: `id`, `addedAt`, `stats`
105
- 5. Reset: `enabled=true`, `consecutiveFailures=0`, `lastFailureTime=null`, `rateLimitResetTimes={}`
106
- 6. Save + print confirmation
107
-
108
- **Key detail:** Replaces whatever's at index N with whatever the user logs in with, regardless of email matching.
109
-
110
- ### `refresh <N>` (alias: `rf <N>`)
111
-
112
- **Flow:**
113
-
114
- 1. Validate index N exists
115
- 2. Call the existing `refreshAccessToken(account)` function
116
- 3. If success: update account, re-enable if disabled, save + print new token expiry
117
- 4. If failure: print error and suggest `reauth <N>` instead
118
-
119
- ## Files Modified
120
-
121
- | File | Change | Status |
122
- | ------------------- | -------------------------------------------------------------------- | ------ |
123
- | `lib/oauth.mjs` | **NEW** — Extracted `authorize()`, `exchange()`, plus new `revoke()` | Done |
124
- | `index.mjs` | Replace inline OAuth functions with imports from `lib/oauth.mjs` | Done |
125
- | `cli.mjs` | Add 5 new commands + browser opener utility | Done |
126
- | `scripts/build.mjs` | No change needed — esbuild follows imports automatically | N/A |
127
-
128
- ## Task List
129
-
130
- - [x] **Task 1:** Create `lib/oauth.mjs` — extract `authorize()` and `exchange()` from `index.mjs`, add `revoke()`
131
- - [x] **Task 2:** Update `index.mjs` — replace inline functions with imports from `lib/oauth.mjs`
132
- - [x] **Task 3:** Add `openBrowser()` utility to `cli.mjs`
133
- - [x] **Task 4:** Implement `cmdLogin` in `cli.mjs`
134
- - [x] **Task 5:** Implement `cmdLogout` and `cmdLogoutAll` in `cli.mjs`
135
- - [x] **Task 6:** Implement `cmdReauth` in `cli.mjs`
136
- - [x] **Task 7:** Implement `cmdRefresh` in `cli.mjs`
137
- - [x] **Task 8:** Wire new commands into `main()` dispatcher and update `cmdHelp()`
138
- - [x] **Task 9:** Build — passes (esbuild picks up `lib/oauth.mjs` automatically)
139
- - [x] **Task 10:** Tests — all 349 tests pass across 7 test files, zero failures
140
- - [x] **Task 11:** Lint — eslint passes cleanly
141
- - [x] **Task 12:** Live verification — `refresh 1` correctly fails (broken token), `refresh 2` succeeds (7h 59m expiry)
142
- - [x] **Task 13:** Update this document with completion status
143
-
144
- ## Token Revocation Notes
145
-
146
- Anthropic's OAuth implementation may or may not support RFC 7009 token revocation at `POST /v1/oauth/revoke`. The `revoke()` function is implemented as best-effort:
147
-
148
- - Attempts `POST https://console.anthropic.com/v1/oauth/revoke` with `{ token: refreshToken, token_type_hint: "refresh_token", client_id: CLIENT_ID }`
149
- - Returns `true` if 2xx response, `false` otherwise
150
- - `logout` proceeds with local cleanup regardless of revocation result
151
- - No error is surfaced to the user if revocation fails — only a dim note
152
-
153
- ## Immediate Use Case
154
-
155
- To fix the disabled account #1:
156
-
157
- ```bash
158
- # Option A: Try a token refresh first (no browser)
159
- opencode-anthropic-auth refresh 1
160
- # Tested: correctly fails with "token refresh failed" and suggests reauth
161
-
162
- # Option B: If refresh fails, do a full re-auth
163
- opencode-anthropic-auth reauth 1
164
-
165
- # Option C: Full nuke and re-login
166
- opencode-anthropic-auth logout 1
167
- opencode-anthropic-auth login
168
- ```
169
-
170
- **Verified:** `refresh 2` (healthy account) succeeds and reports "Token refreshed, expires in 7h 59m".
171
-
172
- ## Updated Help Text (Preview)
173
-
174
- ```
175
- Anthropic Multi-Account Auth CLI
176
-
177
- Usage:
178
- opencode-anthropic-auth [command] [args]
179
-
180
- Commands:
181
- list Show all accounts with status (default)
182
- status Compact one-liner for scripts/prompts
183
- login Add a new account via browser OAuth flow
184
- logout <N> Revoke tokens and remove account N
185
- logout --all Revoke all tokens and clear all accounts
186
- reauth <N> Re-authenticate account N with fresh OAuth tokens
187
- refresh <N> Attempt token refresh (no browser needed)
188
- switch <N> Set account N as active
189
- enable <N> Enable a disabled account
190
- disable <N> Disable an account (skipped in rotation)
191
- remove <N> Remove an account permanently
192
- reset <N|all> Clear rate-limit / failure tracking
193
- stats Show per-account usage statistics
194
- reset-stats [N|all] Reset usage statistics
195
- strategy [name] Show or change selection strategy
196
- config Show configuration and file paths
197
- manage Interactive account management menu
198
- help Show this help message
199
- ```
200
-
201
- ## Post-Review Remediations
202
-
203
- Following delegated code review, the following fixes were applied:
204
-
205
- 1. **Logout-all semantics hardened**
206
- - `cmdLogoutAll` now writes explicit empty account storage instead of deleting the accounts file.
207
- - `AccountManager.load` now treats existing storage (including empty storage) as authoritative, preventing fallback re-bootstrap when users explicitly logout-all.
208
-
209
- 2. **Windows browser launch fixed**
210
- - `openBrowser` now uses `cmd /c start "" <url>` on Windows for correct URL handling.
211
-
212
- 3. **Reauth state message bug fixed**
213
- - `cmdReauth` now captures prior disabled state (`wasDisabled`) before mutation and correctly prints "re-enabled" message when applicable.
214
-
215
- 4. **Login dedupe ordering corrected**
216
- - `cmdLogin` now performs duplicate-token update path before applying max-account rejection, so existing accounts can still be refreshed when at capacity.
217
-
218
- 5. **Unit test coverage expanded for new auth commands**
219
- - Added direct CLI unit tests for `cmdLogin`, `cmdLogout` (single + `--all`), `cmdReauth`, and `cmdRefresh`.
220
- - Added main routing tests for new aliases/paths: `ln`, `lo --all --force`, `ra`, and `rf`.
221
- - Added help text assertions for new commands/examples.
@@ -1,201 +0,0 @@
1
- # Architecture
2
-
3
- **Analysis Date:** 2026-02-07
4
-
5
- ## Pattern Overview
6
-
7
- **Overall:** Plugin + CLI dual-entry-point architecture with shared library layer
8
-
9
- **Key Characteristics:**
10
-
11
- - OpenCode plugin providing OAuth-based multi-account Anthropic authentication
12
- - Standalone CLI binary for account management (shares storage layer with plugin)
13
- - Fetch interceptor pattern: the plugin wraps HTTP requests to Anthropic API, injecting OAuth tokens, transforming request/response bodies, and rotating accounts on failures
14
- - Stateful account management with disk persistence and in-memory health tracking
15
- - No framework — pure Node.js ESM modules with zero runtime dependencies beyond `@openauthjs/openauth`
16
-
17
- ## Layers
18
-
19
- **Plugin Entry (`index.mjs`):**
20
-
21
- - Purpose: OpenCode plugin that intercepts Anthropic API calls with OAuth multi-account support
22
- - Location: `index.mjs`
23
- - Contains: OAuth authorize/exchange helpers, request/response transform functions, `AnthropicAuthPlugin` factory, token refresh logic, CLI-style interactive prompts for auth flow
24
- - Depends on: `lib/accounts.mjs`, `lib/config.mjs`, `lib/storage.mjs`, `lib/backoff.mjs`, `@openauthjs/openauth`
25
- - Used by: OpenCode runtime (loaded as plugin)
26
-
27
- **CLI Entry (`cli.mjs`):**
28
-
29
- - Purpose: Standalone CLI for managing accounts outside of the OpenCode TUI
30
- - Location: `cli.mjs`
31
- - Contains: All CLI commands (list, switch, enable, disable, remove, reset, stats, strategy, config, manage), formatting/color helpers, usage quota fetching
32
- - Depends on: `lib/storage.mjs`, `lib/config.mjs`
33
- - Used by: End users via `opencode-anthropic-auth` binary
34
-
35
- **Account Management (`lib/accounts.mjs`):**
36
-
37
- - Purpose: In-memory account pool with health scoring, rotation, and disk sync
38
- - Location: `lib/accounts.mjs`
39
- - Contains: `AccountManager` class — the core runtime coordinator for account selection, failure tracking, rate limit handling, and usage statistics
40
- - Depends on: `lib/storage.mjs`, `lib/rotation.mjs`, `lib/backoff.mjs`
41
- - Used by: `index.mjs`
42
-
43
- **Rotation Logic (`lib/rotation.mjs`):**
44
-
45
- - Purpose: Account selection algorithms and health/token tracking
46
- - Location: `lib/rotation.mjs`
47
- - Contains: `HealthScoreTracker` class, `TokenBucketTracker` class, `selectAccount()` function implementing sticky/round-robin/hybrid strategies
48
- - Depends on: `lib/config.mjs` (for defaults only)
49
- - Used by: `lib/accounts.mjs`
50
-
51
- **Backoff Logic (`lib/backoff.mjs`):**
52
-
53
- - Purpose: Error classification and retry backoff calculation
54
- - Location: `lib/backoff.mjs`
55
- - Contains: `isAccountSpecificError()`, `parseRateLimitReason()`, `calculateBackoffMs()`, `parseRetryAfterHeader()`
56
- - Depends on: Nothing
57
- - Used by: `index.mjs`, `lib/accounts.mjs`
58
-
59
- **Configuration (`lib/config.mjs`):**
60
-
61
- - Purpose: Config file loading, validation, and env var overrides
62
- - Location: `lib/config.mjs`
63
- - Contains: `loadConfig()`, `saveConfig()`, `DEFAULT_CONFIG`, `CLIENT_ID`, strategy validation, XDG-compliant path resolution
64
- - Depends on: Node.js builtins only
65
- - Used by: `index.mjs`, `cli.mjs`, `lib/accounts.mjs`, `lib/rotation.mjs`
66
-
67
- **Storage (`lib/storage.mjs`):**
68
-
69
- - Purpose: Atomic JSON file persistence for account data
70
- - Location: `lib/storage.mjs`
71
- - Contains: `loadAccounts()`, `saveAccounts()`, `clearAccounts()`, account validation/deduplication, `.gitignore` management
72
- - Depends on: `lib/config.mjs` (for config dir path)
73
- - Used by: `index.mjs`, `cli.mjs`, `lib/accounts.mjs`
74
-
75
- ## Data Flow
76
-
77
- **API Request (Plugin fetch interceptor):**
78
-
79
- 1. OpenCode calls `fetch(input, init)` on the plugin's custom fetch
80
- 2. `transformRequestBody()` sanitizes system prompts and prefixes tool names with `mcp_`
81
- 3. `transformRequestUrl()` adds `?beta=true` to `/v1/messages` endpoint
82
- 4. `AccountManager.getCurrentAccount()` selects best account via strategy (sticky/round-robin/hybrid)
83
- 5. If token expired, `refreshAccountTokenSingleFlight()` refreshes OAuth token (single-flight deduplication)
84
- 6. `buildRequestHeaders()` sets Authorization bearer, anthropic-beta headers, removes x-api-key
85
- 7. Actual `fetch()` to Anthropic API executes
86
- 8. On account-specific error (429, 401, 400/403 with account language): mark account, try next account (up to N accounts)
87
- 9. On service-wide error (529, 503, 500): return immediately (switching won't help)
88
- 10. On success: `transformResponse()` wraps the SSE stream to strip `mcp_` prefix from tool names, extract token usage, and detect mid-stream account errors
89
-
90
- **Account Selection (per-request):**
91
-
92
- 1. `AccountManager.syncActiveIndexFromDisk()` reconciles with CLI changes
93
- 2. Build candidate list: enabled accounts, not in per-request exclusion set
94
- 3. `selectAccount()` applies configured strategy:
95
- - **sticky**: Stay on current account unless rate-limited/failed
96
- - **round-robin**: Rotate cursor through available accounts
97
- - **hybrid**: Score accounts by health (weighted ×2) + token bucket (weighted ×5) + freshness, with stickiness bonus (150 points) and switch threshold (100 points)
98
- 4. Consume a token bucket token for the selected account
99
- 5. Return selected `ManagedAccount`
100
-
101
- **OAuth Login Flow:**
102
-
103
- 1. User triggers `opencode auth login` → plugin's `auth.methods[0].authorize()` runs
104
- 2. If existing accounts: prompt menu (add/fresh/manage/cancel)
105
- 3. Generate PKCE challenge via `@openauthjs/openauth`
106
- 4. Build authorize URL for `claude.ai/oauth/authorize`
107
- 5. User pastes authorization code
108
- 6. `exchange()` POSTs to `console.anthropic.com/v1/oauth/token` for tokens
109
- 7. `AccountManager.addAccount()` stores new credentials (deduplicates by refresh token)
110
- 8. `AccountManager.saveToDisk()` persists to `~/.config/opencode/anthropic-accounts.json`
111
-
112
- **State Management:**
113
-
114
- - **In-memory (plugin process):** `AccountManager` with `HealthScoreTracker` and `TokenBucketTracker` — these are ephemeral, rebuilt on plugin load
115
- - **On-disk (shared):** `anthropic-accounts.json` stores account credentials, failure tracking, rate limit reset times, usage stats, and active index
116
- - **Sync mechanism:** Plugin reads `activeIndex` from disk before each request to pick up CLI changes; debounced writes (1s) persist plugin-side changes back
117
- - **Stats merge-on-save:** To handle concurrent instances, `saveToDisk()` reads disk values and applies in-memory deltas rather than overwriting
118
-
119
- ## Key Abstractions
120
-
121
- **AccountManager (`lib/accounts.mjs`):**
122
-
123
- - Purpose: Central coordinator for multi-account state and request routing
124
- - Pattern: Singleton per plugin instance with private state (`#accounts`, `#healthTracker`, `#tokenTracker`)
125
- - Static factory: `AccountManager.load(config, authFallback)` bootstraps from disk + OpenCode auth
126
- - Key methods: `getCurrentAccount()`, `markRateLimited()`, `markSuccess()`, `markFailure()`, `recordUsage()`, `syncActiveIndexFromDisk()`
127
-
128
- **HealthScoreTracker (`lib/rotation.mjs`):**
129
-
130
- - Purpose: Track per-account health with passive time-based recovery
131
- - Pattern: In-memory score map with configurable initial/penalty/recovery values
132
- - Score range: 0–100, min_usable threshold (default 50) gates account selection
133
-
134
- **TokenBucketTracker (`lib/rotation.mjs`):**
135
-
136
- - Purpose: Rate-limit requests per account using token bucket algorithm
137
- - Pattern: Per-account bucket with configurable max tokens, regeneration rate
138
- - Default: 50 tokens, 6/minute regen
139
-
140
- **ManagedAccount (typedef in `lib/accounts.mjs`):**
141
-
142
- - Purpose: Runtime representation of an Anthropic OAuth account
143
- - Contains: id, index, email, refreshToken, access, expires, enabled, rateLimitResetTimes, consecutiveFailures, stats
144
- - Pattern: Mutable in-memory state with periodic disk sync
145
-
146
- ## Entry Points
147
-
148
- **Plugin (`index.mjs` → `AnthropicAuthPlugin`):**
149
-
150
- - Location: `index.mjs`
151
- - Triggers: Loaded by OpenCode plugin system as ESM module
152
- - Responsibilities: Exports an async plugin factory function returning hooks for `experimental.chat.system.transform`, `auth.loader`, and `auth.methods`
153
- - Exported as: `export async function AnthropicAuthPlugin({ client })`
154
-
155
- **CLI (`cli.mjs` → `main()`):**
156
-
157
- - Location: `cli.mjs`
158
- - Triggers: Executed as `opencode-anthropic-auth [command]` via `~/.local/bin` symlink/copy
159
- - Responsibilities: Parse argv, route to command handlers, manage accounts via direct storage manipulation
160
- - Self-detection: Uses `import.meta.url === pathToFileURL(process.argv[1]).href` with symlink resolution
161
-
162
- **Build (`scripts/build.mjs`):**
163
-
164
- - Location: `scripts/build.mjs`
165
- - Triggers: `npm run build`
166
- - Responsibilities: Bundle `index.mjs` → `dist/opencode-anthropic-auth-plugin.js` and `cli.mjs` → `dist/opencode-anthropic-auth-cli.mjs` using esbuild (ESM, node20 target, all deps bundled except node: builtins)
167
-
168
- **Install (`scripts/install.mjs`):**
169
-
170
- - Location: `scripts/install.mjs`
171
- - Triggers: `npm run install:link` (dev) or `npm run install:copy` (production)
172
- - Responsibilities: Symlink or copy plugin to `~/.config/opencode/plugin/` and CLI to `~/.local/bin/`
173
-
174
- ## Error Handling
175
-
176
- **Strategy:** Account-specific vs. service-wide error classification with automatic account rotation
177
-
178
- **Patterns:**
179
-
180
- - **Account-specific errors** (429, 401, 400/403 with account error body): Mark account with backoff, rotate to next account. Auth failures (401) invalidate cached token.
181
- - **Service-wide errors** (529, 503, 500): Return immediately to caller — switching accounts won't help.
182
- - **Token refresh failures**: Transient errors skip account for current request; terminal errors (400, 401, 403, `invalid_grant`) disable the account permanently.
183
- - **Mid-stream SSE errors**: Detected in response stream transformer. Account marked for next request (not retried mid-stream).
184
- - **Single-flight token refresh**: Concurrent requests to the same account share one in-flight refresh promise to prevent race conditions.
185
- - **Backoff calculation** (`lib/backoff.mjs`): Retry-After header takes precedence. Otherwise, reason-based: AUTH_FAILED=5s, RATE_LIMIT_EXCEEDED=30s, QUOTA_EXHAUSTED=escalating [1m, 5m, 30m, 2h].
186
-
187
- ## Cross-Cutting Concerns
188
-
189
- **Logging:** Debug logging via `console.error("[opencode-anthropic-auth]", ...)` gated by `config.debug` flag (env: `OPENCODE_ANTHROPIC_DEBUG=1`). CLI uses direct `console.log` with ANSI color helpers.
190
-
191
- **Validation:** Config values are clamped to valid ranges in `lib/config.mjs` (`clampNumber()`). Account data is validated on load with type checks and fallbacks in `lib/storage.mjs` (`validateAccount()`). Duplicate refresh tokens are deduplicated.
192
-
193
- **Persistence:** All disk writes use atomic temp-file-and-rename pattern. Account storage uses `randomBytes(6).hex` temp names; config uses PID-based temp names. Files are written with `mode: 0o600` for security.
194
-
195
- **Configuration Precedence:** Config file (`~/.config/opencode/anthropic-auth.json`) → environment variable overrides (`OPENCODE_ANTHROPIC_STRATEGY`, `OPENCODE_ANTHROPIC_DEBUG`, `OPENCODE_ANTHROPIC_QUIET`).
196
-
197
- **Toast Notifications:** Plugin sends TUI toasts via `client.tui?.showToast()` for account usage, switching, and errors. Toasts are debounced per category and suppressible via quiet mode.
198
-
199
- ---
200
-
201
- _Architecture analysis: 2026-02-07_
@@ -1,219 +0,0 @@
1
- # Codebase Concerns
2
-
3
- **Analysis Date:** 2026-02-07
4
-
5
- ## Tech Debt
6
-
7
- **Duplicated Token Refresh Logic:**
8
-
9
- - Issue: Token refresh is implemented three separate times with different error handling
10
- - Files: `index.mjs` (lines 40-68 `exchange()`, lines 535-591 `refreshAccountToken()`), `cli.mjs` (lines 138-159 `refreshAccessToken()`)
11
- - Impact: Bug fixes or API changes must be applied in three places. `cli.mjs` version silently swallows errors (returns `null`), while `index.mjs` version throws with detailed error codes. Divergent behavior makes debugging harder.
12
- - Fix approach: Extract a shared `refreshToken()` function into `lib/auth.mjs` with consistent error handling. Both `index.mjs` and `cli.mjs` should import from the shared module.
13
-
14
- **Hardcoded User-Agent String:**
15
-
16
- - Issue: `user-agent` header is hardcoded as `"claude-cli/2.1.2 (external, cli)"` with a static version
17
- - Files: `index.mjs` (line 210)
18
- - Impact: Version never updates as the actual project version changes (currently `0.0.13` in `package.json`). Server-side analytics will show a stale version. The user-agent also impersonates a different product.
19
- - Fix approach: Read version from `package.json` or a generated constant at build time. Consider using the actual product name.
20
-
21
- **Hardcoded Beta Header Versions:**
22
-
23
- - Issue: Required beta header values `"oauth-2025-04-20"` and `"interleaved-thinking-2025-05-14"` are hardcoded inline
24
- - Files: `index.mjs` (line 205)
25
- - Impact: When Anthropic updates or removes beta flags, the change must be found inline rather than in a central configuration.
26
- - Fix approach: Move beta header values to constants in `lib/config.mjs` alongside `CLIENT_ID`.
27
-
28
- **Hardcoded OAuth URLs:**
29
-
30
- - Issue: URLs like `https://console.anthropic.com/v1/oauth/token`, `https://claude.ai/oauth/authorize`, and `https://api.anthropic.com/api/oauth/claude_cli/create_api_key` are scattered as string literals
31
- - Files: `index.mjs` (lines 21, 25, 42, 536, 1028), `cli.mjs` (lines 140, 168)
32
- - Impact: No way to override for testing or staging environments. URLs duplicated across files.
33
- - Fix approach: Centralize all API URLs as constants in `lib/config.mjs`.
34
-
35
- **No TypeScript — JSDoc Only:**
36
-
37
- - Issue: The entire codebase uses plain `.mjs` files with JSDoc type annotations instead of TypeScript
38
- - Files: All source files (`index.mjs`, `cli.mjs`, `lib/*.mjs`)
39
- - Impact: No compile-time type checking unless an editor provides it. `@ts-ignore` comments (3 instances in `index.mjs` lines 563-567) indicate type system friction. JSDoc types are more verbose and less expressive than TypeScript equivalents.
40
- - Fix approach: This is a conscious design choice (no build step for plugin, direct ESM). If migrating, use esbuild's existing TS support. Low priority unless type bugs emerge.
41
-
42
- **System Prompt Manipulation is Fragile:**
43
-
44
- - Issue: System prompt transform uses regex replacement `OpenCode → Claude Code` and `opencode → Claude` with a lookbehind `(?<!\/)` to preserve paths
45
- - Files: `index.mjs` (lines 233-239)
46
- - Impact: The regex may not handle all edge cases (e.g., `opencode` in URLs with query strings, or in code blocks). The prefix injection `"You are Claude Code, Anthropic's official CLI for Claude."` is concatenated by string position (`output.system[1]`), which assumes specific system prompt array structure.
47
- - Fix approach: Consider a more robust approach that doesn't depend on array index positions. Document the assumed system prompt structure.
48
-
49
- **`index.mjs` is a 1050-line Monolith:**
50
-
51
- - Issue: `index.mjs` contains OAuth helpers, request building, body transformation, SSE parsing, response wrapping, token refresh, the entire retry loop, and the plugin entry point — all in one file
52
- - Files: `index.mjs` (1050 lines)
53
- - Impact: Difficult to test individual pieces in isolation (integration test `index.test.mjs` must mock many dependencies). High cognitive load for maintainers.
54
- - Fix approach: Extract into focused modules: `lib/oauth.mjs` (authorize/exchange), `lib/request.mjs` (header building, body/URL transforms), `lib/sse.mjs` (stream parsing, usage extraction), `lib/retry.mjs` (fetch retry loop). Keep `index.mjs` as thin plugin wiring.
55
-
56
- ## Known Bugs
57
-
58
- **Tool Name Prefix Regex May Match False Positives in SSE Stream:**
59
-
60
- - Symptoms: The response stream regex `/"name"\s*:\s*"mcp_([^"]+)"/g` strips `mcp_` from any JSON `"name"` field in the raw text stream, not just tool_use blocks
61
- - Files: `index.mjs` (line 489)
62
- - Trigger: An assistant response containing literal text like `"name": "mcp_something"` in a string field (e.g., in a code example within the response) would have `mcp_` incorrectly stripped
63
- - Workaround: Low probability in practice since the regex requires the exact JSON format. A proper fix would parse SSE events and only transform `tool_use` blocks.
64
-
65
- **Storage Version Migration Returns Null:**
66
-
67
- - Symptoms: If the storage file has a version other than `1`, `loadAccounts()` returns `null` — silently discarding all accounts
68
- - Files: `lib/storage.mjs` (lines 193-196)
69
- - Trigger: Future version migration — the comment says "Future: handle migrations here" but currently just returns null
70
- - Workaround: None currently needed (only version 1 exists), but this will cause data loss when version 2 is introduced unless migration is implemented first.
71
-
72
- ## Security Considerations
73
-
74
- **Refresh Tokens Stored in Plaintext JSON:**
75
-
76
- - Risk: OAuth refresh tokens (long-lived credentials) are stored in `~/.config/opencode/anthropic-accounts.json` as plaintext JSON
77
- - Files: `lib/storage.mjs` (lines 230-251), `lib/config.mjs` (lines 253-268)
78
- - Current mitigation: File permissions set to `0o600` (owner-only read/write). `.gitignore` entry added automatically by `ensureGitignore()`. Storage path is in XDG config directory.
79
- - Recommendations: Consider using OS keychain (macOS Keychain, Linux Secret Service) for refresh tokens. At minimum, document the security model for users. The `0o600` permission is correctly applied.
80
-
81
- **Access Tokens Briefly Stored on Disk:**
82
-
83
- - Risk: Short-lived access tokens are persisted alongside refresh tokens in the accounts JSON file (via debounced save)
84
- - Files: `lib/accounts.mjs` (lines 474-479)
85
- - Current mitigation: Access tokens expire (typically 1 hour). File has `0o600` permissions.
86
- - Recommendations: Consider not persisting access tokens at all — they can always be re-derived from the refresh token. This reduces the window of exposure.
87
-
88
- **Silent Error Swallowing (17 Empty Catch Blocks):**
89
-
90
- - Risk: 17 empty `catch {}` blocks across the codebase silently swallow errors, potentially hiding security-relevant failures
91
- - Files: `index.mjs` (7 instances: lines 270, 292, 457, 557, 639, 738, 854), `cli.mjs` (3 instances: lines 156, 178, 1288), `lib/accounts.mjs` (line 435), `lib/backoff.mjs` (lines 57, 68), `lib/config.mjs` (lines 224, 242), `lib/storage.mjs` (lines 116, 246)
92
- - Current mitigation: Many are intentional (e.g., fallback to defaults on parse error, ignore cleanup errors). The `debugLog` function exists but is only used in the fetch retry loop.
93
- - Recommendations: Add `debugLog` calls to critical catch blocks (token refresh errors, storage write failures). At minimum, differentiate between "expected/safe" catches and "should-never-happen" catches with comments.
94
-
95
- **Client ID is Hardcoded and Public:**
96
-
97
- - Risk: The OAuth client ID `9d1c250a-e61b-44d9-88ed-5944d1962f5e` is hardcoded in source
98
- - Files: `lib/config.mjs` (line 71)
99
- - Current mitigation: OAuth client IDs are considered public (not secrets). PKCE flow provides security without a client secret.
100
- - Recommendations: No action needed — this is correct OAuth design.
101
-
102
- ## Performance Bottlenecks
103
-
104
- **Disk I/O on Every Request (syncActiveIndexFromDisk):**
105
-
106
- - Problem: `syncActiveIndexFromDisk()` reads the entire accounts JSON file from disk at the start of every API request
107
- - Files: `lib/accounts.mjs` (lines 513-573), `index.mjs` (line 747)
108
- - Cause: Enables the CLI to change active account while the plugin is running. Reads `anthropic-accounts.json`, parses JSON, compares snapshots, potentially rebuilds the entire account list.
109
- - Improvement path: Use filesystem watcher (`fs.watch`) instead of polling on every request. Alternatively, add a TTL cache (e.g., only re-read if more than 5 seconds since last read). The current approach adds file I/O latency to every API call.
110
-
111
- **Debounced Save Creates Write Pressure:**
112
-
113
- - Problem: `requestSaveToDisk()` uses a 1-second debounce timer, but during rapid account switching (multiple 429s in quick succession), each `markRateLimited()` call resets the timer — meaning the write can be delayed indefinitely under sustained failures
114
- - Files: `lib/accounts.mjs` (lines 412-418)
115
- - Cause: Debounce resets on each call. Under heavy load with rapid account switches, state may be stale on disk for extended periods.
116
- - Improvement path: Add a maximum delay (e.g., save at most every 5 seconds regardless of debounce resets). Use a "leading + trailing" debounce pattern.
117
-
118
- **Full Account List Rebuild on Sync:**
119
-
120
- - Problem: When `syncActiveIndexFromDisk()` detects changes, it rebuilds the entire `#accounts` array and resets both `HealthScoreTracker` and `TokenBucketTracker`
121
- - Files: `lib/accounts.mjs` (lines 521-558)
122
- - Cause: Snapshot comparison uses string concatenation of all refresh tokens and enabled states. Any change triggers full rebuild.
123
- - Improvement path: Track individual account changes rather than full rebuild. Preserve health/token state for accounts that haven't changed.
124
-
125
- ## Fragile Areas
126
-
127
- **SSE Stream Parser:**
128
-
129
- - Files: `index.mjs` (lines 406-499, `transformResponse()`)
130
- - Why fragile: The SSE parser manually splits on `\n\n` boundaries, handles chunk splitting across `reader.read()` calls, normalizes CRLF, and simultaneously strips tool name prefixes via regex, extracts usage stats, and detects mid-stream errors — all in a single `ReadableStream.pull()` callback.
131
- - Safe modification: Any changes to SSE parsing should be accompanied by tests that verify chunk-split scenarios (data split across multiple `reader.read()` calls). The existing tests in `index.test.mjs` cover this well (see "chunk-split mid-stream error" test).
132
- - Test coverage: Good — multiple SSE edge cases tested. But the regex-based tool name stripping (line 489) operates on raw text, not parsed JSON, making it sensitive to formatting changes.
133
-
134
- **Account Selection Retry Loop:**
135
-
136
- - Files: `index.mjs` (lines 723-930, the `for` loop inside `fetch()`)
137
- - Why fragile: 200+ line retry loop with nested async operations, multiple `continue` paths, error classification, token refresh with single-flight protection, toast debouncing, and account state mutations. Interleaves I/O (fetch, disk sync, toast) with state transitions.
138
- - Safe modification: Ensure any changes maintain the invariant that each account is tried at most once per request. The `maxAttempts = accountManager.getTotalAccountCount()` bound prevents infinite loops. Always test with 1, 2, and 3+ accounts.
139
- - Test coverage: Good — `index.test.mjs` has comprehensive tests for 429 retry, 401 auth failure, network errors, and mixed failures across accounts.
140
-
141
- **Plugin-CLI Shared State via Disk:**
142
-
143
- - Files: `lib/accounts.mjs` (`saveToDisk`, `syncActiveIndexFromDisk`), `lib/storage.mjs` (`saveAccounts`, `loadAccounts`)
144
- - Why fragile: The plugin (running inside OpenCode) and CLI (separate process) share state through `anthropic-accounts.json`. Both can read and write concurrently. The merge-on-save strategy in `saveToDisk()` (lines 427-506) attempts to handle this, but there's no file locking.
145
- - Safe modification: Always use atomic write (temp file + rename, already implemented). Test any changes with concurrent read/write scenarios. The stats delta merge system is particularly complex — changes need careful testing.
146
- - Test coverage: `lib/accounts.test.mjs` covers the AccountManager thoroughly. Storage atomicity is implemented but not stress-tested.
147
-
148
- ## Scaling Limits
149
-
150
- **MAX_ACCOUNTS = 10:**
151
-
152
- - Current capacity: Hard limit of 10 accounts
153
- - Limit: `addAccount()` returns `null` when at capacity
154
- - Scaling path: The limit is defined in `lib/accounts.mjs` (line 35). Increasing it is trivial but would slow down the retry loop (which tries each account at most once per request) and make the disk sync snapshot comparison heavier.
155
- - Files: `lib/accounts.mjs` (line 35)
156
-
157
- **In-Memory Health/Token Trackers Not Persisted:**
158
-
159
- - Current capacity: Health scores and token bucket states exist only in memory
160
- - Limit: Plugin restart (OpenCode restart) resets all health scores to defaults. Recent rate-limit history from the health tracker is lost, potentially sending traffic to recently-failed accounts.
161
- - Scaling path: Persist tracker state alongside accounts, or at minimum, bootstrap health scores from `consecutiveFailures` and `lastFailureTime` on load.
162
- - Files: `lib/rotation.mjs` (classes `HealthScoreTracker`, `TokenBucketTracker`)
163
-
164
- ## Dependencies at Risk
165
-
166
- **`@openauthjs/openauth` (^0.4.3):**
167
-
168
- - Risk: Only used for PKCE generation (`generatePKCE`). Small API surface but the package is pre-1.0 and could introduce breaking changes.
169
- - Impact: Auth flow breaks if PKCE generation API changes.
170
- - Migration plan: PKCE is a simple algorithm — could be replaced with a ~20 line implementation using `node:crypto`. Would eliminate the only production dependency.
171
-
172
- **`@opencode-ai/plugin` (devDependency, ^0.4.45):**
173
-
174
- - Risk: Defines the plugin interface (`Plugin` type). Pre-1.0, interface may change without notice.
175
- - Impact: Plugin won't load if the interface changes. This is a dev dependency (types only), so runtime impact is indirect.
176
- - Migration plan: No alternative — must track OpenCode's plugin interface. Pin version when stability is critical.
177
-
178
- ## Missing Critical Features
179
-
180
- **No Automatic Account Recovery:**
181
-
182
- - Problem: When an account is disabled (e.g., due to repeated token refresh failures), it stays disabled forever until manually re-enabled via CLI
183
- - Blocks: Unattended long-running sessions. If a token refresh fails due to a transient network issue and triggers the disable path (400/401/403 status), the user must notice and manually run `opencode-anthropic-auth enable N`.
184
- - Files: `index.mjs` (lines 798-809, `shouldDisable` logic)
185
- - Fix approach: Add a periodic recovery check that attempts to refresh disabled accounts (e.g., every 30 minutes). Or add an `auto_disable: false` config option to prevent automatic disabling.
186
-
187
- **No Token Refresh Retry:**
188
-
189
- - Problem: Token refresh failures are treated as final — no retry with backoff. A single network hiccup during refresh disables or skips the account.
190
- - Blocks: Reliability in unstable network conditions.
191
- - Files: `index.mjs` (lines 785-816)
192
- - Fix approach: Add 1-2 retries with short backoff for token refresh before marking the account as failed.
193
-
194
- ## Test Coverage Gaps
195
-
196
- **CLI Commands (`cli.mjs`) — Partial Coverage:**
197
-
198
- - What's not tested: `cmdList()` live usage quota fetching, `cmdManage()` interactive flows with actual account mutations, `cmdConfig()` output formatting, error paths in `cmdSwitch`/`cmdEnable`/`cmdDisable` when storage write fails
199
- - Files: `cli.mjs` (1301 lines), `cli.test.mjs` (1443 lines — tests exist but focus on unit functions and command routing, not integration)
200
- - Risk: CLI commands that mutate storage could have bugs in edge cases (e.g., removing the last account, disabling while active). The formatting/display functions are tested but the end-to-end command flows less so.
201
- - Priority: Medium — CLI is a secondary interface; the plugin's fetch interceptor is the critical path.
202
-
203
- **No Coverage Enforcement:**
204
-
205
- - What's not tested: No coverage thresholds or reports are configured
206
- - Files: `package.json` (no coverage script)
207
- - Risk: Coverage can silently degrade. Currently at 335 passing tests across 7 files, which is good, but there's no gate preventing regressions.
208
- - Priority: Low — test suite is healthy, but adding `vitest --coverage` to CI would formalize the baseline.
209
-
210
- **SSE Stream Edge Cases:**
211
-
212
- - What's not tested: Malformed SSE payloads (partially valid JSON), very large SSE events, streams with only `event:` lines and no `data:` lines, CRLF vs LF mixed in same stream
213
- - Files: `index.mjs` (lines 406-499)
214
- - Risk: Parsing bugs could cause token usage tracking to miss events or tool name stripping to fail silently
215
- - Priority: Low — existing tests cover the main paths including chunk splitting. The parser is defensive (catches JSON parse errors).
216
-
217
- ---
218
-
219
- _Concerns audit: 2026-02-07_