@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.
Files changed (4) hide show
  1. package/README.md +32 -0
  2. package/package.json +3 -2
  3. package/postinstall.js +84 -0
  4. 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.0.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() { _sync_current_creds; render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"; }
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 npm_cmd; npm_cmd=$(command -v npm 2>/dev/null)
709
- if [[ -n "${npm_cmd}" ]]; then
710
- log "Installing via npm..."
711
- npm install -g @dst-justin/relay@latest
712
- ok "Updated to $(_read_version)"
713
- elif [[ -d "${relay_dir}/.git" ]]; then
714
- log "npm not found updating via git..."
715
- git -C "${relay_dir}" pull
716
- else
717
- err "Cannot update: npm not found and no .git directory"
718
- log "Install via npm: ${CY}npm install -g @dst-justin/relay@latest${R}"
719
- fi
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 ;;