@karpeleslab/teamclaude 1.0.6 → 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
@@ -2,10 +2,13 @@
2
2
 
3
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
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,6 +108,21 @@ 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) => {
@@ -104,8 +138,7 @@ async function serverCommand() {
104
138
  // Pick up any new accounts from disk so index matching stays correct
105
139
  // (only add, don't refresh credentials — we're about to write the authoritative tokens)
106
140
  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);
141
+ const known = config.accounts.some(a => sameIdentity(a, diskAcct));
109
142
  if (!known) {
110
143
  config.accounts.push(diskAcct);
111
144
  accountManager.addAccount(diskAcct);
@@ -141,9 +174,7 @@ async function serverCommand() {
141
174
  refreshToken: am.refreshToken,
142
175
  expiresAt: am.expiresAt,
143
176
  } : a;
144
- const diskAcct = diskConfig.accounts.find(
145
- d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
146
- );
177
+ const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
147
178
  return diskAcct ? { ...diskAcct, ...live } : live;
148
179
  });
149
180
  }),
@@ -152,7 +183,11 @@ async function serverCommand() {
152
183
  if (!diskConfig) return 0;
153
184
  return syncAccountsFromDisk(diskConfig, config, accountManager);
154
185
  },
155
- onQuit: () => { server.close(() => process.exit(0)); },
186
+ onQuit: async () => {
187
+ if (quotaSaveInterval) clearInterval(quotaSaveInterval);
188
+ await persistQuotaState();
189
+ server.close(() => process.exit(0));
190
+ },
156
191
  });
157
192
  hooks = {
158
193
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -162,8 +197,17 @@ async function serverCommand() {
162
197
  }
163
198
 
164
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);
165
205
 
166
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}`));
167
211
  if (tui) {
168
212
  tui.start();
169
213
  console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
@@ -189,15 +233,19 @@ async function serverCommand() {
189
233
  }
190
234
  });
191
235
 
236
+ // Persist quota every minute; unref so it never keeps the process alive.
237
+ quotaSaveInterval = setInterval(persistQuotaState, 60_000);
238
+ quotaSaveInterval.unref?.();
239
+
192
240
  if (!tui) {
193
- process.on('SIGINT', () => {
241
+ const shutdown = async () => {
194
242
  console.log('\n[TeamClaude] Shutting down...');
243
+ clearInterval(quotaSaveInterval);
244
+ await persistQuotaState();
195
245
  server.close(() => process.exit(0));
196
- });
197
- process.on('SIGTERM', () => {
198
- console.log('\n[TeamClaude] Shutting down...');
199
- server.close(() => process.exit(0));
200
- });
246
+ };
247
+ process.on('SIGINT', shutdown);
248
+ process.on('SIGTERM', shutdown);
201
249
  }
202
250
  }
203
251
 
@@ -207,14 +255,36 @@ async function importCommand() {
207
255
  const config = await loadOrCreateConfig();
208
256
 
209
257
  let name = argValue('--name');
210
- const fromPath = argValue('--from') || '~/.claude/.credentials.json';
258
+ const jsonStr = argValue('--json');
211
259
 
212
260
  let creds;
213
- try {
214
- creds = await importCredentials(fromPath);
215
- } catch (err) {
216
- console.error(`Failed to import from ${fromPath}: ${err.message}`);
217
- 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
+ }
218
288
  }
219
289
 
220
290
  await upsertOAuthAccount(config, name, creds, 'import');
@@ -317,16 +387,25 @@ async function runCommand() {
317
387
  const claudeArgs = args.slice(1);
318
388
  if (claudeArgs[0] === '--') claudeArgs.shift();
319
389
 
320
- // Only set ANTHROPIC_BASE_URL Claude Code keeps its own OAuth token
321
- // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
322
- // lets Claude Code stay in subscription mode (full model access).
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
+
323
404
  // Use spawnSync so the Node process blocks entirely — behaves like execvp.
324
405
  const result = spawnSync('claude', claudeArgs, {
325
406
  stdio: 'inherit',
326
- env: {
327
- ...process.env,
328
- ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
329
- },
407
+ shell: process.platform === 'win32',
408
+ env,
330
409
  });
331
410
 
332
411
  if (result.error) {
@@ -359,7 +438,7 @@ async function statusCommand() {
359
438
  const current = acct.name === data.currentAccount ? ' *' : '';
360
439
 
361
440
  console.log(` ${acct.name} (${acct.type})${current}`);
362
- console.log(` Status: ${acct.status}`);
441
+ console.log(` Status: ${acct.status}${acct.disabled ? ' (disabled)' : ''}`);
363
442
 
364
443
  if (q.unified5h != null || q.unified7d != null) {
365
444
  const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
@@ -418,32 +497,51 @@ async function accountsCommand() {
418
497
  )
419
498
  );
420
499
 
421
- // 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.
422
503
  const seen = new Map();
423
504
  let removed = 0;
505
+ let touched = false;
424
506
  for (let i = config.accounts.length - 1; i >= 0; i--) {
425
507
  const a = config.accounts[i];
426
- const uuid = profiles[i]?.accountUuid || a.accountUuid;
427
- if (uuid) {
428
- if (seen.has(uuid)) {
429
- config.accounts.splice(i, 1);
430
- profiles.splice(i, 1);
431
- removed++;
432
- } else {
433
- seen.set(uuid, i);
434
- // Update stored UUID and name from profile
435
- if (profiles[i] && !profiles[i].error) {
436
- a.accountUuid = profiles[i].accountUuid;
437
- if (profiles[i].email) a.name = profiles[i].email;
438
- }
439
- }
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; }
440
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);
441
533
  }
442
- if (removed > 0) {
443
- await saveConfig(config);
444
- 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; }
445
540
  }
446
541
 
542
+ if (touched) await saveConfig(config);
543
+ if (removed > 0) console.log(`Removed ${removed} duplicate account(s)\n`);
544
+
447
545
  for (const [i, a] of config.accounts.entries()) {
448
546
  const p = profiles[i];
449
547
 
@@ -494,7 +592,7 @@ async function apiCommand() {
494
592
  const accounts = await resolveAccounts(config);
495
593
  let account;
496
594
  if (accountName) {
497
- account = accounts.find(a => a.name === accountName);
595
+ account = resolveAccount(accounts, accountName, argValue('--org'));
498
596
  if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
499
597
  } else {
500
598
  account = accounts.find(a => a.type === 'oauth') || accounts[0];
@@ -534,26 +632,128 @@ async function apiCommand() {
534
632
  }
535
633
  }
536
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
+
537
648
  // ── remove ──────────────────────────────────────────────────
538
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
+
539
669
  async function removeCommand() {
540
670
  const config = await loadOrCreateConfig();
541
671
  const name = args[1];
542
672
 
543
673
  if (!name) {
544
- console.error('Usage: teamclaude remove <account-name>');
674
+ console.error('Usage: teamclaude remove <account-name|email> [--org <name|uuid>]');
545
675
  process.exit(1);
546
676
  }
547
677
 
548
- const idx = config.accounts.findIndex(a => a.name === name);
549
- if (idx < 0) {
678
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
679
+ if (!account) {
550
680
  console.error(`Account "${name}" not found`);
551
681
  process.exit(1);
552
682
  }
553
683
 
554
- config.accounts.splice(idx, 1);
684
+ config.accounts.splice(config.accounts.indexOf(account), 1);
555
685
  await saveConfig(config);
556
- console.log(`Removed account "${name}"`);
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).');
699
+ process.exit(1);
700
+ }
701
+
702
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
703
+ if (!account) {
704
+ console.error(`Account "${name}" not found`);
705
+ process.exit(1);
706
+ }
707
+
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;
725
+ await saveConfig(config);
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
+ }
557
757
  }
558
758
 
559
759
  // ── help ────────────────────────────────────────────────────
@@ -569,16 +769,24 @@ Commands:
569
769
  login OAuth login via browser
570
770
  login --api Add an API key account
571
771
  env Print env vars to use with Claude
572
- 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)
573
775
  status Show proxy & account status (live)
574
776
  accounts List configured accounts
575
- 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)
576
781
  api <path> Call an API endpoint with account credentials
577
782
  help Show this help
578
783
 
579
784
  Options:
580
785
  --name NAME Set account name (import/login)
786
+ --org NAME|UUID Disambiguate when an email spans multiple orgs (remove/priority/api)
581
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}'
582
790
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
583
791
 
584
792
  Config: ${getConfigPath()}
@@ -587,8 +795,14 @@ Config: ${getConfigPath()}
587
795
 
588
796
  // ── shared account upsert ────────────────────────────────────
589
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
+
590
803
  async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
591
- // 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;
592
806
  const profile = await fetchProfile(creds.accessToken);
593
807
  const profileOk = profile && !profile.error;
594
808
 
@@ -610,23 +824,40 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
610
824
  type: 'oauth',
611
825
  source,
612
826
  accountUuid: profile?.accountUuid || null,
827
+ orgUuid: profile?.orgUuid || null,
828
+ orgName: profile?.orgName || null,
613
829
  accessToken: creds.accessToken,
614
830
  refreshToken: creds.refreshToken,
615
831
  expiresAt: creds.expiresAt,
616
832
  };
617
833
 
618
- // Deduplicate: match by UUID first, then by name
619
- let idx = profile?.accountUuid
620
- ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
621
- : -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));
622
837
  if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
623
838
 
624
839
  if (idx >= 0) {
625
- config.accounts[idx] = account;
626
- 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}"`);
627
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
+ }
628
859
  config.accounts.push(account);
629
- console.log(`Added account "${name}"`);
860
+ console.log(`Added account "${account.name}"`);
630
861
  }
631
862
 
632
863
  await saveConfig(config);
@@ -636,14 +867,10 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
636
867
  // ── config sync helpers ─────────────────────────────────────
637
868
 
638
869
  /**
639
- * 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.
640
871
  */
641
872
  function findConfigAccount(diskConfig, account) {
642
- if (account.accountUuid) {
643
- const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
644
- if (idx >= 0) return idx;
645
- }
646
- return diskConfig.accounts.findIndex(a => a.name === account.name);
873
+ return diskConfig.accounts.findIndex(a => sameIdentity(a, account));
647
874
  }
648
875
 
649
876
  /**
@@ -653,21 +880,46 @@ function findConfigAccount(diskConfig, account) {
653
880
  */
654
881
  async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
655
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
+
656
898
  for (const diskAcct of diskConfig.accounts) {
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);
899
+ const mgrIdx = claim(diskAcct);
661
900
 
662
- if (memIdx < 0) {
901
+ if (mgrIdx < 0) {
663
902
  // New account discovered on disk — add to running server
664
903
  memConfig.accounts.push(diskAcct);
665
904
  accountManager.addAccount(diskAcct);
905
+ claimed.add(accountManager.accounts.length - 1);
666
906
  added++;
667
907
  console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
668
908
  continue;
669
909
  }
670
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
+
671
923
  // Existing account — resolve fresh credentials from disk
672
924
  let freshCred = null;
673
925
  if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
@@ -685,12 +937,6 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
685
937
 
686
938
  if (!freshCred) continue;
687
939
 
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
940
  if (freshCred.accessToken) {
695
941
  const changed = mgr.credential !== freshCred.accessToken ||
696
942
  mgr.refreshToken !== freshCred.refreshToken;
@@ -741,3 +987,32 @@ function argValue(flag) {
741
987
  const i = args.indexOf(flag);
742
988
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
743
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
+ }
package/src/oauth.js CHANGED
@@ -129,6 +129,7 @@ export async function fetchProfile(accessToken) {
129
129
  accountUuid: data.account?.uuid,
130
130
  email: data.account?.email,
131
131
  name: data.account?.display_name,
132
+ orgUuid: data.organization?.uuid,
132
133
  orgName: data.organization?.name,
133
134
  orgType: data.organization?.organization_type,
134
135
  hasClaudeMax: data.account?.has_claude_max,