@karpeleslab/teamclaude 1.0.5 → 1.0.7

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/src/index.js CHANGED
@@ -1,11 +1,14 @@
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
- import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
5
+ import net from 'node:net';
6
+ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState } from './config.js';
6
7
  import { AccountManager } from './account-manager.js';
7
8
  import { createProxyServer } from './server.js';
8
- import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
9
+ import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
+ import { sameIdentity, orgKey, matchAccounts } from './identity.js';
11
+ import * as alias from './alias.js';
9
12
  import { TUI } from './tui.js';
10
13
 
11
14
  const args = process.argv.slice(2);
@@ -42,10 +45,26 @@ switch (command) {
42
45
  await removeCommand();
43
46
  process.exit(0);
44
47
  break;
48
+ case 'priority':
49
+ await priorityCommand();
50
+ process.exit(0);
51
+ break;
52
+ case 'disable':
53
+ await setDisabledCommand(true);
54
+ process.exit(0);
55
+ break;
56
+ case 'enable':
57
+ await setDisabledCommand(false);
58
+ process.exit(0);
59
+ break;
45
60
  case 'api':
46
61
  await apiCommand();
47
62
  process.exit(0);
48
63
  break;
64
+ case 'alias':
65
+ aliasCommand();
66
+ process.exit(0);
67
+ break;
49
68
  case 'help':
50
69
  case '--help':
51
70
  case '-h':
@@ -89,13 +108,42 @@ async function serverCommand() {
89
108
  const threshold = config.switchThreshold || 0.98;
90
109
  const accountManager = new AccountManager(accounts, threshold);
91
110
 
111
+ // Restore quota observed in a previous run so a restart doesn't lose rotation
112
+ // state (passive — we never call the API to re-learn it). Stale windows are
113
+ // cleared automatically on first use by _clearExpiredQuotas.
114
+ const savedState = await loadState().catch(err => {
115
+ console.error(`[TeamClaude] Could not read saved state: ${err.message}`);
116
+ return null;
117
+ });
118
+ if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
119
+
120
+ // Periodically persist quota (and once more on shutdown) to the state file.
121
+ const persistQuotaState = () =>
122
+ saveState({ quota: accountManager.exportQuotaState() })
123
+ .catch(err => console.error(`[TeamClaude] Failed to save quota state: ${err.message}`));
124
+ let quotaSaveInterval = null;
125
+
92
126
  // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
93
127
  // accounts added externally, e.g. by `teamclaude import` while server is running)
94
128
  accountManager.onTokenRefresh((idx, newTokens) => {
95
129
  const account = accountManager.accounts[idx];
96
130
  if (!account) return;
131
+ // Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
132
+ if (config.accounts[idx]) {
133
+ config.accounts[idx].accessToken = newTokens.accessToken;
134
+ config.accounts[idx].refreshToken = newTokens.refreshToken;
135
+ config.accounts[idx].expiresAt = newTokens.expiresAt;
136
+ }
97
137
  atomicConfigUpdate(diskConfig => {
98
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
138
+ // Pick up any new accounts from disk so index matching stays correct
139
+ // (only add, don't refresh credentials — we're about to write the authoritative tokens)
140
+ for (const diskAcct of diskConfig.accounts) {
141
+ const known = config.accounts.some(a => sameIdentity(a, diskAcct));
142
+ if (!known) {
143
+ config.accounts.push(diskAcct);
144
+ accountManager.addAccount(diskAcct);
145
+ }
146
+ }
99
147
  // Match by UUID first, then by name — index may have shifted
100
148
  const cfgIdx = findConfigAccount(diskConfig, account);
101
149
  if (cfgIdx >= 0) {
@@ -114,24 +162,32 @@ async function serverCommand() {
114
162
  if (useTUI) {
115
163
  tui = new TUI({
116
164
  accountManager, config,
117
- saveConfig: () => atomicConfigUpdate(diskConfig => {
118
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
119
- // Write in-memory accounts back, preserving extra disk-only fields
120
- diskConfig.accounts = config.accounts.map(a => {
121
- const diskAcct = diskConfig.accounts.find(
122
- d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
123
- );
124
- return diskAcct ? { ...diskAcct, ...a } : a;
165
+ saveConfig: () => atomicConfigUpdate(async diskConfig => {
166
+ // Write in-memory accounts as the authoritative state, preserving
167
+ // extra disk-only fields (e.g. importFrom) where the account still exists.
168
+ // Use live tokens from AccountManager (not the stale config.accounts copy).
169
+ diskConfig.accounts = config.accounts.map((a, i) => {
170
+ const am = accountManager.accounts[i];
171
+ const live = am ? {
172
+ ...a,
173
+ accessToken: am.credential,
174
+ refreshToken: am.refreshToken,
175
+ expiresAt: am.expiresAt,
176
+ } : a;
177
+ const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
178
+ return diskAcct ? { ...diskAcct, ...live } : live;
125
179
  });
126
180
  }),
127
181
  syncAccounts: async () => {
128
182
  const diskConfig = await loadConfig();
129
183
  if (!diskConfig) return 0;
130
- const before = accountManager.accounts.length;
131
- syncNewAccountsFromDisk(diskConfig, config, accountManager);
132
- return accountManager.accounts.length - before;
184
+ return syncAccountsFromDisk(diskConfig, config, accountManager);
185
+ },
186
+ onQuit: async () => {
187
+ if (quotaSaveInterval) clearInterval(quotaSaveInterval);
188
+ await persistQuotaState();
189
+ server.close(() => process.exit(0));
133
190
  },
134
- onQuit: () => { server.close(() => process.exit(0)); },
135
191
  });
136
192
  hooks = {
137
193
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -141,8 +197,17 @@ async function serverCommand() {
141
197
  }
142
198
 
143
199
  const server = createProxyServer(accountManager, config, hooks);
200
+ // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
201
+ // remove this handler so a later runtime 'error' isn't misreported as a
202
+ // listen failure and exit the whole proxy.
203
+ const onListenError = err => handleServerListenError(err, port);
204
+ server.once('error', onListenError);
144
205
 
145
206
  server.listen(port, () => {
207
+ // Bind succeeded: stop treating errors as listen failures, but keep a
208
+ // benign runtime handler so a later 'error' is logged rather than thrown.
209
+ server.removeListener('error', onListenError);
210
+ server.on('error', err => console.error(`[TeamClaude] Server error: ${err.message}`));
146
211
  if (tui) {
147
212
  tui.start();
148
213
  console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
@@ -168,15 +233,19 @@ async function serverCommand() {
168
233
  }
169
234
  });
170
235
 
236
+ // Persist quota every minute; unref so it never keeps the process alive.
237
+ quotaSaveInterval = setInterval(persistQuotaState, 60_000);
238
+ quotaSaveInterval.unref?.();
239
+
171
240
  if (!tui) {
172
- process.on('SIGINT', () => {
241
+ const shutdown = async () => {
173
242
  console.log('\n[TeamClaude] Shutting down...');
243
+ clearInterval(quotaSaveInterval);
244
+ await persistQuotaState();
174
245
  server.close(() => process.exit(0));
175
- });
176
- process.on('SIGTERM', () => {
177
- console.log('\n[TeamClaude] Shutting down...');
178
- server.close(() => process.exit(0));
179
- });
246
+ };
247
+ process.on('SIGINT', shutdown);
248
+ process.on('SIGTERM', shutdown);
180
249
  }
181
250
  }
182
251
 
@@ -186,14 +255,36 @@ async function importCommand() {
186
255
  const config = await loadOrCreateConfig();
187
256
 
188
257
  let name = argValue('--name');
189
- const fromPath = argValue('--from') || '~/.claude/.credentials.json';
258
+ const jsonStr = argValue('--json');
190
259
 
191
260
  let creds;
192
- try {
193
- creds = await importCredentials(fromPath);
194
- } catch (err) {
195
- console.error(`Failed to import from ${fromPath}: ${err.message}`);
196
- process.exit(1);
261
+ if (jsonStr) {
262
+ // Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
263
+ // or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
264
+ try {
265
+ const raw = JSON.parse(jsonStr);
266
+ const data = raw.claudeAiOauth || raw;
267
+ if (!data.accessToken) {
268
+ console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
269
+ process.exit(1);
270
+ }
271
+ creds = {
272
+ accessToken: data.accessToken,
273
+ refreshToken: data.refreshToken,
274
+ expiresAt: data.expiresAt,
275
+ };
276
+ } catch (err) {
277
+ console.error(`Failed to parse --json: ${err.message}`);
278
+ process.exit(1);
279
+ }
280
+ } else {
281
+ const fromPath = argValue('--from') || '~/.claude/.credentials.json';
282
+ try {
283
+ creds = await importCredentials(fromPath);
284
+ } catch (err) {
285
+ console.error(`Failed to import from ${fromPath}: ${err.message}`);
286
+ process.exit(1);
287
+ }
197
288
  }
198
289
 
199
290
  await upsertOAuthAccount(config, name, creds, 'import');
@@ -296,27 +387,37 @@ async function runCommand() {
296
387
  const claudeArgs = args.slice(1);
297
388
  if (claudeArgs[0] === '--') claudeArgs.shift();
298
389
 
299
- // Only set ANTHROPIC_BASE_URL Claude Code keeps its own OAuth token
300
- // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
301
- // lets Claude Code stay in subscription mode (full model access).
302
- const child = spawn('claude', claudeArgs, {
390
+ // Route through the proxy only when it's actually up; otherwise launch claude
391
+ // directly so a stopped proxy doesn't break `claude`. This is what lets the
392
+ // shell alias (`claude='teamclaude run --'`) be a dumb passthrough.
393
+ const port = config.proxy.port;
394
+ const env = { ...process.env };
395
+ if (await isProxyUp(port)) {
396
+ // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
397
+ // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
398
+ // lets Claude Code stay in subscription mode (full model access).
399
+ env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
400
+ } else {
401
+ console.error(`[TeamClaude] Proxy not running on port ${port} — launching claude directly (start it with: teamclaude server)`);
402
+ }
403
+
404
+ // Use spawnSync so the Node process blocks entirely — behaves like execvp.
405
+ const result = spawnSync('claude', claudeArgs, {
303
406
  stdio: 'inherit',
304
- env: {
305
- ...process.env,
306
- ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
307
- },
407
+ shell: process.platform === 'win32',
408
+ env,
308
409
  });
309
410
 
310
- child.on('error', (err) => {
311
- if (err.code === 'ENOENT') {
411
+ if (result.error) {
412
+ if (result.error.code === 'ENOENT') {
312
413
  console.error('Claude Code not found in PATH. Install it first.');
313
414
  } else {
314
- console.error(`Failed to start claude: ${err.message}`);
415
+ console.error(`Failed to start claude: ${result.error.message}`);
315
416
  }
316
417
  process.exit(1);
317
- });
418
+ }
318
419
 
319
- child.on('exit', (code) => process.exit(code ?? 1));
420
+ process.exit(result.status ?? 1);
320
421
  }
321
422
 
322
423
  // ── status ──────────────────────────────────────────────────
@@ -337,7 +438,7 @@ async function statusCommand() {
337
438
  const current = acct.name === data.currentAccount ? ' *' : '';
338
439
 
339
440
  console.log(` ${acct.name} (${acct.type})${current}`);
340
- console.log(` Status: ${acct.status}`);
441
+ console.log(` Status: ${acct.status}${acct.disabled ? ' (disabled)' : ''}`);
341
442
 
342
443
  if (q.unified5h != null || q.unified7d != null) {
343
444
  const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
@@ -372,6 +473,23 @@ async function accountsCommand() {
372
473
  return;
373
474
  }
374
475
 
476
+ // Refresh expired tokens before fetching profiles
477
+ let configDirty = false;
478
+ await Promise.all(config.accounts.map(async (a) => {
479
+ if (a.type !== 'oauth' || !a.refreshToken) return;
480
+ if (!isTokenExpiringSoon(a.expiresAt)) return;
481
+ try {
482
+ const newTokens = await refreshAccessToken(a.refreshToken);
483
+ a.accessToken = newTokens.accessToken;
484
+ a.refreshToken = newTokens.refreshToken;
485
+ a.expiresAt = newTokens.expiresAt;
486
+ configDirty = true;
487
+ } catch (err) {
488
+ // refresh failed — fetchProfile will report the specific error
489
+ }
490
+ }));
491
+ if (configDirty) await saveConfig(config);
492
+
375
493
  // Fetch profiles in parallel for all OAuth accounts
376
494
  const profiles = await Promise.all(
377
495
  config.accounts.map(a =>
@@ -379,32 +497,51 @@ async function accountsCommand() {
379
497
  )
380
498
  );
381
499
 
382
- // Deduplicate by accountUuid keep the last (most recently added) entry
500
+ // Backfill account+org identity from profiles, then deduplicate by
501
+ // (accountUuid, org): the same person in a different org is a distinct
502
+ // account, not a duplicate. Keep the last (most recently added) entry.
383
503
  const seen = new Map();
384
504
  let removed = 0;
505
+ let touched = false;
385
506
  for (let i = config.accounts.length - 1; i >= 0; i--) {
386
507
  const a = config.accounts[i];
387
- const uuid = profiles[i]?.accountUuid || a.accountUuid;
388
- if (uuid) {
389
- if (seen.has(uuid)) {
390
- config.accounts.splice(i, 1);
391
- profiles.splice(i, 1);
392
- removed++;
393
- } else {
394
- seen.set(uuid, i);
395
- // Update stored UUID and name from profile
396
- if (profiles[i]) {
397
- a.accountUuid = profiles[i].accountUuid;
398
- if (profiles[i].email) a.name = profiles[i].email;
399
- }
400
- }
508
+ const p = profiles[i];
509
+ if (p && !p.error) {
510
+ if (p.accountUuid && a.accountUuid !== p.accountUuid) { a.accountUuid = p.accountUuid; touched = true; }
511
+ if (p.orgUuid && a.orgUuid !== p.orgUuid) { a.orgUuid = p.orgUuid; touched = true; }
512
+ if (p.orgName && a.orgName !== p.orgName) { a.orgName = p.orgName; touched = true; }
401
513
  }
514
+ const uuid = a.accountUuid;
515
+ if (!uuid) continue;
516
+ const key = `${uuid}::${orgKey(a) || ''}`;
517
+ if (seen.has(key)) {
518
+ config.accounts.splice(i, 1);
519
+ profiles.splice(i, 1);
520
+ removed++;
521
+ touched = true;
522
+ } else {
523
+ seen.set(key, i);
524
+ }
525
+ }
526
+
527
+ // Name accounts from their email: plain when the person has a single org,
528
+ // "email (Org)" when the same person spans multiple orgs. Names must stay
529
+ // unique — they are the user-facing key for remove/api/selection.
530
+ const orgCount = new Map();
531
+ for (const a of config.accounts) {
532
+ if (a.accountUuid) orgCount.set(a.accountUuid, (orgCount.get(a.accountUuid) || 0) + 1);
402
533
  }
403
- if (removed > 0) {
404
- await saveConfig(config);
405
- console.log(`Removed ${removed} duplicate account(s)\n`);
534
+ for (const [i, a] of config.accounts.entries()) {
535
+ const p = profiles[i];
536
+ const email = (p && !p.error && p.email) ? p.email : null;
537
+ if (!email) continue;
538
+ const newName = orgCount.get(a.accountUuid) > 1 ? `${email} (${orgLabel(a)})` : email;
539
+ if (a.name !== newName) { a.name = newName; touched = true; }
406
540
  }
407
541
 
542
+ if (touched) await saveConfig(config);
543
+ if (removed > 0) console.log(`Removed ${removed} duplicate account(s)\n`);
544
+
408
545
  for (const [i, a] of config.accounts.entries()) {
409
546
  const p = profiles[i];
410
547
 
@@ -414,12 +551,13 @@ async function accountsCommand() {
414
551
  }
415
552
 
416
553
  // OAuth account
417
- const tier = p?.hasClaudeMax ? 'Max' : p?.hasClaudePro ? 'Pro' : 'subscription';
418
- const status = p ? `Claude ${tier}` : 'unknown (profile fetch failed)';
554
+ const hasProfile = p && !p.error;
555
+ const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
556
+ const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
419
557
  const src = a.source ? `, ${a.source}` : '';
420
558
  console.log(` [${i + 1}] ${a.name} (${status}${src})`);
421
- if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
422
- if (p?.orgName) console.log(` Org: ${p.orgName}`);
559
+ if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
560
+ if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
423
561
  if (verbose && a.expiresAt) {
424
562
  const remaining = a.expiresAt - Date.now();
425
563
  if (remaining <= 0) {
@@ -454,7 +592,7 @@ async function apiCommand() {
454
592
  const accounts = await resolveAccounts(config);
455
593
  let account;
456
594
  if (accountName) {
457
- account = accounts.find(a => a.name === accountName);
595
+ account = resolveAccount(accounts, accountName, argValue('--org'));
458
596
  if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
459
597
  } else {
460
598
  account = accounts.find(a => a.type === 'oauth') || accounts[0];
@@ -494,26 +632,128 @@ async function apiCommand() {
494
632
  }
495
633
  }
496
634
 
635
+ // ── alias ───────────────────────────────────────────────────
636
+
637
+ function aliasCommand() {
638
+ const shell = argValue('--shell') || undefined;
639
+ if (args.includes('--uninstall')) {
640
+ alias.uninstallAlias({ shell });
641
+ } else if (args.includes('--install')) {
642
+ alias.installAlias({ shell });
643
+ } else {
644
+ alias.printAlias({ shell });
645
+ }
646
+ }
647
+
497
648
  // ── remove ──────────────────────────────────────────────────
498
649
 
650
+ /**
651
+ * Resolve a single account from a name-or-email query.
652
+ *
653
+ * An exact display-name match wins. Otherwise match by email (the part before a
654
+ * " (org)" suffix), optionally narrowed by --org. If still ambiguous across
655
+ * orgs, print the candidates and exit so the caller can disambiguate with --org.
656
+ * Returns the matched account, or null if nothing matched.
657
+ */
658
+ function resolveAccount(accounts, query, orgFilter) {
659
+ const matches = matchAccounts(accounts, query, orgFilter);
660
+ if (matches.length === 1) return matches[0];
661
+ if (matches.length === 0) return null;
662
+ console.error(`"${query}" matches ${matches.length} accounts — disambiguate with --org <name|uuid>:`);
663
+ for (const a of matches) {
664
+ console.error(` - ${a.name}${a.orgName ? ` (org: ${a.orgName})` : ''}`);
665
+ }
666
+ process.exit(1);
667
+ }
668
+
499
669
  async function removeCommand() {
500
670
  const config = await loadOrCreateConfig();
501
671
  const name = args[1];
502
672
 
503
673
  if (!name) {
504
- console.error('Usage: teamclaude remove <account-name>');
674
+ console.error('Usage: teamclaude remove <account-name|email> [--org <name|uuid>]');
675
+ process.exit(1);
676
+ }
677
+
678
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
679
+ if (!account) {
680
+ console.error(`Account "${name}" not found`);
681
+ process.exit(1);
682
+ }
683
+
684
+ config.accounts.splice(config.accounts.indexOf(account), 1);
685
+ await saveConfig(config);
686
+ console.log(`Removed account "${account.name}"`);
687
+ }
688
+
689
+ // ── priority ────────────────────────────────────────────────
690
+
691
+ async function priorityCommand() {
692
+ const config = await loadOrCreateConfig();
693
+ const name = args[1];
694
+
695
+ if (!name) {
696
+ console.error('Usage: teamclaude priority <account-name|email> <n> [--org <name|uuid>]');
697
+ console.error(' teamclaude priority <account-name|email> --first | --last');
698
+ console.error('Lower priority is preferred for rotation (default 0).');
505
699
  process.exit(1);
506
700
  }
507
701
 
508
- const idx = config.accounts.findIndex(a => a.name === name);
509
- if (idx < 0) {
702
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
703
+ if (!account) {
510
704
  console.error(`Account "${name}" not found`);
511
705
  process.exit(1);
512
706
  }
513
707
 
514
- config.accounts.splice(idx, 1);
708
+ const priorities = config.accounts.map(a => a.priority || 0);
709
+ let priority;
710
+ if (args.includes('--first')) {
711
+ priority = Math.min(0, ...priorities) - 1;
712
+ } else if (args.includes('--last')) {
713
+ priority = Math.max(0, ...priorities) + 1;
714
+ } else {
715
+ // Accept the integer in any position (e.g. after --org) — first int-looking token.
716
+ const numTok = args.slice(2).find(t => /^-?\d+$/.test(t));
717
+ priority = numTok != null ? parseInt(numTok, 10) : NaN;
718
+ if (Number.isNaN(priority)) {
719
+ console.error('Provide an integer priority, or --first / --last.');
720
+ process.exit(1);
721
+ }
722
+ }
723
+
724
+ account.priority = priority;
515
725
  await saveConfig(config);
516
- console.log(`Removed account "${name}"`);
726
+ console.log(`Set priority of "${account.name}" to ${priority} (lower = preferred)`);
727
+ }
728
+
729
+ // ── enable / disable ────────────────────────────────────────
730
+
731
+ async function setDisabledCommand(disabled) {
732
+ const config = await loadOrCreateConfig();
733
+ const name = args[1];
734
+ const verb = disabled ? 'disable' : 'enable';
735
+
736
+ if (!name) {
737
+ console.error(`Usage: teamclaude ${verb} <account-name|email> [--org <name|uuid>]`);
738
+ process.exit(1);
739
+ }
740
+
741
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
742
+ if (!account) {
743
+ console.error(`Account "${name}" not found`);
744
+ process.exit(1);
745
+ }
746
+
747
+ if (disabled) {
748
+ account.disabled = true;
749
+ } else {
750
+ delete account.disabled;
751
+ }
752
+ await saveConfig(config);
753
+ console.log(`${disabled ? 'Disabled' : 'Enabled'} account "${account.name}"`);
754
+ if (!disabled) {
755
+ console.log('(restart or reload the running server to retry it if it was in an error state)');
756
+ }
517
757
  }
518
758
 
519
759
  // ── help ────────────────────────────────────────────────────
@@ -529,16 +769,24 @@ Commands:
529
769
  login OAuth login via browser
530
770
  login --api Add an API key account
531
771
  env Print env vars to use with Claude
532
- run [-- args...] Run Claude Code through the proxy
772
+ run [-- args...] Run Claude Code through the proxy (direct if it's down)
773
+ alias Print a shell alias so plain 'claude' routes via the proxy
774
+ (--install to write it to your shell rc; --uninstall to remove)
533
775
  status Show proxy & account status (live)
534
776
  accounts List configured accounts
535
- remove <name> Remove an account
777
+ remove <name> Remove an account (by name or email; --org to disambiguate)
778
+ disable <name> Temporarily exclude an account from rotation
779
+ enable <name> Re-enable a disabled account (also clears a stuck error)
780
+ priority <name> <n> Set rotation priority (lower = preferred; --first/--last)
536
781
  api <path> Call an API endpoint with account credentials
537
782
  help Show this help
538
783
 
539
784
  Options:
540
785
  --name NAME Set account name (import/login)
786
+ --org NAME|UUID Disambiguate when an email spans multiple orgs (remove/priority/api)
541
787
  --from PATH Credentials path (import, default: ~/.claude/.credentials.json)
788
+ --json JSON Import from inline JSON (import), e.g.:
789
+ --json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
542
790
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
543
791
 
544
792
  Config: ${getConfigPath()}
@@ -547,10 +795,20 @@ Config: ${getConfigPath()}
547
795
 
548
796
  // ── shared account upsert ────────────────────────────────────
549
797
 
798
+ /** Short human label for an account's organization, for disambiguating names. */
799
+ function orgLabel(a) {
800
+ return a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
801
+ }
802
+
550
803
  async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
551
- // Fetch profile to auto-name and deduplicate by account UUID
804
+ // Fetch profile to auto-name and deduplicate by account+org identity.
805
+ const userNamed = !!name;
552
806
  const profile = await fetchProfile(creds.accessToken);
807
+ const profileOk = profile && !profile.error;
553
808
 
809
+ if (!profileOk) {
810
+ console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
811
+ }
554
812
  if (!name && profile?.email) {
555
813
  name = profile.email;
556
814
  const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
@@ -566,23 +824,40 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
566
824
  type: 'oauth',
567
825
  source,
568
826
  accountUuid: profile?.accountUuid || null,
827
+ orgUuid: profile?.orgUuid || null,
828
+ orgName: profile?.orgName || null,
569
829
  accessToken: creds.accessToken,
570
830
  refreshToken: creds.refreshToken,
571
831
  expiresAt: creds.expiresAt,
572
832
  };
573
833
 
574
- // Deduplicate: match by UUID first, then by name
575
- let idx = profile?.accountUuid
576
- ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
577
- : -1;
834
+ // Deduplicate by account+org identity (same email in a different org is a
835
+ // distinct account), then by name.
836
+ let idx = config.accounts.findIndex(a => sameIdentity(a, account));
578
837
  if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
579
838
 
580
839
  if (idx >= 0) {
581
- config.accounts[idx] = account;
582
- console.log(`Updated account "${name}"`);
840
+ // Same account+org: refresh credentials and org info, but keep the existing
841
+ // display name and any disk-only fields (e.g. importFrom).
842
+ const prev = config.accounts[idx];
843
+ config.accounts[idx] = { ...prev, ...account, name: prev.name };
844
+ console.log(`Updated account "${prev.name}"`);
583
845
  } else {
846
+ // New org for this person: if another entry shares the accountUuid, the bare
847
+ // email name would collide — disambiguate both with " (org)".
848
+ if (!userNamed && account.accountUuid) {
849
+ const collisions = config.accounts.filter(
850
+ a => a.accountUuid === account.accountUuid && !sameIdentity(a, account)
851
+ );
852
+ if (collisions.length > 0) {
853
+ for (const c of collisions) {
854
+ if (!c.name.includes(' (')) c.name = `${c.name} (${orgLabel(c)})`;
855
+ }
856
+ account.name = `${name} (${orgLabel(account)})`;
857
+ }
858
+ }
584
859
  config.accounts.push(account);
585
- console.log(`Added account "${name}"`);
860
+ console.log(`Added account "${account.name}"`);
586
861
  }
587
862
 
588
863
  await saveConfig(config);
@@ -592,33 +867,94 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
592
867
  // ── config sync helpers ─────────────────────────────────────
593
868
 
594
869
  /**
595
- * Find a config account entry matching an in-memory account (by UUID, then name).
870
+ * Find a config account entry matching an in-memory account by account+org identity.
596
871
  */
597
872
  function findConfigAccount(diskConfig, account) {
598
- if (account.accountUuid) {
599
- const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
600
- if (idx >= 0) return idx;
601
- }
602
- return diskConfig.accounts.findIndex(a => a.name === account.name);
873
+ return diskConfig.accounts.findIndex(a => sameIdentity(a, account));
603
874
  }
604
875
 
605
876
  /**
606
- * Detect accounts added to disk config by external processes and add them
607
- * to the running AccountManager + in-memory config.
877
+ * Sync accounts from disk config: add new accounts and refresh credentials
878
+ * for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
879
+ * Returns the number of new accounts added.
608
880
  */
609
- function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
881
+ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
882
+ let added = 0;
883
+ // Greedy 1:1 pairing of disk entries to in-memory accounts, account+org aware.
884
+ // Each disk entry claims at most one unclaimed manager account, so multiple
885
+ // same-person/different-org entries pair correctly instead of all matching the
886
+ // first one with that accountUuid.
887
+ const claimed = new Set();
888
+ const claim = (diskAcct) => {
889
+ for (let i = 0; i < accountManager.accounts.length; i++) {
890
+ if (!claimed.has(i) && sameIdentity(accountManager.accounts[i], diskAcct)) {
891
+ claimed.add(i);
892
+ return i;
893
+ }
894
+ }
895
+ return -1;
896
+ };
897
+
610
898
  for (const diskAcct of diskConfig.accounts) {
611
- const knownByUuid = diskAcct.accountUuid &&
612
- memConfig.accounts.some(a => a.accountUuid === diskAcct.accountUuid);
613
- const knownByName = memConfig.accounts.some(a => a.name === diskAcct.name);
899
+ const mgrIdx = claim(diskAcct);
614
900
 
615
- if (!knownByUuid && !knownByName) {
901
+ if (mgrIdx < 0) {
616
902
  // New account discovered on disk — add to running server
617
903
  memConfig.accounts.push(diskAcct);
618
904
  accountManager.addAccount(diskAcct);
905
+ claimed.add(accountManager.accounts.length - 1);
906
+ added++;
619
907
  console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
908
+ continue;
909
+ }
910
+
911
+ const mgr = accountManager.accounts[mgrIdx];
912
+
913
+ // Backfill org identity and pick up renames/priority onto the running
914
+ // account (e.g. after disk-side org disambiguation or a `priority` change).
915
+ if (diskAcct.orgUuid && !mgr.orgUuid) mgr.orgUuid = diskAcct.orgUuid;
916
+ if (diskAcct.orgName && !mgr.orgName) mgr.orgName = diskAcct.orgName;
917
+ if (diskAcct.name && mgr.name !== diskAcct.name) mgr.name = diskAcct.name;
918
+ if (diskAcct.priority != null && mgr.priority !== diskAcct.priority) mgr.priority = diskAcct.priority;
919
+ // Pick up enable/disable toggles; re-enabling clears a stuck error state.
920
+ const wantDisabled = !!diskAcct.disabled;
921
+ if (mgr.disabled !== wantDisabled) accountManager.setDisabled(mgr.index, wantDisabled);
922
+
923
+ // Existing account — resolve fresh credentials from disk
924
+ let freshCred = null;
925
+ if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
926
+ try {
927
+ const creds = await importCredentials(diskAcct.importFrom);
928
+ freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
929
+ } catch (err) {
930
+ console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
931
+ }
932
+ } else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
933
+ freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
934
+ } else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
935
+ freshCred = { apiKey: diskAcct.apiKey };
936
+ }
937
+
938
+ if (!freshCred) continue;
939
+
940
+ if (freshCred.accessToken) {
941
+ const changed = mgr.credential !== freshCred.accessToken ||
942
+ mgr.refreshToken !== freshCred.refreshToken;
943
+ // Don't overwrite in-memory credentials with staler ones from disk
944
+ // (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
945
+ const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
946
+ freshCred.expiresAt < mgr.expiresAt;
947
+ if (changed && !diskIsStaler) {
948
+ accountManager.updateAccountTokens(mgr.index, freshCred);
949
+ console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
950
+ }
951
+ } else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
952
+ mgr.credential = freshCred.apiKey;
953
+ if (mgr.status === 'error') mgr.status = 'active';
954
+ console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
620
955
  }
621
956
  }
957
+ return added;
622
958
  }
623
959
 
624
960
  // ── helpers ─────────────────────────────────────────────────
@@ -651,3 +987,32 @@ function argValue(flag) {
651
987
  const i = args.indexOf(flag);
652
988
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
653
989
  }
990
+
991
+ // Quick liveness probe: is something listening on the local proxy port?
992
+ // A successful TCP connect is enough (the proxy is local). Times out fast so a
993
+ // down proxy doesn't add noticeable latency to `claude` launches via the alias.
994
+ function isProxyUp(port, timeout = 600) {
995
+ return new Promise(resolve => {
996
+ const socket = net.connect({ host: '127.0.0.1', port });
997
+ const done = up => { socket.destroy(); resolve(up); };
998
+ socket.setTimeout(timeout);
999
+ socket.once('connect', () => done(true));
1000
+ socket.once('timeout', () => done(false));
1001
+ socket.once('error', () => resolve(false));
1002
+ });
1003
+ }
1004
+
1005
+ function handleServerListenError(err, port) {
1006
+ if (err.code === 'EADDRINUSE') {
1007
+ console.error(`[TeamClaude] Port ${port} is already in use.`);
1008
+ console.error('Another TeamClaude proxy may already be running.');
1009
+ console.error('Check the existing server with: teamclaude status');
1010
+ console.error(`Find the listener with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
1011
+ } else if (err.code === 'EACCES') {
1012
+ console.error(`[TeamClaude] Permission denied while listening on port ${port}.`);
1013
+ console.error('Choose a non-privileged port in the TeamClaude config.');
1014
+ } else {
1015
+ console.error(`[TeamClaude] Failed to listen on port ${port}: ${err.message}`);
1016
+ }
1017
+ process.exit(1);
1018
+ }