@karpeleslab/teamclaude 1.0.7 → 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.
package/src/index.js CHANGED
@@ -9,7 +9,10 @@ import { createProxyServer } from './server.js';
9
9
  import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
10
  import { sameIdentity, orgKey, matchAccounts } from './identity.js';
11
11
  import * as alias from './alias.js';
12
+ import { ensureCerts } from './mitm.js';
13
+ import { Prober } from './prober.js';
12
14
  import { TUI } from './tui.js';
15
+ import { SxManager } from './sx.js';
13
16
 
14
17
  const args = process.argv.slice(2);
15
18
  const command = args[0];
@@ -65,6 +68,10 @@ switch (command) {
65
68
  aliasCommand();
66
69
  process.exit(0);
67
70
  break;
71
+ case 'probe':
72
+ await probeCommand();
73
+ process.exit(0);
74
+ break;
68
75
  case 'help':
69
76
  case '--help':
70
77
  case '-h':
@@ -117,6 +124,10 @@ async function serverCommand() {
117
124
  });
118
125
  if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
119
126
 
127
+ // With quota restored, pick the best account up front (highest priority /
128
+ // soonest-resetting weekly window) instead of defaulting to the first one.
129
+ accountManager.selectActiveAccount();
130
+
120
131
  // Periodically persist quota (and once more on shutdown) to the state file.
121
132
  const persistQuotaState = () =>
122
133
  saveState({ quota: accountManager.exportQuotaState() })
@@ -154,14 +165,54 @@ async function serverCommand() {
154
165
  }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
155
166
  });
156
167
  const port = config.proxy.port;
157
- const useTUI = process.stdout.isTTY && process.stdin.isTTY;
168
+ const headless = args.includes('--headless') || args.includes('--no-tui');
169
+ const useTUI = !headless && process.stdout.isTTY && process.stdin.isTTY;
170
+
171
+ // Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
172
+ let prober = null;
173
+
174
+ // sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
175
+ // config.sx.apiKey; when set we provision a proxy and route upstream through it.
176
+ const sx = new SxManager({ log: console.error });
177
+ if (config.sx?.apiKey) {
178
+ const r = await sx.configure(config.sx.apiKey, config.sx.mode);
179
+ if (!r.ok) console.error(`[TeamClaude] sx.org disabled: ${r.error}`);
180
+ } else if (config.sx?.mode) {
181
+ await sx.setMode(config.sx.mode);
182
+ }
183
+
184
+ // Re-sync accounts from disk without a restart. The TUI's 'R' key, the
185
+ // POST /teamclaude/reload endpoint, and the CLI notify after add/change all
186
+ // funnel through here. Returns the number of newly added accounts. Also picks
187
+ // up a changed probe interval so `teamclaude probe` applies live.
188
+ const reloadAccounts = async () => {
189
+ const diskConfig = await loadConfig();
190
+ if (!diskConfig) return 0;
191
+ const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
192
+ // Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
193
+ const diskSxKey = diskConfig.sx?.apiKey || null;
194
+ const diskSxMode = diskConfig.sx?.mode || 'always';
195
+ if (diskSxKey !== sx.apiKey || diskSxMode !== sx.mode) {
196
+ config.sx = diskConfig.sx;
197
+ if (diskSxKey) await sx.configure(diskSxKey, diskSxMode);
198
+ else { sx.disable(); await sx.setMode(diskSxMode); }
199
+ }
200
+ if (prober) {
201
+ const ms = (diskConfig.quotaProbeSeconds || 0) * 1000;
202
+ if (ms !== prober.intervalMs) {
203
+ config.quotaProbeSeconds = diskConfig.quotaProbeSeconds || 0;
204
+ prober.reschedule(ms);
205
+ }
206
+ }
207
+ return added;
208
+ };
158
209
 
159
210
  let tui = null;
160
211
  let hooks = {};
161
212
 
162
213
  if (useTUI) {
163
214
  tui = new TUI({
164
- accountManager, config,
215
+ accountManager, config, sx,
165
216
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
166
217
  // Write in-memory accounts as the authoritative state, preserving
167
218
  // extra disk-only fields (e.g. importFrom) where the account still exists.
@@ -177,13 +228,15 @@ async function serverCommand() {
177
228
  const diskAcct = diskConfig.accounts.find(d => sameIdentity(d, a));
178
229
  return diskAcct ? { ...diskAcct, ...live } : live;
179
230
  });
231
+ // Persist sx.org settings (set/cleared from the TUI settings screen).
232
+ if (config.sx) diskConfig.sx = config.sx; else delete diskConfig.sx;
233
+ // Persist other runtime-tunable settings edited from the TUI.
234
+ if (config.switchThreshold != null) diskConfig.switchThreshold = config.switchThreshold;
235
+ if (config.quotaProbeSeconds != null) diskConfig.quotaProbeSeconds = config.quotaProbeSeconds;
180
236
  }),
181
- syncAccounts: async () => {
182
- const diskConfig = await loadConfig();
183
- if (!diskConfig) return 0;
184
- return syncAccountsFromDisk(diskConfig, config, accountManager);
185
- },
237
+ syncAccounts: reloadAccounts,
186
238
  onQuit: async () => {
239
+ prober?.stop();
187
240
  if (quotaSaveInterval) clearInterval(quotaSaveInterval);
188
241
  await persistQuotaState();
189
242
  server.close(() => process.exit(0));
@@ -196,7 +249,10 @@ async function serverCommand() {
196
249
  };
197
250
  }
198
251
 
199
- const server = createProxyServer(accountManager, config, hooks);
252
+ // Expose reload to the proxy's control endpoint (works with or without TUI).
253
+ hooks.reload = reloadAccounts;
254
+
255
+ const server = createProxyServer(accountManager, config, hooks, sx);
200
256
  // Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
201
257
  // remove this handler so a later runtime 'error' isn't misreported as a
202
258
  // listen failure and exit the whole proxy.
@@ -237,9 +293,14 @@ async function serverCommand() {
237
293
  quotaSaveInterval = setInterval(persistQuotaState, 60_000);
238
294
  quotaSaveInterval.unref?.();
239
295
 
296
+ // Start the opt-in quota probe (no-op when quotaProbeSeconds is 0).
297
+ prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
298
+ prober.start();
299
+
240
300
  if (!tui) {
241
301
  const shutdown = async () => {
242
302
  console.log('\n[TeamClaude] Shutting down...');
303
+ prober?.stop();
243
304
  clearInterval(quotaSaveInterval);
244
305
  await persistQuotaState();
245
306
  server.close(() => process.exit(0));
@@ -383,9 +444,13 @@ async function envCommand() {
383
444
  async function runCommand() {
384
445
  const config = await loadOrCreateConfig();
385
446
 
386
- // Everything after 'run' (skip -- separator if present)
387
- const claudeArgs = args.slice(1);
388
- if (claudeArgs[0] === '--') claudeArgs.shift();
447
+ // Args after 'run'. teamclaude flags (e.g. --mitm) are recognized only before
448
+ // an optional `--` separator; everything after `--` goes verbatim to claude.
449
+ const rest = args.slice(1);
450
+ const sep = rest.indexOf('--');
451
+ const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
452
+ const useMitm = tcFlags.includes('--mitm');
453
+ const claudeArgs = sep >= 0 ? rest.slice(sep + 1) : rest.filter(a => a !== '--mitm');
389
454
 
390
455
  // Route through the proxy only when it's actually up; otherwise launch claude
391
456
  // directly so a stopped proxy doesn't break `claude`. This is what lets the
@@ -393,10 +458,23 @@ async function runCommand() {
393
458
  const port = config.proxy.port;
394
459
  const env = { ...process.env };
395
460
  if (await isProxyUp(port)) {
396
- // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
397
- // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
398
- // lets Claude Code stay in subscription mode (full model access).
399
- env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
461
+ if (useMitm) {
462
+ // Route ALL of claude's traffic through us as an HTTPS forward proxy, so
463
+ // even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
464
+ // real token injected. claude trusts our MITM leaf via NODE_EXTRA_CA_CERTS.
465
+ const host = upstreamHost(config);
466
+ const { caPath } = await ensureCerts(host);
467
+ const proxyUrl = `http://127.0.0.1:${port}`;
468
+ env.HTTPS_PROXY = env.HTTP_PROXY = env.https_proxy = env.http_proxy = proxyUrl;
469
+ env.NO_PROXY = env.no_proxy = ''; // ensure nothing (esp. the upstream host) bypasses us
470
+ env.NODE_EXTRA_CA_CERTS = caPath;
471
+ delete env.ANTHROPIC_BASE_URL;
472
+ } else {
473
+ // Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
474
+ // which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
475
+ // lets Claude Code stay in subscription mode (full model access).
476
+ env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
477
+ }
400
478
  } else {
401
479
  console.error(`[TeamClaude] Proxy not running on port ${port} — launching claude directly (start it with: teamclaude server)`);
402
480
  }
@@ -440,10 +518,12 @@ async function statusCommand() {
440
518
  console.log(` ${acct.name} (${acct.type})${current}`);
441
519
  console.log(` Status: ${acct.status}${acct.disabled ? ' (disabled)' : ''}`);
442
520
 
443
- if (q.unified5h != null || q.unified7d != null) {
521
+ if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
444
522
  const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
445
523
  const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
446
- console.log(` Session: ${ses} used Weekly: ${wk} used`);
524
+ let line = ` Session: ${ses} used Weekly: ${wk} used`;
525
+ if (q.unified7dSonnet != null) line += ` Sonnet7d: ${(q.unified7dSonnet * 100).toFixed(1)}% used`;
526
+ console.log(line);
447
527
  } else {
448
528
  const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
449
529
  const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
@@ -645,6 +725,42 @@ function aliasCommand() {
645
725
  }
646
726
  }
647
727
 
728
+ // ── probe ───────────────────────────────────────────────────
729
+
730
+ async function probeCommand() {
731
+ const config = await loadOrCreateConfig();
732
+ const arg = args[1];
733
+
734
+ if (arg === undefined) {
735
+ const cur = config.quotaProbeSeconds || 0;
736
+ console.log(cur > 0 ? `Quota probe: every ${cur}s` : 'Quota probe: off (passive only)');
737
+ console.log('Set with: teamclaude probe <off|seconds> e.g. teamclaude probe 300');
738
+ return;
739
+ }
740
+
741
+ let seconds;
742
+ if (arg === 'off' || arg === '0') {
743
+ seconds = 0;
744
+ } else {
745
+ seconds = parseInt(arg, 10);
746
+ if (Number.isNaN(seconds) || seconds < 0) {
747
+ console.error('Usage: teamclaude probe <off|seconds>');
748
+ process.exit(1);
749
+ }
750
+ if (seconds > 0 && seconds < 30) {
751
+ console.error('Minimum probe interval is 30s (to avoid hammering the usage endpoint).');
752
+ process.exit(1);
753
+ }
754
+ }
755
+
756
+ config.quotaProbeSeconds = seconds;
757
+ await saveConfig(config);
758
+ console.log(seconds > 0
759
+ ? `Quota probe set to every ${seconds}s (reads /api/oauth/usage; does not spend quota).`
760
+ : 'Quota probe disabled (passive only).');
761
+ await notifyRunningServer(config);
762
+ }
763
+
648
764
  // ── remove ──────────────────────────────────────────────────
649
765
 
650
766
  /**
@@ -724,6 +840,7 @@ async function priorityCommand() {
724
840
  account.priority = priority;
725
841
  await saveConfig(config);
726
842
  console.log(`Set priority of "${account.name}" to ${priority} (lower = preferred)`);
843
+ await notifyRunningServer(config);
727
844
  }
728
845
 
729
846
  // ── enable / disable ────────────────────────────────────────
@@ -751,9 +868,7 @@ async function setDisabledCommand(disabled) {
751
868
  }
752
869
  await saveConfig(config);
753
870
  console.log(`${disabled ? 'Disabled' : 'Enabled'} account "${account.name}"`);
754
- if (!disabled) {
755
- console.log('(restart or reload the running server to retry it if it was in an error state)');
756
- }
871
+ await notifyRunningServer(config);
757
872
  }
758
873
 
759
874
  // ── help ────────────────────────────────────────────────────
@@ -764,12 +879,15 @@ function showHelp() {
764
879
  Usage: teamclaude [command] [options]
765
880
 
766
881
  Commands:
767
- server Start the proxy server (default)
882
+ server Start the proxy server (default; --headless to skip the TUI)
768
883
  import Import credentials from Claude Code
769
884
  login OAuth login via browser
770
885
  login --api Add an API key account
771
886
  env Print env vars to use with Claude
772
- run [-- args...] Run Claude Code through the proxy (direct if it's down)
887
+ run [--mitm] [-- args...]
888
+ Run Claude Code through the proxy (direct if it's down);
889
+ --mitm routes via an HTTPS forward proxy + local CA so even
890
+ hardcoded api.anthropic.com endpoints are intercepted
773
891
  alias Print a shell alias so plain 'claude' routes via the proxy
774
892
  (--install to write it to your shell rc; --uninstall to remove)
775
893
  status Show proxy & account status (live)
@@ -778,6 +896,8 @@ Commands:
778
896
  disable <name> Temporarily exclude an account from rotation
779
897
  enable <name> Re-enable a disabled account (also clears a stuck error)
780
898
  priority <name> <n> Set rotation priority (lower = preferred; --first/--last)
899
+ probe [off|secs] Opt-in background quota refresh for idle accounts
900
+ (off by default; reads usage endpoint, spends no quota)
781
901
  api <path> Call an API endpoint with account credentials
782
902
  help Show this help
783
903
 
@@ -788,6 +908,14 @@ Options:
788
908
  --json JSON Import from inline JSON (import), e.g.:
789
909
  --json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
790
910
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
911
+ --headless Run the server without the interactive TUI (for backgrounding)
912
+ --mitm (run) route claude via the HTTPS forward proxy + local CA
913
+
914
+ The server always accepts both base-URL and proxy/CONNECT clients, so instances
915
+ launched with and without --mitm can share one server.
916
+
917
+ A running server re-syncs accounts from config on POST /teamclaude/reload
918
+ (local only). add/login/enable/disable/priority trigger it automatically.
791
919
 
792
920
  Config: ${getConfigPath()}
793
921
  `);
@@ -862,6 +990,7 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
862
990
 
863
991
  await saveConfig(config);
864
992
  console.log(`Saved to ${getConfigPath()}`);
993
+ await notifyRunningServer(config);
865
994
  }
866
995
 
867
996
  // ── config sync helpers ─────────────────────────────────────
@@ -988,6 +1117,32 @@ function argValue(flag) {
988
1117
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
989
1118
  }
990
1119
 
1120
+ // Hostname of the configured upstream (the host MITM-intercepts under `run --mitm`).
1121
+ function upstreamHost(config) {
1122
+ try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
1123
+ catch { return 'api.anthropic.com'; }
1124
+ }
1125
+
1126
+ // Best-effort: tell a running server (if any) to re-sync accounts from config so
1127
+ // CLI changes take effect without a restart. A closed local port refuses the
1128
+ // connection immediately, so this is a no-op (and near-instant) when nothing is
1129
+ // running. Reload picks up new accounts, credential, priority, and enable/disable
1130
+ // changes; account removals still need a restart.
1131
+ async function notifyRunningServer(config) {
1132
+ const port = config?.proxy?.port;
1133
+ if (!port) return;
1134
+ try {
1135
+ const res = await fetch(`http://localhost:${port}/teamclaude/reload`, {
1136
+ method: 'POST',
1137
+ headers: { 'x-api-key': config.proxy?.apiKey || '' },
1138
+ });
1139
+ if (res.ok) {
1140
+ const data = await res.json().catch(() => ({}));
1141
+ console.log(`Reloaded running server${data.added ? ` (+${data.added} new account)` : ''}.`);
1142
+ }
1143
+ } catch { /* no server running — nothing to notify */ }
1144
+ }
1145
+
991
1146
  // Quick liveness probe: is something listening on the local proxy port?
992
1147
  // A successful TCP connect is enough (the proxy is local). Times out fast so a
993
1148
  // down proxy doesn't add noticeable latency to `claude` launches via the alias.
@@ -0,0 +1,65 @@
1
+ // Streaming JSON pretty-printer (no regex, no buffering of the whole body).
2
+ //
3
+ // Built on the same idea as the account_uuid patcher: walk the bytes once,
4
+ // tracking only enough state (nesting depth, in-string, escape) to know where
5
+ // we are, and re-emit with indentation as we go. This lets the request logger
6
+ // flush a readable body to disk *as it streams* — so when a request blocks
7
+ // mid-flight, the partial (pretty) body is already on disk and you can see how
8
+ // far it got. Bodies can be ~1M tokens, so we never hold more than the current
9
+ // chunk.
10
+ //
11
+ // Whitespace outside strings is dropped and re-inserted; strings (including any
12
+ // whitespace/escapes inside them) are copied verbatim. Operates on latin1 so a
13
+ // multi-byte UTF-8 sequence split across chunks is preserved byte-for-byte.
14
+ export class JsonStreamFormatter {
15
+ constructor(indent = 2) {
16
+ this.pad = ' '.repeat(indent);
17
+ this.depth = 0;
18
+ this.inStr = false;
19
+ this.esc = false;
20
+ this.freshContainer = false; // just opened { or [ — first element needs a newline+indent
21
+ this.started = false;
22
+ }
23
+
24
+ nl(depth) { return '\n' + this.pad.repeat(depth); }
25
+
26
+ // Feed a chunk; returns the formatted text for that chunk.
27
+ push(buf) {
28
+ const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
29
+ let out = '';
30
+ for (let i = 0; i < text.length; i++) {
31
+ const ch = text[i];
32
+
33
+ if (this.inStr) {
34
+ out += ch;
35
+ if (this.esc) this.esc = false;
36
+ else if (ch === '\\') this.esc = true;
37
+ else if (ch === '"') this.inStr = false;
38
+ continue;
39
+ }
40
+
41
+ // Outside a string: collapse existing whitespace; we re-insert our own.
42
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
43
+
44
+ if (ch === '}' || ch === ']') {
45
+ this.depth--;
46
+ // Empty container: emit "{}" / "[]" with no inner newline.
47
+ if (this.freshContainer) { this.freshContainer = false; out += ch; }
48
+ else out += this.nl(this.depth) + ch;
49
+ continue;
50
+ }
51
+
52
+ // Any other token. If it's the first token inside a just-opened
53
+ // container, break the line and indent first.
54
+ if (this.freshContainer) { out += this.nl(this.depth); this.freshContainer = false; }
55
+
56
+ if (ch === '{' || ch === '[') { out += ch; this.depth++; this.freshContainer = true; continue; }
57
+ if (ch === ',') { out += ',' + this.nl(this.depth); continue; }
58
+ if (ch === ':') { out += ': '; continue; }
59
+ if (ch === '"') { this.inStr = true; out += ch; continue; }
60
+ out += ch; // number / true / false / null character
61
+ }
62
+ this.started = this.started || out.length > 0;
63
+ return out;
64
+ }
65
+ }