@karpeleslab/teamclaude 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -10,8 +10,9 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
10
10
 
11
11
  - **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
12
12
  - **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
13
- - **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
14
- - **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
13
+ - **Interactive TUI** — real-time dashboard with color-coded quota bars showing reset countdowns, activity log, and keyboard controls
14
+ - **OAuth token refresh** — proactively refreshes expiring tokens, intercepts client token renewals, and persists them to config
15
+ - **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
15
16
  - **Request logging** — optional full request/response logging for debugging
16
17
  - **Zero dependencies** — uses only Node.js built-in modules
17
18
 
@@ -23,12 +24,11 @@ Requires Node.js 18+.
23
24
  # Install
24
25
  npm install -g @karpeleslab/teamclaude
25
26
 
26
- # Import your current Claude Code credentials
27
- teamclaude import
27
+ # Add your first account (opens browser for OAuth)
28
+ teamclaude login
28
29
 
29
- # Import a second account (log into it in Claude Code first, then import)
30
- # claude /login
31
- # teamclaude import
30
+ # Add a second account
31
+ teamclaude login
32
32
 
33
33
  # Start the proxy
34
34
  teamclaude server
@@ -37,27 +37,37 @@ teamclaude server
37
37
  teamclaude run
38
38
  ```
39
39
 
40
+ You can also import existing Claude Code credentials instead of logging in:
41
+
42
+ ```bash
43
+ claude /login # Log into an account in Claude Code
44
+ teamclaude import # Import its credentials
45
+ ```
46
+
40
47
  ## Adding Accounts
41
48
 
42
- ### Import from Claude Code (recommended)
49
+ ### OAuth Login (recommended)
43
50
 
44
- The easiest way to add accounts. Log into each account in Claude Code, then import:
51
+ The easiest way to add accounts opens your browser for authentication:
45
52
 
46
53
  ```bash
47
- # Log into your first account in Claude Code
48
- claude /login
54
+ teamclaude login
55
+ ```
56
+
57
+ Uses the same OAuth flow as Claude Code. Auto-detects the account email and subscription tier. Logging in with the same account again updates its credentials.
49
58
 
50
- # Import it
51
- teamclaude import
59
+ You can add accounts while the server is running — press **R** in the TUI to reload.
52
60
 
53
- # Switch to another account in Claude Code
54
- claude /login
61
+ ### Import from Claude Code
55
62
 
56
- # Import that one too
57
- teamclaude import
63
+ If you already have Claude Code set up, you can import its credentials directly:
64
+
65
+ ```bash
66
+ claude /login # Log into an account in Claude Code
67
+ teamclaude import # Import its credentials
58
68
  ```
59
69
 
60
- Each import auto-detects the account email and subscription tier. Re-importing the same account updates its credentials.
70
+ Re-importing the same account updates its credentials.
61
71
 
62
72
  ### API Key
63
73
 
@@ -67,16 +77,6 @@ For Anthropic API key accounts (billed via Console):
67
77
  teamclaude login --api
68
78
  ```
69
79
 
70
- ### OAuth Login (experimental)
71
-
72
- Direct browser-based OAuth login without needing Claude Code:
73
-
74
- ```bash
75
- teamclaude login
76
- ```
77
-
78
- > **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
79
-
80
80
  ## Usage
81
81
 
82
82
  ### Start the proxy server
@@ -88,7 +88,7 @@ teamclaude server
88
88
  When running from a TTY, shows an interactive TUI with:
89
89
  - Account table with session/weekly quota progress bars
90
90
  - Real-time activity log with request tracking
91
- - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
91
+ - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **R**eload, **q**uit
92
92
 
93
93
  Falls back to plain log output when not a TTY (e.g. running as a service).
94
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -252,7 +252,11 @@ export class AccountManager {
252
252
  this._onTokenRefresh?.(accountIndex, newTokens);
253
253
  } catch (err) {
254
254
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
255
- account.status = 'error';
255
+ // Only mark as error if the access token is actually expired;
256
+ // a failed proactive refresh shouldn't kill a still-valid token
257
+ if (!account.expiresAt || Date.now() >= account.expiresAt) {
258
+ account.status = 'error';
259
+ }
256
260
  } finally {
257
261
  account._refreshPromise = null;
258
262
  }
package/src/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'node:child_process';
3
+ import { spawnSync } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
6
6
  import { AccountManager } from './account-manager.js';
7
7
  import { createProxyServer } from './server.js';
8
- import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
8
+ import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
9
9
  import { TUI } from './tui.js';
10
10
 
11
11
  const args = process.argv.slice(2);
@@ -15,29 +15,36 @@ switch (command) {
15
15
  case 'server':
16
16
  await serverCommand();
17
17
  break;
18
+ case 'run':
19
+ await runCommand();
20
+ break;
18
21
  case 'import':
19
22
  await importCommand();
23
+ process.exit(0);
20
24
  break;
21
25
  case 'login':
22
26
  await loginCommand();
27
+ process.exit(0);
23
28
  break;
24
29
  case 'env':
25
30
  await envCommand();
26
- break;
27
- case 'run':
28
- await runCommand();
31
+ process.exit(0);
29
32
  break;
30
33
  case 'status':
31
34
  await statusCommand();
35
+ process.exit(0);
32
36
  break;
33
37
  case 'accounts':
34
38
  await accountsCommand();
39
+ process.exit(0);
35
40
  break;
36
41
  case 'remove':
37
42
  await removeCommand();
43
+ process.exit(0);
38
44
  break;
39
45
  case 'api':
40
46
  await apiCommand();
47
+ process.exit(0);
41
48
  break;
42
49
  case 'help':
43
50
  case '--help':
@@ -87,8 +94,23 @@ async function serverCommand() {
87
94
  accountManager.onTokenRefresh((idx, newTokens) => {
88
95
  const account = accountManager.accounts[idx];
89
96
  if (!account) return;
97
+ // Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
98
+ if (config.accounts[idx]) {
99
+ config.accounts[idx].accessToken = newTokens.accessToken;
100
+ config.accounts[idx].refreshToken = newTokens.refreshToken;
101
+ config.accounts[idx].expiresAt = newTokens.expiresAt;
102
+ }
90
103
  atomicConfigUpdate(diskConfig => {
91
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
104
+ // Pick up any new accounts from disk so index matching stays correct
105
+ // (only add, don't refresh credentials — we're about to write the authoritative tokens)
106
+ for (const diskAcct of diskConfig.accounts) {
107
+ const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
108
+ || config.accounts.some(a => a.name === diskAcct.name);
109
+ if (!known) {
110
+ config.accounts.push(diskAcct);
111
+ accountManager.addAccount(diskAcct);
112
+ }
113
+ }
92
114
  // Match by UUID first, then by name — index may have shifted
93
115
  const cfgIdx = findConfigAccount(diskConfig, account);
94
116
  if (cfgIdx >= 0) {
@@ -107,22 +129,28 @@ async function serverCommand() {
107
129
  if (useTUI) {
108
130
  tui = new TUI({
109
131
  accountManager, config,
110
- saveConfig: () => atomicConfigUpdate(diskConfig => {
111
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
112
- // Write in-memory accounts back, preserving extra disk-only fields
113
- diskConfig.accounts = config.accounts.map(a => {
132
+ saveConfig: () => atomicConfigUpdate(async diskConfig => {
133
+ // Write in-memory accounts as the authoritative state, preserving
134
+ // extra disk-only fields (e.g. importFrom) where the account still exists.
135
+ // Use live tokens from AccountManager (not the stale config.accounts copy).
136
+ diskConfig.accounts = config.accounts.map((a, i) => {
137
+ const am = accountManager.accounts[i];
138
+ const live = am ? {
139
+ ...a,
140
+ accessToken: am.credential,
141
+ refreshToken: am.refreshToken,
142
+ expiresAt: am.expiresAt,
143
+ } : a;
114
144
  const diskAcct = diskConfig.accounts.find(
115
145
  d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
116
146
  );
117
- return diskAcct ? { ...diskAcct, ...a } : a;
147
+ return diskAcct ? { ...diskAcct, ...live } : live;
118
148
  });
119
149
  }),
120
150
  syncAccounts: async () => {
121
151
  const diskConfig = await loadConfig();
122
152
  if (!diskConfig) return 0;
123
- const before = accountManager.accounts.length;
124
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
125
- return accountManager.accounts.length - before;
153
+ return syncAccountsFromDisk(diskConfig, config, accountManager);
126
154
  },
127
155
  onQuit: () => { server.close(() => process.exit(0)); },
128
156
  });
@@ -292,7 +320,8 @@ async function runCommand() {
292
320
  // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
293
321
  // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
294
322
  // lets Claude Code stay in subscription mode (full model access).
295
- const child = spawn('claude', claudeArgs, {
323
+ // Use spawnSync so the Node process blocks entirely — behaves like execvp.
324
+ const result = spawnSync('claude', claudeArgs, {
296
325
  stdio: 'inherit',
297
326
  env: {
298
327
  ...process.env,
@@ -300,16 +329,16 @@ async function runCommand() {
300
329
  },
301
330
  });
302
331
 
303
- child.on('error', (err) => {
304
- if (err.code === 'ENOENT') {
332
+ if (result.error) {
333
+ if (result.error.code === 'ENOENT') {
305
334
  console.error('Claude Code not found in PATH. Install it first.');
306
335
  } else {
307
- console.error(`Failed to start claude: ${err.message}`);
336
+ console.error(`Failed to start claude: ${result.error.message}`);
308
337
  }
309
338
  process.exit(1);
310
- });
339
+ }
311
340
 
312
- child.on('exit', (code) => process.exit(code ?? 1));
341
+ process.exit(result.status ?? 1);
313
342
  }
314
343
 
315
344
  // ── status ──────────────────────────────────────────────────
@@ -365,6 +394,23 @@ async function accountsCommand() {
365
394
  return;
366
395
  }
367
396
 
397
+ // Refresh expired tokens before fetching profiles
398
+ let configDirty = false;
399
+ await Promise.all(config.accounts.map(async (a) => {
400
+ if (a.type !== 'oauth' || !a.refreshToken) return;
401
+ if (!isTokenExpiringSoon(a.expiresAt)) return;
402
+ try {
403
+ const newTokens = await refreshAccessToken(a.refreshToken);
404
+ a.accessToken = newTokens.accessToken;
405
+ a.refreshToken = newTokens.refreshToken;
406
+ a.expiresAt = newTokens.expiresAt;
407
+ configDirty = true;
408
+ } catch (err) {
409
+ // refresh failed — fetchProfile will report the specific error
410
+ }
411
+ }));
412
+ if (configDirty) await saveConfig(config);
413
+
368
414
  // Fetch profiles in parallel for all OAuth accounts
369
415
  const profiles = await Promise.all(
370
416
  config.accounts.map(a =>
@@ -386,7 +432,7 @@ async function accountsCommand() {
386
432
  } else {
387
433
  seen.set(uuid, i);
388
434
  // Update stored UUID and name from profile
389
- if (profiles[i]) {
435
+ if (profiles[i] && !profiles[i].error) {
390
436
  a.accountUuid = profiles[i].accountUuid;
391
437
  if (profiles[i].email) a.name = profiles[i].email;
392
438
  }
@@ -407,12 +453,13 @@ async function accountsCommand() {
407
453
  }
408
454
 
409
455
  // OAuth account
410
- const tier = p?.hasClaudeMax ? 'Max' : p?.hasClaudePro ? 'Pro' : 'subscription';
411
- const status = p ? `Claude ${tier}` : 'unknown (profile fetch failed)';
456
+ const hasProfile = p && !p.error;
457
+ const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
458
+ const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
412
459
  const src = a.source ? `, ${a.source}` : '';
413
460
  console.log(` [${i + 1}] ${a.name} (${status}${src})`);
414
- if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
415
- if (p?.orgName) console.log(` Org: ${p.orgName}`);
461
+ if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
462
+ if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
416
463
  if (verbose && a.expiresAt) {
417
464
  const remaining = a.expiresAt - Date.now();
418
465
  if (remaining <= 0) {
@@ -543,7 +590,11 @@ Config: ${getConfigPath()}
543
590
  async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
544
591
  // Fetch profile to auto-name and deduplicate by account UUID
545
592
  const profile = await fetchProfile(creds.accessToken);
593
+ const profileOk = profile && !profile.error;
546
594
 
595
+ if (!profileOk) {
596
+ console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
597
+ }
547
598
  if (!name && profile?.email) {
548
599
  name = profile.email;
549
600
  const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
@@ -596,22 +647,68 @@ function findConfigAccount(diskConfig, account) {
596
647
  }
597
648
 
598
649
  /**
599
- * Detect accounts added to disk config by external processes and add them
600
- * to the running AccountManager + in-memory config.
650
+ * Sync accounts from disk config: add new accounts and refresh credentials
651
+ * for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
652
+ * Returns the number of new accounts added.
601
653
  */
602
- function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
654
+ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
655
+ let added = 0;
603
656
  for (const diskAcct of diskConfig.accounts) {
604
- const knownByUuid = diskAcct.accountUuid &&
605
- memConfig.accounts.some(a => a.accountUuid === diskAcct.accountUuid);
606
- const knownByName = memConfig.accounts.some(a => a.name === diskAcct.name);
657
+ const matchByUuid = diskAcct.accountUuid &&
658
+ memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
659
+ const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
660
+ const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
607
661
 
608
- if (!knownByUuid && !knownByName) {
662
+ if (memIdx < 0) {
609
663
  // New account discovered on disk — add to running server
610
664
  memConfig.accounts.push(diskAcct);
611
665
  accountManager.addAccount(diskAcct);
666
+ added++;
612
667
  console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
668
+ continue;
669
+ }
670
+
671
+ // Existing account — resolve fresh credentials from disk
672
+ let freshCred = null;
673
+ if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
674
+ try {
675
+ const creds = await importCredentials(diskAcct.importFrom);
676
+ freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
677
+ } catch (err) {
678
+ console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
679
+ }
680
+ } else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
681
+ freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
682
+ } else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
683
+ freshCred = { apiKey: diskAcct.apiKey };
684
+ }
685
+
686
+ if (!freshCred) continue;
687
+
688
+ // Find the corresponding AccountManager entry and update credentials
689
+ const mgr = accountManager.accounts.find(a =>
690
+ (diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name
691
+ );
692
+ if (!mgr) continue;
693
+
694
+ if (freshCred.accessToken) {
695
+ const changed = mgr.credential !== freshCred.accessToken ||
696
+ mgr.refreshToken !== freshCred.refreshToken;
697
+ // Don't overwrite in-memory credentials with staler ones from disk
698
+ // (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
699
+ const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
700
+ freshCred.expiresAt < mgr.expiresAt;
701
+ if (changed && !diskIsStaler) {
702
+ accountManager.updateAccountTokens(mgr.index, freshCred);
703
+ console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
704
+ }
705
+ } else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
706
+ mgr.credential = freshCred.apiKey;
707
+ if (mgr.status === 'error') mgr.status = 'active';
708
+ console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
613
709
  }
614
710
  }
711
+ return added;
615
712
  }
616
713
 
617
714
  // ── helpers ─────────────────────────────────────────────────
package/src/oauth.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
4
  import { exec } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
5
6
  import http from 'node:http';
6
7
 
7
8
  /**
@@ -68,7 +69,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
68
69
  return {
69
70
  accessToken: data.access_token,
70
71
  refreshToken: data.refresh_token || refreshToken,
71
- expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
72
+ expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
72
73
  };
73
74
  } catch (err) {
74
75
  const isNetworkError = err instanceof Error &&
@@ -84,24 +85,45 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
84
85
  }
85
86
  }
86
87
 
88
+ /**
89
+ * Normalize an expires_at value to milliseconds.
90
+ * OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
91
+ */
92
+ export function normalizeExpiresAt(expiresAt) {
93
+ if (!expiresAt) return expiresAt;
94
+ // If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
95
+ // convert to milliseconds
96
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
97
+ }
98
+
87
99
  /**
88
100
  * Check if an OAuth token is expiring within the given threshold.
89
101
  */
90
102
  export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
91
103
  if (!expiresAt) return false;
92
- return Date.now() + thresholdMs >= expiresAt;
104
+ return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
93
105
  }
94
106
 
95
107
  /**
96
108
  * Fetch account profile for an OAuth token.
97
- * Returns { email, name, orgName, orgType } or null on failure.
109
+ * Returns { email, name, orgName, orgType, ... } on success,
110
+ * or { error: 'reason' } on failure.
98
111
  */
99
112
  export async function fetchProfile(accessToken) {
100
113
  try {
101
114
  const res = await fetch(PROFILE_URL, {
102
115
  headers: { 'Authorization': `Bearer ${accessToken}` },
103
116
  });
104
- if (!res.ok) return null;
117
+ if (!res.ok) {
118
+ let detail = '';
119
+ try {
120
+ const body = await res.json();
121
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
122
+ } catch {
123
+ detail = await res.text().catch(() => '');
124
+ }
125
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
126
+ }
105
127
  const data = await res.json();
106
128
  return {
107
129
  accountUuid: data.account?.uuid,
@@ -112,8 +134,8 @@ export async function fetchProfile(accessToken) {
112
134
  hasClaudeMax: data.account?.has_claude_max,
113
135
  hasClaudePro: data.account?.has_claude_pro,
114
136
  };
115
- } catch {
116
- return null;
137
+ } catch (err) {
138
+ return { error: err.message || String(err) };
117
139
  }
118
140
  }
119
141
 
@@ -153,10 +175,10 @@ export async function loginOAuth() {
153
175
  console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
154
176
  openBrowser(authUrl.toString());
155
177
 
156
- // Wait for the authorization code
178
+ // Wait for either the callback server or manual paste from stdin
157
179
  let code;
158
180
  try {
159
- code = await codePromise;
181
+ code = await raceWithStdinCode(codePromise, state);
160
182
  } finally {
161
183
  server.close();
162
184
  }
@@ -185,10 +207,58 @@ export async function loginOAuth() {
185
207
  return {
186
208
  accessToken: tokens.access_token,
187
209
  refreshToken: tokens.refresh_token,
188
- expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
210
+ expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
189
211
  };
190
212
  }
191
213
 
214
+ /**
215
+ * Race the callback server promise against manual code entry from stdin.
216
+ * The user can paste the full callback URL or just the authorization code.
217
+ */
218
+ function raceWithStdinCode(callbackPromise, expectedState) {
219
+ if (!process.stdin.isTTY) return callbackPromise;
220
+
221
+ return new Promise((resolve, reject) => {
222
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
223
+ let settled = false;
224
+
225
+ const settle = (fn, val) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ rl.close();
229
+ fn(val);
230
+ };
231
+
232
+ rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
233
+ const trimmed = answer.trim();
234
+ if (!trimmed) return; // empty input, keep waiting for callback
235
+
236
+ // Try to parse as a URL with ?code= parameter
237
+ try {
238
+ const url = new URL(trimmed);
239
+ const code = url.searchParams.get('code');
240
+ const state = url.searchParams.get('state');
241
+ if (code) {
242
+ if (expectedState && state && state !== expectedState) {
243
+ settle(reject, new Error('OAuth state mismatch'));
244
+ } else {
245
+ settle(resolve, code);
246
+ }
247
+ return;
248
+ }
249
+ } catch {}
250
+
251
+ // Treat raw input as the authorization code
252
+ settle(resolve, trimmed);
253
+ });
254
+
255
+ callbackPromise.then(
256
+ code => settle(resolve, code),
257
+ err => settle(reject, err),
258
+ );
259
+ });
260
+ }
261
+
192
262
  function startCallbackServer(expectedState) {
193
263
  return new Promise((resolve, reject) => {
194
264
  let resolveCode, rejectCode;
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
5
+
5
6
  const HOP_BY_HOP_HEADERS = new Set([
6
7
  'host', 'connection', 'keep-alive', 'transfer-encoding',
7
8
  'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
@@ -39,9 +40,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
39
40
  return;
40
41
  }
41
42
 
42
- // Intercept token refresh requests forward to real endpoint and capture new tokens
43
+ // Let client token refresh requests pass through to upstream untouched.
44
+ // The proxy manages its own tokens via ensureTokenFresh(); intercepting
45
+ // or rewriting client refreshes would cause token rotation conflicts.
43
46
  if (req.method === 'POST' && req.url === '/v1/oauth/token') {
44
- await handleTokenRefresh(req, res, accountManager, hooks);
47
+ await relayRaw(req, res, upstream);
45
48
  return;
46
49
  }
47
50
 
@@ -57,82 +60,52 @@ export function createProxyServer(accountManager, config, hooks = {}) {
57
60
  const body = Buffer.concat(bodyChunks);
58
61
 
59
62
  const ctx = { account: null, status: null };
60
- await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
61
-
62
- hooks.onRequestEnd?.(reqId, {
63
- method: req.method, path: req.url,
64
- account: ctx.account, status: ctx.status,
65
- });
63
+ try {
64
+ await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
65
+ } catch (err) {
66
+ ctx.status = ctx.status || 502;
67
+ console.error('[TeamClaude] Unhandled error:', err);
68
+ if (!res.headersSent) {
69
+ res.writeHead(502, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({
71
+ type: 'error',
72
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
73
+ }));
74
+ }
75
+ } finally {
76
+ hooks.onRequestEnd?.(reqId, {
77
+ method: req.method, path: req.url,
78
+ account: ctx.account, status: ctx.status,
79
+ });
80
+ }
66
81
  } catch (err) {
67
82
  console.error('[TeamClaude] Unhandled error:', err);
68
- if (!res.headersSent) {
69
- res.writeHead(502, { 'Content-Type': 'application/json' });
70
- res.end(JSON.stringify({
71
- type: 'error',
72
- error: { type: 'proxy_error', message: 'Internal proxy error' },
73
- }));
74
- }
75
83
  }
76
84
  });
77
85
 
78
86
  return server;
79
87
  }
80
88
 
81
- const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
82
-
83
89
  /**
84
- * Forward a token refresh request to the real token endpoint, capture new tokens,
85
- * and pass the response back to the client.
90
+ * Relay a request to upstream with no header rewriting pure passthrough.
86
91
  */
87
- async function handleTokenRefresh(req, res, accountManager, hooks) {
92
+ async function relayRaw(req, res, upstream) {
88
93
  const bodyChunks = [];
89
- for await (const chunk of req) {
90
- bodyChunks.push(chunk);
91
- }
92
- const rawBody = Buffer.concat(bodyChunks);
93
-
94
- // Replace the refresh token in the request with the current account's refresh token
95
- // so the renewal happens for the active account, not just the one the client knows about
96
- let body = rawBody;
97
- try {
98
- const parsed = JSON.parse(rawBody.toString());
99
- const currentAccount = accountManager.accounts[accountManager.currentIndex];
100
- if (parsed.grant_type === 'refresh_token' && currentAccount?.type === 'oauth' && currentAccount.refreshToken) {
101
- parsed.refresh_token = currentAccount.refreshToken;
102
- body = JSON.stringify(parsed);
103
- console.log(`[TeamClaude] Token refresh: substituted refresh token for account "${currentAccount.name}"`);
104
- }
105
- } catch {}
94
+ for await (const chunk of req) bodyChunks.push(chunk);
95
+ const body = Buffer.concat(bodyChunks);
106
96
 
107
97
  try {
108
- // Forward to the real token endpoint
109
- const upstreamRes = await fetch(TOKEN_ENDPOINT, {
110
- method: 'POST',
98
+ const upstreamRes = await fetch(`${upstream}${req.url}`, {
99
+ method: req.method,
111
100
  headers: {
112
- 'Content-Type': req.headers['content-type'] || 'application/json',
113
- 'Accept': 'application/json, text/plain, */*',
114
- 'User-Agent': req.headers['user-agent'] || 'axios/1.13.6',
101
+ 'content-type': req.headers['content-type'] || 'application/json',
102
+ 'accept': req.headers['accept'] || 'application/json',
103
+ 'user-agent': req.headers['user-agent'] || 'node',
115
104
  },
116
- body,
105
+ body: body.length > 0 ? body : undefined,
117
106
  });
118
107
 
119
108
  const responseBody = await upstreamRes.text();
120
-
121
- // Capture tokens from successful refresh — update the current account directly
122
- if (upstreamRes.ok) {
123
- try {
124
- const tokens = JSON.parse(responseBody);
125
- if (tokens.access_token) {
126
- accountManager.updateAccountTokens(accountManager.currentIndex, {
127
- accessToken: tokens.access_token,
128
- refreshToken: tokens.refresh_token,
129
- expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
130
- });
131
- }
132
- } catch {}
133
- }
134
-
135
- // Forward response to client
136
109
  const responseHeaders = {};
137
110
  for (const [key, value] of upstreamRes.headers.entries()) {
138
111
  if (key === 'transfer-encoding' || key === 'connection') continue;
@@ -141,17 +114,15 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
141
114
  res.writeHead(upstreamRes.status, responseHeaders);
142
115
  res.end(responseBody);
143
116
  } catch (err) {
144
- console.error('[TeamClaude] Token refresh proxy error:', err.message);
117
+ console.error('[TeamClaude] Raw relay error:', err.message);
145
118
  if (!res.headersSent) {
146
119
  res.writeHead(502, { 'Content-Type': 'application/json' });
147
- res.end(JSON.stringify({
148
- type: 'error',
149
- error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
150
- }));
120
+ res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: 'Upstream unreachable' } }));
151
121
  }
152
122
  }
153
123
  }
154
124
 
125
+
155
126
  function logTimestamp() {
156
127
  const d = new Date();
157
128
  const pad = (n, w = 2) => String(n).padStart(w, '0');
@@ -272,6 +243,23 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
272
243
  }
273
244
  accountManager.updateQuota(account.index, rateLimitHeaders);
274
245
 
246
+ // On 429, wait the retry-after duration and retry on the same account
247
+ // (this is a transient rate limit, not quota exhaustion)
248
+ if (upstreamRes.status === 429) {
249
+ const retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10) || 60;
250
+ // Discard the 429 response body
251
+ await upstreamRes.body?.cancel();
252
+
253
+ if (logDir) {
254
+ logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
255
+ }
256
+ console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
257
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
258
+ // Client may have disconnected during the wait
259
+ if (res.destroyed) return;
260
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir);
261
+ }
262
+
275
263
  // Log response headers
276
264
  if (logDir) {
277
265
  logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
@@ -329,6 +317,17 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
329
317
  writeRequestLog(logDir, reqId, logSections);
330
318
  }
331
319
 
320
+ const isTransient = err instanceof Error &&
321
+ (err.message.includes('fetch failed') ||
322
+ err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
323
+ err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
324
+
325
+ // Transient network errors: just close the connection and let the client retry
326
+ if (isTransient) {
327
+ res.destroy();
328
+ return;
329
+ }
330
+
332
331
  if (retryCount < maxRetries && !res.headersSent) {
333
332
  account.status = 'error';
334
333
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
@@ -358,6 +357,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
358
357
  const { done, value } = await reader.read();
359
358
  if (done) break;
360
359
 
360
+ // Client disconnected — stop reading from upstream
361
+ if (res.destroyed) break;
362
+
361
363
  // Forward chunk immediately
362
364
  const ok = res.write(value);
363
365
 
@@ -375,9 +377,14 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
375
377
  parseSSEUsage(event, accountIndex, accountManager);
376
378
  }
377
379
 
378
- // Handle backpressure
380
+ // Handle backpressure — also bail out if client disconnects,
381
+ // because 'drain' will never fire on a destroyed socket
379
382
  if (!ok) {
380
- await new Promise(resolve => res.once('drain', resolve));
383
+ await new Promise(resolve => {
384
+ res.once('drain', resolve);
385
+ res.once('close', resolve);
386
+ });
387
+ if (res.destroyed) break;
381
388
  }
382
389
  }
383
390
 
@@ -386,7 +393,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
386
393
  parseSSEUsage(sseBuffer, accountIndex, accountManager);
387
394
  }
388
395
  } finally {
389
- res.end();
396
+ // Cancel upstream reader to stop consuming data nobody needs
397
+ reader.cancel().catch(() => {});
398
+ if (!res.writableEnded) res.end();
390
399
  }
391
400
  }
392
401
 
package/src/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { importCredentials } from './oauth.js';
1
+ import { importCredentials, fetchProfile } from './oauth.js';
2
2
 
3
3
  // ── ANSI helpers ─────────────────────────────────────────────
4
4
 
@@ -283,7 +283,7 @@ export class TUI {
283
283
  if (count > 0) {
284
284
  this._addLog(`Synced ${count} new account(s) from config`);
285
285
  } else {
286
- this._addLog('Config reloaded, no new accounts');
286
+ this._addLog('Config reloaded, credentials refreshed');
287
287
  }
288
288
  } catch (e) {
289
289
  this._addLog(`Sync failed: ${e.message}`);
@@ -292,19 +292,59 @@ export class TUI {
292
292
 
293
293
  async _doImport() {
294
294
  try {
295
+ this._addLog('Importing credentials...');
295
296
  const creds = await importCredentials('~/.claude/.credentials.json');
296
- const n = this.config.accounts.filter(a => a.name.startsWith('max-')).length + 1;
297
- const name = `max-${n}`;
297
+ const profile = await fetchProfile(creds.accessToken);
298
+ const profileOk = profile && !profile.error;
299
+
300
+ if (!profileOk) {
301
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
302
+ }
303
+
304
+ let name;
305
+ if (profile?.email) {
306
+ name = profile.email;
307
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
308
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
309
+ } else {
310
+ const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
311
+ name = `account-${n}`;
312
+ }
313
+
298
314
  const entry = {
299
- name, type: 'oauth',
315
+ name, type: 'oauth', source: 'import',
316
+ accountUuid: profile?.accountUuid || null,
300
317
  accessToken: creds.accessToken,
301
318
  refreshToken: creds.refreshToken,
302
319
  expiresAt: creds.expiresAt,
303
320
  };
304
- this.config.accounts.push(entry);
305
- this.am.addAccount(entry);
321
+
322
+ // Deduplicate: match by UUID first, then by name
323
+ let idx = profile?.accountUuid
324
+ ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
325
+ : -1;
326
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
327
+
328
+ if (idx >= 0) {
329
+ this.config.accounts[idx] = entry;
330
+ // Update the running account manager entry
331
+ const amAcct = this.am.accounts[idx];
332
+ if (amAcct) {
333
+ amAcct.credential = creds.accessToken;
334
+ amAcct.refreshToken = creds.refreshToken;
335
+ amAcct.expiresAt = creds.expiresAt;
336
+ amAcct.accountUuid = entry.accountUuid;
337
+ amAcct.name = name;
338
+ if (amAcct.status === 'error') amAcct.status = 'active';
339
+ }
340
+ this._addLog(`Updated account "${name}"`);
341
+ } else {
342
+ this.config.accounts.push(entry);
343
+ this.am.addAccount(entry);
344
+ this._addLog(`Imported account "${name}"`);
345
+ }
346
+
306
347
  await this.saveConfig(this.config);
307
- this._addLog(`Imported account "${name}"`);
308
348
  } catch (e) {
309
349
  this._addLog(`Import failed: ${e.message}`);
310
350
  }