@dst-justin/relay 2.3.1 → 2.5.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 +43 -1
  2. package/package.json +1 -1
  3. package/relay +477 -33
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,15 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
255
288
 
256
289
  ## Changelog
257
290
 
291
+ ### v2.5.0 — 2026-08-12
292
+ - Add `--subagent-model` to `relay provider add`, storing a `subagent_model` field that `relay provider use`/`relay run` inject as `CLAUDE_CODE_SUBAGENT_MODEL` — lets a LiteLLM provider pin the subagent model independently of the main `ANTHROPIC_MODEL`.
293
+ - `relay provider list` now also shows `subagent_model` when set.
294
+ - Expand `relay help` with every subcommand's flags (provider add's `--base-url`/`--token`/`--model`/`--subagent-model`/`--discover-models`, `list -f`/`--no-usage`, `run -- <args>`, `warmup test`, aliases, etc.)
295
+
296
+ ### v2.4.0 — 2026-07-25
297
+ - 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).
298
+ - Add `relay run <name>` for a one-off session pinned to a specific account or provider, independent of any global switch.
299
+
258
300
  ### v2.3.1 — 2026-07-12
259
301
  - 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
260
302
  - 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.3.1",
3
+ "version": "2.5.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.
@@ -266,6 +293,145 @@ require_claude() {
266
293
  exit 1
267
294
  }
268
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_SUBAGENT_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_SUBAGENT_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
+
269
435
  # ══════════════════════════════════════════════════════════════════
270
436
  # Python core: parallel usage fetch + table rendering
271
437
  # args: <mode: quick|full> <creds_dir> <meta_dir> <current_name> [--no-usage]
@@ -521,6 +687,11 @@ _do_switch_locked() {
521
687
  local name="$1"
522
688
  local current; current=$(current_name)
523
689
 
690
+ if [[ -n "$(active_provider_name)" ]]; then
691
+ _settings_env_restore_locked
692
+ rm -f "${ACTIVE_PROVIDER_FILE}"
693
+ fi
694
+
524
695
  if [[ "${current}" == "${name}" ]]; then
525
696
  ok "Already on account '${B}${name}${R}'"
526
697
  return 0
@@ -560,8 +731,16 @@ _sync_current_creds() {
560
731
  [[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
561
732
  }
562
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
+
563
741
  cmd_quick() {
564
742
  _check_update_bg
743
+ _provider_banner
565
744
  _sync_current_creds
566
745
  render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
567
746
  _show_update_notice
@@ -607,6 +786,7 @@ cmd_list() {
607
786
  }
608
787
 
609
788
  _cmd_status_once() {
789
+ _provider_banner
610
790
  _sync_current_creds
611
791
  local current; current=$(current_name)
612
792
  hdr "Current Status"
@@ -784,12 +964,217 @@ PYEOF
784
964
  fi
785
965
  }
786
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>] [--subagent-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="" subagent_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
+ --subagent-model) subagent_model="${2:-}"; shift 2 ;;
991
+ --discover-models) discover=1; shift ;;
992
+ *) err "unknown option: $1"; exit 1 ;;
993
+ esac
994
+ done
995
+
996
+ [[ -z "${base_url}" ]] && { err "--base-url is required"; exit 1; }
997
+ [[ -z "${token}" ]] && { err "--token is required"; exit 1; }
998
+ case "${base_url}" in
999
+ http://*|https://*) ;;
1000
+ *) err "--base-url must start with http:// or https://"; exit 1 ;;
1001
+ esac
1002
+
1003
+ RELAY_PROVIDER_PATH="$(provider_file "${name}")" \
1004
+ RELAY_PROVIDER_BASE_URL="${base_url}" \
1005
+ RELAY_PROVIDER_TOKEN="${token}" \
1006
+ RELAY_PROVIDER_MODEL="${model}" \
1007
+ RELAY_PROVIDER_SUBAGENT_MODEL="${subagent_model}" \
1008
+ RELAY_PROVIDER_DISCOVER="${discover}" \
1009
+ "${PY}" -c '
1010
+ import json, os
1011
+ path = os.environ["RELAY_PROVIDER_PATH"]
1012
+ base_url = os.environ["RELAY_PROVIDER_BASE_URL"]
1013
+ token = os.environ["RELAY_PROVIDER_TOKEN"]
1014
+ model = os.environ.get("RELAY_PROVIDER_MODEL", "")
1015
+ subagent_model = os.environ.get("RELAY_PROVIDER_SUBAGENT_MODEL", "")
1016
+ discover = os.environ.get("RELAY_PROVIDER_DISCOVER", "")
1017
+ d = {"base_url": base_url, "auth_token": token}
1018
+ if model:
1019
+ d["model"] = model
1020
+ if subagent_model:
1021
+ d["subagent_model"] = subagent_model
1022
+ if discover == "1":
1023
+ d["discover_models"] = True
1024
+ with open(path, "w") as f:
1025
+ json.dump(d, f)
1026
+ os.chmod(path, 0o600)
1027
+ '
1028
+
1029
+ ok "Provider '${B}${name}${R}' added ${D}${base_url}${R}"
1030
+ }
1031
+
1032
+ cmd_provider_list() {
1033
+ hdr "LiteLLM providers"
1034
+ local names; names=$(list_provider_names)
1035
+ if [[ -z "${names}" ]]; then
1036
+ warn "No providers configured — add one with: relay provider add <name> --base-url <url> --token <token>"
1037
+ return 0
1038
+ fi
1039
+ local active; active=$(active_provider_name)
1040
+ local name base_url model subagent_model info
1041
+ while IFS= read -r name; do
1042
+ [[ -z "${name}" ]] && continue
1043
+ base_url=$(_provider_field "${name}" base_url)
1044
+ model=$(_provider_field "${name}" model)
1045
+ subagent_model=$(_provider_field "${name}" subagent_model)
1046
+ info="${base_url}"
1047
+ [[ -n "${model}" ]] && info="${info} model=${model}"
1048
+ [[ -n "${subagent_model}" ]] && info="${info} subagent_model=${subagent_model}"
1049
+ if [[ "${name}" == "${active}" ]]; then
1050
+ printf " ${GR}${B}✓ %-16s${R} ${D}%s${R}\n" "${name}" "${info}"
1051
+ else
1052
+ printf " %-16s ${D}%s${R}\n" "${name}" "${info}"
1053
+ fi
1054
+ done <<< "${names}"
1055
+ }
1056
+
1057
+ cmd_provider_use() {
1058
+ local name="${1:-}"
1059
+ [[ -z "${name}" ]] && { err "usage: relay provider use <name>"; exit 1; }
1060
+ provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
1061
+
1062
+ local base_url token model subagent_model discover
1063
+ base_url=$(_provider_field "${name}" base_url)
1064
+ token=$(_provider_field "${name}" auth_token)
1065
+ model=$(_provider_field "${name}" model)
1066
+ subagent_model=$(_provider_field "${name}" subagent_model)
1067
+ discover=$(_provider_discover "${name}")
1068
+
1069
+ local pairs=("ANTHROPIC_BASE_URL=${base_url}" "ANTHROPIC_AUTH_TOKEN=${token}")
1070
+ [[ -n "${model}" ]] && pairs+=("ANTHROPIC_MODEL=${model}")
1071
+ [[ -n "${subagent_model}" ]] && pairs+=("CLAUDE_CODE_SUBAGENT_MODEL=${subagent_model}")
1072
+ [[ -n "${discover}" ]] && pairs+=("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1")
1073
+
1074
+ _settings_env_activate "${pairs[@]}" || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
1075
+ printf '%s' "${name}" > "${ACTIVE_PROVIDER_FILE}"
1076
+
1077
+ printf "\n ${GR}${B}⚡ provider active → %s${R} ${D}%s${R}\n" "${name}" "${base_url}"
1078
+ printf " ${D}Active sessions pick up the switch on next message. New session: ${CY}claude -c${R}\n\n"
1079
+ }
1080
+
1081
+ cmd_provider_off() {
1082
+ local active; active=$(active_provider_name)
1083
+ if [[ -z "${active}" ]]; then
1084
+ warn "No provider is currently active"
1085
+ return 0
1086
+ fi
1087
+ _settings_env_restore || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
1088
+ rm -f "${ACTIVE_PROVIDER_FILE}"
1089
+ ok "Provider mode off — subscription account resumes"
1090
+ }
1091
+
1092
+ cmd_provider_remove() {
1093
+ local name="${1:-}"
1094
+ [[ -z "${name}" ]] && { err "usage: relay provider remove <name>"; exit 1; }
1095
+ provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
1096
+ printf "\n ${YL}Delete provider '${B}${name}${R}${YL}'? (y/N) ${R}"
1097
+ read -r c
1098
+ [[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
1099
+ if [[ "$(active_provider_name)" == "${name}" ]]; then
1100
+ cmd_provider_off
1101
+ fi
1102
+ rm -f "$(provider_file "${name}")"
1103
+ ok "Deleted provider '${name}'"
1104
+ }
1105
+
1106
+ cmd_run() {
1107
+ local name="${1:-}"
1108
+ [[ -z "${name}" ]] && { err "usage: relay run <name> [-- <claude args...>]"; exit 1; }
1109
+ shift
1110
+ [[ "${1:-}" == "--" ]] && shift
1111
+
1112
+ require_claude
1113
+
1114
+ if account_exists "${name}"; then
1115
+ do_switch "${name}"
1116
+ exec "${REAL_CLAUDE}" "$@"
1117
+ elif provider_exists "${name}"; then
1118
+ local base_url token model subagent_model discover settings_file
1119
+ base_url=$(_provider_field "${name}" base_url)
1120
+ token=$(_provider_field "${name}" auth_token)
1121
+ model=$(_provider_field "${name}" model)
1122
+ subagent_model=$(_provider_field "${name}" subagent_model)
1123
+ discover=$(_provider_discover "${name}")
1124
+
1125
+ # Opportunistic cleanup: a prior `relay run <provider>` can't clean up
1126
+ # after itself (exec replaces the process, so no trap ever fires) —
1127
+ # sweep anything old enough that the session which created it has
1128
+ # almost certainly ended, so these don't accumulate indefinitely.
1129
+ find "${RELAY_DIR}" -maxdepth 1 -name '.run-settings.*' -mtime +1 -delete 2>/dev/null || true
1130
+
1131
+ # Written to a chmod-600 file under RELAY_DIR (mktemp's default file
1132
+ # mode is already 0600) rather than passed inline via `claude`'s own
1133
+ # argv — the exec'd claude process's cmdline would otherwise expose
1134
+ # this provider's auth token to any local user via `ps`/`ps aux` for
1135
+ # the entire session lifetime.
1136
+ settings_file=$(mktemp "${RELAY_DIR}/.run-settings.XXXXXX")
1137
+ RELAY_RUN_BASE_URL="${base_url}" \
1138
+ RELAY_RUN_TOKEN="${token}" \
1139
+ RELAY_RUN_MODEL="${model}" \
1140
+ RELAY_RUN_SUBAGENT_MODEL="${subagent_model}" \
1141
+ RELAY_RUN_DISCOVER="${discover}" \
1142
+ RELAY_RUN_SETTINGS_FILE="${settings_file}" \
1143
+ "${PY}" -c '
1144
+ import json, os
1145
+ base_url = os.environ["RELAY_RUN_BASE_URL"]
1146
+ token = os.environ["RELAY_RUN_TOKEN"]
1147
+ model = os.environ.get("RELAY_RUN_MODEL", "")
1148
+ subagent_model = os.environ.get("RELAY_RUN_SUBAGENT_MODEL", "")
1149
+ discover = os.environ.get("RELAY_RUN_DISCOVER", "")
1150
+ env = {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": token}
1151
+ if model:
1152
+ env["ANTHROPIC_MODEL"] = model
1153
+ if subagent_model:
1154
+ env["CLAUDE_CODE_SUBAGENT_MODEL"] = subagent_model
1155
+ if discover == "1":
1156
+ env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
1157
+ with open(os.environ["RELAY_RUN_SETTINGS_FILE"], "w") as f:
1158
+ json.dump({"env": env}, f)
1159
+ '
1160
+
1161
+ exec "${REAL_CLAUDE}" --settings "${settings_file}" "$@"
1162
+ else
1163
+ err "Unknown account or provider: ${name}"
1164
+ exit 1
1165
+ fi
1166
+ }
1167
+
787
1168
  cmd_add() {
788
1169
  local name="${1:-}"
789
1170
  [[ -z "${name}" ]] && { err "usage: relay add <name>"; exit 1; }
790
1171
  case "${name}" in
791
1172
  *[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
792
1173
  esac
1174
+ if provider_exists "${name}"; then
1175
+ err "'${name}' is already a LiteLLM provider — pick a different account name"
1176
+ exit 1
1177
+ fi
793
1178
  require_claude
794
1179
  if account_exists "${name}"; then
795
1180
  warn "Account '${name}' already exists"
@@ -2352,44 +2737,91 @@ cmd_uninstall() {
2352
2737
 
2353
2738
  cmd_help() {
2354
2739
  printf "\n${B}${CY} relay${R} ${D}%s — multi-account switcher for Claude Code${R}\n\n" "$(_read_version)"
2740
+
2355
2741
  printf " ${B}Inside Claude Code (prefix with !)${R}\n"
2356
- printf " %-32s %s\n" " !relay" "account menu + 5hr usage"
2357
- printf " %-32s %s\n" " !relay 2" "switch to account #2"
2358
- printf " %-32s %s\n" " !relay work" "switch to named account"
2359
- printf " %-32s %s\n" " !relay status" "detailed usage for current account"
2742
+ printf " %-42s %s\n" " !relay" "account menu + 5hr usage"
2743
+ printf " %-42s %s\n" " !relay 2" "switch to account #2 (by index shown in the menu)"
2744
+ printf " %-42s %s\n" " !relay work" "switch to account named 'work'"
2745
+ printf " %-42s %s\n" " !relay status" "detailed usage for current account"
2360
2746
  echo ""
2747
+
2361
2748
  printf " ${B}Account management${R}\n"
2362
- printf " %-32s %s\n" " relay add <name>" "add account via browser login"
2363
- printf " %-32s %s\n" " relay add-force <name>" "force re-login for existing account"
2364
- printf " %-32s %s\n" " relay refresh <name>" "re-login to refresh an expired token"
2365
- printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
2366
- printf " %-32s %s\n" " relay save <name>" "save current login state"
2367
- printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
2368
- printf " %-32s %s\n" " relay reorder" "change account display/switch order"
2369
- printf " %-32s %s\n" " relay list" "full list with weekly usage"
2370
- printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
2371
- printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
2372
- printf " %-32s %s\n" " relay status -f" "live-refresh current account status"
2373
- printf " %-32s %s\n" " relay remove <name>" "delete an account"
2374
- printf " %-32s %s\n" " relay sessions" "show all sessions"
2375
- printf " %-32s %s\n" " relay version" "show current version"
2376
- printf " %-32s %s\n" " relay update" "update to latest version"
2377
- printf " %-32s %s\n" " relay uninstall" "remove relay and all account data"
2749
+ printf " %-42s %s\n" " relay add <name>" "add account via browser login (opens claude /login)"
2750
+ printf " %-42s %s\n" " relay add-force <name>" "wipe stored creds/meta first, then force re-login"
2751
+ printf " %-42s %s\n" " relay refresh <name>" "re-login to refresh an expired/expiring token"
2752
+ printf " %-42s %s\n" " relay refresh-all" "silent OAuth refresh for every stored account"
2753
+ printf " %-42s %s\n" " relay save <name>" "save whatever is currently logged in as <name>"
2754
+ printf " %-42s %s\n" " relay rename <old> <new>" "rename an account (updates order + current pointer)"
2755
+ printf " %-42s %s\n" " relay remove <name>" "delete an account (alias: rm, del)"
2756
+ printf " %-42s %s\n" " relay reorder" "interactively change account display/switch order"
2757
+ printf " %-42s %s\n" " relay switch <name|#>" "switch account by name or index (alias: sw, use)"
2758
+ printf " %-42s %s\n" " relay <name>" "bare name/index also switches (shortcut for switch)"
2759
+ printf " %-42s %s\n" " relay continue [name]" "optionally switch, then exec 'claude --continue' (alias: cont, c)"
2378
2760
  echo ""
2379
- printf " ${B}Autoswitch${R}\n"
2380
- printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
2381
- printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
2382
- printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
2761
+
2762
+ printf " ${B}Listing & status${R}\n"
2763
+ printf " %-42s %s\n" " relay list" "full account list with weekly usage (alias: ls)"
2764
+ printf " %-42s %s\n" " -f, --follow" "live-refresh mode, redraws every 30s (Ctrl+C to exit)"
2765
+ printf " %-42s %s\n" " --no-usage" "list without querying the usage API (fast, offline)"
2766
+ printf " %-42s %s\n" " relay status" "detailed usage for the current account (alias: st)"
2767
+ printf " %-42s %s\n" " -f" "live-refresh current account status"
2768
+ printf " %-42s %s\n" " relay sessions" "show all local Claude sessions (alias: sess)"
2769
+ echo ""
2770
+
2771
+ printf " ${B}One-off runs${R}\n"
2772
+ printf " %-42s %s\n" " relay run <name>" "one-off session on an account or provider, then exec claude"
2773
+ printf " %-42s %s\n" " relay run <name> -- <args>" "forward extra args to claude itself, e.g. -- --resume"
2774
+ printf " %-42s %s\n" " (leaves global switch/provider state untouched — safe for concurrent sessions)"
2775
+ echo ""
2776
+
2777
+ printf " ${B}LiteLLM providers${R}\n"
2778
+ printf " %-42s %s\n" " relay provider add <name>" "add a LiteLLM-compatible provider (alias: prov)"
2779
+ printf " %-42s %s\n" " --base-url <url>" "required: http(s):// base URL of the gateway"
2780
+ printf " %-42s %s\n" " --token <token>" "required: auth token/API key for the gateway"
2781
+ printf " %-42s %s\n" " --model <model>" "optional: pin ANTHROPIC_MODEL to this alias"
2782
+ printf " %-42s %s\n" " --subagent-model <model>" "optional: pin CLAUDE_CODE_SUBAGENT_MODEL"
2783
+ printf " %-42s %s\n" " " "(overrides every subagent's own model: frontmatter)"
2784
+ printf " %-42s %s\n" " --discover-models" "optional: enable gateway model discovery instead of pinning"
2785
+ printf " %-42s %s\n" " relay provider list" "list configured providers + their settings"
2786
+ printf " %-42s %s\n" " relay provider use <name>" "route all future sessions through this provider"
2787
+ printf " %-42s %s\n" " relay provider off" "stop routing through a provider, resume subscription account"
2788
+ printf " %-42s %s\n" " relay provider remove <name>" "delete a provider (prompts for confirmation)"
2789
+ echo ""
2790
+
2791
+ printf " ${B}Autoswitch daemon${R}\n"
2792
+ printf " %-42s %s\n" " relay autoswitch config" "interactive setup: order, thresholds, poll interval (alias: as)"
2793
+ printf " %-42s %s\n" " relay autoswitch start" "install + start the background launchd/systemd daemon"
2794
+ printf " %-42s %s\n" " relay autoswitch stop" "stop and uninstall the background daemon"
2795
+ printf " %-42s %s\n" " relay autoswitch status" "daemon running state + per-account thresholds"
2796
+ printf " %-42s %s\n" " relay autoswitch log" "tail the daemon's log file"
2797
+ printf " %-42s %s\n" " relay autoswitch" "no subcommand: config if unset, else status"
2383
2798
  echo ""
2799
+
2800
+ printf " ${B}Locking (autoswitch)${R}\n"
2801
+ printf " %-42s %s\n" " relay lock <name>" "exclude an account from autoswitch cycling when over limit"
2802
+ printf " %-42s %s\n" " relay unlock <name>" "remove a lock"
2803
+ printf " %-42s %s\n" " relay lock" "no argument: show all locked accounts"
2804
+ echo ""
2805
+
2384
2806
  printf " ${B}Warmup${R}\n"
2385
- printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
2386
- printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
2387
- printf " %-32s %s\n" " relay warmup list" "show scheduled warmups + last result"
2388
- printf " %-32s %s\n" " relay warmup pause/resume" "suspend/re-enable without deleting"
2389
- printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
2390
- printf " %-32s %s\n" " relay unlock <name>" "remove lock"
2391
- printf " %-32s %s\n" " relay lock" "show locked accounts"
2807
+ printf " %-42s %s\n" " relay warmup add <acct> <HH:MM>" "schedule a daily pre-warm ping (24h local time)"
2808
+ printf " %-42s %s\n" " relay warmup remove <acct> [HH:MM]" "remove one scheduled time, or all for <acct> if omitted (alias: rm)"
2809
+ printf " %-42s %s\n" " relay warmup list" "show scheduled warmups + last-run result (alias: ls)"
2810
+ printf " %-42s %s\n" " relay warmup test <acct>" "fire a warmup ping for <acct> right now, synchronously"
2811
+ printf " %-42s %s\n" " relay warmup pause" "suspend all warmups without deleting entries"
2812
+ printf " %-42s %s\n" " relay warmup resume" "re-enable previously paused warmups"
2813
+ printf " %-42s %s\n" " relay warmup" "no subcommand: same as 'relay warmup list'"
2814
+ printf " %-42s %s\n" " requires the autoswitch daemon running (relay autoswitch start) to actually fire"
2392
2815
  echo ""
2816
+
2817
+ printf " ${B}Maintenance${R}\n"
2818
+ printf " %-42s %s\n" " relay version" "show current version (alias: --version, -V)"
2819
+ printf " %-42s %s\n" " relay update" "update relay to latest version"
2820
+ printf " %-42s %s\n" " relay install" "(re)install shell integration / launcher"
2821
+ printf " %-42s %s\n" " relay uninstall" "remove relay and all account data"
2822
+ printf " %-42s %s\n" " relay help" "show this help (alias: --help, -h)"
2823
+ echo ""
2824
+
2393
2825
  printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
2394
2826
  printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
2395
2827
  _check_update_bg
@@ -2427,6 +2859,18 @@ case "${CMD}" in
2427
2859
  remove|rm|del) cmd_remove "$@" ;;
2428
2860
  rename|mv) cmd_rename "$@" ;;
2429
2861
  reorder) cmd_reorder "$@" ;;
2862
+ provider|prov)
2863
+ sub="${1:-}"
2864
+ [[ -n "${1:-}" ]] && shift
2865
+ case "${sub}" in
2866
+ add) cmd_provider_add "$@" ;;
2867
+ list|ls) cmd_provider_list ;;
2868
+ use) cmd_provider_use "$@" ;;
2869
+ off) cmd_provider_off ;;
2870
+ remove|rm) cmd_provider_remove "$@" ;;
2871
+ *) err "usage: relay provider <add|list|use|off|remove> ..."; exit 1 ;;
2872
+ esac ;;
2873
+ run) cmd_run "$@" ;;
2430
2874
  sessions|sess) cmd_sessions ;;
2431
2875
  autoswitch|as) cmd_autoswitch "$@" ;;
2432
2876
  lock) cmd_lock "$@" ;;