@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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/relay +172 -23
- 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
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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}" -
|
|
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
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
-
$
|
|
178
|
-
$
|
|
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
|
}
|