@karpeleslab/teamclaude 1.0.8 → 1.0.9

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.
Files changed (3) hide show
  1. package/package.json +9 -1
  2. package/src/oauth.js +3 -3
  3. package/src/tui.js +147 -40
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
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/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 }. Tolerant of field-name and
153
- // percentage/fraction and seconds/ms variations across payload versions.
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
- ? (parsedPct > 1 ? parsedPct / 100 : parsedPct)
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
- if (k === 't') {
249
- this.mode = 'input';
250
- this.inputPrompt = 'Switch threshold % (1-100)';
251
- this.inputBuf = '';
252
- this.inputCb = v => { if (v) this._doSetThreshold(v.trim()); };
253
- }
254
- else if (k === 'p') {
255
- this.mode = 'input';
256
- this.inputPrompt = 'Quota probe seconds (0=off, min 30)';
257
- this.inputBuf = '';
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 = 'normal'; this.inputCb = null; this.inputBuf = '';
427
+ this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = '';
337
428
  cb?.(v);
338
429
  }
339
- else if (k === 'esc') { this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; }
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. Keeps the API key, so the user can disable
387
- // sx.org without deconfiguring it.
388
- async _doCycleSxMode() {
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()) + 1) % order.length];
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(` Switch at: ${green(`${Math.round(thr * 100)}%`)} ${dim('utilization')}`);
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(` Interval: ${probe > 0 ? green(`${probe}s`) : gray('off (passive)')}`);
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(` Mode: ${modeStr}`);
703
- lines.push(` API key: ${masked}`);
704
- lines.push(` Proxy: ${proxyStr}`);
705
- lines.push(` Balance: ${b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')}`);
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 ` ${bold('t')} threshold ${bold('p')} probe ${bold('m')} sx-mode ${bold('k')} sx-key ${bold('x')} clear-key ${bold('Esc')} back`;
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'