@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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/relay +146 -10
- 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
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_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
|
|
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
|
}
|