@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/LICENSE +21 -0
- package/README.md +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
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
|
|
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 =
|
|
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
|
|
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:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
319
|
+
const jsonStr = argValue('--json');
|
|
211
320
|
|
|
212
321
|
let creds;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
427
|
-
if (
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
549
|
-
if (
|
|
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
|
-
|
|
864
|
+
if (disabled) {
|
|
865
|
+
account.disabled = true;
|
|
866
|
+
} else {
|
|
867
|
+
delete account.disabled;
|
|
868
|
+
}
|
|
555
869
|
await saveConfig(config);
|
|
556
|
-
console.log(
|
|
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...]
|
|
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
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
|
999
|
+
* Find a config account entry matching an in-memory account by account+org identity.
|
|
640
1000
|
*/
|
|
641
1001
|
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);
|
|
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
|
|
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 (
|
|
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
|
+
}
|