oc-auth-switcher 0.6.0 → 0.7.1

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/dist/cli.js +397 -46
  2. package/dist/index.js +51 -16
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // src/accounts.ts
5
5
  import fs from "fs";
6
6
  import path2 from "path";
7
+ import { randomUUID } from "crypto";
7
8
 
8
9
  // src/constants.ts
9
10
  import path from "path";
@@ -14,6 +15,12 @@ var STATE_FILE = path.join(configDir, "auth-switcher-state.json");
14
15
  var DEFAULT_THRESHOLD = 0.95;
15
16
  var AUTH_FAILURE_COOLDOWN = 10 * 60 * 1000;
16
17
  var REJECTION_FALLBACK_SECONDS = 60 * 60;
18
+ var METRIC_MODEL_FAMILY = {
19
+ session5h: null,
20
+ weekly7d: null,
21
+ weekly7dSonnet: "sonnet",
22
+ weekly7dFable: "fable"
23
+ };
17
24
 
18
25
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/constants.js
19
26
  var CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -63,18 +70,52 @@ function safeReadJSON(filePath, fallback) {
63
70
  }
64
71
  return fallback;
65
72
  }
66
- function safeWriteJSON(filePath, data) {
73
+ var writeQueues = new Map;
74
+ var MAX_RENAME_ATTEMPTS = 3;
75
+ var RENAME_RETRY_CODES = new Set(["ENOENT", "EEXIST", "EPERM"]);
76
+ function errorCode(error) {
77
+ return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
78
+ }
79
+ async function writeJSON(filePath, data, options) {
67
80
  ensureDir(filePath);
68
81
  const content = JSON.stringify(data, null, 2);
69
- const tmpPath = filePath + ".tmp";
70
82
  const bakPath = filePath + ".bak";
83
+ const rename = options.rename ?? fs.promises.rename;
84
+ const retryDelayMs = options.retryDelayMs ?? 5;
71
85
  if (fs.existsSync(filePath)) {
72
86
  try {
73
- fs.copyFileSync(filePath, bakPath);
87
+ await fs.promises.copyFile(filePath, bakPath);
74
88
  } catch {}
75
89
  }
76
- fs.writeFileSync(tmpPath, content, { mode: 384 });
77
- fs.renameSync(tmpPath, filePath);
90
+ let lastError;
91
+ for (let attempt = 1;attempt <= MAX_RENAME_ATTEMPTS; attempt++) {
92
+ const tmpPath = `${filePath}.${process.pid}-${randomUUID()}.tmp`;
93
+ try {
94
+ await fs.promises.writeFile(tmpPath, content, { mode: 384 });
95
+ await rename(tmpPath, filePath);
96
+ return;
97
+ } catch (error) {
98
+ lastError = error;
99
+ await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
100
+ if (!RENAME_RETRY_CODES.has(errorCode(error) ?? "") || attempt === MAX_RENAME_ATTEMPTS) {
101
+ break;
102
+ }
103
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
104
+ }
105
+ }
106
+ throw lastError;
107
+ }
108
+ function safeWriteJSON(filePath, data, options = {}) {
109
+ const previous = writeQueues.get(filePath) ?? Promise.resolve();
110
+ const current = previous.catch(() => {}).then(() => writeJSON(filePath, data, options)).catch((error) => {
111
+ console.warn(`[oc-auth-switcher] Could not safely write ${path2.basename(filePath)}:`, error instanceof Error ? error.message : String(error));
112
+ });
113
+ writeQueues.set(filePath, current);
114
+ current.finally(() => {
115
+ if (writeQueues.get(filePath) === current)
116
+ writeQueues.delete(filePath);
117
+ });
118
+ return current;
78
119
  }
79
120
  function loadAccounts() {
80
121
  const raw = safeReadJSON(ACCOUNTS_FILE, {
@@ -84,9 +125,9 @@ function loadAccounts() {
84
125
  return { accounts };
85
126
  }
86
127
  function saveAccounts(data) {
87
- safeWriteJSON(ACCOUNTS_FILE, data);
128
+ return safeWriteJSON(ACCOUNTS_FILE, data);
88
129
  }
89
- function addAccount(account) {
130
+ async function addAccount(account) {
90
131
  const data = loadAccounts();
91
132
  const idx = data.accounts.findIndex((a) => a.name === account.name);
92
133
  if (idx >= 0) {
@@ -94,23 +135,23 @@ function addAccount(account) {
94
135
  } else {
95
136
  data.accounts.push(account);
96
137
  }
97
- saveAccounts(data);
138
+ await saveAccounts(data);
98
139
  return data;
99
140
  }
100
- function removeAccount(name) {
141
+ async function removeAccount(name) {
101
142
  const data = loadAccounts();
102
143
  data.accounts = data.accounts.filter((a) => a.name !== name);
103
- saveAccounts(data);
144
+ await saveAccounts(data);
104
145
  return data;
105
146
  }
106
- function updateAccountTokens(name, access, refresh, expires) {
147
+ async function updateAccountTokens(name, access, refresh, expires) {
107
148
  const data = loadAccounts();
108
149
  const account = data.accounts.find((a) => a.name === name);
109
150
  if (account) {
110
151
  account.access = access;
111
152
  account.refresh = refresh;
112
153
  account.expires = expires;
113
- saveAccounts(data);
154
+ await saveAccounts(data);
114
155
  }
115
156
  }
116
157
  async function refreshAccountToken(refreshTokenValue) {
@@ -201,7 +242,7 @@ function loadState() {
201
242
  return normalizeState(safeReadJSON(STATE_FILE, {}));
202
243
  }
203
244
  function saveState(state) {
204
- safeWriteJSON(STATE_FILE, state);
245
+ return safeWriteJSON(STATE_FILE, state);
205
246
  }
206
247
  function getThresholds(config) {
207
248
  if (typeof config.threshold === "number") {
@@ -257,10 +298,252 @@ function ensureAccountsInState(state, accountNames) {
257
298
  }
258
299
 
259
300
  // src/rotation.ts
301
+ function isTemporarilyUnavailable(state, accountName) {
302
+ const cooldownUntil = state.authFailures[accountName];
303
+ if (!cooldownUntil)
304
+ return false;
305
+ if (Date.now() > cooldownUntil) {
306
+ delete state.authFailures[accountName];
307
+ return false;
308
+ }
309
+ return true;
310
+ }
311
+ function isOverThreshold(usage, state, modelFamily) {
312
+ if (!usage)
313
+ return false;
314
+ const thresholds = getThresholds(state.config);
315
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
316
+ return true;
317
+ return metricEntries(usage, thresholds, modelFamily).some((metric) => metric.threshold > 0 && metric.util >= metric.threshold);
318
+ }
319
+ function getUtilizationScore(usage, state, modelFamily) {
320
+ if (!usage)
321
+ return 0;
322
+ const thresholds = getThresholds(state.config);
323
+ if (usage.rejected.status?.toLowerCase() === "rejected" && isRejectionRelevant(usage.rejected.prefix, modelFamily))
324
+ return Infinity;
325
+ const scores = metricEntries(usage, thresholds, modelFamily).filter((metric) => metric.threshold > 0).map((metric) => metric.util / metric.threshold);
326
+ return scores.length > 0 ? Math.max(...scores) : 0;
327
+ }
328
+ function getModelFamily(model) {
329
+ if (!model)
330
+ return;
331
+ const normalized = model.toLowerCase();
332
+ return ["fable", "sonnet", "opus"].find((family) => normalized.includes(family));
333
+ }
334
+ function isMetricRelevant(metric, modelFamily) {
335
+ const metricFamily = METRIC_MODEL_FAMILY[metric];
336
+ return modelFamily === undefined || metricFamily === null || metricFamily === modelFamily;
337
+ }
338
+ function rejectionFamily(prefix) {
339
+ if (!prefix)
340
+ return;
341
+ const normalized = prefix.toLowerCase();
342
+ return ["fable", "sonnet", "opus"].find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
343
+ }
344
+ function isRejectionRelevant(prefix, modelFamily) {
345
+ const rejectedFamily = rejectionFamily(prefix);
346
+ return modelFamily === undefined || rejectedFamily === undefined || rejectedFamily === modelFamily;
347
+ }
348
+ function metricEntries(usage, thresholds, modelFamily) {
349
+ return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
350
+ name: key,
351
+ util: usage[key].utilization,
352
+ threshold: thresholds[key]
353
+ }));
354
+ }
355
+ function purgeExpiredCooldowns(state) {
356
+ const now = Date.now();
357
+ for (const name of Object.keys(state.authFailures)) {
358
+ if (state.authFailures[name] <= now) {
359
+ delete state.authFailures[name];
360
+ }
361
+ }
362
+ }
363
+ function findBestAvailable(candidates, state, exclude, modelFamily) {
364
+ let best = null;
365
+ let bestScore = Infinity;
366
+ for (const acct of candidates) {
367
+ if (exclude.has(acct.name))
368
+ continue;
369
+ if (isTemporarilyUnavailable(state, acct.name))
370
+ continue;
371
+ if (!isOverThreshold(state.usage[acct.name], state, modelFamily)) {
372
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
373
+ if (!best || score < bestScore) {
374
+ bestScore = score;
375
+ best = acct;
376
+ }
377
+ }
378
+ }
379
+ if (best)
380
+ return best;
381
+ best = null;
382
+ bestScore = Infinity;
383
+ for (const acct of candidates) {
384
+ if (exclude.has(acct.name))
385
+ continue;
386
+ if (isTemporarilyUnavailable(state, acct.name))
387
+ continue;
388
+ const score = getUtilizationScore(state.usage[acct.name], state, modelFamily);
389
+ if (!best || score < bestScore) {
390
+ bestScore = score;
391
+ best = acct;
392
+ }
393
+ }
394
+ return best;
395
+ }
396
+ function selectAccount(accounts, state, model) {
397
+ if (accounts.length === 0) {
398
+ throw new Error("No accounts available");
399
+ }
400
+ purgeExpiredCooldowns(state);
401
+ const modelFamily = getModelFamily(model);
402
+ const currentIdx = accounts.findIndex((a) => a.name === state.currentAccount);
403
+ if (currentIdx < 0) {
404
+ const best = findBestAvailable(accounts, state, new Set, modelFamily);
405
+ const selected = best ?? accounts[0];
406
+ return {
407
+ account: selected,
408
+ switched: true,
409
+ reason: `Selecting account ${selected.name}`
410
+ };
411
+ }
412
+ const current = accounts[currentIdx];
413
+ const currentUsage = state.usage[current.name];
414
+ const currentOverThreshold = isOverThreshold(currentUsage, state, modelFamily);
415
+ const currentInCooldown = isTemporarilyUnavailable(state, current.name);
416
+ if (currentOverThreshold || currentInCooldown) {
417
+ const reason = currentInCooldown ? `${current.name} in auth-failure cooldown` : `${current.name} exceeded threshold`;
418
+ const best = findBestAvailable(accounts, state, new Set([current.name]), modelFamily);
419
+ if (best && best.name !== current.name) {
420
+ return {
421
+ account: best,
422
+ switched: true,
423
+ reason: `${reason} \u2014 switching to ${best.name}`
424
+ };
425
+ }
426
+ return { account: current, switched: false };
427
+ }
428
+ return { account: current, switched: false };
429
+ }
260
430
  function clearAuthFailure(state, accountName) {
261
431
  delete state.authFailures[accountName];
262
432
  }
263
433
 
434
+ // src/status.ts
435
+ var MODEL_FAMILIES = ["opus", "sonnet", "fable"];
436
+ function percentage(value) {
437
+ return `${(value * 100).toFixed(0)}%`;
438
+ }
439
+ function formatRelativeDuration(targetMs, nowMs = Date.now()) {
440
+ const difference = targetMs - nowMs;
441
+ const absoluteSeconds = Math.abs(difference) / 1000;
442
+ if (absoluteSeconds < 30)
443
+ return "now";
444
+ const units = [
445
+ ["d", 86400],
446
+ ["h", 3600],
447
+ ["m", 60]
448
+ ];
449
+ const parts = [];
450
+ let remaining = absoluteSeconds;
451
+ for (const [suffix, seconds] of units) {
452
+ const amount = Math.floor(remaining / seconds);
453
+ if (amount > 0) {
454
+ parts.push(`${amount}${suffix}`);
455
+ remaining -= amount * seconds;
456
+ }
457
+ if (parts.length === 2)
458
+ break;
459
+ }
460
+ if (parts.length === 0)
461
+ parts.push(`${Math.max(1, Math.round(remaining))}s`);
462
+ return difference >= 0 ? `in ${parts.join(" ")}` : `${parts.join(" ")} ago`;
463
+ }
464
+ function displayRejectionPrefix(prefix) {
465
+ return prefix?.replace(/^anthropic-ratelimit-unified-/i, "");
466
+ }
467
+ function rejectionFamily2(prefix) {
468
+ if (!prefix)
469
+ return;
470
+ const normalized = prefix.toLowerCase();
471
+ return MODEL_FAMILIES.find((family) => normalized === `7d_${family}` || normalized === `anthropic-ratelimit-unified-7d_${family}`);
472
+ }
473
+ function tokenExpiryDescription(account, nowMs) {
474
+ if (account.expires > nowMs) {
475
+ return `expires ${formatRelativeDuration(account.expires, nowMs)}`;
476
+ }
477
+ if (account.refresh) {
478
+ return account.expires > 0 ? `expired ${formatRelativeDuration(account.expires, nowMs)}; refresh available` : "expired; refresh available";
479
+ }
480
+ return account.expires > 0 ? `expired/unrefreshable (${formatRelativeDuration(account.expires, nowMs)})` : "expired/unrefreshable";
481
+ }
482
+ function deriveAccountHealth(account, usage, thresholds, cooldownUntil, nowMs = Date.now()) {
483
+ const availability = Object.fromEntries(MODEL_FAMILIES.map((family) => [family, true]));
484
+ const reasons = [];
485
+ const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).map((metric) => ({
486
+ metric,
487
+ utilization: usage[metric].utilization,
488
+ threshold: thresholds[metric],
489
+ family: METRIC_MODEL_FAMILY[metric]
490
+ })) : [];
491
+ for (const metric of metrics) {
492
+ if (metric.utilization < metric.threshold)
493
+ continue;
494
+ const families = metric.family ? [metric.family] : [...MODEL_FAMILIES];
495
+ for (const family of families)
496
+ availability[family] = false;
497
+ reasons.push({
498
+ kind: "metric",
499
+ message: `${metric.metric} ${percentage(metric.utilization)} >= ${percentage(metric.threshold)}${metric.family ? ` \u2014 ${metric.family} only` : ""}`,
500
+ families,
501
+ reset: usage?.[metric.metric].reset
502
+ });
503
+ }
504
+ if (usage?.rejected.status?.toLowerCase() === "rejected") {
505
+ const family = rejectionFamily2(usage.rejected.prefix);
506
+ const families = family ? [family] : [...MODEL_FAMILIES];
507
+ for (const blockedFamily of families)
508
+ availability[blockedFamily] = false;
509
+ const prefix = displayRejectionPrefix(usage.rejected.prefix);
510
+ reasons.push({
511
+ kind: "rejection",
512
+ message: `rejected${prefix ? ` (${prefix})` : ""}${family ? ` \u2014 ${family} only` : ""}`,
513
+ families,
514
+ reset: usage.rejected.reset
515
+ });
516
+ }
517
+ if (cooldownUntil && cooldownUntil > nowMs) {
518
+ for (const family of MODEL_FAMILIES)
519
+ availability[family] = false;
520
+ reasons.push({
521
+ kind: "cooldown",
522
+ message: `auth-failure cooldown \u2014 ${formatRelativeDuration(cooldownUntil, nowMs)}`,
523
+ families: [...MODEL_FAMILIES]
524
+ });
525
+ }
526
+ const hasUsableToken = !!account.access && account.expires > nowMs || !!account.refresh;
527
+ if (!hasUsableToken) {
528
+ for (const family of MODEL_FAMILIES)
529
+ availability[family] = false;
530
+ reasons.push({
531
+ kind: "token",
532
+ message: "expired/unrefreshable token",
533
+ families: [...MODEL_FAMILIES]
534
+ });
535
+ }
536
+ const highestUtilization = metrics.sort((a, b) => b.utilization / b.threshold - a.utilization / a.threshold)[0] ?? null;
537
+ const futureResets = reasons.map((reason) => reason.reset).filter((reset) => !!reset && reset * 1000 > nowMs);
538
+ return {
539
+ availability,
540
+ highestUtilization,
541
+ reasons,
542
+ earliestReset: futureResets.length > 0 ? Math.min(...futureResets) : undefined,
543
+ tokenExpiry: tokenExpiryDescription(account, nowMs)
544
+ };
545
+ }
546
+
264
547
  // node_modules/@ex-machina/opencode-anthropic-auth/dist/pkce.js
265
548
  function base64UrlEncode(bytes) {
266
549
  let bin = "";
@@ -372,14 +655,15 @@ async function exchange(input, verifier, redirectUri, expectedState) {
372
655
 
373
656
  // src/cli.ts
374
657
  import { spawn } from "child_process";
375
- var RESET = "\x1B[0m";
376
- var BOLD = "\x1B[1m";
377
- var DIM = "\x1B[2m";
378
- var RED = "\x1B[31m";
379
- var GREEN = "\x1B[32m";
380
- var YELLOW = "\x1B[33m";
381
- var BLUE = "\x1B[34m";
382
- var CYAN = "\x1B[36m";
658
+ var COLOR = !!process.stdout.isTTY;
659
+ var RESET = COLOR ? "\x1B[0m" : "";
660
+ var BOLD = COLOR ? "\x1B[1m" : "";
661
+ var DIM = COLOR ? "\x1B[2m" : "";
662
+ var RED = COLOR ? "\x1B[31m" : "";
663
+ var GREEN = COLOR ? "\x1B[32m" : "";
664
+ var YELLOW = COLOR ? "\x1B[33m" : "";
665
+ var BLUE = COLOR ? "\x1B[34m" : "";
666
+ var CYAN = COLOR ? "\x1B[36m" : "";
383
667
  function progressBar(value, threshold, width = 30) {
384
668
  const pct = Math.min(value, 1);
385
669
  const filled = Math.round(pct * width);
@@ -471,7 +755,7 @@ ${BOLD}Starting OAuth flow for account: ${CYAN}${name}${RESET}
471
755
  ${RED}Authentication failed${RESET}`);
472
756
  process.exit(1);
473
757
  }
474
- addAccount({
758
+ await addAccount({
475
759
  name,
476
760
  access: exchangeResult.access,
477
761
  refresh: exchangeResult.refresh,
@@ -484,7 +768,7 @@ ${GREEN}Account "${name}" added successfully.${RESET}`);
484
768
  if (data.accounts.length === 1) {
485
769
  const state = loadState();
486
770
  state.currentAccount = name;
487
- saveState(state);
771
+ await saveState(state);
488
772
  console.log(`
489
773
  ${YELLOW}This is the only account \u2014 set as active.${RESET}`);
490
774
  }
@@ -533,10 +817,10 @@ ${RED}Re-authentication failed${RESET}`);
533
817
  account.access = exchangeResult.access;
534
818
  account.refresh = exchangeResult.refresh;
535
819
  account.expires = exchangeResult.expires;
536
- saveAccounts(data);
820
+ await saveAccounts(data);
537
821
  const state = loadState();
538
822
  clearAuthFailure(state, name);
539
- saveState(state);
823
+ await saveState(state);
540
824
  console.log(`
541
825
  ${GREEN}Account "${name}" re-authenticated successfully.${RESET}`);
542
826
  }
@@ -549,7 +833,7 @@ async function refreshExpiredAccounts(data, state) {
549
833
  account.access = result.access;
550
834
  account.refresh = result.refresh;
551
835
  account.expires = result.expires;
552
- updateAccountTokens(account.name, result.access, result.refresh, result.expires);
836
+ await updateAccountTokens(account.name, result.access, result.refresh, result.expires);
553
837
  clearAuthFailure(state, account.name);
554
838
  }
555
839
  }
@@ -560,7 +844,7 @@ async function cmdUsage(args) {
560
844
  const data = loadAccounts();
561
845
  const state = loadState();
562
846
  await refreshExpiredAccounts(data, state);
563
- saveState(state);
847
+ await saveState(state);
564
848
  resolveStaleMetrics(state);
565
849
  ensureAccountsInState(state, data.accounts.map((a) => a.name));
566
850
  const thresholds = getThresholds(state.config);
@@ -622,7 +906,7 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
622
906
  new Promise(() => {});
623
907
  }
624
908
  }
625
- function cmdConfig(args) {
909
+ async function cmdConfig(args) {
626
910
  const state = loadState();
627
911
  if (args.length === 0) {
628
912
  const thresholds = getThresholds(state.config);
@@ -672,9 +956,9 @@ ${BOLD}${CYAN}=== Auth Switcher Configuration ===${RESET}
672
956
  console.log(`${GREEN}Reset to default threshold: ${(DEFAULT_THRESHOLD * 100).toFixed(0)}%${RESET}`);
673
957
  }
674
958
  }
675
- saveState(state);
959
+ await saveState(state);
676
960
  }
677
- function cmdSwitch(args) {
961
+ async function cmdSwitch(args) {
678
962
  const data = loadAccounts();
679
963
  if (data.accounts.length === 0) {
680
964
  console.error(`${RED}No accounts configured.${RESET}`);
@@ -701,26 +985,93 @@ ${RED}Usage: oc-auth-switcher switch <account-name>${RESET}`);
701
985
  console.log(`${CYAN}Switching to account: ${name}...${RESET}`);
702
986
  const state = loadState();
703
987
  state.currentAccount = name;
704
- saveState(state);
988
+ await saveState(state);
705
989
  console.log(`${GREEN}Switched to "${name}". Will take effect on the next API request.${RESET}`);
706
990
  }
991
+ function healthLabel(health) {
992
+ const usable = MODEL_FAMILIES.filter((family) => health.availability[family]);
993
+ const unavailable = MODEL_FAMILIES.filter((family) => !health.availability[family]);
994
+ if (unavailable.length === 0)
995
+ return `${GREEN}healthy \u2014 all models usable${RESET}`;
996
+ if (usable.length === 0)
997
+ return `${RED}unavailable \u2014 all models${RESET}`;
998
+ return `${YELLOW}partial \u2014 usable: ${usable.join(", ")}; unavailable: ${unavailable.join(", ")}${RESET}`;
999
+ }
1000
+ function utilizationLabel(health) {
1001
+ const peak = health.highestUtilization;
1002
+ if (!peak)
1003
+ return `${DIM}no usage data${RESET}`;
1004
+ const utilization = `${(peak.utilization * 100).toFixed(0)}%`;
1005
+ const threshold = `${(peak.threshold * 100).toFixed(0)}%`;
1006
+ const family = peak.family ? ` \u2014 ${peak.family} only` : "";
1007
+ if (peak.utilization >= peak.threshold) {
1008
+ return `${RED}${peak.metric} ${utilization} >= ${threshold}${family}${RESET}`;
1009
+ }
1010
+ const color = peak.utilization >= peak.threshold * 0.8 ? YELLOW : GREEN;
1011
+ return `${color}${peak.metric} ${utilization} / ${threshold} threshold${family}${RESET}`;
1012
+ }
1013
+ function nextAccountSummary(accounts, state, activeName) {
1014
+ if (accounts.length === 0 || activeName && accounts.length === 1) {
1015
+ return "(none \u2014 no alternate account)";
1016
+ }
1017
+ const selections = MODEL_FAMILIES.map((family) => {
1018
+ const candidateState = structuredClone(state);
1019
+ candidateState.currentAccount = activeName;
1020
+ if (activeName)
1021
+ candidateState.authFailures[activeName] = Date.now() + 60000;
1022
+ const selected = selectAccount(accounts, candidateState, `claude-${family}`);
1023
+ return [
1024
+ family,
1025
+ selected.account.name === activeName ? null : selected.account.name
1026
+ ];
1027
+ });
1028
+ const names = new Set(selections.map(([_, name]) => name));
1029
+ if (names.size === 1) {
1030
+ return selections[0][1] ? `${selections[0][1]} (all models)` : "(none \u2014 no usable alternate account)";
1031
+ }
1032
+ return selections.map(([family, name]) => `${family}: ${name ?? "none"}`).join(", ");
1033
+ }
707
1034
  function cmdStatus() {
708
1035
  const data = loadAccounts();
709
1036
  const state = loadState();
710
1037
  resolveStaleMetrics(state);
1038
+ const thresholds = getThresholds(state.config);
1039
+ const now = Date.now();
1040
+ const configuredActive = data.accounts.find((account) => account.name === state.currentAccount);
1041
+ const healthByAccount = new Map(data.accounts.map((account) => [
1042
+ account.name,
1043
+ deriveAccountHealth(account, state.usage[account.name], thresholds, state.authFailures[account.name], now)
1044
+ ]));
711
1045
  console.log(`
712
1046
  ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
713
1047
  `);
714
- console.log(` Active account: ${BOLD}${state.currentAccount || "(none)"}${RESET}`);
715
- console.log(` Total accounts: ${data.accounts.length}`);
716
- console.log(` Request count: ${state.requestCount}`);
1048
+ if (configuredActive) {
1049
+ console.log(` Active account: ${BOLD}${configuredActive.name}${RESET} \u2014 ${healthLabel(healthByAccount.get(configuredActive.name))}`);
1050
+ } else {
1051
+ console.log(` Active account: ${BOLD}(none)${RESET}`);
1052
+ }
1053
+ console.log(` Next if unavailable: ${BOLD}${nextAccountSummary(data.accounts, state, configuredActive?.name ?? null)}${RESET}`);
1054
+ console.log(` Accounts: ${data.accounts.length} Requests: ${state.requestCount}`);
717
1055
  console.log();
718
- const failures = Object.entries(state.authFailures).filter(([_, until]) => until > Date.now());
719
- if (failures.length > 0) {
720
- console.log(` ${RED}Auth cooldowns:${RESET}`);
721
- for (const [name, until] of failures) {
722
- const remaining = Math.ceil((until - Date.now()) / 60000);
723
- console.log(` - ${name}: ${remaining} min remaining`);
1056
+ if (data.accounts.length === 0) {
1057
+ console.log(` ${DIM}No accounts configured. Run 'oc-auth-switcher add' to add one.${RESET}`);
1058
+ console.log();
1059
+ } else {
1060
+ console.log(` ${BOLD}Account health${RESET}`);
1061
+ for (const account of data.accounts) {
1062
+ const health = healthByAccount.get(account.name);
1063
+ const active = account.name === configuredActive?.name ? ` ${GREEN}[ACTIVE]${RESET}` : "";
1064
+ console.log(` ${BOLD}${account.name}${RESET}${active} \u2014 ${healthLabel(health)}`);
1065
+ console.log(` Peak: ${utilizationLabel(health)}`);
1066
+ for (const reason of health.reasons) {
1067
+ console.log(` ${RED}Reason: ${reason.message}${RESET}`);
1068
+ }
1069
+ if (health.earliestReset) {
1070
+ const resetMs = health.earliestReset * 1000;
1071
+ console.log(` Reset: ${new Date(resetMs).toLocaleString()} (${formatRelativeDuration(resetMs, now)})`);
1072
+ }
1073
+ const tokenColor = health.reasons.some((reason) => reason.kind === "token") ? RED : DIM;
1074
+ console.log(` ${tokenColor}Token: ${health.tokenExpiry}${RESET}`);
724
1075
  }
725
1076
  console.log();
726
1077
  }
@@ -728,7 +1079,7 @@ ${BOLD}${CYAN}=== Auth Switcher Status ===${RESET}
728
1079
  console.log(` ${DIM}State file: ${STATE_FILE}${RESET}`);
729
1080
  console.log();
730
1081
  }
731
- function cmdRemove(args) {
1082
+ async function cmdRemove(args) {
732
1083
  const name = args[0];
733
1084
  if (!name) {
734
1085
  console.error(`${RED}Usage: oc-auth-switcher remove <account-name>${RESET}`);
@@ -740,12 +1091,12 @@ function cmdRemove(args) {
740
1091
  console.error(`${RED}Account "${name}" not found${RESET}`);
741
1092
  process.exit(1);
742
1093
  }
743
- removeAccount(name);
1094
+ await removeAccount(name);
744
1095
  console.log(`${GREEN}Account "${name}" removed.${RESET}`);
745
1096
  const state = loadState();
746
1097
  if (state.currentAccount === name) {
747
1098
  state.currentAccount = null;
748
- saveState(state);
1099
+ await saveState(state);
749
1100
  console.log(`${YELLOW}This was the active account. Rotation will pick a new one automatically.${RESET}`);
750
1101
  }
751
1102
  }
@@ -796,18 +1147,18 @@ async function main() {
796
1147
  break;
797
1148
  case "config":
798
1149
  case "c":
799
- cmdConfig(commandArgs);
1150
+ await cmdConfig(commandArgs);
800
1151
  break;
801
1152
  case "switch":
802
1153
  case "s":
803
- cmdSwitch(commandArgs);
1154
+ await cmdSwitch(commandArgs);
804
1155
  break;
805
1156
  case "status":
806
1157
  cmdStatus();
807
1158
  break;
808
1159
  case "remove":
809
1160
  case "rm":
810
- cmdRemove(commandArgs);
1161
+ await cmdRemove(commandArgs);
811
1162
  break;
812
1163
  case "help":
813
1164
  case "--help":
package/dist/index.js CHANGED
@@ -406,6 +406,7 @@ async function exchange(input, verifier, redirectUri, expectedState) {
406
406
  // src/accounts.ts
407
407
  import fs from "node:fs";
408
408
  import path2 from "node:path";
409
+ import { randomUUID } from "node:crypto";
409
410
 
410
411
  // src/constants.ts
411
412
  import path from "node:path";
@@ -454,18 +455,52 @@ function safeReadJSON(filePath, fallback) {
454
455
  }
455
456
  return fallback;
456
457
  }
457
- function safeWriteJSON(filePath, data) {
458
+ var writeQueues = new Map;
459
+ var MAX_RENAME_ATTEMPTS = 3;
460
+ var RENAME_RETRY_CODES = new Set(["ENOENT", "EEXIST", "EPERM"]);
461
+ function errorCode(error) {
462
+ return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
463
+ }
464
+ async function writeJSON(filePath, data, options) {
458
465
  ensureDir(filePath);
459
466
  const content = JSON.stringify(data, null, 2);
460
- const tmpPath = filePath + ".tmp";
461
467
  const bakPath = filePath + ".bak";
468
+ const rename = options.rename ?? fs.promises.rename;
469
+ const retryDelayMs = options.retryDelayMs ?? 5;
462
470
  if (fs.existsSync(filePath)) {
463
471
  try {
464
- fs.copyFileSync(filePath, bakPath);
472
+ await fs.promises.copyFile(filePath, bakPath);
465
473
  } catch {}
466
474
  }
467
- fs.writeFileSync(tmpPath, content, { mode: 384 });
468
- fs.renameSync(tmpPath, filePath);
475
+ let lastError;
476
+ for (let attempt = 1;attempt <= MAX_RENAME_ATTEMPTS; attempt++) {
477
+ const tmpPath = `${filePath}.${process.pid}-${randomUUID()}.tmp`;
478
+ try {
479
+ await fs.promises.writeFile(tmpPath, content, { mode: 384 });
480
+ await rename(tmpPath, filePath);
481
+ return;
482
+ } catch (error) {
483
+ lastError = error;
484
+ await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
485
+ if (!RENAME_RETRY_CODES.has(errorCode(error) ?? "") || attempt === MAX_RENAME_ATTEMPTS) {
486
+ break;
487
+ }
488
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
489
+ }
490
+ }
491
+ throw lastError;
492
+ }
493
+ function safeWriteJSON(filePath, data, options = {}) {
494
+ const previous = writeQueues.get(filePath) ?? Promise.resolve();
495
+ const current = previous.catch(() => {}).then(() => writeJSON(filePath, data, options)).catch((error) => {
496
+ console.warn(`[oc-auth-switcher] Could not safely write ${path2.basename(filePath)}:`, error instanceof Error ? error.message : String(error));
497
+ });
498
+ writeQueues.set(filePath, current);
499
+ current.finally(() => {
500
+ if (writeQueues.get(filePath) === current)
501
+ writeQueues.delete(filePath);
502
+ });
503
+ return current;
469
504
  }
470
505
  function loadAccounts() {
471
506
  const raw = safeReadJSON(ACCOUNTS_FILE, {
@@ -475,16 +510,16 @@ function loadAccounts() {
475
510
  return { accounts };
476
511
  }
477
512
  function saveAccounts(data) {
478
- safeWriteJSON(ACCOUNTS_FILE, data);
513
+ return safeWriteJSON(ACCOUNTS_FILE, data);
479
514
  }
480
- function updateAccountTokens(name, access, refresh, expires) {
515
+ async function updateAccountTokens(name, access, refresh, expires) {
481
516
  const data = loadAccounts();
482
517
  const account = data.accounts.find((a) => a.name === name);
483
518
  if (account) {
484
519
  account.access = access;
485
520
  account.refresh = refresh;
486
521
  account.expires = expires;
487
- saveAccounts(data);
522
+ await saveAccounts(data);
488
523
  }
489
524
  }
490
525
  async function refreshAccountToken(refreshTokenValue) {
@@ -575,7 +610,7 @@ function loadState() {
575
610
  return normalizeState(safeReadJSON(STATE_FILE, {}));
576
611
  }
577
612
  function saveState(state) {
578
- safeWriteJSON(STATE_FILE, state);
613
+ return safeWriteJSON(STATE_FILE, state);
579
614
  }
580
615
  function getThresholds(config) {
581
616
  if (typeof config.threshold === "number") {
@@ -865,13 +900,13 @@ function selectionSnapshot(state) {
865
900
  currentAccount: state.currentAccount
866
901
  };
867
902
  }
868
- function saveRequestState(state, initiallyLoaded) {
903
+ async function saveRequestState(state, initiallyLoaded) {
869
904
  const onDisk = loadState();
870
905
  const selectionChangedSinceLoad = onDisk.currentAccount !== initiallyLoaded.currentAccount;
871
906
  if (selectionChangedSinceLoad) {
872
907
  state.currentAccount = onDisk.currentAccount;
873
908
  }
874
- saveState(state);
909
+ await saveState(state);
875
910
  }
876
911
  function getRequestModel(body) {
877
912
  if (typeof body !== "string")
@@ -973,13 +1008,13 @@ var AuthSwitcherPlugin = async ({ client }) => {
973
1008
  account.access = result.access;
974
1009
  account.refresh = result.refresh;
975
1010
  account.expires = result.expires;
976
- updateAccountTokens(account.name, result.access, result.refresh, result.expires);
1011
+ await updateAccountTokens(account.name, result.access, result.refresh, result.expires);
977
1012
  } else {
978
1013
  markAuthFailure(state, account.name);
979
1014
  const available = accounts.filter((candidate) => !attemptedAccounts.has(candidate.name) && (!state.authFailures[candidate.name] || state.authFailures[candidate.name] <= Date.now()));
980
1015
  const next = available.length > 0 ? selectAccount(available, state, model).account : undefined;
981
1016
  if (!next) {
982
- saveRequestState(state, initiallyLoadedSelection);
1017
+ await saveRequestState(state, initiallyLoadedSelection);
983
1018
  throw new Error(`[oc-auth-switcher] All accounts failed token refresh`);
984
1019
  }
985
1020
  account = next;
@@ -1021,7 +1056,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
1021
1056
  next.access = result.access;
1022
1057
  next.refresh = result.refresh;
1023
1058
  next.expires = result.expires;
1024
- updateAccountTokens(next.name, result.access, result.refresh, result.expires);
1059
+ await updateAccountTokens(next.name, result.access, result.refresh, result.expires);
1025
1060
  }
1026
1061
  }
1027
1062
  const retryHeaders = mergeHeaders(input, init);
@@ -1036,7 +1071,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
1036
1071
  });
1037
1072
  updateUsageFromHeaders(state, next.name, retryResponse.headers);
1038
1073
  clearAuthFailure(state, next.name);
1039
- saveRequestState(state, initiallyLoadedSelection);
1074
+ await saveRequestState(state, initiallyLoadedSelection);
1040
1075
  return createStrippedStream(retryResponse);
1041
1076
  }
1042
1077
  }
@@ -1044,7 +1079,7 @@ var AuthSwitcherPlugin = async ({ client }) => {
1044
1079
  updateUsageFromHeaders(state, account.name, response.headers);
1045
1080
  clearAuthFailure(state, account.name);
1046
1081
  state.requestCount = (state.requestCount || 0) + 1;
1047
- saveRequestState(state, initiallyLoadedSelection);
1082
+ await saveRequestState(state, initiallyLoadedSelection);
1048
1083
  return createStrippedStream(response);
1049
1084
  }
1050
1085
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "OpenCode auth plugin for multi-account Anthropic Claude Max rotation with automatic failover.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",