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