@dst-justin/relay 2.3.0 → 2.4.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 +46 -1
  2. package/package.json +1 -1
  3. package/relay +637 -41
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # relay
1
+ <p align="center"><img src="assets/banner.png" alt="relay" width="600"></p>
2
2
 
3
3
  A lightweight CLI tool for switching between multiple Claude Code accounts instantly.
4
4
 
@@ -226,6 +226,39 @@ Warmup requires the autoswitch daemon to be running (`relay autoswitch start`) t
226
226
  }
227
227
  ```
228
228
 
229
+ ## LiteLLM providers
230
+
231
+ Beyond subscription accounts, relay can route Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy — useful for supplementing subscription usage with other model providers.
232
+
233
+ ```bash
234
+ relay provider add mylitellm --base-url http://localhost:4000 --token sk-your-litellm-key [--model claude-sonnet-4-5] [--discover-models]
235
+ relay provider list
236
+ relay provider use mylitellm # routes every future `claude` launch through it
237
+ relay provider off # stop routing, subscription account resumes
238
+ relay provider remove mylitellm
239
+ ```
240
+
241
+ `--discover-models` lets Claude Code's `/model` picker show every model configured in your LiteLLM proxy's `config.yaml`, switchable live mid-session.
242
+
243
+ For a one-off session on a specific account or provider, without touching any global state:
244
+
245
+ ```bash
246
+ relay run mylitellm -- -p "say hi" # or any claude args
247
+ relay run work # same as relay work, then claude
248
+ ```
249
+
250
+ Minimal LiteLLM `config.yaml`:
251
+ ```yaml
252
+ model_list:
253
+ - model_name: claude-sonnet-4-5
254
+ litellm_params:
255
+ model: openai/gpt-4o
256
+ api_key: os.environ/OPENAI_API_KEY
257
+
258
+ general_settings:
259
+ master_key: sk-your-litellm-master-key
260
+ ```
261
+
229
262
  ## How It Works
230
263
 
231
264
  relay stores a snapshot of each account's OAuth credentials in `~/.claude-relay/credentials/`. Switching writes the target account's credentials back into the store that Claude Code reads from.
@@ -255,6 +288,18 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
255
288
 
256
289
  ## Changelog
257
290
 
291
+ ### v2.4.0 — 2026-07-25
292
+ - Add LiteLLM provider support: `relay provider add/list/use/off/remove` routes Claude Code through a LiteLLM proxy instead of a subscription account, via `${CLAUDE_DIR}/settings.json`'s env block (never touches Keychain/credentials).
293
+ - Add `relay run <name>` for a one-off session pinned to a specific account or provider, independent of any global switch.
294
+
295
+ ### v2.3.1 — 2026-07-12
296
+ - Fix: autoswitch daemon now auto-redeploys and restarts after relay itself is updated — previously the daemon file was only regenerated by `relay autoswitch start`, so an already-running daemon would silently keep running stale code (missing new features and previously-fixed bugs) until manually restarted
297
+ - Fix: `relay reorder` no longer drops accounts omitted from the typed order — they're now appended in their prior relative order instead of being silently removed from autoswitch rotation
298
+ - Fix: autoswitch reorder wizard no longer discards a typed order; supports concatenated digit shorthand (e.g. `231`)
299
+ - Add: standalone `relay reorder` command
300
+ - Fix: lock/warmup auto-default configs, autoswitch daemon auto-default order, and `render_table` all respect the persisted account order file instead of falling back to alphabetical sort
301
+ - Fix: account order file stays in sync on add/save/remove/rename
302
+
258
303
  ### v2.3.0 — 2026-07-10
259
304
  - Add scheduled warmup: `relay warmup add/remove/list/pause/resume` to pre-warm a 5hr session at set times
260
305
  - Daemon warmup engine (`check_warmup`/`do_warmup`) fires scheduled warmups; always pings (`claude -p ping`) on fire rather than skipping when the account looks "already active" — an idle-overnight account was silently missing its pre-warm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.3.0",
3
+ "version": "2.4.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
@@ -14,8 +14,12 @@ META_STORE="${RELAY_DIR}/meta"
14
14
  CURRENT_FILE="${RELAY_DIR}/current"
15
15
  ORDER_FILE="${RELAY_DIR}/order"
16
16
  UPDATE_CACHE="${RELAY_DIR}/.update_cache"
17
+ PROVIDERS_STORE="${RELAY_DIR}/providers"
18
+ ACTIVE_PROVIDER_FILE="${RELAY_DIR}/active_provider"
19
+ SETTINGS_ENV_SNAPSHOT="${RELAY_DIR}/settings_env_snapshot.json"
17
20
  CLAUDE_DIR="${HOME}/.claude"
18
21
  CLAUDE_JSON="${HOME}/.claude.json"
22
+ CLAUDE_SETTINGS="${CLAUDE_DIR}/settings.json"
19
23
  REAL_CLAUDE=$(command -v claude 2>/dev/null || echo "")
20
24
  # macOS: /usr/bin/python3 uses the system TLS stack (correct certs);
21
25
  # /usr/local/bin/python3 (Homebrew/standalone) often lacks bundled certs → SSL failures
@@ -90,8 +94,8 @@ with open(lockfile, "a") as f:
90
94
  return "${status}"
91
95
  }
92
96
 
93
- mkdir -p "${CREDS_STORE}" "${META_STORE}" "${CLAUDE_DIR}"
94
- chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" 2>/dev/null || true
97
+ mkdir -p "${CREDS_STORE}" "${META_STORE}" "${PROVIDERS_STORE}" "${CLAUDE_DIR}"
98
+ chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" "${PROVIDERS_STORE}" 2>/dev/null || true
95
99
 
96
100
  [[ -z "${PY}" ]] && { err "python3 is required"; exit 1; }
97
101
 
@@ -100,6 +104,29 @@ account_creds() { echo "${CREDS_STORE}/$1.json"; }
100
104
  account_meta() { echo "${META_STORE}/$1"; }
101
105
  account_exists() { [[ -f "$(account_creds "$1")" ]]; }
102
106
 
107
+ provider_file() { echo "${PROVIDERS_STORE}/$1.json"; }
108
+ provider_exists() { [[ -f "$(provider_file "$1")" ]]; }
109
+ active_provider_name() { [[ -f "${ACTIVE_PROVIDER_FILE}" ]] && cat "${ACTIVE_PROVIDER_FILE}" || echo ""; }
110
+
111
+ # list providers alphabetically, one per line (bash 3.2 compatible)
112
+ list_provider_names() {
113
+ local f
114
+ for f in "${PROVIDERS_STORE}"/*.json; do
115
+ [[ -f "${f}" ]] || continue
116
+ basename "${f}" .json
117
+ done | sort
118
+ }
119
+
120
+ # read one string field from a provider's JSON file; "" if absent
121
+ _provider_field() {
122
+ "${PY}" -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],"") or "")' "$(provider_file "$1")" "$2"
123
+ }
124
+
125
+ # "1" if the provider has discover_models truthy, else ""
126
+ _provider_discover() {
127
+ "${PY}" -c 'import json,sys; print("1" if json.load(open(sys.argv[1])).get("discover_models") else "")' "$(provider_file "$1")"
128
+ }
129
+
103
130
  # list accounts in canonical add-order, one per line (bash 3.2 compatible).
104
131
  # Self-healing: drops names whose credential file is gone, appends any
105
132
  # credential file not yet tracked (alphabetically), and rewrites ORDER_FILE.
@@ -141,6 +168,86 @@ list_account_names() {
141
168
  fi
142
169
  }
143
170
 
171
+ # append name to ORDER_FILE if not already tracked (used by add/save)
172
+ add_to_order() {
173
+ local name="$1"
174
+ touch "${ORDER_FILE}"
175
+ grep -Fxq "${name}" "${ORDER_FILE}" 2>/dev/null && return 0
176
+ echo "${name}" >> "${ORDER_FILE}"
177
+ }
178
+
179
+ # remove name's line from ORDER_FILE (used by remove)
180
+ remove_from_order() {
181
+ local name="$1"
182
+ [[ -f "${ORDER_FILE}" ]] || return 0
183
+ local tmp="${ORDER_FILE}.tmp.$$"
184
+ grep -Fxv "${name}" "${ORDER_FILE}" > "${tmp}" 2>/dev/null || : > "${tmp}"
185
+ mv "${tmp}" "${ORDER_FILE}"
186
+ }
187
+
188
+ # rename a tracked entry in place, preserving its position (used by rename)
189
+ rename_in_order() {
190
+ local old="$1" new="$2"
191
+ [[ -f "${ORDER_FILE}" ]] || return 0
192
+ local tmp="${ORDER_FILE}.tmp.$$"
193
+ awk -v old="${old}" -v new="${new}" '{ print ($0 == old) ? new : $0 }' "${ORDER_FILE}" > "${tmp}"
194
+ mv "${tmp}" "${ORDER_FILE}"
195
+ }
196
+
197
+ # interactive numbered reorder picker. Prints the account list + prompt to
198
+ # stderr (so it's visible even when the caller captures stdout), reads one
199
+ # line from stdin, resolves it to a comma-joined order, and echoes that
200
+ # order to stdout for the caller to capture.
201
+ prompt_reorder() {
202
+ local accounts=("$@")
203
+ echo "" >&2
204
+ printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n" >&2
205
+ local i=1 acct
206
+ for acct in "${accounts[@]}"; do
207
+ printf " ${D}%d${R} %s\n" "${i}" "${acct}" >&2
208
+ i=$((i+1))
209
+ done
210
+ echo "" >&2
211
+ printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D}, or ${CY}21${D} for <=9 accounts, or Enter to keep the order above):${R}\n" >&2
212
+ printf " > " >&2
213
+ read -r order_input
214
+
215
+ local order_str; order_str=$("${PY}" - "${order_input}" "${accounts[@]}" <<'PYEOF'
216
+ import sys, re
217
+ raw = sys.argv[1].strip()
218
+ accts = sys.argv[2:]
219
+ if not raw:
220
+ result = accts
221
+ elif raw.isdigit() and not re.search(r'[\s,]', raw) and len(accts) <= 9:
222
+ # concatenated shorthand, e.g. "312" -> [3, 1, 2], only unambiguous for <=9 accounts
223
+ result = []
224
+ for ch in raw:
225
+ idx = int(ch) - 1
226
+ if 0 <= idx < len(accts):
227
+ result.append(accts[idx])
228
+ else:
229
+ tokens = re.split(r'[\s,]+', raw)
230
+ result = []
231
+ for t in tokens:
232
+ t = t.strip()
233
+ if t.isdigit():
234
+ idx = int(t) - 1
235
+ if 0 <= idx < len(accts):
236
+ result.append(accts[idx])
237
+ elif t:
238
+ result.append(t)
239
+ print(','.join(result))
240
+ PYEOF
241
+ )
242
+
243
+ local chain; chain=$(echo "${order_str}" | "${PY}" -c "
244
+ import sys; names=sys.stdin.read().strip().split(',')
245
+ print(' -> '.join(names) + ' -> (cycle)')")
246
+ printf " ${D}Order: ${CY}%s${R}\n" "${chain}" >&2
247
+
248
+ echo "${order_str}"
249
+ }
250
+
144
251
  account_by_index() {
145
252
  local idx="$1" i=1 name
146
253
  while IFS= read -r name; do
@@ -186,6 +293,145 @@ require_claude() {
186
293
  exit 1
187
294
  }
188
295
 
296
+ # Fully replace the 4 managed keys with exactly the given KEY=VALUE pairs.
297
+ # Snapshots the pre-relay original values/absences the first time this is
298
+ # called since the last full restore (SETTINGS_ENV_SNAPSHOT doesn't exist).
299
+ # Assumes the caller already holds the credential lock.
300
+ _settings_env_activate_locked() {
301
+ RELAY_SETTINGS_PATH="${CLAUDE_SETTINGS}" \
302
+ RELAY_SNAPSHOT_PATH="${SETTINGS_ENV_SNAPSHOT}" \
303
+ RELAY_ENV_PAIRS="$(printf '%s\n' "$@")" \
304
+ "${PY}" <<'EOF'
305
+ import json, os, sys, tempfile
306
+
307
+ MANAGED = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"]
308
+
309
+ def load_json(p, default):
310
+ if not os.path.exists(p):
311
+ return default
312
+ raw = open(p).read().strip()
313
+ return json.loads(raw) if raw else default
314
+
315
+ def atomic_write(p, data, mode=0o600):
316
+ d = os.path.dirname(p) or "."
317
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".relay-tmp-")
318
+ try:
319
+ with os.fdopen(fd, "w") as f:
320
+ json.dump(data, f, indent=2)
321
+ f.write("\n")
322
+ os.chmod(tmp, mode)
323
+ os.replace(tmp, p)
324
+ except Exception:
325
+ try:
326
+ os.unlink(tmp)
327
+ except OSError:
328
+ pass
329
+ raise
330
+
331
+ path = os.environ["RELAY_SETTINGS_PATH"]
332
+ snapshot_path = os.environ["RELAY_SNAPSHOT_PATH"]
333
+ pairs = [p for p in os.environ.get("RELAY_ENV_PAIRS", "").split("\n") if p]
334
+
335
+ try:
336
+ data = load_json(path, {})
337
+ except json.JSONDecodeError as e:
338
+ print(f"settings.json is not valid JSON: {e}", file=sys.stderr)
339
+ sys.exit(1)
340
+ if not isinstance(data, dict):
341
+ print("settings.json root is not a JSON object", file=sys.stderr)
342
+ sys.exit(1)
343
+
344
+ env = data.get("env")
345
+ if not isinstance(env, dict):
346
+ env = {}
347
+
348
+ if not os.path.exists(snapshot_path):
349
+ snapshot = {k: env.get(k) for k in MANAGED}
350
+ atomic_write(snapshot_path, snapshot, 0o600)
351
+
352
+ for k in MANAGED:
353
+ env.pop(k, None)
354
+ for pair in pairs:
355
+ k, _, v = pair.partition("=")
356
+ env[k] = v
357
+ data["env"] = env
358
+ atomic_write(path, data, 0o600)
359
+ EOF
360
+ }
361
+ _settings_env_activate() { with_credential_lock _settings_env_activate_locked "$@"; }
362
+
363
+ # Restore the snapshotted values/absences for the 4 managed keys (or clear
364
+ # them if no snapshot exists, as a defensive fallback), then delete the
365
+ # snapshot. Assumes the caller already holds the credential lock.
366
+ _settings_env_restore_locked() {
367
+ "${PY}" - "${CLAUDE_SETTINGS}" "${SETTINGS_ENV_SNAPSHOT}" <<'EOF'
368
+ import json, os, sys, tempfile
369
+
370
+ MANAGED = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"]
371
+
372
+ def load_json(p, default):
373
+ if not os.path.exists(p):
374
+ return default
375
+ raw = open(p).read().strip()
376
+ return json.loads(raw) if raw else default
377
+
378
+ def atomic_write(p, data, mode=0o600):
379
+ d = os.path.dirname(p) or "."
380
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".relay-tmp-")
381
+ try:
382
+ with os.fdopen(fd, "w") as f:
383
+ json.dump(data, f, indent=2)
384
+ f.write("\n")
385
+ os.chmod(tmp, mode)
386
+ os.replace(tmp, p)
387
+ except Exception:
388
+ try:
389
+ os.unlink(tmp)
390
+ except OSError:
391
+ pass
392
+ raise
393
+
394
+ path, snapshot_path = sys.argv[1], sys.argv[2]
395
+
396
+ try:
397
+ data = load_json(path, {})
398
+ except json.JSONDecodeError as e:
399
+ print(f"settings.json is not valid JSON: {e}", file=sys.stderr)
400
+ sys.exit(1)
401
+ if not isinstance(data, dict):
402
+ print("settings.json root is not a JSON object", file=sys.stderr)
403
+ sys.exit(1)
404
+
405
+ env = data.get("env")
406
+ if not isinstance(env, dict):
407
+ env = {}
408
+
409
+ changed = False
410
+ if os.path.exists(snapshot_path):
411
+ snapshot = load_json(snapshot_path, {})
412
+ for k in MANAGED:
413
+ v = snapshot.get(k)
414
+ if v is None:
415
+ changed = env.pop(k, None) is not None or changed
416
+ else:
417
+ changed = env.get(k) != v or changed
418
+ env[k] = v
419
+ else:
420
+ for k in MANAGED:
421
+ if k in env:
422
+ del env[k]
423
+ changed = True
424
+
425
+ if changed:
426
+ data["env"] = env
427
+ atomic_write(path, data, 0o600)
428
+
429
+ if os.path.exists(snapshot_path):
430
+ os.unlink(snapshot_path)
431
+ EOF
432
+ }
433
+ _settings_env_restore() { with_credential_lock _settings_env_restore_locked; }
434
+
189
435
  # ══════════════════════════════════════════════════════════════════
190
436
  # Python core: parallel usage fetch + table rendering
191
437
  # args: <mode: quick|full> <creds_dir> <meta_dir> <current_name> [--no-usage]
@@ -228,7 +474,24 @@ def reset_in(iso):
228
474
  except Exception:
229
475
  return '—'
230
476
 
231
- names = sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(creds_dir, '*.json')))
477
+ def read_order(creds_dir):
478
+ relay_dir = os.path.dirname(creds_dir)
479
+ order_file = os.path.join(relay_dir, 'order')
480
+ on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
481
+ ordered = []
482
+ if os.path.exists(order_file):
483
+ for line in open(order_file):
484
+ n = line.strip()
485
+ if n in on_disk and n not in ordered:
486
+ ordered.append(n)
487
+ for n in sorted(on_disk):
488
+ if n not in ordered:
489
+ ordered.append(n)
490
+ with open(order_file, 'w') as f:
491
+ f.write('\n'.join(ordered) + ('\n' if ordered else ''))
492
+ return ordered
493
+
494
+ names = read_order(creds_dir)
232
495
  if not names:
233
496
  print(f' \033[33m⚠\033[0m No accounts yet. Run: {B}relay add <name>{R}')
234
497
  sys.exit(0)
@@ -424,6 +687,11 @@ _do_switch_locked() {
424
687
  local name="$1"
425
688
  local current; current=$(current_name)
426
689
 
690
+ if [[ -n "$(active_provider_name)" ]]; then
691
+ _settings_env_restore_locked
692
+ rm -f "${ACTIVE_PROVIDER_FILE}"
693
+ fi
694
+
427
695
  if [[ "${current}" == "${name}" ]]; then
428
696
  ok "Already on account '${B}${name}${R}'"
429
697
  return 0
@@ -463,8 +731,16 @@ _sync_current_creds() {
463
731
  [[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
464
732
  }
465
733
 
734
+ _provider_banner() {
735
+ local active; active=$(active_provider_name)
736
+ [[ -z "${active}" ]] && return 0
737
+ local base_url; base_url=$(_provider_field "${active}" base_url)
738
+ printf "\n ${YL}⚡ litellm:${R} ${B}%s${R} ${D}(%s)${R}\n" "${active}" "${base_url}"
739
+ }
740
+
466
741
  cmd_quick() {
467
742
  _check_update_bg
743
+ _provider_banner
468
744
  _sync_current_creds
469
745
  render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
470
746
  _show_update_notice
@@ -510,6 +786,7 @@ cmd_list() {
510
786
  }
511
787
 
512
788
  _cmd_status_once() {
789
+ _provider_banner
513
790
  _sync_current_creds
514
791
  local current; current=$(current_name)
515
792
  hdr "Current Status"
@@ -687,12 +964,203 @@ PYEOF
687
964
  fi
688
965
  }
689
966
 
967
+ cmd_provider_add() {
968
+ local name="${1:-}"
969
+ [[ -z "${name}" ]] && { err "usage: relay provider add <name> --base-url <url> --token <token> [--model <model>] [--discover-models]"; exit 1; }
970
+ shift
971
+ case "${name}" in
972
+ *[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
973
+ esac
974
+ if account_exists "${name}"; then
975
+ err "'${name}' is already an account — pick a different provider name"
976
+ exit 1
977
+ fi
978
+ if provider_exists "${name}"; then
979
+ warn "Provider '${name}' already exists"
980
+ log "To change it: relay provider remove ${name} && relay provider add ${name} ..."
981
+ return 0
982
+ fi
983
+
984
+ local base_url="" token="" model="" discover=0
985
+ while [[ $# -gt 0 ]]; do
986
+ case "$1" in
987
+ --base-url) base_url="${2:-}"; shift 2 ;;
988
+ --token) token="${2:-}"; shift 2 ;;
989
+ --model) model="${2:-}"; shift 2 ;;
990
+ --discover-models) discover=1; shift ;;
991
+ *) err "unknown option: $1"; exit 1 ;;
992
+ esac
993
+ done
994
+
995
+ [[ -z "${base_url}" ]] && { err "--base-url is required"; exit 1; }
996
+ [[ -z "${token}" ]] && { err "--token is required"; exit 1; }
997
+ case "${base_url}" in
998
+ http://*|https://*) ;;
999
+ *) err "--base-url must start with http:// or https://"; exit 1 ;;
1000
+ esac
1001
+
1002
+ RELAY_PROVIDER_PATH="$(provider_file "${name}")" \
1003
+ RELAY_PROVIDER_BASE_URL="${base_url}" \
1004
+ RELAY_PROVIDER_TOKEN="${token}" \
1005
+ RELAY_PROVIDER_MODEL="${model}" \
1006
+ RELAY_PROVIDER_DISCOVER="${discover}" \
1007
+ "${PY}" -c '
1008
+ import json, os
1009
+ path = os.environ["RELAY_PROVIDER_PATH"]
1010
+ base_url = os.environ["RELAY_PROVIDER_BASE_URL"]
1011
+ token = os.environ["RELAY_PROVIDER_TOKEN"]
1012
+ model = os.environ.get("RELAY_PROVIDER_MODEL", "")
1013
+ discover = os.environ.get("RELAY_PROVIDER_DISCOVER", "")
1014
+ d = {"base_url": base_url, "auth_token": token}
1015
+ if model:
1016
+ d["model"] = model
1017
+ if discover == "1":
1018
+ d["discover_models"] = True
1019
+ with open(path, "w") as f:
1020
+ json.dump(d, f)
1021
+ os.chmod(path, 0o600)
1022
+ '
1023
+
1024
+ ok "Provider '${B}${name}${R}' added ${D}${base_url}${R}"
1025
+ }
1026
+
1027
+ cmd_provider_list() {
1028
+ hdr "LiteLLM providers"
1029
+ local names; names=$(list_provider_names)
1030
+ if [[ -z "${names}" ]]; then
1031
+ warn "No providers configured — add one with: relay provider add <name> --base-url <url> --token <token>"
1032
+ return 0
1033
+ fi
1034
+ local active; active=$(active_provider_name)
1035
+ local name base_url model info
1036
+ while IFS= read -r name; do
1037
+ [[ -z "${name}" ]] && continue
1038
+ base_url=$(_provider_field "${name}" base_url)
1039
+ model=$(_provider_field "${name}" model)
1040
+ info="${base_url}"
1041
+ [[ -n "${model}" ]] && info="${info} model=${model}"
1042
+ if [[ "${name}" == "${active}" ]]; then
1043
+ printf " ${GR}${B}✓ %-16s${R} ${D}%s${R}\n" "${name}" "${info}"
1044
+ else
1045
+ printf " %-16s ${D}%s${R}\n" "${name}" "${info}"
1046
+ fi
1047
+ done <<< "${names}"
1048
+ }
1049
+
1050
+ cmd_provider_use() {
1051
+ local name="${1:-}"
1052
+ [[ -z "${name}" ]] && { err "usage: relay provider use <name>"; exit 1; }
1053
+ provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
1054
+
1055
+ local base_url token model discover
1056
+ base_url=$(_provider_field "${name}" base_url)
1057
+ token=$(_provider_field "${name}" auth_token)
1058
+ model=$(_provider_field "${name}" model)
1059
+ discover=$(_provider_discover "${name}")
1060
+
1061
+ local pairs=("ANTHROPIC_BASE_URL=${base_url}" "ANTHROPIC_AUTH_TOKEN=${token}")
1062
+ [[ -n "${model}" ]] && pairs+=("ANTHROPIC_MODEL=${model}")
1063
+ [[ -n "${discover}" ]] && pairs+=("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1")
1064
+
1065
+ _settings_env_activate "${pairs[@]}" || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
1066
+ printf '%s' "${name}" > "${ACTIVE_PROVIDER_FILE}"
1067
+
1068
+ printf "\n ${GR}${B}⚡ provider active → %s${R} ${D}%s${R}\n" "${name}" "${base_url}"
1069
+ printf " ${D}Active sessions pick up the switch on next message. New session: ${CY}claude -c${R}\n\n"
1070
+ }
1071
+
1072
+ cmd_provider_off() {
1073
+ local active; active=$(active_provider_name)
1074
+ if [[ -z "${active}" ]]; then
1075
+ warn "No provider is currently active"
1076
+ return 0
1077
+ fi
1078
+ _settings_env_restore || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
1079
+ rm -f "${ACTIVE_PROVIDER_FILE}"
1080
+ ok "Provider mode off — subscription account resumes"
1081
+ }
1082
+
1083
+ cmd_provider_remove() {
1084
+ local name="${1:-}"
1085
+ [[ -z "${name}" ]] && { err "usage: relay provider remove <name>"; exit 1; }
1086
+ provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
1087
+ printf "\n ${YL}Delete provider '${B}${name}${R}${YL}'? (y/N) ${R}"
1088
+ read -r c
1089
+ [[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
1090
+ if [[ "$(active_provider_name)" == "${name}" ]]; then
1091
+ cmd_provider_off
1092
+ fi
1093
+ rm -f "$(provider_file "${name}")"
1094
+ ok "Deleted provider '${name}'"
1095
+ }
1096
+
1097
+ cmd_run() {
1098
+ local name="${1:-}"
1099
+ [[ -z "${name}" ]] && { err "usage: relay run <name> [-- <claude args...>]"; exit 1; }
1100
+ shift
1101
+ [[ "${1:-}" == "--" ]] && shift
1102
+
1103
+ require_claude
1104
+
1105
+ if account_exists "${name}"; then
1106
+ do_switch "${name}"
1107
+ exec "${REAL_CLAUDE}" "$@"
1108
+ elif provider_exists "${name}"; then
1109
+ local base_url token model discover settings_file
1110
+ base_url=$(_provider_field "${name}" base_url)
1111
+ token=$(_provider_field "${name}" auth_token)
1112
+ model=$(_provider_field "${name}" model)
1113
+ discover=$(_provider_discover "${name}")
1114
+
1115
+ # Opportunistic cleanup: a prior `relay run <provider>` can't clean up
1116
+ # after itself (exec replaces the process, so no trap ever fires) —
1117
+ # sweep anything old enough that the session which created it has
1118
+ # almost certainly ended, so these don't accumulate indefinitely.
1119
+ find "${RELAY_DIR}" -maxdepth 1 -name '.run-settings.*' -mtime +1 -delete 2>/dev/null || true
1120
+
1121
+ # Written to a chmod-600 file under RELAY_DIR (mktemp's default file
1122
+ # mode is already 0600) rather than passed inline via `claude`'s own
1123
+ # argv — the exec'd claude process's cmdline would otherwise expose
1124
+ # this provider's auth token to any local user via `ps`/`ps aux` for
1125
+ # the entire session lifetime.
1126
+ settings_file=$(mktemp "${RELAY_DIR}/.run-settings.XXXXXX")
1127
+ RELAY_RUN_BASE_URL="${base_url}" \
1128
+ RELAY_RUN_TOKEN="${token}" \
1129
+ RELAY_RUN_MODEL="${model}" \
1130
+ RELAY_RUN_DISCOVER="${discover}" \
1131
+ RELAY_RUN_SETTINGS_FILE="${settings_file}" \
1132
+ "${PY}" -c '
1133
+ import json, os
1134
+ base_url = os.environ["RELAY_RUN_BASE_URL"]
1135
+ token = os.environ["RELAY_RUN_TOKEN"]
1136
+ model = os.environ.get("RELAY_RUN_MODEL", "")
1137
+ discover = os.environ.get("RELAY_RUN_DISCOVER", "")
1138
+ env = {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": token}
1139
+ if model:
1140
+ env["ANTHROPIC_MODEL"] = model
1141
+ if discover == "1":
1142
+ env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
1143
+ with open(os.environ["RELAY_RUN_SETTINGS_FILE"], "w") as f:
1144
+ json.dump({"env": env}, f)
1145
+ '
1146
+
1147
+ exec "${REAL_CLAUDE}" --settings "${settings_file}" "$@"
1148
+ else
1149
+ err "Unknown account or provider: ${name}"
1150
+ exit 1
1151
+ fi
1152
+ }
1153
+
690
1154
  cmd_add() {
691
1155
  local name="${1:-}"
692
1156
  [[ -z "${name}" ]] && { err "usage: relay add <name>"; exit 1; }
693
1157
  case "${name}" in
694
1158
  *[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
695
1159
  esac
1160
+ if provider_exists "${name}"; then
1161
+ err "'${name}' is already a LiteLLM provider — pick a different account name"
1162
+ exit 1
1163
+ fi
696
1164
  require_claude
697
1165
  if account_exists "${name}"; then
698
1166
  warn "Account '${name}' already exists"
@@ -731,6 +1199,7 @@ cmd_add() {
731
1199
  printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
732
1200
  chmod 600 "$(account_creds "${name}")"
733
1201
  save_meta_email "${name}"
1202
+ add_to_order "${name}"
734
1203
  echo "${name}" > "${CURRENT_FILE}"
735
1204
  ok "Account '${B}${name}${R}' added ${D}$(get_meta_email "${name}")${R}"
736
1205
  }
@@ -753,6 +1222,7 @@ cmd_save() {
753
1222
  [[ ${saved} -eq 0 ]] && { err "No credentials found — log in first with: claude /login"; exit 1; }
754
1223
 
755
1224
  save_meta_email "${name}"
1225
+ add_to_order "${name}"
756
1226
  echo "${name}" > "${CURRENT_FILE}"
757
1227
  ok "Account '${B}${name}${R}' saved ${D}$(get_meta_email "${name}")${R}"
758
1228
  }
@@ -851,6 +1321,7 @@ cmd_remove() {
851
1321
  read -r c
852
1322
  [[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
853
1323
  rm -f "$(account_creds "${name}")" "$(account_meta "${name}")"
1324
+ remove_from_order "${name}"
854
1325
  [[ "$(current_name)" == "${name}" ]] && rm -f "${CURRENT_FILE}"
855
1326
  ok "Deleted '${name}' (sessions are unaffected)"
856
1327
  }
@@ -933,6 +1404,7 @@ cmd_rename() {
933
1404
 
934
1405
  mv "$(account_creds "${old}")" "$(account_creds "${new}")"
935
1406
  [[ -f "$(account_meta "${old}")" ]] && mv "$(account_meta "${old}")" "$(account_meta "${new}")"
1407
+ rename_in_order "${old}" "${new}"
936
1408
  [[ "$(current_name)" == "${old}" ]] && echo "${new}" > "${CURRENT_FILE}"
937
1409
  ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
938
1410
  }
@@ -1239,6 +1711,22 @@ def load_raw_config():
1239
1711
  log_event('config_parse_error', error=str(e))
1240
1712
  return {}
1241
1713
 
1714
+ def _read_order(creds_dir):
1715
+ order_file = os.path.join(os.path.dirname(creds_dir), 'order')
1716
+ on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
1717
+ ordered = []
1718
+ if os.path.exists(order_file):
1719
+ for line in open(order_file):
1720
+ n = line.strip()
1721
+ if n in on_disk and n not in ordered:
1722
+ ordered.append(n)
1723
+ for n in sorted(on_disk):
1724
+ if n not in ordered:
1725
+ ordered.append(n)
1726
+ with open(order_file, 'w') as f:
1727
+ f.write('\n'.join(ordered) + ('\n' if ordered else ''))
1728
+ return ordered
1729
+
1242
1730
  def load_config():
1243
1731
  try:
1244
1732
  return json.load(open(CONFIG_FILE))
@@ -1246,7 +1734,7 @@ def load_config():
1246
1734
  # Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
1247
1735
  if not os.path.isdir(CREDS_DIR):
1248
1736
  return None
1249
- accounts = sorted(f[:-5] for f in os.listdir(CREDS_DIR) if f.endswith('.json'))
1737
+ accounts = _read_order(CREDS_DIR)
1250
1738
  if len(accounts) < 2:
1251
1739
  return None
1252
1740
  return {
@@ -1335,6 +1823,44 @@ if __name__ == '__main__':
1335
1823
  main()
1336
1824
  DAEMON_EOF
1337
1825
  chmod 755 "${AUTOSWITCH_DAEMON}"
1826
+ _read_version > "${RELAY_DIR}/daemon_version"
1827
+ }
1828
+
1829
+ # Restarts the already-installed daemon service in place (launchd/systemd/cron),
1830
+ # without touching plist/service file contents. Used after a silent redeploy.
1831
+ _restart_daemon_service() {
1832
+ if [[ "$(uname)" == "Darwin" ]] && [[ -f "${AUTOSWITCH_PLIST}" ]]; then
1833
+ launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
1834
+ launchctl load "${AUTOSWITCH_PLIST}"
1835
+ elif command -v systemctl >/dev/null 2>&1 && [[ -f "${AUTOSWITCH_SERVICE}" ]]; then
1836
+ systemctl --user restart relay-autoswitch
1837
+ else
1838
+ # cron fallback: no long-lived unit to restart — kill the running instance so
1839
+ # the next cron tick (daemon's own lock file) starts a fresh copy of the file
1840
+ # we just wrote via _extract_daemon.
1841
+ local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
1842
+ [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true
1843
+ fi
1844
+ }
1845
+
1846
+ # Runs on every invocation (cheap no-op unless autoswitch is actually running):
1847
+ # if relay itself was updated (npm/git/direct) since the daemon file on disk was
1848
+ # generated, silently regenerate it from the current script and restart it —
1849
+ # so daemon-side fixes (warmup engine, credential lock, etc.) don't require the
1850
+ # user to remember to run `relay autoswitch start` again after every update.
1851
+ _maybe_redeploy_daemon() {
1852
+ [[ -f "${AUTOSWITCH_DAEMON}" ]] || return 0
1853
+ local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
1854
+ [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null || return 0
1855
+
1856
+ local deployed; deployed=$(cat "${RELAY_DIR}/daemon_version" 2>/dev/null || echo "")
1857
+ local current; current=$(_read_version)
1858
+ [[ "${current}" == "unknown" ]] && return 0
1859
+ [[ "${deployed}" == "${current}" ]] && return 0
1860
+
1861
+ _extract_daemon
1862
+ _restart_daemon_service
1863
+ warn "relay updated to ${current} — autoswitch daemon redeployed and restarted"
1338
1864
  }
1339
1865
 
1340
1866
  cmd_autoswitch_config() {
@@ -1352,41 +1878,7 @@ cmd_autoswitch_config() {
1352
1878
  # ── Step 1: switch order ───────────────────────────────────────
1353
1879
  echo ""
1354
1880
  printf " ${B}Step 1 / 3 — Switch order${R}\n"
1355
- printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n"
1356
- local i=1
1357
- for acct in "${accounts[@]}"; do
1358
- printf " ${D}%d${R} %s\n" "${i}" "${acct}"
1359
- i=$((i+1))
1360
- done
1361
- echo ""
1362
- printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D} or just Enter to keep the order above):${R}\n"
1363
- printf " > "; read -r order_input
1364
-
1365
- # resolve numbers (space or comma) to names; empty = default order
1366
- local order_str; order_str=$(echo "${order_input}" | "${PY}" - "${accounts[@]}" <<'PYEOF'
1367
- import sys, re
1368
- raw = sys.stdin.read().strip()
1369
- accts = sys.argv[1:]
1370
- if not raw:
1371
- print(','.join(accts))
1372
- else:
1373
- tokens = re.split(r'[\s,]+', raw)
1374
- result = []
1375
- for t in tokens:
1376
- t = t.strip()
1377
- if t.isdigit():
1378
- idx = int(t) - 1
1379
- if 0 <= idx < len(accts): result.append(accts[idx])
1380
- elif t:
1381
- result.append(t)
1382
- print(','.join(result))
1383
- PYEOF
1384
- )
1385
- # show resolved order as a visual chain
1386
- local chain; chain=$(echo "${order_str}" | "${PY}" -c "
1387
- import sys; names=sys.stdin.read().strip().split(',')
1388
- print(' → '.join(names) + ' → (cycle)')")
1389
- printf " ${D}Order: ${CY}%s${R}\n" "${chain}"
1881
+ local order_str; order_str=$(prompt_reorder "${accounts[@]}")
1390
1882
 
1391
1883
  # ── Step 2: thresholds ────────────────────────────────────────
1392
1884
  echo ""
@@ -1684,6 +2176,62 @@ cmd_autoswitch() {
1684
2176
  esac
1685
2177
  }
1686
2178
 
2179
+ cmd_reorder() {
2180
+ hdr "reorder accounts"
2181
+
2182
+ local accounts=()
2183
+ local name
2184
+ while IFS= read -r name; do accounts+=("${name}"); done < <(list_account_names)
2185
+
2186
+ if [[ ${#accounts[@]} -eq 0 ]]; then
2187
+ err "No accounts found. Run: relay add <name>"
2188
+ exit 1
2189
+ fi
2190
+
2191
+ local order_str; order_str=$(prompt_reorder "${accounts[@]}")
2192
+
2193
+ if [[ -z "${order_str}" ]]; then
2194
+ err "No valid order given — nothing changed"
2195
+ exit 1
2196
+ fi
2197
+
2198
+ local order_arr=()
2199
+ IFS=',' read -ra order_arr <<< "${order_str}"
2200
+
2201
+ local account ordered found
2202
+ for account in ${accounts[@]+"${accounts[@]}"}; do
2203
+ found=0
2204
+ for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
2205
+ [[ "${ordered}" == "${account}" ]] && { found=1; break; }
2206
+ done
2207
+ [[ "${found}" -eq 0 ]] && order_arr+=("${account}")
2208
+ done
2209
+
2210
+ order_str=""
2211
+ for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
2212
+ if [[ -z "${order_str}" ]]; then
2213
+ order_str="${ordered}"
2214
+ else
2215
+ order_str="${order_str},${ordered}"
2216
+ fi
2217
+ done
2218
+
2219
+ printf '%s\n' "${order_arr[@]}" > "${ORDER_FILE}"
2220
+ ok "Order saved to ${ORDER_FILE}"
2221
+
2222
+ local cfg="${RELAY_DIR}/autoswitch.json"
2223
+ if [[ -f "${cfg}" ]]; then
2224
+ "${PY}" - "${cfg}" "${order_str}" <<'PYEOF'
2225
+ import json, sys
2226
+ cfg_path, order_str = sys.argv[1], sys.argv[2]
2227
+ cfg = json.load(open(cfg_path))
2228
+ cfg['order'] = order_str.split(',')
2229
+ json.dump(cfg, open(cfg_path, 'w'), indent=2)
2230
+ PYEOF
2231
+ ok "Also updated order in ${cfg}"
2232
+ fi
2233
+ }
2234
+
1687
2235
  cmd_lock() {
1688
2236
  local name="${1:-}"
1689
2237
  local cfg="${RELAY_DIR}/autoswitch.json"
@@ -1717,7 +2265,19 @@ PYEOF
1717
2265
  "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1718
2266
  import json, sys, os
1719
2267
  creds_dir, cfg_path = sys.argv[1], sys.argv[2]
1720
- accounts = sorted(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
2268
+ order_file = os.path.join(os.path.dirname(creds_dir), 'order')
2269
+ on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
2270
+ accounts = []
2271
+ if os.path.exists(order_file):
2272
+ for line in open(order_file):
2273
+ n = line.strip()
2274
+ if n in on_disk and n not in accounts:
2275
+ accounts.append(n)
2276
+ for n in sorted(on_disk):
2277
+ if n not in accounts:
2278
+ accounts.append(n)
2279
+ with open(order_file, 'w') as f:
2280
+ f.write('\n'.join(accounts) + ('\n' if accounts else ''))
1721
2281
  config = {
1722
2282
  'order': accounts,
1723
2283
  'thresholds': {a: 80 for a in accounts},
@@ -1784,7 +2344,19 @@ _warmup_ensure_config() {
1784
2344
  "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1785
2345
  import json, sys, os
1786
2346
  creds_dir, cfg_path = sys.argv[1], sys.argv[2]
1787
- accounts = sorted(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else []
2347
+ on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
2348
+ order_file = os.path.join(os.path.dirname(creds_dir), 'order')
2349
+ accounts = []
2350
+ if os.path.exists(order_file):
2351
+ for line in open(order_file):
2352
+ n = line.strip()
2353
+ if n in on_disk and n not in accounts:
2354
+ accounts.append(n)
2355
+ for n in sorted(on_disk):
2356
+ if n not in accounts:
2357
+ accounts.append(n)
2358
+ with open(order_file, 'w') as f:
2359
+ f.write('\n'.join(accounts) + ('\n' if accounts else ''))
1788
2360
  config = {
1789
2361
  'order': accounts,
1790
2362
  'thresholds': {a: 80 for a in accounts},
@@ -2164,6 +2736,8 @@ cmd_help() {
2164
2736
  printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
2165
2737
  printf " %-32s %s\n" " relay save <name>" "save current login state"
2166
2738
  printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
2739
+ printf " %-32s %s\n" " relay reorder" "change account display/switch order"
2740
+ printf " %-32s %s\n" " relay run <name>" "one-off session on an account or provider"
2167
2741
  printf " %-32s %s\n" " relay list" "full list with weekly usage"
2168
2742
  printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
2169
2743
  printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
@@ -2179,6 +2753,13 @@ cmd_help() {
2179
2753
  printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
2180
2754
  printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
2181
2755
  echo ""
2756
+ printf " ${B}LiteLLM providers${R}\n"
2757
+ printf " %-32s %s\n" " relay provider add <name> --base-url <url> --token <token>" "add a provider"
2758
+ printf " %-32s %s\n" " relay provider list" "list configured providers"
2759
+ printf " %-32s %s\n" " relay provider use <name>" "route all future sessions through this provider"
2760
+ printf " %-32s %s\n" " relay provider off" "stop routing through a provider, resume subscription"
2761
+ printf " %-32s %s\n" " relay provider remove <name>" "delete a provider"
2762
+ echo ""
2182
2763
  printf " ${B}Warmup${R}\n"
2183
2764
  printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
2184
2765
  printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
@@ -2194,6 +2775,8 @@ cmd_help() {
2194
2775
  _show_update_notice
2195
2776
  }
2196
2777
 
2778
+ _maybe_redeploy_daemon
2779
+
2197
2780
  # ══════════════════════════════════════════════════════════════════
2198
2781
  # Dispatch — single entry point, no fall-through
2199
2782
  # ══════════════════════════════════════════════════════════════════
@@ -2222,6 +2805,19 @@ case "${CMD}" in
2222
2805
  status|st) cmd_status ;;
2223
2806
  remove|rm|del) cmd_remove "$@" ;;
2224
2807
  rename|mv) cmd_rename "$@" ;;
2808
+ reorder) cmd_reorder "$@" ;;
2809
+ provider|prov)
2810
+ sub="${1:-}"
2811
+ [[ -n "${1:-}" ]] && shift
2812
+ case "${sub}" in
2813
+ add) cmd_provider_add "$@" ;;
2814
+ list|ls) cmd_provider_list ;;
2815
+ use) cmd_provider_use "$@" ;;
2816
+ off) cmd_provider_off ;;
2817
+ remove|rm) cmd_provider_remove "$@" ;;
2818
+ *) err "usage: relay provider <add|list|use|off|remove> ..."; exit 1 ;;
2819
+ esac ;;
2820
+ run) cmd_run "$@" ;;
2225
2821
  sessions|sess) cmd_sessions ;;
2226
2822
  autoswitch|as) cmd_autoswitch "$@" ;;
2227
2823
  lock) cmd_lock "$@" ;;