@karpeleslab/teamclaude 1.0.6 → 1.0.8

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,11 +2,17 @@
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';
12
+ import { ensureCerts } from './mitm.js';
13
+ import { Prober } from './prober.js';
9
14
  import { TUI } from './tui.js';
15
+ import { SxManager } from './sx.js';
10
16
 
11
17
  const args = process.argv.slice(2);
12
18
  const command = args[0];
@@ -42,10 +48,30 @@ switch (command) {
42
48
  await removeCommand();
43
49
  process.exit(0);
44
50
  break;
51
+ case 'priority':
52
+ await priorityCommand();
53
+ process.exit(0);
54
+ break;
55
+ case 'disable':
56
+ await setDisabledCommand(true);
57
+ process.exit(0);
58
+ break;
59
+ case 'enable':
60
+ await setDisabledCommand(false);
61
+ process.exit(0);
62
+ break;
45
63
  case 'api':
46
64
  await apiCommand();
47
65
  process.exit(0);
48
66
  break;
67
+ case 'alias':
68
+ aliasCommand();
69
+ process.exit(0);
70
+ break;
71
+ case 'probe':
72
+ await probeCommand();
73
+ process.exit(0);
74
+ break;
49
75
  case 'help':
50
76
  case '--help':
51
77
  case '-h':
@@ -89,6 +115,25 @@ async function serverCommand() {
89
115
  const threshold = config.switchThreshold || 0.98;
90
116
  const accountManager = new AccountManager(accounts, threshold);
91
117
 
118
+ // Restore quota observed in a previous run so a restart doesn't lose rotation
119
+ // state (passive — we never call the API to re-learn it). Stale windows are
120
+ // cleared automatically on first use by _clearExpiredQuotas.
121
+ const savedState = await loadState().catch(err => {
122
+ console.error(`[TeamClaude] Could not read saved state: ${err.message}`);
123
+ return null;
124
+ });
125
+ if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
126
+
127
+ // With quota restored, pick the best account up front (highest priority /
128
+ // soonest-resetting weekly window) instead of defaulting to the first one.
129
+ accountManager.selectActiveAccount();
130
+
131
+ // Periodically persist quota (and once more on shutdown) to the state file.
132
+ const persistQuotaState = () =>
133
+ saveState({ quota: accountManager.exportQuotaState() })
134
+ .catch(err => console.error(`[TeamClaude] Failed to save quota state: ${err.message}`));
135
+ let quotaSaveInterval = null;
136
+
92
137
  // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
93
138
  // accounts added externally, e.g. by `teamclaude import` while server is running)
94
139
  accountManager.onTokenRefresh((idx, newTokens) => {
@@ -104,8 +149,7 @@ async function serverCommand() {
104
149
  // Pick up any new accounts from disk so index matching stays correct
105
150
  // (only add, don't refresh credentials — we're about to write the authoritative tokens)
106
151
  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);
152
+ const known = config.accounts.some(a => sameIdentity(a, diskAcct));
109
153
  if (!known) {
110
154
  config.accounts.push(diskAcct);
111
155
  accountManager.addAccount(diskAcct);
@@ -121,14 +165,54 @@ async function serverCommand() {
121
165
  }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
122
166
  });
123
167
  const port = config.proxy.port;
124
- const useTUI = process.stdout.isTTY && process.stdin.isTTY;
168
+ const headless = args.includes('--headless') || args.includes('--no-tui');
169
+ const useTUI = !headless && process.stdout.isTTY && process.stdin.isTTY;
170
+
171
+ // Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
172
+ let prober = null;
173
+
174
+ // sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
175
+ // config.sx.apiKey; when set we provision a proxy and route upstream through it.
176
+ const sx = new SxManager({ log: console.error });
177
+ if (config.sx?.apiKey) {
178
+ const r = await sx.configure(config.sx.apiKey, config.sx.mode);
179
+ if (!r.ok) console.error(`[TeamClaude] sx.org disabled: ${r.error}`);
180
+ } else if (config.sx?.mode) {
181
+ await sx.setMode(config.sx.mode);
182
+ }
183
+
184
+ // Re-sync accounts from disk without a restart. The TUI's 'R' key, the
185
+ // POST /teamclaude/reload endpoint, and the CLI notify after add/change all
186
+ // funnel through here. Returns the number of newly added accounts. Also picks
187
+ // up a changed probe interval so `teamclaude probe` applies live.
188
+ const reloadAccounts = async () => {
189
+ const diskConfig = await loadConfig();
190
+ if (!diskConfig) return 0;
191
+ const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
192
+ // Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
193
+ const diskSxKey = diskConfig.sx?.apiKey || null;
194
+ const diskSxMode = diskConfig.sx?.mode || 'always';
195
+ if (diskSxKey !== sx.apiKey || diskSxMode !== sx.mode) {
196
+ config.sx = diskConfig.sx;
197
+ if (diskSxKey) await sx.configure(diskSxKey, diskSxMode);
198
+ else { sx.disable(); await sx.setMode(diskSxMode); }
199
+ }
200
+ if (prober) {
201
+ const ms = (diskConfig.quotaProbeSeconds || 0) * 1000;
202
+ if (ms !== prober.intervalMs) {
203
+ config.quotaProbeSeconds = diskConfig.quotaProbeSeconds || 0;
204
+ prober.reschedule(ms);
205
+ }
206
+ }
207
+ return added;
208
+ };
125
209
 
126
210
  let tui = null;
127
211
  let hooks = {};
128
212
 
129
213
  if (useTUI) {
130
214
  tui = new TUI({
131
- accountManager, config,
215
+ accountManager, config, sx,
132
216
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
133
217
  // Write in-memory accounts as the authoritative state, preserving
134
218
  // extra disk-only fields (e.g. importFrom) where the account still exists.
@@ -141,18 +225,22 @@ async function serverCommand() {
141
225
  refreshToken: am.refreshToken,
142
226
  expiresAt: am.expiresAt,
143
227
  } : a;
144
- const diskAcct = diskConfig.accounts.find(
145
- d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
146
- );
228
+ const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
147
229
  return diskAcct ? { ...diskAcct, ...live } : live;
148
230
  });
231
+ // Persist sx.org settings (set/cleared from the TUI settings screen).
232
+ if (config.sx) diskConfig.sx = config.sx; else delete diskConfig.sx;
233
+ // Persist other runtime-tunable settings edited from the TUI.
234
+ if (config.switchThreshold != null) diskConfig.switchThreshold = config.switchThreshold;
235
+ if (config.quotaProbeSeconds != null) diskConfig.quotaProbeSeconds = config.quotaProbeSeconds;
149
236
  }),
150
- syncAccounts: async () => {
151
- const diskConfig = await loadConfig();
152
- if (!diskConfig) return 0;
153
- return syncAccountsFromDisk(diskConfig, config, accountManager);
237
+ syncAccounts: reloadAccounts,
238
+ onQuit: async () => {
239
+ prober?.stop();
240
+ if (quotaSaveInterval) clearInterval(quotaSaveInterval);
241
+ await persistQuotaState();
242
+ server.close(() => process.exit(0));
154
243
  },
155
- onQuit: () => { server.close(() => process.exit(0)); },
156
244
  });
157
245
  hooks = {
158
246
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -161,9 +249,21 @@ async function serverCommand() {
161
249
  };
162
250
  }
163
251
 
164
- const server = createProxyServer(accountManager, config, hooks);
252
+ // Expose reload to the proxy's control endpoint (works with or without TUI).
253
+ hooks.reload = reloadAccounts;
254
+
255
+ const server = createProxyServer(accountManager, config, hooks, sx);
256
+ // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
257
+ // remove this handler so a later runtime 'error' isn't misreported as a
258
+ // listen failure and exit the whole proxy.
259
+ const onListenError = err => handleServerListenError(err, port);
260
+ server.once('error', onListenError);
165
261
 
166
262
  server.listen(port, () => {
263
+ // Bind succeeded: stop treating errors as listen failures, but keep a
264
+ // benign runtime handler so a later 'error' is logged rather than thrown.
265
+ server.removeListener('error', onListenError);
266
+ server.on('error', err => console.error(`[TeamClaude] Server error: ${err.message}`));
167
267
  if (tui) {
168
268
  tui.start();
169
269
  console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
@@ -189,15 +289,24 @@ async function serverCommand() {
189
289
  }
190
290
  });
191
291
 
292
+ // Persist quota every minute; unref so it never keeps the process alive.
293
+ quotaSaveInterval = setInterval(persistQuotaState, 60_000);
294
+ quotaSaveInterval.unref?.();
295
+
296
+ // Start the opt-in quota probe (no-op when quotaProbeSeconds is 0).
297
+ prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
298
+ prober.start();
299
+
192
300
  if (!tui) {
193
- process.on('SIGINT', () => {
194
- console.log('\n[TeamClaude] Shutting down...');
195
- server.close(() => process.exit(0));
196
- });
197
- process.on('SIGTERM', () => {
301
+ const shutdown = async () => {
198
302
  console.log('\n[TeamClaude] Shutting down...');
303
+ prober?.stop();
304
+ clearInterval(quotaSaveInterval);
305
+ await persistQuotaState();
199
306
  server.close(() => process.exit(0));
200
- });
307
+ };
308
+ process.on('SIGINT', shutdown);
309
+ process.on('SIGTERM', shutdown);
201
310
  }
202
311
  }
203
312
 
@@ -207,14 +316,36 @@ async function importCommand() {
207
316
  const config = await loadOrCreateConfig();
208
317
 
209
318
  let name = argValue('--name');
210
- const fromPath = argValue('--from') || '~/.claude/.credentials.json';
319
+ const jsonStr = argValue('--json');
211
320
 
212
321
  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);
322
+ if (jsonStr) {
323
+ // Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
324
+ // or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
325
+ try {
326
+ const raw = JSON.parse(jsonStr);
327
+ const data = raw.claudeAiOauth || raw;
328
+ if (!data.accessToken) {
329
+ console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
330
+ process.exit(1);
331
+ }
332
+ creds = {
333
+ accessToken: data.accessToken,
334
+ refreshToken: data.refreshToken,
335
+ expiresAt: data.expiresAt,
336
+ };
337
+ } catch (err) {
338
+ console.error(`Failed to parse --json: ${err.message}`);
339
+ process.exit(1);
340
+ }
341
+ } else {
342
+ const fromPath = argValue('--from') || '~/.claude/.credentials.json';
343
+ try {
344
+ creds = await importCredentials(fromPath);
345
+ } catch (err) {
346
+ console.error(`Failed to import from ${fromPath}: ${err.message}`);
347
+ process.exit(1);
348
+ }
218
349
  }
219
350
 
220
351
  await upsertOAuthAccount(config, name, creds, 'import');
@@ -313,20 +444,46 @@ async function envCommand() {
313
444
  async function runCommand() {
314
445
  const config = await loadOrCreateConfig();
315
446
 
316
- // Everything after 'run' (skip -- separator if present)
317
- const claudeArgs = args.slice(1);
318
- if (claudeArgs[0] === '--') claudeArgs.shift();
447
+ // Args after 'run'. teamclaude flags (e.g. --mitm) are recognized only before
448
+ // an optional `--` separator; everything after `--` goes verbatim to claude.
449
+ const rest = args.slice(1);
450
+ const sep = rest.indexOf('--');
451
+ const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
452
+ const useMitm = tcFlags.includes('--mitm');
453
+ const claudeArgs = sep >= 0 ? rest.slice(sep + 1) : rest.filter(a => a !== '--mitm');
454
+
455
+ // Route through the proxy only when it's actually up; otherwise launch claude
456
+ // directly so a stopped proxy doesn't break `claude`. This is what lets the
457
+ // shell alias (`claude='teamclaude run --'`) be a dumb passthrough.
458
+ const port = config.proxy.port;
459
+ const env = { ...process.env };
460
+ if (await isProxyUp(port)) {
461
+ if (useMitm) {
462
+ // Route ALL of claude's traffic through us as an HTTPS forward proxy, so
463
+ // even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
464
+ // real token injected. claude trusts our MITM leaf via NODE_EXTRA_CA_CERTS.
465
+ const host = upstreamHost(config);
466
+ const { caPath } = await ensureCerts(host);
467
+ const proxyUrl = `http://127.0.0.1:${port}`;
468
+ env.HTTPS_PROXY = env.HTTP_PROXY = env.https_proxy = env.http_proxy = proxyUrl;
469
+ env.NO_PROXY = env.no_proxy = ''; // ensure nothing (esp. the upstream host) bypasses us
470
+ env.NODE_EXTRA_CA_CERTS = caPath;
471
+ delete env.ANTHROPIC_BASE_URL;
472
+ } else {
473
+ // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
474
+ // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
475
+ // lets Claude Code stay in subscription mode (full model access).
476
+ env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
477
+ }
478
+ } else {
479
+ console.error(`[TeamClaude] Proxy not running on port ${port} — launching claude directly (start it with: teamclaude server)`);
480
+ }
319
481
 
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).
323
482
  // Use spawnSync so the Node process blocks entirely — behaves like execvp.
324
483
  const result = spawnSync('claude', claudeArgs, {
325
484
  stdio: 'inherit',
326
- env: {
327
- ...process.env,
328
- ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
329
- },
485
+ shell: process.platform === 'win32',
486
+ env,
330
487
  });
331
488
 
332
489
  if (result.error) {
@@ -359,12 +516,14 @@ async function statusCommand() {
359
516
  const current = acct.name === data.currentAccount ? ' *' : '';
360
517
 
361
518
  console.log(` ${acct.name} (${acct.type})${current}`);
362
- console.log(` Status: ${acct.status}`);
519
+ console.log(` Status: ${acct.status}${acct.disabled ? ' (disabled)' : ''}`);
363
520
 
364
- if (q.unified5h != null || q.unified7d != null) {
521
+ if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
365
522
  const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
366
523
  const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
367
- console.log(` Session: ${ses} used Weekly: ${wk} used`);
524
+ let line = ` Session: ${ses} used Weekly: ${wk} used`;
525
+ if (q.unified7dSonnet != null) line += ` Sonnet7d: ${(q.unified7dSonnet * 100).toFixed(1)}% used`;
526
+ console.log(line);
368
527
  } else {
369
528
  const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
370
529
  const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
@@ -418,31 +577,50 @@ async function accountsCommand() {
418
577
  )
419
578
  );
420
579
 
421
- // Deduplicate by accountUuid keep the last (most recently added) entry
580
+ // Backfill account+org identity from profiles, then deduplicate by
581
+ // (accountUuid, org): the same person in a different org is a distinct
582
+ // account, not a duplicate. Keep the last (most recently added) entry.
422
583
  const seen = new Map();
423
584
  let removed = 0;
585
+ let touched = false;
424
586
  for (let i = config.accounts.length - 1; i >= 0; i--) {
425
587
  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
- }
588
+ const p = profiles[i];
589
+ if (p && !p.error) {
590
+ if (p.accountUuid && a.accountUuid !== p.accountUuid) { a.accountUuid = p.accountUuid; touched = true; }
591
+ if (p.orgUuid && a.orgUuid !== p.orgUuid) { a.orgUuid = p.orgUuid; touched = true; }
592
+ if (p.orgName && a.orgName !== p.orgName) { a.orgName = p.orgName; touched = true; }
593
+ }
594
+ const uuid = a.accountUuid;
595
+ if (!uuid) continue;
596
+ const key = `${uuid}::${orgKey(a) || ''}`;
597
+ if (seen.has(key)) {
598
+ config.accounts.splice(i, 1);
599
+ profiles.splice(i, 1);
600
+ removed++;
601
+ touched = true;
602
+ } else {
603
+ seen.set(key, i);
440
604
  }
441
605
  }
442
- if (removed > 0) {
443
- await saveConfig(config);
444
- console.log(`Removed ${removed} duplicate account(s)\n`);
606
+
607
+ // Name accounts from their email: plain when the person has a single org,
608
+ // "email (Org)" when the same person spans multiple orgs. Names must stay
609
+ // unique — they are the user-facing key for remove/api/selection.
610
+ const orgCount = new Map();
611
+ for (const a of config.accounts) {
612
+ if (a.accountUuid) orgCount.set(a.accountUuid, (orgCount.get(a.accountUuid) || 0) + 1);
445
613
  }
614
+ for (const [i, a] of config.accounts.entries()) {
615
+ const p = profiles[i];
616
+ const email = (p && !p.error && p.email) ? p.email : null;
617
+ if (!email) continue;
618
+ const newName = orgCount.get(a.accountUuid) > 1 ? `${email} (${orgLabel(a)})` : email;
619
+ if (a.name !== newName) { a.name = newName; touched = true; }
620
+ }
621
+
622
+ if (touched) await saveConfig(config);
623
+ if (removed > 0) console.log(`Removed ${removed} duplicate account(s)\n`);
446
624
 
447
625
  for (const [i, a] of config.accounts.entries()) {
448
626
  const p = profiles[i];
@@ -494,7 +672,7 @@ async function apiCommand() {
494
672
  const accounts = await resolveAccounts(config);
495
673
  let account;
496
674
  if (accountName) {
497
- account = accounts.find(a => a.name === accountName);
675
+ account = resolveAccount(accounts, accountName, argValue('--org'));
498
676
  if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
499
677
  } else {
500
678
  account = accounts.find(a => a.type === 'oauth') || accounts[0];
@@ -534,26 +712,163 @@ async function apiCommand() {
534
712
  }
535
713
  }
536
714
 
715
+ // ── alias ───────────────────────────────────────────────────
716
+
717
+ function aliasCommand() {
718
+ const shell = argValue('--shell') || undefined;
719
+ if (args.includes('--uninstall')) {
720
+ alias.uninstallAlias({ shell });
721
+ } else if (args.includes('--install')) {
722
+ alias.installAlias({ shell });
723
+ } else {
724
+ alias.printAlias({ shell });
725
+ }
726
+ }
727
+
728
+ // ── probe ───────────────────────────────────────────────────
729
+
730
+ async function probeCommand() {
731
+ const config = await loadOrCreateConfig();
732
+ const arg = args[1];
733
+
734
+ if (arg === undefined) {
735
+ const cur = config.quotaProbeSeconds || 0;
736
+ console.log(cur > 0 ? `Quota probe: every ${cur}s` : 'Quota probe: off (passive only)');
737
+ console.log('Set with: teamclaude probe <off|seconds> e.g. teamclaude probe 300');
738
+ return;
739
+ }
740
+
741
+ let seconds;
742
+ if (arg === 'off' || arg === '0') {
743
+ seconds = 0;
744
+ } else {
745
+ seconds = parseInt(arg, 10);
746
+ if (Number.isNaN(seconds) || seconds < 0) {
747
+ console.error('Usage: teamclaude probe <off|seconds>');
748
+ process.exit(1);
749
+ }
750
+ if (seconds > 0 && seconds < 30) {
751
+ console.error('Minimum probe interval is 30s (to avoid hammering the usage endpoint).');
752
+ process.exit(1);
753
+ }
754
+ }
755
+
756
+ config.quotaProbeSeconds = seconds;
757
+ await saveConfig(config);
758
+ console.log(seconds > 0
759
+ ? `Quota probe set to every ${seconds}s (reads /api/oauth/usage; does not spend quota).`
760
+ : 'Quota probe disabled (passive only).');
761
+ await notifyRunningServer(config);
762
+ }
763
+
537
764
  // ── remove ──────────────────────────────────────────────────
538
765
 
766
+ /**
767
+ * Resolve a single account from a name-or-email query.
768
+ *
769
+ * An exact display-name match wins. Otherwise match by email (the part before a
770
+ * " (org)" suffix), optionally narrowed by --org. If still ambiguous across
771
+ * orgs, print the candidates and exit so the caller can disambiguate with --org.
772
+ * Returns the matched account, or null if nothing matched.
773
+ */
774
+ function resolveAccount(accounts, query, orgFilter) {
775
+ const matches = matchAccounts(accounts, query, orgFilter);
776
+ if (matches.length === 1) return matches[0];
777
+ if (matches.length === 0) return null;
778
+ console.error(`"${query}" matches ${matches.length} accounts — disambiguate with --org <name|uuid>:`);
779
+ for (const a of matches) {
780
+ console.error(` - ${a.name}${a.orgName ? ` (org: ${a.orgName})` : ''}`);
781
+ }
782
+ process.exit(1);
783
+ }
784
+
539
785
  async function removeCommand() {
540
786
  const config = await loadOrCreateConfig();
541
787
  const name = args[1];
542
788
 
543
789
  if (!name) {
544
- console.error('Usage: teamclaude remove <account-name>');
790
+ console.error('Usage: teamclaude remove <account-name|email> [--org <name|uuid>]');
791
+ process.exit(1);
792
+ }
793
+
794
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
795
+ if (!account) {
796
+ console.error(`Account "${name}" not found`);
797
+ process.exit(1);
798
+ }
799
+
800
+ config.accounts.splice(config.accounts.indexOf(account), 1);
801
+ await saveConfig(config);
802
+ console.log(`Removed account "${account.name}"`);
803
+ }
804
+
805
+ // ── priority ────────────────────────────────────────────────
806
+
807
+ async function priorityCommand() {
808
+ const config = await loadOrCreateConfig();
809
+ const name = args[1];
810
+
811
+ if (!name) {
812
+ console.error('Usage: teamclaude priority <account-name|email> <n> [--org <name|uuid>]');
813
+ console.error(' teamclaude priority <account-name|email> --first | --last');
814
+ console.error('Lower priority is preferred for rotation (default 0).');
815
+ process.exit(1);
816
+ }
817
+
818
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
819
+ if (!account) {
820
+ console.error(`Account "${name}" not found`);
821
+ process.exit(1);
822
+ }
823
+
824
+ const priorities = config.accounts.map(a => a.priority || 0);
825
+ let priority;
826
+ if (args.includes('--first')) {
827
+ priority = Math.min(0, ...priorities) - 1;
828
+ } else if (args.includes('--last')) {
829
+ priority = Math.max(0, ...priorities) + 1;
830
+ } else {
831
+ // Accept the integer in any position (e.g. after --org) — first int-looking token.
832
+ const numTok = args.slice(2).find(t => /^-?\d+$/.test(t));
833
+ priority = numTok != null ? parseInt(numTok, 10) : NaN;
834
+ if (Number.isNaN(priority)) {
835
+ console.error('Provide an integer priority, or --first / --last.');
836
+ process.exit(1);
837
+ }
838
+ }
839
+
840
+ account.priority = priority;
841
+ await saveConfig(config);
842
+ console.log(`Set priority of "${account.name}" to ${priority} (lower = preferred)`);
843
+ await notifyRunningServer(config);
844
+ }
845
+
846
+ // ── enable / disable ────────────────────────────────────────
847
+
848
+ async function setDisabledCommand(disabled) {
849
+ const config = await loadOrCreateConfig();
850
+ const name = args[1];
851
+ const verb = disabled ? 'disable' : 'enable';
852
+
853
+ if (!name) {
854
+ console.error(`Usage: teamclaude ${verb} <account-name|email> [--org <name|uuid>]`);
545
855
  process.exit(1);
546
856
  }
547
857
 
548
- const idx = config.accounts.findIndex(a => a.name === name);
549
- if (idx < 0) {
858
+ const account = resolveAccount(config.accounts, name, argValue('--org'));
859
+ if (!account) {
550
860
  console.error(`Account "${name}" not found`);
551
861
  process.exit(1);
552
862
  }
553
863
 
554
- config.accounts.splice(idx, 1);
864
+ if (disabled) {
865
+ account.disabled = true;
866
+ } else {
867
+ delete account.disabled;
868
+ }
555
869
  await saveConfig(config);
556
- console.log(`Removed account "${name}"`);
870
+ console.log(`${disabled ? 'Disabled' : 'Enabled'} account "${account.name}"`);
871
+ await notifyRunningServer(config);
557
872
  }
558
873
 
559
874
  // ── help ────────────────────────────────────────────────────
@@ -564,22 +879,43 @@ function showHelp() {
564
879
  Usage: teamclaude [command] [options]
565
880
 
566
881
  Commands:
567
- server Start the proxy server (default)
882
+ server Start the proxy server (default; --headless to skip the TUI)
568
883
  import Import credentials from Claude Code
569
884
  login OAuth login via browser
570
885
  login --api Add an API key account
571
886
  env Print env vars to use with Claude
572
- run [-- args...] Run Claude Code through the proxy
887
+ run [--mitm] [-- args...]
888
+ Run Claude Code through the proxy (direct if it's down);
889
+ --mitm routes via an HTTPS forward proxy + local CA so even
890
+ hardcoded api.anthropic.com endpoints are intercepted
891
+ alias Print a shell alias so plain 'claude' routes via the proxy
892
+ (--install to write it to your shell rc; --uninstall to remove)
573
893
  status Show proxy & account status (live)
574
894
  accounts List configured accounts
575
- remove <name> Remove an account
895
+ remove <name> Remove an account (by name or email; --org to disambiguate)
896
+ disable <name> Temporarily exclude an account from rotation
897
+ enable <name> Re-enable a disabled account (also clears a stuck error)
898
+ priority <name> <n> Set rotation priority (lower = preferred; --first/--last)
899
+ probe [off|secs] Opt-in background quota refresh for idle accounts
900
+ (off by default; reads usage endpoint, spends no quota)
576
901
  api <path> Call an API endpoint with account credentials
577
902
  help Show this help
578
903
 
579
904
  Options:
580
905
  --name NAME Set account name (import/login)
906
+ --org NAME|UUID Disambiguate when an email spans multiple orgs (remove/priority/api)
581
907
  --from PATH Credentials path (import, default: ~/.claude/.credentials.json)
908
+ --json JSON Import from inline JSON (import), e.g.:
909
+ --json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
582
910
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
911
+ --headless Run the server without the interactive TUI (for backgrounding)
912
+ --mitm (run) route claude via the HTTPS forward proxy + local CA
913
+
914
+ The server always accepts both base-URL and proxy/CONNECT clients, so instances
915
+ launched with and without --mitm can share one server.
916
+
917
+ A running server re-syncs accounts from config on POST /teamclaude/reload
918
+ (local only). add/login/enable/disable/priority trigger it automatically.
583
919
 
584
920
  Config: ${getConfigPath()}
585
921
  `);
@@ -587,8 +923,14 @@ Config: ${getConfigPath()}
587
923
 
588
924
  // ── shared account upsert ────────────────────────────────────
589
925
 
926
+ /** Short human label for an account's organization, for disambiguating names. */
927
+ function orgLabel(a) {
928
+ return a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
929
+ }
930
+
590
931
  async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
591
- // Fetch profile to auto-name and deduplicate by account UUID
932
+ // Fetch profile to auto-name and deduplicate by account+org identity.
933
+ const userNamed = !!name;
592
934
  const profile = await fetchProfile(creds.accessToken);
593
935
  const profileOk = profile && !profile.error;
594
936
 
@@ -610,40 +952,54 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
610
952
  type: 'oauth',
611
953
  source,
612
954
  accountUuid: profile?.accountUuid || null,
955
+ orgUuid: profile?.orgUuid || null,
956
+ orgName: profile?.orgName || null,
613
957
  accessToken: creds.accessToken,
614
958
  refreshToken: creds.refreshToken,
615
959
  expiresAt: creds.expiresAt,
616
960
  };
617
961
 
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;
962
+ // Deduplicate by account+org identity (same email in a different org is a
963
+ // distinct account), then by name.
964
+ let idx = config.accounts.findIndex(a => sameIdentity(a, account));
622
965
  if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
623
966
 
624
967
  if (idx >= 0) {
625
- config.accounts[idx] = account;
626
- console.log(`Updated account "${name}"`);
968
+ // Same account+org: refresh credentials and org info, but keep the existing
969
+ // display name and any disk-only fields (e.g. importFrom).
970
+ const prev = config.accounts[idx];
971
+ config.accounts[idx] = { ...prev, ...account, name: prev.name };
972
+ console.log(`Updated account "${prev.name}"`);
627
973
  } else {
974
+ // New org for this person: if another entry shares the accountUuid, the bare
975
+ // email name would collide — disambiguate both with " (org)".
976
+ if (!userNamed && account.accountUuid) {
977
+ const collisions = config.accounts.filter(
978
+ a => a.accountUuid === account.accountUuid && !sameIdentity(a, account)
979
+ );
980
+ if (collisions.length > 0) {
981
+ for (const c of collisions) {
982
+ if (!c.name.includes(' (')) c.name = `${c.name} (${orgLabel(c)})`;
983
+ }
984
+ account.name = `${name} (${orgLabel(account)})`;
985
+ }
986
+ }
628
987
  config.accounts.push(account);
629
- console.log(`Added account "${name}"`);
988
+ console.log(`Added account "${account.name}"`);
630
989
  }
631
990
 
632
991
  await saveConfig(config);
633
992
  console.log(`Saved to ${getConfigPath()}`);
993
+ await notifyRunningServer(config);
634
994
  }
635
995
 
636
996
  // ── config sync helpers ─────────────────────────────────────
637
997
 
638
998
  /**
639
- * Find a config account entry matching an in-memory account (by UUID, then name).
999
+ * Find a config account entry matching an in-memory account by account+org identity.
640
1000
  */
641
1001
  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);
1002
+ return diskConfig.accounts.findIndex(a => sameIdentity(a, account));
647
1003
  }
648
1004
 
649
1005
  /**
@@ -653,21 +1009,46 @@ function findConfigAccount(diskConfig, account) {
653
1009
  */
654
1010
  async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
655
1011
  let added = 0;
1012
+ // Greedy 1:1 pairing of disk entries to in-memory accounts, account+org aware.
1013
+ // Each disk entry claims at most one unclaimed manager account, so multiple
1014
+ // same-person/different-org entries pair correctly instead of all matching the
1015
+ // first one with that accountUuid.
1016
+ const claimed = new Set();
1017
+ const claim = (diskAcct) => {
1018
+ for (let i = 0; i < accountManager.accounts.length; i++) {
1019
+ if (!claimed.has(i) && sameIdentity(accountManager.accounts[i], diskAcct)) {
1020
+ claimed.add(i);
1021
+ return i;
1022
+ }
1023
+ }
1024
+ return -1;
1025
+ };
1026
+
656
1027
  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);
1028
+ const mgrIdx = claim(diskAcct);
661
1029
 
662
- if (memIdx < 0) {
1030
+ if (mgrIdx < 0) {
663
1031
  // New account discovered on disk — add to running server
664
1032
  memConfig.accounts.push(diskAcct);
665
1033
  accountManager.addAccount(diskAcct);
1034
+ claimed.add(accountManager.accounts.length - 1);
666
1035
  added++;
667
1036
  console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
668
1037
  continue;
669
1038
  }
670
1039
 
1040
+ const mgr = accountManager.accounts[mgrIdx];
1041
+
1042
+ // Backfill org identity and pick up renames/priority onto the running
1043
+ // account (e.g. after disk-side org disambiguation or a `priority` change).
1044
+ if (diskAcct.orgUuid && !mgr.orgUuid) mgr.orgUuid = diskAcct.orgUuid;
1045
+ if (diskAcct.orgName && !mgr.orgName) mgr.orgName = diskAcct.orgName;
1046
+ if (diskAcct.name && mgr.name !== diskAcct.name) mgr.name = diskAcct.name;
1047
+ if (diskAcct.priority != null && mgr.priority !== diskAcct.priority) mgr.priority = diskAcct.priority;
1048
+ // Pick up enable/disable toggles; re-enabling clears a stuck error state.
1049
+ const wantDisabled = !!diskAcct.disabled;
1050
+ if (mgr.disabled !== wantDisabled) accountManager.setDisabled(mgr.index, wantDisabled);
1051
+
671
1052
  // Existing account — resolve fresh credentials from disk
672
1053
  let freshCred = null;
673
1054
  if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
@@ -685,12 +1066,6 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
685
1066
 
686
1067
  if (!freshCred) continue;
687
1068
 
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
1069
  if (freshCred.accessToken) {
695
1070
  const changed = mgr.credential !== freshCred.accessToken ||
696
1071
  mgr.refreshToken !== freshCred.refreshToken;
@@ -741,3 +1116,58 @@ function argValue(flag) {
741
1116
  const i = args.indexOf(flag);
742
1117
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
743
1118
  }
1119
+
1120
+ // Hostname of the configured upstream (the host MITM-intercepts under `run --mitm`).
1121
+ function upstreamHost(config) {
1122
+ try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
1123
+ catch { return 'api.anthropic.com'; }
1124
+ }
1125
+
1126
+ // Best-effort: tell a running server (if any) to re-sync accounts from config so
1127
+ // CLI changes take effect without a restart. A closed local port refuses the
1128
+ // connection immediately, so this is a no-op (and near-instant) when nothing is
1129
+ // running. Reload picks up new accounts, credential, priority, and enable/disable
1130
+ // changes; account removals still need a restart.
1131
+ async function notifyRunningServer(config) {
1132
+ const port = config?.proxy?.port;
1133
+ if (!port) return;
1134
+ try {
1135
+ const res = await fetch(`http://localhost:${port}/teamclaude/reload`, {
1136
+ method: 'POST',
1137
+ headers: { 'x-api-key': config.proxy?.apiKey || '' },
1138
+ });
1139
+ if (res.ok) {
1140
+ const data = await res.json().catch(() => ({}));
1141
+ console.log(`Reloaded running server${data.added ? ` (+${data.added} new account)` : ''}.`);
1142
+ }
1143
+ } catch { /* no server running — nothing to notify */ }
1144
+ }
1145
+
1146
+ // Quick liveness probe: is something listening on the local proxy port?
1147
+ // A successful TCP connect is enough (the proxy is local). Times out fast so a
1148
+ // down proxy doesn't add noticeable latency to `claude` launches via the alias.
1149
+ function isProxyUp(port, timeout = 600) {
1150
+ return new Promise(resolve => {
1151
+ const socket = net.connect({ host: '127.0.0.1', port });
1152
+ const done = up => { socket.destroy(); resolve(up); };
1153
+ socket.setTimeout(timeout);
1154
+ socket.once('connect', () => done(true));
1155
+ socket.once('timeout', () => done(false));
1156
+ socket.once('error', () => resolve(false));
1157
+ });
1158
+ }
1159
+
1160
+ function handleServerListenError(err, port) {
1161
+ if (err.code === 'EADDRINUSE') {
1162
+ console.error(`[TeamClaude] Port ${port} is already in use.`);
1163
+ console.error('Another TeamClaude proxy may already be running.');
1164
+ console.error('Check the existing server with: teamclaude status');
1165
+ console.error(`Find the listener with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
1166
+ } else if (err.code === 'EACCES') {
1167
+ console.error(`[TeamClaude] Permission denied while listening on port ${port}.`);
1168
+ console.error('Choose a non-privileged port in the TeamClaude config.');
1169
+ } else {
1170
+ console.error(`[TeamClaude] Failed to listen on port ${port}: ${err.message}`);
1171
+ }
1172
+ process.exit(1);
1173
+ }