@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/LICENSE +21 -0
- package/README.md +93 -14
- package/package.json +7 -2
- package/src/account-manager.js +214 -11
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/identity.js +65 -0
- package/src/index.js +353 -78
- package/src/oauth.js +1 -0
- package/src/server.js +23 -3
- package/src/tui.js +66 -13
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
|
|
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 =
|
|
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: () => {
|
|
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
|
-
|
|
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('
|
|
198
|
-
|
|
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
|
|
258
|
+
const jsonStr = argValue('--json');
|
|
211
259
|
|
|
212
260
|
let creds;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
//
|
|
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
|
|
427
|
-
if (
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
549
|
-
if (
|
|
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(
|
|
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
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
|
870
|
+
* Find a config account entry matching an in-memory account by account+org identity.
|
|
640
871
|
*/
|
|
641
872
|
function findConfigAccount(diskConfig, account) {
|
|
642
|
-
|
|
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
|
|
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 (
|
|
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,
|