@dst-justin/relay 2.0.2 → 2.1.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/README.md +32 -0
- package/package.json +3 -2
- package/postinstall.js +84 -0
- package/relay +644 -13
package/README.md
CHANGED
|
@@ -161,6 +161,38 @@ Prefix commands with `!` to run them inline:
|
|
|
161
161
|
| `relay version` | Show current version |
|
|
162
162
|
| `relay update` | Check GitHub releases and update to the latest version |
|
|
163
163
|
| `relay uninstall` | Remove relay and all account data (macOS/Linux only) |
|
|
164
|
+
| `relay autoswitch config` | Interactive setup wizard |
|
|
165
|
+
| `relay autoswitch start` | Install and start background daemon |
|
|
166
|
+
| `relay autoswitch stop` | Stop and remove daemon |
|
|
167
|
+
| `relay autoswitch status` | Daemon state and per-account thresholds |
|
|
168
|
+
| `relay autoswitch log` | Recent auto-switch history |
|
|
169
|
+
|
|
170
|
+
## Autoswitch
|
|
171
|
+
|
|
172
|
+
relay can automatically switch accounts when a 5-hour usage threshold is hit.
|
|
173
|
+
|
|
174
|
+
**Setup:**
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
relay autoswitch config # interactive wizard
|
|
178
|
+
relay autoswitch start # install daemon (launchd / systemd / cron)
|
|
179
|
+
relay autoswitch status # verify it's running
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Config file** (`~/.claude-relay/autoswitch.json`):
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"order": ["work", "personal", "backup"],
|
|
187
|
+
"thresholds": { "work": 70, "personal": 80 },
|
|
188
|
+
"poll": { "low_minutes": 10, "high_minutes": 2, "high_threshold": 50 }
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- Only accounts listed in `order` with a `thresholds` entry participate.
|
|
193
|
+
- No config file = autoswitch disabled entirely.
|
|
194
|
+
- Manual switches (`!relay work`) are respected until that account hits its threshold.
|
|
195
|
+
- If all accounts are over threshold, relay switches to the least-used one.
|
|
164
196
|
|
|
165
197
|
## How It Works
|
|
166
198
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dst-justin/relay",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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"
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"relay.js",
|
|
11
11
|
"relay.ps1",
|
|
12
12
|
"relay.cmd",
|
|
13
|
+
"postinstall.js",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
|
-
"postinstall": "node -e \"if(process.platform!=='win32'){try{require('fs').chmodSync(require('path').join(__dirname,'relay'),'755')}catch(_){}}\""
|
|
17
|
+
"postinstall": "node -e \"if(process.platform!=='win32'){try{require('fs').chmodSync(require('path').join(__dirname,'relay'),'755')}catch(_){}}\" && node postinstall.js"
|
|
17
18
|
},
|
|
18
19
|
"engines": {
|
|
19
20
|
"node": ">=16"
|
package/postinstall.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs after `npm install -g @dst-justin/relay`.
|
|
3
|
+
// If the npm global bin dir is not in PATH, appends it to shell config files.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } = require('fs');
|
|
8
|
+
const { join } = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// ── Windows ───────────────────────────────────────────────────────────────────
|
|
12
|
+
// npm on Windows already manages PATH via the installer; nothing to do here.
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
// Verify relay.js can reach relay.ps1 (sanity check)
|
|
15
|
+
if (!existsSync(join(__dirname, 'relay.ps1'))) {
|
|
16
|
+
console.warn(' relay: warning — relay.ps1 not found, Windows support may be broken');
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Get npm global bin dir ────────────────────────────────────────────────────
|
|
22
|
+
// npm sets npm_config_prefix during install; fall back to `npm prefix -g`
|
|
23
|
+
let prefix = process.env.npm_config_prefix;
|
|
24
|
+
if (!prefix) {
|
|
25
|
+
try {
|
|
26
|
+
prefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
27
|
+
} catch (_) {
|
|
28
|
+
process.exit(0); // can't determine, give up silently
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const binDir = join(prefix, 'bin');
|
|
32
|
+
|
|
33
|
+
// Already in PATH — nothing to do
|
|
34
|
+
const pathDirs = (process.env.PATH || '').split(':');
|
|
35
|
+
if (pathDirs.includes(binDir)) process.exit(0);
|
|
36
|
+
|
|
37
|
+
const home = os.homedir();
|
|
38
|
+
|
|
39
|
+
// ── POSIX shells: bash / zsh / sh ────────────────────────────────────────────
|
|
40
|
+
const exportLine = `export PATH="${binDir}:$PATH"`;
|
|
41
|
+
const marker = '# added by relay';
|
|
42
|
+
const block = `\n${exportLine} ${marker}\n`;
|
|
43
|
+
|
|
44
|
+
const rcFiles = ['.bashrc', '.zshrc', '.profile'].map(f => join(home, f));
|
|
45
|
+
let patched = false;
|
|
46
|
+
|
|
47
|
+
for (const rc of rcFiles) {
|
|
48
|
+
if (!existsSync(rc)) continue;
|
|
49
|
+
try {
|
|
50
|
+
if (readFileSync(rc, 'utf8').includes(marker)) continue; // idempotent
|
|
51
|
+
appendFileSync(rc, block);
|
|
52
|
+
console.log(` relay: added ${binDir} to PATH in ~/${require('path').basename(rc)}`);
|
|
53
|
+
patched = true;
|
|
54
|
+
} catch (_) {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// If no shell rc found, create ~/.bashrc as a last resort
|
|
58
|
+
if (!patched) {
|
|
59
|
+
const bashrc = join(home, '.bashrc');
|
|
60
|
+
try {
|
|
61
|
+
appendFileSync(bashrc, block);
|
|
62
|
+
console.log(` relay: created ~/.bashrc with PATH entry for ${binDir}`);
|
|
63
|
+
patched = true;
|
|
64
|
+
} catch (_) {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Fish shell ────────────────────────────────────────────────────────────────
|
|
68
|
+
const fishConfig = join(home, '.config', 'fish', 'config.fish');
|
|
69
|
+
const fishMarker = '# added by relay';
|
|
70
|
+
const fishLine = `\nset -gx PATH "${binDir}" $PATH ${fishMarker}\n`;
|
|
71
|
+
|
|
72
|
+
if (existsSync(fishConfig)) {
|
|
73
|
+
try {
|
|
74
|
+
if (!readFileSync(fishConfig, 'utf8').includes(fishMarker)) {
|
|
75
|
+
appendFileSync(fishConfig, fishLine);
|
|
76
|
+
console.log(` relay: added ${binDir} to PATH in ~/.config/fish/config.fish`);
|
|
77
|
+
patched = true;
|
|
78
|
+
}
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (patched) {
|
|
83
|
+
console.log(` relay: restart your shell (or 'source ~/.bashrc' / 'source ~/.zshrc') then try: relay list`);
|
|
84
|
+
}
|
package/relay
CHANGED
|
@@ -12,6 +12,7 @@ RELAY_DIR="${HOME}/.claude-relay"
|
|
|
12
12
|
CREDS_STORE="${RELAY_DIR}/credentials"
|
|
13
13
|
META_STORE="${RELAY_DIR}/meta"
|
|
14
14
|
CURRENT_FILE="${RELAY_DIR}/current"
|
|
15
|
+
UPDATE_CACHE="${RELAY_DIR}/.update_cache"
|
|
15
16
|
CLAUDE_DIR="${HOME}/.claude"
|
|
16
17
|
CLAUDE_JSON="${HOME}/.claude.json"
|
|
17
18
|
REAL_CLAUDE=$(command -v claude 2>/dev/null || echo "")
|
|
@@ -353,6 +354,9 @@ do_switch() {
|
|
|
353
354
|
local email; email=$(get_meta_email "${name}")
|
|
354
355
|
printf "\n ${GR}${B}✓ switched → %s${R} ${D}%s${R}\n" "${name}" "${email}"
|
|
355
356
|
printf " ${D}Restart claude to apply. Resume last session: ${CY}claude -c${R}\n\n"
|
|
357
|
+
|
|
358
|
+
# ponytail: sentinel lets autoswitch daemon skip this account until threshold hit
|
|
359
|
+
printf '{"account":"%s","ts":%s}' "${name}" "$(date +%s)" > "${RELAY_DIR}/manual_switch"
|
|
356
360
|
}
|
|
357
361
|
|
|
358
362
|
# ══════════════════════════════════════════════════════════════════
|
|
@@ -368,14 +372,21 @@ _sync_current_creds() {
|
|
|
368
372
|
[[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
|
|
369
373
|
}
|
|
370
374
|
|
|
371
|
-
cmd_quick() {
|
|
375
|
+
cmd_quick() {
|
|
376
|
+
_check_update_bg
|
|
377
|
+
_sync_current_creds
|
|
378
|
+
render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
|
|
379
|
+
_show_update_notice
|
|
380
|
+
}
|
|
372
381
|
|
|
373
382
|
cmd_list() {
|
|
383
|
+
_check_update_bg
|
|
374
384
|
hdr "Account List"
|
|
375
385
|
_sync_current_creds
|
|
376
386
|
render_table full "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
|
|
377
387
|
echo ""
|
|
378
388
|
ok "Inside Claude Code: ${CY}!relay <index>${R} to switch"
|
|
389
|
+
_show_update_notice
|
|
379
390
|
}
|
|
380
391
|
|
|
381
392
|
cmd_status() {
|
|
@@ -447,6 +458,8 @@ EOF
|
|
|
447
458
|
[[ -d "${CLAUDE_DIR}/projects" ]] && \
|
|
448
459
|
n=$(find "${CLAUDE_DIR}/projects" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ')
|
|
449
460
|
printf "\n ${B}Sessions:${R} %s (shared across all accounts in ~/.claude/projects/)\n" "${n}"
|
|
461
|
+
_check_update_bg
|
|
462
|
+
_show_update_notice
|
|
450
463
|
}
|
|
451
464
|
|
|
452
465
|
cmd_add() {
|
|
@@ -596,6 +609,8 @@ print()
|
|
|
596
609
|
print(f' {total} session(s)' if total else ' No sessions found')
|
|
597
610
|
EOF
|
|
598
611
|
log "${CY}claude -c${R} resume last ${D}|${R} ${CY}claude -r${R} pick one ${D}|${R} ${CY}claude --resume <id>${R}"
|
|
612
|
+
_check_update_bg
|
|
613
|
+
_show_update_notice
|
|
599
614
|
}
|
|
600
615
|
|
|
601
616
|
cmd_install() {
|
|
@@ -648,6 +663,527 @@ cmd_rename() {
|
|
|
648
663
|
ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
|
|
649
664
|
}
|
|
650
665
|
|
|
666
|
+
AUTOSWITCH_DAEMON="${RELAY_DIR}/autoswitch-daemon.py"
|
|
667
|
+
AUTOSWITCH_PLIST="${HOME}/Library/LaunchAgents/com.relay.autoswitch.plist"
|
|
668
|
+
AUTOSWITCH_SERVICE="${HOME}/.config/systemd/user/relay-autoswitch.service"
|
|
669
|
+
|
|
670
|
+
_extract_daemon() {
|
|
671
|
+
cat > "${AUTOSWITCH_DAEMON}" <<'DAEMON_EOF'
|
|
672
|
+
#!/usr/bin/env python3
|
|
673
|
+
"""relay autoswitch daemon — runs in background, switches accounts by usage threshold."""
|
|
674
|
+
import json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal
|
|
675
|
+
|
|
676
|
+
RELAY_DIR = os.path.expanduser('~/.claude-relay')
|
|
677
|
+
CONFIG_FILE = os.path.join(RELAY_DIR, 'autoswitch.json')
|
|
678
|
+
LOCK_FILE = os.path.join(RELAY_DIR, 'autoswitch.lock')
|
|
679
|
+
LOG_FILE = os.path.join(RELAY_DIR, 'autoswitch.log')
|
|
680
|
+
MANUAL_FILE = os.path.join(RELAY_DIR, 'manual_switch')
|
|
681
|
+
CURRENT_FILE = os.path.join(RELAY_DIR, 'current')
|
|
682
|
+
CREDS_DIR = os.path.join(RELAY_DIR, 'credentials')
|
|
683
|
+
CACHE_FILE = os.path.join(RELAY_DIR, 'usage_cache.json')
|
|
684
|
+
CACHE_TTL = 120 # seconds — same as render_table
|
|
685
|
+
|
|
686
|
+
# ── lock ──────────────────────────────────────────────────────────
|
|
687
|
+
def write_lock():
|
|
688
|
+
with open(LOCK_FILE, 'w') as f: f.write(str(os.getpid()))
|
|
689
|
+
|
|
690
|
+
def remove_lock():
|
|
691
|
+
try: os.remove(LOCK_FILE)
|
|
692
|
+
except: pass
|
|
693
|
+
|
|
694
|
+
def lock_pid():
|
|
695
|
+
try: return int(open(LOCK_FILE).read().strip())
|
|
696
|
+
except: return None
|
|
697
|
+
|
|
698
|
+
def is_running(pid):
|
|
699
|
+
try: os.kill(pid, 0); return True
|
|
700
|
+
except: return False
|
|
701
|
+
|
|
702
|
+
def check_single_instance():
|
|
703
|
+
pid = lock_pid()
|
|
704
|
+
if pid and is_running(pid):
|
|
705
|
+
print(f'daemon already running (pid {pid})', file=sys.stderr); sys.exit(1)
|
|
706
|
+
write_lock()
|
|
707
|
+
|
|
708
|
+
signal.signal(signal.SIGTERM, lambda *_: (remove_lock(), sys.exit(0)))
|
|
709
|
+
|
|
710
|
+
# ── log ───────────────────────────────────────────────────────────
|
|
711
|
+
def rotate_log():
|
|
712
|
+
"""Keep last 200 lines if log exceeds 500 lines. Called once at startup."""
|
|
713
|
+
try:
|
|
714
|
+
lines = open(LOG_FILE).readlines()
|
|
715
|
+
if len(lines) > 500:
|
|
716
|
+
with open(LOG_FILE, 'w') as f: f.writelines(lines[-200:])
|
|
717
|
+
except: pass
|
|
718
|
+
|
|
719
|
+
def log_event(event, **kwargs):
|
|
720
|
+
entry = {'ts': int(time.time()), 'event': event, **kwargs}
|
|
721
|
+
with open(LOG_FILE, 'a') as f: f.write(json.dumps(entry) + '\n')
|
|
722
|
+
|
|
723
|
+
# ── notify ────────────────────────────────────────────────────────
|
|
724
|
+
def notify(title, msg):
|
|
725
|
+
try:
|
|
726
|
+
p = platform.system()
|
|
727
|
+
if p == 'Darwin':
|
|
728
|
+
subprocess.run(['osascript', '-e',
|
|
729
|
+
f'display notification "{msg}" with title "{title}"'],
|
|
730
|
+
capture_output=True, timeout=3)
|
|
731
|
+
elif p == 'Linux':
|
|
732
|
+
subprocess.run(['notify-send', title, msg],
|
|
733
|
+
capture_output=True, timeout=3)
|
|
734
|
+
# Windows: called from .ps1 wrapper, not this script
|
|
735
|
+
except: pass
|
|
736
|
+
|
|
737
|
+
# ── credentials ───────────────────────────────────────────────────
|
|
738
|
+
def kc_read():
|
|
739
|
+
p = platform.system()
|
|
740
|
+
if p == 'Darwin':
|
|
741
|
+
r = subprocess.run(['security', 'find-generic-password',
|
|
742
|
+
'-s', 'Claude Code-credentials', '-a', subprocess.run(
|
|
743
|
+
['whoami'], capture_output=True, text=True).stdout.strip(), '-w'],
|
|
744
|
+
capture_output=True, text=True)
|
|
745
|
+
return r.stdout.strip() if r.returncode == 0 else ''
|
|
746
|
+
else:
|
|
747
|
+
live = os.path.join(os.path.expanduser('~'), '.claude', '.credentials.json')
|
|
748
|
+
try: return open(live).read()
|
|
749
|
+
except: return ''
|
|
750
|
+
|
|
751
|
+
def kc_write(content):
|
|
752
|
+
p = platform.system()
|
|
753
|
+
if p == 'Darwin':
|
|
754
|
+
user = subprocess.run(['whoami'], capture_output=True, text=True).stdout.strip()
|
|
755
|
+
svc = 'Claude Code-credentials'
|
|
756
|
+
subprocess.run(['security', 'delete-generic-password', '-s', svc, '-a', user], capture_output=True)
|
|
757
|
+
subprocess.run(['security', 'add-generic-password', '-s', svc, '-a', user, '-w', content], capture_output=True)
|
|
758
|
+
else:
|
|
759
|
+
live = os.path.join(os.path.expanduser('~'), '.claude', '.credentials.json')
|
|
760
|
+
with open(live, 'w') as f: f.write(content)
|
|
761
|
+
os.chmod(live, 0o600)
|
|
762
|
+
|
|
763
|
+
def do_switch(name):
|
|
764
|
+
cred = os.path.join(CREDS_DIR, name + '.json')
|
|
765
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
766
|
+
if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
|
|
767
|
+
live = kc_read()
|
|
768
|
+
if live:
|
|
769
|
+
with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
|
|
770
|
+
with open(CURRENT_FILE, 'w') as f: f.write(name)
|
|
771
|
+
content = open(cred).read()
|
|
772
|
+
kc_write(content)
|
|
773
|
+
|
|
774
|
+
# ── usage fetch ───────────────────────────────────────────────────
|
|
775
|
+
def load_cache():
|
|
776
|
+
try: return json.load(open(CACHE_FILE))
|
|
777
|
+
except: return {}
|
|
778
|
+
|
|
779
|
+
def save_cache(c):
|
|
780
|
+
try:
|
|
781
|
+
with open(CACHE_FILE, 'w') as f: json.dump(c, f)
|
|
782
|
+
except: pass
|
|
783
|
+
|
|
784
|
+
def fetch_usage(name):
|
|
785
|
+
cred_path = os.path.join(CREDS_DIR, name + '.json')
|
|
786
|
+
try:
|
|
787
|
+
d = json.load(open(cred_path))
|
|
788
|
+
oauth = d.get('claudeAiOauth') or {}
|
|
789
|
+
tok = oauth.get('accessToken', '')
|
|
790
|
+
if not tok: return None
|
|
791
|
+
|
|
792
|
+
expires_at_ms = oauth.get('expiresAt', 0)
|
|
793
|
+
now_ms = time.time() * 1000
|
|
794
|
+
if expires_at_ms and now_ms > expires_at_ms: return 'expired'
|
|
795
|
+
|
|
796
|
+
c = load_cache()
|
|
797
|
+
entry = c.get(name)
|
|
798
|
+
if entry and time.time() - entry.get('ts', 0) < CACHE_TTL:
|
|
799
|
+
return entry.get('data')
|
|
800
|
+
|
|
801
|
+
req = urllib.request.Request(
|
|
802
|
+
'https://api.anthropic.com/api/oauth/usage',
|
|
803
|
+
headers={'Authorization': 'Bearer ' + tok, 'User-Agent': 'relay/2.0'})
|
|
804
|
+
with urllib.request.urlopen(req, timeout=6) as r:
|
|
805
|
+
data = json.loads(r.read())
|
|
806
|
+
c[name] = {'ts': time.time(), 'data': data}
|
|
807
|
+
save_cache(c)
|
|
808
|
+
return data
|
|
809
|
+
except urllib.error.HTTPError as e:
|
|
810
|
+
return 'expired' if e.code == 401 else None
|
|
811
|
+
except: return None
|
|
812
|
+
|
|
813
|
+
def get_utilization(usage_data):
|
|
814
|
+
"""Return 5hr utilization % or None."""
|
|
815
|
+
if not isinstance(usage_data, dict): return None
|
|
816
|
+
fh = usage_data.get('five_hour') or {}
|
|
817
|
+
u = fh.get('utilization')
|
|
818
|
+
return int(u) if u is not None else None
|
|
819
|
+
|
|
820
|
+
# ── manual switch protection ───────────────────────────────────────
|
|
821
|
+
def get_manual_switch():
|
|
822
|
+
try: return json.load(open(MANUAL_FILE))
|
|
823
|
+
except: return None
|
|
824
|
+
|
|
825
|
+
def clear_manual_switch():
|
|
826
|
+
try: os.remove(MANUAL_FILE)
|
|
827
|
+
except: pass
|
|
828
|
+
|
|
829
|
+
# ── main loop ─────────────────────────────────────────────────────
|
|
830
|
+
def load_config():
|
|
831
|
+
try: return json.load(open(CONFIG_FILE))
|
|
832
|
+
except: return None
|
|
833
|
+
|
|
834
|
+
def main():
|
|
835
|
+
check_single_instance()
|
|
836
|
+
rotate_log()
|
|
837
|
+
log_event('start')
|
|
838
|
+
|
|
839
|
+
while True:
|
|
840
|
+
cfg = load_config()
|
|
841
|
+
if not cfg:
|
|
842
|
+
time.sleep(60); continue
|
|
843
|
+
|
|
844
|
+
order = cfg.get('order', [])
|
|
845
|
+
thresholds = cfg.get('thresholds', {})
|
|
846
|
+
poll = cfg.get('poll', {})
|
|
847
|
+
low_min = int(poll.get('low_minutes', 10))
|
|
848
|
+
high_min = int(poll.get('high_minutes', 2))
|
|
849
|
+
high_thr = int(poll.get('high_threshold', 50))
|
|
850
|
+
|
|
851
|
+
if not order:
|
|
852
|
+
time.sleep(60); continue
|
|
853
|
+
|
|
854
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
855
|
+
|
|
856
|
+
usage = {name: fetch_usage(name) for name in order}
|
|
857
|
+
|
|
858
|
+
cur_util = get_utilization(usage.get(current))
|
|
859
|
+
sleep_sec = high_min * 60 if (cur_util is not None and cur_util >= high_thr) else low_min * 60
|
|
860
|
+
|
|
861
|
+
manual = get_manual_switch()
|
|
862
|
+
if manual and manual.get('account') == current:
|
|
863
|
+
threshold = thresholds.get(current)
|
|
864
|
+
if threshold is not None and cur_util is not None and cur_util >= threshold:
|
|
865
|
+
clear_manual_switch()
|
|
866
|
+
else:
|
|
867
|
+
time.sleep(sleep_sec); continue
|
|
868
|
+
|
|
869
|
+
cur_threshold = thresholds.get(current)
|
|
870
|
+
if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
|
|
871
|
+
time.sleep(sleep_sec); continue
|
|
872
|
+
|
|
873
|
+
candidates = [(n, get_utilization(usage.get(n))) for n in order if n != current]
|
|
874
|
+
under = [(n, u) for n, u in candidates if u is not None and thresholds.get(n) is not None and u < thresholds[n]]
|
|
875
|
+
|
|
876
|
+
if under:
|
|
877
|
+
target, target_util = under[0]
|
|
878
|
+
else:
|
|
879
|
+
measured = [(n, u) for n, u in candidates if u is not None]
|
|
880
|
+
if not measured:
|
|
881
|
+
time.sleep(sleep_sec); continue
|
|
882
|
+
target, target_util = min(measured, key=lambda x: x[1])
|
|
883
|
+
log_event('all_over_threshold', selected=target, usage=target_util)
|
|
884
|
+
notify('relay', f'All accounts over threshold — switching to {target} ({target_util}%)')
|
|
885
|
+
do_switch(target)
|
|
886
|
+
time.sleep(sleep_sec); continue
|
|
887
|
+
|
|
888
|
+
log_event('switch', frm=current, to=target, usage=cur_util)
|
|
889
|
+
notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
|
|
890
|
+
do_switch(target)
|
|
891
|
+
time.sleep(sleep_sec)
|
|
892
|
+
|
|
893
|
+
if __name__ == '__main__':
|
|
894
|
+
main()
|
|
895
|
+
DAEMON_EOF
|
|
896
|
+
chmod 755 "${AUTOSWITCH_DAEMON}"
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
cmd_autoswitch_config() {
|
|
900
|
+
hdr "autoswitch — configure"
|
|
901
|
+
|
|
902
|
+
local accounts=()
|
|
903
|
+
local name
|
|
904
|
+
while IFS= read -r name; do accounts+=("${name}"); done < <(list_account_names)
|
|
905
|
+
|
|
906
|
+
if [[ ${#accounts[@]} -eq 0 ]]; then
|
|
907
|
+
err "No accounts found. Run: relay add <name>"
|
|
908
|
+
exit 1
|
|
909
|
+
fi
|
|
910
|
+
|
|
911
|
+
echo ""
|
|
912
|
+
printf " Accounts found: ${CY}%s${R}\n\n" "$(IFS=', '; echo "${accounts[*]}")"
|
|
913
|
+
|
|
914
|
+
printf " ${B}Switch order${R} (comma-separated, only listed accounts join autoswitch):\n"
|
|
915
|
+
printf " > "; read -r order_input
|
|
916
|
+
local order_str; order_str=$(echo "${order_input}" | "${PY}" -c "
|
|
917
|
+
import sys,re
|
|
918
|
+
raw=sys.stdin.read().strip()
|
|
919
|
+
names=[x.strip() for x in raw.split(',') if x.strip()]
|
|
920
|
+
print(','.join(names))")
|
|
921
|
+
|
|
922
|
+
echo ""
|
|
923
|
+
printf " ${B}Thresholds${R} (press enter to skip account — it won't join autoswitch):\n"
|
|
924
|
+
local thresholds_json="{"
|
|
925
|
+
local order_json="["
|
|
926
|
+
local first_order=1
|
|
927
|
+
local first_thresh=1
|
|
928
|
+
IFS=',' read -ra order_arr <<< "${order_str}"
|
|
929
|
+
for acct in "${order_arr[@]}"; do
|
|
930
|
+
printf " %s threshold %% [default 80]: " "${acct}"
|
|
931
|
+
read -r val
|
|
932
|
+
if [[ -z "${val}" ]]; then
|
|
933
|
+
continue
|
|
934
|
+
fi
|
|
935
|
+
[[ ${first_order} -eq 0 ]] && order_json+=","
|
|
936
|
+
order_json+="\"${acct}\""
|
|
937
|
+
first_order=0
|
|
938
|
+
[[ ${first_thresh} -eq 0 ]] && thresholds_json+=","
|
|
939
|
+
thresholds_json+="\"${acct}\":${val}"
|
|
940
|
+
first_thresh=0
|
|
941
|
+
done
|
|
942
|
+
order_json+="]"
|
|
943
|
+
thresholds_json+="}"
|
|
944
|
+
|
|
945
|
+
echo ""
|
|
946
|
+
printf " ${B}Poll interval${R} — low usage (minutes) [default 10]: "
|
|
947
|
+
read -r low_min; low_min="${low_min:-10}"
|
|
948
|
+
printf " ${B}Poll interval${R} — high usage (minutes) [default 2]: "
|
|
949
|
+
read -r high_min; high_min="${high_min:-2}"
|
|
950
|
+
printf " ${B}High usage starts at${R} %% [default 50]: "
|
|
951
|
+
read -r high_thr; high_thr="${high_thr:-50}"
|
|
952
|
+
|
|
953
|
+
local cfg_file="${RELAY_DIR}/autoswitch.json"
|
|
954
|
+
printf '{"order":%s,"thresholds":%s,"poll":{"low_minutes":%s,"high_minutes":%s,"high_threshold":%s}}' \
|
|
955
|
+
"${order_json}" "${thresholds_json}" "${low_min}" "${high_min}" "${high_thr}" \
|
|
956
|
+
| "${PY}" -c "import json,sys; print(json.dumps(json.load(sys.stdin), indent=2))" \
|
|
957
|
+
> "${cfg_file}"
|
|
958
|
+
|
|
959
|
+
echo ""
|
|
960
|
+
ok "Saved to ${cfg_file}"
|
|
961
|
+
log "Run ${CY}relay autoswitch start${R} to activate"
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
cmd_autoswitch_start() {
|
|
965
|
+
[[ -f "${RELAY_DIR}/autoswitch.json" ]] || {
|
|
966
|
+
err "No config found. Run: relay autoswitch config"
|
|
967
|
+
exit 1
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
hdr "autoswitch — start"
|
|
971
|
+
_extract_daemon
|
|
972
|
+
log "Daemon extracted to ${AUTOSWITCH_DAEMON}"
|
|
973
|
+
|
|
974
|
+
local py; py=$(command -v python3 || command -v python || echo "")
|
|
975
|
+
[[ -z "${py}" ]] && { err "python3 required"; exit 1; }
|
|
976
|
+
|
|
977
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
978
|
+
mkdir -p "${HOME}/Library/LaunchAgents"
|
|
979
|
+
cat > "${AUTOSWITCH_PLIST}" <<PLIST
|
|
980
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
981
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
982
|
+
<plist version="1.0">
|
|
983
|
+
<dict>
|
|
984
|
+
<key>Label</key><string>com.relay.autoswitch</string>
|
|
985
|
+
<key>ProgramArguments</key>
|
|
986
|
+
<array>
|
|
987
|
+
<string>${py}</string>
|
|
988
|
+
<string>${AUTOSWITCH_DAEMON}</string>
|
|
989
|
+
</array>
|
|
990
|
+
<key>RunAtLoad</key><true/>
|
|
991
|
+
<key>KeepAlive</key><true/>
|
|
992
|
+
<key>StandardOutPath</key><string>${RELAY_DIR}/autoswitch-daemon.log</string>
|
|
993
|
+
<key>StandardErrorPath</key><string>${RELAY_DIR}/autoswitch-daemon.log</string>
|
|
994
|
+
</dict>
|
|
995
|
+
</plist>
|
|
996
|
+
PLIST
|
|
997
|
+
launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
|
|
998
|
+
launchctl load "${AUTOSWITCH_PLIST}"
|
|
999
|
+
ok "Daemon started via launchd"
|
|
1000
|
+
|
|
1001
|
+
elif command -v systemctl >/dev/null 2>&1 && systemctl --user status >/dev/null 2>&1; then
|
|
1002
|
+
mkdir -p "${HOME}/.config/systemd/user"
|
|
1003
|
+
cat > "${AUTOSWITCH_SERVICE}" <<SVCEOF
|
|
1004
|
+
[Unit]
|
|
1005
|
+
Description=relay autoswitch daemon
|
|
1006
|
+
|
|
1007
|
+
[Service]
|
|
1008
|
+
ExecStart=${py} ${AUTOSWITCH_DAEMON}
|
|
1009
|
+
Restart=always
|
|
1010
|
+
RestartSec=5
|
|
1011
|
+
|
|
1012
|
+
[Install]
|
|
1013
|
+
WantedBy=default.target
|
|
1014
|
+
SVCEOF
|
|
1015
|
+
systemctl --user daemon-reload
|
|
1016
|
+
systemctl --user enable --now relay-autoswitch
|
|
1017
|
+
ok "Daemon started via systemd"
|
|
1018
|
+
|
|
1019
|
+
else
|
|
1020
|
+
# ponytail: cron fallback — daemon's lock file prevents overlap
|
|
1021
|
+
local cron_entry="*/2 * * * * ${py} ${AUTOSWITCH_DAEMON}"
|
|
1022
|
+
( crontab -l 2>/dev/null | grep -v "autoswitch-daemon"; echo "${cron_entry}" ) | crontab -
|
|
1023
|
+
ok "Daemon scheduled via cron (every 2 min)"
|
|
1024
|
+
warn "For persistent autoswitch, install systemd or use macOS"
|
|
1025
|
+
fi
|
|
1026
|
+
|
|
1027
|
+
log "Run ${CY}relay autoswitch status${R} to verify"
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
cmd_autoswitch_stop() {
|
|
1031
|
+
hdr "autoswitch — stop"
|
|
1032
|
+
|
|
1033
|
+
if [[ "$(uname)" == "Darwin" ]] && [[ -f "${AUTOSWITCH_PLIST}" ]]; then
|
|
1034
|
+
launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
|
|
1035
|
+
rm -f "${AUTOSWITCH_PLIST}"
|
|
1036
|
+
ok "Removed launchd job"
|
|
1037
|
+
|
|
1038
|
+
elif command -v systemctl >/dev/null 2>&1; then
|
|
1039
|
+
systemctl --user disable --now relay-autoswitch 2>/dev/null || true
|
|
1040
|
+
rm -f "${AUTOSWITCH_SERVICE}"
|
|
1041
|
+
systemctl --user daemon-reload
|
|
1042
|
+
ok "Removed systemd service"
|
|
1043
|
+
|
|
1044
|
+
else
|
|
1045
|
+
crontab -l 2>/dev/null | grep -v "autoswitch-daemon" | crontab -
|
|
1046
|
+
ok "Removed cron entry"
|
|
1047
|
+
fi
|
|
1048
|
+
|
|
1049
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1050
|
+
if [[ -n "${pid}" ]]; then
|
|
1051
|
+
kill "${pid}" 2>/dev/null || true
|
|
1052
|
+
fi
|
|
1053
|
+
rm -f "${RELAY_DIR}/autoswitch.lock"
|
|
1054
|
+
ok "Daemon stopped"
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
cmd_autoswitch_status() {
|
|
1058
|
+
hdr "autoswitch — status"
|
|
1059
|
+
|
|
1060
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1061
|
+
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
|
1062
|
+
printf " ${B}Daemon:${R} ${GR}● running${R} (pid ${pid})\n"
|
|
1063
|
+
else
|
|
1064
|
+
printf " ${B}Daemon:${R} ${D}○ stopped${R}\n"
|
|
1065
|
+
fi
|
|
1066
|
+
|
|
1067
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1068
|
+
if [[ ! -f "${cfg}" ]]; then
|
|
1069
|
+
warn "No config. Run: relay autoswitch config"
|
|
1070
|
+
return 0
|
|
1071
|
+
fi
|
|
1072
|
+
|
|
1073
|
+
printf " ${B}Config:${R} ${D}%s${R}\n\n" "${cfg}"
|
|
1074
|
+
|
|
1075
|
+
local current; current=$(current_name)
|
|
1076
|
+
|
|
1077
|
+
"${PY}" - "${cfg}" "${current}" <<'EOF'
|
|
1078
|
+
import json, sys, os, time
|
|
1079
|
+
|
|
1080
|
+
cfg = json.load(open(sys.argv[1]))
|
|
1081
|
+
current = sys.argv[2]
|
|
1082
|
+
order = cfg.get('order', [])
|
|
1083
|
+
thresholds = cfg.get('thresholds', {})
|
|
1084
|
+
relay_dir = os.path.expanduser('~/.claude-relay')
|
|
1085
|
+
cache_file = os.path.join(relay_dir, 'usage_cache.json')
|
|
1086
|
+
|
|
1087
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'
|
|
1088
|
+
GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
|
|
1089
|
+
|
|
1090
|
+
try: cache = json.load(open(cache_file))
|
|
1091
|
+
except: cache = {}
|
|
1092
|
+
|
|
1093
|
+
def get_util(name):
|
|
1094
|
+
entry = cache.get(name)
|
|
1095
|
+
if not entry: return None
|
|
1096
|
+
data = entry.get('data') if isinstance(entry, dict) else None
|
|
1097
|
+
if not isinstance(data, dict): return None
|
|
1098
|
+
fh = data.get('five_hour') or {}
|
|
1099
|
+
u = fh.get('utilization')
|
|
1100
|
+
return int(u) if u is not None else None
|
|
1101
|
+
|
|
1102
|
+
print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
|
|
1103
|
+
print(f' {D}{"─"*52}{R}')
|
|
1104
|
+
for i, name in enumerate(order, 1):
|
|
1105
|
+
cur = name == current
|
|
1106
|
+
marker = f'{GR}●{R}' if cur else ' '
|
|
1107
|
+
ncol = GR + B if cur else B
|
|
1108
|
+
thr = thresholds.get(name)
|
|
1109
|
+
thr_s = f'{thr}%' if thr is not None else f'{D}skipped{R}'
|
|
1110
|
+
util = get_util(name)
|
|
1111
|
+
util_s = f'{util}%' if util is not None else f'{D}—{R}'
|
|
1112
|
+
over = thr is not None and util is not None and util >= thr
|
|
1113
|
+
if over: util_s += f' {YL}⚠ over{R}'
|
|
1114
|
+
next_s = ''
|
|
1115
|
+
if not cur and not over:
|
|
1116
|
+
prev_over = all(
|
|
1117
|
+
(thresholds.get(order[j]) is not None and
|
|
1118
|
+
get_util(order[j]) is not None and
|
|
1119
|
+
get_util(order[j]) >= thresholds[order[j]])
|
|
1120
|
+
for j in range(i-1)
|
|
1121
|
+
)
|
|
1122
|
+
if prev_over: next_s = f' {CY}← next{R}'
|
|
1123
|
+
print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}')
|
|
1124
|
+
|
|
1125
|
+
log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
|
|
1126
|
+
try:
|
|
1127
|
+
lines = open(log_file).readlines()
|
|
1128
|
+
for line in reversed(lines):
|
|
1129
|
+
e = json.loads(line)
|
|
1130
|
+
if e.get('event') == 'switch':
|
|
1131
|
+
print(f'\n {B}Last switch:{R} {e["frm"]} → {e["to"]}')
|
|
1132
|
+
break
|
|
1133
|
+
except: pass
|
|
1134
|
+
EOF
|
|
1135
|
+
|
|
1136
|
+
echo ""
|
|
1137
|
+
log "Run ${CY}relay autoswitch log${R} to see full history"
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
cmd_autoswitch_log() {
|
|
1141
|
+
local log_file="${RELAY_DIR}/autoswitch.log"
|
|
1142
|
+
[[ -f "${log_file}" ]] || { warn "No log yet"; return 0; }
|
|
1143
|
+
|
|
1144
|
+
hdr "autoswitch — log (last 20 events)"
|
|
1145
|
+
"${PY}" - "${log_file}" <<'EOF'
|
|
1146
|
+
import json, sys, datetime
|
|
1147
|
+
|
|
1148
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'
|
|
1149
|
+
GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
|
|
1150
|
+
|
|
1151
|
+
lines = open(sys.argv[1]).readlines()
|
|
1152
|
+
for line in lines[-20:]:
|
|
1153
|
+
try:
|
|
1154
|
+
e = json.loads(line)
|
|
1155
|
+
ts = datetime.datetime.fromtimestamp(e['ts']).strftime('%m/%d %H:%M')
|
|
1156
|
+
ev = e['event']
|
|
1157
|
+
if ev == 'switch':
|
|
1158
|
+
print(f' {D}{ts}{R} {GR}switch{R} {e["frm"]} → {CY}{e["to"]}{R} ({e.get("usage","?")}%)')
|
|
1159
|
+
elif ev == 'all_over_threshold':
|
|
1160
|
+
print(f' {D}{ts}{R} {YL}all_over{R} → {CY}{e["selected"]}{R} ({e.get("usage","?")}%) {YL}⚠{R}')
|
|
1161
|
+
elif ev == 'start':
|
|
1162
|
+
print(f' {D}{ts}{R} {D}daemon start{R}')
|
|
1163
|
+
except: pass
|
|
1164
|
+
EOF
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
cmd_autoswitch() {
|
|
1168
|
+
local sub="${1:-}"; [[ $# -gt 0 ]] && shift
|
|
1169
|
+
case "${sub}" in
|
|
1170
|
+
config) cmd_autoswitch_config ;;
|
|
1171
|
+
start) cmd_autoswitch_start ;;
|
|
1172
|
+
stop) cmd_autoswitch_stop ;;
|
|
1173
|
+
status) cmd_autoswitch_status ;;
|
|
1174
|
+
log) cmd_autoswitch_log ;;
|
|
1175
|
+
*)
|
|
1176
|
+
hdr "autoswitch"
|
|
1177
|
+
printf " %-32s %s\n" " relay autoswitch config" "interactive setup wizard"
|
|
1178
|
+
printf " %-32s %s\n" " relay autoswitch start" "install and start daemon"
|
|
1179
|
+
printf " %-32s %s\n" " relay autoswitch stop" "stop and remove daemon"
|
|
1180
|
+
printf " %-32s %s\n" " relay autoswitch status" "show daemon state + config"
|
|
1181
|
+
printf " %-32s %s\n" " relay autoswitch log" "show recent switch history"
|
|
1182
|
+
echo ""
|
|
1183
|
+
;;
|
|
1184
|
+
esac
|
|
1185
|
+
}
|
|
1186
|
+
|
|
651
1187
|
_script_dir() {
|
|
652
1188
|
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
653
1189
|
local src="$0"
|
|
@@ -666,6 +1202,68 @@ cmd_version() {
|
|
|
666
1202
|
printf "relay %s\n" "$(_read_version)"
|
|
667
1203
|
}
|
|
668
1204
|
|
|
1205
|
+
# ── Update notification helpers ───────────────────────────────────────────────
|
|
1206
|
+
# Cache format: "<epoch>:<version>" TTL = 24h
|
|
1207
|
+
_check_update_bg() {
|
|
1208
|
+
(
|
|
1209
|
+
local ttl=86400
|
|
1210
|
+
if [[ -f "${UPDATE_CACHE}" ]]; then
|
|
1211
|
+
local cached; cached=$(cat "${UPDATE_CACHE}" 2>/dev/null)
|
|
1212
|
+
local ts="${cached%%:*}"
|
|
1213
|
+
local now; now=$(date +%s)
|
|
1214
|
+
[[ $(( now - ts )) -lt ${ttl} ]] && exit 0
|
|
1215
|
+
fi
|
|
1216
|
+
local ver
|
|
1217
|
+
ver=$("${PY}" - 2>/dev/null <<'PYEOF'
|
|
1218
|
+
import urllib.request, json, sys
|
|
1219
|
+
def fetch(url, h={}):
|
|
1220
|
+
r = urllib.request.Request(url, headers=h)
|
|
1221
|
+
with urllib.request.urlopen(r, timeout=6) as resp:
|
|
1222
|
+
return json.loads(resp.read())
|
|
1223
|
+
try:
|
|
1224
|
+
d = fetch('https://api.github.com/repos/darkstar1227/relay/releases/latest',
|
|
1225
|
+
{'User-Agent': 'relay-update'})
|
|
1226
|
+
print(d['tag_name'].lstrip('v')); sys.exit(0)
|
|
1227
|
+
except Exception: pass
|
|
1228
|
+
try:
|
|
1229
|
+
d = fetch('https://registry.npmjs.org/@dst-justin%2frelay/latest')
|
|
1230
|
+
print(d['version'])
|
|
1231
|
+
except Exception: sys.exit(1)
|
|
1232
|
+
PYEOF
|
|
1233
|
+
)
|
|
1234
|
+
[[ -n "${ver}" ]] && printf '%s:%s' "$(date +%s)" "${ver}" > "${UPDATE_CACHE}"
|
|
1235
|
+
) >/dev/null 2>&1 &
|
|
1236
|
+
disown 2>/dev/null || true
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
_show_update_notice() {
|
|
1240
|
+
local current; current=$(_read_version)
|
|
1241
|
+
local latest=""
|
|
1242
|
+
if [[ -f "${UPDATE_CACHE}" ]]; then
|
|
1243
|
+
local cached; cached=$(cat "${UPDATE_CACHE}" 2>/dev/null)
|
|
1244
|
+
latest="${cached#*:}"
|
|
1245
|
+
fi
|
|
1246
|
+
if [[ -n "${latest}" && "${latest}" != "${current}" ]]; then
|
|
1247
|
+
printf "\n ${D}relay version: ${B}${current}${R}${D} → ${CY}${B}${latest}${R}${D} available — run ${CY}relay update${R}${D} to install${R}\n"
|
|
1248
|
+
else
|
|
1249
|
+
local ver_display="${latest:-${current}}"
|
|
1250
|
+
printf "\n ${D}relay version: ${B}${ver_display}${R}${D} (up to date)${R}\n"
|
|
1251
|
+
fi
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
# Detect how relay was originally installed:
|
|
1255
|
+
# npm — package.json present in script dir (npm unpacks the full package)
|
|
1256
|
+
# git — .git dir present in script dir
|
|
1257
|
+
# direct — bare script copy (no package.json, no .git)
|
|
1258
|
+
_detect_install_method() {
|
|
1259
|
+
local d; d=$(_script_dir)
|
|
1260
|
+
# .git check first: git clone has both .git AND package.json; npm publish strips .git
|
|
1261
|
+
if [[ -d "${d}/.git" ]]; then echo "git"
|
|
1262
|
+
elif [[ -f "${d}/package.json" ]]; then echo "npm"
|
|
1263
|
+
else echo "direct"
|
|
1264
|
+
fi
|
|
1265
|
+
}
|
|
1266
|
+
|
|
669
1267
|
cmd_update() {
|
|
670
1268
|
hdr "Update relay"
|
|
671
1269
|
|
|
@@ -705,18 +1303,43 @@ PYEOF
|
|
|
705
1303
|
fi
|
|
706
1304
|
|
|
707
1305
|
local relay_dir; relay_dir=$(_script_dir)
|
|
708
|
-
local
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
npm
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1306
|
+
local method; method=$(_detect_install_method)
|
|
1307
|
+
|
|
1308
|
+
case "${method}" in
|
|
1309
|
+
npm)
|
|
1310
|
+
local npm_cmd; npm_cmd=$(command -v npm 2>/dev/null)
|
|
1311
|
+
if [[ -n "${npm_cmd}" ]]; then
|
|
1312
|
+
log "Updating via npm (original install method)..."
|
|
1313
|
+
npm install -g @dst-justin/relay@latest
|
|
1314
|
+
ok "Updated to $(_read_version)"
|
|
1315
|
+
else
|
|
1316
|
+
err "npm not found — reinstall npm and retry"
|
|
1317
|
+
log "Or update manually: ${CY}npm install -g @dst-justin/relay@latest${R}"
|
|
1318
|
+
fi ;;
|
|
1319
|
+
git)
|
|
1320
|
+
log "Updating via git pull (original install method)..."
|
|
1321
|
+
git -C "${relay_dir}" pull ;;
|
|
1322
|
+
direct)
|
|
1323
|
+
log "Updating via direct download (original install method)..."
|
|
1324
|
+
local script_path; script_path=$(readlink -f "$0" 2>/dev/null || echo "$0")
|
|
1325
|
+
"${PY}" - "${script_path}" "${latest}" <<'PYEOF'
|
|
1326
|
+
import urllib.request, sys, os, stat
|
|
1327
|
+
script_path, version = sys.argv[1], sys.argv[2]
|
|
1328
|
+
url = f'https://raw.githubusercontent.com/darkstar1227/relay/v{version}/relay'
|
|
1329
|
+
try:
|
|
1330
|
+
with urllib.request.urlopen(url, timeout=15) as r:
|
|
1331
|
+
content = r.read()
|
|
1332
|
+
tmp = script_path + '.tmp'
|
|
1333
|
+
with open(tmp, 'wb') as f: f.write(content)
|
|
1334
|
+
os.chmod(tmp, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
|
1335
|
+
os.replace(tmp, script_path)
|
|
1336
|
+
except Exception as e:
|
|
1337
|
+
print(f' download failed: {e}', file=sys.stderr); sys.exit(1)
|
|
1338
|
+
PYEOF
|
|
1339
|
+
ok "Updated ${script_path} to ${latest}" ;;
|
|
1340
|
+
esac
|
|
1341
|
+
# Invalidate update cache so next display shows fresh state
|
|
1342
|
+
rm -f "${UPDATE_CACHE}" 2>/dev/null || true
|
|
720
1343
|
}
|
|
721
1344
|
|
|
722
1345
|
cmd_uninstall() {
|
|
@@ -766,8 +1389,15 @@ cmd_help() {
|
|
|
766
1389
|
printf " %-32s %s\n" " relay update" "update to latest version"
|
|
767
1390
|
printf " %-32s %s\n" " relay uninstall" "remove relay and all account data"
|
|
768
1391
|
echo ""
|
|
1392
|
+
printf " ${B}Autoswitch${R}\n"
|
|
1393
|
+
printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
|
|
1394
|
+
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
1395
|
+
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
1396
|
+
echo ""
|
|
769
1397
|
printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
|
|
770
1398
|
printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
|
|
1399
|
+
_check_update_bg
|
|
1400
|
+
_show_update_notice
|
|
771
1401
|
}
|
|
772
1402
|
|
|
773
1403
|
# ══════════════════════════════════════════════════════════════════
|
|
@@ -798,6 +1428,7 @@ case "${CMD}" in
|
|
|
798
1428
|
remove|rm|del) cmd_remove "$@" ;;
|
|
799
1429
|
rename|mv) cmd_rename "$@" ;;
|
|
800
1430
|
sessions|sess) cmd_sessions ;;
|
|
1431
|
+
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
801
1432
|
version|--version|-V) cmd_version ;;
|
|
802
1433
|
update) cmd_update ;;
|
|
803
1434
|
install) cmd_install ;;
|