@dst-justin/relay 2.0.2 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. package/relay +530 -0
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.0",
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"
package/relay CHANGED
@@ -353,6 +353,9 @@ do_switch() {
353
353
  local email; email=$(get_meta_email "${name}")
354
354
  printf "\n ${GR}${B}✓ switched → %s${R} ${D}%s${R}\n" "${name}" "${email}"
355
355
  printf " ${D}Restart claude to apply. Resume last session: ${CY}claude -c${R}\n\n"
356
+
357
+ # ponytail: sentinel lets autoswitch daemon skip this account until threshold hit
358
+ printf '{"account":"%s","ts":%s}' "${name}" "$(date +%s)" > "${RELAY_DIR}/manual_switch"
356
359
  }
357
360
 
358
361
  # ══════════════════════════════════════════════════════════════════
@@ -648,6 +651,527 @@ cmd_rename() {
648
651
  ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
649
652
  }
650
653
 
654
+ AUTOSWITCH_DAEMON="${RELAY_DIR}/autoswitch-daemon.py"
655
+ AUTOSWITCH_PLIST="${HOME}/Library/LaunchAgents/com.relay.autoswitch.plist"
656
+ AUTOSWITCH_SERVICE="${HOME}/.config/systemd/user/relay-autoswitch.service"
657
+
658
+ _extract_daemon() {
659
+ cat > "${AUTOSWITCH_DAEMON}" <<'DAEMON_EOF'
660
+ #!/usr/bin/env python3
661
+ """relay autoswitch daemon — runs in background, switches accounts by usage threshold."""
662
+ import json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal
663
+
664
+ RELAY_DIR = os.path.expanduser('~/.claude-relay')
665
+ CONFIG_FILE = os.path.join(RELAY_DIR, 'autoswitch.json')
666
+ LOCK_FILE = os.path.join(RELAY_DIR, 'autoswitch.lock')
667
+ LOG_FILE = os.path.join(RELAY_DIR, 'autoswitch.log')
668
+ MANUAL_FILE = os.path.join(RELAY_DIR, 'manual_switch')
669
+ CURRENT_FILE = os.path.join(RELAY_DIR, 'current')
670
+ CREDS_DIR = os.path.join(RELAY_DIR, 'credentials')
671
+ CACHE_FILE = os.path.join(RELAY_DIR, 'usage_cache.json')
672
+ CACHE_TTL = 120 # seconds — same as render_table
673
+
674
+ # ── lock ──────────────────────────────────────────────────────────
675
+ def write_lock():
676
+ with open(LOCK_FILE, 'w') as f: f.write(str(os.getpid()))
677
+
678
+ def remove_lock():
679
+ try: os.remove(LOCK_FILE)
680
+ except: pass
681
+
682
+ def lock_pid():
683
+ try: return int(open(LOCK_FILE).read().strip())
684
+ except: return None
685
+
686
+ def is_running(pid):
687
+ try: os.kill(pid, 0); return True
688
+ except: return False
689
+
690
+ def check_single_instance():
691
+ pid = lock_pid()
692
+ if pid and is_running(pid):
693
+ print(f'daemon already running (pid {pid})', file=sys.stderr); sys.exit(1)
694
+ write_lock()
695
+
696
+ signal.signal(signal.SIGTERM, lambda *_: (remove_lock(), sys.exit(0)))
697
+
698
+ # ── log ───────────────────────────────────────────────────────────
699
+ def rotate_log():
700
+ """Keep last 200 lines if log exceeds 500 lines. Called once at startup."""
701
+ try:
702
+ lines = open(LOG_FILE).readlines()
703
+ if len(lines) > 500:
704
+ with open(LOG_FILE, 'w') as f: f.writelines(lines[-200:])
705
+ except: pass
706
+
707
+ def log_event(event, **kwargs):
708
+ entry = {'ts': int(time.time()), 'event': event, **kwargs}
709
+ with open(LOG_FILE, 'a') as f: f.write(json.dumps(entry) + '\n')
710
+
711
+ # ── notify ────────────────────────────────────────────────────────
712
+ def notify(title, msg):
713
+ try:
714
+ p = platform.system()
715
+ if p == 'Darwin':
716
+ subprocess.run(['osascript', '-e',
717
+ f'display notification "{msg}" with title "{title}"'],
718
+ capture_output=True, timeout=3)
719
+ elif p == 'Linux':
720
+ subprocess.run(['notify-send', title, msg],
721
+ capture_output=True, timeout=3)
722
+ # Windows: called from .ps1 wrapper, not this script
723
+ except: pass
724
+
725
+ # ── credentials ───────────────────────────────────────────────────
726
+ def kc_read():
727
+ p = platform.system()
728
+ if p == 'Darwin':
729
+ r = subprocess.run(['security', 'find-generic-password',
730
+ '-s', 'Claude Code-credentials', '-a', subprocess.run(
731
+ ['whoami'], capture_output=True, text=True).stdout.strip(), '-w'],
732
+ capture_output=True, text=True)
733
+ return r.stdout.strip() if r.returncode == 0 else ''
734
+ else:
735
+ live = os.path.join(os.path.expanduser('~'), '.claude', '.credentials.json')
736
+ try: return open(live).read()
737
+ except: return ''
738
+
739
+ def kc_write(content):
740
+ p = platform.system()
741
+ if p == 'Darwin':
742
+ user = subprocess.run(['whoami'], capture_output=True, text=True).stdout.strip()
743
+ svc = 'Claude Code-credentials'
744
+ subprocess.run(['security', 'delete-generic-password', '-s', svc, '-a', user], capture_output=True)
745
+ subprocess.run(['security', 'add-generic-password', '-s', svc, '-a', user, '-w', content], capture_output=True)
746
+ else:
747
+ live = os.path.join(os.path.expanduser('~'), '.claude', '.credentials.json')
748
+ with open(live, 'w') as f: f.write(content)
749
+ os.chmod(live, 0o600)
750
+
751
+ def do_switch(name):
752
+ cred = os.path.join(CREDS_DIR, name + '.json')
753
+ current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
754
+ if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
755
+ live = kc_read()
756
+ if live:
757
+ with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
758
+ with open(CURRENT_FILE, 'w') as f: f.write(name)
759
+ content = open(cred).read()
760
+ kc_write(content)
761
+
762
+ # ── usage fetch ───────────────────────────────────────────────────
763
+ def load_cache():
764
+ try: return json.load(open(CACHE_FILE))
765
+ except: return {}
766
+
767
+ def save_cache(c):
768
+ try:
769
+ with open(CACHE_FILE, 'w') as f: json.dump(c, f)
770
+ except: pass
771
+
772
+ def fetch_usage(name):
773
+ cred_path = os.path.join(CREDS_DIR, name + '.json')
774
+ try:
775
+ d = json.load(open(cred_path))
776
+ oauth = d.get('claudeAiOauth') or {}
777
+ tok = oauth.get('accessToken', '')
778
+ if not tok: return None
779
+
780
+ expires_at_ms = oauth.get('expiresAt', 0)
781
+ now_ms = time.time() * 1000
782
+ if expires_at_ms and now_ms > expires_at_ms: return 'expired'
783
+
784
+ c = load_cache()
785
+ entry = c.get(name)
786
+ if entry and time.time() - entry.get('ts', 0) < CACHE_TTL:
787
+ return entry.get('data')
788
+
789
+ req = urllib.request.Request(
790
+ 'https://api.anthropic.com/api/oauth/usage',
791
+ headers={'Authorization': 'Bearer ' + tok, 'User-Agent': 'relay/2.0'})
792
+ with urllib.request.urlopen(req, timeout=6) as r:
793
+ data = json.loads(r.read())
794
+ c[name] = {'ts': time.time(), 'data': data}
795
+ save_cache(c)
796
+ return data
797
+ except urllib.error.HTTPError as e:
798
+ return 'expired' if e.code == 401 else None
799
+ except: return None
800
+
801
+ def get_utilization(usage_data):
802
+ """Return 5hr utilization % or None."""
803
+ if not isinstance(usage_data, dict): return None
804
+ fh = usage_data.get('five_hour') or {}
805
+ u = fh.get('utilization')
806
+ return int(u) if u is not None else None
807
+
808
+ # ── manual switch protection ───────────────────────────────────────
809
+ def get_manual_switch():
810
+ try: return json.load(open(MANUAL_FILE))
811
+ except: return None
812
+
813
+ def clear_manual_switch():
814
+ try: os.remove(MANUAL_FILE)
815
+ except: pass
816
+
817
+ # ── main loop ─────────────────────────────────────────────────────
818
+ def load_config():
819
+ try: return json.load(open(CONFIG_FILE))
820
+ except: return None
821
+
822
+ def main():
823
+ check_single_instance()
824
+ rotate_log()
825
+ log_event('start')
826
+
827
+ while True:
828
+ cfg = load_config()
829
+ if not cfg:
830
+ time.sleep(60); continue
831
+
832
+ order = cfg.get('order', [])
833
+ thresholds = cfg.get('thresholds', {})
834
+ poll = cfg.get('poll', {})
835
+ low_min = int(poll.get('low_minutes', 10))
836
+ high_min = int(poll.get('high_minutes', 2))
837
+ high_thr = int(poll.get('high_threshold', 50))
838
+
839
+ if not order:
840
+ time.sleep(60); continue
841
+
842
+ current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
843
+
844
+ usage = {name: fetch_usage(name) for name in order}
845
+
846
+ cur_util = get_utilization(usage.get(current))
847
+ sleep_sec = high_min * 60 if (cur_util is not None and cur_util >= high_thr) else low_min * 60
848
+
849
+ manual = get_manual_switch()
850
+ if manual and manual.get('account') == current:
851
+ threshold = thresholds.get(current)
852
+ if threshold is not None and cur_util is not None and cur_util >= threshold:
853
+ clear_manual_switch()
854
+ else:
855
+ time.sleep(sleep_sec); continue
856
+
857
+ cur_threshold = thresholds.get(current)
858
+ if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
859
+ time.sleep(sleep_sec); continue
860
+
861
+ candidates = [(n, get_utilization(usage.get(n))) for n in order if n != current]
862
+ under = [(n, u) for n, u in candidates if u is not None and thresholds.get(n) is not None and u < thresholds[n]]
863
+
864
+ if under:
865
+ target, target_util = under[0]
866
+ else:
867
+ measured = [(n, u) for n, u in candidates if u is not None]
868
+ if not measured:
869
+ time.sleep(sleep_sec); continue
870
+ target, target_util = min(measured, key=lambda x: x[1])
871
+ log_event('all_over_threshold', selected=target, usage=target_util)
872
+ notify('relay', f'All accounts over threshold — switching to {target} ({target_util}%)')
873
+ do_switch(target)
874
+ time.sleep(sleep_sec); continue
875
+
876
+ log_event('switch', frm=current, to=target, usage=cur_util)
877
+ notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
878
+ do_switch(target)
879
+ time.sleep(sleep_sec)
880
+
881
+ if __name__ == '__main__':
882
+ main()
883
+ DAEMON_EOF
884
+ chmod 755 "${AUTOSWITCH_DAEMON}"
885
+ }
886
+
887
+ cmd_autoswitch_config() {
888
+ hdr "autoswitch — configure"
889
+
890
+ local accounts=()
891
+ local name
892
+ while IFS= read -r name; do accounts+=("${name}"); done < <(list_account_names)
893
+
894
+ if [[ ${#accounts[@]} -eq 0 ]]; then
895
+ err "No accounts found. Run: relay add <name>"
896
+ exit 1
897
+ fi
898
+
899
+ echo ""
900
+ printf " Accounts found: ${CY}%s${R}\n\n" "$(IFS=', '; echo "${accounts[*]}")"
901
+
902
+ printf " ${B}Switch order${R} (comma-separated, only listed accounts join autoswitch):\n"
903
+ printf " > "; read -r order_input
904
+ local order_str; order_str=$(echo "${order_input}" | "${PY}" -c "
905
+ import sys,re
906
+ raw=sys.stdin.read().strip()
907
+ names=[x.strip() for x in raw.split(',') if x.strip()]
908
+ print(','.join(names))")
909
+
910
+ echo ""
911
+ printf " ${B}Thresholds${R} (press enter to skip account — it won't join autoswitch):\n"
912
+ local thresholds_json="{"
913
+ local order_json="["
914
+ local first_order=1
915
+ local first_thresh=1
916
+ IFS=',' read -ra order_arr <<< "${order_str}"
917
+ for acct in "${order_arr[@]}"; do
918
+ printf " %s threshold %% [default 80]: " "${acct}"
919
+ read -r val
920
+ if [[ -z "${val}" ]]; then
921
+ continue
922
+ fi
923
+ [[ ${first_order} -eq 0 ]] && order_json+=","
924
+ order_json+="\"${acct}\""
925
+ first_order=0
926
+ [[ ${first_thresh} -eq 0 ]] && thresholds_json+=","
927
+ thresholds_json+="\"${acct}\":${val}"
928
+ first_thresh=0
929
+ done
930
+ order_json+="]"
931
+ thresholds_json+="}"
932
+
933
+ echo ""
934
+ printf " ${B}Poll interval${R} — low usage (minutes) [default 10]: "
935
+ read -r low_min; low_min="${low_min:-10}"
936
+ printf " ${B}Poll interval${R} — high usage (minutes) [default 2]: "
937
+ read -r high_min; high_min="${high_min:-2}"
938
+ printf " ${B}High usage starts at${R} %% [default 50]: "
939
+ read -r high_thr; high_thr="${high_thr:-50}"
940
+
941
+ local cfg_file="${RELAY_DIR}/autoswitch.json"
942
+ printf '{"order":%s,"thresholds":%s,"poll":{"low_minutes":%s,"high_minutes":%s,"high_threshold":%s}}' \
943
+ "${order_json}" "${thresholds_json}" "${low_min}" "${high_min}" "${high_thr}" \
944
+ | "${PY}" -c "import json,sys; print(json.dumps(json.load(sys.stdin), indent=2))" \
945
+ > "${cfg_file}"
946
+
947
+ echo ""
948
+ ok "Saved to ${cfg_file}"
949
+ log "Run ${CY}relay autoswitch start${R} to activate"
950
+ }
951
+
952
+ cmd_autoswitch_start() {
953
+ [[ -f "${RELAY_DIR}/autoswitch.json" ]] || {
954
+ err "No config found. Run: relay autoswitch config"
955
+ exit 1
956
+ }
957
+
958
+ hdr "autoswitch — start"
959
+ _extract_daemon
960
+ log "Daemon extracted to ${AUTOSWITCH_DAEMON}"
961
+
962
+ local py; py=$(command -v python3 || command -v python || echo "")
963
+ [[ -z "${py}" ]] && { err "python3 required"; exit 1; }
964
+
965
+ if [[ "$(uname)" == "Darwin" ]]; then
966
+ mkdir -p "${HOME}/Library/LaunchAgents"
967
+ cat > "${AUTOSWITCH_PLIST}" <<PLIST
968
+ <?xml version="1.0" encoding="UTF-8"?>
969
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
970
+ <plist version="1.0">
971
+ <dict>
972
+ <key>Label</key><string>com.relay.autoswitch</string>
973
+ <key>ProgramArguments</key>
974
+ <array>
975
+ <string>${py}</string>
976
+ <string>${AUTOSWITCH_DAEMON}</string>
977
+ </array>
978
+ <key>RunAtLoad</key><true/>
979
+ <key>KeepAlive</key><true/>
980
+ <key>StandardOutPath</key><string>${RELAY_DIR}/autoswitch-daemon.log</string>
981
+ <key>StandardErrorPath</key><string>${RELAY_DIR}/autoswitch-daemon.log</string>
982
+ </dict>
983
+ </plist>
984
+ PLIST
985
+ launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
986
+ launchctl load "${AUTOSWITCH_PLIST}"
987
+ ok "Daemon started via launchd"
988
+
989
+ elif command -v systemctl >/dev/null 2>&1 && systemctl --user status >/dev/null 2>&1; then
990
+ mkdir -p "${HOME}/.config/systemd/user"
991
+ cat > "${AUTOSWITCH_SERVICE}" <<SVCEOF
992
+ [Unit]
993
+ Description=relay autoswitch daemon
994
+
995
+ [Service]
996
+ ExecStart=${py} ${AUTOSWITCH_DAEMON}
997
+ Restart=always
998
+ RestartSec=5
999
+
1000
+ [Install]
1001
+ WantedBy=default.target
1002
+ SVCEOF
1003
+ systemctl --user daemon-reload
1004
+ systemctl --user enable --now relay-autoswitch
1005
+ ok "Daemon started via systemd"
1006
+
1007
+ else
1008
+ # ponytail: cron fallback — daemon's lock file prevents overlap
1009
+ local cron_entry="*/2 * * * * ${py} ${AUTOSWITCH_DAEMON}"
1010
+ ( crontab -l 2>/dev/null | grep -v "autoswitch-daemon"; echo "${cron_entry}" ) | crontab -
1011
+ ok "Daemon scheduled via cron (every 2 min)"
1012
+ warn "For persistent autoswitch, install systemd or use macOS"
1013
+ fi
1014
+
1015
+ log "Run ${CY}relay autoswitch status${R} to verify"
1016
+ }
1017
+
1018
+ cmd_autoswitch_stop() {
1019
+ hdr "autoswitch — stop"
1020
+
1021
+ if [[ "$(uname)" == "Darwin" ]] && [[ -f "${AUTOSWITCH_PLIST}" ]]; then
1022
+ launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
1023
+ rm -f "${AUTOSWITCH_PLIST}"
1024
+ ok "Removed launchd job"
1025
+
1026
+ elif command -v systemctl >/dev/null 2>&1; then
1027
+ systemctl --user disable --now relay-autoswitch 2>/dev/null || true
1028
+ rm -f "${AUTOSWITCH_SERVICE}"
1029
+ systemctl --user daemon-reload
1030
+ ok "Removed systemd service"
1031
+
1032
+ else
1033
+ crontab -l 2>/dev/null | grep -v "autoswitch-daemon" | crontab -
1034
+ ok "Removed cron entry"
1035
+ fi
1036
+
1037
+ local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
1038
+ if [[ -n "${pid}" ]]; then
1039
+ kill "${pid}" 2>/dev/null || true
1040
+ fi
1041
+ rm -f "${RELAY_DIR}/autoswitch.lock"
1042
+ ok "Daemon stopped"
1043
+ }
1044
+
1045
+ cmd_autoswitch_status() {
1046
+ hdr "autoswitch — status"
1047
+
1048
+ local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
1049
+ if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
1050
+ printf " ${B}Daemon:${R} ${GR}● running${R} (pid ${pid})\n"
1051
+ else
1052
+ printf " ${B}Daemon:${R} ${D}○ stopped${R}\n"
1053
+ fi
1054
+
1055
+ local cfg="${RELAY_DIR}/autoswitch.json"
1056
+ if [[ ! -f "${cfg}" ]]; then
1057
+ warn "No config. Run: relay autoswitch config"
1058
+ return 0
1059
+ fi
1060
+
1061
+ printf " ${B}Config:${R} ${D}%s${R}\n\n" "${cfg}"
1062
+
1063
+ local current; current=$(current_name)
1064
+
1065
+ "${PY}" - "${cfg}" "${current}" <<'EOF'
1066
+ import json, sys, os, time
1067
+
1068
+ cfg = json.load(open(sys.argv[1]))
1069
+ current = sys.argv[2]
1070
+ order = cfg.get('order', [])
1071
+ thresholds = cfg.get('thresholds', {})
1072
+ relay_dir = os.path.expanduser('~/.claude-relay')
1073
+ cache_file = os.path.join(relay_dir, 'usage_cache.json')
1074
+
1075
+ R='\033[0m'; B='\033[1m'; D='\033[2m'
1076
+ GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
1077
+
1078
+ try: cache = json.load(open(cache_file))
1079
+ except: cache = {}
1080
+
1081
+ def get_util(name):
1082
+ entry = cache.get(name)
1083
+ if not entry: return None
1084
+ data = entry.get('data') if isinstance(entry, dict) else None
1085
+ if not isinstance(data, dict): return None
1086
+ fh = data.get('five_hour') or {}
1087
+ u = fh.get('utilization')
1088
+ return int(u) if u is not None else None
1089
+
1090
+ print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
1091
+ print(f' {D}{"─"*52}{R}')
1092
+ for i, name in enumerate(order, 1):
1093
+ cur = name == current
1094
+ marker = f'{GR}●{R}' if cur else ' '
1095
+ ncol = GR + B if cur else B
1096
+ thr = thresholds.get(name)
1097
+ thr_s = f'{thr}%' if thr is not None else f'{D}skipped{R}'
1098
+ util = get_util(name)
1099
+ util_s = f'{util}%' if util is not None else f'{D}—{R}'
1100
+ over = thr is not None and util is not None and util >= thr
1101
+ if over: util_s += f' {YL}⚠ over{R}'
1102
+ next_s = ''
1103
+ if not cur and not over:
1104
+ prev_over = all(
1105
+ (thresholds.get(order[j]) is not None and
1106
+ get_util(order[j]) is not None and
1107
+ get_util(order[j]) >= thresholds[order[j]])
1108
+ for j in range(i-1)
1109
+ )
1110
+ if prev_over: next_s = f' {CY}← next{R}'
1111
+ print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}')
1112
+
1113
+ log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
1114
+ try:
1115
+ lines = open(log_file).readlines()
1116
+ for line in reversed(lines):
1117
+ e = json.loads(line)
1118
+ if e.get('event') == 'switch':
1119
+ print(f'\n {B}Last switch:{R} {e["frm"]} → {e["to"]}')
1120
+ break
1121
+ except: pass
1122
+ EOF
1123
+
1124
+ echo ""
1125
+ log "Run ${CY}relay autoswitch log${R} to see full history"
1126
+ }
1127
+
1128
+ cmd_autoswitch_log() {
1129
+ local log_file="${RELAY_DIR}/autoswitch.log"
1130
+ [[ -f "${log_file}" ]] || { warn "No log yet"; return 0; }
1131
+
1132
+ hdr "autoswitch — log (last 20 events)"
1133
+ "${PY}" - "${log_file}" <<'EOF'
1134
+ import json, sys, datetime
1135
+
1136
+ R='\033[0m'; B='\033[1m'; D='\033[2m'
1137
+ GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
1138
+
1139
+ lines = open(sys.argv[1]).readlines()
1140
+ for line in lines[-20:]:
1141
+ try:
1142
+ e = json.loads(line)
1143
+ ts = datetime.datetime.fromtimestamp(e['ts']).strftime('%m/%d %H:%M')
1144
+ ev = e['event']
1145
+ if ev == 'switch':
1146
+ print(f' {D}{ts}{R} {GR}switch{R} {e["frm"]} → {CY}{e["to"]}{R} ({e.get("usage","?")}%)')
1147
+ elif ev == 'all_over_threshold':
1148
+ print(f' {D}{ts}{R} {YL}all_over{R} → {CY}{e["selected"]}{R} ({e.get("usage","?")}%) {YL}⚠{R}')
1149
+ elif ev == 'start':
1150
+ print(f' {D}{ts}{R} {D}daemon start{R}')
1151
+ except: pass
1152
+ EOF
1153
+ }
1154
+
1155
+ cmd_autoswitch() {
1156
+ local sub="${1:-}"; [[ $# -gt 0 ]] && shift
1157
+ case "${sub}" in
1158
+ config) cmd_autoswitch_config ;;
1159
+ start) cmd_autoswitch_start ;;
1160
+ stop) cmd_autoswitch_stop ;;
1161
+ status) cmd_autoswitch_status ;;
1162
+ log) cmd_autoswitch_log ;;
1163
+ *)
1164
+ hdr "autoswitch"
1165
+ printf " %-32s %s\n" " relay autoswitch config" "interactive setup wizard"
1166
+ printf " %-32s %s\n" " relay autoswitch start" "install and start daemon"
1167
+ printf " %-32s %s\n" " relay autoswitch stop" "stop and remove daemon"
1168
+ printf " %-32s %s\n" " relay autoswitch status" "show daemon state + config"
1169
+ printf " %-32s %s\n" " relay autoswitch log" "show recent switch history"
1170
+ echo ""
1171
+ ;;
1172
+ esac
1173
+ }
1174
+
651
1175
  _script_dir() {
652
1176
  # Resolve symlinks so we find package.json even when installed via npm/symlink
653
1177
  local src="$0"
@@ -766,6 +1290,11 @@ cmd_help() {
766
1290
  printf " %-32s %s\n" " relay update" "update to latest version"
767
1291
  printf " %-32s %s\n" " relay uninstall" "remove relay and all account data"
768
1292
  echo ""
1293
+ printf " ${B}Autoswitch${R}\n"
1294
+ printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
1295
+ printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
1296
+ printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
1297
+ echo ""
769
1298
  printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
770
1299
  printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
771
1300
  }
@@ -798,6 +1327,7 @@ case "${CMD}" in
798
1327
  remove|rm|del) cmd_remove "$@" ;;
799
1328
  rename|mv) cmd_rename "$@" ;;
800
1329
  sessions|sess) cmd_sessions ;;
1330
+ autoswitch|as) cmd_autoswitch "$@" ;;
801
1331
  version|--version|-V) cmd_version ;;
802
1332
  update) cmd_update ;;
803
1333
  install) cmd_install ;;