opencode-anthropic-fix 0.0.14
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.
- package/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
|
@@ -0,0 +1,219 @@
|
|
|
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_
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# Coding Conventions
|
|
2
|
+
|
|
3
|
+
**Analysis Date:** 2026-02-07
|
|
4
|
+
|
|
5
|
+
## Language & Module System
|
|
6
|
+
|
|
7
|
+
**Language:** Pure JavaScript (ES2025) — no TypeScript, no compilation for source files.
|
|
8
|
+
**Module System:** ESM exclusively. All source files use `.mjs` extension.
|
|
9
|
+
**Type Safety:** JSDoc annotations provide full type information without a compile step.
|
|
10
|
+
|
|
11
|
+
## Naming Patterns
|
|
12
|
+
|
|
13
|
+
**Files:**
|
|
14
|
+
|
|
15
|
+
- Source files: `kebab-case.mjs` — e.g., `lib/storage.mjs`, `lib/backoff.mjs`
|
|
16
|
+
- Test files: `kebab-case.test.mjs` — co-located next to source
|
|
17
|
+
- Scripts: `kebab-case.mjs` in `scripts/` — e.g., `scripts/build.mjs`, `scripts/install.mjs`
|
|
18
|
+
- Entry points: `index.mjs`, `cli.mjs` at project root
|
|
19
|
+
|
|
20
|
+
**Functions:**
|
|
21
|
+
|
|
22
|
+
- Use `camelCase` for all functions: `loadAccounts`, `calculateBackoffMs`, `parseRetryAfterHeader`
|
|
23
|
+
- Factory/builder functions: `make*` prefix — `makeStoredAccount`, `makeClient`
|
|
24
|
+
- Validation functions: `validate*` prefix — `validateAccount`, `validateConfig`, `validateStats`
|
|
25
|
+
- Boolean-returning functions: `is*` prefix — `isAccountSpecificError`, `isUsable`
|
|
26
|
+
- Getters: `get*` prefix — `getScore`, `getConfigDir`, `getStoragePath`, `getCurrentIndex`
|
|
27
|
+
- Event recorders: `record*` prefix — `recordSuccess`, `recordRateLimit`, `recordFailure`
|
|
28
|
+
|
|
29
|
+
**Variables:**
|
|
30
|
+
|
|
31
|
+
- Use `camelCase`: `storagePath`, `configDir`, `activeIndex`
|
|
32
|
+
- Constants: `UPPER_SNAKE_CASE` — `MAX_ACCOUNTS`, `RATE_LIMIT_KEY`, `CURRENT_VERSION`
|
|
33
|
+
- Unused parameters: prefix with `_` — `_error`, `_response` (enforced by ESLint `argsIgnorePattern: "^_"`)
|
|
34
|
+
|
|
35
|
+
**Classes:**
|
|
36
|
+
|
|
37
|
+
- Use `PascalCase`: `AccountManager`, `HealthScoreTracker`, `TokenBucketTracker`
|
|
38
|
+
- Private fields use `#` prefix (true private fields): `#accounts`, `#cursor`, `#config`, `#scores`
|
|
39
|
+
- No underscore-prefixed "private" conventions — always use `#` for private state
|
|
40
|
+
|
|
41
|
+
**Types (JSDoc):**
|
|
42
|
+
|
|
43
|
+
- Use `PascalCase` for `@typedef` names: `ManagedAccount`, `AccountStorage`, `RateLimitReason`
|
|
44
|
+
- String literal unions: `@typedef {'AUTH_FAILED' | 'QUOTA_EXHAUSTED' | 'RATE_LIMIT_EXCEEDED'} RateLimitReason`
|
|
45
|
+
|
|
46
|
+
## Code Style
|
|
47
|
+
|
|
48
|
+
**Formatting (Prettier):**
|
|
49
|
+
|
|
50
|
+
- Config: `.prettierrc`
|
|
51
|
+
- Print width: 120
|
|
52
|
+
- Indent: 2 spaces (no tabs)
|
|
53
|
+
- Semicolons: always
|
|
54
|
+
- Quotes: double quotes (`"`)
|
|
55
|
+
- Trailing commas: all (including function parameters)
|
|
56
|
+
- Bracket spacing: true
|
|
57
|
+
- Arrow parens: always
|
|
58
|
+
|
|
59
|
+
**Linting (ESLint):**
|
|
60
|
+
|
|
61
|
+
- Config: `eslint.config.mjs` (flat config format)
|
|
62
|
+
- Base: `@eslint/js` recommended
|
|
63
|
+
- ECMAScript: 2025
|
|
64
|
+
- Key rules:
|
|
65
|
+
- `prefer-const`: error — always use `const` unless reassignment needed
|
|
66
|
+
- `no-var`: error — never use `var`
|
|
67
|
+
- `eqeqeq: smart` — use `===` except allow `== null` for null/undefined checks
|
|
68
|
+
- `no-console: off` — this is a CLI tool, console output is expected
|
|
69
|
+
- `no-constant-condition: { checkLoops: false }` — `while (true)` is intentional
|
|
70
|
+
- `no-unused-vars: warn` with `argsIgnorePattern: "^_"` and `varsIgnorePattern: "^_"`
|
|
71
|
+
- Globals: Node.js + Web API globals declared explicitly (no `node: true` environment)
|
|
72
|
+
|
|
73
|
+
**Pre-commit (Husky + lint-staged):**
|
|
74
|
+
|
|
75
|
+
- Config: `package.json` under `lint-staged`
|
|
76
|
+
- `.mjs` files: `prettier --write` then `eslint --fix`
|
|
77
|
+
- `.json`, `.md` files: `prettier --write`
|
|
78
|
+
- Pre-push hook: runs `npm test`
|
|
79
|
+
|
|
80
|
+
## Import Organization
|
|
81
|
+
|
|
82
|
+
**Order:**
|
|
83
|
+
|
|
84
|
+
1. External packages — `import { createClient } from "@openauthjs/openauth/client"`
|
|
85
|
+
2. Node built-ins — `import { promises as fs } from "node:fs"`, `import { join } from "node:path"`
|
|
86
|
+
3. Local modules — `import { loadAccounts } from "./lib/storage.mjs"`
|
|
87
|
+
|
|
88
|
+
**Rules:**
|
|
89
|
+
|
|
90
|
+
- Always use `node:` prefix for Node.js built-in modules
|
|
91
|
+
- Always include `.mjs` extension in relative imports
|
|
92
|
+
- Named exports only — no default exports anywhere in the codebase
|
|
93
|
+
- Destructured imports preferred: `import { loadAccounts, saveAccounts } from "./storage.mjs"`
|
|
94
|
+
|
|
95
|
+
**Path Aliases:**
|
|
96
|
+
|
|
97
|
+
- None configured. All imports use relative paths.
|
|
98
|
+
|
|
99
|
+
## JSDoc Type System
|
|
100
|
+
|
|
101
|
+
**Every function gets JSDoc:**
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
/**
|
|
105
|
+
* Parse the Retry-After header from a response.
|
|
106
|
+
* Supports both seconds (integer) and HTTP-date formats.
|
|
107
|
+
* @param {Response} response
|
|
108
|
+
* @returns {number | null} Retry-after duration in milliseconds, or null
|
|
109
|
+
*/
|
|
110
|
+
export function parseRetryAfterHeader(response) {
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Type definitions at the top of files using `@typedef`:**
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {object} ManagedAccount
|
|
118
|
+
* @property {string} id
|
|
119
|
+
* @property {number} index
|
|
120
|
+
* @property {string} [email]
|
|
121
|
+
* @property {string} refreshToken
|
|
122
|
+
* @property {boolean} enabled
|
|
123
|
+
* @property {AccountStats} stats
|
|
124
|
+
*/
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Cross-file type imports:**
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {import('./config.mjs').AnthropicAuthConfig} AnthropicAuthConfig
|
|
132
|
+
* @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
|
|
133
|
+
*/
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Inline JSDoc casts for type narrowing:**
|
|
137
|
+
|
|
138
|
+
```javascript
|
|
139
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
140
|
+
const hs = /** @type {Record<string, unknown>} */ (raw.health_score);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Private class field annotations:**
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
/** @type {ManagedAccount[]} */
|
|
147
|
+
#accounts = [];
|
|
148
|
+
/** @type {Map<string, StatsDelta>} */
|
|
149
|
+
#statsDeltas = new Map();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Error Handling
|
|
153
|
+
|
|
154
|
+
**Pattern 1: Silent catch for non-critical operations**
|
|
155
|
+
Used when failure should not disrupt the main flow:
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
try {
|
|
159
|
+
await fs.unlink(tempPath);
|
|
160
|
+
} catch {
|
|
161
|
+
// Ignore cleanup errors
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Pattern 2: Error code discrimination for fs operations**
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
try {
|
|
169
|
+
const content = await fs.readFile(storagePath, "utf-8");
|
|
170
|
+
// ...process
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
173
|
+
if (code === "ENOENT") return null;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Pattern 3: Graceful degradation — return defaults on failure**
|
|
179
|
+
Config loading, account loading, etc. return defaults or null rather than throwing:
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
export function loadConfig() {
|
|
183
|
+
try {
|
|
184
|
+
// ...try to read and parse
|
|
185
|
+
} catch {
|
|
186
|
+
return { ...DEFAULT_CONFIG };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Pattern 4: Propagate errors for critical paths**
|
|
192
|
+
Storage save errors are thrown after cleanup, so callers know the save failed:
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
try {
|
|
196
|
+
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
197
|
+
await fs.rename(tempPath, storagePath);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
try {
|
|
200
|
+
await fs.unlink(tempPath);
|
|
201
|
+
} catch {
|
|
202
|
+
/* ignore */
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**General rule:** Never crash the plugin. If something fails, log/ignore and continue. The plugin is middleware for a CLI tool — availability beats correctness for non-critical paths.
|
|
209
|
+
|
|
210
|
+
## File I/O Patterns
|
|
211
|
+
|
|
212
|
+
**Atomic writes (temp file + rename):**
|
|
213
|
+
All data persistence uses this pattern to prevent corruption on crash:
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
217
|
+
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
218
|
+
await fs.rename(tempPath, storagePath);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
See `lib/storage.mjs` `saveAccounts()`.
|
|
222
|
+
|
|
223
|
+
**File permissions:**
|
|
224
|
+
Sensitive files (accounts with tokens) are written with `mode: 0o600` (owner read/write only).
|
|
225
|
+
|
|
226
|
+
**Debounced persistence:**
|
|
227
|
+
`AccountManager` in `lib/accounts.mjs` uses a 1-second debounce (`#saveTimeout`) to coalesce rapid state changes into a single disk write.
|
|
228
|
+
|
|
229
|
+
**Merge-on-save:**
|
|
230
|
+
Before writing, `AccountManager._persistNow()` re-reads the current disk state and merges in-memory deltas. This supports multiple CLI/plugin instances running concurrently without data loss.
|
|
231
|
+
|
|
232
|
+
## Code Organization
|
|
233
|
+
|
|
234
|
+
**Section comments:**
|
|
235
|
+
Major logical sections are separated with comment blocks:
|
|
236
|
+
|
|
237
|
+
```javascript
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Color helpers — zero dependencies, respects NO_COLOR / TTY
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Module structure pattern:**
|
|
244
|
+
|
|
245
|
+
1. Imports
|
|
246
|
+
2. JSDoc `@typedef` declarations
|
|
247
|
+
3. Constants (`const UPPER_SNAKE = ...`)
|
|
248
|
+
4. Internal/private helper functions
|
|
249
|
+
5. Exported functions/classes
|
|
250
|
+
6. Main entry point (in entry files like `cli.mjs`)
|
|
251
|
+
|
|
252
|
+
**Export pattern:**
|
|
253
|
+
|
|
254
|
+
- Named exports only: `export function ...`, `export class ...`, `export const ...`
|
|
255
|
+
- No barrel files — each module exports its own public API
|
|
256
|
+
- No default exports
|
|
257
|
+
|
|
258
|
+
## CLI Patterns
|
|
259
|
+
|
|
260
|
+
**Command functions return exit codes:**
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
async function cmdList(storage) {
|
|
264
|
+
// ...
|
|
265
|
+
return 0; // success
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Color helpers respect `NO_COLOR` and TTY:**
|
|
270
|
+
|
|
271
|
+
```javascript
|
|
272
|
+
let USE_COLOR = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
273
|
+
const ansi = (code, text) => (USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
274
|
+
const c = {
|
|
275
|
+
bold: (t) => ansi("1", t),
|
|
276
|
+
dim: (t) => ansi("2", t),
|
|
277
|
+
green: (t) => ansi("32", t),
|
|
278
|
+
// ...
|
|
279
|
+
};
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Interactive prompts:**
|
|
283
|
+
Use `node:readline/promises` with `createInterface({ input: stdin, output: stdout })`.
|
|
284
|
+
|
|
285
|
+
## Validation Patterns
|
|
286
|
+
|
|
287
|
+
**Config validation with clamping:**
|
|
288
|
+
Invalid or out-of-range values are clamped to valid ranges, never rejected:
|
|
289
|
+
|
|
290
|
+
```javascript
|
|
291
|
+
function clampNumber(value, min, max, fallback) {
|
|
292
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
293
|
+
return Math.max(min, Math.min(max, value));
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
See `lib/config.mjs` `validateConfig()`.
|
|
298
|
+
|
|
299
|
+
**Account validation with field-level sanitization:**
|
|
300
|
+
Each field is individually validated and defaulted. Invalid accounts (no refreshToken) return null and are filtered out:
|
|
301
|
+
|
|
302
|
+
```javascript
|
|
303
|
+
if (typeof acc.refreshToken !== "string" || !acc.refreshToken.trim()) return null;
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
See `lib/storage.mjs` `validateAccount()`.
|
|
307
|
+
|
|
308
|
+
**Deduplication:**
|
|
309
|
+
Accounts are deduplicated by `refreshToken` — the one with the latest `lastUsed` timestamp wins. See `lib/storage.mjs` `deduplicateByRefreshToken()`.
|
|
310
|
+
|
|
311
|
+
## Null Handling
|
|
312
|
+
|
|
313
|
+
**Use `== null` for null/undefined checks** (enabled by `eqeqeq: smart`):
|
|
314
|
+
|
|
315
|
+
```javascript
|
|
316
|
+
if (body == null) {
|
|
317
|
+
return { errorType, message, text };
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Nullable fields use `| null` in JSDoc, not `undefined`:**
|
|
322
|
+
|
|
323
|
+
```javascript
|
|
324
|
+
/** @property {number | null} lastFailureTime */
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Optional fields use `[bracket]` notation in JSDoc:**
|
|
328
|
+
|
|
329
|
+
```javascript
|
|
330
|
+
/** @property {string} [email] */
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
_Convention analysis: 2026-02-07_
|