@dst-justin/relay 2.1.3 → 2.2.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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/package.json +1 -1
  4. package/relay +146 -10
  5. package/relay.ps1 +37 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 darkstar1227
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -223,6 +223,13 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
223
223
 
224
224
  ## Changelog
225
225
 
226
+ ### v2.2.0 — 2026-06-28
227
+ - Silent OAuth auto-refresh: `relay list` and `relay status` now silently refresh expired tokens using the stored `refreshToken` — no browser login needed for routine expiry
228
+ - Pre-emptive refresh: tokens are refreshed 5 minutes before expiry, not just after
229
+ - Autoswitch daemon now refreshes tokens proactively every 30 minutes and on expiry detection, ensuring the daemon never switches to a dead account
230
+ - New `relay refresh-all` command: silently refreshes all accounts via OAuth in one go
231
+ - Windows (`relay.ps1`): token refresh wired into `Show-Table` usage loop
232
+
226
233
  ### v2.1.1 — 2026-06-24
227
234
  - Display current version and latest version at the end of `list`, `status`, `relay` (menu), `sessions`, and `help` commands
228
235
  - Background version check (24h cache) — non-blocking, never slows down output
@@ -243,3 +250,7 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
243
250
 
244
251
  ### v2.0.2 — 2026-06-23
245
252
  - Skip `npm install` during `relay update` when already on the latest version or when version check fails
253
+
254
+ ## License
255
+
256
+ MIT © [darkstar1227](https://github.com/darkstar1227)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "description": "Multi-account switcher for Claude Code — instant credential swap across macOS, Linux, and Windows",
5
5
  "bin": {
6
6
  "relay": "./relay.js"
package/relay CHANGED
@@ -226,8 +226,9 @@ def save_cache(c):
226
226
  except Exception:
227
227
  pass
228
228
 
229
- def fetch(name):
229
+ def fetch(name, _retried=False):
230
230
  cred_path = os.path.join(creds_dir, name + '.json')
231
+ was_refreshed = False
231
232
  try:
232
233
  d = json.load(open(cred_path))
233
234
  oauth = d.get('claudeAiOauth') or {}
@@ -235,16 +236,21 @@ def fetch(name):
235
236
  if not tok:
236
237
  return name, None
237
238
 
238
- # Short-circuit: token is locally known to be expired
239
239
  expires_at_ms = oauth.get('expiresAt', 0)
240
240
  now_ms = datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000
241
- if expires_at_ms and now_ms > expires_at_ms:
242
- return name, 'expired'
241
+ # Pre-emptive: refresh 5 minutes before expiry (300000ms)
242
+ if expires_at_ms and now_ms > expires_at_ms - 300000:
243
+ new_tok = try_refresh(name, cred_path)
244
+ if new_tok:
245
+ tok = new_tok
246
+ was_refreshed = True
247
+ elif now_ms > expires_at_ms:
248
+ return name, 'expired'
249
+ # else: pre-emptive window, refresh failed, token still valid — fall through
243
250
 
244
- # Check cache
245
251
  c = load_cache()
246
252
  entry = c.get(name)
247
- if entry and now_ms / 1000 - entry.get('ts', 0) < CACHE_TTL:
253
+ if entry and now_ms / 1000 - entry.get('ts', 0) < CACHE_TTL and not was_refreshed:
248
254
  return name, entry.get('data')
249
255
 
250
256
  req = urllib.request.Request(
@@ -256,7 +262,10 @@ def fetch(name):
256
262
  save_cache(c)
257
263
  return name, data
258
264
  except urllib.error.HTTPError as e:
259
- if e.code == 401:
265
+ if e.code == 401 and not _retried:
266
+ new_tok = try_refresh(name, cred_path)
267
+ if new_tok:
268
+ return fetch(name, _retried=True)
260
269
  return name, 'expired'
261
270
  return name, None
262
271
  except Exception:
@@ -399,7 +408,7 @@ cmd_status() {
399
408
  warn "Recorded account '${current}' no longer exists"
400
409
  else
401
410
  "${PY}" - "$(account_creds "${current}")" "${current}" "$(get_meta_email "${current}")" <<'EOF'
402
- import json, sys, datetime, urllib.request
411
+ import json, sys, datetime, urllib.request, urllib.parse
403
412
 
404
413
  creds_path, name, email = sys.argv[1], sys.argv[2], sys.argv[3]
405
414
  R='\033[0m'; B='\033[1m'; D='\033[2m'
@@ -408,11 +417,43 @@ GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
408
417
  print(f' {B}Account:{R} {GR}{B}{name}{R}')
409
418
  print(f' {B}Email:{R} {email}')
410
419
 
420
+ # ponytail: intentional copy of try_refresh() — cmd_status has its own heredoc scope
421
+ def _try_refresh(cred_path):
422
+ try:
423
+ d2 = json.load(open(cred_path))
424
+ oauth2 = d2.get('claudeAiOauth') or {}
425
+ rt = oauth2.get('refreshToken', '')
426
+ if not rt:
427
+ return None
428
+ params = urllib.parse.urlencode({'grant_type': 'refresh_token', 'refresh_token': rt}).encode()
429
+ req2 = urllib.request.Request('https://api.anthropic.com/token', data=params,
430
+ headers={'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'relay/2.0'})
431
+ with urllib.request.urlopen(req2, timeout=10) as r2:
432
+ resp = json.loads(r2.read())
433
+ oauth2['accessToken'] = resp['access_token']
434
+ if 'refresh_token' in resp:
435
+ oauth2['refreshToken'] = resp['refresh_token']
436
+ oauth2['expiresAt'] = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000) + resp.get('expires_in', 3600) * 1000
437
+ d2['claudeAiOauth'] = oauth2
438
+ import os; open(cred_path, 'w').write(json.dumps(d2)); os.chmod(cred_path, 0o600)
439
+ return oauth2['accessToken']
440
+ except Exception:
441
+ return None
442
+
411
443
  try:
412
444
  d = json.load(open(creds_path))
413
- tok = (d.get('claudeAiOauth') or {}).get('accessToken', '')
445
+ oauth = d.get('claudeAiOauth') or {}
446
+ tok = oauth.get('accessToken', '')
414
447
  if not tok:
415
448
  print(f'\n {YL}⚠ No access token — please log in again{R}'); sys.exit(0)
449
+ expires_at_ms = oauth.get('expiresAt', 0)
450
+ now_ms = datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000
451
+ if expires_at_ms and now_ms > expires_at_ms - 300000:
452
+ new_tok = _try_refresh(creds_path)
453
+ if new_tok:
454
+ tok = new_tok
455
+ elif now_ms > expires_at_ms:
456
+ print(f'\n {YL}⚠ Token expired — run: relay refresh {name}{R}'); sys.exit(0)
416
457
  req = urllib.request.Request(
417
458
  'https://api.anthropic.com/api/oauth/usage',
418
459
  headers={'Authorization': 'Bearer ' + tok, 'User-Agent': 'relay/2.0'})
@@ -569,6 +610,55 @@ cmd_refresh() {
569
610
  ok "Account '${B}${name}${R}' refreshed ${D}$(get_meta_email "${name}")${R}"
570
611
  }
571
612
 
613
+ cmd_refresh_all() {
614
+ hdr "Refresh all accounts (silent OAuth)"
615
+ local names=()
616
+ while IFS= read -r f; do
617
+ names+=("$(basename "${f%.json}")")
618
+ done < <(find "${CREDS_STORE}" -maxdepth 1 -name '*.json' 2>/dev/null | sort)
619
+ [[ ${#names[@]} -eq 0 ]] && { warn "No accounts found"; return 0; }
620
+ "${PY}" - "${CREDS_STORE}" "${#names[@]}" "${names[@]}" <<'EOF'
621
+ import json, sys, datetime, urllib.request, urllib.parse, os
622
+
623
+ creds_dir, n = sys.argv[1], int(sys.argv[2])
624
+ names = sys.argv[3:3+n]
625
+ R='\033[0m'; B='\033[1m'; GR='\033[32m'; YL='\033[33m'; RD='\033[31m'
626
+
627
+ # ponytail: intentional copy of try_refresh() — self-contained heredoc scope
628
+ def try_refresh(name, cred_path):
629
+ try:
630
+ d = json.load(open(cred_path))
631
+ oauth = d.get('claudeAiOauth') or {}
632
+ rt = oauth.get('refreshToken', '')
633
+ if not rt:
634
+ return None
635
+ params = urllib.parse.urlencode({'grant_type': 'refresh_token', 'refresh_token': rt}).encode()
636
+ req = urllib.request.Request('https://api.anthropic.com/token', data=params,
637
+ headers={'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'relay/2.0'})
638
+ with urllib.request.urlopen(req, timeout=10) as r:
639
+ resp = json.loads(r.read())
640
+ oauth['accessToken'] = resp['access_token']
641
+ if 'refresh_token' in resp:
642
+ oauth['refreshToken'] = resp['refresh_token']
643
+ oauth['expiresAt'] = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000) + resp.get('expires_in', 3600) * 1000
644
+ d['claudeAiOauth'] = oauth
645
+ open(cred_path, 'w').write(json.dumps(d))
646
+ os.chmod(cred_path, 0o600)
647
+ return oauth['accessToken']
648
+ except Exception:
649
+ return None
650
+
651
+ for name in names:
652
+ cred_path = os.path.join(creds_dir, name + '.json')
653
+ result = try_refresh(name, cred_path)
654
+ if result:
655
+ print(f' {GR}✓{R} {B}{name}{R} refreshed')
656
+ else:
657
+ print(f' {YL}⚠{R} {B}{name}{R} refresh failed (token may already be fresh or refreshToken expired)')
658
+ print()
659
+ EOF
660
+ }
661
+
572
662
  cmd_remove() {
573
663
  local name="${1:-}"
574
664
  [[ -z "${name}" ]] && { err "usage: relay remove <name>"; exit 1; }
@@ -781,6 +871,35 @@ def save_cache(c):
781
871
  with open(CACHE_FILE, 'w') as f: json.dump(c, f)
782
872
  except: pass
783
873
 
874
+ # ponytail: intentional copy of try_refresh() — daemon is a standalone extracted script
875
+ def try_refresh_daemon(name, cred_path):
876
+ try:
877
+ d = json.load(open(cred_path))
878
+ oauth = d.get('claudeAiOauth') or {}
879
+ rt = oauth.get('refreshToken', '')
880
+ if not rt:
881
+ return None
882
+ import urllib.parse
883
+ params = urllib.parse.urlencode({'grant_type': 'refresh_token', 'refresh_token': rt}).encode()
884
+ req = urllib.request.Request('https://api.anthropic.com/token', data=params,
885
+ headers={'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'relay/2.0'})
886
+ with urllib.request.urlopen(req, timeout=10) as r:
887
+ resp = json.loads(r.read())
888
+ oauth['accessToken'] = resp['access_token']
889
+ if 'refresh_token' in resp:
890
+ oauth['refreshToken'] = resp['refresh_token']
891
+ oauth['expiresAt'] = int(time.time() * 1000) + resp.get('expires_in', 3600) * 1000
892
+ d['claudeAiOauth'] = oauth
893
+ content = json.dumps(d)
894
+ open(cred_path, 'w').write(content)
895
+ os.chmod(cred_path, 0o600)
896
+ current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
897
+ if name == current:
898
+ kc_write(content) # critical: update keychain so do_switch() doesn't clobber
899
+ return oauth['accessToken']
900
+ except Exception:
901
+ return None
902
+
784
903
  def fetch_usage(name):
785
904
  cred_path = os.path.join(CREDS_DIR, name + '.json')
786
905
  try:
@@ -791,7 +910,12 @@ def fetch_usage(name):
791
910
 
792
911
  expires_at_ms = oauth.get('expiresAt', 0)
793
912
  now_ms = time.time() * 1000
794
- if expires_at_ms and now_ms > expires_at_ms: return 'expired'
913
+ if expires_at_ms and now_ms > expires_at_ms - 300000:
914
+ new_tok = try_refresh_daemon(name, cred_path)
915
+ if new_tok:
916
+ tok = new_tok
917
+ elif now_ms > expires_at_ms:
918
+ return 'expired'
795
919
 
796
920
  c = load_cache()
797
921
  entry = c.get(name)
@@ -835,8 +959,18 @@ def main():
835
959
  check_single_instance()
836
960
  rotate_log()
837
961
  log_event('start')
962
+ last_refresh_ts = 0
838
963
 
839
964
  while True:
965
+ # Proactive refresh: every 30 minutes, refresh all account tokens
966
+ if time.time() - last_refresh_ts > 1800:
967
+ if os.path.isdir(CREDS_DIR):
968
+ for cred_file in os.listdir(CREDS_DIR):
969
+ if cred_file.endswith('.json'):
970
+ acct = cred_file[:-5]
971
+ try_refresh_daemon(acct, os.path.join(CREDS_DIR, cred_file))
972
+ last_refresh_ts = time.time()
973
+
840
974
  cfg = load_config()
841
975
  if not cfg:
842
976
  time.sleep(60); continue
@@ -1431,6 +1565,7 @@ cmd_help() {
1431
1565
  printf " %-32s %s\n" " relay add <name>" "add account via browser login"
1432
1566
  printf " %-32s %s\n" " relay add-force <name>" "force re-login for existing account"
1433
1567
  printf " %-32s %s\n" " relay refresh <name>" "re-login to refresh an expired token"
1568
+ printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
1434
1569
  printf " %-32s %s\n" " relay save <name>" "save current login state"
1435
1570
  printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
1436
1571
  printf " %-32s %s\n" " relay list" "full list with weekly usage"
@@ -1466,6 +1601,7 @@ case "${CMD}" in
1466
1601
  cmd_add "$@" ;;
1467
1602
  save) cmd_save "$@" ;;
1468
1603
  refresh) cmd_refresh "$@" ;;
1604
+ refresh-all) cmd_refresh_all ;;
1469
1605
  switch|sw|use)
1470
1606
  if [[ -z "${1:-}" ]]; then cmd_quick
1471
1607
  elif [[ "${1}" =~ ^[0-9]+$ ]]; then
package/relay.ps1 CHANGED
@@ -120,6 +120,27 @@ function Get-Usage($tok) {
120
120
  } catch { return $null }
121
121
  }
122
122
 
123
+ # ponytail: intentional copy of try_refresh() — relay.ps1 is a standalone Windows script
124
+ function Invoke-TokenRefresh($credPath) {
125
+ try {
126
+ $d = Get-Content $credPath -Raw | ConvertFrom-Json
127
+ $oauth = $d.claudeAiOauth
128
+ $rt = $oauth.refreshToken
129
+ if (-not $rt) { return $null }
130
+ $body = "grant_type=refresh_token&refresh_token=$([System.Uri]::EscapeDataString($rt))"
131
+ $resp = Invoke-RestMethod "https://api.anthropic.com/token" -Method Post `
132
+ -Body $body -ContentType "application/x-www-form-urlencoded" `
133
+ -Headers @{ "User-Agent" = "relay/2.0" } -TimeoutSec 10
134
+ $oauth.accessToken = $resp.access_token
135
+ if ($resp.refresh_token) { $oauth.refreshToken = $resp.refresh_token }
136
+ $expiresMs = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + ($resp.expires_in ?? 3600) * 1000
137
+ $oauth | Add-Member -NotePropertyName expiresAt -NotePropertyValue $expiresMs -Force
138
+ $d.claudeAiOauth = $oauth
139
+ $d | ConvertTo-Json -Depth 10 | Set-Content $credPath -Encoding UTF8
140
+ return $resp.access_token
141
+ } catch { return $null }
142
+ }
143
+
123
144
  function New-Bar([int]$pct, [int]$w = 10) {
124
145
  $f = [int]([Math]::Round($pct / 100.0 * $w))
125
146
  $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
@@ -174,8 +195,22 @@ function Show-Table([string]$mode, [bool]$noUsage = $false) {
174
195
  if (-not $noUsage) {
175
196
  Write-Host " ${D}fetching usage...${R}" -NoNewline
176
197
  foreach ($n in $names) {
177
- $tok = Get-Token (Get-Content (Get-CredsPath $n) -Raw -ErrorAction SilentlyContinue)
178
- $usage[$n] = if ($tok) { Get-Usage $tok } else { $null }
198
+ $credPath = Get-CredsPath $n
199
+ $raw = Get-Content $credPath -Raw -ErrorAction SilentlyContinue
200
+ $tok = Get-Token $raw
201
+ if ($tok) {
202
+ # Pre-emptive: try refresh if within 5 minutes of expiry
203
+ try {
204
+ $oauth = ($raw | ConvertFrom-Json).claudeAiOauth
205
+ $expiresAt = $oauth.expiresAt
206
+ $nowMs = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
207
+ if ($expiresAt -and $nowMs -gt ($expiresAt - 300000)) {
208
+ $newTok = Invoke-TokenRefresh $credPath
209
+ if ($newTok) { $tok = $newTok }
210
+ }
211
+ } catch {}
212
+ $usage[$n] = Get-Usage $tok
213
+ } else { $usage[$n] = $null }
179
214
  }
180
215
  Write-Host "`r$(' ' * 25)`r" -NoNewline
181
216
  }