teamclaude-cloud 1.4.1

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,1726 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { unlinkSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { createInterface } from 'node:readline';
6
+ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync } from './config.js';
7
+ import { AccountManager } from './account-manager.js';
8
+ import { createProxyServer } from './server.js';
9
+ import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
+ import { cloudLogin, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
11
+ import { TUI } from './tui.js';
12
+
13
+ const args = process.argv.slice(2);
14
+ const command = args[0];
15
+
16
+ // This package's npm name (used by the self-update path). Declared at module top so
17
+ // the hoisted self-update functions can reference it even when the top-level command
18
+ // switch runs before their later definitions (const is NOT hoisted → avoid a TDZ error).
19
+ const PKG_NAME = 'teamclaude-cloud';
20
+
21
+ switch (command) {
22
+ case 'server':
23
+ await serverCommand();
24
+ break;
25
+ case 'stop':
26
+ await stopCommand();
27
+ process.exit(0);
28
+ break;
29
+ case 'restart':
30
+ await restartCommand();
31
+ break;
32
+ case 'run':
33
+ await runCommand();
34
+ break;
35
+ case 'import':
36
+ await importCommand();
37
+ process.exit(0);
38
+ break;
39
+ case 'login':
40
+ await loginCommand();
41
+ process.exit(0);
42
+ break;
43
+ case 'env':
44
+ await envCommand();
45
+ process.exit(0);
46
+ break;
47
+ case 'status':
48
+ await statusCommand();
49
+ process.exit(0);
50
+ break;
51
+ case 'accounts':
52
+ await accountsCommand();
53
+ process.exit(0);
54
+ break;
55
+ case 'remove':
56
+ await removeCommand();
57
+ process.exit(0);
58
+ break;
59
+ case 'disable':
60
+ await setEnabledCommand(false);
61
+ process.exit(0);
62
+ break;
63
+ case 'enable':
64
+ await setEnabledCommand(true);
65
+ process.exit(0);
66
+ break;
67
+ case 'priority':
68
+ await setPriorityCommand();
69
+ process.exit(0);
70
+ break;
71
+ case 'api':
72
+ await apiCommand();
73
+ process.exit(0);
74
+ break;
75
+ case 'cloud':
76
+ await cloudCommand();
77
+ process.exit(0);
78
+ break;
79
+ case 'update':
80
+ await updateCommand();
81
+ process.exit(0);
82
+ break;
83
+ case 'version':
84
+ case '--version':
85
+ case '-v':
86
+ console.log(currentVersion() || 'unknown');
87
+ process.exit(0);
88
+ break;
89
+ case 'help':
90
+ case '--help':
91
+ case '-h':
92
+ showHelp();
93
+ break;
94
+ default:
95
+ // No command or unknown command → start server
96
+ if (command && !command.startsWith('-')) {
97
+ console.error(`Unknown command: ${command}\n`);
98
+ showHelp();
99
+ process.exit(1);
100
+ }
101
+ await serverCommand();
102
+ break;
103
+ }
104
+
105
+ // ── server ──────────────────────────────────────────────────
106
+
107
+ async function serverCommand() {
108
+ const config = await loadOrCreateConfig();
109
+ updateNotifier(config); // non-blocking: prints a one-line notice if a newer version is on npm
110
+
111
+ // --log-to <dir>
112
+ const logTo = argValue('--log-to');
113
+ if (logTo) config.logDir = logTo;
114
+
115
+ if (config.accounts.length === 0) {
116
+ console.error('No accounts configured.\n');
117
+ console.error('Add an account first:');
118
+ console.error(' teamclaude import Import from Claude Code');
119
+ console.error(' teamclaude login OAuth login via browser');
120
+ console.error(' teamclaude login --api Add an API key');
121
+ process.exit(1);
122
+ }
123
+
124
+ const accounts = await resolveAccounts(config);
125
+ if (accounts.length === 0) {
126
+ console.error('No valid accounts after initialization');
127
+ process.exit(1);
128
+ }
129
+
130
+ const threshold = config.switchThreshold || 0.98;
131
+ // An explicit numeric `reevalIntervalMs: 0` (or any number <= 0) disables the
132
+ // 5-minute periodic account re-switching. Require a finite number so a
133
+ // malformed value (false, "", "abc", null, ...) falls back to the default
134
+ // rather than silently disabling switching.
135
+ const reevalIntervalMs = Number.isFinite(config.reevalIntervalMs)
136
+ ? config.reevalIntervalMs
137
+ : 5 * 60 * 1000;
138
+ // Default per-account concurrency cap (max simultaneous in-flight requests an
139
+ // account handles before load spreads to the next account). A per-account
140
+ // `maxConcurrent` overrides this. Must be a positive number, else default 3.
141
+ const maxConcurrentDefault = Number.isFinite(config.maxConcurrentPerAccount) && config.maxConcurrentPerAccount >= 1
142
+ ? config.maxConcurrentPerAccount
143
+ : 3;
144
+ // Hard cap on the overflow wait-queue (requests waiting for a free slot when
145
+ // every account is at its cap). Bounds memory/FDs under a request flood.
146
+ const overflowQueueMaxDepth = Number.isFinite(config.overflowQueueMaxDepth) && config.overflowQueueMaxDepth >= 0
147
+ ? config.overflowQueueMaxDepth
148
+ : 256;
149
+ const accountManager = new AccountManager(accounts, threshold, reevalIntervalMs, maxConcurrentDefault, overflowQueueMaxDepth);
150
+
151
+ // Restore the last run's quota snapshot so a restart doesn't blank the
152
+ // dashboard (quota otherwise lives only in memory and is re-learned from
153
+ // traffic). Stale-safe: the proxy takes no traffic while down, expired
154
+ // windows are lazily swept, and a still-future throttle is re-applied.
155
+ const quotaCache = await readQuotaCache();
156
+ if (quotaCache?.accounts) {
157
+ accountManager.importQuotaState(quotaCache.accounts);
158
+ // Restore the active-account marker too (identity by name) so the sticky
159
+ // primary — and its warm prompt cache — carries across the restart.
160
+ const cur = quotaCache.currentAccount
161
+ && accountManager.accounts.find(a => a.name === quotaCache.currentAccount);
162
+ if (cur) accountManager.currentIndex = cur.index;
163
+ console.log(`[TeamClaude] Restored quota snapshot for ${quotaCache.accounts.length} account(s)`);
164
+ }
165
+ const saveQuotaSnapshot = () => writeQuotaCacheSync({
166
+ savedAt: new Date().toISOString(),
167
+ currentAccount: accountManager.accounts[accountManager.currentIndex]?.name || null,
168
+ accounts: accountManager.exportQuotaState(),
169
+ // The committed warm-up probe template — persisting it lets forced
170
+ // re-measure (TUI R) and warm-up probes work immediately after a restart,
171
+ // before any traffic re-seeds a known-accepted request shape. (`server` is
172
+ // initialized before this ever runs: the snapshot writers are registered
173
+ // inside the listen callback.)
174
+ probeTemplate: server.exportProbeTemplate?.() ?? null,
175
+ });
176
+
177
+ // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
178
+ // accounts added externally, e.g. by `teamclaude import` while server is running)
179
+ accountManager.onTokenRefresh((idx, newTokens) => {
180
+ const account = accountManager.accounts[idx];
181
+ if (!account) return;
182
+ // Keep the in-memory config.accounts in sync so a TUI saveConfig doesn't
183
+ // clobber fresh tokens. Match by IDENTITY (UUID first, then name) — not by the
184
+ // AccountManager index `idx`, which can point at a different config entry when
185
+ // a tokenless config account was skipped at load.
186
+ const memIdx = findConfigAccount(config, account);
187
+ if (memIdx >= 0) {
188
+ config.accounts[memIdx].accessToken = newTokens.accessToken;
189
+ config.accounts[memIdx].refreshToken = newTokens.refreshToken;
190
+ config.accounts[memIdx].expiresAt = newTokens.expiresAt;
191
+ }
192
+ atomicConfigUpdate(diskConfig => {
193
+ // Persist ONLY the refreshed account's tokens. We deliberately do NOT ingest
194
+ // disk-only accounts here: that loop existed to keep the old INDEX-based
195
+ // matching aligned, but matching is identity-based now (findConfigAccount),
196
+ // so it's unnecessary — and re-adding every disk account would resurrect one
197
+ // the TUI just deleted (whose save may not have committed yet). External
198
+ // account discovery stays in syncAccountsFromDisk (TUI R / restart).
199
+ // Match by UUID first, then by name — index may have shifted.
200
+ const cfgIdx = findConfigAccount(diskConfig, account);
201
+ if (cfgIdx >= 0) {
202
+ diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
203
+ diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
204
+ diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
205
+ }
206
+ }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
207
+ // Admin auto-push: if this machine can push (has a cloud login session), upload
208
+ // the freshly-ROTATED token to the cloud immediately. Anthropic invalidates the
209
+ // previous refresh token on every refresh, so a cloud snapshot from an earlier
210
+ // push holds a now-dead refresh token — a consumer that (re-)pulls it gets an
211
+ // account it can't refresh ("error"). Pushing on every rotation keeps the cloud
212
+ // token valid, so remove/re-add from a group restores a WORKING token, not a
213
+ // stale one. Best-effort: cloud problems never affect the proxy.
214
+ maybePushRefreshedToken(config, account, newTokens);
215
+ });
216
+
217
+ // Cloud-centralized refresh: on a consumer (has a pull key), route token
218
+ // refreshes through the cloud so the SAME account used from multiple PCs is not
219
+ // rotated by each PC independently (which invalidates the others). The cloud does
220
+ // the single rotation; this PC only receives the fresh access token. Falls back
221
+ // to a local refresh if the cloud is unreachable (availability over strict
222
+ // single-authority — a brief local rotation is the accepted tradeoff).
223
+ if (config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY) {
224
+ accountManager.setRefreshHandler(async (account) => {
225
+ const cloud = resolveCloud(config);
226
+ const key = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
227
+ if (key && account.accountUuid) {
228
+ try {
229
+ const t = await cloudRefreshToken(cloud, key, account.accountUuid);
230
+ return {
231
+ accessToken: t.accessToken,
232
+ refreshToken: t.refreshToken || account.refreshToken,
233
+ expiresAt: t.expiresAt,
234
+ };
235
+ } catch (err) {
236
+ console.error(`[TeamClaude] Cloud refresh failed for "${account.name}", falling back to local: ${err.message}`);
237
+ }
238
+ }
239
+ return refreshAccessToken(account.refreshToken); // local fallback
240
+ });
241
+ }
242
+
243
+ const port = config.proxy.port;
244
+ const useTUI = process.stdout.isTTY && process.stdin.isTTY;
245
+
246
+ let tui = null;
247
+ let hooks = {};
248
+
249
+ if (useTUI) {
250
+ tui = new TUI({
251
+ accountManager, config,
252
+ saveConfig: () => atomicConfigUpdate(async diskConfig => {
253
+ // Write in-memory accounts as the authoritative state, preserving
254
+ // extra disk-only fields (e.g. importFrom) where the account still exists.
255
+ // Use live tokens from AccountManager (not the stale config.accounts copy).
256
+ const mapped = config.accounts.map(a => {
257
+ // Match the live account by IDENTITY — never by array index:
258
+ // resolveAccounts() can skip a tokenless/bad config entry, so
259
+ // config.accounts and accountManager.accounts are not index-aligned, and
260
+ // an index map would overlay the wrong account's credentials. Two-phase
261
+ // (UUID first, then name): a single `uuid===x || name===x` find could
262
+ // return an earlier same-name account before reaching the real UUID match.
263
+ const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
264
+ || accountManager.accounts.find(x => x.name === a.name);
265
+ const live = am ? {
266
+ ...a,
267
+ accessToken: am.credential,
268
+ refreshToken: am.refreshToken,
269
+ expiresAt: am.expiresAt,
270
+ } : a;
271
+ const diskAcct = (a.accountUuid && diskConfig.accounts.find(d => d.accountUuid === a.accountUuid))
272
+ || diskConfig.accounts.find(d => d.name === a.name);
273
+ return diskAcct ? { ...diskAcct, ...live } : live;
274
+ });
275
+ // The TUI's in-memory config is authoritative for the account SET (an
276
+ // account it deleted must stay deleted, not be resurrected from the disk
277
+ // copy this atomic update re-read). An account added to disk by an external
278
+ // `teamclaude import/login` while the TUI runs is reconciled on the next
279
+ // reload (R) / restart via syncAccountsFromDisk — not merged here, since we
280
+ // can't distinguish "added externally" from "deleted locally" at save time.
281
+ diskConfig.accounts = mapped;
282
+ }),
283
+ syncAccounts: async () => {
284
+ const diskConfig = await loadConfig();
285
+ if (!diskConfig) return 0;
286
+ return syncAccountsFromDisk(diskConfig, config, accountManager);
287
+ },
288
+ // R also forces a fleet-wide quota re-measure. `server` is assigned below
289
+ // (before listen), and the TUI only starts inside the listen callback, so
290
+ // this closure never runs before the binding is initialized.
291
+ refreshQuota: () => server.refreshQuotaAll(),
292
+ onQuit: () => { server.close(() => process.exit(0)); },
293
+ });
294
+ hooks = {
295
+ onRequestStart: (id, info) => tui.onRequestStart(id, info),
296
+ onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
297
+ onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
298
+ };
299
+ }
300
+
301
+ // If a TeamClaude server is already running on this config's port, don't try to
302
+ // bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
303
+ const existing = await findRunningServer(config);
304
+ if (existing && existing.port === port) {
305
+ console.error(`[TeamClaude] A server is already running on port ${port}${existing.pid ? ` (pid ${existing.pid})` : ''}.`);
306
+ console.error(' See it: teamclaude status');
307
+ console.error(' Stop it: teamclaude stop');
308
+ console.error(' Restart it: teamclaude restart');
309
+ process.exit(1);
310
+ }
311
+
312
+ const server = createProxyServer(accountManager, config, hooks);
313
+ // Restore the last run's committed probe template alongside the quota
314
+ // snapshot, so forced re-measure (TUI R) and warm-up probes work on a
315
+ // freshly restarted idle proxy — without this the template is memory-only
316
+ // and R reports "no request has flowed" until real traffic re-seeds it.
317
+ if (quotaCache?.probeTemplate && server.importProbeTemplate?.(quotaCache.probeTemplate)) {
318
+ console.log('[TeamClaude] Restored warm-up probe template');
319
+ }
320
+ // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
321
+ // remove this handler so a later runtime 'error' isn't misreported as a
322
+ // listen failure and exit the whole proxy.
323
+ const onListenError = err => handleServerListenError(err, port);
324
+ server.once('error', onListenError);
325
+
326
+ server.listen(port, () => {
327
+ server.removeListener('error', onListenError);
328
+ // Record runtime state so `teamclaude status/stop/restart` can find us, and
329
+ // remove it on process exit (covers SIGINT/SIGTERM/TUI quit/normal exit). A
330
+ // SIGKILL leaves a stale file, which stop/server detect as dead and clean up.
331
+ writeServerState({ pid: process.pid, port, startedAt: new Date().toISOString(), config: getConfigPath() }).catch(() => {});
332
+ const stateP = getServerStatePath();
333
+ process.on('exit', () => { try { unlinkSync(stateP); } catch { /* already gone */ } });
334
+ // Persist the quota snapshot on every exit path (TUI quit, SIGINT/SIGTERM
335
+ // → server.close → process.exit) and every minute as a crash backstop
336
+ // (a SIGKILL loses at most the last interval). The 'exit' write is sync.
337
+ process.on('exit', saveQuotaSnapshot);
338
+ setInterval(saveQuotaSnapshot, 60_000).unref();
339
+
340
+ // Live cloud sync: if this proxy was populated from the cloud (a stored pull
341
+ // key), keep it in sync with the cloud while running — token/group changes and
342
+ // key revocation apply automatically, no restart. Polling (zero-dep); cloud
343
+ // outage is non-blocking (transient errors keep the last-known accounts).
344
+ const cloudKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
345
+ const cloudSyncIntervalMs = Number.isFinite(config.cloudSyncIntervalMs)
346
+ ? Math.max(0, config.cloudSyncIntervalMs)
347
+ : 30_000; // 0 disables
348
+ if (cloudKey && cloudSyncIntervalMs > 0) {
349
+ const stopCloudSync = startCloudSyncLoop(config, accountManager, resolveCloud(config), cloudKey, cloudSyncIntervalMs);
350
+ process.on('exit', () => { try { stopCloudSync(); } catch { /* nothing to do on exit */ } });
351
+ console.log(`[TeamClaude] Cloud live-sync every ${Math.round(cloudSyncIntervalMs / 1000)}s (token/group changes + key revocation apply automatically)`);
352
+ }
353
+
354
+ // Live usage sync: the proxies that actually USE the accounts report the live
355
+ // 5h/7d/Fable numbers they observe, so the dashboard reflects real-time usage —
356
+ // not only when `cloud push` is run by hand. PRIMARY path: the consumer's pull
357
+ // key IS the credential (same as pull/refresh) — no owner session needed.
358
+ // Fallback: an owner cloud session (admin machine without a key). Usage-only:
359
+ // never touches tokens; unknown/other-group accounts are skipped server-side.
360
+ // Best-effort — a cloud outage never affects the running proxy. Skips a tick
361
+ // when the measured usage is unchanged since the last report (idle fleet).
362
+ if (cloudKey || config.cloud?.session?.access_token) {
363
+ const usageIntervalMs = Number.isFinite(config.cloudUsagePushIntervalMs)
364
+ ? Math.max(0, config.cloudUsagePushIntervalMs)
365
+ : 30_000; // 0 disables
366
+ if (usageIntervalMs > 0) {
367
+ let lastUsageJson = '';
368
+ const pushUsage = async () => {
369
+ try {
370
+ const usageByUuid = {};
371
+ for (const entry of accountManager.exportQuotaState()) {
372
+ if (!entry.accountUuid) continue;
373
+ const u = buildUsageFromQuota(entry);
374
+ if (u) usageByUuid[entry.accountUuid] = u;
375
+ }
376
+ if (!Object.keys(usageByUuid).length) return; // nothing measured yet
377
+ const snapshot = JSON.stringify(usageByUuid);
378
+ if (snapshot === lastUsageJson) return; // unchanged since last report — skip
379
+ if (cloudKey) {
380
+ await cloudReportUsage(resolveCloud(config), cloudKey, usageByUuid);
381
+ } else {
382
+ const cloud = await ensureCloudSession(config).catch(() => config.cloud);
383
+ await cloudPushUsage(cloud, usageByUuid);
384
+ }
385
+ lastUsageJson = snapshot;
386
+ } catch (err) {
387
+ console.error(`[TeamClaude] Cloud usage sync failed: ${err.message}`);
388
+ }
389
+ };
390
+ const usageTimer = setInterval(pushUsage, usageIntervalMs);
391
+ usageTimer.unref();
392
+ process.on('exit', () => { try { clearInterval(usageTimer); } catch { /* nothing to do on exit */ } });
393
+ setImmediate(pushUsage); // seed once at startup so the dashboard is fresh immediately
394
+ console.log(`[TeamClaude] Cloud usage sync every ${Math.round(usageIntervalMs / 1000)}s via ${cloudKey ? 'auth key' : 'owner session'} (real-time usage, tokens untouched)`);
395
+ }
396
+ }
397
+
398
+ if (tui) {
399
+ tui.start();
400
+ console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
401
+ } else {
402
+ const sep = '='.repeat(60);
403
+ console.log('');
404
+ console.log(sep);
405
+ console.log(' TeamClaude Proxy');
406
+ console.log(sep);
407
+ console.log(` Port: ${port}`);
408
+ console.log(` Accounts: ${accounts.length}`);
409
+ console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
410
+ console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
411
+ console.log('');
412
+ accounts.forEach((a, i) => {
413
+ console.log(` [${i + 1}] ${a.name} (${a.type})`);
414
+ });
415
+ console.log('');
416
+ console.log(' Run Claude through proxy: teamclaude run');
417
+ console.log(' Show env vars: teamclaude env');
418
+ console.log(sep);
419
+ console.log('');
420
+ }
421
+ });
422
+
423
+ if (!tui) {
424
+ process.on('SIGINT', () => {
425
+ console.log('\n[TeamClaude] Shutting down...');
426
+ server.close(() => process.exit(0));
427
+ });
428
+ process.on('SIGTERM', () => {
429
+ console.log('\n[TeamClaude] Shutting down...');
430
+ server.close(() => process.exit(0));
431
+ });
432
+ }
433
+ }
434
+
435
+ // ── server lifecycle: discover / stop / restart ─────────────
436
+
437
+ // Function declaration (not a const arrow) so it is hoisted — these helpers run
438
+ // from the top-level command switch, which executes before later `const` lines
439
+ // in this module are initialized (temporal dead zone).
440
+ function delay(ms) {
441
+ return new Promise(resolve => setTimeout(resolve, ms));
442
+ }
443
+
444
+ /** Is a pid alive? EPERM = alive but not ours; ESRCH = gone. */
445
+ function isPidAlive(pid) {
446
+ if (!pid) return false;
447
+ try { process.kill(pid, 0); return true; }
448
+ catch (e) { return e.code === 'EPERM'; }
449
+ }
450
+
451
+ async function waitForExit(pid, timeoutMs) {
452
+ const deadline = Date.now() + timeoutMs;
453
+ while (Date.now() < deadline) {
454
+ if (!isPidAlive(pid)) return true;
455
+ await delay(150);
456
+ }
457
+ return !isPidAlive(pid);
458
+ }
459
+
460
+ /**
461
+ * Does a *TeamClaude* proxy answer on this port? Verifies the status endpoint
462
+ * returns our JSON shape, not just any 200 — so a foreign process occupying the
463
+ * port is NOT mistaken for our server (it falls through to the EADDRINUSE path).
464
+ */
465
+ async function probeServer(port, timeoutMs = 1500) {
466
+ if (!port) return false;
467
+ const ctrl = new AbortController();
468
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
469
+ try {
470
+ const res = await fetch(`http://127.0.0.1:${port}/teamclaude/status`, { signal: ctrl.signal });
471
+ if (!res.ok) return false;
472
+ const data = await res.json();
473
+ return Array.isArray(data?.accounts) && typeof data?.switchThreshold === 'number';
474
+ } catch { return false; }
475
+ finally { clearTimeout(timer); }
476
+ }
477
+
478
+ /** Best-effort: the pid listening on a TCP port (macOS/Linux via lsof). */
479
+ function lsofPid(port) {
480
+ if (process.platform === 'win32') return null;
481
+ try {
482
+ const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
483
+ const pid = parseInt((r.stdout || '').trim().split('\n')[0], 10);
484
+ return Number.isInteger(pid) ? pid : null;
485
+ } catch { return null; }
486
+ }
487
+
488
+ /**
489
+ * Locate a running TeamClaude server for this config's port, returning the pid
490
+ * that ACTUALLY owns the listening socket — never a pid taken on faith from the
491
+ * state file. That matters because a state file can be stale (the recorded pid
492
+ * died and the OS recycled it for an unrelated process) or hand-written; trusting
493
+ * it would let `stop` signal the wrong pid. So: confirm a TeamClaude-shaped server
494
+ * answers on the port, then resolve the owner via `lsof`. The state file is only a
495
+ * fallback for the pid when lsof can't determine it (and only if it's alive and
496
+ * for this same port). Returns { pid, port } (pid may be null if undeterminable),
497
+ * or null when nothing is listening.
498
+ */
499
+ async function findRunningServer(config) {
500
+ const configPort = config?.proxy?.port;
501
+ const state = await readServerState();
502
+
503
+ // Try the port the server ACTUALLY bound (recorded in the state file) first —
504
+ // it may differ from the current config port after the config was edited, and
505
+ // probing only the config port would miss (and then orphan) the live server.
506
+ const candidates = [];
507
+ if (state?.port) candidates.push(state.port);
508
+ if (configPort && configPort !== state?.port) candidates.push(configPort);
509
+
510
+ for (const port of candidates) {
511
+ if (!(await probeServer(port))) continue;
512
+ const ownerPid = lsofPid(port); // authoritative: who actually holds the socket
513
+ if (ownerPid) return { pid: ownerPid, port };
514
+ // lsof unavailable: trust the recorded pid only if alive AND recorded for THIS port.
515
+ if (state?.pid && state.port === port && isPidAlive(state.pid)) return { pid: state.pid, port };
516
+ return { pid: null, port };
517
+ }
518
+
519
+ // Nothing TeamClaude-shaped answers on any candidate port. Only drop the state
520
+ // file if its recorded pid is also gone — don't delete the discovery record for
521
+ // a server that's merely unreachable for a moment.
522
+ if (state && !(state.pid && isPidAlive(state.pid))) await clearServerState();
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * Stop the running server: SIGTERM, wait for graceful exit, escalate to SIGKILL.
528
+ * Returns { stopped, reason?, pid?, port? }.
529
+ */
530
+ async function stopRunningServer() {
531
+ const config = await loadConfig();
532
+ if (!config) return { stopped: false, reason: 'not-running' };
533
+
534
+ const found = await findRunningServer(config);
535
+ if (!found) { await clearServerState(); return { stopped: false, reason: 'not-running' }; }
536
+
537
+ const { pid, port } = found;
538
+ if (!pid) return { stopped: false, reason: 'no-pid', port };
539
+
540
+ try {
541
+ process.kill(pid, 'SIGTERM');
542
+ } catch (e) {
543
+ if (e.code === 'ESRCH') { await clearServerState(); return { stopped: true, pid, port }; }
544
+ if (e.code === 'EPERM') return { stopped: false, reason: 'eperm', pid, port };
545
+ throw e;
546
+ }
547
+
548
+ if (!(await waitForExit(pid, 6000))) {
549
+ try { process.kill(pid, 'SIGKILL'); } catch { /* may have just exited */ }
550
+ await waitForExit(pid, 2000);
551
+ }
552
+ if (isPidAlive(pid)) return { stopped: false, reason: 'failed', pid, port };
553
+
554
+ await clearServerState();
555
+ return { stopped: true, pid, port };
556
+ }
557
+
558
+ async function stopCommand() {
559
+ const r = await stopRunningServer();
560
+ if (r.stopped) {
561
+ console.log(`Stopped TeamClaude server (pid ${r.pid}, port ${r.port}).`);
562
+ return;
563
+ }
564
+ switch (r.reason) {
565
+ case 'not-running':
566
+ console.log('No TeamClaude server is running.');
567
+ return;
568
+ case 'no-pid':
569
+ console.error(`A server is responding on port ${r.port} but its PID is unknown (lsof unavailable).`);
570
+ console.error(`Stop it once with: kill $(lsof -nP -iTCP:${r.port} -sTCP:LISTEN -t)`);
571
+ process.exit(1);
572
+ break;
573
+ case 'eperm':
574
+ console.error(`No permission to signal pid ${r.pid}.`);
575
+ process.exit(1);
576
+ break;
577
+ default:
578
+ console.error(`Failed to stop pid ${r.pid} on port ${r.port}.`);
579
+ process.exit(1);
580
+ }
581
+ }
582
+
583
+ async function restartCommand() {
584
+ const r = await stopRunningServer();
585
+ if (r.stopped) {
586
+ console.log(`Stopped previous server (pid ${r.pid}).`);
587
+ } else if (r.reason !== 'not-running') {
588
+ console.error(`Could not stop the existing server (${r.reason}); aborting restart.`);
589
+ if (r.reason === 'no-pid') {
590
+ console.error(`Stop it manually first: kill $(lsof -nP -iTCP:${r.port} -sTCP:LISTEN -t)`);
591
+ }
592
+ process.exit(1);
593
+ }
594
+ // Wait for the port to be released before re-binding.
595
+ const port = (await loadConfig())?.proxy?.port;
596
+ for (let i = 0; i < 20 && await probeServer(port, 500); i++) await delay(150);
597
+ await serverCommand();
598
+ }
599
+
600
+ // ── import ──────────────────────────────────────────────────
601
+
602
+ async function importCommand() {
603
+ const config = await loadOrCreateConfig();
604
+
605
+ let name = argValue('--name');
606
+ const jsonStr = argValue('--json');
607
+
608
+ let creds;
609
+ if (jsonStr) {
610
+ // Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
611
+ // or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
612
+ try {
613
+ const raw = JSON.parse(jsonStr);
614
+ const data = raw.claudeAiOauth || raw;
615
+ if (!data.accessToken) {
616
+ console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
617
+ process.exit(1);
618
+ }
619
+ creds = {
620
+ accessToken: data.accessToken,
621
+ refreshToken: data.refreshToken,
622
+ expiresAt: data.expiresAt,
623
+ };
624
+ } catch (err) {
625
+ console.error(`Failed to parse --json: ${err.message}`);
626
+ process.exit(1);
627
+ }
628
+ } else {
629
+ const fromPath = argValue('--from') || '~/.claude/.credentials.json';
630
+ try {
631
+ creds = await importCredentials(fromPath);
632
+ } catch (err) {
633
+ console.error(`Failed to import from ${fromPath}: ${err.message}`);
634
+ process.exit(1);
635
+ }
636
+ }
637
+
638
+ await upsertOAuthAccount(config, name, creds, 'import');
639
+ }
640
+
641
+ // ── login ───────────────────────────────────────────────────
642
+
643
+ async function loginCommand() {
644
+ if (args.includes('--api')) {
645
+ await loginApiCommand();
646
+ return;
647
+ }
648
+ if (args.includes('--oauth')) {
649
+ await loginOAuthCommand();
650
+ return;
651
+ }
652
+
653
+ // Default to OAuth if not a TTY
654
+ if (!process.stdout.isTTY) {
655
+ await loginOAuthCommand();
656
+ return;
657
+ }
658
+
659
+ // Interactive menu
660
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
661
+ console.log('Select login method:\n');
662
+ console.log(' 1. Claude subscription (Pro, Max, Team, Enterprise)');
663
+ console.log(' 2. Anthropic API key (Console API billing)');
664
+ console.log('');
665
+ const choice = await new Promise(resolve => rl.question('Choice [1]: ', resolve));
666
+ rl.close();
667
+
668
+ switch (choice.trim() || '1') {
669
+ case '1': await loginOAuthCommand(); break;
670
+ case '2': await loginApiCommand(); break;
671
+ default:
672
+ console.error(`Invalid choice: ${choice.trim()}`);
673
+ process.exit(1);
674
+ }
675
+ }
676
+
677
+ async function loginApiCommand() {
678
+ const config = await loadOrCreateConfig();
679
+ let name = argValue('--name');
680
+
681
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
682
+ const apiKey = await new Promise(resolve => rl.question('Anthropic API key: ', resolve));
683
+ rl.close();
684
+
685
+ if (!apiKey.trim()) {
686
+ console.error('No API key provided');
687
+ process.exit(1);
688
+ }
689
+
690
+ if (!name) {
691
+ // First FREE api-N (not `count + 1`, which collides after a delete) — a unique
692
+ // name is the identity key for credential-less API-key accounts.
693
+ let n = 1;
694
+ do { name = `api-${n++}`; } while (config.accounts.some(a => a.name === name));
695
+ }
696
+
697
+ config.accounts.push({ name, type: 'apikey', apiKey: apiKey.trim() });
698
+ await saveConfig(config);
699
+ console.log(`Added API key account "${name}"`);
700
+ console.log(`Saved to ${getConfigPath()}`);
701
+ }
702
+
703
+ async function loginOAuthCommand() {
704
+ const config = await loadOrCreateConfig();
705
+ let name = argValue('--name');
706
+
707
+ console.log('Starting OAuth login...');
708
+ let creds;
709
+ try {
710
+ creds = await loginOAuth();
711
+ } catch (err) {
712
+ console.error(`OAuth login failed: ${err.message}`);
713
+ console.error('');
714
+ console.error('Alternatives:');
715
+ console.error(' teamclaude import Import from existing Claude Code credentials');
716
+ console.error(' teamclaude login --api Add an API key instead');
717
+ process.exit(1);
718
+ }
719
+
720
+ await upsertOAuthAccount(config, name, creds, 'login');
721
+ }
722
+
723
+ // ── env ─────────────────────────────────────────────────────
724
+
725
+ async function envCommand() {
726
+ const config = await loadOrCreateConfig();
727
+ console.log(`export ANTHROPIC_BASE_URL=http://localhost:${config.proxy.port}`);
728
+ console.log(`export ANTHROPIC_API_KEY=${config.proxy.apiKey}`);
729
+ }
730
+
731
+ // ── run ─────────────────────────────────────────────────────
732
+
733
+ async function runCommand() {
734
+ const config = await loadOrCreateConfig();
735
+
736
+ // Everything after 'run' (skip -- separator if present)
737
+ const claudeArgs = args.slice(1);
738
+ if (claudeArgs[0] === '--') claudeArgs.shift();
739
+
740
+ // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
741
+ // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
742
+ // lets Claude Code stay in subscription mode (full model access).
743
+ // Use spawnSync so the Node process blocks entirely — behaves like execvp.
744
+ const result = spawnSync('claude', claudeArgs, {
745
+ stdio: 'inherit',
746
+ env: {
747
+ ...process.env,
748
+ ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
749
+ },
750
+ });
751
+
752
+ if (result.error) {
753
+ if (result.error.code === 'ENOENT') {
754
+ console.error('Claude Code not found in PATH. Install it first.');
755
+ } else {
756
+ console.error(`Failed to start claude: ${result.error.message}`);
757
+ }
758
+ process.exit(1);
759
+ }
760
+
761
+ process.exit(result.status ?? 1);
762
+ }
763
+
764
+ // ── status ──────────────────────────────────────────────────
765
+
766
+ async function statusCommand() {
767
+ const config = await loadOrCreateConfig();
768
+ // Locate the actual running server (its bound port may differ from the current
769
+ // config port after an edit). findRunningServer handles stale-state cleanup; do
770
+ // NOT clear state here, or a momentary blip would orphan a live server.
771
+ const running = await findRunningServer(config);
772
+ if (!running) {
773
+ console.log(`Server: not running (no proxy on port ${config.proxy.port})`);
774
+ console.log('Start it with: teamclaude server');
775
+ process.exit(1);
776
+ }
777
+ const url = `http://127.0.0.1:${running.port}/teamclaude/status`;
778
+
779
+ try {
780
+ const res = await fetch(url, { headers: { 'x-api-key': config.proxy.apiKey } });
781
+ const data = await res.json();
782
+
783
+ const pidStr = running.pid ? `pid ${running.pid}, ` : '';
784
+ console.log(`Server: running (${pidStr}port ${running.port})`);
785
+ console.log(`Active account: ${data.currentAccount}`);
786
+ console.log(`Switch at: ${(data.switchThreshold * 100).toFixed(0)}% usage\n`);
787
+
788
+ for (const acct of data.accounts) {
789
+ const q = acct.quota;
790
+ const current = acct.name === data.currentAccount ? ' *' : '';
791
+
792
+ const disabledTag = acct.enabled === false ? ' [disabled]' : '';
793
+ console.log(` ${acct.name} (${acct.type})${current}${disabledTag}`);
794
+ console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
795
+ if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
796
+ if (acct.maxConcurrent != null) {
797
+ console.log(` In flight: ${acct.inflight ?? 0}/${acct.maxConcurrent} concurrent`);
798
+ }
799
+
800
+ if (q.unified5h != null || q.unified7d != null || Object.keys(q.modelWeekly ?? {}).length > 0) {
801
+ const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
802
+ const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
803
+ let line = ` Session: ${ses} used Weekly: ${wk} used`;
804
+ // Model-scoped weekly windows (7d_oi = the Fable weekly limit). Guarded
805
+ // with ?. so a status from an older running server (no modelWeekly
806
+ // field) still prints. Unknown labels print as-is.
807
+ for (const [label, w] of Object.entries(q.modelWeekly ?? {})) {
808
+ if (w?.utilization == null) continue;
809
+ const name = label === '7d_oi' ? 'Fable wk' : label;
810
+ line += ` ${name}: ${(w.utilization * 100).toFixed(1)}% used`;
811
+ }
812
+ console.log(line);
813
+ } else {
814
+ const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
815
+ const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
816
+ console.log(` Tokens: ${tok} used Requests: ${req} used`);
817
+ }
818
+
819
+ console.log(` Total: ${acct.usage.totalInputTokens + acct.usage.totalOutputTokens} tokens, ${acct.usage.totalRequests} requests`);
820
+ if (acct.rateLimitedUntil) console.log(` Throttled until: ${acct.rateLimitedUntil}`);
821
+ console.log('');
822
+ }
823
+ } catch {
824
+ // findRunningServer just confirmed a server answered; a failure here is a
825
+ // transient blip, not a reason to delete the discovery record.
826
+ console.log(`Server: unreachable (port ${running.port}) — try again`);
827
+ process.exit(1);
828
+ }
829
+ }
830
+
831
+ // ── accounts ────────────────────────────────────────────────
832
+
833
+ async function accountsCommand() {
834
+ const config = await loadOrCreateConfig();
835
+ const verbose = args.includes('-v') || args.includes('--verbose');
836
+
837
+ if (config.accounts.length === 0) {
838
+ console.log('No accounts configured.');
839
+ console.log('Add one with: teamclaude import, teamclaude login, or teamclaude login --api');
840
+ return;
841
+ }
842
+
843
+ // Refresh expired tokens before fetching profiles
844
+ let configDirty = false;
845
+ await Promise.all(config.accounts.map(async (a) => {
846
+ if (a.type !== 'oauth' || !a.refreshToken) return;
847
+ if (!isTokenExpiringSoon(a.expiresAt)) return;
848
+ try {
849
+ const newTokens = await refreshAccessToken(a.refreshToken);
850
+ a.accessToken = newTokens.accessToken;
851
+ a.refreshToken = newTokens.refreshToken;
852
+ a.expiresAt = newTokens.expiresAt;
853
+ configDirty = true;
854
+ } catch (err) {
855
+ // refresh failed — fetchProfile will report the specific error
856
+ }
857
+ }));
858
+ if (configDirty) await saveConfig(config);
859
+
860
+ // Fetch profiles in parallel for all OAuth accounts
861
+ const profiles = await Promise.all(
862
+ config.accounts.map(a =>
863
+ a.type === 'oauth' && a.accessToken ? fetchProfile(a.accessToken) : null
864
+ )
865
+ );
866
+
867
+ // Deduplicate by accountUuid — keep the last (most recently added) entry
868
+ const seen = new Map();
869
+ let removed = 0;
870
+ for (let i = config.accounts.length - 1; i >= 0; i--) {
871
+ const a = config.accounts[i];
872
+ const uuid = profiles[i]?.accountUuid || a.accountUuid;
873
+ if (uuid) {
874
+ if (seen.has(uuid)) {
875
+ config.accounts.splice(i, 1);
876
+ profiles.splice(i, 1);
877
+ removed++;
878
+ } else {
879
+ seen.set(uuid, i);
880
+ // Update stored UUID and name from profile
881
+ if (profiles[i] && !profiles[i].error) {
882
+ a.accountUuid = profiles[i].accountUuid;
883
+ if (profiles[i].email) a.name = profiles[i].email;
884
+ }
885
+ }
886
+ }
887
+ }
888
+ if (removed > 0) {
889
+ await saveConfig(config);
890
+ console.log(`Removed ${removed} duplicate account(s)\n`);
891
+ }
892
+
893
+ for (const [i, a] of config.accounts.entries()) {
894
+ const p = profiles[i];
895
+
896
+ if (a.type === 'apikey') {
897
+ console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
898
+ continue;
899
+ }
900
+
901
+ // OAuth account
902
+ const hasProfile = p && !p.error;
903
+ const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
904
+ const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
905
+ const src = a.source ? `, ${a.source}` : '';
906
+ console.log(` [${i + 1}] ${a.name} (${status}${src})`);
907
+ if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
908
+ if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
909
+ if (verbose && a.expiresAt) {
910
+ const remaining = a.expiresAt - Date.now();
911
+ if (remaining <= 0) {
912
+ console.log(` Token: expired`);
913
+ } else {
914
+ const mins = Math.floor(remaining / 60000);
915
+ const hrs = Math.floor(mins / 60);
916
+ const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
917
+ console.log(` Token: expires in ${expiry}`);
918
+ }
919
+ }
920
+ }
921
+ }
922
+
923
+ // ── api ─────────────────────────────────────────────────────
924
+
925
+ async function apiCommand() {
926
+ const config = await loadOrCreateConfig();
927
+ const path = args[1];
928
+
929
+ if (!path) {
930
+ console.error('Usage: teamclaude api <path> [--account NAME] [--method POST] [--data JSON]');
931
+ console.error('Example: teamclaude api /api/oauth/claude_cli/roles');
932
+ process.exit(1);
933
+ }
934
+
935
+ // Find account to use
936
+ const accountName = argValue('--account');
937
+ const method = (argValue('--method') || 'GET').toUpperCase();
938
+ const data = argValue('--data');
939
+
940
+ const accounts = await resolveAccounts(config);
941
+ let account;
942
+ if (accountName) {
943
+ account = accounts.find(a => a.name === accountName);
944
+ if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
945
+ } else {
946
+ account = accounts.find(a => a.type === 'oauth') || accounts[0];
947
+ if (!account) { console.error('No accounts configured'); process.exit(1); }
948
+ }
949
+
950
+ const credential = account.accessToken || account.apiKey;
951
+ const isOAuth = account.type === 'oauth';
952
+ const upstream = config.upstream || 'https://api.anthropic.com';
953
+ const url = path.startsWith('http') ? path : `${upstream}${path}`;
954
+
955
+ const headers = isOAuth
956
+ ? { 'Authorization': `Bearer ${credential}` }
957
+ : { 'x-api-key': credential };
958
+
959
+ const fetchOpts = { method, headers };
960
+ if (data) {
961
+ headers['Content-Type'] = 'application/json';
962
+ fetchOpts.body = data;
963
+ }
964
+
965
+ const res = await fetch(url, fetchOpts);
966
+
967
+ // Print response headers to stderr
968
+ console.error(`${res.status} ${res.statusText}`);
969
+ for (const [k, v] of res.headers.entries()) {
970
+ console.error(` ${k}: ${v}`);
971
+ }
972
+ console.error('');
973
+
974
+ // Print body to stdout
975
+ const body = await res.text();
976
+ try {
977
+ console.log(JSON.stringify(JSON.parse(body), null, 2));
978
+ } catch {
979
+ console.log(body);
980
+ }
981
+ }
982
+
983
+ // ── remove ──────────────────────────────────────────────────
984
+
985
+ async function removeCommand() {
986
+ const config = await loadOrCreateConfig();
987
+ const name = args[1];
988
+
989
+ if (!name) {
990
+ console.error('Usage: teamclaude remove <account-name>');
991
+ process.exit(1);
992
+ }
993
+
994
+ const idx = config.accounts.findIndex(a => a.name === name);
995
+ if (idx < 0) {
996
+ console.error(`Account "${name}" not found`);
997
+ process.exit(1);
998
+ }
999
+
1000
+ config.accounts.splice(idx, 1);
1001
+ await saveConfig(config);
1002
+ console.log(`Removed account "${name}"`);
1003
+ }
1004
+
1005
+ // ── enable / disable / priority ─────────────────────────────
1006
+
1007
+ /** Note that changes apply to a running server only after a reload/restart. */
1008
+ function noteRunningServerReload(config) {
1009
+ return findRunningServer(config).then(running => {
1010
+ if (running) {
1011
+ console.log('A server is running — apply now with: teamclaude restart');
1012
+ console.log(' (or press "R" in the TUI to reload from config).');
1013
+ }
1014
+ }).catch(() => {});
1015
+ }
1016
+
1017
+ async function setEnabledCommand(enabled) {
1018
+ const name = args[1];
1019
+ if (!name) {
1020
+ console.error(`Usage: teamclaude ${enabled ? 'enable' : 'disable'} <account-name>`);
1021
+ process.exit(1);
1022
+ }
1023
+ // atomicConfigUpdate re-reads disk before writing, so a concurrent token
1024
+ // refresh from the running server (or another CLI edit) isn't clobbered by a
1025
+ // stale snapshot. Match by name (what the user typed) within the fresh copy.
1026
+ let found = false;
1027
+ const config = await atomicConfigUpdate(cfg => {
1028
+ const acct = cfg.accounts.find(a => a.name === name);
1029
+ if (acct) { acct.enabled = enabled; found = true; }
1030
+ });
1031
+ if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
1032
+ console.log(`${enabled ? 'Enabled' : 'Disabled'} account "${name}"`);
1033
+ if (!enabled) console.log(' (excluded from active rotation; in-flight requests still finish)');
1034
+ await noteRunningServerReload(config);
1035
+ }
1036
+
1037
+ async function setPriorityCommand() {
1038
+ const name = args[1];
1039
+ const raw = args[2];
1040
+ if (!name || raw === undefined) {
1041
+ console.error('Usage: teamclaude priority <account-name> <number|auto>');
1042
+ console.error(' Lower number = preferred first. Use "auto" (or "clear") to return the');
1043
+ console.error(' account to automatic ordering: weekly reset soonest is drained first.');
1044
+ process.exit(1);
1045
+ }
1046
+ const clearing = raw === 'auto' || raw === 'clear' || raw === 'none' || raw === 'null';
1047
+ let value = null;
1048
+ if (!clearing) {
1049
+ const n = Number(raw);
1050
+ if (!Number.isFinite(n)) { console.error(`Invalid priority "${raw}" — expected a number or "auto"`); process.exit(1); }
1051
+ value = Math.floor(n);
1052
+ }
1053
+ let found = false;
1054
+ const config = await atomicConfigUpdate(cfg => {
1055
+ const acct = cfg.accounts.find(a => a.name === name);
1056
+ if (acct) { found = true; if (clearing) delete acct.priority; else acct.priority = value; }
1057
+ });
1058
+ if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
1059
+ console.log(clearing
1060
+ ? `Set "${name}" to auto (use-or-lose ordering: weekly reset soonest first)`
1061
+ : `Set priority of "${name}" to ${value} (lower = preferred first)`);
1062
+ await noteRunningServerReload(config);
1063
+ }
1064
+
1065
+ // ── help ────────────────────────────────────────────────────
1066
+
1067
+ function showHelp() {
1068
+ console.log(`TeamClaude - Multi-account Claude proxy
1069
+
1070
+ Usage: teamclaude [command] [options]
1071
+
1072
+ Commands:
1073
+ server Start the proxy server (default)
1074
+ stop Stop the running proxy server
1075
+ restart Stop the running server (if any) and start a fresh one
1076
+ import Import credentials from Claude Code
1077
+ login OAuth login via browser
1078
+ login --api Add an API key account
1079
+ env Print env vars to use with Claude
1080
+ run [-- args...] Run Claude Code through the proxy
1081
+ status Show proxy & account status (live)
1082
+ accounts List configured accounts
1083
+ remove <name> Remove an account
1084
+ disable <name> Disable an account (excluded from rotation)
1085
+ enable <name> Re-enable a disabled account
1086
+ priority <name> <n> Set selection priority (lower = preferred; "auto" to return
1087
+ to automatic ordering — weekly reset soonest drained first)
1088
+ api <path> Call an API endpoint with account credentials
1089
+ cloud <sub> Cloud SaaS: sync tokens + group/auth-key based token control
1090
+ cloud login --url <u> --anon-key <k> --email <e> [--password <p>]
1091
+ cloud push Push local tokens (+usage snapshot) to the cloud
1092
+ cloud pull --key <auth-key> Pull the key's group's tokens into local config
1093
+ cloud key create [--group g] | list | revoke <id> | move <id> --group <g>
1094
+ cloud group list | create <n> | rename <n> --to <n2> | delete <n>
1095
+ | allow <n> --accounts uuid1,uuid2 (order = priority)
1096
+ cloud account enable|disable <accountUuid|localName> (global)
1097
+ cloud status
1098
+ update Update to the latest published version (npm)
1099
+ version Print the installed version
1100
+ help Show this help
1101
+
1102
+ Options:
1103
+ --name NAME Set account name (import/login)
1104
+ --from PATH Credentials path (import, default: ~/.claude/.credentials.json)
1105
+ --json JSON Import from inline JSON (import), e.g.:
1106
+ --json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
1107
+ --log-to DIR Log full requests/responses to DIR (server, one file per request)
1108
+
1109
+ Config: ${getConfigPath()}
1110
+ `);
1111
+ }
1112
+
1113
+ // ── shared account upsert ────────────────────────────────────
1114
+
1115
+ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1116
+ // Fetch profile to auto-name and deduplicate by account UUID
1117
+ const profile = await fetchProfile(creds.accessToken);
1118
+ const profileOk = profile && !profile.error;
1119
+
1120
+ if (!profileOk) {
1121
+ console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
1122
+ }
1123
+ if (!name && profile?.email) {
1124
+ name = profile.email;
1125
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
1126
+ if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
1127
+ }
1128
+ if (!name) {
1129
+ // First FREE account-N (not `count + 1`, which collides after a delete) so the
1130
+ // generated name stays a unique identity key.
1131
+ let n = 1;
1132
+ do { name = `account-${n++}`; } while (config.accounts.some(a => a.name === name));
1133
+ }
1134
+
1135
+ const account = {
1136
+ name,
1137
+ type: 'oauth',
1138
+ source,
1139
+ accountUuid: profile?.accountUuid || null,
1140
+ accessToken: creds.accessToken,
1141
+ refreshToken: creds.refreshToken,
1142
+ expiresAt: creds.expiresAt,
1143
+ };
1144
+
1145
+ // Deduplicate: match by UUID first, then by name
1146
+ let idx = profile?.accountUuid
1147
+ ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
1148
+ : -1;
1149
+ if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
1150
+
1151
+ if (idx >= 0) {
1152
+ // Re-credentialing an existing account must not wipe its manual routing
1153
+ // settings — carry enabled/priority over from the entry being replaced.
1154
+ const prev = config.accounts[idx];
1155
+ if (prev.enabled !== undefined) account.enabled = prev.enabled;
1156
+ if (prev.priority !== undefined) account.priority = prev.priority;
1157
+ config.accounts[idx] = account;
1158
+ console.log(`Updated account "${name}"`);
1159
+ } else {
1160
+ config.accounts.push(account);
1161
+ console.log(`Added account "${name}"`);
1162
+ }
1163
+
1164
+ await saveConfig(config);
1165
+ console.log(`Saved to ${getConfigPath()}`);
1166
+ }
1167
+
1168
+ // ── config sync helpers ─────────────────────────────────────
1169
+
1170
+ /**
1171
+ * Find a config account entry matching an in-memory account (by UUID, then name).
1172
+ */
1173
+ function findConfigAccount(diskConfig, account) {
1174
+ if (account.accountUuid) {
1175
+ const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
1176
+ if (idx >= 0) return idx;
1177
+ }
1178
+ return diskConfig.accounts.findIndex(a => a.name === account.name);
1179
+ }
1180
+
1181
+ /**
1182
+ * Sync accounts from disk config: add new accounts and refresh credentials
1183
+ * for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
1184
+ * Returns the number of new accounts added.
1185
+ */
1186
+ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1187
+ let added = 0;
1188
+ for (const diskAcct of diskConfig.accounts) {
1189
+ const matchByUuid = diskAcct.accountUuid &&
1190
+ memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
1191
+ const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
1192
+ const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
1193
+
1194
+ if (memIdx < 0) {
1195
+ // New account discovered on disk — add to running server
1196
+ memConfig.accounts.push(diskAcct);
1197
+ accountManager.addAccount(diskAcct);
1198
+ added++;
1199
+ console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
1200
+ continue;
1201
+ }
1202
+
1203
+ // Find the corresponding AccountManager entry — UUID first, then name, so a
1204
+ // disk entry whose UUID and name resolve to *different* live accounts can't
1205
+ // misattribute the update to the name-match when a UUID-match exists.
1206
+ const mgr = (diskAcct.accountUuid && accountManager.accounts.find(a => a.accountUuid === diskAcct.accountUuid))
1207
+ || accountManager.accounts.find(a => a.name === diskAcct.name);
1208
+
1209
+ // Apply enable/disable + priority from disk FIRST — independent of credential
1210
+ // re-resolution below. A failed re-import (freshCred null) must NOT strand a
1211
+ // `teamclaude disable`/`priority` set while the server runs. setEnabled drains
1212
+ // the overflow queue when re-enabling so a freed-up account is used at once.
1213
+ if (mgr) {
1214
+ const wantEnabled = diskAcct.enabled !== false;
1215
+ if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
1216
+ const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
1217
+ if (mgr.priority !== diskPriority) accountManager.setPriority(mgr, diskPriority);
1218
+ // Mirror the applied state into the in-memory config copy too. Otherwise a
1219
+ // later TUI saveConfig (for any unrelated op) would spread the pre-sync
1220
+ // enabled/priority over the disk value and silently revert a CLI change.
1221
+ const memAcct = memConfig.accounts[memIdx];
1222
+ if (memAcct) {
1223
+ if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
1224
+ if (diskPriority === null) delete memAcct.priority; else memAcct.priority = diskPriority;
1225
+ }
1226
+ }
1227
+
1228
+ // Existing account — resolve fresh credentials from disk
1229
+ let freshCred = null;
1230
+ if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
1231
+ try {
1232
+ const creds = await importCredentials(diskAcct.importFrom);
1233
+ freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
1234
+ } catch (err) {
1235
+ console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
1236
+ }
1237
+ } else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
1238
+ freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
1239
+ } else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
1240
+ freshCred = { apiKey: diskAcct.apiKey };
1241
+ }
1242
+
1243
+ if (!freshCred || !mgr) continue;
1244
+
1245
+ if (freshCred.accessToken) {
1246
+ const changed = mgr.credential !== freshCred.accessToken ||
1247
+ mgr.refreshToken !== freshCred.refreshToken;
1248
+ // Don't overwrite in-memory credentials with staler ones from disk
1249
+ // (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
1250
+ const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
1251
+ freshCred.expiresAt < mgr.expiresAt;
1252
+ if (changed && !diskIsStaler) {
1253
+ accountManager.updateAccountTokens(mgr.index, freshCred);
1254
+ console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
1255
+ }
1256
+ } else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
1257
+ mgr.credential = freshCred.apiKey;
1258
+ if (mgr.status === 'error') mgr.status = 'active';
1259
+ console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
1260
+ }
1261
+ }
1262
+ return added;
1263
+ }
1264
+
1265
+ // ── live cloud sync (running server ⇄ cloud) ────────────────
1266
+
1267
+ // One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
1268
+ // Returns { stop } — stop=true means the key is revoked and the loop must end.
1269
+ // Failure classification (irreversible-mutation 3-way): revoked(403) → drop the
1270
+ // key's accounts and stop; transient(5xx/network) → keep last-known accounts and
1271
+ // retry next interval (cloud outage must NOT block the local proxy).
1272
+ async function syncFromCloudOnce(config, accountManager, cloud, key) {
1273
+ let pulled, allowed, source;
1274
+ try {
1275
+ ({ accounts: pulled, allowed, source } = await cloudPull(cloud, key));
1276
+ } catch (err) {
1277
+ if (err && err.revoked) {
1278
+ // Revoke: prune every account this key manages (mergePulledAccounts with an
1279
+ // empty allow-set drops this key's source and removes now-unmanaged accounts).
1280
+ const src = keySource(key);
1281
+ await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
1282
+ const disk = await loadConfig();
1283
+ const removed = disk ? reconcileRemovedAccounts(disk, config, accountManager) : 0;
1284
+ console.error(`[TeamClaude] Cloud auth key revoked — removed ${removed} account(s). The proxy will refuse new requests until you re-pull with a valid key.`);
1285
+ return { stop: true };
1286
+ }
1287
+ console.error(`[TeamClaude] Cloud sync skipped (transient — keeping current accounts): ${err.message}`);
1288
+ return { stop: false };
1289
+ }
1290
+ await atomicConfigUpdate(c => { mergePulledAccounts(c, pulled, source, allowed); });
1291
+ const disk = await loadConfig();
1292
+ if (disk) {
1293
+ await syncAccountsFromDisk(disk, config, accountManager); // add / token-refresh / enable / priority
1294
+ reconcileRemovedAccounts(disk, config, accountManager); // removal (group shrink)
1295
+ }
1296
+ return { stop: false };
1297
+ }
1298
+
1299
+ // Start the periodic live-sync loop. Returns a stop() function. The timer is
1300
+ // unref'd (never keeps the process alive) and non-overlapping (a slow pull won't
1301
+ // stack). On revoke it self-cancels.
1302
+ function startCloudSyncLoop(config, accountManager, cloud, key, intervalMs) {
1303
+ let timer = null;
1304
+ let running = false;
1305
+ const tick = async () => {
1306
+ if (running) return;
1307
+ running = true;
1308
+ try {
1309
+ const r = await syncFromCloudOnce(config, accountManager, cloud, key);
1310
+ if (r.stop && timer) { clearInterval(timer); timer = null; }
1311
+ } catch (e) {
1312
+ console.error(`[TeamClaude] Cloud sync error: ${e.message}`);
1313
+ } finally {
1314
+ running = false;
1315
+ }
1316
+ };
1317
+ timer = setInterval(tick, intervalMs);
1318
+ timer.unref?.();
1319
+ return () => { if (timer) { clearInterval(timer); timer = null; } };
1320
+ }
1321
+
1322
+ // ── self-update (public npm) ────────────────────────────────
1323
+
1324
+ // This package's own version, read from its package.json (zero-dep, ESM-safe).
1325
+ function currentVersion() {
1326
+ try {
1327
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
1328
+ } catch {
1329
+ return null;
1330
+ }
1331
+ }
1332
+
1333
+ // a.b.c compare (ignores prerelease tags — fine for our versions). true if a > b.
1334
+ function semverGt(a, b) {
1335
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
1336
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
1337
+ for (let i = 0; i < 3; i++) {
1338
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
1339
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
1340
+ }
1341
+ return false;
1342
+ }
1343
+
1344
+ function updateCachePath() {
1345
+ return getConfigPath() + '.update-check.json';
1346
+ }
1347
+
1348
+ // Non-blocking update notice: print immediately from cache if we already know a
1349
+ // newer version is published, then refresh the cache in the background (≤ once/day,
1350
+ // short timeout, all errors swallowed) for next time. Never delays or breaks the
1351
+ // proxy; opt out with config.updateCheck === false. Before the package is on npm
1352
+ // the registry returns 404 → no cache written → silent (correct pre-publish).
1353
+ function updateNotifier(config) {
1354
+ if (!config || config.updateCheck === false) return;
1355
+ const cur = currentVersion();
1356
+ if (!cur) return;
1357
+ const path = updateCachePath();
1358
+ let cache = null;
1359
+ try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch { /* no cache yet */ }
1360
+ if (cache && cache.latest && semverGt(cache.latest, cur)) {
1361
+ console.log(`[TeamClaude] Update available: ${cur} → ${cache.latest}. Run: teamclaude update`);
1362
+ }
1363
+ const stale = !cache || !cache.checkedAt || (Date.now() - cache.checkedAt > 24 * 60 * 60 * 1000);
1364
+ if (!stale) return;
1365
+ const ctrl = new AbortController();
1366
+ const t = setTimeout(() => ctrl.abort(), 3000);
1367
+ t.unref?.();
1368
+ fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, { signal: ctrl.signal })
1369
+ .then((r) => (r.ok ? r.json() : null))
1370
+ .then((j) => { if (j && j.version) { try { writeFileSync(path, JSON.stringify({ checkedAt: Date.now(), latest: j.version }), { mode: 0o600 }); } catch { /* cache is best-effort */ } } })
1371
+ .catch(() => { /* offline / registry down — try again next run */ })
1372
+ .finally(() => clearTimeout(t));
1373
+ }
1374
+
1375
+ // `teamclaude update` — pull the latest published version globally.
1376
+ async function updateCommand() {
1377
+ const cur = currentVersion() || 'unknown';
1378
+ console.log(`Current version: ${cur}. Updating ${PKG_NAME} to the latest published release…`);
1379
+ const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
1380
+ if (r.error || r.status !== 0) {
1381
+ console.error('\nUpdate failed. Run it manually:');
1382
+ console.error(` npm install -g ${PKG_NAME}@latest`);
1383
+ console.error('(If you get a permissions error, your global npm prefix may need sudo.)');
1384
+ process.exitCode = 1;
1385
+ return;
1386
+ }
1387
+ console.log(`\n✓ Updated. New version: ${spawnSync('teamclaude', ['version'], { encoding: 'utf8' }).stdout?.trim() || 'run `teamclaude version`'}`);
1388
+ }
1389
+
1390
+ // ── helpers ─────────────────────────────────────────────────
1391
+
1392
+ async function resolveAccounts(config) {
1393
+ const accounts = [];
1394
+ for (const acct of config.accounts) {
1395
+ if (acct.type === 'oauth') {
1396
+ if (acct.importFrom) {
1397
+ try {
1398
+ const creds = await importCredentials(acct.importFrom);
1399
+ // Carry accountUuid through so the live account can be matched UUID-first
1400
+ // on sync (otherwise it stays null and a name change misroutes the update).
1401
+ accounts.push({ name: acct.name, type: 'oauth', accountUuid: acct.accountUuid, maxConcurrent: acct.maxConcurrent, enabled: acct.enabled, priority: acct.priority, ...creds });
1402
+ console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
1403
+ } catch (err) {
1404
+ console.error(`Failed to import "${acct.name}": ${err.message}`);
1405
+ }
1406
+ } else if (acct.accessToken) {
1407
+ accounts.push(acct);
1408
+ } else {
1409
+ console.error(`No token for "${acct.name}", skipping`);
1410
+ }
1411
+ } else if (acct.type === 'apikey' && acct.apiKey) {
1412
+ accounts.push(acct);
1413
+ }
1414
+ }
1415
+ return accounts;
1416
+ }
1417
+
1418
+ function argValue(flag) {
1419
+ const i = args.indexOf(flag);
1420
+ return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
1421
+ }
1422
+
1423
+ // Coalesce concurrent auto-pushes per account so a burst of refreshes doesn't fan
1424
+ // out duplicate cloud calls.
1425
+ const _autoPushInFlight = new Set();
1426
+
1427
+ /**
1428
+ * Best-effort: push a single freshly-refreshed account's token to the cloud, so the
1429
+ * cloud never serves a rotated-away (dead) refresh token to consumers. No-op unless
1430
+ * this machine has a cloud login session (i.e. it's an admin/source, not a pure
1431
+ * consumer). Never throws — cloud problems must not affect the running proxy.
1432
+ */
1433
+ async function maybePushRefreshedToken(config, account, newTokens) {
1434
+ const key = account.accountUuid || account.name;
1435
+ try {
1436
+ if (!config.cloud || !config.cloud.session || !config.cloud.session.access_token) return; // not an admin/pusher
1437
+ if (_autoPushInFlight.has(key)) return;
1438
+ _autoPushInFlight.add(key);
1439
+ const cloud = await ensureCloudSession(config).catch(() => config.cloud);
1440
+ await cloudPush(cloud, [{
1441
+ accountUuid: account.accountUuid,
1442
+ name: account.name,
1443
+ subscriptionType: account.subscriptionType,
1444
+ tier: account.tier,
1445
+ type: account.type,
1446
+ accessToken: newTokens.accessToken,
1447
+ refreshToken: newTokens.refreshToken,
1448
+ expiresAt: newTokens.expiresAt,
1449
+ }]);
1450
+ } catch (err) {
1451
+ console.error(`[TeamClaude] Cloud auto-push (token refresh) failed for ${account.name}: ${err.message}`);
1452
+ } finally {
1453
+ _autoPushInFlight.delete(key);
1454
+ }
1455
+ }
1456
+
1457
+ // ── cloud (SaaS token sync + auth-key control) ──────────────────
1458
+
1459
+ // Refresh the owner session if it is expiring; persist the new session.
1460
+ async function ensureCloudSession(config) {
1461
+ const cloud = config.cloud;
1462
+ if (!cloud || !cloud.session || !cloud.session.access_token) return cloud;
1463
+ const expMs = (Number(cloud.session.expires_at) || 0) * 1000; // Supabase expires_at is seconds
1464
+ if (expMs && Date.now() + 60_000 >= expMs && cloud.session.refresh_token) {
1465
+ const s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
1466
+ cloud.session = s;
1467
+ await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
1468
+ }
1469
+ return cloud;
1470
+ }
1471
+
1472
+ async function cloudCommand() {
1473
+ const sub = args[1];
1474
+ const config = await loadOrCreateConfig();
1475
+ switch (sub) {
1476
+ case 'login': await cloudLoginCommand(config); break;
1477
+ case 'push': await cloudPushCommand(config); break;
1478
+ case 'pull': await cloudPullCommand(config); break;
1479
+ case 'key': await cloudKeyCommand(config); break;
1480
+ case 'group': await cloudGroupCommand(config); break;
1481
+ case 'account': await cloudAccountCommand(config); break;
1482
+ case 'status': await cloudStatusCommand(config); break;
1483
+ default:
1484
+ console.log('Cloud commands (SaaS token sync + auth-key control):');
1485
+ console.log(' teamclaude cloud pull --key <auth-key> Pull allowed tokens (key-only — no login)');
1486
+ console.log(' teamclaude cloud login --email <e> [--password <p>] (admin, to push)');
1487
+ console.log(' teamclaude cloud push Push local account tokens to the cloud');
1488
+ console.log(' teamclaude cloud key create [--label <l>] [--accounts uuid1,uuid2]');
1489
+ console.log(' teamclaude cloud key list');
1490
+ console.log(' teamclaude cloud key revoke <id>');
1491
+ console.log(' teamclaude cloud key allow <id> --accounts uuid1,uuid2');
1492
+ console.log(' teamclaude cloud status');
1493
+ }
1494
+ }
1495
+
1496
+ async function cloudLoginCommand(config) {
1497
+ // url/anon-key default to the built-in public endpoint — only --email/--password
1498
+ // are required for the owner (admin) login.
1499
+ const url = argValue('--url') || config.cloud?.url || process.env.SUPABASE_URL || DEFAULT_CLOUD.url;
1500
+ const anonKey = argValue('--anon-key') || config.cloud?.anonKey || process.env.SUPABASE_ANON_KEY || DEFAULT_CLOUD.anonKey;
1501
+ const email = argValue('--email') || config.cloud?.email || process.env.TEAMCLAUDE_CLOUD_EMAIL;
1502
+ let password = argValue('--password') || process.env.TEAMCLAUDE_CLOUD_PASSWORD;
1503
+ if (!email) {
1504
+ console.error('Missing --email (or TEAMCLAUDE_CLOUD_EMAIL).');
1505
+ process.exit(1);
1506
+ }
1507
+ if (!password) {
1508
+ if (!process.stdin.isTTY) { console.error('Password required (--password or TEAMCLAUDE_CLOUD_PASSWORD).'); process.exit(1); }
1509
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1510
+ password = await new Promise(resolve => rl.question('Cloud password: ', resolve));
1511
+ rl.close();
1512
+ }
1513
+ const session = await cloudLogin({ url, anonKey }, email, password);
1514
+ await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
1515
+ console.log(`Logged in to cloud as ${email}.`);
1516
+ }
1517
+
1518
+ async function cloudPushCommand(config) {
1519
+ const cloud = await ensureCloudSession(config);
1520
+ if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
1521
+ const accounts = (config.accounts || []).filter(a => a.type !== 'apikey');
1522
+ if (!accounts.length) { console.error('No OAuth accounts to push.'); process.exit(1); }
1523
+ // Ride the proxy's quota snapshot (<config>.quota.json) along so the cloud
1524
+ // dashboard can show 5h/7d/Fable utilization. Absence is fine — usage rows
1525
+ // are simply not written (the dashboard shows "no data", not a fake zero).
1526
+ const usageByUuid = new Map();
1527
+ const quotaCache = await readQuotaCache();
1528
+ for (const entry of quotaCache?.accounts || []) {
1529
+ if (!entry?.accountUuid) continue;
1530
+ const usage = buildUsageFromQuota(entry);
1531
+ if (usage) usageByUuid.set(entry.accountUuid, usage);
1532
+ }
1533
+ const r = await cloudPush(cloud, accounts, usageByUuid);
1534
+ const usageNote = usageByUuid.size ? `, usage ${r.usage ?? 0}/${usageByUuid.size}` : ' (no quota snapshot — start the server once to measure usage)';
1535
+ console.log(`Pushed ${r.pushed} new, updated ${r.updated}, skipped ${r.skipped} (stale/invalid)${usageNote}.`);
1536
+ }
1537
+
1538
+ async function cloudPullCommand(config) {
1539
+ // Consumer side: the auth key IS the credential — no owner `cloud login` needed.
1540
+ // resolveCloud() supplies the built-in public endpoint, so this works right after install.
1541
+ const cloud = resolveCloud(config);
1542
+ const key = argValue('--key') || process.env.TEAMCLAUDE_AUTH_KEY;
1543
+ if (!key) { console.error('Auth key required (--key or TEAMCLAUDE_AUTH_KEY).'); process.exit(1); }
1544
+ const { accounts: pulled, allowed, source } = await cloudPull(cloud, key);
1545
+ const updated = await atomicConfigUpdate(c => {
1546
+ c._pullSummary = mergePulledAccounts(c, pulled, source, allowed);
1547
+ // Persist the endpoint + auth key so a running `server` can re-pull on its own
1548
+ // (live sync). The key is the consumer's own credential; config is written 0o600.
1549
+ c.cloud = { ...(c.cloud || {}), url: cloud.url, anonKey: cloud.anonKey, pullKey: key };
1550
+ });
1551
+ const s = updated._pullSummary || { added: 0, updated: 0, skipped: 0, removed: 0 };
1552
+ await atomicConfigUpdate(c => { delete c._pullSummary; });
1553
+ const removedNote = s.removed ? `, ${s.removed} removed (revoked from group)` : '';
1554
+ console.log(`Pulled ${pulled.length} allowed account(s): +${s.added} added, ${s.updated} updated, ${s.skipped} kept-local (fresher)${removedNote}.`);
1555
+ console.log('A running `teamclaude server` will keep this in sync automatically (token/group changes + key revocation apply live).');
1556
+ }
1557
+
1558
+ /** Resolve a group by name or id against the cloud's group list. */
1559
+ async function resolveGroup(cloud, nameOrId) {
1560
+ const groups = await cloudGroupList(cloud);
1561
+ const g = groups.find(x => x.name === nameOrId) || groups.find(x => x.id === nameOrId);
1562
+ if (!g) {
1563
+ console.error(`Group not found: ${nameOrId} (existing: ${groups.map(x => x.name).join(', ') || 'none'})`);
1564
+ process.exit(1);
1565
+ }
1566
+ return g;
1567
+ }
1568
+
1569
+ async function cloudKeyCommand(config) {
1570
+ const cloud = await ensureCloudSession(config);
1571
+ if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
1572
+ const action = args[2];
1573
+ switch (action) {
1574
+ case 'create': {
1575
+ const groupArg = argValue('--group');
1576
+ const groupId = groupArg ? (await resolveGroup(cloud, groupArg)).id : null; // null → main
1577
+ const r = await cloudKeyCreate(cloud, { label: argValue('--label'), groupId });
1578
+ console.log(`Created auth key (id ${r.id}, group ${groupArg || 'main'}):`);
1579
+ console.log(` ${r.key}`);
1580
+ console.log(' ^ shown once — store it now. Consumers pull with: teamclaude cloud pull --key <this>');
1581
+ break;
1582
+ }
1583
+ case 'list': {
1584
+ const keys = await cloudKeyList(cloud);
1585
+ if (!keys.length) { console.log('No auth keys.'); break; }
1586
+ for (const k of keys) {
1587
+ console.log(`${k.key_prefix}… ${k.status.padEnd(8)} ${(k.group || 'main').padEnd(12)} ${k.label || ''} id=${k.id}`);
1588
+ }
1589
+ console.log('(허용 계정은 키가 아니라 그룹에 매핑됩니다: teamclaude cloud group list)');
1590
+ break;
1591
+ }
1592
+ case 'revoke': {
1593
+ const id = args[3];
1594
+ if (!id) { console.error('Usage: teamclaude cloud key revoke <id>'); process.exit(1); }
1595
+ await cloudKeyRevoke(cloud, id);
1596
+ console.log(`Revoked key ${id}.`);
1597
+ break;
1598
+ }
1599
+ case 'move': {
1600
+ const id = args[3];
1601
+ const groupArg = argValue('--group');
1602
+ if (!id || !groupArg) { console.error('Usage: teamclaude cloud key move <id> --group <name>'); process.exit(1); }
1603
+ const g = await resolveGroup(cloud, groupArg);
1604
+ const r = await cloudKeyMove(cloud, id, g.id);
1605
+ console.log(`Key ${id} moved to group "${r.group}".`);
1606
+ break;
1607
+ }
1608
+ case 'allow':
1609
+ console.error('`key allow` is gone — accounts are allowed per GROUP now:');
1610
+ console.error(' teamclaude cloud group allow <group> --accounts uuid1,uuid2 (order = priority)');
1611
+ process.exit(1);
1612
+ break;
1613
+ default:
1614
+ console.error('Usage: teamclaude cloud key <create|list|revoke|move>');
1615
+ process.exit(1);
1616
+ }
1617
+ }
1618
+
1619
+ async function cloudGroupCommand(config) {
1620
+ const cloud = await ensureCloudSession(config);
1621
+ if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
1622
+ const action = args[2];
1623
+ switch (action) {
1624
+ case 'list': {
1625
+ const groups = await cloudGroupList(cloud);
1626
+ for (const g of groups) {
1627
+ console.log(`${g.name} (keys: ${g.activeKeys}, id=${g.id})`);
1628
+ if (!g.accounts.length) { console.log(' (no accounts)'); continue; }
1629
+ for (const a of g.accounts) {
1630
+ const prio = Number.isInteger(a.priority) ? `#${a.priority}` : 'auto';
1631
+ const flags = `${a.enabled ? 'enabled' : 'disabled'}${a.accountEnabled === false ? ' (globally disabled)' : ''}`;
1632
+ console.log(` ${prio.padEnd(5)} ${a.name} ${flags} ${a.accountUuid}`);
1633
+ }
1634
+ }
1635
+ break;
1636
+ }
1637
+ case 'create': {
1638
+ const name = args[3];
1639
+ if (!name) { console.error('Usage: teamclaude cloud group create <name>'); process.exit(1); }
1640
+ const r = await cloudGroupCreate(cloud, name);
1641
+ console.log(`Created group "${r.name}" (id ${r.id}).`);
1642
+ break;
1643
+ }
1644
+ case 'rename': {
1645
+ const nameOrId = args[3];
1646
+ const to = argValue('--to');
1647
+ if (!nameOrId || !to) { console.error('Usage: teamclaude cloud group rename <name|id> --to <newName>'); process.exit(1); }
1648
+ const g = await resolveGroup(cloud, nameOrId);
1649
+ await cloudGroupRename(cloud, g.id, to);
1650
+ console.log(`Renamed group "${g.name}" → "${to}".`);
1651
+ break;
1652
+ }
1653
+ case 'delete': {
1654
+ const nameOrId = args[3];
1655
+ if (!nameOrId) { console.error('Usage: teamclaude cloud group delete <name|id>'); process.exit(1); }
1656
+ const g = await resolveGroup(cloud, nameOrId);
1657
+ await cloudGroupDelete(cloud, g.id);
1658
+ console.log(`Deleted group "${g.name}" (its keys moved to "main").`);
1659
+ break;
1660
+ }
1661
+ case 'allow': {
1662
+ // Order in --accounts IS the priority (first = 0 = preferred first).
1663
+ const nameOrId = args[3];
1664
+ const accountsArg = argValue('--accounts');
1665
+ if (!nameOrId || !accountsArg) {
1666
+ console.error('Usage: teamclaude cloud group allow <name|id> --accounts uuid1,uuid2 (order = priority)');
1667
+ console.error(' --auto-priority 로 priority 없이(use-or-lose 자동 순서) 허용만 설정');
1668
+ process.exit(1);
1669
+ }
1670
+ const g = await resolveGroup(cloud, nameOrId);
1671
+ const autoPrio = args.includes('--auto-priority');
1672
+ const accounts = accountsArg.split(',').map(s => s.trim()).filter(Boolean)
1673
+ .map((accountUuid, i) => ({ accountUuid, enabled: true, priority: autoPrio ? null : i }));
1674
+ // Pass the revision we just read so the write goes through optimistic-
1675
+ // concurrency (a concurrent edit is rejected 409 stale_revision).
1676
+ const r = await cloudGroupSetAccounts(cloud, g.id, accounts, g.revision);
1677
+ console.log(`Group "${g.name}" now allows ${r.accounts} account(s)${autoPrio ? ' (auto order)' : ' (priority = given order)'}.`);
1678
+ break;
1679
+ }
1680
+ default:
1681
+ console.error('Usage: teamclaude cloud group <list|create|rename|delete|allow>');
1682
+ process.exit(1);
1683
+ }
1684
+ }
1685
+
1686
+ async function cloudAccountCommand(config) {
1687
+ const cloud = await ensureCloudSession(config);
1688
+ if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
1689
+ const action = args[2];
1690
+ const ref = args[3];
1691
+ if ((action !== 'enable' && action !== 'disable') || !ref) {
1692
+ console.error('Usage: teamclaude cloud account <enable|disable> <accountUuid|localName>');
1693
+ process.exit(1);
1694
+ }
1695
+ // Convenience: allow a local account NAME and resolve it to its uuid.
1696
+ const local = (config.accounts || []).find(a => a.accountUuid === ref || a.name === ref);
1697
+ const uuid = local?.accountUuid || ref;
1698
+ const r = await cloudAccountSetEnabled(cloud, uuid, action === 'enable');
1699
+ console.log(`Account ${r.accountUuid} is now ${r.enabled ? 'enabled' : 'disabled'} (globally${r.enabled ? '' : ' — excluded from every group\'s pull'}).`);
1700
+ }
1701
+
1702
+ async function cloudStatusCommand(config) {
1703
+ const cloud = config.cloud;
1704
+ if (!cloud?.url) { console.log('Cloud: not configured.'); return; }
1705
+ console.log(`Cloud URL: ${cloud.url}`);
1706
+ console.log(`Email: ${cloud.email || '(unknown)'}`);
1707
+ const expMs = (Number(cloud.session?.expires_at) || 0) * 1000;
1708
+ const loggedIn = cloud.session?.access_token && (!expMs || expMs > Date.now());
1709
+ console.log(`Session: ${loggedIn ? 'active' : 'expired/none — run `teamclaude cloud login`'}`);
1710
+ }
1711
+
1712
+ function handleServerListenError(err, port) {
1713
+ if (err.code === 'EADDRINUSE') {
1714
+ console.error(`[TeamClaude] Port ${port} is already in use.`);
1715
+ console.error('Another TeamClaude proxy may already be running.');
1716
+ console.error(' See it: teamclaude status');
1717
+ console.error(' Stop it: teamclaude stop');
1718
+ console.error(' Restart it: teamclaude restart');
1719
+ } else if (err.code === 'EACCES') {
1720
+ console.error(`[TeamClaude] Permission denied while listening on port ${port}.`);
1721
+ console.error('Choose a non-privileged port in the TeamClaude config.');
1722
+ } else {
1723
+ console.error(`[TeamClaude] Failed to listen on port ${port}: ${err.message}`);
1724
+ }
1725
+ process.exit(1);
1726
+ }