@karpeleslab/teamclaude 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -14
- package/package.json +7 -2
- package/src/account-manager.js +219 -12
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/identity.js +65 -0
- package/src/index.js +458 -93
- package/src/oauth.js +80 -9
- package/src/server.js +97 -68
- package/src/tui.js +105 -12
package/src/oauth.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { randomBytes, createHash } from 'node:crypto';
|
|
4
4
|
import { exec } from 'node:child_process';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
5
6
|
import http from 'node:http';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -68,7 +69,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
68
69
|
return {
|
|
69
70
|
accessToken: data.access_token,
|
|
70
71
|
refreshToken: data.refresh_token || refreshToken,
|
|
71
|
-
expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
72
|
+
expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
72
73
|
};
|
|
73
74
|
} catch (err) {
|
|
74
75
|
const isNetworkError = err instanceof Error &&
|
|
@@ -84,36 +85,58 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Normalize an expires_at value to milliseconds.
|
|
90
|
+
* OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeExpiresAt(expiresAt) {
|
|
93
|
+
if (!expiresAt) return expiresAt;
|
|
94
|
+
// If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
|
|
95
|
+
// convert to milliseconds
|
|
96
|
+
return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
/**
|
|
88
100
|
* Check if an OAuth token is expiring within the given threshold.
|
|
89
101
|
*/
|
|
90
102
|
export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
|
|
91
103
|
if (!expiresAt) return false;
|
|
92
|
-
return Date.now() + thresholdMs >= expiresAt;
|
|
104
|
+
return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/**
|
|
96
108
|
* Fetch account profile for an OAuth token.
|
|
97
|
-
* Returns { email, name, orgName, orgType }
|
|
109
|
+
* Returns { email, name, orgName, orgType, ... } on success,
|
|
110
|
+
* or { error: 'reason' } on failure.
|
|
98
111
|
*/
|
|
99
112
|
export async function fetchProfile(accessToken) {
|
|
100
113
|
try {
|
|
101
114
|
const res = await fetch(PROFILE_URL, {
|
|
102
115
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
103
116
|
});
|
|
104
|
-
if (!res.ok)
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
let detail = '';
|
|
119
|
+
try {
|
|
120
|
+
const body = await res.json();
|
|
121
|
+
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
|
|
122
|
+
} catch {
|
|
123
|
+
detail = await res.text().catch(() => '');
|
|
124
|
+
}
|
|
125
|
+
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
|
|
126
|
+
}
|
|
105
127
|
const data = await res.json();
|
|
106
128
|
return {
|
|
107
129
|
accountUuid: data.account?.uuid,
|
|
108
130
|
email: data.account?.email,
|
|
109
131
|
name: data.account?.display_name,
|
|
132
|
+
orgUuid: data.organization?.uuid,
|
|
110
133
|
orgName: data.organization?.name,
|
|
111
134
|
orgType: data.organization?.organization_type,
|
|
112
135
|
hasClaudeMax: data.account?.has_claude_max,
|
|
113
136
|
hasClaudePro: data.account?.has_claude_pro,
|
|
114
137
|
};
|
|
115
|
-
} catch {
|
|
116
|
-
return
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { error: err.message || String(err) };
|
|
117
140
|
}
|
|
118
141
|
}
|
|
119
142
|
|
|
@@ -153,10 +176,10 @@ export async function loginOAuth() {
|
|
|
153
176
|
console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
154
177
|
openBrowser(authUrl.toString());
|
|
155
178
|
|
|
156
|
-
// Wait for the
|
|
179
|
+
// Wait for either the callback server or manual paste from stdin
|
|
157
180
|
let code;
|
|
158
181
|
try {
|
|
159
|
-
code = await codePromise;
|
|
182
|
+
code = await raceWithStdinCode(codePromise, state);
|
|
160
183
|
} finally {
|
|
161
184
|
server.close();
|
|
162
185
|
}
|
|
@@ -185,10 +208,58 @@ export async function loginOAuth() {
|
|
|
185
208
|
return {
|
|
186
209
|
accessToken: tokens.access_token,
|
|
187
210
|
refreshToken: tokens.refresh_token,
|
|
188
|
-
expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
211
|
+
expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
189
212
|
};
|
|
190
213
|
}
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Race the callback server promise against manual code entry from stdin.
|
|
217
|
+
* The user can paste the full callback URL or just the authorization code.
|
|
218
|
+
*/
|
|
219
|
+
function raceWithStdinCode(callbackPromise, expectedState) {
|
|
220
|
+
if (!process.stdin.isTTY) return callbackPromise;
|
|
221
|
+
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
224
|
+
let settled = false;
|
|
225
|
+
|
|
226
|
+
const settle = (fn, val) => {
|
|
227
|
+
if (settled) return;
|
|
228
|
+
settled = true;
|
|
229
|
+
rl.close();
|
|
230
|
+
fn(val);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
|
|
234
|
+
const trimmed = answer.trim();
|
|
235
|
+
if (!trimmed) return; // empty input, keep waiting for callback
|
|
236
|
+
|
|
237
|
+
// Try to parse as a URL with ?code= parameter
|
|
238
|
+
try {
|
|
239
|
+
const url = new URL(trimmed);
|
|
240
|
+
const code = url.searchParams.get('code');
|
|
241
|
+
const state = url.searchParams.get('state');
|
|
242
|
+
if (code) {
|
|
243
|
+
if (expectedState && state && state !== expectedState) {
|
|
244
|
+
settle(reject, new Error('OAuth state mismatch'));
|
|
245
|
+
} else {
|
|
246
|
+
settle(resolve, code);
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
} catch {}
|
|
251
|
+
|
|
252
|
+
// Treat raw input as the authorization code
|
|
253
|
+
settle(resolve, trimmed);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
callbackPromise.then(
|
|
257
|
+
code => settle(resolve, code),
|
|
258
|
+
err => settle(reject, err),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
192
263
|
function startCallbackServer(expectedState) {
|
|
193
264
|
return new Promise((resolve, reject) => {
|
|
194
265
|
let resolveCode, rejectCode;
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
7
|
'host', 'connection', 'keep-alive', 'transfer-encoding',
|
|
7
8
|
'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
|
|
@@ -39,9 +40,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
+
// Let client token refresh requests pass through to upstream untouched.
|
|
44
|
+
// The proxy manages its own tokens via ensureTokenFresh(); intercepting
|
|
45
|
+
// or rewriting client refreshes would cause token rotation conflicts.
|
|
43
46
|
if (req.method === 'POST' && req.url === '/v1/oauth/token') {
|
|
44
|
-
await
|
|
47
|
+
await relayRaw(req, res, upstream);
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -57,82 +60,52 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
57
60
|
const body = Buffer.concat(bodyChunks);
|
|
58
61
|
|
|
59
62
|
const ctx = { account: null, status: null };
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
try {
|
|
64
|
+
await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
ctx.status = ctx.status || 502;
|
|
67
|
+
console.error('[TeamClaude] Unhandled error:', err);
|
|
68
|
+
if (!res.headersSent) {
|
|
69
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify({
|
|
71
|
+
type: 'error',
|
|
72
|
+
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
hooks.onRequestEnd?.(reqId, {
|
|
77
|
+
method: req.method, path: req.url,
|
|
78
|
+
account: ctx.account, status: ctx.status,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
66
81
|
} catch (err) {
|
|
67
82
|
console.error('[TeamClaude] Unhandled error:', err);
|
|
68
|
-
if (!res.headersSent) {
|
|
69
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
70
|
-
res.end(JSON.stringify({
|
|
71
|
-
type: 'error',
|
|
72
|
-
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
73
|
-
}));
|
|
74
|
-
}
|
|
75
83
|
}
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
return server;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
82
|
-
|
|
83
89
|
/**
|
|
84
|
-
*
|
|
85
|
-
* and pass the response back to the client.
|
|
90
|
+
* Relay a request to upstream with no header rewriting — pure passthrough.
|
|
86
91
|
*/
|
|
87
|
-
async function
|
|
92
|
+
async function relayRaw(req, res, upstream) {
|
|
88
93
|
const bodyChunks = [];
|
|
89
|
-
for await (const chunk of req)
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const rawBody = Buffer.concat(bodyChunks);
|
|
93
|
-
|
|
94
|
-
// Replace the refresh token in the request with the current account's refresh token
|
|
95
|
-
// so the renewal happens for the active account, not just the one the client knows about
|
|
96
|
-
let body = rawBody;
|
|
97
|
-
try {
|
|
98
|
-
const parsed = JSON.parse(rawBody.toString());
|
|
99
|
-
const currentAccount = accountManager.accounts[accountManager.currentIndex];
|
|
100
|
-
if (parsed.grant_type === 'refresh_token' && currentAccount?.type === 'oauth' && currentAccount.refreshToken) {
|
|
101
|
-
parsed.refresh_token = currentAccount.refreshToken;
|
|
102
|
-
body = JSON.stringify(parsed);
|
|
103
|
-
console.log(`[TeamClaude] Token refresh: substituted refresh token for account "${currentAccount.name}"`);
|
|
104
|
-
}
|
|
105
|
-
} catch {}
|
|
94
|
+
for await (const chunk of req) bodyChunks.push(chunk);
|
|
95
|
+
const body = Buffer.concat(bodyChunks);
|
|
106
96
|
|
|
107
97
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
method: 'POST',
|
|
98
|
+
const upstreamRes = await fetch(`${upstream}${req.url}`, {
|
|
99
|
+
method: req.method,
|
|
111
100
|
headers: {
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
101
|
+
'content-type': req.headers['content-type'] || 'application/json',
|
|
102
|
+
'accept': req.headers['accept'] || 'application/json',
|
|
103
|
+
'user-agent': req.headers['user-agent'] || 'node',
|
|
115
104
|
},
|
|
116
|
-
body,
|
|
105
|
+
body: body.length > 0 ? body : undefined,
|
|
117
106
|
});
|
|
118
107
|
|
|
119
108
|
const responseBody = await upstreamRes.text();
|
|
120
|
-
|
|
121
|
-
// Capture tokens from successful refresh — update the current account directly
|
|
122
|
-
if (upstreamRes.ok) {
|
|
123
|
-
try {
|
|
124
|
-
const tokens = JSON.parse(responseBody);
|
|
125
|
-
if (tokens.access_token) {
|
|
126
|
-
accountManager.updateAccountTokens(accountManager.currentIndex, {
|
|
127
|
-
accessToken: tokens.access_token,
|
|
128
|
-
refreshToken: tokens.refresh_token,
|
|
129
|
-
expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
} catch {}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Forward response to client
|
|
136
109
|
const responseHeaders = {};
|
|
137
110
|
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
138
111
|
if (key === 'transfer-encoding' || key === 'connection') continue;
|
|
@@ -141,17 +114,15 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
|
|
|
141
114
|
res.writeHead(upstreamRes.status, responseHeaders);
|
|
142
115
|
res.end(responseBody);
|
|
143
116
|
} catch (err) {
|
|
144
|
-
console.error('[TeamClaude]
|
|
117
|
+
console.error('[TeamClaude] Raw relay error:', err.message);
|
|
145
118
|
if (!res.headersSent) {
|
|
146
119
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
147
|
-
res.end(JSON.stringify({
|
|
148
|
-
type: 'error',
|
|
149
|
-
error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
|
|
150
|
-
}));
|
|
120
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: 'Upstream unreachable' } }));
|
|
151
121
|
}
|
|
152
122
|
}
|
|
153
123
|
}
|
|
154
124
|
|
|
125
|
+
|
|
155
126
|
function logTimestamp() {
|
|
156
127
|
const d = new Date();
|
|
157
128
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
@@ -272,6 +243,43 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
272
243
|
}
|
|
273
244
|
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
274
245
|
|
|
246
|
+
// On 429, wait the retry-after duration and retry on the same account
|
|
247
|
+
// (this is a transient rate limit, not quota exhaustion).
|
|
248
|
+
if (upstreamRes.status === 429) {
|
|
249
|
+
// Clamp Retry-After to a sane window: missing/invalid falls back to 60s,
|
|
250
|
+
// and out-of-range values are bounded to [1, 300]. A negative value would
|
|
251
|
+
// otherwise bypass the retry cap — setTimeout returns immediately and
|
|
252
|
+
// markRateLimited would set rateLimitedUntil in the past.
|
|
253
|
+
let retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10);
|
|
254
|
+
if (Number.isNaN(retryAfter)) retryAfter = 60;
|
|
255
|
+
retryAfter = Math.min(Math.max(retryAfter, 1), 300);
|
|
256
|
+
// Discard the 429 response body
|
|
257
|
+
await upstreamRes.body?.cancel();
|
|
258
|
+
|
|
259
|
+
// Bound the retries: a persistently-throttled upstream must not loop
|
|
260
|
+
// forever (that would tie up the client connection indefinitely).
|
|
261
|
+
// Once retries are exhausted, throttle this account and re-dispatch —
|
|
262
|
+
// getActiveAccount then picks another account, or returns 429 to the
|
|
263
|
+
// client if every account is throttled.
|
|
264
|
+
if (retryCount >= maxRetries) {
|
|
265
|
+
console.log(`[TeamClaude] Persistent 429 on "${account.name}" — throttling ${retryAfter}s and re-dispatching`);
|
|
266
|
+
accountManager.markRateLimited(account.index, retryAfter);
|
|
267
|
+
if (logDir) {
|
|
268
|
+
logSections.push(`=== RESPONSE 429 — capped after ${retryCount} retries, throttling account ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
269
|
+
}
|
|
270
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (logDir) {
|
|
274
|
+
logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
275
|
+
}
|
|
276
|
+
console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
|
|
277
|
+
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
|
278
|
+
// Client may have disconnected during the wait
|
|
279
|
+
if (res.destroyed) return;
|
|
280
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
281
|
+
}
|
|
282
|
+
|
|
275
283
|
// Log response headers
|
|
276
284
|
if (logDir) {
|
|
277
285
|
logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
@@ -329,6 +337,17 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
329
337
|
writeRequestLog(logDir, reqId, logSections);
|
|
330
338
|
}
|
|
331
339
|
|
|
340
|
+
const isTransient = err instanceof Error &&
|
|
341
|
+
(err.message.includes('fetch failed') ||
|
|
342
|
+
err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
343
|
+
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
|
|
344
|
+
|
|
345
|
+
// Transient network errors: just close the connection and let the client retry
|
|
346
|
+
if (isTransient) {
|
|
347
|
+
res.destroy();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
332
351
|
if (retryCount < maxRetries && !res.headersSent) {
|
|
333
352
|
account.status = 'error';
|
|
334
353
|
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
@@ -358,6 +377,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
358
377
|
const { done, value } = await reader.read();
|
|
359
378
|
if (done) break;
|
|
360
379
|
|
|
380
|
+
// Client disconnected — stop reading from upstream
|
|
381
|
+
if (res.destroyed) break;
|
|
382
|
+
|
|
361
383
|
// Forward chunk immediately
|
|
362
384
|
const ok = res.write(value);
|
|
363
385
|
|
|
@@ -375,9 +397,14 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
375
397
|
parseSSEUsage(event, accountIndex, accountManager);
|
|
376
398
|
}
|
|
377
399
|
|
|
378
|
-
// Handle backpressure
|
|
400
|
+
// Handle backpressure — also bail out if client disconnects,
|
|
401
|
+
// because 'drain' will never fire on a destroyed socket
|
|
379
402
|
if (!ok) {
|
|
380
|
-
await new Promise(resolve =>
|
|
403
|
+
await new Promise(resolve => {
|
|
404
|
+
res.once('drain', resolve);
|
|
405
|
+
res.once('close', resolve);
|
|
406
|
+
});
|
|
407
|
+
if (res.destroyed) break;
|
|
381
408
|
}
|
|
382
409
|
}
|
|
383
410
|
|
|
@@ -386,7 +413,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
386
413
|
parseSSEUsage(sseBuffer, accountIndex, accountManager);
|
|
387
414
|
}
|
|
388
415
|
} finally {
|
|
389
|
-
|
|
416
|
+
// Cancel upstream reader to stop consuming data nobody needs
|
|
417
|
+
reader.cancel().catch(() => {});
|
|
418
|
+
if (!res.writableEnded) res.end();
|
|
390
419
|
}
|
|
391
420
|
}
|
|
392
421
|
|
package/src/tui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { importCredentials } from './oauth.js';
|
|
1
|
+
import { importCredentials, fetchProfile } from './oauth.js';
|
|
2
|
+
import { sameIdentity } from './identity.js';
|
|
2
3
|
|
|
3
4
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
4
5
|
|
|
@@ -232,6 +233,9 @@ export class TUI {
|
|
|
232
233
|
else if (k === 'r' && this.am.accounts.length > 0) {
|
|
233
234
|
this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
|
|
234
235
|
}
|
|
236
|
+
else if (k === 'd' && this.am.accounts.length > 0) {
|
|
237
|
+
this.mode = 'select'; this.selAction = 'toggle'; this.selIdx = this.am.currentIndex;
|
|
238
|
+
}
|
|
235
239
|
else if (k === 'a') { this.mode = 'add'; }
|
|
236
240
|
else if (k === 'R') { this._doSync(); }
|
|
237
241
|
}
|
|
@@ -244,6 +248,8 @@ export class TUI {
|
|
|
244
248
|
if (this.selAction === 'switch') {
|
|
245
249
|
this.am.currentIndex = this.selIdx;
|
|
246
250
|
this._addLog(`Switched to "${this.am.accounts[this.selIdx].name}"`);
|
|
251
|
+
} else if (this.selAction === 'toggle') {
|
|
252
|
+
this._doToggleDisabled(this.selIdx);
|
|
247
253
|
} else {
|
|
248
254
|
this._doRemove(this.selIdx);
|
|
249
255
|
}
|
|
@@ -283,7 +289,7 @@ export class TUI {
|
|
|
283
289
|
if (count > 0) {
|
|
284
290
|
this._addLog(`Synced ${count} new account(s) from config`);
|
|
285
291
|
} else {
|
|
286
|
-
this._addLog('Config reloaded,
|
|
292
|
+
this._addLog('Config reloaded, credentials refreshed');
|
|
287
293
|
}
|
|
288
294
|
} catch (e) {
|
|
289
295
|
this._addLog(`Sync failed: ${e.message}`);
|
|
@@ -292,19 +298,75 @@ export class TUI {
|
|
|
292
298
|
|
|
293
299
|
async _doImport() {
|
|
294
300
|
try {
|
|
301
|
+
this._addLog('Importing credentials...');
|
|
295
302
|
const creds = await importCredentials('~/.claude/.credentials.json');
|
|
296
|
-
const
|
|
297
|
-
const
|
|
303
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
304
|
+
const profileOk = profile && !profile.error;
|
|
305
|
+
|
|
306
|
+
if (!profileOk) {
|
|
307
|
+
this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let name;
|
|
311
|
+
if (profile?.email) {
|
|
312
|
+
name = profile.email;
|
|
313
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
314
|
+
if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
|
|
315
|
+
} else {
|
|
316
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
|
|
317
|
+
name = `account-${n}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
298
320
|
const entry = {
|
|
299
|
-
name, type: 'oauth',
|
|
321
|
+
name, type: 'oauth', source: 'import',
|
|
322
|
+
accountUuid: profile?.accountUuid || null,
|
|
323
|
+
orgUuid: profile?.orgUuid || null,
|
|
324
|
+
orgName: profile?.orgName || null,
|
|
300
325
|
accessToken: creds.accessToken,
|
|
301
326
|
refreshToken: creds.refreshToken,
|
|
302
327
|
expiresAt: creds.expiresAt,
|
|
303
328
|
};
|
|
304
|
-
|
|
305
|
-
|
|
329
|
+
|
|
330
|
+
// Deduplicate by account+org identity (same email in a different org is a
|
|
331
|
+
// distinct account), then by name.
|
|
332
|
+
let idx = this.config.accounts.findIndex(a => sameIdentity(a, entry));
|
|
333
|
+
if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
|
|
334
|
+
|
|
335
|
+
if (idx >= 0) {
|
|
336
|
+
const prev = this.config.accounts[idx];
|
|
337
|
+
this.config.accounts[idx] = { ...prev, ...entry, name: prev.name };
|
|
338
|
+
// Update the running account manager entry
|
|
339
|
+
const amAcct = this.am.accounts.find(a => sameIdentity(a, entry)) || this.am.accounts[idx];
|
|
340
|
+
if (amAcct) {
|
|
341
|
+
amAcct.credential = creds.accessToken;
|
|
342
|
+
amAcct.refreshToken = creds.refreshToken;
|
|
343
|
+
amAcct.expiresAt = creds.expiresAt;
|
|
344
|
+
amAcct.accountUuid = entry.accountUuid;
|
|
345
|
+
amAcct.orgUuid = entry.orgUuid;
|
|
346
|
+
amAcct.orgName = entry.orgName;
|
|
347
|
+
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
348
|
+
}
|
|
349
|
+
this._addLog(`Updated account "${prev.name}"`);
|
|
350
|
+
} else {
|
|
351
|
+
// New org for this person: disambiguate colliding email names with " (org)".
|
|
352
|
+
if (profile?.accountUuid) {
|
|
353
|
+
const orgLbl = a => a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
|
|
354
|
+
const collisions = this.config.accounts.filter(
|
|
355
|
+
a => a.accountUuid === entry.accountUuid && !sameIdentity(a, entry)
|
|
356
|
+
);
|
|
357
|
+
if (collisions.length > 0) {
|
|
358
|
+
for (const c of collisions) {
|
|
359
|
+
if (!c.name.includes(' (')) c.name = `${c.name} (${orgLbl(c)})`;
|
|
360
|
+
}
|
|
361
|
+
entry.name = `${name} (${orgLbl(entry)})`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.config.accounts.push(entry);
|
|
365
|
+
this.am.addAccount(entry);
|
|
366
|
+
this._addLog(`Imported account "${entry.name}"`);
|
|
367
|
+
}
|
|
368
|
+
|
|
306
369
|
await this.saveConfig(this.config);
|
|
307
|
-
this._addLog(`Imported account "${name}"`);
|
|
308
370
|
} catch (e) {
|
|
309
371
|
this._addLog(`Import failed: ${e.message}`);
|
|
310
372
|
}
|
|
@@ -329,10 +391,37 @@ export class TUI {
|
|
|
329
391
|
this._addLog(`Removed account "${name}"`);
|
|
330
392
|
}
|
|
331
393
|
|
|
394
|
+
async _doToggleDisabled(idx) {
|
|
395
|
+
if (idx < 0 || idx >= this.am.accounts.length) return;
|
|
396
|
+
const acct = this.am.accounts[idx];
|
|
397
|
+
const next = !acct.disabled;
|
|
398
|
+
this.am.setDisabled(idx, next); // re-enabling also clears a stuck error state
|
|
399
|
+
// Write an explicit boolean (not delete): saveConfig merges over the on-disk
|
|
400
|
+
// entry, so a `delete` would leave a stale `disabled: true` from disk intact.
|
|
401
|
+
if (this.config.accounts[idx]) this.config.accounts[idx].disabled = next;
|
|
402
|
+
await this.saveConfig(this.config);
|
|
403
|
+
this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
|
|
404
|
+
}
|
|
405
|
+
|
|
332
406
|
// ── rendering ──────────────────────────────────────
|
|
333
407
|
|
|
334
408
|
render() {
|
|
335
409
|
if (!this.running) return;
|
|
410
|
+
// Guard against re-entry: clearing an expired quota logs, and _addLog calls
|
|
411
|
+
// render() again — without this the nested call would render twice.
|
|
412
|
+
if (this._rendering) return;
|
|
413
|
+
this._rendering = true;
|
|
414
|
+
try {
|
|
415
|
+
this._render();
|
|
416
|
+
} finally {
|
|
417
|
+
this._rendering = false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_render() {
|
|
422
|
+
// Reset the display the instant a quota window (e.g. 5-hour session) expires,
|
|
423
|
+
// instead of waiting for the next request to clear it.
|
|
424
|
+
this.am.refreshExpiredQuotas();
|
|
336
425
|
const W = process.stdout.columns || 80;
|
|
337
426
|
const H = process.stdout.rows || 24;
|
|
338
427
|
|
|
@@ -423,9 +512,11 @@ export class TUI {
|
|
|
423
512
|
// Type
|
|
424
513
|
const type = gray(a.type.padEnd(7));
|
|
425
514
|
|
|
426
|
-
// Status
|
|
515
|
+
// Status — a disabled account is shown as such regardless of its quota state.
|
|
427
516
|
let status;
|
|
428
|
-
|
|
517
|
+
if (a.disabled) {
|
|
518
|
+
status = gray('disabled');
|
|
519
|
+
} else switch (a.status) {
|
|
429
520
|
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
430
521
|
case 'throttled': status = yellow('throttled'); break;
|
|
431
522
|
case 'exhausted': status = red('exhausted'); break;
|
|
@@ -464,9 +555,11 @@ export class TUI {
|
|
|
464
555
|
_renderFooter() {
|
|
465
556
|
switch (this.mode) {
|
|
466
557
|
case 'normal':
|
|
467
|
-
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
|
|
558
|
+
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('q')}uit`;
|
|
468
559
|
case 'select': {
|
|
469
|
-
const act = this.selAction === 'switch' ? 'switch'
|
|
560
|
+
const act = this.selAction === 'switch' ? 'switch'
|
|
561
|
+
: this.selAction === 'toggle' ? 'enable/disable'
|
|
562
|
+
: 'remove';
|
|
470
563
|
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|
|
471
564
|
}
|
|
472
565
|
case 'add':
|