@karpeleslab/teamclaude 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/package.json +9 -1
- package/src/index.js +18 -12
- package/src/oauth.js +3 -3
- package/src/tui.js +147 -40
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
|
|
|
24
24
|
- **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
|
|
25
25
|
- **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
|
|
26
26
|
- **Optional quota probe** — off by default; when enabled, periodically refreshes idle accounts' quota from the usage endpoint (no message spend), and surfaces the Sonnet weekly bucket
|
|
27
|
-
- **
|
|
27
|
+
- **MITM proxy mode (default)** — `teamclaude run` routes claude via an HTTPS forward proxy with a local CA so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) get the real token injected; pass `--no-mitm` for base-URL routing only
|
|
28
28
|
- **Optional sx.org proxy mode** — off by default; set an [sx.org](https://sx.org) API key in the TUI settings screen (`g`) and TeamClaude auto-provisions a residential proxy to change the egress IP and work around IP-based `429`s. Three modes (`m` to cycle): **always** (route all upstream traffic), **on 429 only** (stay direct, fail over to the proxy after a 429), or **off** (keep the key but don't use it). TLS stays end-to-end with Anthropic (the proxy only relays ciphertext)
|
|
29
29
|
- **Request logging** — optional full request/response logging for debugging
|
|
30
30
|
- **Zero dependencies** — uses only Node.js built-in modules
|
|
@@ -138,6 +138,12 @@ teamclaude run
|
|
|
138
138
|
|
|
139
139
|
`run` probes the proxy first: if it's up, Claude Code is routed through it; if it's **not** running, `claude` is launched directly so nothing breaks.
|
|
140
140
|
|
|
141
|
+
Since **1.1.0**, `run` defaults to [MITM forward-proxy mode](#mitm-proxy-mode-default) so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) are intercepted. To keep the previous base-URL-only behavior, pass `--no-mitm`:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
teamclaude run --no-mitm
|
|
145
|
+
```
|
|
146
|
+
|
|
141
147
|
Or manually set the environment:
|
|
142
148
|
|
|
143
149
|
```bash
|
|
@@ -255,14 +261,18 @@ You can also set the interval live from the TUI settings screen (`g` → `p`), a
|
|
|
255
261
|
|
|
256
262
|
It reads each OAuth account's utilization from Anthropic's usage endpoint (`/api/oauth/usage`), which reports quota **without consuming any message quota**. Minimum interval is 30s. Changing it takes effect on a running server immediately (no restart). When enabled, it also surfaces the **Sonnet 7-day** bucket as an extra bar in the TUI / `status` (when your plan exposes it).
|
|
257
263
|
|
|
258
|
-
### MITM proxy mode (
|
|
264
|
+
### MITM proxy mode (default)
|
|
259
265
|
|
|
260
|
-
The
|
|
266
|
+
The plain reverse-proxy only intercepts what `ANTHROPIC_BASE_URL` covers. Some Claude Code features (e.g. the **Claude Design MCP**) use a **hardcoded** `https://api.anthropic.com` URL that ignores that variable, so they bypass the proxy. MITM proxy mode captures those too, which is why it's the default for `teamclaude run` (and the shell alias):
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
teamclaude run -- <claude args...>
|
|
270
|
+
```
|
|
261
271
|
|
|
262
|
-
|
|
272
|
+
To opt out and route via `ANTHROPIC_BASE_URL` only, pass `--no-mitm`:
|
|
263
273
|
|
|
264
274
|
```bash
|
|
265
|
-
teamclaude run --mitm -- <claude args...>
|
|
275
|
+
teamclaude run --no-mitm -- <claude args...>
|
|
266
276
|
```
|
|
267
277
|
|
|
268
278
|
That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS_PROXY`) and trusts a locally-generated CA (`NODE_EXTRA_CA_CERTS`). For an intercepted host, teamclaude **dials the real upstream first, mirrors its negotiated ALPN** (HTTP/2 or HTTP/1.1), then terminates TLS toward claude with the same protocol and relays the traffic **as transparently as possible** — rewriting only what it must:
|
|
@@ -271,7 +281,7 @@ That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS
|
|
|
271
281
|
- the **`account_uuid`** inside `metadata.user_id` → the active account's UUID (so the body agrees with the injected token);
|
|
272
282
|
- and it reads `anthropic-ratelimit-*` from responses for quota.
|
|
273
283
|
|
|
274
|
-
Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--mitm` can share one server.
|
|
284
|
+
Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--no-mitm` can share one server.
|
|
275
285
|
|
|
276
286
|
Trust model:
|
|
277
287
|
- The CA is generated locally, stored in the config dir, and trusted **only** by the claude process you launch via `teamclaude run` (through `NODE_EXTRA_CA_CERTS`) — it is **never** added to your system trust store. The leaf private key is `0600`; the CA private key is never written to disk.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karpeleslab/teamclaude",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Multi-account Claude proxy with automatic quota-based rotation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
"multi-account"
|
|
24
24
|
],
|
|
25
25
|
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/KarpelesLab/teamclaude.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/KarpelesLab/teamclaude/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/KarpelesLab/teamclaude#readme",
|
|
26
34
|
"engines": {
|
|
27
35
|
"node": ">=18.0.0"
|
|
28
36
|
},
|
package/src/index.js
CHANGED
|
@@ -444,13 +444,18 @@ async function envCommand() {
|
|
|
444
444
|
async function runCommand() {
|
|
445
445
|
const config = await loadOrCreateConfig();
|
|
446
446
|
|
|
447
|
-
// Args after 'run'. teamclaude flags (e.g. --mitm) are recognized only
|
|
448
|
-
// an optional `--` separator; everything after `--` goes verbatim to
|
|
447
|
+
// Args after 'run'. teamclaude flags (e.g. --no-mitm) are recognized only
|
|
448
|
+
// before an optional `--` separator; everything after `--` goes verbatim to
|
|
449
|
+
// claude. MITM forward-proxy mode is the default so hardcoded api.anthropic.com
|
|
450
|
+
// endpoints are intercepted too; --no-mitm opts back into base-URL-only routing.
|
|
451
|
+
// --mitm is still accepted (now a no-op) for backward compatibility.
|
|
449
452
|
const rest = args.slice(1);
|
|
450
453
|
const sep = rest.indexOf('--');
|
|
451
454
|
const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
|
|
452
|
-
const useMitm = tcFlags.includes('--mitm');
|
|
453
|
-
const claudeArgs = sep >= 0
|
|
455
|
+
const useMitm = !tcFlags.includes('--no-mitm');
|
|
456
|
+
const claudeArgs = sep >= 0
|
|
457
|
+
? rest.slice(sep + 1)
|
|
458
|
+
: rest.filter(a => a !== '--mitm' && a !== '--no-mitm');
|
|
454
459
|
|
|
455
460
|
// Route through the proxy only when it's actually up; otherwise launch claude
|
|
456
461
|
// directly so a stopped proxy doesn't break `claude`. This is what lets the
|
|
@@ -564,7 +569,7 @@ async function accountsCommand() {
|
|
|
564
569
|
a.refreshToken = newTokens.refreshToken;
|
|
565
570
|
a.expiresAt = newTokens.expiresAt;
|
|
566
571
|
configDirty = true;
|
|
567
|
-
} catch
|
|
572
|
+
} catch {
|
|
568
573
|
// refresh failed — fetchProfile will report the specific error
|
|
569
574
|
}
|
|
570
575
|
}));
|
|
@@ -884,10 +889,11 @@ Commands:
|
|
|
884
889
|
login OAuth login via browser
|
|
885
890
|
login --api Add an API key account
|
|
886
891
|
env Print env vars to use with Claude
|
|
887
|
-
run [--mitm] [-- args...]
|
|
888
|
-
Run Claude Code through the proxy (direct if it's down)
|
|
889
|
-
|
|
890
|
-
hardcoded api.anthropic.com endpoints are intercepted
|
|
892
|
+
run [--no-mitm] [-- args...]
|
|
893
|
+
Run Claude Code through the proxy (direct if it's down).
|
|
894
|
+
Routes via an HTTPS forward proxy + local CA by default, so
|
|
895
|
+
even hardcoded api.anthropic.com endpoints are intercepted;
|
|
896
|
+
--no-mitm uses base-URL routing only
|
|
891
897
|
alias Print a shell alias so plain 'claude' routes via the proxy
|
|
892
898
|
(--install to write it to your shell rc; --uninstall to remove)
|
|
893
899
|
status Show proxy & account status (live)
|
|
@@ -909,10 +915,10 @@ Options:
|
|
|
909
915
|
--json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
|
|
910
916
|
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
911
917
|
--headless Run the server without the interactive TUI (for backgrounding)
|
|
912
|
-
--mitm
|
|
918
|
+
--no-mitm (run) skip the forward proxy; route via ANTHROPIC_BASE_URL only
|
|
913
919
|
|
|
914
920
|
The server always accepts both base-URL and proxy/CONNECT clients, so instances
|
|
915
|
-
launched with and without --mitm can share one server.
|
|
921
|
+
launched with and without --no-mitm can share one server.
|
|
916
922
|
|
|
917
923
|
A running server re-syncs accounts from config on POST /teamclaude/reload
|
|
918
924
|
(local only). add/login/enable/disable/priority trigger it automatically.
|
|
@@ -1117,7 +1123,7 @@ function argValue(flag) {
|
|
|
1117
1123
|
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
|
|
1118
1124
|
}
|
|
1119
1125
|
|
|
1120
|
-
// Hostname of the configured upstream (the host MITM-intercepts under `run
|
|
1126
|
+
// Hostname of the configured upstream (the host MITM-intercepts under `run`).
|
|
1121
1127
|
function upstreamHost(config) {
|
|
1122
1128
|
try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
|
|
1123
1129
|
catch { return 'api.anthropic.com'; }
|
package/src/oauth.js
CHANGED
|
@@ -149,15 +149,15 @@ export async function fetchProfile(accessToken) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Normalize one usage bucket from the /api/oauth/usage payload into
|
|
152
|
-
// { utilization: 0-1, resetAt: ms-epoch }.
|
|
153
|
-
// percentage
|
|
152
|
+
// { utilization: 0-1, resetAt: ms-epoch }. The endpoint reports utilization
|
|
153
|
+
// as a percentage in the 0-100 range, so 1 means 1%, not 100%.
|
|
154
154
|
export function normalizeUsageBucket(bucket) {
|
|
155
155
|
if (!bucket || typeof bucket !== 'object') return null;
|
|
156
156
|
|
|
157
157
|
const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
|
|
158
158
|
const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
|
|
159
159
|
const utilization = Number.isFinite(parsedPct)
|
|
160
|
-
?
|
|
160
|
+
? parsedPct / 100
|
|
161
161
|
: null;
|
|
162
162
|
|
|
163
163
|
const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
|
package/src/tui.js
CHANGED
|
@@ -8,6 +8,7 @@ const ESC = '\x1b[';
|
|
|
8
8
|
const RESET = `${ESC}0m`;
|
|
9
9
|
const BOLD = `${ESC}1m`;
|
|
10
10
|
const DIM = `${ESC}2m`;
|
|
11
|
+
const REV = `${ESC}7m`; // reverse video — used for the BIOS-style settings cursor
|
|
11
12
|
|
|
12
13
|
const bold = s => `${BOLD}${s}${RESET}`;
|
|
13
14
|
const dim = s => `${DIM}${s}${RESET}`;
|
|
@@ -129,9 +130,11 @@ export class TUI {
|
|
|
129
130
|
this.mode = 'normal'; // normal | select | add | input | settings
|
|
130
131
|
this.selAction = null; // switch | remove
|
|
131
132
|
this.selIdx = 0;
|
|
133
|
+
this.setIdx = 0; // cursor row on the settings screen (BIOS-style nav)
|
|
132
134
|
this.inputPrompt = '';
|
|
133
135
|
this.inputBuf = '';
|
|
134
136
|
this.inputCb = null;
|
|
137
|
+
this.inputReturn = 'normal'; // mode to fall back to when an input is cancelled
|
|
135
138
|
this.frame = 0;
|
|
136
139
|
this.running = false;
|
|
137
140
|
this.timer = null;
|
|
@@ -208,6 +211,8 @@ export class TUI {
|
|
|
208
211
|
_onData(d) {
|
|
209
212
|
if (d === '\x1b[A') return this._key('up');
|
|
210
213
|
if (d === '\x1b[B') return this._key('down');
|
|
214
|
+
if (d === '\x1b[C') return this._key('right');
|
|
215
|
+
if (d === '\x1b[D') return this._key('left');
|
|
211
216
|
if (d === '\x1b') return this._key('esc');
|
|
212
217
|
if (d === '\r' || d === '\n') return this._key('enter');
|
|
213
218
|
if (d === '\x03') return this._key('ctrl-c');
|
|
@@ -241,33 +246,118 @@ export class TUI {
|
|
|
241
246
|
}
|
|
242
247
|
else if (k === 'a') { this.mode = 'add'; }
|
|
243
248
|
else if (k === 'R') { this._doSync(); }
|
|
244
|
-
else if (k === 'g' && this.sx) { this.mode = 'settings'; this._loadSxBalance(); }
|
|
249
|
+
else if (k === 'g' && this.sx) { this.mode = 'settings'; this.setIdx = 0; this._loadSxBalance(); }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Navigable rows on the settings screen, top to bottom. Both the renderer and
|
|
253
|
+
// the key handler build this list so the cursor and the display stay in sync.
|
|
254
|
+
// Rows are conditional (sx.org rows only when that build feature is present),
|
|
255
|
+
// so always index through the returned array — never hard-code positions.
|
|
256
|
+
_settingsFields() {
|
|
257
|
+
const fields = [];
|
|
258
|
+
|
|
259
|
+
fields.push({
|
|
260
|
+
id: 'threshold',
|
|
261
|
+
label: 'Switch threshold',
|
|
262
|
+
hint: '←→ ±1%',
|
|
263
|
+
value: () => {
|
|
264
|
+
const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
|
|
265
|
+
return green(`${Math.round(thr * 100)}%`);
|
|
266
|
+
},
|
|
267
|
+
left: () => this._nudgeThreshold(-1),
|
|
268
|
+
right: () => this._nudgeThreshold(+1),
|
|
269
|
+
enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
fields.push({
|
|
273
|
+
id: 'probe',
|
|
274
|
+
label: 'Quota probe',
|
|
275
|
+
hint: '←→ ±30s',
|
|
276
|
+
value: () => {
|
|
277
|
+
const probe = this.config.quotaProbeSeconds || 0;
|
|
278
|
+
return probe > 0 ? green(`${probe}s`) : gray('off (passive)');
|
|
279
|
+
},
|
|
280
|
+
left: () => this._nudgeProbe(-30),
|
|
281
|
+
right: () => this._nudgeProbe(+30),
|
|
282
|
+
enter: () => this._promptInput('Quota probe seconds (0=off, min 30)', v => this._doSetProbe(v.trim())),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (this.sx) {
|
|
286
|
+
fields.push({
|
|
287
|
+
id: 'sxmode',
|
|
288
|
+
label: 'sx.org mode',
|
|
289
|
+
hint: '←→ cycle',
|
|
290
|
+
value: () => {
|
|
291
|
+
const mode = this.sx.getMode();
|
|
292
|
+
return mode === 'always' ? green('always')
|
|
293
|
+
: mode === '429' ? cyan('on 429 only')
|
|
294
|
+
: gray('off');
|
|
295
|
+
},
|
|
296
|
+
left: () => this._cycleSxMode(-1),
|
|
297
|
+
right: () => this._cycleSxMode(+1),
|
|
298
|
+
enter: () => this._cycleSxMode(+1),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
fields.push({
|
|
302
|
+
id: 'sxkey',
|
|
303
|
+
label: 'sx.org API key',
|
|
304
|
+
hint: 'Enter to set',
|
|
305
|
+
value: () => {
|
|
306
|
+
const key = this.config.sx?.apiKey;
|
|
307
|
+
return key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
|
|
308
|
+
},
|
|
309
|
+
enter: () => this._promptInput('sx.org API key', v => this._doSetSxKey(v.trim())),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (this.config.sx?.apiKey) {
|
|
313
|
+
fields.push({
|
|
314
|
+
id: 'sxclear',
|
|
315
|
+
label: 'Clear sx.org key',
|
|
316
|
+
hint: 'Enter to clear',
|
|
317
|
+
value: () => dim('—'),
|
|
318
|
+
enter: () => this._doClearSxKey(),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return fields;
|
|
245
324
|
}
|
|
246
325
|
|
|
247
326
|
_keySettings(k) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
else if (k === '
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
this.inputCb = v => { if (v) this._doSetProbe(v.trim()); };
|
|
259
|
-
}
|
|
260
|
-
else if (k === 'k') {
|
|
261
|
-
this.mode = 'input';
|
|
262
|
-
this.inputPrompt = 'sx.org API key';
|
|
263
|
-
this.inputBuf = '';
|
|
264
|
-
this.inputCb = v => { if (v) this._doSetSxKey(v.trim()); };
|
|
265
|
-
}
|
|
266
|
-
else if (k === 'm') { this._doCycleSxMode(); }
|
|
267
|
-
else if (k === 'x') { this._doClearSxKey(); }
|
|
327
|
+
const fields = this._settingsFields();
|
|
328
|
+
const n = fields.length;
|
|
329
|
+
if (n > 0 && this.setIdx >= n) this.setIdx = n - 1;
|
|
330
|
+
const f = fields[this.setIdx];
|
|
331
|
+
|
|
332
|
+
if (k === 'up' || k === 'k') this.setIdx = (this.setIdx - 1 + n) % n;
|
|
333
|
+
else if (k === 'down' || k === 'j') this.setIdx = (this.setIdx + 1) % n;
|
|
334
|
+
else if (k === 'left') f?.left?.();
|
|
335
|
+
else if (k === 'right') f?.right?.();
|
|
336
|
+
else if (k === 'enter') f?.enter?.();
|
|
268
337
|
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
269
338
|
}
|
|
270
339
|
|
|
340
|
+
// Open the text-input prompt and return to the settings screen afterward.
|
|
341
|
+
_promptInput(prompt, cb) {
|
|
342
|
+
this.mode = 'input';
|
|
343
|
+
this.inputReturn = 'settings';
|
|
344
|
+
this.inputPrompt = prompt;
|
|
345
|
+
this.inputBuf = '';
|
|
346
|
+
this.inputCb = v => { if (v) cb(v); };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_nudgeThreshold(deltaPct) {
|
|
350
|
+
const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
|
|
351
|
+
const next = Math.max(1, Math.min(100, cur + deltaPct));
|
|
352
|
+
if (next !== cur) this._doSetThreshold(String(next));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_nudgeProbe(deltaSec) {
|
|
356
|
+
const cur = this.config.quotaProbeSeconds || 0;
|
|
357
|
+
const next = Math.max(0, cur + deltaSec);
|
|
358
|
+
if (next !== cur) this._doSetProbe(String(next));
|
|
359
|
+
}
|
|
360
|
+
|
|
271
361
|
async _doSetThreshold(input) {
|
|
272
362
|
const pct = Number(input);
|
|
273
363
|
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
|
|
@@ -322,6 +412,7 @@ export class TUI {
|
|
|
322
412
|
if (k === 'i') { this._doImport(); this.mode = 'normal'; }
|
|
323
413
|
else if (k === 'k') {
|
|
324
414
|
this.mode = 'input';
|
|
415
|
+
this.inputReturn = 'normal';
|
|
325
416
|
this.inputPrompt = 'API key';
|
|
326
417
|
this.inputBuf = '';
|
|
327
418
|
this.inputCb = v => { if (v) this._doAddKey(v); };
|
|
@@ -333,10 +424,10 @@ export class TUI {
|
|
|
333
424
|
if (k === 'enter') {
|
|
334
425
|
const cb = this.inputCb;
|
|
335
426
|
const v = this.inputBuf;
|
|
336
|
-
this.mode =
|
|
427
|
+
this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = '';
|
|
337
428
|
cb?.(v);
|
|
338
429
|
}
|
|
339
|
-
else if (k === 'esc') { this.mode =
|
|
430
|
+
else if (k === 'esc') { this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = ''; }
|
|
340
431
|
else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
|
|
341
432
|
else if (k.length === 1) { this.inputBuf += k; }
|
|
342
433
|
}
|
|
@@ -383,11 +474,11 @@ export class TUI {
|
|
|
383
474
|
if (this.running) this.render();
|
|
384
475
|
}
|
|
385
476
|
|
|
386
|
-
// Cycle off → on-429 → always
|
|
387
|
-
// sx.org without deconfiguring it.
|
|
388
|
-
async
|
|
477
|
+
// Cycle off → on-429 → always (dir +1) or the reverse (dir -1). Keeps the API
|
|
478
|
+
// key, so the user can disable sx.org without deconfiguring it.
|
|
479
|
+
async _cycleSxMode(dir = 1) {
|
|
389
480
|
const order = ['off', '429', 'always'];
|
|
390
|
-
const next = order[(order.indexOf(this.sx.getMode()) +
|
|
481
|
+
const next = order[(order.indexOf(this.sx.getMode()) + dir + order.length) % order.length];
|
|
391
482
|
this.config.sx = { ...(this.config.sx || {}), mode: next };
|
|
392
483
|
try { await this.saveConfig(this.config); }
|
|
393
484
|
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
@@ -672,37 +763,53 @@ export class TUI {
|
|
|
672
763
|
}
|
|
673
764
|
|
|
674
765
|
_renderSettings(lines) {
|
|
766
|
+
const fields = this._settingsFields();
|
|
767
|
+
if (this.setIdx >= fields.length) this.setIdx = Math.max(0, fields.length - 1);
|
|
768
|
+
const selId = fields[this.setIdx]?.id;
|
|
769
|
+
const byId = id => fields.find(f => f.id === id);
|
|
770
|
+
|
|
771
|
+
// Render a navigable setting row with a BIOS-style highlight bar on the
|
|
772
|
+
// cursor row. Read-only info rows pass field=null and never highlight.
|
|
773
|
+
const row = field => {
|
|
774
|
+
const selected = field && field.id === selId;
|
|
775
|
+
const label = (field ? field.label : '').padEnd(16);
|
|
776
|
+
const value = field ? field.value() : '';
|
|
777
|
+
if (selected) {
|
|
778
|
+
const hint = field.hint ? ` ${dim(field.hint)}` : '';
|
|
779
|
+
const inner = rpad(` ${label} ${strip(value)} `, 34);
|
|
780
|
+
return ` ${cyan('▸')}${REV}${inner}${RESET}${hint}`;
|
|
781
|
+
}
|
|
782
|
+
return ` ${dim(label)} ${value}`;
|
|
783
|
+
};
|
|
784
|
+
// A plain read-only info line (not selectable), aligned with the rows above.
|
|
785
|
+
const info = (label, value) => ` ${dim(label.padEnd(16))} ${value}`;
|
|
786
|
+
|
|
675
787
|
lines.push('');
|
|
676
788
|
// ── Rotation
|
|
677
|
-
const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
|
|
678
789
|
lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
|
|
679
|
-
lines.push(
|
|
790
|
+
lines.push(row(byId('threshold')));
|
|
680
791
|
lines.push('');
|
|
681
792
|
// ── Quota probe
|
|
682
|
-
const probe = this.config.quotaProbeSeconds || 0;
|
|
683
793
|
lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
|
|
684
|
-
lines.push(
|
|
794
|
+
lines.push(row(byId('probe')));
|
|
685
795
|
lines.push('');
|
|
686
796
|
// ── sx.org
|
|
687
797
|
lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
|
|
688
798
|
lines.push('');
|
|
689
799
|
if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
|
|
690
800
|
const key = this.config.sx?.apiKey;
|
|
691
|
-
const masked = key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
|
|
692
801
|
const mode = this.sx.getMode();
|
|
693
|
-
const modeStr = mode === 'always' ? green('always')
|
|
694
|
-
: mode === '429' ? cyan('on 429 only')
|
|
695
|
-
: gray('off');
|
|
696
802
|
const p = this.sx.getProxy?.();
|
|
697
803
|
const proxyStr = mode === 'off' ? gray('—')
|
|
698
804
|
: this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
|
|
699
805
|
: key ? yellow('not provisioned')
|
|
700
806
|
: gray('no key');
|
|
701
807
|
const b = this.sxBalance;
|
|
702
|
-
lines.push(
|
|
703
|
-
lines.push(
|
|
704
|
-
lines.push(
|
|
705
|
-
lines.push(
|
|
808
|
+
lines.push(row(byId('sxmode')));
|
|
809
|
+
lines.push(row(byId('sxkey')));
|
|
810
|
+
lines.push(info('Proxy', proxyStr));
|
|
811
|
+
lines.push(info('Balance', b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')));
|
|
812
|
+
if (byId('sxclear')) lines.push(row(byId('sxclear')));
|
|
706
813
|
lines.push('');
|
|
707
814
|
lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
|
|
708
815
|
lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
|
|
@@ -716,7 +823,7 @@ export class TUI {
|
|
|
716
823
|
case 'normal':
|
|
717
824
|
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
|
|
718
825
|
case 'settings':
|
|
719
|
-
return ` ${
|
|
826
|
+
return ` ${dim('↑↓')} navigate ${dim('←→')} change ${bold('Enter')} edit ${bold('Esc')} back`;
|
|
720
827
|
case 'select': {
|
|
721
828
|
const act = this.selAction === 'switch' ? 'switch'
|
|
722
829
|
: this.selAction === 'toggle' ? 'enable/disable'
|