@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.
- package/README.md +43 -1
- package/package.json +1 -1
- package/relay +477 -33
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
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 " %-
|
|
2357
|
-
printf " %-
|
|
2358
|
-
printf " %-
|
|
2359
|
-
printf " %-
|
|
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 " %-
|
|
2363
|
-
printf " %-
|
|
2364
|
-
printf " %-
|
|
2365
|
-
printf " %-
|
|
2366
|
-
printf " %-
|
|
2367
|
-
printf " %-
|
|
2368
|
-
printf " %-
|
|
2369
|
-
printf " %-
|
|
2370
|
-
printf " %-
|
|
2371
|
-
printf " %-
|
|
2372
|
-
printf " %-
|
|
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
|
-
|
|
2380
|
-
printf "
|
|
2381
|
-
printf " %-
|
|
2382
|
-
printf " %-
|
|
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 " %-
|
|
2386
|
-
printf " %-
|
|
2387
|
-
printf " %-
|
|
2388
|
-
printf " %-
|
|
2389
|
-
printf " %-
|
|
2390
|
-
printf " %-
|
|
2391
|
-
printf " %-
|
|
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 "$@" ;;
|