@dst-justin/relay 2.1.3 → 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/package.json +1 -1
- package/relay +146 -10
- package/relay.ps1 +37 -2
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
|
|
@@ -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
|
-
$
|
|
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
|
}
|