claude-relay 2.2.1 → 2.2.3

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/bin/cli.js CHANGED
@@ -312,6 +312,33 @@ function getLocalIP() {
312
312
  }
313
313
 
314
314
  // --- Certs ---
315
+ function isRoutableIP(addr) {
316
+ if (addr.startsWith("10.")) return true;
317
+ if (addr.startsWith("192.168.")) return true;
318
+ if (addr.startsWith("100.")) {
319
+ var second = parseInt(addr.split(".")[1], 10);
320
+ return second >= 64 && second <= 127; // CGNAT (Tailscale)
321
+ }
322
+ if (addr.startsWith("172.")) {
323
+ var second = parseInt(addr.split(".")[1], 10);
324
+ return second >= 16 && second <= 31;
325
+ }
326
+ return false;
327
+ }
328
+
329
+ function getAllIPs() {
330
+ var ips = [];
331
+ var ifaces = os.networkInterfaces();
332
+ for (var addrs of Object.values(ifaces)) {
333
+ for (var j = 0; j < addrs.length; j++) {
334
+ if (addrs[j].family === "IPv4" && !addrs[j].internal && isRoutableIP(addrs[j].address)) {
335
+ ips.push(addrs[j].address);
336
+ }
337
+ }
338
+ }
339
+ return ips;
340
+ }
341
+
315
342
  function ensureCerts(ip) {
316
343
  var homeDir = os.homedir();
317
344
  var certDir = path.join(homeDir, ".claude-relay", "certs");
@@ -336,21 +363,29 @@ function ensureCerts(ip) {
336
363
  if (!fs.existsSync(caRoot)) caRoot = null;
337
364
  } catch (e) {}
338
365
 
366
+ // Collect all IPv4 addresses (Tailscale + LAN)
367
+ var allIPs = getAllIPs();
368
+
339
369
  if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
340
370
  var needRegen = false;
341
- if (ip && ip !== "localhost") {
342
- try {
343
- var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
344
- if (certText.indexOf(ip) === -1) needRegen = true;
345
- } catch (e) {}
346
- }
371
+ try {
372
+ var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
373
+ for (var i = 0; i < allIPs.length; i++) {
374
+ if (certText.indexOf(allIPs[i]) === -1) {
375
+ needRegen = true;
376
+ break;
377
+ }
378
+ }
379
+ } catch (e) { needRegen = true; }
347
380
  if (!needRegen) return { key: keyPath, cert: certPath, caRoot: caRoot };
348
381
  }
349
382
 
350
383
  fs.mkdirSync(certDir, { recursive: true });
351
384
 
352
385
  var domains = ["localhost", "127.0.0.1", "::1"];
353
- if (ip && ip !== "localhost") domains.push(ip);
386
+ for (var i = 0; i < allIPs.length; i++) {
387
+ if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
388
+ }
354
389
 
355
390
  try {
356
391
  execSync(
@@ -1435,22 +1470,6 @@ function showSetupGuide(config, ip, goBack) {
1435
1470
  var wantRemote = false;
1436
1471
  var wantPush = false;
1437
1472
 
1438
- // If everything is already set up, skip straight to QR
1439
- var tsReady = getTailscaleIP() !== null;
1440
- var mcReady = hasMkcert();
1441
- if (tsReady && mcReady && config.tls) {
1442
- console.clear();
1443
- printLogo();
1444
- log("");
1445
- log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
1446
- log(sym.bar);
1447
- log(sym.done + " " + a.green + "Tailscale" + a.reset + a.dim + " · " + getTailscaleIP() + a.reset);
1448
- log(sym.done + " " + a.green + "HTTPS" + a.reset + a.dim + " · mkcert installed" + a.reset);
1449
- log(sym.bar);
1450
- showSetupQR();
1451
- return;
1452
- }
1453
-
1454
1473
  console.clear();
1455
1474
  printLogo();
1456
1475
  log("");
@@ -1564,10 +1583,19 @@ function showSetupGuide(config, ip, goBack) {
1564
1583
 
1565
1584
  function showSetupQR() {
1566
1585
  var tsIP = getTailscaleIP();
1586
+ var lanIP = null;
1587
+ if (!wantRemote) {
1588
+ var allIPs = getAllIPs();
1589
+ for (var j = 0; j < allIPs.length; j++) {
1590
+ if (!allIPs[j].startsWith("100.")) { lanIP = allIPs[j]; break; }
1591
+ }
1592
+ }
1593
+ var setupIP = wantRemote ? (tsIP || ip) : (lanIP || ip);
1594
+ var setupQuery = wantRemote ? "" : "?mode=lan";
1567
1595
  // Always use HTTP onboarding URL for QR/setup when TLS is active
1568
1596
  var setupUrl = config.tls
1569
- ? "http://" + (tsIP || ip) + ":" + (config.port + 1) + "/setup"
1570
- : "http://" + (tsIP || ip) + ":" + config.port + "/setup";
1597
+ ? "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery
1598
+ : "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
1571
1599
  log(sym.pointer + " " + a.bold + "Continue on your device" + a.reset);
1572
1600
  log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
1573
1601
  log(sym.bar + " " + a.bold + setupUrl + a.reset);
@@ -1576,7 +1604,7 @@ function showSetupGuide(config, ip, goBack) {
1576
1604
  var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
1577
1605
  console.log(lines);
1578
1606
  log(sym.bar);
1579
- if (tsIP) {
1607
+ if (wantRemote) {
1580
1608
  log(sym.bar + " " + a.dim + "Can't connect? Make sure Tailscale is installed on your phone too." + a.reset);
1581
1609
  } else {
1582
1610
  log(sym.bar + " " + a.dim + "Can't connect? Your phone must be on the same Wi-Fi network." + a.reset);
package/lib/pages.js CHANGED
@@ -40,7 +40,7 @@ function pinPageHtml() {
40
40
  '</script></div></body></html>';
41
41
  }
42
42
 
43
- function setupPageHtml(httpsUrl, httpUrl, hasCert) {
43
+ function setupPageHtml(httpsUrl, httpUrl, hasCert, lanMode) {
44
44
  return `<!DOCTYPE html><html lang="en"><head>
45
45
  <meta charset="UTF-8">
46
46
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -195,6 +195,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
195
195
  <div class="step-desc">Install Claude Relay as an app for quick access and a full-screen experience.</div>
196
196
 
197
197
  <div class="platform-ios">
198
+ <div class="check-status warn">On iOS, push notifications only work from the installed app. This step is required.</div>
198
199
  <div id="ios-not-safari" class="check-status warn" style="display:none">You must use <b>Safari</b> to install. Open this page in Safari first.</div>
199
200
  <div id="ios-safari-steps">
200
201
  <div class="instruction"><div class="inst-num">1</div>
@@ -237,6 +238,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
237
238
  </div>
238
239
 
239
240
  <div id="pwa-status" class="check-status pending">After installing, open Claude Relay from your home screen to continue setup.</div>
241
+ <button class="skip-link" id="pwa-skip" onclick="nextStep()" style="display:none">Skip for now</button>
240
242
  </div>
241
243
 
242
244
  <!-- Step 3: Push Notifications -->
@@ -270,6 +272,7 @@ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
270
272
  var httpsUrl = ${JSON.stringify(httpsUrl)};
271
273
  var httpUrl = ${JSON.stringify(httpUrl)};
272
274
  var hasCert = ${hasCert ? 'true' : 'false'};
275
+ var lanMode = ${lanMode ? 'true' : 'false'};
273
276
  var isHttps = location.protocol === "https:";
274
277
  var ua = navigator.userAgent;
275
278
  var isIOS = /iPhone|iPad|iPod/.test(ua);
@@ -326,10 +329,17 @@ if (isStandalone && localStorage.getItem("setup-pending")) {
326
329
 
327
330
  function buildSteps(hasPushSub) {
328
331
  steps = [];
329
- if (!isTailscale && !isLocal) steps.push("tailscale");
332
+ if (!isTailscale && !isLocal && !lanMode) steps.push("tailscale");
330
333
  if (hasCert && !isHttps) steps.push("cert");
331
- if (!isStandalone) steps.push("pwa");
332
- if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
334
+ if (isAndroid) {
335
+ // Android: push first (works in browser), then PWA as optional
336
+ if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
337
+ if (!isStandalone) steps.push("pwa");
338
+ } else {
339
+ // iOS: PWA required for push, so install first
340
+ if (!isStandalone) steps.push("pwa");
341
+ if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
342
+ }
333
343
  steps.push("done");
334
344
 
335
345
  // Trigger HTTPS check now that steps are built
@@ -350,6 +360,14 @@ function buildSteps(hasPushSub) {
350
360
  localStorage.setItem("setup-pending", String(stepsBeforePwa + 1));
351
361
  }
352
362
 
363
+ // Android: PWA is optional, show skip button and update text
364
+ if (isAndroid && steps.indexOf("pwa") !== -1) {
365
+ var pwaSkip = document.getElementById("pwa-skip");
366
+ var pwaStatus = document.getElementById("pwa-status");
367
+ if (pwaSkip) pwaSkip.style.display = "block";
368
+ if (pwaStatus) pwaStatus.textContent = "Optional: install for quick access and full-screen experience.";
369
+ }
370
+
353
371
  // Push: show warning if not on HTTPS
354
372
  if (!isHttps && !isLocal) {
355
373
  pushBtn.style.display = "none";
@@ -393,7 +411,7 @@ function showStep(idx) {
393
411
  function nextStep() {
394
412
  // After cert step on HTTP, redirect to HTTPS for remaining steps
395
413
  if (!isHttps && steps[currentStep] === "cert") {
396
- location.replace(httpsUrl + "/setup");
414
+ location.replace(httpsUrl + "/setup" + (lanMode ? "?mode=lan" : ""));
397
415
  return;
398
416
  }
399
417
  if (currentStep < steps.length - 1) showStep(currentStep + 1);
@@ -616,7 +634,7 @@ if (!isHttps && !isLocal) {
616
634
  var ac = new AbortController();
617
635
  setTimeout(function() { ac.abort(); }, 3000);
618
636
  fetch(info.httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
619
- .then(function() { location.replace(info.httpsUrl + "/setup"); })
637
+ .then(function() { location.replace(info.httpsUrl + "/setup" + (lanMode ? "?mode=lan" : "")); })
620
638
  .catch(function() { init(); });
621
639
  }).catch(function() { init(); });
622
640
  } else {
package/lib/project.js CHANGED
@@ -4,7 +4,6 @@ var { createSessionManager } = require("./sessions");
4
4
  var { createSDKBridge } = require("./sdk-bridge");
5
5
  var { createTerminalManager } = require("./terminal-manager");
6
6
  var { fetchLatestVersion, isNewer } = require("./updater");
7
- var { fetchUsageData } = require("./usage");
8
7
  var { execFileSync } = require("child_process");
9
8
 
10
9
  // SDK loaded dynamically (ESM module)
@@ -317,14 +316,6 @@ function createProjectContext(opts) {
317
316
  return;
318
317
  }
319
318
 
320
- if (msg.type === "get_usage") {
321
- fetchUsageData().then(function (data) {
322
- sendTo(ws, { type: "usage_data", data: data });
323
- }).catch(function (err) {
324
- sendTo(ws, { type: "usage_data", error: err.message || "Failed to fetch usage data" });
325
- });
326
- return;
327
- }
328
319
 
329
320
  if (msg.type === "set_model" && msg.model) {
330
321
  var session = sm.getActiveSession();
package/lib/public/app.js CHANGED
@@ -502,8 +502,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
502
502
  connectOverlay.classList.add("hidden");
503
503
  stopVerbCycle();
504
504
  updateFavicon("#57AB5A");
505
- if (usageFab) usageFab.classList.remove("hidden");
506
- if (usageHeaderBtn) usageHeaderBtn.classList.remove("hidden");
507
505
  } else if (status === "processing") {
508
506
  statusDot.classList.add("processing");
509
507
  processing = true;
@@ -605,8 +603,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
605
603
  // --- Usage panel ---
606
604
  var usagePanel = $("usage-panel");
607
605
  var usagePanelClose = $("usage-panel-close");
608
- var usageFab = $("usage-fab");
609
- var usageHeaderBtn = $("usage-header-btn");
610
606
  var usageCostEl = $("usage-cost");
611
607
  var usageInputEl = $("usage-input");
612
608
  var usageOutputEl = $("usage-output");
@@ -615,126 +611,12 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
615
611
  var usageTurnsEl = $("usage-turns");
616
612
  var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
617
613
 
618
- // Rate limit bar elements
619
- var usageLoading = $("usage-loading");
620
- var usageError = $("usage-error");
621
- var usageBars = $("usage-bars");
622
- var usageRateLimitFetched = false;
623
-
624
614
  function formatTokens(n) {
625
615
  if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
626
616
  if (n >= 1000) return (n / 1000).toFixed(1) + "K";
627
617
  return String(n);
628
618
  }
629
619
 
630
- function formatTimeUntil(isoStr) {
631
- if (!isoStr) return "";
632
- var d = new Date(isoStr);
633
- var now = Date.now();
634
- var diff = d.getTime() - now;
635
- if (diff <= 0) return "Resets soon";
636
- var hours = Math.floor(diff / 3600000);
637
- var minutes = Math.floor((diff % 3600000) / 60000);
638
- var relative;
639
- if (hours > 24) {
640
- var days = Math.floor(hours / 24);
641
- relative = days + "d " + (hours % 24) + "h";
642
- } else if (hours > 0) {
643
- relative = hours + "h " + minutes + "m";
644
- } else {
645
- relative = minutes + "m";
646
- }
647
- // Absolute time: "Mon 2/17 15:00" or "15:00" if today
648
- var nowDate = new Date(now);
649
- var month = d.getMonth() + 1;
650
- var day = d.getDate();
651
- var hh = String(d.getHours()).padStart(2, "0");
652
- var mm = String(d.getMinutes()).padStart(2, "0");
653
- var sameDay = d.getFullYear() === nowDate.getFullYear() && d.getMonth() === nowDate.getMonth() && d.getDate() === nowDate.getDate();
654
- var abs = sameDay ? hh + ":" + mm : month + "/" + day + " " + hh + ":" + mm;
655
- return "Resets in " + relative + " (" + abs + ")";
656
- }
657
-
658
- function updateRateLimitBar(prefix, utilization, resetsAt) {
659
- var pctEl = $("usage-pct-" + prefix);
660
- var fillEl = $("usage-fill-" + prefix);
661
- var resetEl = $("usage-reset-" + prefix);
662
- var groupEl = $("usage-bar-" + prefix);
663
- if (!pctEl || !fillEl || !resetEl) return;
664
-
665
- if (utilization == null) {
666
- if (groupEl) groupEl.classList.add("hidden");
667
- return;
668
- }
669
- if (groupEl) groupEl.classList.remove("hidden");
670
-
671
- var pct = Math.max(0, Math.min(100, Math.round(utilization)));
672
- pctEl.textContent = pct + "%";
673
- fillEl.style.width = pct + "%";
674
- fillEl.className = "usage-bar-fill";
675
- if (prefix === "extra") fillEl.classList.add("usage-bar-fill-extra");
676
- if (pct >= 90) fillEl.classList.add("critical");
677
- else if (pct >= 70) fillEl.classList.add("warn");
678
-
679
- resetEl.textContent = formatTimeUntil(resetsAt);
680
- }
681
-
682
- function handleUsageData(msg) {
683
- if (!usageLoading || !usageError || !usageBars) return;
684
- usageLoading.style.display = "none";
685
-
686
- if (msg.error) {
687
- usageError.textContent = msg.error;
688
- usageError.classList.remove("hidden");
689
- usageBars.classList.add("hidden");
690
- return;
691
- }
692
-
693
- usageError.classList.add("hidden");
694
- usageBars.classList.remove("hidden");
695
- var data = msg.data || {};
696
-
697
- // Session (five_hour)
698
- var session = data.five_hour || {};
699
- updateRateLimitBar("session", session.utilization, session.resets_at);
700
-
701
- // Weekly all models (seven_day)
702
- var weekly = data.seven_day || {};
703
- updateRateLimitBar("weekly", weekly.utilization, weekly.resets_at);
704
-
705
- // Weekly Sonnet only (seven_day_sonnet)
706
- var sonnet = data.seven_day_sonnet || {};
707
- updateRateLimitBar("sonnet", sonnet.utilization, sonnet.resets_at);
708
-
709
- // Extra usage
710
- var extra = data.extra_usage || {};
711
- var extraGroup = $("usage-bar-extra");
712
- if (extra.is_enabled) {
713
- if (extraGroup) extraGroup.classList.remove("hidden");
714
- // Compute reset time as first of next month
715
- var now = new Date();
716
- var nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
717
- updateRateLimitBar("extra", extra.utilization, nextMonth.toISOString());
718
- var extraResetEl = $("usage-reset-extra");
719
- if (extraResetEl && extra.monthly_limit != null) {
720
- var usedDollars = (extra.used_credits / 100).toFixed(2);
721
- var limitDollars = (extra.monthly_limit / 100).toFixed(2);
722
- extraResetEl.textContent = "$" + usedDollars + " / $" + limitDollars;
723
- }
724
- } else {
725
- if (extraGroup) extraGroup.classList.add("hidden");
726
- }
727
-
728
- usageRateLimitFetched = true;
729
- }
730
-
731
- function requestUsageData() {
732
- if (!ws || ws.readyState !== 1) return;
733
- if (usageLoading) usageLoading.style.display = "";
734
- if (usageError) usageError.classList.add("hidden");
735
- ws.send(JSON.stringify({ type: "get_usage" }));
736
- }
737
-
738
620
  function updateUsagePanel() {
739
621
  if (!usageCostEl) return;
740
622
  usageCostEl.textContent = "$" + sessionUsage.cost.toFixed(4);
@@ -761,40 +643,17 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
761
643
  sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
762
644
  updateUsagePanel();
763
645
  if (usagePanel) usagePanel.classList.add("hidden");
764
- if (usageFab) usageFab.classList.remove("active");
765
- if (usageHeaderBtn) usageHeaderBtn.classList.remove("active");
766
- usageRateLimitFetched = false;
767
646
  }
768
647
 
769
648
  function toggleUsagePanel() {
770
649
  if (!usagePanel) return;
771
- var isHidden = usagePanel.classList.toggle("hidden");
772
- if (usageFab) usageFab.classList.toggle("active", !isHidden);
773
- if (usageHeaderBtn) usageHeaderBtn.classList.toggle("active", !isHidden);
774
- // Fetch rate limit data when opening
775
- if (!isHidden) {
776
- requestUsageData();
777
- }
650
+ usagePanel.classList.toggle("hidden");
778
651
  refreshIcons();
779
652
  }
780
653
 
781
654
  if (usagePanelClose) {
782
655
  usagePanelClose.addEventListener("click", function () {
783
656
  usagePanel.classList.add("hidden");
784
- if (usageFab) usageFab.classList.remove("active");
785
- if (usageHeaderBtn) usageHeaderBtn.classList.remove("active");
786
- });
787
- }
788
-
789
- if (usageFab) {
790
- usageFab.addEventListener("click", function () {
791
- toggleUsagePanel();
792
- });
793
- }
794
-
795
- if (usageHeaderBtn) {
796
- usageHeaderBtn.addEventListener("click", function () {
797
- toggleUsagePanel();
798
657
  });
799
658
  }
800
659
 
@@ -1195,10 +1054,6 @@ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUs
1195
1054
  updateModelSelector(msg.model, msg.models || []);
1196
1055
  break;
1197
1056
 
1198
- case "usage_data":
1199
- handleUsageData(msg);
1200
- break;
1201
-
1202
1057
  case "client_count":
1203
1058
  var countEl = document.getElementById("client-count");
1204
1059
  if (countEl) {
@@ -85,24 +85,6 @@
85
85
  flex-shrink: 0;
86
86
  }
87
87
 
88
- /* --- Usage header button (mobile only) --- */
89
- #usage-header-btn {
90
- display: none;
91
- align-items: center;
92
- justify-content: center;
93
- background: none;
94
- border: 1px solid transparent;
95
- border-radius: 8px;
96
- color: var(--text-dimmer);
97
- cursor: pointer;
98
- padding: 4px;
99
- transition: color 0.15s, background 0.15s, border-color 0.15s;
100
- }
101
- #usage-header-btn .lucide { width: 15px; height: 15px; }
102
- #usage-header-btn:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); border-color: var(--border); }
103
- #usage-header-btn.active { color: var(--accent); border-color: var(--accent); }
104
- #usage-header-btn.hidden { display: none !important; }
105
-
106
88
  /* --- Terminal toggle button --- */
107
89
  #terminal-toggle-btn {
108
90
  display: flex;
@@ -485,68 +467,29 @@
485
467
  padding: 8px 14px 12px;
486
468
  }
487
469
 
488
- /* --- Rate limit bars --- */
489
- .usage-loading {
490
- color: var(--text-muted);
491
- padding: 8px 0;
492
- text-align: center;
493
- }
494
-
495
- .usage-error {
496
- color: var(--error);
497
- padding: 6px 0;
498
- font-size: 11px;
499
- line-height: 1.4;
500
- }
501
-
502
- .usage-error.hidden { display: none; }
503
-
504
- .usage-bar-group {
505
- margin-bottom: 10px;
506
- }
507
-
508
- .usage-bar-group:last-child { margin-bottom: 0; }
509
- .usage-bar-group.hidden { display: none; }
510
-
511
- .usage-bar-label {
470
+ /* --- Plan usage link --- */
471
+ .usage-external-link {
512
472
  display: flex;
513
- justify-content: space-between;
514
473
  align-items: center;
515
- margin-bottom: 4px;
516
- color: var(--text-secondary);
517
- font-size: 11px;
518
- }
519
-
520
- .usage-bar-pct {
521
- font-family: "SF Mono", Menlo, Monaco, monospace;
522
- font-weight: 600;
523
- font-size: 11px;
524
- }
525
-
526
- .usage-bar-track {
527
- height: 6px;
528
- background: var(--input-bg);
474
+ justify-content: center;
475
+ gap: 6px;
476
+ padding: 8px 12px;
477
+ border-radius: 6px;
529
478
  border: 1px solid var(--border-subtle);
530
- border-radius: 3px;
531
- overflow: hidden;
479
+ background: var(--input-bg);
480
+ color: var(--text-secondary);
481
+ font-size: 12px;
482
+ text-decoration: none;
483
+ transition: background 0.15s, color 0.15s;
532
484
  }
533
-
534
- .usage-bar-fill {
535
- height: 100%;
536
- border-radius: 3px;
537
- background: var(--accent);
538
- transition: width 0.4s ease;
539
- min-width: 0;
485
+ .usage-external-link:hover {
486
+ background: var(--hover-bg);
487
+ color: var(--text-primary);
540
488
  }
541
-
542
- .usage-bar-fill.warn { background: var(--warning, #E5A84B); }
543
- .usage-bar-fill.critical { background: var(--error); }
544
- .usage-bar-fill-extra { background: var(--text-dimmer); }
545
-
546
- .usage-bar-reset {
547
- font-size: 10px;
548
- color: var(--text-muted);
549
- margin-top: 2px;
489
+ .usage-external-link svg {
490
+ width: 13px;
491
+ height: 13px;
492
+ flex-shrink: 0;
550
493
  }
551
494
 
552
495
  .usage-divider {
@@ -580,31 +523,6 @@
580
523
  font-weight: 500;
581
524
  }
582
525
 
583
- /* --- Usage FAB --- */
584
- #usage-fab {
585
- position: fixed;
586
- bottom: calc(var(--safe-bottom, 0px) + 16px);
587
- right: 16px;
588
- width: 40px;
589
- height: 40px;
590
- border-radius: 50%;
591
- border: 1px solid var(--border);
592
- background: var(--bg-alt);
593
- color: var(--text-muted);
594
- cursor: pointer;
595
- display: flex;
596
- align-items: center;
597
- justify-content: center;
598
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
599
- z-index: 90;
600
- transition: color 0.15s, background 0.15s, border-color 0.15s;
601
- }
602
-
603
- #usage-fab.hidden { display: none; }
604
- #usage-fab .lucide { width: 18px; height: 18px; }
605
- #usage-fab:hover { color: var(--text); border-color: var(--text-dimmer); }
606
- #usage-fab.active { color: var(--accent); border-color: var(--accent); }
607
-
608
526
  @media (max-width: 768px) {
609
527
  #usage-panel {
610
528
  top: auto;
@@ -614,8 +532,6 @@
614
532
  width: auto;
615
533
  max-height: 60vh;
616
534
  }
617
- #usage-fab { display: none !important; }
618
- #usage-header-btn:not(.hidden) { display: flex; }
619
535
  }
620
536
 
621
537
  .status-dot.connected { background: var(--success); }
@@ -80,9 +80,6 @@
80
80
  <i data-lucide="ellipsis" style="width:14px;height:14px"></i>
81
81
  </button>
82
82
  <div id="sidebar-footer-menu" class="hidden">
83
- <button class="sidebar-menu-item" id="footer-usage">
84
- <i data-lucide="gauge"></i> <span>Usage</span>
85
- </button>
86
83
  <a class="sidebar-menu-item" href="https://github.com/chadbyte/claude-relay" target="_blank" rel="noopener">
87
84
  <i data-lucide="github"></i> <span>GitHub</span>
88
85
  </a>
@@ -125,7 +122,6 @@
125
122
  </label>
126
123
  </div>
127
124
  </div>
128
- <button id="usage-header-btn" class="hidden" title="Usage"><i data-lucide="dollar-sign"></i></button>
129
125
  <button id="terminal-toggle-btn" title="Terminal"><i data-lucide="square-terminal"></i><span id="terminal-count" class="hidden"></span></button>
130
126
  <button id="qr-btn" title="Share"><i data-lucide="share"></i></button>
131
127
  <div id="qr-overlay" class="hidden">
@@ -173,33 +169,11 @@
173
169
  <button id="usage-panel-close" aria-label="Close"><i data-lucide="x"></i></button>
174
170
  </div>
175
171
  <div class="usage-panel-body">
176
- <div id="usage-rate-limits">
177
- <div id="usage-loading" class="usage-loading">Loading...</div>
178
- <div id="usage-error" class="usage-error hidden"></div>
179
- <div id="usage-bars" class="hidden">
180
- <div class="usage-bar-group" id="usage-bar-session">
181
- <div class="usage-bar-label"><span>Current session</span><span class="usage-bar-pct" id="usage-pct-session"></span></div>
182
- <div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-session"></div></div>
183
- <div class="usage-bar-reset" id="usage-reset-session"></div>
184
- </div>
185
- <div class="usage-bar-group" id="usage-bar-weekly">
186
- <div class="usage-bar-label"><span>Current week (all models)</span><span class="usage-bar-pct" id="usage-pct-weekly"></span></div>
187
- <div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-weekly"></div></div>
188
- <div class="usage-bar-reset" id="usage-reset-weekly"></div>
189
- </div>
190
- <div class="usage-bar-group" id="usage-bar-sonnet">
191
- <div class="usage-bar-label"><span>Current week (Sonnet only)</span><span class="usage-bar-pct" id="usage-pct-sonnet"></span></div>
192
- <div class="usage-bar-track"><div class="usage-bar-fill" id="usage-fill-sonnet"></div></div>
193
- <div class="usage-bar-reset" id="usage-reset-sonnet"></div>
194
- </div>
195
- <div class="usage-bar-group hidden" id="usage-bar-extra">
196
- <div class="usage-bar-label"><span>Extra usage</span><span class="usage-bar-pct" id="usage-pct-extra"></span></div>
197
- <div class="usage-bar-track"><div class="usage-bar-fill usage-bar-fill-extra" id="usage-fill-extra"></div></div>
198
- <div class="usage-bar-reset" id="usage-reset-extra"></div>
199
- </div>
200
- </div>
201
- </div>
202
- <div class="usage-divider" id="usage-session-divider"></div>
172
+ <a href="https://claude.ai/settings/usage" target="_blank" rel="noopener" class="usage-external-link">
173
+ <span>Check plan usage on claude.ai</span>
174
+ <i data-lucide="external-link"></i>
175
+ </a>
176
+ <div class="usage-divider"></div>
203
177
  <div class="usage-section-label">Session</div>
204
178
  <div class="usage-row"><span class="usage-label">Cost</span><span id="usage-cost" class="usage-value">$0.0000</span></div>
205
179
  <div class="usage-row"><span class="usage-label">Input tokens</span><span id="usage-input" class="usage-value">0</span></div>
@@ -211,7 +185,6 @@
211
185
  </div>
212
186
  <div id="todo-sticky" class="hidden"></div>
213
187
  <button id="new-msg-btn" class="hidden" aria-label="Scroll to bottom"></button>
214
- <button id="usage-fab" class="hidden" aria-label="Usage"><i data-lucide="gauge"></i></button>
215
188
  <div id="input-area">
216
189
  <div id="input-wrapper">
217
190
  <div id="slash-menu"></div>
@@ -137,7 +137,6 @@ export function initNotifications(_ctx) {
137
137
  var footerBtn = $("sidebar-footer-btn");
138
138
  var footerMenu = $("sidebar-footer-menu");
139
139
  var footerUpdateCheck = $("footer-update-check");
140
- var footerUsage = $("footer-usage");
141
140
  if (!footerBtn || !footerMenu) return;
142
141
 
143
142
  footerBtn.addEventListener("click", function (e) {
@@ -151,14 +150,6 @@ export function initNotifications(_ctx) {
151
150
  }
152
151
  });
153
152
 
154
- if (footerUsage && ctx.toggleUsagePanel) {
155
- footerUsage.addEventListener("click", function (e) {
156
- e.stopPropagation();
157
- footerMenu.classList.add("hidden");
158
- ctx.toggleUsagePanel();
159
- });
160
- }
161
-
162
153
  function setUpdateIcon(name, spin) {
163
154
  var el = footerUpdateCheck.querySelector(".lucide, [data-lucide]");
164
155
  if (!el) return;
package/lib/server.js CHANGED
@@ -202,16 +202,17 @@ function createServer(opts) {
202
202
  }
203
203
 
204
204
  // Setup page
205
- if (req.url === "/setup" && req.method === "GET") {
205
+ if (fullUrl === "/setup" && req.method === "GET") {
206
206
  var host = req.headers.host || "localhost";
207
207
  var hostname = host.split(":")[0];
208
208
  var protocol = tlsOptions ? "https" : "http";
209
209
  var setupUrl = protocol + "://" + hostname + ":" + portNum;
210
+ var lanMode = /[?&]mode=lan/.test(req.url);
210
211
  res.writeHead(200, {
211
212
  "Content-Type": "text/html; charset=utf-8",
212
213
  "Access-Control-Allow-Origin": "*",
213
214
  });
214
- res.end(setupPageHtml(setupUrl, setupUrl, !!caContent));
215
+ res.end(setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
215
216
  return;
216
217
  }
217
218
 
@@ -364,8 +365,9 @@ function createServer(opts) {
364
365
  var hostname = host.split(":")[0];
365
366
  var httpsSetupUrl = "https://" + hostname + ":" + portNum;
366
367
  var httpSetupUrl = "http://" + hostname + ":" + (portNum + 1);
368
+ var lanMode = /[?&]mode=lan/.test(req.url);
367
369
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
368
- res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent));
370
+ res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
369
371
  return;
370
372
  }
371
373
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-relay",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "claude-relay": "./bin/cli.js"
package/lib/usage.js DELETED
@@ -1,90 +0,0 @@
1
- var os = require("os");
2
- var path = require("path");
3
- var fs = require("fs");
4
- var { execFileSync } = require("child_process");
5
- var https = require("https");
6
-
7
- var BASE_API_URL = "https://api.anthropic.com";
8
-
9
- function readOAuthToken() {
10
- // Priority 1: env var override
11
- if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
12
- return process.env.CLAUDE_CODE_OAUTH_TOKEN;
13
- }
14
-
15
- // Priority 2: macOS keychain
16
- if (process.platform === "darwin") {
17
- try {
18
- var user = os.userInfo().username;
19
- var data = execFileSync("security", [
20
- "find-generic-password", "-a", user, "-w", "-s", "Claude Code-credentials"
21
- ], { encoding: "utf8", timeout: 5000 }).trim();
22
- if (data) {
23
- var parsed = JSON.parse(data);
24
- if (parsed.claudeAiOauth && parsed.claudeAiOauth.accessToken) {
25
- return parsed.claudeAiOauth.accessToken;
26
- }
27
- }
28
- } catch (e) { /* fall through */ }
29
- }
30
-
31
- // Priority 3: plaintext credentials file
32
- var configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
33
- var credFile = path.join(configDir, ".credentials.json");
34
- try {
35
- var content = fs.readFileSync(credFile, "utf8");
36
- var parsed = JSON.parse(content);
37
- if (parsed.claudeAiOauth && parsed.claudeAiOauth.accessToken) {
38
- return parsed.claudeAiOauth.accessToken;
39
- }
40
- } catch (e) { /* fall through */ }
41
-
42
- return null;
43
- }
44
-
45
- function fetchUsageData() {
46
- return new Promise(function (resolve, reject) {
47
- var token = readOAuthToken();
48
- if (!token) {
49
- reject(new Error("No OAuth token available"));
50
- return;
51
- }
52
-
53
- var url = new URL(BASE_API_URL + "/api/oauth/usage");
54
- var options = {
55
- hostname: url.hostname,
56
- port: url.port || 443,
57
- path: url.pathname,
58
- method: "GET",
59
- headers: {
60
- "Content-Type": "application/json",
61
- "Authorization": "Bearer " + token,
62
- "anthropic-beta": "oauth-2025-04-20",
63
- "User-Agent": "claude-code/2.0.0",
64
- },
65
- timeout: 5000,
66
- };
67
-
68
- var req = https.request(options, function (res) {
69
- var body = "";
70
- res.on("data", function (chunk) { body += chunk; });
71
- res.on("end", function () {
72
- if (res.statusCode !== 200) {
73
- reject(new Error("Usage API returned " + res.statusCode));
74
- return;
75
- }
76
- try {
77
- resolve(JSON.parse(body));
78
- } catch (e) {
79
- reject(new Error("Invalid JSON from usage API"));
80
- }
81
- });
82
- });
83
-
84
- req.on("error", function (err) { reject(err); });
85
- req.on("timeout", function () { req.destroy(); reject(new Error("Usage API timeout")); });
86
- req.end();
87
- });
88
- }
89
-
90
- module.exports = { fetchUsageData: fetchUsageData };