@rikcodes/teamclaude 1.1.13-rik.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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1873 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { createWriteStream } from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getCrashLogPath, loadState, saveState } from './config.js';
|
|
8
|
+
import { installCrashHandlers } from './crash-log.js';
|
|
9
|
+
import { AccountManager } from './account-manager.js';
|
|
10
|
+
import { createProxyServer } from './server.js';
|
|
11
|
+
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
12
|
+
import { sameIdentity, orgKey, matchAccounts, findUpsertTarget } from './identity.js';
|
|
13
|
+
import { resolveAccounts } from './resolve-accounts.js';
|
|
14
|
+
import * as alias from './alias.js';
|
|
15
|
+
import { ensureCerts } from './mitm.js';
|
|
16
|
+
import { Prober } from './prober.js';
|
|
17
|
+
import { Warmer } from './warmer.js';
|
|
18
|
+
import { TUI } from './tui.js';
|
|
19
|
+
import { RemoteControl, createAttachSession } from './tui-remote.js';
|
|
20
|
+
import { SxManager } from './sx.js';
|
|
21
|
+
import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
|
|
22
|
+
import { renderStatus } from './status-renderer.js';
|
|
23
|
+
import { buildClaudeEnvLines, encodePinComponent } from './claude-env.js';
|
|
24
|
+
import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
|
|
25
|
+
import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
|
|
26
|
+
import { getUpstreamProxy, describeProxy } from './upstream-proxy.js';
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args[0];
|
|
30
|
+
|
|
31
|
+
switch (command) {
|
|
32
|
+
case 'server':
|
|
33
|
+
await serverCommand();
|
|
34
|
+
break;
|
|
35
|
+
case 'run':
|
|
36
|
+
await runCommand();
|
|
37
|
+
break;
|
|
38
|
+
case 'import':
|
|
39
|
+
await importCommand();
|
|
40
|
+
process.exit(0);
|
|
41
|
+
break;
|
|
42
|
+
case 'login':
|
|
43
|
+
await loginCommand();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
break;
|
|
46
|
+
case 'env':
|
|
47
|
+
await envCommand();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
break;
|
|
50
|
+
case 'status':
|
|
51
|
+
await statusCommand();
|
|
52
|
+
process.exit(0);
|
|
53
|
+
break;
|
|
54
|
+
case 'attach':
|
|
55
|
+
await attachCommand();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
break;
|
|
58
|
+
case 'accounts':
|
|
59
|
+
await accountsCommand();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
break;
|
|
62
|
+
case 'switch':
|
|
63
|
+
await switchCommand();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
break;
|
|
66
|
+
case 'remove':
|
|
67
|
+
await removeCommand();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
break;
|
|
70
|
+
case 'priority':
|
|
71
|
+
await priorityCommand();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
break;
|
|
74
|
+
case 'disable':
|
|
75
|
+
await setDisabledCommand(true);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
break;
|
|
78
|
+
case 'enable':
|
|
79
|
+
await setDisabledCommand(false);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
break;
|
|
82
|
+
case 'api':
|
|
83
|
+
await apiCommand();
|
|
84
|
+
process.exit(0);
|
|
85
|
+
break;
|
|
86
|
+
case 'alias':
|
|
87
|
+
aliasCommand();
|
|
88
|
+
process.exit(0);
|
|
89
|
+
break;
|
|
90
|
+
case 'service':
|
|
91
|
+
await serviceCommand();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
break;
|
|
94
|
+
case 'probe':
|
|
95
|
+
await probeCommand();
|
|
96
|
+
process.exit(0);
|
|
97
|
+
break;
|
|
98
|
+
case 'warmup':
|
|
99
|
+
await warmupCommand();
|
|
100
|
+
process.exit(0);
|
|
101
|
+
break;
|
|
102
|
+
case 'route':
|
|
103
|
+
case 'routes':
|
|
104
|
+
await routeCommand();
|
|
105
|
+
process.exit(0);
|
|
106
|
+
break;
|
|
107
|
+
case 'update':
|
|
108
|
+
await updateCommand();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
break;
|
|
111
|
+
case 'version':
|
|
112
|
+
case '--version':
|
|
113
|
+
case '-V':
|
|
114
|
+
console.log(currentVersion() || 'unknown');
|
|
115
|
+
process.exit(0);
|
|
116
|
+
break;
|
|
117
|
+
case 'help':
|
|
118
|
+
case '--help':
|
|
119
|
+
case '-h':
|
|
120
|
+
showHelp();
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
// No command or unknown command → start server
|
|
124
|
+
if (command && !command.startsWith('-')) {
|
|
125
|
+
console.error(`Unknown command: ${command}\n`);
|
|
126
|
+
showHelp();
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
await serverCommand();
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── server ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async function serverCommand() {
|
|
136
|
+
// Installed first: the server is the long-lived process, it runs under a TUI
|
|
137
|
+
// that repaints over anything Node prints on the way out, and a crash here
|
|
138
|
+
// takes every routed session with it. Without this, a proxy that vanished
|
|
139
|
+
// overnight leaves nothing behind to explain why.
|
|
140
|
+
const crashLog = getCrashLogPath();
|
|
141
|
+
installCrashHandlers(crashLog);
|
|
142
|
+
|
|
143
|
+
const config = await loadOrCreateConfig();
|
|
144
|
+
|
|
145
|
+
// --log-to <dir>
|
|
146
|
+
const logTo = argValue('--log-to');
|
|
147
|
+
if (logTo) config.logDir = logTo;
|
|
148
|
+
|
|
149
|
+
// --activity-log <file>
|
|
150
|
+
const activityLogPath = argValue('--activity-log') || null;
|
|
151
|
+
|
|
152
|
+
if (config.accounts.length === 0) {
|
|
153
|
+
console.error('No accounts configured.\n');
|
|
154
|
+
console.error('Add an account first:');
|
|
155
|
+
console.error(' teamclaude import Import from Claude Code');
|
|
156
|
+
console.error(' teamclaude login OAuth login via browser');
|
|
157
|
+
console.error(' teamclaude login --api Add an API key');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const accounts = await resolveAccounts(config);
|
|
162
|
+
if (accounts.length === 0) {
|
|
163
|
+
console.error('No valid accounts after initialization');
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// `accounts[].models` (#74) is superseded by the `routes` table (#86). Routes
|
|
168
|
+
// do the same job with glob matching, several accounts per rule and a bucket
|
|
169
|
+
// override — and, unlike `models`, they don't silently change eligibility
|
|
170
|
+
// fleet-wide the moment one account declares a list (see _accountOwnsModel).
|
|
171
|
+
// Behaviour is unchanged; this only tells pre-#86 configs what to migrate to
|
|
172
|
+
// before the field goes away. Reported against config.accounts so the notice
|
|
173
|
+
// names what is actually written on disk, whatever resolution does with it.
|
|
174
|
+
for (const acct of config.accounts) {
|
|
175
|
+
if (!acct.models?.length) continue;
|
|
176
|
+
const route = { name: acct.name, match: acct.models, accounts: [acct.name] };
|
|
177
|
+
console.error(`[TeamClaude] Deprecated: account "${acct.name}" uses "models" — replace it with a routes entry: ${JSON.stringify(route)}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const threshold = config.switchThreshold || 0.98;
|
|
181
|
+
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly });
|
|
182
|
+
|
|
183
|
+
// Restore quota observed in a previous run so a restart doesn't lose rotation
|
|
184
|
+
// state (passive — we never call the API to re-learn it). Stale windows are
|
|
185
|
+
// cleared automatically on first use by _clearExpiredQuotas.
|
|
186
|
+
const savedState = await loadState().catch(err => {
|
|
187
|
+
console.error(`[TeamClaude] Could not read saved state: ${err.message}`);
|
|
188
|
+
return null;
|
|
189
|
+
});
|
|
190
|
+
if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
|
|
191
|
+
|
|
192
|
+
// With quota restored, pick the best account up front (highest priority /
|
|
193
|
+
// soonest-resetting weekly window) instead of defaulting to the first one.
|
|
194
|
+
accountManager.selectActiveAccount();
|
|
195
|
+
|
|
196
|
+
// Periodically persist quota (and once more on shutdown) to the state file.
|
|
197
|
+
const persistQuotaState = () =>
|
|
198
|
+
saveState({ quota: accountManager.exportQuotaState() })
|
|
199
|
+
.catch(err => console.error(`[TeamClaude] Failed to save quota state: ${err.message}`));
|
|
200
|
+
let quotaSaveInterval = null;
|
|
201
|
+
|
|
202
|
+
// Persist refreshed tokens back to config (re-read from disk to avoid clobbering
|
|
203
|
+
// accounts added externally, e.g. by `teamclaude import` while server is running)
|
|
204
|
+
accountManager.onTokenRefresh((idx, newTokens) => {
|
|
205
|
+
const account = accountManager.accounts[idx];
|
|
206
|
+
if (!account) return;
|
|
207
|
+
// Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
|
|
208
|
+
if (config.accounts[idx]) {
|
|
209
|
+
config.accounts[idx].accessToken = newTokens.accessToken;
|
|
210
|
+
config.accounts[idx].refreshToken = newTokens.refreshToken;
|
|
211
|
+
config.accounts[idx].expiresAt = newTokens.expiresAt;
|
|
212
|
+
}
|
|
213
|
+
atomicConfigUpdate(diskConfig => {
|
|
214
|
+
// Pick up any new accounts from disk so index matching stays correct
|
|
215
|
+
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
|
|
216
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
217
|
+
const known = config.accounts.some(a => sameIdentity(a, diskAcct));
|
|
218
|
+
if (!known) {
|
|
219
|
+
config.accounts.push(diskAcct);
|
|
220
|
+
accountManager.addAccount(diskAcct);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Match by UUID first, then by name — index may have shifted
|
|
224
|
+
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
225
|
+
if (cfgIdx >= 0) {
|
|
226
|
+
diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
|
|
227
|
+
diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
|
|
228
|
+
diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
|
|
229
|
+
}
|
|
230
|
+
}).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
|
|
231
|
+
});
|
|
232
|
+
const port = config.proxy.port;
|
|
233
|
+
// Bind loopback by default so the proxy isn't reachable off-box (it injects
|
|
234
|
+
// account tokens and — via CONNECT — can relay arbitrarily). Opt into a wider
|
|
235
|
+
// bind explicitly with TEAMCLAUDE_HOST or config.proxy.host (e.g. '0.0.0.0'),
|
|
236
|
+
// in which case set proxy.apiKey so the auth gate protects remote clients.
|
|
237
|
+
const bindHost = process.env.TEAMCLAUDE_HOST || config.proxy.host || '127.0.0.1';
|
|
238
|
+
const headless = args.includes('--headless') || args.includes('--no-tui');
|
|
239
|
+
const useTUI = !headless && process.stdout.isTTY && process.stdin.isTTY;
|
|
240
|
+
|
|
241
|
+
// Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
|
|
242
|
+
let prober = null;
|
|
243
|
+
// Opt-in keep-warm scheduler (config.warmupSeconds, default 0 = off).
|
|
244
|
+
let warmer = null;
|
|
245
|
+
const serverStartedAt = Date.now();
|
|
246
|
+
|
|
247
|
+
// sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
|
|
248
|
+
// config.sx.apiKey; when set we provision a proxy and route upstream through it.
|
|
249
|
+
const sx = new SxManager({ log: console.error });
|
|
250
|
+
if (config.sx?.apiKey) {
|
|
251
|
+
const r = await sx.configure(config.sx.apiKey, config.sx.mode);
|
|
252
|
+
if (!r.ok) console.error(`[TeamClaude] sx.org disabled: ${r.error}`);
|
|
253
|
+
} else if (config.sx?.mode) {
|
|
254
|
+
await sx.setMode(config.sx.mode);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Re-sync accounts from disk without a restart. The TUI's 'R' key, the
|
|
258
|
+
// POST /teamclaude/reload endpoint, and the CLI notify after add/change all
|
|
259
|
+
// funnel through here. Returns the number of newly added accounts. Also picks
|
|
260
|
+
// up a changed probe interval so `teamclaude probe` applies live.
|
|
261
|
+
const reloadAccounts = async () => {
|
|
262
|
+
const diskConfig = await loadConfig();
|
|
263
|
+
if (!diskConfig) return 0;
|
|
264
|
+
const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
265
|
+
// Pick up route table edits (teamclaude route …, TUI editor, or a hand edit).
|
|
266
|
+
config.routes = diskConfig.routes || [];
|
|
267
|
+
accountManager.setRoutes(config.routes);
|
|
268
|
+
config.distributeSessions = !!diskConfig.distributeSessions;
|
|
269
|
+
accountManager.setDistributeSessions(config.distributeSessions);
|
|
270
|
+
config.soonestWeekly = diskConfig.soonestWeekly;
|
|
271
|
+
accountManager.setSoonestWeekly(config.soonestWeekly);
|
|
272
|
+
// Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
|
|
273
|
+
const diskSxKey = diskConfig.sx?.apiKey || null;
|
|
274
|
+
const diskSxMode = diskConfig.sx?.mode || 'always';
|
|
275
|
+
if (diskSxKey !== sx.apiKey || diskSxMode !== sx.mode) {
|
|
276
|
+
config.sx = diskConfig.sx;
|
|
277
|
+
if (diskSxKey) await sx.configure(diskSxKey, diskSxMode);
|
|
278
|
+
else { sx.disable(); await sx.setMode(diskSxMode); }
|
|
279
|
+
}
|
|
280
|
+
if (prober) {
|
|
281
|
+
const ms = (diskConfig.quotaProbeSeconds || 0) * 1000;
|
|
282
|
+
if (ms !== prober.intervalMs) {
|
|
283
|
+
config.quotaProbeSeconds = diskConfig.quotaProbeSeconds || 0;
|
|
284
|
+
prober.reschedule(ms);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (warmer) {
|
|
288
|
+
const ms = (diskConfig.warmupSeconds || 0) * 1000;
|
|
289
|
+
if (ms !== warmer.intervalMs) {
|
|
290
|
+
config.warmupSeconds = diskConfig.warmupSeconds || 0;
|
|
291
|
+
warmer.reschedule(ms);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return added;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
let tui = null;
|
|
298
|
+
let hooks = {};
|
|
299
|
+
|
|
300
|
+
if (useTUI) {
|
|
301
|
+
tui = new TUI({
|
|
302
|
+
accountManager, config, sx, activityLogPath,
|
|
303
|
+
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
304
|
+
// Write in-memory accounts as the authoritative state, preserving
|
|
305
|
+
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
306
|
+
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
307
|
+
diskConfig.accounts = config.accounts.map((a, i) => {
|
|
308
|
+
const am = accountManager.accounts[i];
|
|
309
|
+
const live = am ? {
|
|
310
|
+
...a,
|
|
311
|
+
accessToken: am.credential,
|
|
312
|
+
refreshToken: am.refreshToken,
|
|
313
|
+
expiresAt: am.expiresAt,
|
|
314
|
+
} : a;
|
|
315
|
+
const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
|
|
316
|
+
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
317
|
+
});
|
|
318
|
+
// Persist sx.org settings (set/cleared from the TUI settings screen).
|
|
319
|
+
if (config.sx) diskConfig.sx = config.sx; else delete diskConfig.sx;
|
|
320
|
+
// Persist other runtime-tunable settings edited from the TUI.
|
|
321
|
+
if (config.switchThreshold != null) diskConfig.switchThreshold = config.switchThreshold;
|
|
322
|
+
if (config.quotaProbeSeconds != null) diskConfig.quotaProbeSeconds = config.quotaProbeSeconds;
|
|
323
|
+
if (config.warmupSeconds != null) diskConfig.warmupSeconds = config.warmupSeconds;
|
|
324
|
+
// Persist the route table (edited from the TUI routes screen).
|
|
325
|
+
if (config.routes != null) diskConfig.routes = config.routes;
|
|
326
|
+
}),
|
|
327
|
+
syncAccounts: reloadAccounts,
|
|
328
|
+
// `p` key: on-demand fleet-wide quota refresh. The prober is constructed
|
|
329
|
+
// after the TUI, so this is a thunk over the closure variable.
|
|
330
|
+
probeQuota: () => prober?.probeAll(),
|
|
331
|
+
// ctrl-c / q from the TUI: funnel through the same idempotent shutdown as
|
|
332
|
+
// POSIX signals (defined below). In raw mode ctrl-c never reaches the OS as
|
|
333
|
+
// a signal, so without this the process would only tear down via keypress.
|
|
334
|
+
onQuit: () => shutdown(),
|
|
335
|
+
});
|
|
336
|
+
hooks = {
|
|
337
|
+
onRequestStart: (id, info) => tui.onRequestStart(id, info),
|
|
338
|
+
onRequestModel: (id, info) => tui.onRequestModel(id, info),
|
|
339
|
+
onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
|
|
340
|
+
onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// In headless mode, wire activity-log writes directly via hooks + console.
|
|
345
|
+
if (!tui && activityLogPath) {
|
|
346
|
+
const aStream = createWriteStream(activityLogPath, { flags: 'a' });
|
|
347
|
+
aStream.on('error', err => process.stderr.write(`[TeamClaude] activity log error: ${err.message}\n`));
|
|
348
|
+
const ts = () => new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
349
|
+
const writeActivity = msg => {
|
|
350
|
+
// Strip [TeamClaude] prefix to match TUI behaviour
|
|
351
|
+
aStream.write(`${ts()} ${msg.replace(/^\[TeamClaude\]\s*/, '')}\n`);
|
|
352
|
+
};
|
|
353
|
+
// Capture request completions via the hook
|
|
354
|
+
const inFlight = new Map();
|
|
355
|
+
hooks.onRequestStart = (id, info) => inFlight.set(id, { ...info, started: Date.now() });
|
|
356
|
+
hooks.onRequestModel = (id, info) => {
|
|
357
|
+
const r = inFlight.get(id);
|
|
358
|
+
if (r && info.model) r.model = info.model;
|
|
359
|
+
};
|
|
360
|
+
hooks.onRequestRouted = (id, info) => {
|
|
361
|
+
const r = inFlight.get(id);
|
|
362
|
+
if (r) r.account = info.account;
|
|
363
|
+
};
|
|
364
|
+
hooks.onRequestEnd = (id, info) => {
|
|
365
|
+
const r = inFlight.get(id);
|
|
366
|
+
inFlight.delete(id);
|
|
367
|
+
const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
|
|
368
|
+
const acct = info.account || r?.account || '?';
|
|
369
|
+
const model = info.model ? ` (${info.model})` : '';
|
|
370
|
+
const sid = info.sessionId ? `${info.sessionId.slice(0, 6)} ` : '';
|
|
371
|
+
const pin = (info.pinned || r?.pinned) ? ' [pin]' : '';
|
|
372
|
+
writeActivity(`${sid}${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
|
|
373
|
+
};
|
|
374
|
+
// Tee console output to the activity log as well
|
|
375
|
+
const origLog = console.log;
|
|
376
|
+
const origErr = console.error;
|
|
377
|
+
console.log = (...a) => { const m = a.join(' '); origLog(m); writeActivity(m); };
|
|
378
|
+
console.error = (...a) => { const m = a.join(' '); origErr(m); writeActivity(m); };
|
|
379
|
+
process.on('exit', () => aStream.end());
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Expose reload to the proxy's control endpoint (works with or without TUI).
|
|
383
|
+
hooks.reload = reloadAccounts;
|
|
384
|
+
hooks.getStatusExtra = () => ({
|
|
385
|
+
// Read live from the shared config (not a startup snapshot) so the TUI's
|
|
386
|
+
// blocklist editor shows up in `status` immediately, the same way the
|
|
387
|
+
// per-request gate in server.js picks it up.
|
|
388
|
+
blockedModels: [...(config.blockedModels || [])],
|
|
389
|
+
server: {
|
|
390
|
+
startedAt: new Date(serverStartedAt).toISOString(),
|
|
391
|
+
uptimeSeconds: Math.round((Date.now() - serverStartedAt) / 1000),
|
|
392
|
+
port,
|
|
393
|
+
upstream: config.upstream || 'https://api.anthropic.com',
|
|
394
|
+
},
|
|
395
|
+
probe: prober?.getStatus() || {
|
|
396
|
+
enabled: false,
|
|
397
|
+
intervalSeconds: config.quotaProbeSeconds || 0,
|
|
398
|
+
running: false,
|
|
399
|
+
accounts: accountManager.accounts.map(account => ({
|
|
400
|
+
name: account.name,
|
|
401
|
+
status: account.type === 'oauth' ? 'never' : 'not-applicable',
|
|
402
|
+
lastProbedAt: null,
|
|
403
|
+
startedAt: null,
|
|
404
|
+
durationMs: null,
|
|
405
|
+
error: null,
|
|
406
|
+
})),
|
|
407
|
+
},
|
|
408
|
+
warm: warmer?.getStatus() || {
|
|
409
|
+
enabled: false,
|
|
410
|
+
intervalSeconds: config.warmupSeconds || 0,
|
|
411
|
+
running: false,
|
|
412
|
+
accounts: accountManager.accounts.map(account => ({
|
|
413
|
+
name: account.name,
|
|
414
|
+
status: (account.type === 'oauth' && !account.upstream) ? 'never' : 'not-applicable',
|
|
415
|
+
lastWarmedAt: null,
|
|
416
|
+
startedAt: null,
|
|
417
|
+
durationMs: null,
|
|
418
|
+
error: null,
|
|
419
|
+
})),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const server = createProxyServer(accountManager, config, hooks, sx);
|
|
424
|
+
// Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
|
|
425
|
+
// remove this handler so a later runtime 'error' isn't misreported as a
|
|
426
|
+
// listen failure and exit the whole proxy.
|
|
427
|
+
const onListenError = err => handleServerListenError(err, port);
|
|
428
|
+
server.once('error', onListenError);
|
|
429
|
+
|
|
430
|
+
server.listen(port, bindHost, () => {
|
|
431
|
+
// Bind succeeded: stop treating errors as listen failures, but keep a
|
|
432
|
+
// benign runtime handler so a later 'error' is logged rather than thrown.
|
|
433
|
+
server.removeListener('error', onListenError);
|
|
434
|
+
server.on('error', err => console.error(`[TeamClaude] Server error: ${err.message}`));
|
|
435
|
+
// Announce an egress proxy, especially one inherited from the environment:
|
|
436
|
+
// it changes where every upstream byte goes, and a value nobody typed here
|
|
437
|
+
// should never be in force silently.
|
|
438
|
+
const egressProxy = getUpstreamProxy();
|
|
439
|
+
if (egressProxy.proxy) {
|
|
440
|
+
const via = egressProxy.source.startsWith('env:') ? ` (from ${egressProxy.source.slice(4)})` : '';
|
|
441
|
+
console.log(`[TeamClaude] Upstream proxy: ${describeProxy(egressProxy.proxy)}${via}`);
|
|
442
|
+
}
|
|
443
|
+
if (tui) {
|
|
444
|
+
tui.start();
|
|
445
|
+
console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
|
|
446
|
+
} else {
|
|
447
|
+
const sep = '='.repeat(60);
|
|
448
|
+
console.log('');
|
|
449
|
+
console.log(sep);
|
|
450
|
+
console.log(' TeamClaude Proxy');
|
|
451
|
+
console.log(sep);
|
|
452
|
+
console.log(` Bind: ${bindHost}:${port}${bindHost === '127.0.0.1' ? ' (localhost only)' : ' (reachable off-box — ensure proxy.apiKey is set)'}`);
|
|
453
|
+
console.log(` Accounts: ${accounts.length}`);
|
|
454
|
+
console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
|
|
455
|
+
console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
|
|
456
|
+
console.log('');
|
|
457
|
+
accounts.forEach((a, i) => {
|
|
458
|
+
console.log(` [${i + 1}] ${a.name} (${a.type})`);
|
|
459
|
+
});
|
|
460
|
+
console.log('');
|
|
461
|
+
console.log(' Run Claude through proxy: teamclaude run');
|
|
462
|
+
console.log(' Show env vars: teamclaude env');
|
|
463
|
+
console.log(sep);
|
|
464
|
+
console.log('');
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Reflect the active account in the terminal title so a backgrounded/tabbed
|
|
469
|
+
// server is glanceable. Works in both TUI and headless modes.
|
|
470
|
+
const stopTitle = startTerminalTitleUpdater(accountManager);
|
|
471
|
+
|
|
472
|
+
// Persist quota every minute; unref so it never keeps the process alive.
|
|
473
|
+
quotaSaveInterval = setInterval(persistQuotaState, 60_000);
|
|
474
|
+
quotaSaveInterval.unref?.();
|
|
475
|
+
|
|
476
|
+
// Start the opt-in quota probe (no-op when quotaProbeSeconds is 0).
|
|
477
|
+
prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
|
|
478
|
+
prober.start();
|
|
479
|
+
|
|
480
|
+
// Start the opt-in keep-warm scheduler (no-op when warmupSeconds is 0). It
|
|
481
|
+
// spawns a minimal `claude` per idle account through this proxy, pinned via
|
|
482
|
+
// /tc-acct/<index>, so needs our own port and proxy key.
|
|
483
|
+
warmer = new Warmer(accountManager, {
|
|
484
|
+
intervalMs: (config.warmupSeconds || 0) * 1000,
|
|
485
|
+
port,
|
|
486
|
+
apiKey: config.proxy?.apiKey,
|
|
487
|
+
});
|
|
488
|
+
warmer.start();
|
|
489
|
+
|
|
490
|
+
// Background self-update for a backgrounded (headless) server. Skipped under
|
|
491
|
+
// the TUI, where npm's install output would corrupt the display — interactive
|
|
492
|
+
// users update via `teamclaude run` (post-session) or `teamclaude update`.
|
|
493
|
+
if (!tui) autoUpdate({ config }).catch(() => {});
|
|
494
|
+
|
|
495
|
+
// One idempotent shutdown funnel for BOTH modes and BOTH triggers: POSIX
|
|
496
|
+
// signals (SIGINT/SIGTERM) and the TUI's ctrl-c / q keypress (which in raw mode
|
|
497
|
+
// never reaches the OS as a signal). Guards re-entry: a second ctrl-c — an
|
|
498
|
+
// impatient user, or a signal racing the keypress — forces an immediate exit
|
|
499
|
+
// instead of re-running teardown, which would re-arm server.close() and leak a
|
|
500
|
+
// 'close' listener on the server each time (MaxListenersExceededWarning).
|
|
501
|
+
let shuttingDown = false;
|
|
502
|
+
async function shutdown() {
|
|
503
|
+
if (shuttingDown) process.exit(0); // second ctrl-c: stop waiting, just go
|
|
504
|
+
shuttingDown = true;
|
|
505
|
+
try { tui?.stop(); } catch { /* terminal already restored */ }
|
|
506
|
+
stopTitle();
|
|
507
|
+
if (!tui) console.log('\n[TeamClaude] Shutting down...');
|
|
508
|
+
prober?.stop();
|
|
509
|
+
warmer?.stop();
|
|
510
|
+
if (quotaSaveInterval) clearInterval(quotaSaveInterval);
|
|
511
|
+
await persistQuotaState();
|
|
512
|
+
// Don't linger waiting on keep-alive / streaming connections: actively
|
|
513
|
+
// destroy them so server.close() can complete promptly, and hard-exit after a
|
|
514
|
+
// short grace period in case anything still hangs.
|
|
515
|
+
setTimeout(() => process.exit(0), 2000).unref?.();
|
|
516
|
+
server.closeAllConnections?.();
|
|
517
|
+
server.close(() => process.exit(0));
|
|
518
|
+
}
|
|
519
|
+
process.on('SIGINT', shutdown);
|
|
520
|
+
process.on('SIGTERM', shutdown);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── import ──────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
async function importCommand() {
|
|
526
|
+
const config = await loadOrCreateConfig();
|
|
527
|
+
|
|
528
|
+
let name = argValue('--name');
|
|
529
|
+
const jsonStr = argValue('--json');
|
|
530
|
+
|
|
531
|
+
let creds;
|
|
532
|
+
if (jsonStr) {
|
|
533
|
+
// Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
|
|
534
|
+
// or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
|
|
535
|
+
try {
|
|
536
|
+
const raw = JSON.parse(jsonStr);
|
|
537
|
+
const data = raw.claudeAiOauth || raw;
|
|
538
|
+
if (!data.accessToken) {
|
|
539
|
+
console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
creds = {
|
|
543
|
+
accessToken: data.accessToken,
|
|
544
|
+
refreshToken: data.refreshToken,
|
|
545
|
+
expiresAt: data.expiresAt,
|
|
546
|
+
};
|
|
547
|
+
} catch (err) {
|
|
548
|
+
console.error(`Failed to parse --json: ${err.message}`);
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
const fromPath = argValue('--from') || '~/.claude/.credentials.json';
|
|
553
|
+
try {
|
|
554
|
+
creds = await importCredentials(fromPath);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
console.error(`Failed to import from ${fromPath}: ${err.message}`);
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
await upsertOAuthAccount(config, name, creds, 'import');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── login ───────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
async function loginCommand() {
|
|
567
|
+
if (args.includes('--api')) {
|
|
568
|
+
await loginApiCommand();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (args.includes('--oauth')) {
|
|
572
|
+
await loginOAuthCommand();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Default to OAuth if not a TTY
|
|
577
|
+
if (!process.stdout.isTTY) {
|
|
578
|
+
await loginOAuthCommand();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Interactive menu
|
|
583
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
584
|
+
console.log('Select login method:\n');
|
|
585
|
+
console.log(' 1. Claude subscription (Pro, Max, Team, Enterprise)');
|
|
586
|
+
console.log(' 2. Anthropic API key (Console API billing)');
|
|
587
|
+
console.log('');
|
|
588
|
+
const choice = await new Promise(resolve => rl.question('Choice [1]: ', resolve));
|
|
589
|
+
rl.close();
|
|
590
|
+
|
|
591
|
+
switch (choice.trim() || '1') {
|
|
592
|
+
case '1': await loginOAuthCommand(); break;
|
|
593
|
+
case '2': await loginApiCommand(); break;
|
|
594
|
+
default:
|
|
595
|
+
console.error(`Invalid choice: ${choice.trim()}`);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function loginApiCommand() {
|
|
601
|
+
const config = await loadOrCreateConfig();
|
|
602
|
+
let name = argValue('--name');
|
|
603
|
+
|
|
604
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
605
|
+
const apiKey = await new Promise(resolve => rl.question('Anthropic API key: ', resolve));
|
|
606
|
+
rl.close();
|
|
607
|
+
|
|
608
|
+
if (!apiKey.trim()) {
|
|
609
|
+
console.error('No API key provided');
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!name) {
|
|
614
|
+
const n = config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
|
|
615
|
+
name = `api-${n}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
config.accounts.push({ name, type: 'apikey', apiKey: apiKey.trim() });
|
|
619
|
+
await saveConfig(config);
|
|
620
|
+
console.log(`Added API key account "${name}"`);
|
|
621
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function loginOAuthCommand() {
|
|
625
|
+
const config = await loadOrCreateConfig();
|
|
626
|
+
let name = argValue('--name');
|
|
627
|
+
|
|
628
|
+
console.log('Starting OAuth login...');
|
|
629
|
+
let creds;
|
|
630
|
+
try {
|
|
631
|
+
creds = await loginOAuth();
|
|
632
|
+
} catch (err) {
|
|
633
|
+
console.error(`OAuth login failed: ${err.message}`);
|
|
634
|
+
console.error('');
|
|
635
|
+
console.error('Alternatives:');
|
|
636
|
+
console.error(' teamclaude import Import from existing Claude Code credentials');
|
|
637
|
+
console.error(' teamclaude login --api Add an API key instead');
|
|
638
|
+
process.exit(1);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
await upsertOAuthAccount(config, name, creds, 'login');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── env ─────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
// `teamclaude env [--no-mitm]` — print the export lines that point Claude Code
|
|
647
|
+
// at the proxy, for `eval "$(teamclaude env)"`. Mirrors `teamclaude run`'s
|
|
648
|
+
// environment (MITM forward-proxy by default; --no-mitm for base-URL only) so a
|
|
649
|
+
// tool that spawns claude itself — an agent multiplexer, a CI job, a manual
|
|
650
|
+
// shell — gets the same routing without going through `run`. Only the export
|
|
651
|
+
// lines go to stdout; all guidance goes to stderr so the output stays eval-safe.
|
|
652
|
+
async function envCommand() {
|
|
653
|
+
// Use loadConfig (not loadOrCreateConfig): a query command must never write to
|
|
654
|
+
// stdout — creating a config prints "Created config at …", which would poison
|
|
655
|
+
// `eval "$(teamclaude env)"` — nor silently create config as a side effect.
|
|
656
|
+
const config = await loadConfig();
|
|
657
|
+
if (!config) {
|
|
658
|
+
process.stderr.write(`No config found at ${getConfigPath()}. Add an account first: teamclaude login\n`);
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
const port = config.proxy.port;
|
|
662
|
+
const useMitm = !args.slice(1).includes('--no-mitm');
|
|
663
|
+
|
|
664
|
+
let caPath = null;
|
|
665
|
+
if (useMitm) ({ caPath } = await ensureCerts(upstreamHost(config)));
|
|
666
|
+
|
|
667
|
+
// Same pin as `teamclaude run`, so `eval "$(teamclaude env)"` and `run` agree.
|
|
668
|
+
const account = (process.env.TC_ACCT || '').trim();
|
|
669
|
+
const lines = buildClaudeEnvLines({
|
|
670
|
+
port, useMitm, caPath, holdSeconds: config.holdSeconds,
|
|
671
|
+
account, proxyApiKey: config.proxy?.apiKey || '',
|
|
672
|
+
});
|
|
673
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
674
|
+
|
|
675
|
+
const mode = useMitm ? 'MITM forward-proxy' : 'base-URL';
|
|
676
|
+
process.stderr.write(`# TeamClaude env: ${mode} mode, localhost:${port}\n`);
|
|
677
|
+
if (account) {
|
|
678
|
+
process.stderr.write(`# pinned to account "${account}" (TC_ACCT)\n`);
|
|
679
|
+
// Warn, don't fail: the account list can change before the shell is used,
|
|
680
|
+
// and this command must stay eval-safe.
|
|
681
|
+
if (!(config.accounts || []).some((a, i) => a.name === account || String(i) === account)) {
|
|
682
|
+
process.stderr.write(`# warning: no account named "${account}" in the config — the proxy will refuse this pin\n`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
process.stderr.write(`# apply to this shell: eval "$(teamclaude env${useMitm ? '' : ' --no-mitm'})"\n`);
|
|
686
|
+
if (!(await isProxyUp(port))) {
|
|
687
|
+
process.stderr.write(`# note: proxy not running on port ${port} — start it with: teamclaude server\n`);
|
|
688
|
+
}
|
|
689
|
+
if (config.proxy?.apiKey) {
|
|
690
|
+
process.stderr.write(`# remote (non-loopback) clients must also present the proxy key: ANTHROPIC_API_KEY=<proxy.apiKey> (base-URL), or http://<key>@host:${port} (MITM)\n`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── run ─────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
async function runCommand() {
|
|
697
|
+
const config = await loadOrCreateConfig();
|
|
698
|
+
|
|
699
|
+
// Args after 'run'. teamclaude flags (e.g. --no-mitm) are recognized only
|
|
700
|
+
// before an optional `--` separator; everything after `--` goes verbatim to
|
|
701
|
+
// claude. MITM forward-proxy mode is the default so hardcoded api.anthropic.com
|
|
702
|
+
// endpoints are intercepted too; --no-mitm opts back into base-URL-only routing.
|
|
703
|
+
// --mitm is still accepted (now a no-op) for backward compatibility.
|
|
704
|
+
const rest = args.slice(1);
|
|
705
|
+
const sep = rest.indexOf('--');
|
|
706
|
+
const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
|
|
707
|
+
const useMitm = !tcFlags.includes('--no-mitm');
|
|
708
|
+
const autoFallback = tcFlags.includes('--auto-fallback');
|
|
709
|
+
const claudeArgs = sep >= 0
|
|
710
|
+
? rest.slice(sep + 1)
|
|
711
|
+
: rest.filter(a => a !== '--mitm' && a !== '--no-mitm' && a !== '--auto-fallback');
|
|
712
|
+
|
|
713
|
+
// Route through the proxy when it's up. When it's down we refuse by default —
|
|
714
|
+
// silently launching claude directly hides that requests are bypassing the
|
|
715
|
+
// proxy (no rotation, spending the user's own quota). Pass --auto-fallback to
|
|
716
|
+
// opt back into the transparent direct launch (e.g. for a dumb shell alias).
|
|
717
|
+
const port = config.proxy.port;
|
|
718
|
+
const env = { ...process.env };
|
|
719
|
+
// TC_ACCT pins this session to one account, in either mode. It is teamclaude's
|
|
720
|
+
// own knob, so it never reaches the child: claude has no use for it, and an
|
|
721
|
+
// account name is not something to leak into a subprocess environment that
|
|
722
|
+
// gets inherited by every tool and MCP server claude spawns.
|
|
723
|
+
const tcAcct = (process.env.TC_ACCT || '').trim();
|
|
724
|
+
delete env.TC_ACCT;
|
|
725
|
+
// Legacy: a caller-supplied ANTHROPIC_BASE_URL of http://<this proxy>/tc-acct/…
|
|
726
|
+
// also pins (shipped in 1.1.10). TC_ACCT is the supported way now — it works in
|
|
727
|
+
// MITM mode too, and keeps the pin out of the API path.
|
|
728
|
+
const pinnedBase = isLocalAccountPin(process.env.ANTHROPIC_BASE_URL, port);
|
|
729
|
+
if (await isProxyUp(port)) {
|
|
730
|
+
if (useMitm) {
|
|
731
|
+
// Route ALL of claude's traffic through us as an HTTPS forward proxy, so
|
|
732
|
+
// even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
|
|
733
|
+
// real token injected. claude trusts our MITM leaf via NODE_EXTRA_CA_CERTS.
|
|
734
|
+
const host = upstreamHost(config);
|
|
735
|
+
const { caPath } = await ensureCerts(host);
|
|
736
|
+
// The pin rides in the proxy URL's userinfo, which the client forwards as
|
|
737
|
+
// `Proxy-Authorization: Basic <acct>:<key>` on each CONNECT — the only pin
|
|
738
|
+
// channel an HTTPS_PROXY env var can express. The password slot keeps the
|
|
739
|
+
// proxy apiKey, matching the existing `--proxy http://<key>@host:port`
|
|
740
|
+
// form, so auth and pinning coexist in one URL.
|
|
741
|
+
const userinfo = tcAcct
|
|
742
|
+
? `${encodePinComponent(tcAcct)}:${encodePinComponent(config.proxy?.apiKey || '')}@`
|
|
743
|
+
: '';
|
|
744
|
+
const proxyUrl = `http://${userinfo}127.0.0.1:${port}`;
|
|
745
|
+
env.HTTPS_PROXY = env.HTTP_PROXY = env.https_proxy = env.http_proxy = proxyUrl;
|
|
746
|
+
env.NO_PROXY = env.no_proxy = 'localhost,127.0.0.1,::1';
|
|
747
|
+
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
748
|
+
if (tcAcct) console.error(`[TeamClaude] Pinned to account "${tcAcct}" (TC_ACCT)`);
|
|
749
|
+
else if (pinnedBase) {
|
|
750
|
+
console.error('[TeamClaude] Account pin in ANTHROPIC_BASE_URL ignored: MITM mode does not use a base URL.');
|
|
751
|
+
console.error('[TeamClaude] Use TC_ACCT=<account> instead — it pins in both modes.');
|
|
752
|
+
}
|
|
753
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
754
|
+
} else {
|
|
755
|
+
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
756
|
+
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
757
|
+
// lets Claude Code stay in subscription mode (full model access).
|
|
758
|
+
// TC_ACCT wins; teamclaude builds the pinned URL itself rather than making
|
|
759
|
+
// the caller hand-write one. Otherwise an existing /tc-acct/ base URL
|
|
760
|
+
// pointing at this proxy is preserved for configs written against 1.1.10.
|
|
761
|
+
if (tcAcct) {
|
|
762
|
+
env.ANTHROPIC_BASE_URL = `http://localhost:${port}/tc-acct/${encodePinComponent(tcAcct)}`;
|
|
763
|
+
console.error(`[TeamClaude] Pinned to account "${tcAcct}" (TC_ACCT)`);
|
|
764
|
+
} else if (!pinnedBase) {
|
|
765
|
+
env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} else if (autoFallback) {
|
|
769
|
+
console.error(`[TeamClaude] Proxy not running on port ${port} — launching claude directly (--auto-fallback; start it with: teamclaude server)`);
|
|
770
|
+
} else {
|
|
771
|
+
console.error(`[TeamClaude] Proxy not running on port ${port}.`);
|
|
772
|
+
console.error('Start it with: teamclaude server');
|
|
773
|
+
console.error('Or pass --auto-fallback to launch claude directly (bypassing the proxy) when it is down.');
|
|
774
|
+
process.exit(1);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// If holdSeconds is set, ensure API_TIMEOUT_MS on the Claude Code side is
|
|
778
|
+
// large enough for the hold to complete. Add 60s padding (one extra poll
|
|
779
|
+
// cycle) so the client doesn't time out while we're still waiting.
|
|
780
|
+
// Claude Code defaults API_TIMEOUT_MS to 600000ms (10 min) when unset, so
|
|
781
|
+
// use that as the baseline to avoid accidentally lowering the timeout.
|
|
782
|
+
const holdMs = (config.holdSeconds || 0) * 1000;
|
|
783
|
+
if (holdMs > 0) {
|
|
784
|
+
const needed = holdMs + 60_000;
|
|
785
|
+
const API_TIMEOUT_DEFAULT_MS = 600_000;
|
|
786
|
+
const current = parseInt(env.API_TIMEOUT_MS || '0', 10) || API_TIMEOUT_DEFAULT_MS;
|
|
787
|
+
if (current < needed) env.API_TIMEOUT_MS = String(needed);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Use spawnSync so the Node process blocks entirely — behaves like execvp.
|
|
791
|
+
const result = spawnSync('claude', claudeArgs, {
|
|
792
|
+
stdio: 'inherit',
|
|
793
|
+
shell: process.platform === 'win32',
|
|
794
|
+
env,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
if (result.error) {
|
|
798
|
+
if (result.error.code === 'ENOENT') {
|
|
799
|
+
console.error('Claude Code not found in PATH. Install it first.');
|
|
800
|
+
} else {
|
|
801
|
+
console.error(`Failed to start claude: ${result.error.message}`);
|
|
802
|
+
}
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Session over — check for a newer teamclaude and (for a global npm install)
|
|
807
|
+
// self-update. Throttled to once/day, so this is a no-op on almost every run;
|
|
808
|
+
// it applies to the NEXT launch, never the session that just ran.
|
|
809
|
+
await autoUpdate({ config }).catch(() => {});
|
|
810
|
+
|
|
811
|
+
process.exit(result.status ?? 1);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ── status ──────────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
async function statusCommand() {
|
|
817
|
+
const config = await loadOrCreateConfig();
|
|
818
|
+
const url = `http://localhost:${config.proxy.port}/teamclaude/status`;
|
|
819
|
+
const json = args.includes('--json');
|
|
820
|
+
const colorArg = argValue('--color') || args.find(arg => arg.startsWith('--color='))?.slice('--color='.length);
|
|
821
|
+
const color = colorArg === 'always'
|
|
822
|
+
|| (colorArg !== 'never' && process.stdout.isTTY);
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const res = await fetch(url, { headers: { 'x-api-key': config.proxy.apiKey } });
|
|
826
|
+
const data = await res.json();
|
|
827
|
+
if (json) {
|
|
828
|
+
console.log(JSON.stringify(data, null, 2));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
console.log(renderStatus(data, { color }));
|
|
832
|
+
} catch (err) {
|
|
833
|
+
console.error('Cannot connect to proxy at localhost:' + config.proxy.port);
|
|
834
|
+
console.error('Is the server running? Start with: teamclaude server');
|
|
835
|
+
if (err?.message) console.error(`Details: ${err.message}`);
|
|
836
|
+
process.exit(1);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ── attach ──────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
// The interactive dashboard against a server that is ALREADY running. A proxy
|
|
843
|
+
// installed as a background service has no foreground TUI, so this is the only
|
|
844
|
+
// way to watch and steer it live; it renders from polled status and can only do
|
|
845
|
+
// what the control plane exposes (switch, reload).
|
|
846
|
+
async function attachCommand() {
|
|
847
|
+
const config = await loadOrCreateConfig();
|
|
848
|
+
const port = config.proxy.port;
|
|
849
|
+
// Reach the server where it actually binds (see serverCommand): a host set in
|
|
850
|
+
// the config or the environment is not reachable as localhost, and reporting
|
|
851
|
+
// "not running" for a server that is plainly up is the worst of the answers.
|
|
852
|
+
// A wildcard bind is not an address to dial, so dial this machine instead.
|
|
853
|
+
const bound = process.env.TEAMCLAUDE_HOST || config.proxy.host || '127.0.0.1';
|
|
854
|
+
const host = (bound === '0.0.0.0' || bound === '::') ? '127.0.0.1' : bound;
|
|
855
|
+
|
|
856
|
+
// Checked before connecting: the dashboard needs raw-mode input, and failing
|
|
857
|
+
// on that after a successful poll would be a confusing order to report it in.
|
|
858
|
+
if (!process.stdin.isTTY) {
|
|
859
|
+
console.error('teamclaude attach needs a terminal. For a one-shot readout use: teamclaude status');
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const control = new RemoteControl({ port, host, apiKey: config.proxy.apiKey });
|
|
864
|
+
let first;
|
|
865
|
+
try {
|
|
866
|
+
first = await control.status(); // fail here, with a usable message, not inside the TUI
|
|
867
|
+
} catch (err) {
|
|
868
|
+
console.error(`Cannot connect to proxy at ${host}:${port}`);
|
|
869
|
+
console.error('Is the server running? Start with: teamclaude server');
|
|
870
|
+
if (err?.message) console.error(`Details: ${err.message}`);
|
|
871
|
+
process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
await new Promise(resolve => {
|
|
875
|
+
const session = createAttachSession({ control, config, onQuit: resolve });
|
|
876
|
+
// The status just fetched is the first frame: without it the alt-screen opens
|
|
877
|
+
// on a disconnected, empty dashboard until the first poll lands.
|
|
878
|
+
session.am.applyStatus(first);
|
|
879
|
+
session.start();
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ── switch ──────────────────────────────────────────────────
|
|
884
|
+
|
|
885
|
+
// Manual account switch against a RUNNING server — the headless equivalent of
|
|
886
|
+
// pressing 's' in the TUI, which is unreachable when the proxy runs as a
|
|
887
|
+
// background service. Nothing is written to the config: like the TUI's switch
|
|
888
|
+
// this is a runtime preference that dies with the process, so the server is the
|
|
889
|
+
// only place that can answer or apply it.
|
|
890
|
+
async function switchCommand() {
|
|
891
|
+
const config = await loadOrCreateConfig();
|
|
892
|
+
const port = config.proxy.port;
|
|
893
|
+
const headers = { 'x-api-key': config.proxy.apiKey };
|
|
894
|
+
const name = args[1] && !args[1].startsWith('-') ? args[1] : null;
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
if (!name) {
|
|
898
|
+
const res = await fetch(`http://localhost:${port}/teamclaude/status`, { headers });
|
|
899
|
+
// Something answered on the port. Whether it is our proxy is a separate
|
|
900
|
+
// question, and getting it wrong would blame a down server for a reply we
|
|
901
|
+
// simply could not read — or report an unreadable reply as an empty fleet.
|
|
902
|
+
const data = res.ok ? await res.json().catch(() => null) : null;
|
|
903
|
+
if (!data || !Array.isArray(data.accounts)) {
|
|
904
|
+
console.error(`Unexpected reply from localhost:${port} (HTTP ${res.status}) — no account list in it.`);
|
|
905
|
+
console.error('Something is listening there, but it does not answer like this teamclaude version.');
|
|
906
|
+
process.exit(1);
|
|
907
|
+
}
|
|
908
|
+
if (!data.accounts.length) {
|
|
909
|
+
console.log('No accounts configured.');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
for (const a of data.accounts) {
|
|
913
|
+
// Flag what would stop traffic reaching an account. The TUI shows this in
|
|
914
|
+
// its table, so leaving it out here would make the headless half of the
|
|
915
|
+
// feature the only place a disabled account looks switchable.
|
|
916
|
+
const state = a.disabled ? 'disabled' : (a.status && a.status !== 'active' ? a.status : null);
|
|
917
|
+
console.log(`${a.name === data.currentAccount ? '*' : ' '} ${a.name}${state ? ` (${state})` : ''}`);
|
|
918
|
+
}
|
|
919
|
+
console.log('\nSwitch with: teamclaude switch <name>');
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const res = await fetch(`http://localhost:${port}/teamclaude/switch`, {
|
|
924
|
+
method: 'POST',
|
|
925
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
926
|
+
body: JSON.stringify({ account: name }),
|
|
927
|
+
});
|
|
928
|
+
const data = await res.json().catch(() => ({}));
|
|
929
|
+
if (!res.ok) {
|
|
930
|
+
// Our own errors are strings. A server too old to know this endpoint
|
|
931
|
+
// forwards the request upstream instead, and Anthropic's error is an
|
|
932
|
+
// object — printing that raw gives the user "[object Object]".
|
|
933
|
+
const detail = typeof data.error === 'string' ? data.error : null;
|
|
934
|
+
console.error(detail || `Switch failed: unexpected reply from localhost:${port} (HTTP ${res.status}).`);
|
|
935
|
+
if (!detail) console.error('An older server without this endpoint answers this way; restart it to pick up the new version.');
|
|
936
|
+
if (data.accounts?.length) {
|
|
937
|
+
console.error('Known accounts:');
|
|
938
|
+
for (const n of data.accounts) console.error(` ${n}`);
|
|
939
|
+
}
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
console.log(`Switched to "${data.account}"`);
|
|
943
|
+
// Recorded is not the same as in effect: rotation skips an account it cannot
|
|
944
|
+
// use on the very next request, so saying nothing here would be a quiet lie.
|
|
945
|
+
if (data.eligible === false) {
|
|
946
|
+
console.error(`Warning: "${data.account}" is ${data.reason || 'not currently eligible'}, so requests will not route to it until that changes.`);
|
|
947
|
+
}
|
|
948
|
+
} catch (err) {
|
|
949
|
+
console.error('Cannot connect to proxy at localhost:' + port);
|
|
950
|
+
console.error('Is the server running? Start with: teamclaude server');
|
|
951
|
+
if (err?.message) console.error(`Details: ${err.message}`);
|
|
952
|
+
process.exit(1);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ── accounts ────────────────────────────────────────────────
|
|
957
|
+
|
|
958
|
+
async function accountsCommand() {
|
|
959
|
+
const config = await loadOrCreateConfig();
|
|
960
|
+
const verbose = args.includes('-v') || args.includes('--verbose');
|
|
961
|
+
|
|
962
|
+
if (config.accounts.length === 0) {
|
|
963
|
+
console.log('No accounts configured.');
|
|
964
|
+
console.log('Add one with: teamclaude import, teamclaude login, or teamclaude login --api');
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Refresh expired tokens before fetching profiles
|
|
969
|
+
let configDirty = false;
|
|
970
|
+
await Promise.all(config.accounts.map(async (a) => {
|
|
971
|
+
if (a.type !== 'oauth' || !a.refreshToken) return;
|
|
972
|
+
if (!isTokenExpiringSoon(a.expiresAt)) return;
|
|
973
|
+
try {
|
|
974
|
+
const newTokens = await refreshAccessToken(a.refreshToken);
|
|
975
|
+
a.accessToken = newTokens.accessToken;
|
|
976
|
+
a.refreshToken = newTokens.refreshToken;
|
|
977
|
+
a.expiresAt = newTokens.expiresAt;
|
|
978
|
+
configDirty = true;
|
|
979
|
+
} catch {
|
|
980
|
+
// refresh failed — fetchProfile will report the specific error
|
|
981
|
+
}
|
|
982
|
+
}));
|
|
983
|
+
if (configDirty) await saveConfig(config);
|
|
984
|
+
|
|
985
|
+
// Fetch profiles in parallel for all OAuth accounts
|
|
986
|
+
const profiles = await Promise.all(
|
|
987
|
+
config.accounts.map(a =>
|
|
988
|
+
a.type === 'oauth' && a.accessToken ? fetchProfile(a.accessToken) : null
|
|
989
|
+
)
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
// Backfill account+org identity from profiles, then deduplicate by
|
|
993
|
+
// (accountUuid, org): the same person in a different org is a distinct
|
|
994
|
+
// account, not a duplicate. Keep the last (most recently added) entry.
|
|
995
|
+
const seen = new Map();
|
|
996
|
+
let removed = 0;
|
|
997
|
+
let touched = false;
|
|
998
|
+
for (let i = config.accounts.length - 1; i >= 0; i--) {
|
|
999
|
+
const a = config.accounts[i];
|
|
1000
|
+
const p = profiles[i];
|
|
1001
|
+
if (p && !p.error) {
|
|
1002
|
+
if (p.accountUuid && a.accountUuid !== p.accountUuid) { a.accountUuid = p.accountUuid; touched = true; }
|
|
1003
|
+
if (p.orgUuid && a.orgUuid !== p.orgUuid) { a.orgUuid = p.orgUuid; touched = true; }
|
|
1004
|
+
if (p.orgName && a.orgName !== p.orgName) { a.orgName = p.orgName; touched = true; }
|
|
1005
|
+
}
|
|
1006
|
+
const uuid = a.accountUuid;
|
|
1007
|
+
if (!uuid) continue;
|
|
1008
|
+
const key = `${uuid}::${orgKey(a) || ''}`;
|
|
1009
|
+
if (seen.has(key)) {
|
|
1010
|
+
config.accounts.splice(i, 1);
|
|
1011
|
+
profiles.splice(i, 1);
|
|
1012
|
+
removed++;
|
|
1013
|
+
touched = true;
|
|
1014
|
+
} else {
|
|
1015
|
+
seen.set(key, i);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Name accounts from their email: plain when the person has a single org,
|
|
1020
|
+
// "email (Org)" when the same person spans multiple orgs. Names must stay
|
|
1021
|
+
// unique — they are the user-facing key for remove/api/selection.
|
|
1022
|
+
const orgCount = new Map();
|
|
1023
|
+
for (const a of config.accounts) {
|
|
1024
|
+
if (a.accountUuid) orgCount.set(a.accountUuid, (orgCount.get(a.accountUuid) || 0) + 1);
|
|
1025
|
+
}
|
|
1026
|
+
for (const [i, a] of config.accounts.entries()) {
|
|
1027
|
+
const p = profiles[i];
|
|
1028
|
+
const email = (p && !p.error && p.email) ? p.email : null;
|
|
1029
|
+
if (!email) continue;
|
|
1030
|
+
const newName = orgCount.get(a.accountUuid) > 1 ? `${email} (${orgLabel(a)})` : email;
|
|
1031
|
+
if (a.name !== newName) { a.name = newName; touched = true; }
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (touched) await saveConfig(config);
|
|
1035
|
+
if (removed > 0) console.log(`Removed ${removed} duplicate account(s)\n`);
|
|
1036
|
+
|
|
1037
|
+
for (const [i, a] of config.accounts.entries()) {
|
|
1038
|
+
const p = profiles[i];
|
|
1039
|
+
|
|
1040
|
+
if (a.type === 'apikey') {
|
|
1041
|
+
console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// OAuth account
|
|
1046
|
+
const hasProfile = p && !p.error;
|
|
1047
|
+
const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
|
|
1048
|
+
const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
|
|
1049
|
+
const src = a.source ? `, ${a.source}` : '';
|
|
1050
|
+
console.log(` [${i + 1}] ${a.name} (${status}${src})`);
|
|
1051
|
+
if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
|
|
1052
|
+
if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
|
|
1053
|
+
// The stable pin identity (TC_ACCT), unlike the display name above.
|
|
1054
|
+
if (a.accountUuid) console.log(` ID: ${a.accountUuid}`);
|
|
1055
|
+
if (verbose && a.expiresAt) {
|
|
1056
|
+
const remaining = a.expiresAt - Date.now();
|
|
1057
|
+
if (remaining <= 0) {
|
|
1058
|
+
console.log(` Token: expired`);
|
|
1059
|
+
} else {
|
|
1060
|
+
const mins = Math.floor(remaining / 60000);
|
|
1061
|
+
const hrs = Math.floor(mins / 60);
|
|
1062
|
+
const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
1063
|
+
console.log(` Token: expires in ${expiry}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── api ─────────────────────────────────────────────────────
|
|
1070
|
+
|
|
1071
|
+
async function apiCommand() {
|
|
1072
|
+
const config = await loadOrCreateConfig();
|
|
1073
|
+
const path = args[1];
|
|
1074
|
+
|
|
1075
|
+
if (!path) {
|
|
1076
|
+
console.error('Usage: teamclaude api <path> [--account NAME] [--method POST] [--data JSON]');
|
|
1077
|
+
console.error('Example: teamclaude api /api/oauth/claude_cli/roles');
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Find account to use
|
|
1082
|
+
const accountName = argValue('--account');
|
|
1083
|
+
const method = (argValue('--method') || 'GET').toUpperCase();
|
|
1084
|
+
const data = argValue('--data');
|
|
1085
|
+
|
|
1086
|
+
const accounts = await resolveAccounts(config);
|
|
1087
|
+
let account;
|
|
1088
|
+
if (accountName) {
|
|
1089
|
+
account = resolveAccount(accounts, accountName, argValue('--org'));
|
|
1090
|
+
if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
|
|
1091
|
+
} else {
|
|
1092
|
+
account = accounts.find(a => a.type === 'oauth') || accounts[0];
|
|
1093
|
+
if (!account) { console.error('No accounts configured'); process.exit(1); }
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const credential = account.accessToken || account.apiKey;
|
|
1097
|
+
const isOAuth = account.type === 'oauth';
|
|
1098
|
+
const upstream = config.upstream || 'https://api.anthropic.com';
|
|
1099
|
+
const url = path.startsWith('http') ? path : `${upstream}${path}`;
|
|
1100
|
+
|
|
1101
|
+
const headers = isOAuth
|
|
1102
|
+
? { 'Authorization': `Bearer ${credential}` }
|
|
1103
|
+
: { 'x-api-key': credential };
|
|
1104
|
+
|
|
1105
|
+
const fetchOpts = { method, headers };
|
|
1106
|
+
if (data) {
|
|
1107
|
+
headers['Content-Type'] = 'application/json';
|
|
1108
|
+
fetchOpts.body = data;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const res = await fetch(url, fetchOpts);
|
|
1112
|
+
|
|
1113
|
+
// Print response headers to stderr
|
|
1114
|
+
console.error(`${res.status} ${res.statusText}`);
|
|
1115
|
+
for (const [k, v] of res.headers.entries()) {
|
|
1116
|
+
console.error(` ${k}: ${v}`);
|
|
1117
|
+
}
|
|
1118
|
+
console.error('');
|
|
1119
|
+
|
|
1120
|
+
// Print body to stdout
|
|
1121
|
+
const body = await res.text();
|
|
1122
|
+
try {
|
|
1123
|
+
console.log(JSON.stringify(JSON.parse(body), null, 2));
|
|
1124
|
+
} catch {
|
|
1125
|
+
console.log(body);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ── alias ───────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
function aliasCommand() {
|
|
1132
|
+
const shell = argValue('--shell') || undefined;
|
|
1133
|
+
if (args.includes('--uninstall')) {
|
|
1134
|
+
alias.uninstallAlias({ shell });
|
|
1135
|
+
} else if (args.includes('--install')) {
|
|
1136
|
+
alias.installAlias({ shell });
|
|
1137
|
+
} else {
|
|
1138
|
+
alias.printAlias({ shell });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ── service ─────────────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
async function serviceCommand() {
|
|
1145
|
+
const sub = args[1] || 'status';
|
|
1146
|
+
const kind = serviceKind();
|
|
1147
|
+
if (!kind) {
|
|
1148
|
+
console.error(`teamclaude service: no service integration for ${process.platform}`);
|
|
1149
|
+
console.error('Run the proxy yourself with: teamclaude server --headless');
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
// Carry an explicit config path into the unit: a service started by launchd or
|
|
1153
|
+
// systemd does not inherit the shell's TEAMCLAUDE_CONFIG, so a non-default
|
|
1154
|
+
// config would silently be ignored and the service would serve a different
|
|
1155
|
+
// (or empty) account list than the CLI does.
|
|
1156
|
+
const configPath = process.env.TEAMCLAUDE_CONFIG || null;
|
|
1157
|
+
|
|
1158
|
+
switch (sub) {
|
|
1159
|
+
case 'install': {
|
|
1160
|
+
const res = await installService({ configPath });
|
|
1161
|
+
if (!res.ok) { console.error(`teamclaude service install failed: ${res.error}`); process.exit(1); }
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
case 'uninstall': {
|
|
1165
|
+
const res = await uninstallService();
|
|
1166
|
+
if (!res.ok) { console.error(`teamclaude service uninstall failed: ${res.error}`); process.exit(1); }
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
case 'print':
|
|
1170
|
+
process.stdout.write(renderService({ configPath }));
|
|
1171
|
+
break;
|
|
1172
|
+
case 'status': {
|
|
1173
|
+
const s = await serviceStatus();
|
|
1174
|
+
console.log(`Service: ${s.installed ? s.file : 'not installed'}`);
|
|
1175
|
+
console.log(`State: ${s.running ? `running${s.pid ? ` (pid ${s.pid})` : ''}` : s.detail}`);
|
|
1176
|
+
if (kind === 'launchd') console.log(`Logs: ${logPath()}`);
|
|
1177
|
+
else console.log('Logs: journalctl --user --unit teamclaude.service');
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
default:
|
|
1181
|
+
console.error('Usage: teamclaude service <install|uninstall|status|print>');
|
|
1182
|
+
process.exit(1);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ── probe ───────────────────────────────────────────────────
|
|
1187
|
+
|
|
1188
|
+
async function probeCommand() {
|
|
1189
|
+
const config = await loadOrCreateConfig();
|
|
1190
|
+
const arg = args[1];
|
|
1191
|
+
|
|
1192
|
+
if (arg === undefined) {
|
|
1193
|
+
const cur = config.quotaProbeSeconds || 0;
|
|
1194
|
+
console.log(cur > 0 ? `Quota probe: every ${cur}s` : 'Quota probe: off (passive only)');
|
|
1195
|
+
console.log('Set with: teamclaude probe <off|seconds> e.g. teamclaude probe 300');
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
let seconds;
|
|
1200
|
+
if (arg === 'off' || arg === '0') {
|
|
1201
|
+
seconds = 0;
|
|
1202
|
+
} else {
|
|
1203
|
+
seconds = parseInt(arg, 10);
|
|
1204
|
+
if (Number.isNaN(seconds) || seconds < 0) {
|
|
1205
|
+
console.error('Usage: teamclaude probe <off|seconds>');
|
|
1206
|
+
process.exit(1);
|
|
1207
|
+
}
|
|
1208
|
+
if (seconds > 0 && seconds < 30) {
|
|
1209
|
+
console.error('Minimum probe interval is 30s (to avoid hammering the usage endpoint).');
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
config.quotaProbeSeconds = seconds;
|
|
1215
|
+
await saveConfig(config);
|
|
1216
|
+
console.log(seconds > 0
|
|
1217
|
+
? `Quota probe set to every ${seconds}s (reads /api/oauth/usage; does not spend quota).`
|
|
1218
|
+
: 'Quota probe disabled (passive only).');
|
|
1219
|
+
await notifyRunningServer(config);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ── warmup ──────────────────────────────────────────────────
|
|
1223
|
+
|
|
1224
|
+
async function warmupCommand() {
|
|
1225
|
+
const config = await loadOrCreateConfig();
|
|
1226
|
+
const arg = args[1];
|
|
1227
|
+
|
|
1228
|
+
if (arg === undefined) {
|
|
1229
|
+
const cur = config.warmupSeconds || 0;
|
|
1230
|
+
console.log(cur > 0 ? `Keep-warm: every ${cur}s` : 'Keep-warm: off');
|
|
1231
|
+
console.log('Set with: teamclaude warmup <off|seconds> e.g. teamclaude warmup 600');
|
|
1232
|
+
console.log('Note: warming spawns a minimal `claude` per idle account and DOES spend a little quota');
|
|
1233
|
+
console.log('(unlike the passive quota probe). It only warms accounts whose 5h window is idle.');
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
let seconds;
|
|
1238
|
+
if (arg === 'off' || arg === '0') {
|
|
1239
|
+
seconds = 0;
|
|
1240
|
+
} else {
|
|
1241
|
+
seconds = parseInt(arg, 10);
|
|
1242
|
+
if (Number.isNaN(seconds) || seconds < 0) {
|
|
1243
|
+
console.error('Usage: teamclaude warmup <off|seconds>');
|
|
1244
|
+
process.exit(1);
|
|
1245
|
+
}
|
|
1246
|
+
if (seconds > 0 && seconds < 60) {
|
|
1247
|
+
console.error('Minimum keep-warm interval is 60s.');
|
|
1248
|
+
process.exit(1);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
config.warmupSeconds = seconds;
|
|
1253
|
+
await saveConfig(config);
|
|
1254
|
+
console.log(seconds > 0
|
|
1255
|
+
? `Keep-warm set to every ${seconds}s (spawns a minimal \`claude\` per idle account; spends a little quota).`
|
|
1256
|
+
: 'Keep-warm disabled.');
|
|
1257
|
+
await notifyRunningServer(config);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ── update ──────────────────────────────────────────────────
|
|
1261
|
+
|
|
1262
|
+
async function updateCommand() {
|
|
1263
|
+
const cur = currentVersion();
|
|
1264
|
+
console.log(`Current version: ${cur || 'unknown'}`);
|
|
1265
|
+
|
|
1266
|
+
const kind = installKind();
|
|
1267
|
+
if (kind === 'git') {
|
|
1268
|
+
console.log('This is a git checkout — update it with `git pull`, not npm.');
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const info = await checkForUpdate({ force: true });
|
|
1273
|
+
if (!info) {
|
|
1274
|
+
console.error('Could not reach the npm registry to check for updates.');
|
|
1275
|
+
process.exitCode = 1;
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (!info.updateAvailable) {
|
|
1279
|
+
console.log(`Already up to date (latest is ${info.latest}).`);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
console.log(`Updating ${info.current} → ${info.latest} …`);
|
|
1284
|
+
const ok = runUpdate(info.latest);
|
|
1285
|
+
if (ok) {
|
|
1286
|
+
console.log(`Updated to ${info.latest}. Restart teamclaude to use the new version.`);
|
|
1287
|
+
} else {
|
|
1288
|
+
console.error(`Update failed. Try manually: npm install -g ${PKG_NAME}@latest`);
|
|
1289
|
+
process.exitCode = 1;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── remove ──────────────────────────────────────────────────
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Resolve a single account from a name-or-email query.
|
|
1297
|
+
*
|
|
1298
|
+
* An exact display-name match wins. Otherwise match by email (the part before a
|
|
1299
|
+
* " (org)" suffix), optionally narrowed by --org. If still ambiguous across
|
|
1300
|
+
* orgs, print the candidates and exit so the caller can disambiguate with --org.
|
|
1301
|
+
* Returns the matched account, or null if nothing matched.
|
|
1302
|
+
*/
|
|
1303
|
+
function resolveAccount(accounts, query, orgFilter) {
|
|
1304
|
+
const matches = matchAccounts(accounts, query, orgFilter);
|
|
1305
|
+
if (matches.length === 1) return matches[0];
|
|
1306
|
+
if (matches.length === 0) return null;
|
|
1307
|
+
console.error(`"${query}" matches ${matches.length} accounts — disambiguate with --org <name|uuid>:`);
|
|
1308
|
+
for (const a of matches) {
|
|
1309
|
+
console.error(` - ${a.name}${a.orgName ? ` (org: ${a.orgName})` : ''}`);
|
|
1310
|
+
}
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function removeCommand() {
|
|
1315
|
+
const config = await loadOrCreateConfig();
|
|
1316
|
+
const name = args[1];
|
|
1317
|
+
|
|
1318
|
+
if (!name) {
|
|
1319
|
+
console.error('Usage: teamclaude remove <account-name|email> [--org <name|uuid>]');
|
|
1320
|
+
process.exit(1);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const account = resolveAccount(config.accounts, name, argValue('--org'));
|
|
1324
|
+
if (!account) {
|
|
1325
|
+
console.error(`Account "${name}" not found`);
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
config.accounts.splice(config.accounts.indexOf(account), 1);
|
|
1330
|
+
await saveConfig(config);
|
|
1331
|
+
console.log(`Removed account "${account.name}"`);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ── route ───────────────────────────────────────────────────
|
|
1335
|
+
|
|
1336
|
+
const ROUTE_USAGE = [
|
|
1337
|
+
'Usage: teamclaude route [list]',
|
|
1338
|
+
' teamclaude route add <name> --match "<glob>[,<glob>]" [--accounts "<name-or-index>[,...]"] [--bucket <quota-bucket>] [--color <name>]',
|
|
1339
|
+
' teamclaude route rm <name>',
|
|
1340
|
+
'',
|
|
1341
|
+
'A route pins model ids matching its globs to an exclusive set of accounts.',
|
|
1342
|
+
'Omit --accounts to route to all accounts (e.g. just to override --bucket).',
|
|
1343
|
+
'--color (red/green/yellow/blue/magenta/cyan) tints the route\'s inline marker in the TUI.',
|
|
1344
|
+
'First matching route wins. Changes apply to a running server immediately.',
|
|
1345
|
+
].join('\n');
|
|
1346
|
+
|
|
1347
|
+
const ROUTE_COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'];
|
|
1348
|
+
|
|
1349
|
+
function splitList(value) {
|
|
1350
|
+
return (value || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function routeCommand() {
|
|
1354
|
+
const sub = args[1] || 'list';
|
|
1355
|
+
const config = await loadOrCreateConfig();
|
|
1356
|
+
config.routes = Array.isArray(config.routes) ? config.routes : [];
|
|
1357
|
+
|
|
1358
|
+
if (sub === 'list') {
|
|
1359
|
+
if (!config.routes.length) { console.log('No routes configured.'); return; }
|
|
1360
|
+
for (const r of config.routes) {
|
|
1361
|
+
const match = (Array.isArray(r.match) ? r.match : [r.match]).join(', ');
|
|
1362
|
+
const accts = (r.accounts && r.accounts.length) ? r.accounts.join(', ') : '(all accounts)';
|
|
1363
|
+
const bucket = r.bucket ? ` bucket=${r.bucket}` : '';
|
|
1364
|
+
const color = r.color ? ` color=${r.color}` : '';
|
|
1365
|
+
console.log(`${r.name || '(unnamed)'}: ${match} → ${accts}${bucket}${color}`);
|
|
1366
|
+
}
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (sub === 'add') {
|
|
1371
|
+
const name = args[2] && !args[2].startsWith('--') ? args[2] : null;
|
|
1372
|
+
const match = splitList(argValue('--match'));
|
|
1373
|
+
const accounts = splitList(argValue('--accounts'));
|
|
1374
|
+
const bucket = argValue('--bucket');
|
|
1375
|
+
const color = argValue('--color');
|
|
1376
|
+
if (!name || !match.length) {
|
|
1377
|
+
console.error(ROUTE_USAGE);
|
|
1378
|
+
process.exit(1);
|
|
1379
|
+
}
|
|
1380
|
+
if (color && !ROUTE_COLORS.includes(color.toLowerCase())) {
|
|
1381
|
+
console.error(`Unknown color "${color}" — expected one of: ${ROUTE_COLORS.join(', ')}`);
|
|
1382
|
+
process.exit(1);
|
|
1383
|
+
}
|
|
1384
|
+
const known = new Set(config.accounts.map(a => a.name));
|
|
1385
|
+
for (const a of accounts) {
|
|
1386
|
+
if (!known.has(a) && !/^\d+$/.test(a)) console.error(`Warning: no account named "${a}" (yet)`);
|
|
1387
|
+
}
|
|
1388
|
+
const route = { name, match };
|
|
1389
|
+
if (accounts.length) route.accounts = accounts;
|
|
1390
|
+
if (bucket) route.bucket = bucket;
|
|
1391
|
+
if (color) route.color = color.toLowerCase();
|
|
1392
|
+
const at = config.routes.findIndex(r => r.name === name);
|
|
1393
|
+
if (at >= 0) { config.routes[at] = route; console.log(`Updated route "${name}"`); }
|
|
1394
|
+
else { config.routes.push(route); console.log(`Added route "${name}"`); }
|
|
1395
|
+
await saveConfig(config);
|
|
1396
|
+
await notifyRunningServer(config);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
1401
|
+
const name = args[2];
|
|
1402
|
+
const before = config.routes.length;
|
|
1403
|
+
config.routes = config.routes.filter(r => r.name !== name);
|
|
1404
|
+
if (config.routes.length === before) { console.error(`Route "${name}" not found`); process.exit(1); }
|
|
1405
|
+
await saveConfig(config);
|
|
1406
|
+
await notifyRunningServer(config);
|
|
1407
|
+
console.log(`Removed route "${name}"`);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
console.error(ROUTE_USAGE);
|
|
1412
|
+
process.exit(1);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ── priority ────────────────────────────────────────────────
|
|
1416
|
+
|
|
1417
|
+
async function priorityCommand() {
|
|
1418
|
+
const config = await loadOrCreateConfig();
|
|
1419
|
+
const name = args[1];
|
|
1420
|
+
|
|
1421
|
+
if (!name) {
|
|
1422
|
+
console.error('Usage: teamclaude priority <account-name|email> <n> [--org <name|uuid>]');
|
|
1423
|
+
console.error(' teamclaude priority <account-name|email> --first | --last');
|
|
1424
|
+
console.error('Lower priority is preferred for rotation (default 0).');
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const account = resolveAccount(config.accounts, name, argValue('--org'));
|
|
1429
|
+
if (!account) {
|
|
1430
|
+
console.error(`Account "${name}" not found`);
|
|
1431
|
+
process.exit(1);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const priorities = config.accounts.map(a => a.priority || 0);
|
|
1435
|
+
let priority;
|
|
1436
|
+
if (args.includes('--first')) {
|
|
1437
|
+
priority = Math.min(0, ...priorities) - 1;
|
|
1438
|
+
} else if (args.includes('--last')) {
|
|
1439
|
+
priority = Math.max(0, ...priorities) + 1;
|
|
1440
|
+
} else {
|
|
1441
|
+
// Accept the integer in any position (e.g. after --org) — first int-looking token.
|
|
1442
|
+
const numTok = args.slice(2).find(t => /^-?\d+$/.test(t));
|
|
1443
|
+
priority = numTok != null ? parseInt(numTok, 10) : NaN;
|
|
1444
|
+
if (Number.isNaN(priority)) {
|
|
1445
|
+
console.error('Provide an integer priority, or --first / --last.');
|
|
1446
|
+
process.exit(1);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
account.priority = priority;
|
|
1451
|
+
await saveConfig(config);
|
|
1452
|
+
console.log(`Set priority of "${account.name}" to ${priority} (lower = preferred)`);
|
|
1453
|
+
await notifyRunningServer(config);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// ── enable / disable ────────────────────────────────────────
|
|
1457
|
+
|
|
1458
|
+
async function setDisabledCommand(disabled) {
|
|
1459
|
+
const config = await loadOrCreateConfig();
|
|
1460
|
+
const name = args[1];
|
|
1461
|
+
const verb = disabled ? 'disable' : 'enable';
|
|
1462
|
+
|
|
1463
|
+
if (!name) {
|
|
1464
|
+
console.error(`Usage: teamclaude ${verb} <account-name|email> [--org <name|uuid>]`);
|
|
1465
|
+
process.exit(1);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const account = resolveAccount(config.accounts, name, argValue('--org'));
|
|
1469
|
+
if (!account) {
|
|
1470
|
+
console.error(`Account "${name}" not found`);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (disabled) {
|
|
1475
|
+
account.disabled = true;
|
|
1476
|
+
} else {
|
|
1477
|
+
delete account.disabled;
|
|
1478
|
+
}
|
|
1479
|
+
await saveConfig(config);
|
|
1480
|
+
console.log(`${disabled ? 'Disabled' : 'Enabled'} account "${account.name}"`);
|
|
1481
|
+
await notifyRunningServer(config);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ── help ────────────────────────────────────────────────────
|
|
1485
|
+
|
|
1486
|
+
function showHelp() {
|
|
1487
|
+
console.log(`TeamClaude - Multi-account Claude proxy
|
|
1488
|
+
|
|
1489
|
+
Usage: teamclaude [command] [options]
|
|
1490
|
+
|
|
1491
|
+
Commands:
|
|
1492
|
+
server Start the proxy server (default; --headless to skip the TUI)
|
|
1493
|
+
import Import credentials from Claude Code
|
|
1494
|
+
login OAuth login via browser
|
|
1495
|
+
login --api Add an API key account
|
|
1496
|
+
env [--no-mitm] Print export lines to point Claude Code at the proxy, for
|
|
1497
|
+
'eval "$(teamclaude env)"' (MITM forward-proxy by default;
|
|
1498
|
+
--no-mitm for base-URL only). Handy for agent multiplexers
|
|
1499
|
+
that spawn claude themselves instead of via 'teamclaude run'
|
|
1500
|
+
run [--no-mitm] [--auto-fallback] [-- args...]
|
|
1501
|
+
Run Claude Code through the proxy (errors if it's down,
|
|
1502
|
+
unless --auto-fallback launches claude directly instead).
|
|
1503
|
+
Routes via an HTTPS forward proxy + local CA by default, so
|
|
1504
|
+
even hardcoded api.anthropic.com endpoints are intercepted;
|
|
1505
|
+
--no-mitm uses base-URL routing only. Set TC_ACCT to pin
|
|
1506
|
+
the session to one account (see Environment below)
|
|
1507
|
+
alias Print a shell alias so plain 'claude' routes via the proxy
|
|
1508
|
+
(--install to write it to your shell rc; --uninstall to remove)
|
|
1509
|
+
service <sub> Run the proxy as a user service that starts at login and
|
|
1510
|
+
restarts on its own: install | uninstall | status | print
|
|
1511
|
+
(LaunchAgent on macOS, systemd --user unit on Linux;
|
|
1512
|
+
'print' writes the unit to stdout without touching anything)
|
|
1513
|
+
status [--json] Show rich proxy/account/probe status (live)
|
|
1514
|
+
Use --color=always|never to control ANSI colors
|
|
1515
|
+
attach Open the live dashboard against a running server; s
|
|
1516
|
+
switches account, R reloads config, q leaves it running
|
|
1517
|
+
accounts List configured accounts
|
|
1518
|
+
switch [NAME] Make the running server prefer one account (as 's' in the
|
|
1519
|
+
TUI does); with no NAME, list accounts and mark the current
|
|
1520
|
+
remove <name> Remove an account (by name or email; --org to disambiguate)
|
|
1521
|
+
disable <name> Temporarily exclude an account from rotation
|
|
1522
|
+
enable <name> Re-enable a disabled account (also clears a stuck error)
|
|
1523
|
+
priority <name> <n> Set rotation priority (lower = preferred; --first/--last)
|
|
1524
|
+
route [list|add|rm] Per-model routing: pin model globs to specific accounts
|
|
1525
|
+
(add <name> --match "<glob>" [--accounts "<name>"] [--bucket <b>])
|
|
1526
|
+
probe [off|secs] Opt-in background quota refresh for idle accounts
|
|
1527
|
+
(off by default; reads usage endpoint, spends no quota)
|
|
1528
|
+
warmup [off|secs] Opt-in: keep idle accounts' 5h timers running by sending
|
|
1529
|
+
a minimal claude request to each (off by default; spends
|
|
1530
|
+
a little quota, unlike probe)
|
|
1531
|
+
api <path> Call an API endpoint with account credentials
|
|
1532
|
+
update Check npm for a newer teamclaude and install it
|
|
1533
|
+
version Print the installed version
|
|
1534
|
+
help Show this help
|
|
1535
|
+
|
|
1536
|
+
Options:
|
|
1537
|
+
--name NAME Set account name (import/login)
|
|
1538
|
+
--org NAME|UUID Disambiguate when an email spans multiple orgs (remove/priority/api)
|
|
1539
|
+
--from PATH Credentials path (import, default: ~/.claude/.credentials.json;
|
|
1540
|
+
on macOS the default falls back to the Keychain)
|
|
1541
|
+
--json JSON Import from inline JSON (import), e.g.:
|
|
1542
|
+
--json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
|
|
1543
|
+
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
1544
|
+
--activity-log FILE Append TUI activity lines to FILE (server; works in headless mode too)
|
|
1545
|
+
--headless Run the server without the interactive TUI (for backgrounding)
|
|
1546
|
+
--no-mitm (run) skip the forward proxy; route via ANTHROPIC_BASE_URL only
|
|
1547
|
+
--auto-fallback (run) if the proxy is down, launch claude directly instead
|
|
1548
|
+
of erroring out (bypasses the proxy: no rotation)
|
|
1549
|
+
|
|
1550
|
+
Environment:
|
|
1551
|
+
TC_ACCT Pin a session to ONE account, bypassing rotation. Works in
|
|
1552
|
+
both modes. Accepts accountUuid, orgUuid,
|
|
1553
|
+
accountUuid/orgUuid, or a display name/email:
|
|
1554
|
+
TC_ACCT=me@example.com teamclaude run
|
|
1555
|
+
Prefer a UUID for anything scripted: display names are
|
|
1556
|
+
rewritten when an email gains a second org. Read by 'run'
|
|
1557
|
+
and 'env', then removed from the environment so it never
|
|
1558
|
+
reaches claude or the tools it spawns. An unknown account
|
|
1559
|
+
is refused rather than silently rotated.
|
|
1560
|
+
TEAMCLAUDE_CONFIG Path to the config file (default below)
|
|
1561
|
+
TEAMCLAUDE_DISABLE_AUTOUPDATE=1
|
|
1562
|
+
Skip the background self-update check
|
|
1563
|
+
|
|
1564
|
+
The server always accepts both base-URL and proxy/CONNECT clients, so instances
|
|
1565
|
+
launched with and without --no-mitm can share one server.
|
|
1566
|
+
|
|
1567
|
+
A running server re-syncs accounts from config on POST /teamclaude/reload
|
|
1568
|
+
(local only). add/login/enable/disable/priority trigger it automatically.
|
|
1569
|
+
POST /teamclaude/switch {"account": "<name>"} makes one account the preferred
|
|
1570
|
+
one, which is what 'teamclaude switch' calls.
|
|
1571
|
+
|
|
1572
|
+
Upstream proxy. On a host with no direct route to the internet, set
|
|
1573
|
+
"upstreamProxy": "http://user:pass@host:3128" (or just "host:3128") and every
|
|
1574
|
+
outbound connection — request forwarding, OAuth login, token refresh, profile
|
|
1575
|
+
and usage — is CONNECT-tunneled through it, TLS end to end. HTTPS_PROXY /
|
|
1576
|
+
ALL_PROXY are honored when the config says nothing, NO_PROXY exempts hosts, and
|
|
1577
|
+
"upstreamProxy": false ignores the environment entirely. Settable live in the
|
|
1578
|
+
TUI settings screen. Distinct from "proxy" (the local port Claude Code talks to)
|
|
1579
|
+
and from sx.org (a specific residential-egress provider with its own policy).
|
|
1580
|
+
|
|
1581
|
+
Egress pin (opt-in, off unless configured). Set "egress": { "pin": "auto" } to
|
|
1582
|
+
hold requests whenever the exit IP is not the pinned one — a VPN that dropped
|
|
1583
|
+
mid-session otherwise sends the request from an unexpected region, and upstream
|
|
1584
|
+
answers 403, which Claude Code reports as a dead session and demands a re-login.
|
|
1585
|
+
"auto" pins whatever address the server sees first; an explicit IP (or a list of
|
|
1586
|
+
them) pins those. Held requests wait up to holdSeconds (default 120), then get a
|
|
1587
|
+
503. See config.example.json.
|
|
1588
|
+
|
|
1589
|
+
A global npm install self-updates in the background (checked once/day, applied
|
|
1590
|
+
on the next launch). Disable with TEAMCLAUDE_DISABLE_AUTOUPDATE=1 or
|
|
1591
|
+
"autoUpdate": false in the config.
|
|
1592
|
+
|
|
1593
|
+
Config: ${getConfigPath()}
|
|
1594
|
+
Crash log: ${getCrashLogPath()} (server; written when the process dies unexpectedly)
|
|
1595
|
+
`);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ── shared account upsert ────────────────────────────────────
|
|
1599
|
+
|
|
1600
|
+
/** Short human label for an account's organization, for disambiguating names. */
|
|
1601
|
+
function orgLabel(a) {
|
|
1602
|
+
return a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
1606
|
+
// Fetch profile to auto-name and deduplicate by account+org identity.
|
|
1607
|
+
const userNamed = !!name;
|
|
1608
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
1609
|
+
const profileOk = profile && !profile.error;
|
|
1610
|
+
|
|
1611
|
+
if (!profileOk) {
|
|
1612
|
+
console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
|
|
1613
|
+
}
|
|
1614
|
+
if (!name && profile?.email) {
|
|
1615
|
+
name = profile.email;
|
|
1616
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
1617
|
+
if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
|
|
1618
|
+
}
|
|
1619
|
+
if (!name) {
|
|
1620
|
+
const n = config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
|
|
1621
|
+
name = `account-${n}`;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const account = {
|
|
1625
|
+
name,
|
|
1626
|
+
type: 'oauth',
|
|
1627
|
+
source,
|
|
1628
|
+
accountUuid: profile?.accountUuid || null,
|
|
1629
|
+
orgUuid: profile?.orgUuid || null,
|
|
1630
|
+
orgName: profile?.orgName || null,
|
|
1631
|
+
accessToken: creds.accessToken,
|
|
1632
|
+
refreshToken: creds.refreshToken,
|
|
1633
|
+
expiresAt: creds.expiresAt,
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
// Deduplicate by account+org identity (same email in a different org is a
|
|
1637
|
+
// distinct account), then by name — but only where the name is not standing in
|
|
1638
|
+
// for a different account+org, which is exactly the multi-org case below.
|
|
1639
|
+
const idx = findUpsertTarget(config.accounts, account);
|
|
1640
|
+
|
|
1641
|
+
if (idx >= 0) {
|
|
1642
|
+
// Same account+org: refresh credentials and org info, but keep the existing
|
|
1643
|
+
// display name and any disk-only fields (e.g. importFrom).
|
|
1644
|
+
const prev = config.accounts[idx];
|
|
1645
|
+
config.accounts[idx] = { ...prev, ...account, name: prev.name };
|
|
1646
|
+
console.log(`Updated account "${prev.name}"`);
|
|
1647
|
+
} else {
|
|
1648
|
+
// New org for this person: if another entry shares the accountUuid, the bare
|
|
1649
|
+
// email name would collide — disambiguate both with " (org)".
|
|
1650
|
+
if (!userNamed && account.accountUuid) {
|
|
1651
|
+
const collisions = config.accounts.filter(
|
|
1652
|
+
a => a.accountUuid === account.accountUuid && !sameIdentity(a, account)
|
|
1653
|
+
);
|
|
1654
|
+
if (collisions.length > 0) {
|
|
1655
|
+
for (const c of collisions) {
|
|
1656
|
+
if (!c.name.includes(' (')) c.name = `${c.name} (${orgLabel(c)})`;
|
|
1657
|
+
}
|
|
1658
|
+
account.name = `${name} (${orgLabel(account)})`;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
config.accounts.push(account);
|
|
1662
|
+
console.log(`Added account "${account.name}"`);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
await saveConfig(config);
|
|
1666
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
1667
|
+
await notifyRunningServer(config);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// ── config sync helpers ─────────────────────────────────────
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Find a config account entry matching an in-memory account by account+org identity.
|
|
1674
|
+
*/
|
|
1675
|
+
function findConfigAccount(diskConfig, account) {
|
|
1676
|
+
return diskConfig.accounts.findIndex(a => sameIdentity(a, account));
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Sync accounts from disk config: add new accounts and refresh credentials
|
|
1681
|
+
* for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
|
|
1682
|
+
* Returns the number of new accounts added.
|
|
1683
|
+
*/
|
|
1684
|
+
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
1685
|
+
let added = 0;
|
|
1686
|
+
// Greedy 1:1 pairing of disk entries to in-memory accounts, account+org aware.
|
|
1687
|
+
// Each disk entry claims at most one unclaimed manager account, so multiple
|
|
1688
|
+
// same-person/different-org entries pair correctly instead of all matching the
|
|
1689
|
+
// first one with that accountUuid.
|
|
1690
|
+
const claimed = new Set();
|
|
1691
|
+
const claim = (diskAcct) => {
|
|
1692
|
+
for (let i = 0; i < accountManager.accounts.length; i++) {
|
|
1693
|
+
if (!claimed.has(i) && sameIdentity(accountManager.accounts[i], diskAcct)) {
|
|
1694
|
+
claimed.add(i);
|
|
1695
|
+
return i;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return -1;
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
1702
|
+
const mgrIdx = claim(diskAcct);
|
|
1703
|
+
|
|
1704
|
+
if (mgrIdx < 0) {
|
|
1705
|
+
// New account discovered on disk — add to running server
|
|
1706
|
+
memConfig.accounts.push(diskAcct);
|
|
1707
|
+
accountManager.addAccount(diskAcct);
|
|
1708
|
+
claimed.add(accountManager.accounts.length - 1);
|
|
1709
|
+
added++;
|
|
1710
|
+
console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const mgr = accountManager.accounts[mgrIdx];
|
|
1715
|
+
|
|
1716
|
+
// Backfill org identity and pick up renames/priority onto the running
|
|
1717
|
+
// account (e.g. after disk-side org disambiguation or a `priority` change).
|
|
1718
|
+
if (diskAcct.orgUuid && !mgr.orgUuid) mgr.orgUuid = diskAcct.orgUuid;
|
|
1719
|
+
if (diskAcct.orgName && !mgr.orgName) mgr.orgName = diskAcct.orgName;
|
|
1720
|
+
if (diskAcct.name && mgr.name !== diskAcct.name) mgr.name = diskAcct.name;
|
|
1721
|
+
if (diskAcct.priority != null && mgr.priority !== diskAcct.priority) mgr.priority = diskAcct.priority;
|
|
1722
|
+
// Pick up enable/disable toggles; re-enabling clears a stuck error state.
|
|
1723
|
+
const wantDisabled = !!diskAcct.disabled;
|
|
1724
|
+
if (mgr.disabled !== wantDisabled) accountManager.setDisabled(mgr.index, wantDisabled);
|
|
1725
|
+
|
|
1726
|
+
// Existing account — resolve fresh credentials from disk
|
|
1727
|
+
let freshCred = null;
|
|
1728
|
+
if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
|
|
1729
|
+
try {
|
|
1730
|
+
const creds = await importCredentials(diskAcct.importFrom);
|
|
1731
|
+
freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
|
|
1734
|
+
}
|
|
1735
|
+
} else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
|
|
1736
|
+
freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
|
|
1737
|
+
} else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
|
|
1738
|
+
freshCred = { apiKey: diskAcct.apiKey };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (!freshCred) continue;
|
|
1742
|
+
|
|
1743
|
+
if (freshCred.accessToken) {
|
|
1744
|
+
const changed = mgr.credential !== freshCred.accessToken ||
|
|
1745
|
+
mgr.refreshToken !== freshCred.refreshToken;
|
|
1746
|
+
// Don't overwrite in-memory credentials with staler ones from disk
|
|
1747
|
+
// (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
|
|
1748
|
+
const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
|
|
1749
|
+
freshCred.expiresAt < mgr.expiresAt;
|
|
1750
|
+
if (changed && !diskIsStaler) {
|
|
1751
|
+
accountManager.updateAccountTokens(mgr.index, freshCred);
|
|
1752
|
+
console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
|
|
1753
|
+
}
|
|
1754
|
+
} else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
|
|
1755
|
+
mgr.credential = freshCred.apiKey;
|
|
1756
|
+
if (mgr.status === 'error') mgr.status = 'active';
|
|
1757
|
+
console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return added;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// ── helpers ─────────────────────────────────────────────────
|
|
1764
|
+
|
|
1765
|
+
// Is `url` a /tc-acct/<name> account pin aimed at OUR proxy? Parsed rather than
|
|
1766
|
+
// prefix-matched so every local spelling counts (localhost, 127.0.0.1, [::1]),
|
|
1767
|
+
// while a pin URL for a different host/port is not ours to honour.
|
|
1768
|
+
function isLocalAccountPin(url, port) {
|
|
1769
|
+
if (!url) return false;
|
|
1770
|
+
let u;
|
|
1771
|
+
try { u = new URL(url); } catch { return false; }
|
|
1772
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
1773
|
+
const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
1774
|
+
// An omitted port means the scheme default, which still matches a proxy that
|
|
1775
|
+
// happens to run on 80/443.
|
|
1776
|
+
const urlPort = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
1777
|
+
return isLocal && urlPort === String(port) && u.pathname.startsWith('/tc-acct/');
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function argValue(flag) {
|
|
1781
|
+
const i = args.indexOf(flag);
|
|
1782
|
+
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Hostname of the configured upstream (the host MITM-intercepts under `run`).
|
|
1786
|
+
function upstreamHost(config) {
|
|
1787
|
+
try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
|
|
1788
|
+
catch { return 'api.anthropic.com'; }
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Keep the terminal title in sync with the active account (e.g. "teamclaude 2/4
|
|
1792
|
+
// work") so a backgrounded or tabbed `teamclaude server` is glanceable. TTY-only
|
|
1793
|
+
// — never emit escapes into a pipe, a `--log-to` redirect, or a systemd journal;
|
|
1794
|
+
// opt out entirely with TEAMCLAUDE_NO_TITLE. Polls (rather than hooking every
|
|
1795
|
+
// currentIndex mutation) and writes only when the title actually changes.
|
|
1796
|
+
// Returns an idempotent stop() that restores the shell's previous title.
|
|
1797
|
+
function startTerminalTitleUpdater(accountManager) {
|
|
1798
|
+
const out = process.stdout;
|
|
1799
|
+
if (!out.isTTY || process.env.TEAMCLAUDE_NO_TITLE) return () => {};
|
|
1800
|
+
|
|
1801
|
+
let last = null;
|
|
1802
|
+
const render = () => {
|
|
1803
|
+
const total = accountManager.accounts.length;
|
|
1804
|
+
const index = Math.min(accountManager.currentIndex || 0, Math.max(0, total - 1));
|
|
1805
|
+
const name = accountManager.accounts[index]?.name || null;
|
|
1806
|
+
const title = formatTerminalTitle({ index, total, name });
|
|
1807
|
+
if (title !== last) { last = title; out.write(titleSequence(title)); }
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
out.write(TITLE_STACK_PUSH); // save whatever title the shell had
|
|
1811
|
+
render();
|
|
1812
|
+
const timer = setInterval(render, 2000);
|
|
1813
|
+
timer.unref?.();
|
|
1814
|
+
|
|
1815
|
+
let stopped = false;
|
|
1816
|
+
const stop = () => {
|
|
1817
|
+
if (stopped) return;
|
|
1818
|
+
stopped = true;
|
|
1819
|
+
clearInterval(timer);
|
|
1820
|
+
try { out.write(TITLE_STACK_POP); } catch { /* terminal gone */ }
|
|
1821
|
+
};
|
|
1822
|
+
process.on('exit', stop); // backstop for exits that bypass shutdown()
|
|
1823
|
+
return stop;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Best-effort: tell a running server (if any) to re-sync accounts from config so
|
|
1827
|
+
// CLI changes take effect without a restart. A closed local port refuses the
|
|
1828
|
+
// connection immediately, so this is a no-op (and near-instant) when nothing is
|
|
1829
|
+
// running. Reload picks up new accounts, credential, priority, and enable/disable
|
|
1830
|
+
// changes; account removals still need a restart.
|
|
1831
|
+
async function notifyRunningServer(config) {
|
|
1832
|
+
const port = config?.proxy?.port;
|
|
1833
|
+
if (!port) return;
|
|
1834
|
+
try {
|
|
1835
|
+
const res = await fetch(`http://localhost:${port}/teamclaude/reload`, {
|
|
1836
|
+
method: 'POST',
|
|
1837
|
+
headers: { 'x-api-key': config.proxy?.apiKey || '' },
|
|
1838
|
+
});
|
|
1839
|
+
if (res.ok) {
|
|
1840
|
+
const data = await res.json().catch(() => ({}));
|
|
1841
|
+
console.log(`Reloaded running server${data.added ? ` (+${data.added} new account)` : ''}.`);
|
|
1842
|
+
}
|
|
1843
|
+
} catch { /* no server running — nothing to notify */ }
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// Quick liveness probe: is something listening on the local proxy port?
|
|
1847
|
+
// A successful TCP connect is enough (the proxy is local). Times out fast so a
|
|
1848
|
+
// down proxy doesn't add noticeable latency to `claude` launches via the alias.
|
|
1849
|
+
function isProxyUp(port, timeout = 600) {
|
|
1850
|
+
return new Promise(resolve => {
|
|
1851
|
+
const socket = net.connect({ host: '127.0.0.1', port });
|
|
1852
|
+
const done = up => { socket.destroy(); resolve(up); };
|
|
1853
|
+
socket.setTimeout(timeout);
|
|
1854
|
+
socket.once('connect', () => done(true));
|
|
1855
|
+
socket.once('timeout', () => done(false));
|
|
1856
|
+
socket.once('error', () => resolve(false));
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function handleServerListenError(err, port) {
|
|
1861
|
+
if (err.code === 'EADDRINUSE') {
|
|
1862
|
+
console.error(`[TeamClaude] Port ${port} is already in use.`);
|
|
1863
|
+
console.error('Another TeamClaude proxy may already be running.');
|
|
1864
|
+
console.error('Check the existing server with: teamclaude status');
|
|
1865
|
+
console.error(`Find the listener with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
|
|
1866
|
+
} else if (err.code === 'EACCES') {
|
|
1867
|
+
console.error(`[TeamClaude] Permission denied while listening on port ${port}.`);
|
|
1868
|
+
console.error('Choose a non-privileged port in the TeamClaude config.');
|
|
1869
|
+
} else {
|
|
1870
|
+
console.error(`[TeamClaude] Failed to listen on port ${port}: ${err.message}`);
|
|
1871
|
+
}
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|