@karpeleslab/teamclaude 1.0.3 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -23,6 +23,7 @@ export class AccountManager {
23
23
  index,
24
24
  name: acct.name,
25
25
  type: acct.type,
26
+ accountUuid: acct.accountUuid || null,
26
27
  credential: acct.accessToken || acct.apiKey,
27
28
  refreshToken: acct.refreshToken || null,
28
29
  expiresAt: acct.expiresAt || null,
@@ -268,42 +269,21 @@ export class AccountManager {
268
269
  }
269
270
 
270
271
  /**
271
- * Capture a fresh token from a client request or intercepted token refresh.
272
- * Updates the first OAuth account whose credential matches the old token,
273
- * or the first expired/error OAuth account if none match.
274
- *
275
- * @param {string} accessToken - The new access token
276
- * @param {string} [refreshToken] - New refresh token (if available from intercepted refresh)
277
- * @param {number} [expiresAt] - Token expiry timestamp
272
+ * Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
278
273
  */
279
- captureClientToken(accessToken, refreshToken, expiresAt) {
280
- if (!accessToken) return;
281
-
282
- // Check if any account already has this exact token
283
- const existing = this.accounts.find(a => a.type === 'oauth' && a.credential === accessToken);
284
- if (existing) {
285
- // Update expiry/refresh if we have better info
286
- if (expiresAt && expiresAt > (existing.expiresAt || 0)) existing.expiresAt = expiresAt;
287
- if (refreshToken) existing.refreshToken = refreshToken;
288
- return;
289
- }
290
-
291
- // Find the best OAuth account to update: prefer expired/error accounts
292
- const candidate = this.accounts.find(a =>
293
- a.type === 'oauth' && (a.status === 'error' || isTokenExpiringSoon(a.expiresAt, 0))
294
- ) || this.accounts.find(a => a.type === 'oauth');
295
-
296
- if (!candidate) return;
297
-
298
- candidate.credential = accessToken;
299
- if (refreshToken) candidate.refreshToken = refreshToken;
300
- candidate.expiresAt = expiresAt || Date.now() + 3600 * 1000;
301
- if (candidate.status === 'error') candidate.status = 'active';
302
- console.log(`[TeamClaude] Captured fresh token for account "${candidate.name}"`);
303
- this._onTokenRefresh?.(candidate.index, {
274
+ updateAccountTokens(accountIndex, { accessToken, refreshToken, expiresAt }) {
275
+ const account = this.accounts[accountIndex];
276
+ if (!account || account.type !== 'oauth') return;
277
+
278
+ account.credential = accessToken;
279
+ if (refreshToken) account.refreshToken = refreshToken;
280
+ account.expiresAt = expiresAt;
281
+ if (account.status === 'error') account.status = 'active';
282
+ console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
283
+ this._onTokenRefresh?.(accountIndex, {
304
284
  accessToken,
305
- refreshToken: candidate.refreshToken,
306
- expiresAt: candidate.expiresAt,
285
+ refreshToken: account.refreshToken,
286
+ expiresAt: account.expiresAt,
307
287
  });
308
288
  }
309
289
 
@@ -316,6 +296,7 @@ export class AccountManager {
316
296
  index,
317
297
  name: acctData.name,
318
298
  type: acctData.type,
299
+ accountUuid: acctData.accountUuid || null,
319
300
  credential: acctData.accessToken || acctData.apiKey,
320
301
  refreshToken: acctData.refreshToken || null,
321
302
  expiresAt: acctData.expiresAt || null,
package/src/config.js CHANGED
@@ -46,3 +46,15 @@ export async function saveConfig(config) {
46
46
  await mkdir(dirname(path), { recursive: true });
47
47
  await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
48
48
  }
49
+
50
+ /**
51
+ * Atomically update the config: re-reads from disk, calls updater(config),
52
+ * then saves. Returns the updated config. This prevents overwriting changes
53
+ * made by other processes (e.g. `teamclaude import` while the server runs).
54
+ */
55
+ export async function atomicConfigUpdate(updater) {
56
+ const config = await loadConfig() || createDefaultConfig();
57
+ await updater(config);
58
+ await saveConfig(config);
59
+ return config;
60
+ }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
- import { loadOrCreateConfig, saveConfig, getConfigPath } from './config.js';
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
8
  import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
@@ -82,15 +82,21 @@ async function serverCommand() {
82
82
  const threshold = config.switchThreshold || 0.98;
83
83
  const accountManager = new AccountManager(accounts, threshold);
84
84
 
85
- // Persist refreshed tokens back to config
85
+ // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
86
+ // accounts added externally, e.g. by `teamclaude import` while server is running)
86
87
  accountManager.onTokenRefresh((idx, newTokens) => {
87
- const cfgAcct = config.accounts[idx];
88
- if (cfgAcct) {
89
- cfgAcct.accessToken = newTokens.accessToken;
90
- cfgAcct.refreshToken = newTokens.refreshToken;
91
- cfgAcct.expiresAt = newTokens.expiresAt;
92
- saveConfig(config).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
93
- }
88
+ const account = accountManager.accounts[idx];
89
+ if (!account) return;
90
+ atomicConfigUpdate(diskConfig => {
91
+ syncNewAccountsFromDisk(diskConfig, config, accountManager);
92
+ // Match by UUID first, then by name — index may have shifted
93
+ const cfgIdx = findConfigAccount(diskConfig, account);
94
+ if (cfgIdx >= 0) {
95
+ diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
96
+ diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
97
+ diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
98
+ }
99
+ }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
94
100
  });
95
101
  const port = config.proxy.port;
96
102
  const useTUI = process.stdout.isTTY && process.stdin.isTTY;
@@ -100,7 +106,24 @@ async function serverCommand() {
100
106
 
101
107
  if (useTUI) {
102
108
  tui = new TUI({
103
- accountManager, config, saveConfig,
109
+ 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 => {
114
+ const diskAcct = diskConfig.accounts.find(
115
+ d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
116
+ );
117
+ return diskAcct ? { ...diskAcct, ...a } : a;
118
+ });
119
+ }),
120
+ syncAccounts: async () => {
121
+ const diskConfig = await loadConfig();
122
+ if (!diskConfig) return 0;
123
+ const before = accountManager.accounts.length;
124
+ syncNewAccountsFromDisk(diskConfig, config, accountManager);
125
+ return accountManager.accounts.length - before;
126
+ },
104
127
  onQuit: () => { server.close(() => process.exit(0)); },
105
128
  });
106
129
  hooks = {
@@ -559,6 +582,38 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
559
582
  console.log(`Saved to ${getConfigPath()}`);
560
583
  }
561
584
 
585
+ // ── config sync helpers ─────────────────────────────────────
586
+
587
+ /**
588
+ * Find a config account entry matching an in-memory account (by UUID, then name).
589
+ */
590
+ function findConfigAccount(diskConfig, account) {
591
+ if (account.accountUuid) {
592
+ const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
593
+ if (idx >= 0) return idx;
594
+ }
595
+ return diskConfig.accounts.findIndex(a => a.name === account.name);
596
+ }
597
+
598
+ /**
599
+ * Detect accounts added to disk config by external processes and add them
600
+ * to the running AccountManager + in-memory config.
601
+ */
602
+ function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
603
+ 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);
607
+
608
+ if (!knownByUuid && !knownByName) {
609
+ // New account discovered on disk — add to running server
610
+ memConfig.accounts.push(diskAcct);
611
+ accountManager.addAccount(diskAcct);
612
+ console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
613
+ }
614
+ }
615
+ }
616
+
562
617
  // ── helpers ─────────────────────────────────────────────────
563
618
 
564
619
  async function resolveAccounts(config) {
package/src/server.js CHANGED
@@ -118,13 +118,16 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
118
118
 
119
119
  const responseBody = await upstreamRes.text();
120
120
 
121
- // Capture tokens from successful refresh
121
+ // Capture tokens from successful refresh — update the current account directly
122
122
  if (upstreamRes.ok) {
123
123
  try {
124
124
  const tokens = JSON.parse(responseBody);
125
125
  if (tokens.access_token) {
126
- accountManager.captureClientToken(tokens.access_token, tokens.refresh_token,
127
- tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000));
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
+ });
128
131
  }
129
132
  } catch {}
130
133
  }
@@ -226,12 +229,6 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
226
229
  headers['x-api-key'] = account.credential;
227
230
  }
228
231
 
229
- // Capture fresh Bearer tokens from the client to keep stored credentials up to date
230
- const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
231
- if (clientBearer) {
232
- accountManager.captureClientToken(clientBearer);
233
- }
234
-
235
232
  const upstreamUrl = `${upstream}${req.url}`;
236
233
  const method = req.method;
237
234
 
@@ -332,7 +329,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
332
329
  writeRequestLog(logDir, reqId, logSections);
333
330
  }
334
331
 
335
- if (retryCount < maxRetries) {
332
+ if (retryCount < maxRetries && !res.headersSent) {
336
333
  account.status = 'error';
337
334
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
338
335
  }
package/src/tui.js CHANGED
@@ -17,7 +17,8 @@ const red = s => fg(31, s);
17
17
  const cyan = s => fg(36, s);
18
18
  const gray = s => fg(90, s);
19
19
 
20
- const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ const strip = s => s.replace(ANSI_RE, '');
21
22
  const vw = s => strip(s).length;
22
23
 
23
24
  function rpad(s, w) {
@@ -25,13 +26,85 @@ function rpad(s, w) {
25
26
  return gap > 0 ? s + ' '.repeat(gap) : s;
26
27
  }
27
28
 
28
- function bar(ratio, w = 10) {
29
- if (ratio == null || isNaN(ratio)) return gray('░'.repeat(w)) + ' - ';
29
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
30
+ function truncate(s, w) {
31
+ let visible = 0;
32
+ let out = '';
33
+ let i = 0;
34
+ while (i < s.length && visible < w) {
35
+ if (s[i] === '\x1b') {
36
+ const end = s.indexOf('m', i);
37
+ if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
38
+ }
39
+ out += s[i];
40
+ visible++;
41
+ i++;
42
+ }
43
+ return out + RESET;
44
+ }
45
+
46
+ /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
47
+ function fitLine(s, w) {
48
+ const v = vw(s);
49
+ if (v > w) return truncate(s, w);
50
+ if (v < w) return s + ' '.repeat(w - v);
51
+ return s;
52
+ }
53
+
54
+ function formatReset(resetTs) {
55
+ if (!resetTs) return '';
56
+ const ms = resetTs - Date.now();
57
+ if (ms <= 0) return '';
58
+ const mins = Math.ceil(ms / 60000);
59
+ if (mins < 60) return `${mins}m`;
60
+ const hrs = Math.floor(mins / 60);
61
+ const rm = mins % 60;
62
+ if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
63
+ const days = Math.floor(hrs / 24);
64
+ const rh = hrs % 24;
65
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`;
66
+ }
67
+
68
+ /**
69
+ * Render a progress bar using background colors with text overlaid.
70
+ * The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
71
+ */
72
+ function bar(ratio, w = 10, resetTs) {
73
+ const rst = formatReset(resetTs);
74
+
75
+ if (ratio == null || isNaN(ratio)) {
76
+ // No data — dim background, show label or dash
77
+ const label = rst || '-';
78
+ const text = label.slice(0, w);
79
+ const pad = w - text.length;
80
+ const lp = Math.floor(pad / 2);
81
+ const rp = pad - lp;
82
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
83
+ }
84
+
30
85
  ratio = Math.max(0, Math.min(1, ratio));
31
86
  const f = Math.round(ratio * w);
32
- const c = ratio < 0.7 ? 32 : ratio < 0.9 ? 33 : 31;
33
- const pct = (ratio * 100).toFixed(0).padStart(3) + '%';
34
- return `${ESC}${c}m${'█'.repeat(f)}${ESC}90m${'░'.repeat(w - f)}${RESET} ${pct}`;
87
+ // Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
88
+ const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
89
+
90
+ // Build the label to overlay: show reset time if available, else percentage
91
+ const pct = (ratio * 100).toFixed(0) + '%';
92
+ const label = rst || pct;
93
+ const text = label.slice(0, w);
94
+ const pad = w - text.length;
95
+ const lp = Math.floor(pad / 2);
96
+ const rp = pad - lp;
97
+ const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
98
+
99
+ // Split chars into filled (colored bg) and empty (gray bg) portions
100
+ const filled = chars.slice(0, f);
101
+ const empty = chars.slice(f);
102
+
103
+ let out = '';
104
+ if (filled) out += `${ESC}${bg};97m${filled}`;
105
+ if (empty) out += `${ESC}100;37m${empty}`;
106
+ out += RESET;
107
+ return out;
35
108
  }
36
109
 
37
110
  function timestamp() {
@@ -41,10 +114,11 @@ function timestamp() {
41
114
  // ── TUI class ────────────────────────────────────────────────
42
115
 
43
116
  export class TUI {
44
- constructor({ accountManager, config, saveConfig, onQuit }) {
117
+ constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
45
118
  this.am = accountManager;
46
119
  this.config = config;
47
120
  this.saveConfig = saveConfig;
121
+ this.syncAccounts = syncAccounts;
48
122
  this.onQuit = onQuit;
49
123
 
50
124
  this.log = []; // completed activity entries
@@ -159,6 +233,7 @@ export class TUI {
159
233
  this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
160
234
  }
161
235
  else if (k === 'a') { this.mode = 'add'; }
236
+ else if (k === 'R') { this._doSync(); }
162
237
  }
163
238
 
164
239
  _keySelect(k) {
@@ -202,6 +277,19 @@ export class TUI {
202
277
 
203
278
  // ── account operations ─────────────────────────────
204
279
 
280
+ async _doSync() {
281
+ try {
282
+ const count = await this.syncAccounts();
283
+ if (count > 0) {
284
+ this._addLog(`Synced ${count} new account(s) from config`);
285
+ } else {
286
+ this._addLog('Config reloaded, no new accounts');
287
+ }
288
+ } catch (e) {
289
+ this._addLog(`Sync failed: ${e.message}`);
290
+ }
291
+ }
292
+
205
293
  async _doImport() {
206
294
  try {
207
295
  const creds = await importCredentials('~/.claude/.credentials.json');
@@ -311,7 +399,7 @@ export class TUI {
311
399
  // Write buffer
312
400
  let buf = `${ESC}H`;
313
401
  for (let i = 0; i < H; i++) {
314
- buf += rpad(lines[i] || '', W);
402
+ buf += fitLine(lines[i] || '', W);
315
403
  if (i < H - 1) buf += '\r\n';
316
404
  }
317
405
  // Show cursor only in input mode
@@ -348,11 +436,13 @@ export class TUI {
348
436
 
349
437
  // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
350
438
  const q = a.quota;
351
- let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ';
439
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
352
440
 
353
441
  if (q.unified5h != null || q.unified7d != null) {
354
442
  r1 = q.unified5h;
355
443
  r2 = q.unified7d;
444
+ t1 = q.unified5hReset;
445
+ t2 = q.unified7dReset;
356
446
  } else {
357
447
  l1 = 'Tok';
358
448
  l2 = 'Req';
@@ -360,11 +450,13 @@ export class TUI {
360
450
  ? 1 - q.tokensRemaining / q.tokensLimit : null;
361
451
  r2 = (q.requestsLimit != null && q.requestsRemaining != null)
362
452
  ? 1 - q.requestsRemaining / q.requestsLimit : null;
453
+ t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
454
+ t2 = t1;
363
455
  }
364
456
 
365
- let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw)}`;
457
+ let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
366
458
  if (showBoth) {
367
- line += ` ${l2} ${bar(r2, bw)}`;
459
+ line += ` ${l2} ${bar(r2, bw, t2)}`;
368
460
  }
369
461
  return line;
370
462
  }
@@ -372,7 +464,7 @@ export class TUI {
372
464
  _renderFooter() {
373
465
  switch (this.mode) {
374
466
  case 'normal':
375
- return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('q')}uit`;
467
+ return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
376
468
  case 'select': {
377
469
  const act = this.selAction === 'switch' ? 'switch' : 'remove';
378
470
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;