opencode-anthropic-fix 0.0.17 → 0.0.19
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/index.mjs +48 -14
- package/package.json +7 -1
- package/.github/workflows/publish.yml +0 -31
- package/.husky/pre-commit +0 -2
- package/.husky/pre-push +0 -3
- package/.planning/CLI-ENHANCEMENTS.md +0 -221
- package/.planning/codebase/ARCHITECTURE.md +0 -201
- package/.planning/codebase/CONCERNS.md +0 -219
- package/.planning/codebase/CONVENTIONS.md +0 -335
- package/.planning/codebase/INTEGRATIONS.md +0 -215
- package/.planning/codebase/STACK.md +0 -160
- package/.planning/codebase/STRUCTURE.md +0 -244
- package/.planning/codebase/TESTING.md +0 -456
- package/.prettierignore +0 -3
- package/.prettierrc +0 -10
- package/CONTRIBUTING.md +0 -525
- package/agents.md +0 -22
- package/bun.lock +0 -43
- package/cli.mjs +0 -1775
- package/cli.test.mjs +0 -1797
- package/docs/mimese-http-header-system-prompt.md +0 -326
- package/docs/plans/afcw-upstream-watcher.json +0 -495
- package/eslint.config.mjs +0 -69
- package/index.test.mjs +0 -3359
- package/nul +0 -1
- package/script/publish.ts +0 -29
- package/scripts/build.mjs +0 -34
- package/scripts/install.mjs +0 -319
- package/test/helpers/accounts-fixtures.mjs +0 -51
package/index.mjs
CHANGED
|
@@ -103,7 +103,7 @@ async function promptManageAccounts(accountManager) {
|
|
|
103
103
|
// Request building helpers (extracted from original fetch interceptor)
|
|
104
104
|
// ---------------------------------------------------------------------------
|
|
105
105
|
|
|
106
|
-
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.
|
|
106
|
+
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.76";
|
|
107
107
|
const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
108
108
|
const CLAUDE_CODE_BETA_FLAG = "claude-code-20250219";
|
|
109
109
|
const BILLING_HASH_SALT = "59cf53e54c78";
|
|
@@ -292,7 +292,10 @@ function supportsThinking(model) {
|
|
|
292
292
|
*/
|
|
293
293
|
function isOpus46Model(model) {
|
|
294
294
|
if (!model) return false;
|
|
295
|
-
|
|
295
|
+
// Match standard IDs (claude-opus-4-6, claude-opus-4.6) and Bedrock ARNs
|
|
296
|
+
// (arn:aws:bedrock:...anthropic.claude-opus-4-6-...).
|
|
297
|
+
// Also match bare "opus-4-6" / "opus-4.6" fragments for non-standard strings.
|
|
298
|
+
return /claude-opus-4[._-]6|opus[._-]4[._-]6/i.test(model);
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
/**
|
|
@@ -300,7 +303,8 @@ function isOpus46Model(model) {
|
|
|
300
303
|
* @returns {boolean}
|
|
301
304
|
*/
|
|
302
305
|
function hasOneMillionContext(model) {
|
|
303
|
-
|
|
306
|
+
// Models with explicit 1m suffix, or Opus 4.6 (1M by default since v2.1.75).
|
|
307
|
+
return /(^|[-_ ])1m($|[-_ ])|context[-_]?1m/i.test(model) || isOpus46Model(model);
|
|
304
308
|
}
|
|
305
309
|
|
|
306
310
|
/**
|
|
@@ -732,7 +736,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
|
|
|
732
736
|
return mergedBetas.join(",");
|
|
733
737
|
}
|
|
734
738
|
|
|
735
|
-
/** @typedef {'low' | 'medium' | 'high'
|
|
739
|
+
/** @typedef {'low' | 'medium' | 'high'} ThinkingEffort */
|
|
736
740
|
|
|
737
741
|
/**
|
|
738
742
|
* Map budgetTokens to an adaptive thinking effort level.
|
|
@@ -743,8 +747,7 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
|
|
|
743
747
|
function budgetTokensToEffort(budgetTokens) {
|
|
744
748
|
if (budgetTokens <= 1024) return "low";
|
|
745
749
|
if (budgetTokens <= 8000) return "medium";
|
|
746
|
-
|
|
747
|
-
return "max";
|
|
750
|
+
return "high";
|
|
748
751
|
}
|
|
749
752
|
|
|
750
753
|
/**
|
|
@@ -753,7 +756,7 @@ function budgetTokensToEffort(budgetTokens) {
|
|
|
753
756
|
* @returns {value is ThinkingEffort}
|
|
754
757
|
*/
|
|
755
758
|
function isValidEffort(value) {
|
|
756
|
-
return value === "low" || value === "medium" || value === "high"
|
|
759
|
+
return value === "low" || value === "medium" || value === "high";
|
|
757
760
|
}
|
|
758
761
|
|
|
759
762
|
/**
|
|
@@ -1427,10 +1430,14 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
1427
1430
|
* @param {import('./lib/accounts.mjs').ManagedAccount} account
|
|
1428
1431
|
* @param {ReturnType<typeof import('@opencode-ai/sdk').createOpencodeClient>} client
|
|
1429
1432
|
* @param {"foreground" | "idle"} [source]
|
|
1433
|
+
* @param {{ onTokensUpdated?: () => Promise<void> }} [options] - If provided,
|
|
1434
|
+
* called under the cross-process lock after token update to persist rotated
|
|
1435
|
+
* tokens before the lock is released. Omitting means tokens won't be saved
|
|
1436
|
+
* to disk until the caller arranges it (risking the rotation race).
|
|
1430
1437
|
* @returns {Promise<string>} The new access token
|
|
1431
1438
|
* @throws {Error} If refresh fails
|
|
1432
1439
|
*/
|
|
1433
|
-
async function refreshAccountToken(account, client, source = "foreground") {
|
|
1440
|
+
async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
|
|
1434
1441
|
const lockResult = await acquireRefreshLock(account.id, {
|
|
1435
1442
|
timeoutMs: 2_000,
|
|
1436
1443
|
backoffMs: 60,
|
|
@@ -1471,6 +1478,22 @@ async function refreshAccountToken(account, client, source = "foreground") {
|
|
|
1471
1478
|
}
|
|
1472
1479
|
markTokenStateUpdated(account);
|
|
1473
1480
|
|
|
1481
|
+
// Persist new tokens to disk BEFORE releasing the cross-process lock.
|
|
1482
|
+
// This is critical: if we release the lock first, another process can
|
|
1483
|
+
// acquire it and read the old (now-rotated) refresh token from disk,
|
|
1484
|
+
// leading to an invalid_grant failure. The debounced requestSaveToDisk()
|
|
1485
|
+
// that callers used previously left a ~1 s window where this race could
|
|
1486
|
+
// (and did) happen.
|
|
1487
|
+
if (onTokensUpdated) {
|
|
1488
|
+
try {
|
|
1489
|
+
await onTokensUpdated();
|
|
1490
|
+
} catch {
|
|
1491
|
+
// Best-effort: in-memory tokens remain valid for this process.
|
|
1492
|
+
// The callback is responsible for scheduling its own fallback
|
|
1493
|
+
// (e.g. a debounced retry) if the synchronous save fails.
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1474
1497
|
// Also persist to OpenCode's auth.json for compatibility.
|
|
1475
1498
|
// This should be best-effort: a persistence hiccup should not invalidate an
|
|
1476
1499
|
// otherwise successful refresh token exchange.
|
|
@@ -2011,7 +2034,21 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2011
2034
|
const entry = { source, promise: Promise.resolve("") };
|
|
2012
2035
|
const p = (async () => {
|
|
2013
2036
|
try {
|
|
2014
|
-
return await refreshAccountToken(account, client, source
|
|
2037
|
+
return await refreshAccountToken(account, client, source, {
|
|
2038
|
+
onTokensUpdated: async () => {
|
|
2039
|
+
try {
|
|
2040
|
+
await accountManager.saveToDisk();
|
|
2041
|
+
} catch {
|
|
2042
|
+
// Synchronous save failed (disk full, permissions, etc.).
|
|
2043
|
+
// Schedule a debounced retry so the rotated token eventually
|
|
2044
|
+
// reaches disk. Another process may hit invalid_grant in the
|
|
2045
|
+
// interim, but its retry-from-disk logic can recover once this
|
|
2046
|
+
// save lands.
|
|
2047
|
+
accountManager.requestSaveToDisk();
|
|
2048
|
+
throw new Error("save failed, debounced retry scheduled");
|
|
2049
|
+
}
|
|
2050
|
+
},
|
|
2051
|
+
});
|
|
2015
2052
|
} finally {
|
|
2016
2053
|
if (refreshInFlight.get(key) === entry) {
|
|
2017
2054
|
refreshInFlight.delete(key);
|
|
@@ -2040,7 +2077,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2040
2077
|
try {
|
|
2041
2078
|
try {
|
|
2042
2079
|
await refreshAccountTokenSingleFlight(account, "idle");
|
|
2043
|
-
accountManager.requestSaveToDisk();
|
|
2044
2080
|
return;
|
|
2045
2081
|
} catch (err) {
|
|
2046
2082
|
let details = parseRefreshFailure(err);
|
|
@@ -2068,7 +2104,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2068
2104
|
|
|
2069
2105
|
try {
|
|
2070
2106
|
await refreshAccountTokenSingleFlight(account, "idle");
|
|
2071
|
-
accountManager.requestSaveToDisk();
|
|
2072
2107
|
return;
|
|
2073
2108
|
} catch (retryErr) {
|
|
2074
2109
|
details = parseRefreshFailure(retryErr);
|
|
@@ -2252,8 +2287,8 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2252
2287
|
const attemptedRefreshToken = account.refreshToken;
|
|
2253
2288
|
try {
|
|
2254
2289
|
accessToken = await refreshAccountTokenSingleFlight(account);
|
|
2255
|
-
//
|
|
2256
|
-
|
|
2290
|
+
// Tokens are now saved under the refresh lock (inside
|
|
2291
|
+
// refreshAccountToken) so no debounced save needed here.
|
|
2257
2292
|
} catch (err) {
|
|
2258
2293
|
// Token refresh failed — check if another instance rotated the
|
|
2259
2294
|
// refresh token and persisted it between attempts.
|
|
@@ -2287,7 +2322,6 @@ export async function AnthropicAuthPlugin({ client }) {
|
|
|
2287
2322
|
|
|
2288
2323
|
try {
|
|
2289
2324
|
accessToken = await refreshAccountTokenSingleFlight(account);
|
|
2290
|
-
accountManager.requestSaveToDisk();
|
|
2291
2325
|
} catch (retryErr) {
|
|
2292
2326
|
finalError = retryErr;
|
|
2293
2327
|
details = parseRefreshFailure(retryErr);
|
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anthropic-fix",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"main": "./index.mjs",
|
|
5
|
+
"files": [
|
|
6
|
+
"index.mjs",
|
|
7
|
+
"lib/**/*.mjs",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE"
|
|
10
|
+
],
|
|
5
11
|
"scripts": {
|
|
6
12
|
"build": "node scripts/build.mjs",
|
|
7
13
|
"test": "vitest run",
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
workflow_dispatch:
|
|
5
|
-
inputs:
|
|
6
|
-
bump:
|
|
7
|
-
description: "Version bump type"
|
|
8
|
-
required: false
|
|
9
|
-
default: "patch"
|
|
10
|
-
|
|
11
|
-
permissions:
|
|
12
|
-
id-token: write
|
|
13
|
-
contents: read
|
|
14
|
-
|
|
15
|
-
jobs:
|
|
16
|
-
publish:
|
|
17
|
-
runs-on: ubuntu-latest
|
|
18
|
-
steps:
|
|
19
|
-
- uses: actions/checkout@v4
|
|
20
|
-
|
|
21
|
-
- uses: actions/setup-node@v4
|
|
22
|
-
with:
|
|
23
|
-
node-version: "24"
|
|
24
|
-
registry-url: "https://registry.npmjs.org"
|
|
25
|
-
|
|
26
|
-
- run: npm install
|
|
27
|
-
|
|
28
|
-
- run: npm publish --access public
|
|
29
|
-
env:
|
|
30
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
31
|
-
NPM_CONFIG_PROVENANCE: false
|
package/.husky/pre-commit
DELETED
package/.husky/pre-push
DELETED
|
@@ -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_
|