@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.
- package/README.md +32 -0
- package/package.json +1 -1
- 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
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 ;;
|