@karpeleslab/teamclaude 1.0.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 +169 -0
- package/package.json +27 -0
- package/src/account-manager.js +323 -0
- package/src/config.js +48 -0
- package/src/index.js +577 -0
- package/src/oauth.js +220 -0
- package/src/server.js +351 -0
- package/src/tui.js +388 -0
package/src/tui.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { importCredentials } from './oauth.js';
|
|
2
|
+
|
|
3
|
+
// ── ANSI helpers ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
|
|
6
|
+
const ESC = '\x1b[';
|
|
7
|
+
const RESET = `${ESC}0m`;
|
|
8
|
+
const BOLD = `${ESC}1m`;
|
|
9
|
+
const DIM = `${ESC}2m`;
|
|
10
|
+
|
|
11
|
+
const bold = s => `${BOLD}${s}${RESET}`;
|
|
12
|
+
const dim = s => `${DIM}${s}${RESET}`;
|
|
13
|
+
const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
|
|
14
|
+
const green = s => fg(32, s);
|
|
15
|
+
const yellow = s => fg(33, s);
|
|
16
|
+
const red = s => fg(31, s);
|
|
17
|
+
const cyan = s => fg(36, s);
|
|
18
|
+
const gray = s => fg(90, s);
|
|
19
|
+
|
|
20
|
+
const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
21
|
+
const vw = s => strip(s).length;
|
|
22
|
+
|
|
23
|
+
function rpad(s, w) {
|
|
24
|
+
const gap = w - vw(s);
|
|
25
|
+
return gap > 0 ? s + ' '.repeat(gap) : s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bar(ratio, w = 10) {
|
|
29
|
+
if (ratio == null || isNaN(ratio)) return gray('░'.repeat(w)) + ' - ';
|
|
30
|
+
ratio = Math.max(0, Math.min(1, ratio));
|
|
31
|
+
const f = Math.round(ratio * w);
|
|
32
|
+
const c = ratio < 0.7 ? 32 : ratio < 0.9 ? 33 : 31;
|
|
33
|
+
const pct = (ratio * 100).toFixed(0).padStart(3) + '%';
|
|
34
|
+
return `${ESC}${c}m${'█'.repeat(f)}${ESC}90m${'░'.repeat(w - f)}${RESET} ${pct}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function timestamp() {
|
|
38
|
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── TUI class ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export class TUI {
|
|
44
|
+
constructor({ accountManager, config, saveConfig, onQuit }) {
|
|
45
|
+
this.am = accountManager;
|
|
46
|
+
this.config = config;
|
|
47
|
+
this.saveConfig = saveConfig;
|
|
48
|
+
this.onQuit = onQuit;
|
|
49
|
+
|
|
50
|
+
this.log = []; // completed activity entries
|
|
51
|
+
this.active = new Map(); // in-flight requests
|
|
52
|
+
this.mode = 'normal'; // normal | select | add | input
|
|
53
|
+
this.selAction = null; // switch | remove
|
|
54
|
+
this.selIdx = 0;
|
|
55
|
+
this.inputPrompt = '';
|
|
56
|
+
this.inputBuf = '';
|
|
57
|
+
this.inputCb = null;
|
|
58
|
+
this.frame = 0;
|
|
59
|
+
this.running = false;
|
|
60
|
+
this.timer = null;
|
|
61
|
+
this._origLog = null;
|
|
62
|
+
this._origErr = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── lifecycle ──────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
start() {
|
|
68
|
+
this.running = true;
|
|
69
|
+
process.stdout.write(`${ESC}?1049h${ESC}?25l`);
|
|
70
|
+
process.stdin.setRawMode(true);
|
|
71
|
+
process.stdin.resume();
|
|
72
|
+
process.stdin.setEncoding('utf8');
|
|
73
|
+
this._dataHandler = d => this._onData(d);
|
|
74
|
+
this._resizeHandler = () => this.render();
|
|
75
|
+
process.stdin.on('data', this._dataHandler);
|
|
76
|
+
process.stdout.on('resize', this._resizeHandler);
|
|
77
|
+
|
|
78
|
+
// Redirect console to activity log
|
|
79
|
+
this._origLog = console.log;
|
|
80
|
+
this._origErr = console.error;
|
|
81
|
+
console.log = (...a) => this._addLog(a.join(' '));
|
|
82
|
+
console.error = (...a) => this._addLog(a.join(' '));
|
|
83
|
+
|
|
84
|
+
this.render();
|
|
85
|
+
this.timer = setInterval(() => {
|
|
86
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
87
|
+
this.render();
|
|
88
|
+
}, 500);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
stop() {
|
|
92
|
+
this.running = false;
|
|
93
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
94
|
+
if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
|
|
95
|
+
process.stdin.removeListener('data', this._dataHandler);
|
|
96
|
+
process.stdout.removeListener('resize', this._resizeHandler);
|
|
97
|
+
process.stdout.write(`${ESC}?25h${ESC}?1049l`);
|
|
98
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
99
|
+
process.stdin.pause();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── server hooks ───────────────────────────────────
|
|
103
|
+
|
|
104
|
+
onRequestStart(id, info) {
|
|
105
|
+
this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
|
|
106
|
+
this.render();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onRequestRouted(id, info) {
|
|
110
|
+
const r = this.active.get(id);
|
|
111
|
+
if (r) r.account = info.account;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onRequestEnd(id, info) {
|
|
115
|
+
const r = this.active.get(id);
|
|
116
|
+
this.active.delete(id);
|
|
117
|
+
const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
|
|
118
|
+
const acct = info.account || r?.account || '?';
|
|
119
|
+
this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_addLog(msg) {
|
|
123
|
+
msg = msg.replace(/^\[TeamClaude\]\s*/, '');
|
|
124
|
+
this.log.unshift({ t: timestamp(), msg });
|
|
125
|
+
if (this.log.length > 200) this.log.length = 200;
|
|
126
|
+
if (this.running) this.render();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── input handling ─────────────────────────────────
|
|
130
|
+
|
|
131
|
+
_onData(d) {
|
|
132
|
+
if (d === '\x1b[A') return this._key('up');
|
|
133
|
+
if (d === '\x1b[B') return this._key('down');
|
|
134
|
+
if (d === '\x1b') return this._key('esc');
|
|
135
|
+
if (d === '\r' || d === '\n') return this._key('enter');
|
|
136
|
+
if (d === '\x03') return this._key('ctrl-c');
|
|
137
|
+
if (d === '\x7f' || d === '\x08') return this._key('bs');
|
|
138
|
+
if (d.length === 1 && d >= ' ') return this._key(d);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_key(k) {
|
|
142
|
+
if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
|
|
143
|
+
|
|
144
|
+
switch (this.mode) {
|
|
145
|
+
case 'normal': this._keyNormal(k); break;
|
|
146
|
+
case 'select': this._keySelect(k); break;
|
|
147
|
+
case 'add': this._keyAdd(k); break;
|
|
148
|
+
case 'input': this._keyInput(k); break;
|
|
149
|
+
}
|
|
150
|
+
this.render();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_keyNormal(k) {
|
|
154
|
+
if (k === 'q') { this.stop(); this.onQuit?.(); }
|
|
155
|
+
else if (k === 's' && this.am.accounts.length > 0) {
|
|
156
|
+
this.mode = 'select'; this.selAction = 'switch'; this.selIdx = this.am.currentIndex;
|
|
157
|
+
}
|
|
158
|
+
else if (k === 'r' && this.am.accounts.length > 0) {
|
|
159
|
+
this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
|
|
160
|
+
}
|
|
161
|
+
else if (k === 'a') { this.mode = 'add'; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_keySelect(k) {
|
|
165
|
+
const len = this.am.accounts.length;
|
|
166
|
+
if (k === 'up' || k === 'k') this.selIdx = Math.max(0, this.selIdx - 1);
|
|
167
|
+
else if (k === 'down' || k === 'j') this.selIdx = Math.min(len - 1, this.selIdx + 1);
|
|
168
|
+
else if (k === 'enter') {
|
|
169
|
+
if (this.selAction === 'switch') {
|
|
170
|
+
this.am.currentIndex = this.selIdx;
|
|
171
|
+
this._addLog(`Switched to "${this.am.accounts[this.selIdx].name}"`);
|
|
172
|
+
} else {
|
|
173
|
+
this._doRemove(this.selIdx);
|
|
174
|
+
}
|
|
175
|
+
this.mode = 'normal';
|
|
176
|
+
}
|
|
177
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_keyAdd(k) {
|
|
181
|
+
if (k === 'i') { this._doImport(); this.mode = 'normal'; }
|
|
182
|
+
else if (k === 'k') {
|
|
183
|
+
this.mode = 'input';
|
|
184
|
+
this.inputPrompt = 'API key';
|
|
185
|
+
this.inputBuf = '';
|
|
186
|
+
this.inputCb = v => { if (v) this._doAddKey(v); };
|
|
187
|
+
}
|
|
188
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_keyInput(k) {
|
|
192
|
+
if (k === 'enter') {
|
|
193
|
+
const cb = this.inputCb;
|
|
194
|
+
const v = this.inputBuf;
|
|
195
|
+
this.mode = 'normal'; this.inputCb = null; this.inputBuf = '';
|
|
196
|
+
cb?.(v);
|
|
197
|
+
}
|
|
198
|
+
else if (k === 'esc') { this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; }
|
|
199
|
+
else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
|
|
200
|
+
else if (k.length === 1) { this.inputBuf += k; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── account operations ─────────────────────────────
|
|
204
|
+
|
|
205
|
+
async _doImport() {
|
|
206
|
+
try {
|
|
207
|
+
const creds = await importCredentials('~/.claude/.credentials.json');
|
|
208
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('max-')).length + 1;
|
|
209
|
+
const name = `max-${n}`;
|
|
210
|
+
const entry = {
|
|
211
|
+
name, type: 'oauth',
|
|
212
|
+
accessToken: creds.accessToken,
|
|
213
|
+
refreshToken: creds.refreshToken,
|
|
214
|
+
expiresAt: creds.expiresAt,
|
|
215
|
+
};
|
|
216
|
+
this.config.accounts.push(entry);
|
|
217
|
+
this.am.addAccount(entry);
|
|
218
|
+
await this.saveConfig(this.config);
|
|
219
|
+
this._addLog(`Imported account "${name}"`);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
this._addLog(`Import failed: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async _doAddKey(apiKey) {
|
|
226
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
|
|
227
|
+
const name = `api-${n}`;
|
|
228
|
+
this.config.accounts.push({ name, type: 'apikey', apiKey });
|
|
229
|
+
this.am.addAccount({ name, type: 'apikey', apiKey });
|
|
230
|
+
await this.saveConfig(this.config);
|
|
231
|
+
this._addLog(`Added API key account "${name}"`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async _doRemove(idx) {
|
|
235
|
+
if (idx < 0 || idx >= this.am.accounts.length) return;
|
|
236
|
+
const name = this.am.accounts[idx].name;
|
|
237
|
+
this.am.removeAccount(idx);
|
|
238
|
+
this.config.accounts.splice(idx, 1);
|
|
239
|
+
if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
|
|
240
|
+
await this.saveConfig(this.config);
|
|
241
|
+
this._addLog(`Removed account "${name}"`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── rendering ──────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
render() {
|
|
247
|
+
if (!this.running) return;
|
|
248
|
+
const W = process.stdout.columns || 80;
|
|
249
|
+
const H = process.stdout.rows || 24;
|
|
250
|
+
|
|
251
|
+
if (W < 40 || H < 8) {
|
|
252
|
+
process.stdout.write(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const lines = [];
|
|
257
|
+
|
|
258
|
+
// ── Header
|
|
259
|
+
const left = bold(' TeamClaude');
|
|
260
|
+
const port = this.config.proxy?.port || 3456;
|
|
261
|
+
const right = `Port ${port} ${green('▲')} `;
|
|
262
|
+
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
263
|
+
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
264
|
+
|
|
265
|
+
// ── Accounts
|
|
266
|
+
if (this.am.accounts.length === 0) {
|
|
267
|
+
lines.push('');
|
|
268
|
+
lines.push(yellow(' No accounts configured. Press [a] to add one.'));
|
|
269
|
+
} else {
|
|
270
|
+
lines.push('');
|
|
271
|
+
const showBoth = W >= 70;
|
|
272
|
+
const bw = showBoth
|
|
273
|
+
? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
|
|
274
|
+
: Math.max(5, Math.min(20, W - 45));
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < this.am.accounts.length; i++) {
|
|
277
|
+
lines.push(this._renderAcct(i, bw, showBoth));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Activity header
|
|
282
|
+
lines.push('');
|
|
283
|
+
const ac = this.active.size;
|
|
284
|
+
const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
|
|
285
|
+
const aHdr = ` Activity${acTag} `;
|
|
286
|
+
lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
|
|
287
|
+
|
|
288
|
+
// Active requests
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
for (const [, r] of this.active) {
|
|
291
|
+
const el = ((now - r.started) / 1000).toFixed(1);
|
|
292
|
+
const sp = cyan(SPINNER[this.frame]);
|
|
293
|
+
const a = r.account ? ` → ${r.account}` : '';
|
|
294
|
+
lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Completed log
|
|
298
|
+
const footerH = 2;
|
|
299
|
+
const space = Math.max(0, H - lines.length - footerH);
|
|
300
|
+
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
301
|
+
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Pad to fill
|
|
305
|
+
while (lines.length < H - footerH) lines.push('');
|
|
306
|
+
|
|
307
|
+
// ── Footer
|
|
308
|
+
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
309
|
+
lines.push(this._renderFooter());
|
|
310
|
+
|
|
311
|
+
// Write buffer
|
|
312
|
+
let buf = `${ESC}H`;
|
|
313
|
+
for (let i = 0; i < H; i++) {
|
|
314
|
+
buf += rpad(lines[i] || '', W);
|
|
315
|
+
if (i < H - 1) buf += '\r\n';
|
|
316
|
+
}
|
|
317
|
+
// Show cursor only in input mode
|
|
318
|
+
buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
|
|
319
|
+
process.stdout.write(buf);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
_renderAcct(idx, bw, showBoth) {
|
|
323
|
+
const a = this.am.accounts[idx];
|
|
324
|
+
const isCur = idx === this.am.currentIndex;
|
|
325
|
+
const isSel = this.mode === 'select' && idx === this.selIdx;
|
|
326
|
+
|
|
327
|
+
// Prefix: selection marker + current marker
|
|
328
|
+
const sel = isSel ? cyan('>') : ' ';
|
|
329
|
+
const cur = isCur ? green('►') : ' ';
|
|
330
|
+
|
|
331
|
+
// Name (bold if selected)
|
|
332
|
+
const rawName = a.name.slice(0, 12).padEnd(12);
|
|
333
|
+
const name = isSel ? bold(rawName) : rawName;
|
|
334
|
+
|
|
335
|
+
// Type
|
|
336
|
+
const type = gray(a.type.padEnd(7));
|
|
337
|
+
|
|
338
|
+
// Status
|
|
339
|
+
let status;
|
|
340
|
+
switch (a.status) {
|
|
341
|
+
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
342
|
+
case 'throttled': status = yellow('throttled'); break;
|
|
343
|
+
case 'exhausted': status = red('exhausted'); break;
|
|
344
|
+
case 'error': status = red('error'); break;
|
|
345
|
+
default: status = a.status || 'ready';
|
|
346
|
+
}
|
|
347
|
+
status = rpad(status, 10);
|
|
348
|
+
|
|
349
|
+
// Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
|
|
350
|
+
const q = a.quota;
|
|
351
|
+
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ';
|
|
352
|
+
|
|
353
|
+
if (q.unified5h != null || q.unified7d != null) {
|
|
354
|
+
r1 = q.unified5h;
|
|
355
|
+
r2 = q.unified7d;
|
|
356
|
+
} else {
|
|
357
|
+
l1 = 'Tok';
|
|
358
|
+
l2 = 'Req';
|
|
359
|
+
r1 = (q.tokensLimit != null && q.tokensRemaining != null)
|
|
360
|
+
? 1 - q.tokensRemaining / q.tokensLimit : null;
|
|
361
|
+
r2 = (q.requestsLimit != null && q.requestsRemaining != null)
|
|
362
|
+
? 1 - q.requestsRemaining / q.requestsLimit : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw)}`;
|
|
366
|
+
if (showBoth) {
|
|
367
|
+
line += ` ${l2} ${bar(r2, bw)}`;
|
|
368
|
+
}
|
|
369
|
+
return line;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_renderFooter() {
|
|
373
|
+
switch (this.mode) {
|
|
374
|
+
case 'normal':
|
|
375
|
+
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('q')}uit`;
|
|
376
|
+
case 'select': {
|
|
377
|
+
const act = this.selAction === 'switch' ? 'switch' : 'remove';
|
|
378
|
+
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|
|
379
|
+
}
|
|
380
|
+
case 'add':
|
|
381
|
+
return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`;
|
|
382
|
+
case 'input':
|
|
383
|
+
return ` ${this.inputPrompt}: ${this.inputBuf}█`;
|
|
384
|
+
default:
|
|
385
|
+
return '';
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|