@karpeleslab/teamclaude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { createInterface } from 'node:readline';
5
+ import { loadOrCreateConfig, saveConfig, getConfigPath } from './config.js';
6
+ import { AccountManager } from './account-manager.js';
7
+ import { createProxyServer } from './server.js';
8
+ import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
9
+ import { TUI } from './tui.js';
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = args[0];
13
+
14
+ switch (command) {
15
+ case 'server':
16
+ await serverCommand();
17
+ break;
18
+ case 'import':
19
+ await importCommand();
20
+ break;
21
+ case 'login':
22
+ await loginCommand();
23
+ break;
24
+ case 'env':
25
+ await envCommand();
26
+ break;
27
+ case 'run':
28
+ await runCommand();
29
+ break;
30
+ case 'status':
31
+ await statusCommand();
32
+ break;
33
+ case 'accounts':
34
+ await accountsCommand();
35
+ break;
36
+ case 'remove':
37
+ await removeCommand();
38
+ break;
39
+ case 'api':
40
+ await apiCommand();
41
+ break;
42
+ case 'help':
43
+ case '--help':
44
+ case '-h':
45
+ showHelp();
46
+ break;
47
+ default:
48
+ // No command or unknown command → start server
49
+ if (command && !command.startsWith('-')) {
50
+ console.error(`Unknown command: ${command}\n`);
51
+ showHelp();
52
+ process.exit(1);
53
+ }
54
+ await serverCommand();
55
+ break;
56
+ }
57
+
58
+ // ── server ──────────────────────────────────────────────────
59
+
60
+ async function serverCommand() {
61
+ const config = await loadOrCreateConfig();
62
+
63
+ // --log-to <dir>
64
+ const logTo = argValue('--log-to');
65
+ if (logTo) config.logDir = logTo;
66
+
67
+ if (config.accounts.length === 0) {
68
+ console.error('No accounts configured.\n');
69
+ console.error('Add an account first:');
70
+ console.error(' teamclaude import Import from Claude Code');
71
+ console.error(' teamclaude login OAuth login via browser');
72
+ console.error(' teamclaude login --api Add an API key');
73
+ process.exit(1);
74
+ }
75
+
76
+ const accounts = await resolveAccounts(config);
77
+ if (accounts.length === 0) {
78
+ console.error('No valid accounts after initialization');
79
+ process.exit(1);
80
+ }
81
+
82
+ const threshold = config.switchThreshold || 0.98;
83
+ const accountManager = new AccountManager(accounts, threshold);
84
+
85
+ // Persist refreshed tokens back to config
86
+ accountManager.onTokenRefresh((idx, newTokens) => {
87
+ const cfgAcct = config.accounts[idx];
88
+ if (cfgAcct) {
89
+ cfgAcct.accessToken = newTokens.accessToken;
90
+ cfgAcct.refreshToken = newTokens.refreshToken;
91
+ cfgAcct.expiresAt = newTokens.expiresAt;
92
+ saveConfig(config).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
93
+ }
94
+ });
95
+ const port = config.proxy.port;
96
+ const useTUI = process.stdout.isTTY && process.stdin.isTTY;
97
+
98
+ let tui = null;
99
+ let hooks = {};
100
+
101
+ if (useTUI) {
102
+ tui = new TUI({
103
+ accountManager, config, saveConfig,
104
+ onQuit: () => { server.close(() => process.exit(0)); },
105
+ });
106
+ hooks = {
107
+ onRequestStart: (id, info) => tui.onRequestStart(id, info),
108
+ onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
109
+ onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
110
+ };
111
+ }
112
+
113
+ const server = createProxyServer(accountManager, config, hooks);
114
+
115
+ server.listen(port, () => {
116
+ if (tui) {
117
+ tui.start();
118
+ console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
119
+ } else {
120
+ const sep = '='.repeat(60);
121
+ console.log('');
122
+ console.log(sep);
123
+ console.log(' TeamClaude Proxy');
124
+ console.log(sep);
125
+ console.log(` Port: ${port}`);
126
+ console.log(` Accounts: ${accounts.length}`);
127
+ console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
128
+ console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
129
+ console.log('');
130
+ accounts.forEach((a, i) => {
131
+ console.log(` [${i + 1}] ${a.name} (${a.type})`);
132
+ });
133
+ console.log('');
134
+ console.log(' Run Claude through proxy: teamclaude run');
135
+ console.log(' Show env vars: teamclaude env');
136
+ console.log(sep);
137
+ console.log('');
138
+ }
139
+ });
140
+
141
+ if (!tui) {
142
+ process.on('SIGINT', () => {
143
+ console.log('\n[TeamClaude] Shutting down...');
144
+ server.close(() => process.exit(0));
145
+ });
146
+ process.on('SIGTERM', () => {
147
+ console.log('\n[TeamClaude] Shutting down...');
148
+ server.close(() => process.exit(0));
149
+ });
150
+ }
151
+ }
152
+
153
+ // ── import ──────────────────────────────────────────────────
154
+
155
+ async function importCommand() {
156
+ const config = await loadOrCreateConfig();
157
+
158
+ let name = argValue('--name');
159
+ const fromPath = argValue('--from') || '~/.claude/.credentials.json';
160
+
161
+ let creds;
162
+ try {
163
+ creds = await importCredentials(fromPath);
164
+ } catch (err) {
165
+ console.error(`Failed to import from ${fromPath}: ${err.message}`);
166
+ process.exit(1);
167
+ }
168
+
169
+ await upsertOAuthAccount(config, name, creds, 'import');
170
+ }
171
+
172
+ // ── login ───────────────────────────────────────────────────
173
+
174
+ async function loginCommand() {
175
+ if (args.includes('--api')) {
176
+ await loginApiCommand();
177
+ return;
178
+ }
179
+ if (args.includes('--oauth')) {
180
+ await loginOAuthCommand();
181
+ return;
182
+ }
183
+
184
+ // Default to OAuth if not a TTY
185
+ if (!process.stdout.isTTY) {
186
+ await loginOAuthCommand();
187
+ return;
188
+ }
189
+
190
+ // Interactive menu
191
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
192
+ console.log('Select login method:\n');
193
+ console.log(' 1. Claude subscription (Pro, Max, Team, Enterprise)');
194
+ console.log(' 2. Anthropic API key (Console API billing)');
195
+ console.log('');
196
+ const choice = await new Promise(resolve => rl.question('Choice [1]: ', resolve));
197
+ rl.close();
198
+
199
+ switch (choice.trim() || '1') {
200
+ case '1': await loginOAuthCommand(); break;
201
+ case '2': await loginApiCommand(); break;
202
+ default:
203
+ console.error(`Invalid choice: ${choice.trim()}`);
204
+ process.exit(1);
205
+ }
206
+ }
207
+
208
+ async function loginApiCommand() {
209
+ const config = await loadOrCreateConfig();
210
+ let name = argValue('--name');
211
+
212
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
213
+ const apiKey = await new Promise(resolve => rl.question('Anthropic API key: ', resolve));
214
+ rl.close();
215
+
216
+ if (!apiKey.trim()) {
217
+ console.error('No API key provided');
218
+ process.exit(1);
219
+ }
220
+
221
+ if (!name) {
222
+ const n = config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
223
+ name = `api-${n}`;
224
+ }
225
+
226
+ config.accounts.push({ name, type: 'apikey', apiKey: apiKey.trim() });
227
+ await saveConfig(config);
228
+ console.log(`Added API key account "${name}"`);
229
+ console.log(`Saved to ${getConfigPath()}`);
230
+ }
231
+
232
+ async function loginOAuthCommand() {
233
+ const config = await loadOrCreateConfig();
234
+ let name = argValue('--name');
235
+
236
+ console.log('Starting OAuth login...');
237
+ let creds;
238
+ try {
239
+ creds = await loginOAuth();
240
+ } catch (err) {
241
+ console.error(`OAuth login failed: ${err.message}`);
242
+ console.error('');
243
+ console.error('Alternatives:');
244
+ console.error(' teamclaude import Import from existing Claude Code credentials');
245
+ console.error(' teamclaude login --api Add an API key instead');
246
+ process.exit(1);
247
+ }
248
+
249
+ await upsertOAuthAccount(config, name, creds, 'login');
250
+ }
251
+
252
+ // ── env ─────────────────────────────────────────────────────
253
+
254
+ async function envCommand() {
255
+ const config = await loadOrCreateConfig();
256
+ console.log(`export ANTHROPIC_BASE_URL=http://localhost:${config.proxy.port}`);
257
+ console.log(`export ANTHROPIC_API_KEY=${config.proxy.apiKey}`);
258
+ }
259
+
260
+ // ── run ─────────────────────────────────────────────────────
261
+
262
+ async function runCommand() {
263
+ const config = await loadOrCreateConfig();
264
+
265
+ // Everything after 'run' (skip -- separator if present)
266
+ const claudeArgs = args.slice(1);
267
+ if (claudeArgs[0] === '--') claudeArgs.shift();
268
+
269
+ const child = spawn('claude', claudeArgs, {
270
+ stdio: 'inherit',
271
+ env: {
272
+ ...process.env,
273
+ ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
274
+ ANTHROPIC_API_KEY: config.proxy.apiKey,
275
+ },
276
+ });
277
+
278
+ child.on('error', (err) => {
279
+ if (err.code === 'ENOENT') {
280
+ console.error('Claude Code not found in PATH. Install it first.');
281
+ } else {
282
+ console.error(`Failed to start claude: ${err.message}`);
283
+ }
284
+ process.exit(1);
285
+ });
286
+
287
+ child.on('exit', (code) => process.exit(code ?? 1));
288
+ }
289
+
290
+ // ── status ──────────────────────────────────────────────────
291
+
292
+ async function statusCommand() {
293
+ const config = await loadOrCreateConfig();
294
+ const url = `http://localhost:${config.proxy.port}/teamclaude/status`;
295
+
296
+ try {
297
+ const res = await fetch(url, { headers: { 'x-api-key': config.proxy.apiKey } });
298
+ const data = await res.json();
299
+
300
+ console.log(`Active account: ${data.currentAccount}`);
301
+ console.log(`Switch at: ${(data.switchThreshold * 100).toFixed(0)}% usage\n`);
302
+
303
+ for (const acct of data.accounts) {
304
+ const q = acct.quota;
305
+ const current = acct.name === data.currentAccount ? ' *' : '';
306
+
307
+ console.log(` ${acct.name} (${acct.type})${current}`);
308
+ console.log(` Status: ${acct.status}`);
309
+
310
+ if (q.unified5h != null || q.unified7d != null) {
311
+ const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
312
+ const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
313
+ console.log(` Session: ${ses} used Weekly: ${wk} used`);
314
+ } else {
315
+ const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
316
+ const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
317
+ console.log(` Tokens: ${tok} used Requests: ${req} used`);
318
+ }
319
+
320
+ console.log(` Total: ${acct.usage.totalInputTokens + acct.usage.totalOutputTokens} tokens, ${acct.usage.totalRequests} requests`);
321
+ if (acct.rateLimitedUntil) console.log(` Throttled until: ${acct.rateLimitedUntil}`);
322
+ console.log('');
323
+ }
324
+ } catch {
325
+ console.error(`Cannot connect to proxy at localhost:${config.proxy.port}`);
326
+ console.error('Is the server running? Start with: teamclaude server');
327
+ process.exit(1);
328
+ }
329
+ }
330
+
331
+ // ── accounts ────────────────────────────────────────────────
332
+
333
+ async function accountsCommand() {
334
+ const config = await loadOrCreateConfig();
335
+
336
+ if (config.accounts.length === 0) {
337
+ console.log('No accounts configured.');
338
+ console.log('Add one with: teamclaude import, teamclaude login, or teamclaude login --api');
339
+ return;
340
+ }
341
+
342
+ // Fetch profiles in parallel for all OAuth accounts
343
+ const profiles = await Promise.all(
344
+ config.accounts.map(a =>
345
+ a.type === 'oauth' && a.accessToken ? fetchProfile(a.accessToken) : null
346
+ )
347
+ );
348
+
349
+ // Deduplicate by accountUuid — keep the last (most recently added) entry
350
+ const seen = new Map();
351
+ let removed = 0;
352
+ for (let i = config.accounts.length - 1; i >= 0; i--) {
353
+ const a = config.accounts[i];
354
+ const uuid = profiles[i]?.accountUuid || a.accountUuid;
355
+ if (uuid) {
356
+ if (seen.has(uuid)) {
357
+ config.accounts.splice(i, 1);
358
+ profiles.splice(i, 1);
359
+ removed++;
360
+ } else {
361
+ seen.set(uuid, i);
362
+ // Update stored UUID and name from profile
363
+ if (profiles[i]) {
364
+ a.accountUuid = profiles[i].accountUuid;
365
+ if (profiles[i].email) a.name = profiles[i].email;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ if (removed > 0) {
371
+ await saveConfig(config);
372
+ console.log(`Removed ${removed} duplicate account(s)\n`);
373
+ }
374
+
375
+ for (const [i, a] of config.accounts.entries()) {
376
+ const p = profiles[i];
377
+
378
+ if (a.type === 'apikey') {
379
+ console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
380
+ continue;
381
+ }
382
+
383
+ // OAuth account
384
+ const tier = p?.hasClaudeMax ? 'Max' : p?.hasClaudePro ? 'Pro' : 'subscription';
385
+ const status = p ? `Claude ${tier}` : 'unknown (profile fetch failed)';
386
+ const src = a.source ? `, ${a.source}` : '';
387
+ console.log(` [${i + 1}] ${a.name} (${status}${src})`);
388
+ if (p?.email && p.email !== a.name) console.log(` Email: ${p.email}`);
389
+ if (p?.orgName) console.log(` Org: ${p.orgName}`);
390
+ }
391
+ }
392
+
393
+ // ── api ─────────────────────────────────────────────────────
394
+
395
+ async function apiCommand() {
396
+ const config = await loadOrCreateConfig();
397
+ const path = args[1];
398
+
399
+ if (!path) {
400
+ console.error('Usage: teamclaude api <path> [--account NAME] [--method POST] [--data JSON]');
401
+ console.error('Example: teamclaude api /api/oauth/claude_cli/roles');
402
+ process.exit(1);
403
+ }
404
+
405
+ // Find account to use
406
+ const accountName = argValue('--account');
407
+ const method = (argValue('--method') || 'GET').toUpperCase();
408
+ const data = argValue('--data');
409
+
410
+ const accounts = await resolveAccounts(config);
411
+ let account;
412
+ if (accountName) {
413
+ account = accounts.find(a => a.name === accountName);
414
+ if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
415
+ } else {
416
+ account = accounts.find(a => a.type === 'oauth') || accounts[0];
417
+ if (!account) { console.error('No accounts configured'); process.exit(1); }
418
+ }
419
+
420
+ const credential = account.accessToken || account.apiKey;
421
+ const isOAuth = account.type === 'oauth';
422
+ const upstream = config.upstream || 'https://api.anthropic.com';
423
+ const url = path.startsWith('http') ? path : `${upstream}${path}`;
424
+
425
+ const headers = isOAuth
426
+ ? { 'Authorization': `Bearer ${credential}` }
427
+ : { 'x-api-key': credential };
428
+
429
+ const fetchOpts = { method, headers };
430
+ if (data) {
431
+ headers['Content-Type'] = 'application/json';
432
+ fetchOpts.body = data;
433
+ }
434
+
435
+ const res = await fetch(url, fetchOpts);
436
+
437
+ // Print response headers to stderr
438
+ console.error(`${res.status} ${res.statusText}`);
439
+ for (const [k, v] of res.headers.entries()) {
440
+ console.error(` ${k}: ${v}`);
441
+ }
442
+ console.error('');
443
+
444
+ // Print body to stdout
445
+ const body = await res.text();
446
+ try {
447
+ console.log(JSON.stringify(JSON.parse(body), null, 2));
448
+ } catch {
449
+ console.log(body);
450
+ }
451
+ }
452
+
453
+ // ── remove ──────────────────────────────────────────────────
454
+
455
+ async function removeCommand() {
456
+ const config = await loadOrCreateConfig();
457
+ const name = args[1];
458
+
459
+ if (!name) {
460
+ console.error('Usage: teamclaude remove <account-name>');
461
+ process.exit(1);
462
+ }
463
+
464
+ const idx = config.accounts.findIndex(a => a.name === name);
465
+ if (idx < 0) {
466
+ console.error(`Account "${name}" not found`);
467
+ process.exit(1);
468
+ }
469
+
470
+ config.accounts.splice(idx, 1);
471
+ await saveConfig(config);
472
+ console.log(`Removed account "${name}"`);
473
+ }
474
+
475
+ // ── help ────────────────────────────────────────────────────
476
+
477
+ function showHelp() {
478
+ console.log(`TeamClaude - Multi-account Claude proxy
479
+
480
+ Usage: teamclaude [command] [options]
481
+
482
+ Commands:
483
+ server Start the proxy server (default)
484
+ import Import credentials from Claude Code
485
+ login OAuth login via browser
486
+ login --api Add an API key account
487
+ env Print env vars to use with Claude
488
+ run [-- args...] Run Claude Code through the proxy
489
+ status Show proxy & account status (live)
490
+ accounts List configured accounts
491
+ remove <name> Remove an account
492
+ api <path> Call an API endpoint with account credentials
493
+ help Show this help
494
+
495
+ Options:
496
+ --name NAME Set account name (import/login)
497
+ --from PATH Credentials path (import, default: ~/.claude/.credentials.json)
498
+ --log-to DIR Log full requests/responses to DIR (server, one file per request)
499
+
500
+ Config: ${getConfigPath()}
501
+ `);
502
+ }
503
+
504
+ // ── shared account upsert ────────────────────────────────────
505
+
506
+ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
507
+ // Fetch profile to auto-name and deduplicate by account UUID
508
+ const profile = await fetchProfile(creds.accessToken);
509
+
510
+ if (!name && profile?.email) {
511
+ name = profile.email;
512
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
513
+ if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
514
+ }
515
+ if (!name) {
516
+ const n = config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
517
+ name = `account-${n}`;
518
+ }
519
+
520
+ const account = {
521
+ name,
522
+ type: 'oauth',
523
+ source,
524
+ accountUuid: profile?.accountUuid || null,
525
+ accessToken: creds.accessToken,
526
+ refreshToken: creds.refreshToken,
527
+ expiresAt: creds.expiresAt,
528
+ };
529
+
530
+ // Deduplicate: match by UUID first, then by name
531
+ let idx = profile?.accountUuid
532
+ ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
533
+ : -1;
534
+ if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
535
+
536
+ if (idx >= 0) {
537
+ config.accounts[idx] = account;
538
+ console.log(`Updated account "${name}"`);
539
+ } else {
540
+ config.accounts.push(account);
541
+ console.log(`Added account "${name}"`);
542
+ }
543
+
544
+ await saveConfig(config);
545
+ console.log(`Saved to ${getConfigPath()}`);
546
+ }
547
+
548
+ // ── helpers ─────────────────────────────────────────────────
549
+
550
+ async function resolveAccounts(config) {
551
+ const accounts = [];
552
+ for (const acct of config.accounts) {
553
+ if (acct.type === 'oauth') {
554
+ if (acct.importFrom) {
555
+ try {
556
+ const creds = await importCredentials(acct.importFrom);
557
+ accounts.push({ name: acct.name, type: 'oauth', ...creds });
558
+ console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
559
+ } catch (err) {
560
+ console.error(`Failed to import "${acct.name}": ${err.message}`);
561
+ }
562
+ } else if (acct.accessToken) {
563
+ accounts.push(acct);
564
+ } else {
565
+ console.error(`No token for "${acct.name}", skipping`);
566
+ }
567
+ } else if (acct.type === 'apikey' && acct.apiKey) {
568
+ accounts.push(acct);
569
+ }
570
+ }
571
+ return accounts;
572
+ }
573
+
574
+ function argValue(flag) {
575
+ const i = args.indexOf(flag);
576
+ return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
577
+ }