@dst-justin/relay 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/relay +172 -23
  4. package/relay.ps1 +37 -2
package/README.md CHANGED
@@ -229,6 +229,15 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
229
229
  - `relay update` now detects original install method: uses `npm install -g` for npm installs, `git pull` for git clones, and direct GitHub download for bare script copies
230
230
  - `npm install -g` post-install script automatically patches `~/.bashrc`, `~/.zshrc`, `~/.profile`, and fish `config.fish` if the npm bin dir is missing from PATH
231
231
 
232
+ ### v2.1.3 — 2026-06-24
233
+ - `relay autoswitch` with no subcommand: auto-routes to config wizard (first time) or status panel (already configured)
234
+ - Autoswitch status and log panels now show version info and update notice at the bottom
235
+ - Status panel shows available commands inline
236
+
237
+ ### v2.1.2 — 2026-06-24
238
+ - Improve autoswitch config wizard: 3-step flow with numbered account list, space-separated number input for order, visual chain preview (`work → personal → (cycle)`), and summary after save
239
+ - `relay update` now writes the live-fetched version to cache immediately, so display commands reflect the latest version without waiting 24h
240
+
232
241
  ### v2.1.0 — 2026-06-24
233
242
  - Upgraded GitHub Actions workflow to `actions/checkout@v6` and `actions/setup-node@v6` (Node 24 runtime, removes Node 20 deprecation warning)
234
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
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_DIR}" -maxdepth 1 -name '*.json' 2>/dev/null | sort)
619
+ [[ ${#names[@]} -eq 0 ]] && { warn "No accounts found"; return 0; }
620
+ "${PY}" - "${CREDS_DIR}" "${#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
@@ -986,7 +1120,7 @@ print(' → '.join(names) + ' → (cycle)')")
986
1120
  echo ""
987
1121
  ok "Config saved to ${cfg_file}"
988
1122
  echo ""
989
- "${PY}" -c "
1123
+ "${PY}" - "${cfg_file}" <<'PYEOF'
990
1124
  import json, sys
991
1125
  cfg = json.load(open(sys.argv[1]))
992
1126
  R='\033[0m'; B='\033[1m'; D='\033[2m'; CY='\033[36m'
@@ -994,8 +1128,9 @@ for i, name in enumerate(cfg['order'], 1):
994
1128
  thr = cfg['thresholds'].get(name, '?')
995
1129
  print(f' {D}{i}.{R} {name:<14} → switch at {CY}{thr}%{R}')
996
1130
  p = cfg['poll']
997
- print(f\"\n {D}polling: {p[\"low_minutes\"]}min normal / {p[\"high_minutes\"]}min fast (fast above {p[\"high_threshold\"]}%){R}\")
998
- " "${cfg_file}"
1131
+ lo, hi, thr = p['low_minutes'], p['high_minutes'], p['high_threshold']
1132
+ print(f'\n {D}polling: {lo}min normal / {hi}min fast (fast above {thr}%){R}')
1133
+ PYEOF
999
1134
  echo ""
1000
1135
  log "Run ${CY}relay autoswitch start${R} to activate"
1001
1136
  }
@@ -1094,6 +1229,7 @@ cmd_autoswitch_stop() {
1094
1229
  }
1095
1230
 
1096
1231
  cmd_autoswitch_status() {
1232
+ _check_update_bg
1097
1233
  hdr "autoswitch — status"
1098
1234
 
1099
1235
  local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
@@ -1106,6 +1242,7 @@ cmd_autoswitch_status() {
1106
1242
  local cfg="${RELAY_DIR}/autoswitch.json"
1107
1243
  if [[ ! -f "${cfg}" ]]; then
1108
1244
  warn "No config. Run: relay autoswitch config"
1245
+ _show_update_notice
1109
1246
  return 0
1110
1247
  fi
1111
1248
 
@@ -1173,12 +1310,18 @@ except: pass
1173
1310
  EOF
1174
1311
 
1175
1312
  echo ""
1176
- log "Run ${CY}relay autoswitch log${R} to see full history"
1313
+ printf " ${D}────────────────────────────────────────${R}\n"
1314
+ printf " %-34s %s\n" " ${CY}relay autoswitch start${R}" "start daemon"
1315
+ printf " %-34s %s\n" " ${CY}relay autoswitch stop${R}" "stop daemon"
1316
+ printf " %-34s %s\n" " ${CY}relay autoswitch config${R}" "edit settings"
1317
+ printf " %-34s %s\n" " ${CY}relay autoswitch log${R}" "switch history"
1318
+ _show_update_notice
1177
1319
  }
1178
1320
 
1179
1321
  cmd_autoswitch_log() {
1322
+ _check_update_bg
1180
1323
  local log_file="${RELAY_DIR}/autoswitch.log"
1181
- [[ -f "${log_file}" ]] || { warn "No log yet"; return 0; }
1324
+ [[ -f "${log_file}" ]] || { warn "No log yet"; _show_update_notice; return 0; }
1182
1325
 
1183
1326
  hdr "autoswitch — log (last 20 events)"
1184
1327
  "${PY}" - "${log_file}" <<'EOF'
@@ -1201,6 +1344,7 @@ for line in lines[-20:]:
1201
1344
  print(f' {D}{ts}{R} {D}daemon start{R}')
1202
1345
  except: pass
1203
1346
  EOF
1347
+ _show_update_notice
1204
1348
  }
1205
1349
 
1206
1350
  cmd_autoswitch() {
@@ -1212,13 +1356,11 @@ cmd_autoswitch() {
1212
1356
  status) cmd_autoswitch_status ;;
1213
1357
  log) cmd_autoswitch_log ;;
1214
1358
  *)
1215
- hdr "autoswitch"
1216
- printf " %-32s %s\n" " relay autoswitch config" "interactive setup wizard"
1217
- printf " %-32s %s\n" " relay autoswitch start" "install and start daemon"
1218
- printf " %-32s %s\n" " relay autoswitch stop" "stop and remove daemon"
1219
- printf " %-32s %s\n" " relay autoswitch status" "show daemon state + config"
1220
- printf " %-32s %s\n" " relay autoswitch log" "show recent switch history"
1221
- echo ""
1359
+ if [[ ! -f "${RELAY_DIR}/autoswitch.json" ]]; then
1360
+ cmd_autoswitch_config
1361
+ else
1362
+ cmd_autoswitch_status
1363
+ fi
1222
1364
  ;;
1223
1365
  esac
1224
1366
  }
@@ -1335,7 +1477,12 @@ PYEOF
1335
1477
  warn "Could not determine latest version — check your network"
1336
1478
  log "To update manually: ${CY}npm install -g @dst-justin/relay@latest${R}"
1337
1479
  return 1
1338
- elif [[ "${current}" == "${latest}" ]]; then
1480
+ fi
1481
+
1482
+ # Refresh cache with this live result so display commands reflect it immediately
1483
+ printf '%s:%s' "$(date +%s)" "${latest}" > "${UPDATE_CACHE}" 2>/dev/null || true
1484
+
1485
+ if [[ "${current}" == "${latest}" ]]; then
1339
1486
  ok "Already up to date (${current})"; return 0
1340
1487
  else
1341
1488
  log "Latest available: ${B}${latest}${R}"
@@ -1418,6 +1565,7 @@ cmd_help() {
1418
1565
  printf " %-32s %s\n" " relay add <name>" "add account via browser login"
1419
1566
  printf " %-32s %s\n" " relay add-force <name>" "force re-login for existing account"
1420
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"
1421
1569
  printf " %-32s %s\n" " relay save <name>" "save current login state"
1422
1570
  printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
1423
1571
  printf " %-32s %s\n" " relay list" "full list with weekly usage"
@@ -1453,6 +1601,7 @@ case "${CMD}" in
1453
1601
  cmd_add "$@" ;;
1454
1602
  save) cmd_save "$@" ;;
1455
1603
  refresh) cmd_refresh "$@" ;;
1604
+ refresh-all) cmd_refresh_all ;;
1456
1605
  switch|sw|use)
1457
1606
  if [[ -z "${1:-}" ]]; then cmd_quick
1458
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
  }