@karpeleslab/teamclaude 1.0.3 → 1.0.5
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/README.md +29 -29
- package/package.json +1 -1
- package/src/account-manager.js +15 -34
- package/src/config.js +12 -0
- package/src/index.js +75 -13
- package/src/server.js +7 -10
- package/src/tui.js +104 -12
package/README.md
CHANGED
|
@@ -10,8 +10,9 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
|
|
|
10
10
|
|
|
11
11
|
- **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
|
|
12
12
|
- **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
|
|
13
|
-
- **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
|
|
14
|
-
- **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
|
|
13
|
+
- **Interactive TUI** — real-time dashboard with color-coded quota bars showing reset countdowns, activity log, and keyboard controls
|
|
14
|
+
- **OAuth token refresh** — proactively refreshes expiring tokens, intercepts client token renewals, and persists them to config
|
|
15
|
+
- **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
|
|
15
16
|
- **Request logging** — optional full request/response logging for debugging
|
|
16
17
|
- **Zero dependencies** — uses only Node.js built-in modules
|
|
17
18
|
|
|
@@ -23,12 +24,11 @@ Requires Node.js 18+.
|
|
|
23
24
|
# Install
|
|
24
25
|
npm install -g @karpeleslab/teamclaude
|
|
25
26
|
|
|
26
|
-
#
|
|
27
|
-
teamclaude
|
|
27
|
+
# Add your first account (opens browser for OAuth)
|
|
28
|
+
teamclaude login
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
# teamclaude import
|
|
30
|
+
# Add a second account
|
|
31
|
+
teamclaude login
|
|
32
32
|
|
|
33
33
|
# Start the proxy
|
|
34
34
|
teamclaude server
|
|
@@ -37,27 +37,37 @@ teamclaude server
|
|
|
37
37
|
teamclaude run
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
You can also import existing Claude Code credentials instead of logging in:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude /login # Log into an account in Claude Code
|
|
44
|
+
teamclaude import # Import its credentials
|
|
45
|
+
```
|
|
46
|
+
|
|
40
47
|
## Adding Accounts
|
|
41
48
|
|
|
42
|
-
###
|
|
49
|
+
### OAuth Login (recommended)
|
|
43
50
|
|
|
44
|
-
The easiest way to add accounts
|
|
51
|
+
The easiest way to add accounts — opens your browser for authentication:
|
|
45
52
|
|
|
46
53
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
teamclaude login
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Uses the same OAuth flow as Claude Code. Auto-detects the account email and subscription tier. Logging in with the same account again updates its credentials.
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
teamclaude import
|
|
59
|
+
You can add accounts while the server is running — press **R** in the TUI to reload.
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
claude /login
|
|
61
|
+
### Import from Claude Code
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
If you already have Claude Code set up, you can import its credentials directly:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
claude /login # Log into an account in Claude Code
|
|
67
|
+
teamclaude import # Import its credentials
|
|
58
68
|
```
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
Re-importing the same account updates its credentials.
|
|
61
71
|
|
|
62
72
|
### API Key
|
|
63
73
|
|
|
@@ -67,16 +77,6 @@ For Anthropic API key accounts (billed via Console):
|
|
|
67
77
|
teamclaude login --api
|
|
68
78
|
```
|
|
69
79
|
|
|
70
|
-
### OAuth Login (experimental)
|
|
71
|
-
|
|
72
|
-
Direct browser-based OAuth login without needing Claude Code:
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
teamclaude login
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
> **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
|
|
79
|
-
|
|
80
80
|
## Usage
|
|
81
81
|
|
|
82
82
|
### Start the proxy server
|
|
@@ -88,7 +88,7 @@ teamclaude server
|
|
|
88
88
|
When running from a TTY, shows an interactive TUI with:
|
|
89
89
|
- Account table with session/weekly quota progress bars
|
|
90
90
|
- Real-time activity log with request tracking
|
|
91
|
-
- Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
|
|
91
|
+
- Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **R**eload, **q**uit
|
|
92
92
|
|
|
93
93
|
Falls back to plain log output when not a TTY (e.g. running as a service).
|
|
94
94
|
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -23,6 +23,7 @@ export class AccountManager {
|
|
|
23
23
|
index,
|
|
24
24
|
name: acct.name,
|
|
25
25
|
type: acct.type,
|
|
26
|
+
accountUuid: acct.accountUuid || null,
|
|
26
27
|
credential: acct.accessToken || acct.apiKey,
|
|
27
28
|
refreshToken: acct.refreshToken || null,
|
|
28
29
|
expiresAt: acct.expiresAt || null,
|
|
@@ -268,42 +269,21 @@ export class AccountManager {
|
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
/**
|
|
271
|
-
*
|
|
272
|
-
* Updates the first OAuth account whose credential matches the old token,
|
|
273
|
-
* or the first expired/error OAuth account if none match.
|
|
274
|
-
*
|
|
275
|
-
* @param {string} accessToken - The new access token
|
|
276
|
-
* @param {string} [refreshToken] - New refresh token (if available from intercepted refresh)
|
|
277
|
-
* @param {number} [expiresAt] - Token expiry timestamp
|
|
272
|
+
* Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
|
|
278
273
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Find the best OAuth account to update: prefer expired/error accounts
|
|
292
|
-
const candidate = this.accounts.find(a =>
|
|
293
|
-
a.type === 'oauth' && (a.status === 'error' || isTokenExpiringSoon(a.expiresAt, 0))
|
|
294
|
-
) || this.accounts.find(a => a.type === 'oauth');
|
|
295
|
-
|
|
296
|
-
if (!candidate) return;
|
|
297
|
-
|
|
298
|
-
candidate.credential = accessToken;
|
|
299
|
-
if (refreshToken) candidate.refreshToken = refreshToken;
|
|
300
|
-
candidate.expiresAt = expiresAt || Date.now() + 3600 * 1000;
|
|
301
|
-
if (candidate.status === 'error') candidate.status = 'active';
|
|
302
|
-
console.log(`[TeamClaude] Captured fresh token for account "${candidate.name}"`);
|
|
303
|
-
this._onTokenRefresh?.(candidate.index, {
|
|
274
|
+
updateAccountTokens(accountIndex, { accessToken, refreshToken, expiresAt }) {
|
|
275
|
+
const account = this.accounts[accountIndex];
|
|
276
|
+
if (!account || account.type !== 'oauth') return;
|
|
277
|
+
|
|
278
|
+
account.credential = accessToken;
|
|
279
|
+
if (refreshToken) account.refreshToken = refreshToken;
|
|
280
|
+
account.expiresAt = expiresAt;
|
|
281
|
+
if (account.status === 'error') account.status = 'active';
|
|
282
|
+
console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
|
|
283
|
+
this._onTokenRefresh?.(accountIndex, {
|
|
304
284
|
accessToken,
|
|
305
|
-
refreshToken:
|
|
306
|
-
expiresAt:
|
|
285
|
+
refreshToken: account.refreshToken,
|
|
286
|
+
expiresAt: account.expiresAt,
|
|
307
287
|
});
|
|
308
288
|
}
|
|
309
289
|
|
|
@@ -316,6 +296,7 @@ export class AccountManager {
|
|
|
316
296
|
index,
|
|
317
297
|
name: acctData.name,
|
|
318
298
|
type: acctData.type,
|
|
299
|
+
accountUuid: acctData.accountUuid || null,
|
|
319
300
|
credential: acctData.accessToken || acctData.apiKey,
|
|
320
301
|
refreshToken: acctData.refreshToken || null,
|
|
321
302
|
expiresAt: acctData.expiresAt || null,
|
package/src/config.js
CHANGED
|
@@ -46,3 +46,15 @@ export async function saveConfig(config) {
|
|
|
46
46
|
await mkdir(dirname(path), { recursive: true });
|
|
47
47
|
await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Atomically update the config: re-reads from disk, calls updater(config),
|
|
52
|
+
* then saves. Returns the updated config. This prevents overwriting changes
|
|
53
|
+
* made by other processes (e.g. `teamclaude import` while the server runs).
|
|
54
|
+
*/
|
|
55
|
+
export async function atomicConfigUpdate(updater) {
|
|
56
|
+
const config = await loadConfig() || createDefaultConfig();
|
|
57
|
+
await updater(config);
|
|
58
|
+
await saveConfig(config);
|
|
59
|
+
return config;
|
|
60
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
|
-
import { loadOrCreateConfig, saveConfig, getConfigPath } from './config.js';
|
|
5
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
|
|
6
6
|
import { AccountManager } from './account-manager.js';
|
|
7
7
|
import { createProxyServer } from './server.js';
|
|
8
8
|
import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
|
|
@@ -15,29 +15,36 @@ switch (command) {
|
|
|
15
15
|
case 'server':
|
|
16
16
|
await serverCommand();
|
|
17
17
|
break;
|
|
18
|
+
case 'run':
|
|
19
|
+
await runCommand();
|
|
20
|
+
break;
|
|
18
21
|
case 'import':
|
|
19
22
|
await importCommand();
|
|
23
|
+
process.exit(0);
|
|
20
24
|
break;
|
|
21
25
|
case 'login':
|
|
22
26
|
await loginCommand();
|
|
27
|
+
process.exit(0);
|
|
23
28
|
break;
|
|
24
29
|
case 'env':
|
|
25
30
|
await envCommand();
|
|
26
|
-
|
|
27
|
-
case 'run':
|
|
28
|
-
await runCommand();
|
|
31
|
+
process.exit(0);
|
|
29
32
|
break;
|
|
30
33
|
case 'status':
|
|
31
34
|
await statusCommand();
|
|
35
|
+
process.exit(0);
|
|
32
36
|
break;
|
|
33
37
|
case 'accounts':
|
|
34
38
|
await accountsCommand();
|
|
39
|
+
process.exit(0);
|
|
35
40
|
break;
|
|
36
41
|
case 'remove':
|
|
37
42
|
await removeCommand();
|
|
43
|
+
process.exit(0);
|
|
38
44
|
break;
|
|
39
45
|
case 'api':
|
|
40
46
|
await apiCommand();
|
|
47
|
+
process.exit(0);
|
|
41
48
|
break;
|
|
42
49
|
case 'help':
|
|
43
50
|
case '--help':
|
|
@@ -82,15 +89,21 @@ async function serverCommand() {
|
|
|
82
89
|
const threshold = config.switchThreshold || 0.98;
|
|
83
90
|
const accountManager = new AccountManager(accounts, threshold);
|
|
84
91
|
|
|
85
|
-
// Persist refreshed tokens back to config
|
|
92
|
+
// Persist refreshed tokens back to config (re-read from disk to avoid clobbering
|
|
93
|
+
// accounts added externally, e.g. by `teamclaude import` while server is running)
|
|
86
94
|
accountManager.onTokenRefresh((idx, newTokens) => {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
const account = accountManager.accounts[idx];
|
|
96
|
+
if (!account) return;
|
|
97
|
+
atomicConfigUpdate(diskConfig => {
|
|
98
|
+
syncNewAccountsFromDisk(diskConfig, config, accountManager);
|
|
99
|
+
// Match by UUID first, then by name — index may have shifted
|
|
100
|
+
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
101
|
+
if (cfgIdx >= 0) {
|
|
102
|
+
diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
|
|
103
|
+
diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
|
|
104
|
+
diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
|
|
105
|
+
}
|
|
106
|
+
}).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
|
|
94
107
|
});
|
|
95
108
|
const port = config.proxy.port;
|
|
96
109
|
const useTUI = process.stdout.isTTY && process.stdin.isTTY;
|
|
@@ -100,7 +113,24 @@ async function serverCommand() {
|
|
|
100
113
|
|
|
101
114
|
if (useTUI) {
|
|
102
115
|
tui = new TUI({
|
|
103
|
-
accountManager, config,
|
|
116
|
+
accountManager, config,
|
|
117
|
+
saveConfig: () => atomicConfigUpdate(diskConfig => {
|
|
118
|
+
syncNewAccountsFromDisk(diskConfig, config, accountManager);
|
|
119
|
+
// Write in-memory accounts back, preserving extra disk-only fields
|
|
120
|
+
diskConfig.accounts = config.accounts.map(a => {
|
|
121
|
+
const diskAcct = diskConfig.accounts.find(
|
|
122
|
+
d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
|
|
123
|
+
);
|
|
124
|
+
return diskAcct ? { ...diskAcct, ...a } : a;
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
syncAccounts: async () => {
|
|
128
|
+
const diskConfig = await loadConfig();
|
|
129
|
+
if (!diskConfig) return 0;
|
|
130
|
+
const before = accountManager.accounts.length;
|
|
131
|
+
syncNewAccountsFromDisk(diskConfig, config, accountManager);
|
|
132
|
+
return accountManager.accounts.length - before;
|
|
133
|
+
},
|
|
104
134
|
onQuit: () => { server.close(() => process.exit(0)); },
|
|
105
135
|
});
|
|
106
136
|
hooks = {
|
|
@@ -559,6 +589,38 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
|
559
589
|
console.log(`Saved to ${getConfigPath()}`);
|
|
560
590
|
}
|
|
561
591
|
|
|
592
|
+
// ── config sync helpers ─────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Find a config account entry matching an in-memory account (by UUID, then name).
|
|
596
|
+
*/
|
|
597
|
+
function findConfigAccount(diskConfig, account) {
|
|
598
|
+
if (account.accountUuid) {
|
|
599
|
+
const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
|
|
600
|
+
if (idx >= 0) return idx;
|
|
601
|
+
}
|
|
602
|
+
return diskConfig.accounts.findIndex(a => a.name === account.name);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Detect accounts added to disk config by external processes and add them
|
|
607
|
+
* to the running AccountManager + in-memory config.
|
|
608
|
+
*/
|
|
609
|
+
function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
610
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
611
|
+
const knownByUuid = diskAcct.accountUuid &&
|
|
612
|
+
memConfig.accounts.some(a => a.accountUuid === diskAcct.accountUuid);
|
|
613
|
+
const knownByName = memConfig.accounts.some(a => a.name === diskAcct.name);
|
|
614
|
+
|
|
615
|
+
if (!knownByUuid && !knownByName) {
|
|
616
|
+
// New account discovered on disk — add to running server
|
|
617
|
+
memConfig.accounts.push(diskAcct);
|
|
618
|
+
accountManager.addAccount(diskAcct);
|
|
619
|
+
console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
562
624
|
// ── helpers ─────────────────────────────────────────────────
|
|
563
625
|
|
|
564
626
|
async function resolveAccounts(config) {
|
package/src/server.js
CHANGED
|
@@ -118,13 +118,16 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
|
|
|
118
118
|
|
|
119
119
|
const responseBody = await upstreamRes.text();
|
|
120
120
|
|
|
121
|
-
// Capture tokens from successful refresh
|
|
121
|
+
// Capture tokens from successful refresh — update the current account directly
|
|
122
122
|
if (upstreamRes.ok) {
|
|
123
123
|
try {
|
|
124
124
|
const tokens = JSON.parse(responseBody);
|
|
125
125
|
if (tokens.access_token) {
|
|
126
|
-
accountManager.
|
|
127
|
-
|
|
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
|
+
});
|
|
128
131
|
}
|
|
129
132
|
} catch {}
|
|
130
133
|
}
|
|
@@ -226,12 +229,6 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
226
229
|
headers['x-api-key'] = account.credential;
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
// Capture fresh Bearer tokens from the client to keep stored credentials up to date
|
|
230
|
-
const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
|
|
231
|
-
if (clientBearer) {
|
|
232
|
-
accountManager.captureClientToken(clientBearer);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
232
|
const upstreamUrl = `${upstream}${req.url}`;
|
|
236
233
|
const method = req.method;
|
|
237
234
|
|
|
@@ -332,7 +329,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
332
329
|
writeRequestLog(logDir, reqId, logSections);
|
|
333
330
|
}
|
|
334
331
|
|
|
335
|
-
if (retryCount < maxRetries) {
|
|
332
|
+
if (retryCount < maxRetries && !res.headersSent) {
|
|
336
333
|
account.status = 'error';
|
|
337
334
|
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
338
335
|
}
|
package/src/tui.js
CHANGED
|
@@ -17,7 +17,8 @@ const red = s => fg(31, s);
|
|
|
17
17
|
const cyan = s => fg(36, s);
|
|
18
18
|
const gray = s => fg(90, s);
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
21
|
+
const strip = s => s.replace(ANSI_RE, '');
|
|
21
22
|
const vw = s => strip(s).length;
|
|
22
23
|
|
|
23
24
|
function rpad(s, w) {
|
|
@@ -25,13 +26,85 @@ function rpad(s, w) {
|
|
|
25
26
|
return gap > 0 ? s + ' '.repeat(gap) : s;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
/** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
|
|
30
|
+
function truncate(s, w) {
|
|
31
|
+
let visible = 0;
|
|
32
|
+
let out = '';
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < s.length && visible < w) {
|
|
35
|
+
if (s[i] === '\x1b') {
|
|
36
|
+
const end = s.indexOf('m', i);
|
|
37
|
+
if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
|
|
38
|
+
}
|
|
39
|
+
out += s[i];
|
|
40
|
+
visible++;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return out + RESET;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fit a line to exactly w columns: truncate if too long, pad if too short. */
|
|
47
|
+
function fitLine(s, w) {
|
|
48
|
+
const v = vw(s);
|
|
49
|
+
if (v > w) return truncate(s, w);
|
|
50
|
+
if (v < w) return s + ' '.repeat(w - v);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatReset(resetTs) {
|
|
55
|
+
if (!resetTs) return '';
|
|
56
|
+
const ms = resetTs - Date.now();
|
|
57
|
+
if (ms <= 0) return '';
|
|
58
|
+
const mins = Math.ceil(ms / 60000);
|
|
59
|
+
if (mins < 60) return `${mins}m`;
|
|
60
|
+
const hrs = Math.floor(mins / 60);
|
|
61
|
+
const rm = mins % 60;
|
|
62
|
+
if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
|
|
63
|
+
const days = Math.floor(hrs / 24);
|
|
64
|
+
const rh = hrs % 24;
|
|
65
|
+
return rh > 0 ? `${days}d${rh}h` : `${days}d`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a progress bar using background colors with text overlaid.
|
|
70
|
+
* The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
|
|
71
|
+
*/
|
|
72
|
+
function bar(ratio, w = 10, resetTs) {
|
|
73
|
+
const rst = formatReset(resetTs);
|
|
74
|
+
|
|
75
|
+
if (ratio == null || isNaN(ratio)) {
|
|
76
|
+
// No data — dim background, show label or dash
|
|
77
|
+
const label = rst || '-';
|
|
78
|
+
const text = label.slice(0, w);
|
|
79
|
+
const pad = w - text.length;
|
|
80
|
+
const lp = Math.floor(pad / 2);
|
|
81
|
+
const rp = pad - lp;
|
|
82
|
+
return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
ratio = Math.max(0, Math.min(1, ratio));
|
|
31
86
|
const f = Math.round(ratio * w);
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
87
|
+
// Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
|
|
88
|
+
const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
|
|
89
|
+
|
|
90
|
+
// Build the label to overlay: show reset time if available, else percentage
|
|
91
|
+
const pct = (ratio * 100).toFixed(0) + '%';
|
|
92
|
+
const label = rst || pct;
|
|
93
|
+
const text = label.slice(0, w);
|
|
94
|
+
const pad = w - text.length;
|
|
95
|
+
const lp = Math.floor(pad / 2);
|
|
96
|
+
const rp = pad - lp;
|
|
97
|
+
const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
|
|
98
|
+
|
|
99
|
+
// Split chars into filled (colored bg) and empty (gray bg) portions
|
|
100
|
+
const filled = chars.slice(0, f);
|
|
101
|
+
const empty = chars.slice(f);
|
|
102
|
+
|
|
103
|
+
let out = '';
|
|
104
|
+
if (filled) out += `${ESC}${bg};97m${filled}`;
|
|
105
|
+
if (empty) out += `${ESC}100;37m${empty}`;
|
|
106
|
+
out += RESET;
|
|
107
|
+
return out;
|
|
35
108
|
}
|
|
36
109
|
|
|
37
110
|
function timestamp() {
|
|
@@ -41,10 +114,11 @@ function timestamp() {
|
|
|
41
114
|
// ── TUI class ────────────────────────────────────────────────
|
|
42
115
|
|
|
43
116
|
export class TUI {
|
|
44
|
-
constructor({ accountManager, config, saveConfig, onQuit }) {
|
|
117
|
+
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
|
|
45
118
|
this.am = accountManager;
|
|
46
119
|
this.config = config;
|
|
47
120
|
this.saveConfig = saveConfig;
|
|
121
|
+
this.syncAccounts = syncAccounts;
|
|
48
122
|
this.onQuit = onQuit;
|
|
49
123
|
|
|
50
124
|
this.log = []; // completed activity entries
|
|
@@ -159,6 +233,7 @@ export class TUI {
|
|
|
159
233
|
this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
|
|
160
234
|
}
|
|
161
235
|
else if (k === 'a') { this.mode = 'add'; }
|
|
236
|
+
else if (k === 'R') { this._doSync(); }
|
|
162
237
|
}
|
|
163
238
|
|
|
164
239
|
_keySelect(k) {
|
|
@@ -202,6 +277,19 @@ export class TUI {
|
|
|
202
277
|
|
|
203
278
|
// ── account operations ─────────────────────────────
|
|
204
279
|
|
|
280
|
+
async _doSync() {
|
|
281
|
+
try {
|
|
282
|
+
const count = await this.syncAccounts();
|
|
283
|
+
if (count > 0) {
|
|
284
|
+
this._addLog(`Synced ${count} new account(s) from config`);
|
|
285
|
+
} else {
|
|
286
|
+
this._addLog('Config reloaded, no new accounts');
|
|
287
|
+
}
|
|
288
|
+
} catch (e) {
|
|
289
|
+
this._addLog(`Sync failed: ${e.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
205
293
|
async _doImport() {
|
|
206
294
|
try {
|
|
207
295
|
const creds = await importCredentials('~/.claude/.credentials.json');
|
|
@@ -311,7 +399,7 @@ export class TUI {
|
|
|
311
399
|
// Write buffer
|
|
312
400
|
let buf = `${ESC}H`;
|
|
313
401
|
for (let i = 0; i < H; i++) {
|
|
314
|
-
buf +=
|
|
402
|
+
buf += fitLine(lines[i] || '', W);
|
|
315
403
|
if (i < H - 1) buf += '\r\n';
|
|
316
404
|
}
|
|
317
405
|
// Show cursor only in input mode
|
|
@@ -348,11 +436,13 @@ export class TUI {
|
|
|
348
436
|
|
|
349
437
|
// Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
|
|
350
438
|
const q = a.quota;
|
|
351
|
-
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ';
|
|
439
|
+
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
|
|
352
440
|
|
|
353
441
|
if (q.unified5h != null || q.unified7d != null) {
|
|
354
442
|
r1 = q.unified5h;
|
|
355
443
|
r2 = q.unified7d;
|
|
444
|
+
t1 = q.unified5hReset;
|
|
445
|
+
t2 = q.unified7dReset;
|
|
356
446
|
} else {
|
|
357
447
|
l1 = 'Tok';
|
|
358
448
|
l2 = 'Req';
|
|
@@ -360,11 +450,13 @@ export class TUI {
|
|
|
360
450
|
? 1 - q.tokensRemaining / q.tokensLimit : null;
|
|
361
451
|
r2 = (q.requestsLimit != null && q.requestsRemaining != null)
|
|
362
452
|
? 1 - q.requestsRemaining / q.requestsLimit : null;
|
|
453
|
+
t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
|
|
454
|
+
t2 = t1;
|
|
363
455
|
}
|
|
364
456
|
|
|
365
|
-
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw)}`;
|
|
457
|
+
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
366
458
|
if (showBoth) {
|
|
367
|
-
line += ` ${l2} ${bar(r2, bw)}`;
|
|
459
|
+
line += ` ${l2} ${bar(r2, bw, t2)}`;
|
|
368
460
|
}
|
|
369
461
|
return line;
|
|
370
462
|
}
|
|
@@ -372,7 +464,7 @@ export class TUI {
|
|
|
372
464
|
_renderFooter() {
|
|
373
465
|
switch (this.mode) {
|
|
374
466
|
case 'normal':
|
|
375
|
-
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('q')}uit`;
|
|
467
|
+
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
|
|
376
468
|
case 'select': {
|
|
377
469
|
const act = this.selAction === 'switch' ? 'switch' : 'remove';
|
|
378
470
|
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|