@dst-justin/relay 2.2.5 → 2.2.7

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 +10 -0
  2. package/package.json +1 -1
  3. package/relay +172 -24
package/README.md CHANGED
@@ -223,6 +223,16 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
223
223
 
224
224
  ## Changelog
225
225
 
226
+ ### v2.2.7 — 2026-07-05
227
+ - `relay lock <name>` / `relay unlock <name>`: lock an account so it won't be cycled back to when over its usage threshold
228
+ - `relay lock` (no args): show locked accounts
229
+ - Autoswitch daemon now auto-enables with default 80% threshold when 2+ accounts exist — no config required
230
+ - Cycling follows `order` sequence; locked+over-threshold accounts are skipped; if all candidates are blocked, stays on current account and notifies
231
+ - Lock badge (🔒) shown in `relay list` and `relay autoswitch status`
232
+
233
+ ### v2.2.6 — 2026-07-04
234
+ - `relay status -f` / `relay status --follow`: live-refresh current account status, same 30-second interval as `relay list -f`
235
+
226
236
  ### v2.2.5 — 2026-07-04
227
237
  - `relay list -f` / `relay list --follow`: live-refresh mode — clears the screen and redraws the account table every 30 seconds, Ctrl+C to exit
228
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
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
@@ -136,6 +136,14 @@ mode, creds_dir, meta_dir, current = sys.argv[1], sys.argv[2], sys.argv[3], sys.
136
136
  no_usage = '--no-usage' in sys.argv
137
137
  relay_dir = os.path.dirname(creds_dir)
138
138
  cache_file = os.path.join(relay_dir, 'usage_cache.json')
139
+
140
+ # Load locks from autoswitch config (best-effort, no error if missing)
141
+ _locks = []
142
+ try:
143
+ _as_cfg = json.load(open(os.path.join(relay_dir, 'autoswitch.json')))
144
+ _locks = _as_cfg.get('locks', [])
145
+ except Exception:
146
+ pass
139
147
  CACHE_TTL = 120 # seconds
140
148
 
141
149
  R='\033[0m'; B='\033[1m'; D='\033[2m'
@@ -316,7 +324,8 @@ if mode == 'quick':
316
324
  ncol = GR + B if cur else B
317
325
  email = get_email(name)
318
326
  u = u5_str(usage.get(name)) if not no_usage else ''
319
- print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}')
327
+ lock_badge = f' {YL}🔒{R}' if name in _locks else ''
328
+ print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}{lock_badge}')
320
329
  print()
321
330
  print(f' {D}switch:{R} {CY}!relay <index or name>{R} {D}details:{R} {CY}!relay status{R}')
322
331
  print()
@@ -331,8 +340,9 @@ else:
331
340
  d = usage.get(name)
332
341
  u5 = u5_str(d) if not no_usage else '—'
333
342
  u7 = u7_str(d, name) if not no_usage else '—'
343
+ lock_badge = f' {YL}🔒{R}' if name in _locks else ''
334
344
  # ANSI codes don't consume display width — pad manually for alignment
335
- print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}')
345
+ print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}{lock_badge}')
336
346
  print()
337
347
  n_warn = sum(1 for d in usage.values() if isinstance(d, dict) and (d.get('five_hour') or {}).get('utilization', 0) >= 80)
338
348
  if n_warn:
@@ -432,7 +442,7 @@ cmd_list() {
432
442
  _show_update_notice
433
443
  }
434
444
 
435
- cmd_status() {
445
+ _cmd_status_once() {
436
446
  _sync_current_creds
437
447
  local current; current=$(current_name)
438
448
  hdr "Current Status"
@@ -533,6 +543,29 @@ EOF
533
543
  [[ -d "${CLAUDE_DIR}/projects" ]] && \
534
544
  n=$(find "${CLAUDE_DIR}/projects" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ')
535
545
  printf "\n ${B}Sessions:${R} %s (shared across all accounts in ~/.claude/projects/)\n" "${n}"
546
+ }
547
+
548
+ cmd_status() {
549
+ local follow=0
550
+ for arg in "$@"; do
551
+ [[ "${arg}" == "-f" || "${arg}" == "--follow" ]] && follow=1
552
+ done
553
+
554
+ if [[ "${follow}" -eq 1 ]]; then
555
+ local interval=30
556
+ trap 'tput cnorm 2>/dev/null; printf "\n \033[2mExited relay status -f\033[0m\n\n"; exit 0' INT TERM
557
+ tput civis 2>/dev/null || true
558
+ while true; do
559
+ printf '\033[2J\033[H'
560
+ printf " ${D}live · refreshes every ${interval}s · Ctrl+C to exit${R}\n\n"
561
+ _cmd_status_once
562
+ printf "\n ${D}updated $(date '+%H:%M:%S')${R}\n"
563
+ sleep "${interval}"
564
+ done
565
+ return
566
+ fi
567
+
568
+ _cmd_status_once
536
569
  _check_update_bg
537
570
  _show_update_notice
538
571
  }
@@ -975,6 +1008,14 @@ def get_utilization(usage_data):
975
1008
  u = fh.get('utilization')
976
1009
  return int(u) if u is not None else None
977
1010
 
1011
+ def is_blocked(name, thr_map, locks_list, usage_map):
1012
+ """True if account is locked AND at or over its threshold — skip as switch target."""
1013
+ if name not in locks_list:
1014
+ return False
1015
+ util = get_utilization(usage_map.get(name))
1016
+ threshold = thr_map.get(name, 80)
1017
+ return util is not None and util >= threshold
1018
+
978
1019
  # ── manual switch protection ───────────────────────────────────────
979
1020
  def get_manual_switch():
980
1021
  try: return json.load(open(MANUAL_FILE))
@@ -986,8 +1027,21 @@ def clear_manual_switch():
986
1027
 
987
1028
  # ── main loop ─────────────────────────────────────────────────────
988
1029
  def load_config():
989
- try: return json.load(open(CONFIG_FILE))
990
- except: return None
1030
+ try:
1031
+ return json.load(open(CONFIG_FILE))
1032
+ except:
1033
+ # Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
1034
+ if not os.path.isdir(CREDS_DIR):
1035
+ return None
1036
+ accounts = sorted(f[:-5] for f in os.listdir(CREDS_DIR) if f.endswith('.json'))
1037
+ if len(accounts) < 2:
1038
+ return None
1039
+ return {
1040
+ 'order': accounts,
1041
+ 'thresholds': {a: 80 for a in accounts},
1042
+ 'locks': [],
1043
+ 'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
1044
+ }
991
1045
 
992
1046
  def main():
993
1047
  check_single_instance()
@@ -1011,6 +1065,7 @@ def main():
1011
1065
 
1012
1066
  order = cfg.get('order', [])
1013
1067
  thresholds = cfg.get('thresholds', {})
1068
+ locks = cfg.get('locks', [])
1014
1069
  poll = cfg.get('poll', {})
1015
1070
  low_min = int(poll.get('low_minutes', 10))
1016
1071
  high_min = int(poll.get('high_minutes', 2))
@@ -1038,21 +1093,23 @@ def main():
1038
1093
  if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
1039
1094
  time.sleep(sleep_sec); continue
1040
1095
 
1041
- candidates = [(n, get_utilization(usage.get(n))) for n in order if n != current]
1042
- under = [(n, u) for n, u in candidates if u is not None and thresholds.get(n) is not None and u < thresholds[n]]
1043
-
1044
- if under:
1045
- target, target_util = under[0]
1046
- else:
1047
- measured = [(n, u) for n, u in candidates if u is not None]
1048
- if not measured:
1049
- time.sleep(sleep_sec); continue
1050
- target, target_util = min(measured, key=lambda x: x[1])
1051
- log_event('all_over_threshold', selected=target, usage=target_util)
1052
- notify('relay', f'All accounts over threshold — switching to {target} ({target_util}%)')
1053
- do_switch(target)
1054
- time.sleep(sleep_sec); continue
1055
-
1096
+ # Ordered cycling: walk order[] from current position, skip blocked accounts
1097
+ idx = order.index(current) if current in order else 0
1098
+ target = None
1099
+ for i in range(1, len(order)):
1100
+ candidate = order[(idx + i) % len(order)]
1101
+ if not is_blocked(candidate, thresholds, locks, usage):
1102
+ target = candidate
1103
+ break
1104
+
1105
+ if target is None:
1106
+ # All candidates are locked + over threshold — stay put
1107
+ log_event('all_blocked', current=current)
1108
+ notify('relay', 'All accounts at limit — staying on current account')
1109
+ time.sleep(sleep_sec)
1110
+ continue
1111
+
1112
+ target_util = get_utilization(usage.get(target))
1056
1113
  log_event('switch', frm=current, to=target, usage=cur_util)
1057
1114
  notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
1058
1115
  do_switch(target)
@@ -1291,6 +1348,7 @@ cfg = json.load(open(sys.argv[1]))
1291
1348
  current = sys.argv[2]
1292
1349
  order = cfg.get('order', [])
1293
1350
  thresholds = cfg.get('thresholds', {})
1351
+ locks = cfg.get('locks', [])
1294
1352
  relay_dir = os.path.expanduser('~/.claude-relay')
1295
1353
  cache_file = os.path.join(relay_dir, 'usage_cache.json')
1296
1354
 
@@ -1309,8 +1367,8 @@ def get_util(name):
1309
1367
  u = fh.get('utilization')
1310
1368
  return int(u) if u is not None else None
1311
1369
 
1312
- print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
1313
- print(f' {D}{"─"*52}{R}')
1370
+ print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14} {"lock":<6}{R}')
1371
+ print(f' {D}{"─"*58}{R}')
1314
1372
  for i, name in enumerate(order, 1):
1315
1373
  cur = name == current
1316
1374
  marker = f'{GR}●{R}' if cur else ' '
@@ -1330,7 +1388,8 @@ for i, name in enumerate(order, 1):
1330
1388
  for j in range(i-1)
1331
1389
  )
1332
1390
  if prev_over: next_s = f' {CY}← next{R}'
1333
- print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}')
1391
+ lock_s = f' {YL}🔒{R}' if name in locks else ''
1392
+ print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}{lock_s}')
1334
1393
 
1335
1394
  log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
1336
1395
  try:
@@ -1349,6 +1408,8 @@ EOF
1349
1408
  printf " %-34s %s\n" " ${CY}relay autoswitch stop${R}" "stop daemon"
1350
1409
  printf " %-34s %s\n" " ${CY}relay autoswitch config${R}" "edit settings"
1351
1410
  printf " %-34s %s\n" " ${CY}relay autoswitch log${R}" "switch history"
1411
+ printf " %-34s %s\n" " ${CY}relay lock <name>${R}" "lock account (skip when over threshold)"
1412
+ printf " %-34s %s\n" " ${CY}relay unlock <name>${R}" "remove lock"
1352
1413
  _show_update_notice
1353
1414
  }
1354
1415
 
@@ -1399,6 +1460,87 @@ cmd_autoswitch() {
1399
1460
  esac
1400
1461
  }
1401
1462
 
1463
+ cmd_lock() {
1464
+ local name="${1:-}"
1465
+ local cfg="${RELAY_DIR}/autoswitch.json"
1466
+
1467
+ # No argument: show lock status
1468
+ if [[ -z "${name}" ]]; then
1469
+ hdr "Account locks"
1470
+ if [[ ! -f "${cfg}" ]]; then
1471
+ warn "No autoswitch config. Run: relay autoswitch config"
1472
+ return 0
1473
+ fi
1474
+ "${PY}" - "${cfg}" <<'PYEOF'
1475
+ import json, sys
1476
+ cfg = json.load(open(sys.argv[1]))
1477
+ locks = cfg.get('locks', [])
1478
+ R='\033[0m'; B='\033[1m'; D='\033[2m'; YL='\033[33m'
1479
+ if not locks:
1480
+ print(f' {D}No accounts locked.{R}')
1481
+ else:
1482
+ for name in locks:
1483
+ print(f' {YL}🔒{R} {B}{name}{R}')
1484
+ print()
1485
+ PYEOF
1486
+ return 0
1487
+ fi
1488
+
1489
+ account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
1490
+
1491
+ # Ensure config file exists (create auto-default if missing)
1492
+ if [[ ! -f "${cfg}" ]]; then
1493
+ "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1494
+ import json, sys, os
1495
+ creds_dir, cfg_path = sys.argv[1], sys.argv[2]
1496
+ accounts = sorted(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
1497
+ config = {
1498
+ 'order': accounts,
1499
+ 'thresholds': {a: 80 for a in accounts},
1500
+ 'locks': [],
1501
+ 'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
1502
+ }
1503
+ json.dump(config, open(cfg_path, 'w'), indent=2)
1504
+ PYEOF
1505
+ ok "Created default autoswitch config: ${cfg}"
1506
+ fi
1507
+
1508
+ "${PY}" - "${cfg}" "${name}" <<'PYEOF'
1509
+ import json, sys
1510
+ cfg_path, name = sys.argv[1], sys.argv[2]
1511
+ cfg = json.load(open(cfg_path))
1512
+ locks = cfg.get('locks', [])
1513
+ if name in locks:
1514
+ print(f' already locked: {name}')
1515
+ sys.exit(0)
1516
+ locks.append(name)
1517
+ cfg['locks'] = locks
1518
+ json.dump(cfg, open(cfg_path, 'w'), indent=2)
1519
+ PYEOF
1520
+ ok "Locked '${B}${name}${R}' — won't be switched back to when over threshold"
1521
+ }
1522
+
1523
+ cmd_unlock() {
1524
+ local name="${1:-}"
1525
+ [[ -z "${name}" ]] && { err "usage: relay unlock <name>"; exit 1; }
1526
+ local cfg="${RELAY_DIR}/autoswitch.json"
1527
+ [[ ! -f "${cfg}" ]] && { warn "No autoswitch config — nothing to unlock"; return 0; }
1528
+
1529
+ "${PY}" - "${cfg}" "${name}" <<'PYEOF'
1530
+ import json, sys
1531
+ cfg_path, name = sys.argv[1], sys.argv[2]
1532
+ cfg = json.load(open(cfg_path))
1533
+ locks = cfg.get('locks', [])
1534
+ if name not in locks:
1535
+ print(f' not locked: {name}')
1536
+ sys.exit(0)
1537
+ locks.remove(name)
1538
+ cfg['locks'] = locks
1539
+ json.dump(cfg, open(cfg_path, 'w'), indent=2)
1540
+ PYEOF
1541
+ ok "Unlocked '${B}${name}${R}'"
1542
+ }
1543
+
1402
1544
  _script_dir() {
1403
1545
  # Resolve symlinks so we find package.json even when installed via npm/symlink
1404
1546
  local src="$0"
@@ -1603,8 +1745,9 @@ cmd_help() {
1603
1745
  printf " %-32s %s\n" " relay save <name>" "save current login state"
1604
1746
  printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
1605
1747
  printf " %-32s %s\n" " relay list" "full list with weekly usage"
1606
- printf " %-32s %s\n" " relay list -f" "live-refresh list (Ctrl+C to exit)"
1748
+ printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
1607
1749
  printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
1750
+ printf " %-32s %s\n" " relay status -f" "live-refresh current account status"
1608
1751
  printf " %-32s %s\n" " relay remove <name>" "delete an account"
1609
1752
  printf " %-32s %s\n" " relay sessions" "show all sessions"
1610
1753
  printf " %-32s %s\n" " relay version" "show current version"
@@ -1615,6 +1758,9 @@ cmd_help() {
1615
1758
  printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
1616
1759
  printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
1617
1760
  printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
1761
+ printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
1762
+ printf " %-32s %s\n" " relay unlock <name>" "remove lock"
1763
+ printf " %-32s %s\n" " relay lock" "show locked accounts"
1618
1764
  echo ""
1619
1765
  printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
1620
1766
  printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
@@ -1652,6 +1798,8 @@ case "${CMD}" in
1652
1798
  rename|mv) cmd_rename "$@" ;;
1653
1799
  sessions|sess) cmd_sessions ;;
1654
1800
  autoswitch|as) cmd_autoswitch "$@" ;;
1801
+ lock) cmd_lock "$@" ;;
1802
+ unlock) cmd_unlock "$@" ;;
1655
1803
  version|--version|-V) cmd_version ;;
1656
1804
  update) cmd_update ;;
1657
1805
  install) cmd_install ;;