@dst-justin/relay 2.0.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 +176 -0
- package/package.json +42 -0
- package/relay +815 -0
- package/relay.cmd +7 -0
- package/relay.js +29 -0
- package/relay.ps1 +505 -0
package/relay
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
3
|
+
# relay v2 — multi-account switcher for Claude Code
|
|
4
|
+
# !relay account menu + usage
|
|
5
|
+
# !relay 2 switch by index
|
|
6
|
+
# !relay work switch by name
|
|
7
|
+
# compatible with macOS bash 3.2 / Linux / WSL
|
|
8
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
9
|
+
set -u
|
|
10
|
+
|
|
11
|
+
RELAY_DIR="${HOME}/.claude-relay"
|
|
12
|
+
CREDS_STORE="${RELAY_DIR}/credentials"
|
|
13
|
+
META_STORE="${RELAY_DIR}/meta"
|
|
14
|
+
CURRENT_FILE="${RELAY_DIR}/current"
|
|
15
|
+
CLAUDE_DIR="${HOME}/.claude"
|
|
16
|
+
CLAUDE_JSON="${HOME}/.claude.json"
|
|
17
|
+
REAL_CLAUDE=$(command -v claude 2>/dev/null || echo "")
|
|
18
|
+
# macOS: /usr/bin/python3 uses the system TLS stack (correct certs);
|
|
19
|
+
# /usr/local/bin/python3 (Homebrew/standalone) often lacks bundled certs → SSL failures
|
|
20
|
+
if [[ "$(uname)" == "Darwin" ]] && [[ -x "/usr/bin/python3" ]]; then
|
|
21
|
+
PY="/usr/bin/python3"
|
|
22
|
+
else
|
|
23
|
+
PY=$(command -v python3 || command -v python || echo "")
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Credential helpers — macOS Keychain or Linux file fallback
|
|
27
|
+
# Claude Code on macOS: Keychain service "Claude Code-credentials"
|
|
28
|
+
# Claude Code on Linux: ~/.claude/.credentials.json
|
|
29
|
+
CC_KC_SVC="Claude Code-credentials"
|
|
30
|
+
LINUX_CREDS="${CLAUDE_DIR}/.credentials.json"
|
|
31
|
+
|
|
32
|
+
kc_read() {
|
|
33
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
34
|
+
security find-generic-password -s "${CC_KC_SVC}" -a "$(whoami)" -w 2>/dev/null
|
|
35
|
+
else
|
|
36
|
+
[[ -f "${LINUX_CREDS}" ]] && cat "${LINUX_CREDS}" || echo ""
|
|
37
|
+
fi
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
kc_write() {
|
|
41
|
+
local content="$1"
|
|
42
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
43
|
+
local user; user=$(whoami)
|
|
44
|
+
security delete-generic-password -s "${CC_KC_SVC}" -a "${user}" >/dev/null 2>&1 || true
|
|
45
|
+
security add-generic-password -s "${CC_KC_SVC}" -a "${user}" -w "${content}" >/dev/null 2>&1
|
|
46
|
+
else
|
|
47
|
+
printf '%s' "${content}" > "${LINUX_CREDS}"
|
|
48
|
+
chmod 600 "${LINUX_CREDS}"
|
|
49
|
+
fi
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
R=$'\033[0m'; B=$'\033[1m'; D=$'\033[2m'
|
|
53
|
+
CY=$'\033[36m'; GR=$'\033[32m'; YL=$'\033[33m'; RD=$'\033[31m'; MG=$'\033[35m'
|
|
54
|
+
|
|
55
|
+
log() { printf " ${CY}→${R} %s\n" "$*"; }
|
|
56
|
+
ok() { printf " ${GR}✓${R} %s\n" "$*"; }
|
|
57
|
+
warn() { printf " ${YL}⚠${R} %s\n" "$*"; }
|
|
58
|
+
err() { printf " ${RD}✗${R} %s\n" "$*" >&2; }
|
|
59
|
+
hdr() { printf "\n${B}${MG} %s${R}\n ${D}─────────────────────────────────────${R}\n" "$*"; }
|
|
60
|
+
|
|
61
|
+
mkdir -p "${CREDS_STORE}" "${META_STORE}" "${CLAUDE_DIR}"
|
|
62
|
+
chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" 2>/dev/null || true
|
|
63
|
+
|
|
64
|
+
[[ -z "${PY}" ]] && { err "python3 is required"; exit 1; }
|
|
65
|
+
|
|
66
|
+
current_name() { [[ -f "${CURRENT_FILE}" ]] && cat "${CURRENT_FILE}" || echo ""; }
|
|
67
|
+
account_creds() { echo "${CREDS_STORE}/$1.json"; }
|
|
68
|
+
account_meta() { echo "${META_STORE}/$1"; }
|
|
69
|
+
account_exists() { [[ -f "$(account_creds "$1")" ]]; }
|
|
70
|
+
|
|
71
|
+
# list accounts sorted, one per line (bash 3.2 compatible)
|
|
72
|
+
list_account_names() {
|
|
73
|
+
local f
|
|
74
|
+
for f in "${CREDS_STORE}"/*.json; do
|
|
75
|
+
[[ -f "${f}" ]] || continue
|
|
76
|
+
basename "${f}" .json
|
|
77
|
+
done | sort
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
account_by_index() {
|
|
81
|
+
local idx="$1" i=1 name
|
|
82
|
+
while IFS= read -r name; do
|
|
83
|
+
[[ "${i}" -eq "${idx}" ]] && { echo "${name}"; return 0; }
|
|
84
|
+
i=$((i+1))
|
|
85
|
+
done < <(list_account_names)
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# read the logged-in email from ~/.claude.json (written by Claude Code after login)
|
|
90
|
+
grab_email_from_claude_json() {
|
|
91
|
+
[[ -f "${CLAUDE_JSON}" ]] || { echo ""; return; }
|
|
92
|
+
"${PY}" - "${CLAUDE_JSON}" 2>/dev/null <<'EOF'
|
|
93
|
+
import json, sys
|
|
94
|
+
try:
|
|
95
|
+
d = json.load(open(sys.argv[1]))
|
|
96
|
+
acct = d.get('oauthAccount') or {}
|
|
97
|
+
print(acct.get('emailAddress') or '')
|
|
98
|
+
except: print('')
|
|
99
|
+
EOF
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get_meta_email() {
|
|
103
|
+
local f; f=$(account_meta "$1")
|
|
104
|
+
[[ -f "${f}" ]] && cat "${f}" || echo "—"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
save_meta_email() {
|
|
108
|
+
local name="$1"
|
|
109
|
+
local email
|
|
110
|
+
email=$(grab_email_from_claude_json)
|
|
111
|
+
[[ -n "${email}" ]] && echo "${email}" > "$(account_meta "${name}")"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
require_account() {
|
|
115
|
+
account_exists "$1" && return 0
|
|
116
|
+
return 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
require_claude() {
|
|
120
|
+
[[ -n "${REAL_CLAUDE}" ]] && return 0
|
|
121
|
+
err "claude not found — install it with: npm i -g @anthropic-ai/claude-code"
|
|
122
|
+
exit 1
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# ══════════════════════════════════════════════════════════════════
|
|
126
|
+
# Python core: parallel usage fetch + table rendering
|
|
127
|
+
# args: <mode: quick|full> <creds_dir> <meta_dir> <current_name> [--no-usage]
|
|
128
|
+
# ══════════════════════════════════════════════════════════════════
|
|
129
|
+
render_table() {
|
|
130
|
+
"${PY}" - "$@" <<'EOF'
|
|
131
|
+
import sys, os, json, glob, datetime, urllib.request, urllib.error, urllib.parse, platform, subprocess
|
|
132
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
133
|
+
|
|
134
|
+
mode, creds_dir, meta_dir, current = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
135
|
+
no_usage = '--no-usage' in sys.argv
|
|
136
|
+
relay_dir = os.path.dirname(creds_dir)
|
|
137
|
+
cache_file = os.path.join(relay_dir, 'usage_cache.json')
|
|
138
|
+
CACHE_TTL = 120 # seconds
|
|
139
|
+
|
|
140
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'
|
|
141
|
+
CY='\033[36m'; GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; MG='\033[35m'
|
|
142
|
+
|
|
143
|
+
def color(u): return GR if u < 50 else (YL if u < 80 else RD)
|
|
144
|
+
|
|
145
|
+
def bar(u, w=10):
|
|
146
|
+
f = round(u/100*w)
|
|
147
|
+
return color(u) + '[' + '█'*f + '░'*(w-f) + ']' + R
|
|
148
|
+
|
|
149
|
+
def reset_in(iso):
|
|
150
|
+
try:
|
|
151
|
+
ts = datetime.datetime.fromisoformat(iso.replace('Z','+00:00'))
|
|
152
|
+
s = (ts - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
|
153
|
+
if s <= 0: return 'resetting'
|
|
154
|
+
h, rem = divmod(int(s), 3600)
|
|
155
|
+
return f'{h}h{rem//60:02d}m'
|
|
156
|
+
except Exception:
|
|
157
|
+
return '—'
|
|
158
|
+
|
|
159
|
+
names = sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(creds_dir, '*.json')))
|
|
160
|
+
if not names:
|
|
161
|
+
print(f' \033[33m⚠\033[0m No accounts yet. Run: {B}relay add <name>{R}')
|
|
162
|
+
sys.exit(0)
|
|
163
|
+
|
|
164
|
+
def get_email(name):
|
|
165
|
+
p = os.path.join(meta_dir, name)
|
|
166
|
+
try:
|
|
167
|
+
return open(p).read().strip() or '—'
|
|
168
|
+
except Exception:
|
|
169
|
+
return '—'
|
|
170
|
+
|
|
171
|
+
def update_live_creds(content):
|
|
172
|
+
if platform.system() == 'Darwin':
|
|
173
|
+
user = subprocess.run(['whoami'], capture_output=True, text=True).stdout.strip()
|
|
174
|
+
svc = 'Claude Code-credentials'
|
|
175
|
+
subprocess.run(['security', 'delete-generic-password', '-s', svc, '-a', user], capture_output=True)
|
|
176
|
+
subprocess.run(['security', 'add-generic-password', '-s', svc, '-a', user, '-w', content], capture_output=True)
|
|
177
|
+
else:
|
|
178
|
+
live = os.path.join(os.path.expanduser('~'), '.claude', '.credentials.json')
|
|
179
|
+
with open(live, 'w') as f: f.write(content)
|
|
180
|
+
os.chmod(live, 0o600)
|
|
181
|
+
|
|
182
|
+
def try_refresh(name, cred_path):
|
|
183
|
+
try:
|
|
184
|
+
d = json.load(open(cred_path))
|
|
185
|
+
oauth = d.get('claudeAiOauth') or {}
|
|
186
|
+
refresh_tok = oauth.get('refreshToken', '')
|
|
187
|
+
if not refresh_tok:
|
|
188
|
+
return None
|
|
189
|
+
params = urllib.parse.urlencode({'grant_type': 'refresh_token', 'refresh_token': refresh_tok}).encode()
|
|
190
|
+
req = urllib.request.Request(
|
|
191
|
+
'https://api.anthropic.com/token',
|
|
192
|
+
data=params,
|
|
193
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'relay/2.0'})
|
|
194
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
195
|
+
resp = json.loads(r.read())
|
|
196
|
+
oauth['accessToken'] = resp['access_token']
|
|
197
|
+
if 'refresh_token' in resp:
|
|
198
|
+
oauth['refreshToken'] = resp['refresh_token']
|
|
199
|
+
expires_in = resp.get('expires_in', 3600)
|
|
200
|
+
oauth['expiresAt'] = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000) + expires_in * 1000
|
|
201
|
+
d['claudeAiOauth'] = oauth
|
|
202
|
+
content = json.dumps(d)
|
|
203
|
+
with open(cred_path, 'w') as f: f.write(content)
|
|
204
|
+
os.chmod(cred_path, 0o600)
|
|
205
|
+
if name == current:
|
|
206
|
+
update_live_creds(content)
|
|
207
|
+
return oauth['accessToken']
|
|
208
|
+
except Exception:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
_cache = None
|
|
212
|
+
def load_cache():
|
|
213
|
+
global _cache
|
|
214
|
+
if _cache is not None:
|
|
215
|
+
return _cache
|
|
216
|
+
try:
|
|
217
|
+
_cache = json.load(open(cache_file))
|
|
218
|
+
except Exception:
|
|
219
|
+
_cache = {}
|
|
220
|
+
return _cache
|
|
221
|
+
|
|
222
|
+
def save_cache(c):
|
|
223
|
+
try:
|
|
224
|
+
with open(cache_file, 'w') as f: json.dump(c, f)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def fetch(name):
|
|
229
|
+
cred_path = os.path.join(creds_dir, name + '.json')
|
|
230
|
+
try:
|
|
231
|
+
d = json.load(open(cred_path))
|
|
232
|
+
oauth = d.get('claudeAiOauth') or {}
|
|
233
|
+
tok = oauth.get('accessToken', '')
|
|
234
|
+
if not tok:
|
|
235
|
+
return name, None
|
|
236
|
+
|
|
237
|
+
# Short-circuit: token is locally known to be expired
|
|
238
|
+
expires_at_ms = oauth.get('expiresAt', 0)
|
|
239
|
+
now_ms = datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000
|
|
240
|
+
if expires_at_ms and now_ms > expires_at_ms:
|
|
241
|
+
return name, 'expired'
|
|
242
|
+
|
|
243
|
+
# Check cache
|
|
244
|
+
c = load_cache()
|
|
245
|
+
entry = c.get(name)
|
|
246
|
+
if entry and now_ms / 1000 - entry.get('ts', 0) < CACHE_TTL:
|
|
247
|
+
return name, entry.get('data')
|
|
248
|
+
|
|
249
|
+
req = urllib.request.Request(
|
|
250
|
+
'https://api.anthropic.com/api/oauth/usage',
|
|
251
|
+
headers={'Authorization': 'Bearer ' + tok, 'User-Agent': 'relay/2.0'})
|
|
252
|
+
with urllib.request.urlopen(req, timeout=6) as r:
|
|
253
|
+
data = json.loads(r.read())
|
|
254
|
+
c[name] = {'ts': now_ms / 1000, 'data': data}
|
|
255
|
+
save_cache(c)
|
|
256
|
+
return name, data
|
|
257
|
+
except urllib.error.HTTPError as e:
|
|
258
|
+
if e.code == 401:
|
|
259
|
+
return name, 'expired'
|
|
260
|
+
return name, None
|
|
261
|
+
except Exception:
|
|
262
|
+
return name, None
|
|
263
|
+
|
|
264
|
+
usage = {}
|
|
265
|
+
if not no_usage:
|
|
266
|
+
sys.stderr.write(' \033[2mfetching usage...\033[0m\r')
|
|
267
|
+
sys.stderr.flush()
|
|
268
|
+
with ThreadPoolExecutor(max_workers=min(len(names), 6)) as ex:
|
|
269
|
+
for fut in as_completed([ex.submit(fetch, n) for n in names]):
|
|
270
|
+
n, d = fut.result()
|
|
271
|
+
usage[n] = d
|
|
272
|
+
sys.stderr.write(' ' * 30 + '\r')
|
|
273
|
+
sys.stderr.flush()
|
|
274
|
+
|
|
275
|
+
def u5_str(d):
|
|
276
|
+
if d == 'expired': return f'{YL}⚠ token expired{R}'
|
|
277
|
+
if not d: return '—'
|
|
278
|
+
fh = d.get('five_hour') or {}
|
|
279
|
+
u = int(fh.get('utilization', 0) or 0)
|
|
280
|
+
t = reset_in(fh.get('resets_at', ''))
|
|
281
|
+
c = color(u)
|
|
282
|
+
return f'{bar(u)} {c}{u:3d}%{R} {D}({t}){R}'
|
|
283
|
+
|
|
284
|
+
def u7_str(d, name=''):
|
|
285
|
+
if d == 'expired': return f'{YL}relay refresh {name}{R}' if name else f'{YL}relay refresh <name>{R}'
|
|
286
|
+
if not d: return '—'
|
|
287
|
+
sd = d.get('seven_day') or {}
|
|
288
|
+
u = sd.get('utilization')
|
|
289
|
+
if u is None: return '—'
|
|
290
|
+
u = int(u)
|
|
291
|
+
c = color(u)
|
|
292
|
+
f = round(u/100*8)
|
|
293
|
+
return f'{c}[' + '█'*f + '░'*(8-f) + f']{R} {c}{u}%{R}'
|
|
294
|
+
|
|
295
|
+
if mode == 'quick':
|
|
296
|
+
print(f'\n {B}{MG}relay{R} {D}— switch account{R}')
|
|
297
|
+
print(f' {D}' + '─'*45 + R)
|
|
298
|
+
for i, name in enumerate(names, 1):
|
|
299
|
+
cur = name == current
|
|
300
|
+
marker = f'{GR}{B}●{R}' if cur else f'{D}{i}{R}'
|
|
301
|
+
ncol = GR + B if cur else B
|
|
302
|
+
email = get_email(name)
|
|
303
|
+
u = u5_str(usage.get(name)) if not no_usage else ''
|
|
304
|
+
print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}')
|
|
305
|
+
print()
|
|
306
|
+
print(f' {D}switch:{R} {CY}!relay <index or name>{R} {D}details:{R} {CY}!relay status{R}')
|
|
307
|
+
print()
|
|
308
|
+
else:
|
|
309
|
+
print(f' {B}{"#":<3}{"account":<13} {"email":<28} {"5hr usage":<34} 7d usage{R}')
|
|
310
|
+
print(f' {D}' + '─'*88 + R)
|
|
311
|
+
for i, name in enumerate(names, 1):
|
|
312
|
+
cur = name == current
|
|
313
|
+
marker = f'{GR}●{R}' if cur else ' '
|
|
314
|
+
ncol = GR + B if cur else B
|
|
315
|
+
email = get_email(name)
|
|
316
|
+
d = usage.get(name)
|
|
317
|
+
u5 = u5_str(d) if not no_usage else '—'
|
|
318
|
+
u7 = u7_str(d, name) if not no_usage else '—'
|
|
319
|
+
# ANSI codes don't consume display width — pad manually for alignment
|
|
320
|
+
print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}')
|
|
321
|
+
print()
|
|
322
|
+
n_warn = sum(1 for d in usage.values() if isinstance(d, dict) and (d.get('five_hour') or {}).get('utilization', 0) >= 80)
|
|
323
|
+
if n_warn:
|
|
324
|
+
print(f' {RD}⚠ {n_warn} account(s) above 80% usage{R}')
|
|
325
|
+
EOF
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# ══════════════════════════════════════════════════════════════════
|
|
329
|
+
# Core switch logic
|
|
330
|
+
# ══════════════════════════════════════════════════════════════════
|
|
331
|
+
do_switch() {
|
|
332
|
+
local name="$1"
|
|
333
|
+
local current; current=$(current_name)
|
|
334
|
+
|
|
335
|
+
if [[ "${current}" == "${name}" ]]; then
|
|
336
|
+
ok "Already on account '${B}${name}${R}'"
|
|
337
|
+
return 0
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# Back up the current account's live token before switching
|
|
341
|
+
# (Claude Code refreshes tokens in-place; the snapshot may be stale)
|
|
342
|
+
if [[ -n "${current}" ]] && account_exists "${current}"; then
|
|
343
|
+
local live; live=$(kc_read 2>/dev/null)
|
|
344
|
+
[[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${current}")"
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
echo "${name}" > "${CURRENT_FILE}"
|
|
348
|
+
|
|
349
|
+
# Write the target account's credentials into the store Claude Code reads
|
|
350
|
+
local content; content=$(cat "$(account_creds "${name}")")
|
|
351
|
+
kc_write "${content}" || warn "Credential write failed — switch may not take effect"
|
|
352
|
+
|
|
353
|
+
local email; email=$(get_meta_email "${name}")
|
|
354
|
+
printf "\n ${GR}${B}✓ switched → %s${R} ${D}%s${R}\n" "${name}" "${email}"
|
|
355
|
+
printf " ${D}Restart claude to apply. Resume last session: ${CY}claude -c${R}\n\n"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# ══════════════════════════════════════════════════════════════════
|
|
359
|
+
# Commands
|
|
360
|
+
# ══════════════════════════════════════════════════════════════════
|
|
361
|
+
|
|
362
|
+
# Sync the current account's token from credential store to its snapshot file
|
|
363
|
+
# before displaying usage — prevents showing stale/expired tokens as "—"
|
|
364
|
+
_sync_current_creds() {
|
|
365
|
+
local cur; cur=$(current_name)
|
|
366
|
+
[[ -z "${cur}" ]] || ! account_exists "${cur}" && return
|
|
367
|
+
local live; live=$(kc_read 2>/dev/null)
|
|
368
|
+
[[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
cmd_quick() { _sync_current_creds; render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"; }
|
|
372
|
+
|
|
373
|
+
cmd_list() {
|
|
374
|
+
hdr "Account List"
|
|
375
|
+
_sync_current_creds
|
|
376
|
+
render_table full "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
|
|
377
|
+
echo ""
|
|
378
|
+
ok "Inside Claude Code: ${CY}!relay <index>${R} to switch"
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
cmd_status() {
|
|
382
|
+
_sync_current_creds
|
|
383
|
+
local current; current=$(current_name)
|
|
384
|
+
hdr "Current Status"
|
|
385
|
+
if [[ -z "${current}" ]]; then
|
|
386
|
+
warn "No account set (using system default)"
|
|
387
|
+
elif ! account_exists "${current}"; then
|
|
388
|
+
warn "Recorded account '${current}' no longer exists"
|
|
389
|
+
else
|
|
390
|
+
"${PY}" - "$(account_creds "${current}")" "${current}" "$(get_meta_email "${current}")" <<'EOF'
|
|
391
|
+
import json, sys, datetime, urllib.request
|
|
392
|
+
|
|
393
|
+
creds_path, name, email = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
394
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'
|
|
395
|
+
GR='\033[32m'; YL='\033[33m'; RD='\033[31m'; CY='\033[36m'
|
|
396
|
+
|
|
397
|
+
print(f' {B}Account:{R} {GR}{B}{name}{R}')
|
|
398
|
+
print(f' {B}Email:{R} {email}')
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
d = json.load(open(creds_path))
|
|
402
|
+
tok = (d.get('claudeAiOauth') or {}).get('accessToken', '')
|
|
403
|
+
if not tok:
|
|
404
|
+
print(f'\n {YL}⚠ No access token — please log in again{R}'); sys.exit(0)
|
|
405
|
+
req = urllib.request.Request(
|
|
406
|
+
'https://api.anthropic.com/api/oauth/usage',
|
|
407
|
+
headers={'Authorization': 'Bearer ' + tok, 'User-Agent': 'relay/2.0'})
|
|
408
|
+
with urllib.request.urlopen(req, timeout=8) as r:
|
|
409
|
+
u = json.loads(r.read())
|
|
410
|
+
except Exception as e:
|
|
411
|
+
print(f'\n {RD}✗ Usage query failed: {e}{R}'); sys.exit(0)
|
|
412
|
+
|
|
413
|
+
def color(x): return GR if x < 50 else (YL if x < 80 else RD)
|
|
414
|
+
def bar(x, w=24):
|
|
415
|
+
f = round(x/100*w); return '█'*f + '░'*(w-f)
|
|
416
|
+
def til(iso):
|
|
417
|
+
try:
|
|
418
|
+
ts = datetime.datetime.fromisoformat(iso.replace('Z','+00:00'))
|
|
419
|
+
s = (ts - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
|
420
|
+
if s <= 0: return 'resetting now'
|
|
421
|
+
h, rem = divmod(int(s), 3600)
|
|
422
|
+
return f'resets in {h}h {rem//60:02d}m'
|
|
423
|
+
except Exception: return '—'
|
|
424
|
+
|
|
425
|
+
fh = u.get('five_hour') or {}
|
|
426
|
+
u5 = int(fh.get('utilization', 0) or 0)
|
|
427
|
+
c5 = color(u5)
|
|
428
|
+
print(f'\n {B}5hr usage:{R}')
|
|
429
|
+
print(f' {c5}[{bar(u5)}]{R} {c5}{B}{u5}%{R}')
|
|
430
|
+
print(f' {D}{til(fh.get("resets_at",""))}{R}')
|
|
431
|
+
|
|
432
|
+
sd = u.get('seven_day') or {}
|
|
433
|
+
if sd and sd.get('utilization') is not None:
|
|
434
|
+
u7 = int(sd['utilization']); c7 = color(u7)
|
|
435
|
+
print(f'\n {B}7d usage:{R}')
|
|
436
|
+
print(f' {c7}[{bar(u7)}]{R} {c7}{u7}%{R}')
|
|
437
|
+
if sd.get('resets_at'):
|
|
438
|
+
print(f' {D}{til(sd["resets_at"])}{R}')
|
|
439
|
+
|
|
440
|
+
print()
|
|
441
|
+
if u5 >= 90: print(f' {RD}{B}⚠ Approaching limit — consider switching: !relay <other>{R}')
|
|
442
|
+
elif u5 >= 70: print(f' {YL}⚡ Usage is high — watch for rate limits{R}')
|
|
443
|
+
else: print(f' {GR}✓ Usage is normal{R}')
|
|
444
|
+
EOF
|
|
445
|
+
fi
|
|
446
|
+
local n=0
|
|
447
|
+
[[ -d "${CLAUDE_DIR}/projects" ]] && \
|
|
448
|
+
n=$(find "${CLAUDE_DIR}/projects" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ')
|
|
449
|
+
printf "\n ${B}Sessions:${R} %s (shared across all accounts in ~/.claude/projects/)\n" "${n}"
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
cmd_add() {
|
|
453
|
+
local name="${1:-}"
|
|
454
|
+
[[ -z "${name}" ]] && { err "usage: relay add <name>"; exit 1; }
|
|
455
|
+
case "${name}" in
|
|
456
|
+
*[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
|
|
457
|
+
esac
|
|
458
|
+
require_claude
|
|
459
|
+
if account_exists "${name}"; then
|
|
460
|
+
warn "Account '${name}' already exists"
|
|
461
|
+
log "To re-login: ${B}relay add-force ${name}${R}"
|
|
462
|
+
return 0
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
hdr "Add account: ${name}"
|
|
466
|
+
warn "Complete the browser login then return to this terminal"
|
|
467
|
+
echo ""
|
|
468
|
+
|
|
469
|
+
# Record the token before login to detect whether a real new login occurred
|
|
470
|
+
local tok_before; tok_before=$(kc_read 2>/dev/null | \
|
|
471
|
+
"${PY}" -c "import json,sys; d=json.load(sys.stdin); print((d.get('claudeAiOauth') or {}).get('accessToken',''))" 2>/dev/null || echo "")
|
|
472
|
+
|
|
473
|
+
"${REAL_CLAUDE}" /login || true
|
|
474
|
+
|
|
475
|
+
local kc_creds tok_after
|
|
476
|
+
kc_creds=$(kc_read 2>/dev/null)
|
|
477
|
+
tok_after=$(printf '%s' "${kc_creds}" | \
|
|
478
|
+
"${PY}" -c "import json,sys; d=json.load(sys.stdin); print((d.get('claudeAiOauth') or {}).get('accessToken',''))" 2>/dev/null || echo "")
|
|
479
|
+
|
|
480
|
+
if [[ -z "${kc_creds}" ]]; then
|
|
481
|
+
err "No credentials found after login"
|
|
482
|
+
log "If login succeeded, run: ${B}relay save ${name}${R}"
|
|
483
|
+
exit 1
|
|
484
|
+
fi
|
|
485
|
+
|
|
486
|
+
if [[ -n "${tok_before}" ]] && [[ "${tok_before}" == "${tok_after}" ]]; then
|
|
487
|
+
err "Login did not complete (token unchanged)"
|
|
488
|
+
warn "Run relay add from a regular Terminal — browser login is not available inside Claude Code"
|
|
489
|
+
log "To save the current account under a new name: ${B}relay save ${name}${R}"
|
|
490
|
+
exit 1
|
|
491
|
+
fi
|
|
492
|
+
|
|
493
|
+
printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
|
|
494
|
+
chmod 600 "$(account_creds "${name}")"
|
|
495
|
+
save_meta_email "${name}"
|
|
496
|
+
echo "${name}" > "${CURRENT_FILE}"
|
|
497
|
+
ok "Account '${B}${name}${R}' added ${D}$(get_meta_email "${name}")${R}"
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
cmd_save() {
|
|
501
|
+
local name="${1:-}"
|
|
502
|
+
[[ -z "${name}" ]] && { err "usage: relay save <name>"; exit 1; }
|
|
503
|
+
hdr "Save current account as: ${name}"
|
|
504
|
+
|
|
505
|
+
local saved=0
|
|
506
|
+
local kc; kc=$(kc_read 2>/dev/null)
|
|
507
|
+
if [[ -n "${kc}" ]]; then
|
|
508
|
+
printf '%s' "${kc}" > "$(account_creds "${name}")"
|
|
509
|
+
chmod 600 "$(account_creds "${name}")"
|
|
510
|
+
if [[ "$(uname)" == "Darwin" ]]; then ok "Saved from Keychain"
|
|
511
|
+
else ok "Saved from ~/.claude/.credentials.json"
|
|
512
|
+
fi
|
|
513
|
+
saved=1
|
|
514
|
+
fi
|
|
515
|
+
[[ ${saved} -eq 0 ]] && { err "No credentials found — log in first with: claude /login"; exit 1; }
|
|
516
|
+
|
|
517
|
+
save_meta_email "${name}"
|
|
518
|
+
echo "${name}" > "${CURRENT_FILE}"
|
|
519
|
+
ok "Account '${B}${name}${R}' saved ${D}$(get_meta_email "${name}")${R}"
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
cmd_refresh() {
|
|
523
|
+
local name="${1:-}"
|
|
524
|
+
[[ -z "${name}" ]] && { err "usage: relay refresh <name>"; exit 1; }
|
|
525
|
+
account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
|
|
526
|
+
require_claude
|
|
527
|
+
hdr "Refresh account: ${name}"
|
|
528
|
+
warn "Complete the browser login then return to this terminal"
|
|
529
|
+
echo ""
|
|
530
|
+
|
|
531
|
+
local tok_before; tok_before=$(kc_read 2>/dev/null | \
|
|
532
|
+
"${PY}" -c "import json,sys; d=json.load(sys.stdin); print((d.get('claudeAiOauth') or {}).get('accessToken',''))" 2>/dev/null || echo "")
|
|
533
|
+
|
|
534
|
+
"${REAL_CLAUDE}" /login || true
|
|
535
|
+
|
|
536
|
+
local kc_creds tok_after
|
|
537
|
+
kc_creds=$(kc_read 2>/dev/null)
|
|
538
|
+
tok_after=$(printf '%s' "${kc_creds}" | \
|
|
539
|
+
"${PY}" -c "import json,sys; d=json.load(sys.stdin); print((d.get('claudeAiOauth') or {}).get('accessToken',''))" 2>/dev/null || echo "")
|
|
540
|
+
|
|
541
|
+
if [[ -z "${kc_creds}" ]]; then
|
|
542
|
+
err "No credentials found after login"
|
|
543
|
+
exit 1
|
|
544
|
+
fi
|
|
545
|
+
|
|
546
|
+
if [[ -n "${tok_before}" ]] && [[ "${tok_before}" == "${tok_after}" ]]; then
|
|
547
|
+
err "Login did not complete (token unchanged)"
|
|
548
|
+
warn "Run relay refresh from a regular Terminal — browser login is not available inside Claude Code"
|
|
549
|
+
exit 1
|
|
550
|
+
fi
|
|
551
|
+
|
|
552
|
+
printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
|
|
553
|
+
chmod 600 "$(account_creds "${name}")"
|
|
554
|
+
save_meta_email "${name}"
|
|
555
|
+
echo "${name}" > "${CURRENT_FILE}"
|
|
556
|
+
ok "Account '${B}${name}${R}' refreshed ${D}$(get_meta_email "${name}")${R}"
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
cmd_remove() {
|
|
560
|
+
local name="${1:-}"
|
|
561
|
+
[[ -z "${name}" ]] && { err "usage: relay remove <name>"; exit 1; }
|
|
562
|
+
account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
|
|
563
|
+
printf "\n ${YL}Delete '${B}${name}${R}${YL}'? (y/N) ${R}"
|
|
564
|
+
read -r c
|
|
565
|
+
[[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
|
|
566
|
+
rm -f "$(account_creds "${name}")" "$(account_meta "${name}")"
|
|
567
|
+
[[ "$(current_name)" == "${name}" ]] && rm -f "${CURRENT_FILE}"
|
|
568
|
+
ok "Deleted '${name}' (sessions are unaffected)"
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
cmd_sessions() {
|
|
572
|
+
hdr "Sessions (shared across all accounts)"
|
|
573
|
+
local base="${CLAUDE_DIR}/projects"
|
|
574
|
+
[[ -d "${base}" ]] || { warn "No sessions found"; return 0; }
|
|
575
|
+
"${PY}" - "${base}" <<'EOF'
|
|
576
|
+
import os, sys, glob, datetime
|
|
577
|
+
base = sys.argv[1]
|
|
578
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'; CY='\033[36m'; GR='\033[32m'
|
|
579
|
+
total = 0
|
|
580
|
+
for proj in sorted(os.listdir(base)):
|
|
581
|
+
pdir = os.path.join(base, proj)
|
|
582
|
+
if not os.path.isdir(pdir): continue
|
|
583
|
+
files = sorted(glob.glob(os.path.join(pdir, '*.jsonl')),
|
|
584
|
+
key=os.path.getmtime, reverse=True)
|
|
585
|
+
if not files: continue
|
|
586
|
+
print(f'\n {D}{proj}{R}')
|
|
587
|
+
for i, f in enumerate(files):
|
|
588
|
+
sid = os.path.basename(f)[:-6]
|
|
589
|
+
ts = datetime.datetime.fromtimestamp(os.path.getmtime(f)).strftime('%m/%d %H:%M')
|
|
590
|
+
sz = os.path.getsize(f)
|
|
591
|
+
szs = f'{sz/1048576:.1f}M' if sz > 1048576 else f'{sz//1024}K'
|
|
592
|
+
mark = f' {GR}← latest{R}' if total == 0 and i == 0 else ''
|
|
593
|
+
print(f' {CY}{sid:<40}{R} {ts:<12} {szs}{mark}')
|
|
594
|
+
total += 1
|
|
595
|
+
print()
|
|
596
|
+
print(f' {total} session(s)' if total else ' No sessions found')
|
|
597
|
+
EOF
|
|
598
|
+
log "${CY}claude -c${R} resume last ${D}|${R} ${CY}claude -r${R} pick one ${D}|${R} ${CY}claude --resume <id>${R}"
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
cmd_install() {
|
|
602
|
+
local script_path; script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
|
603
|
+
local target="/usr/local/bin/relay"
|
|
604
|
+
|
|
605
|
+
hdr "Install relay"
|
|
606
|
+
|
|
607
|
+
if ln -sf "${script_path}" "${target}" 2>/dev/null; then
|
|
608
|
+
ok "Installed at ${target}"
|
|
609
|
+
log "Run ${CY}relay help${R} to verify"
|
|
610
|
+
elif sudo ln -sf "${script_path}" "${target}" 2>/dev/null; then
|
|
611
|
+
ok "Installed at ${target} (via sudo)"
|
|
612
|
+
else
|
|
613
|
+
warn "Cannot write to /usr/local/bin — installing to ~/bin instead"
|
|
614
|
+
mkdir -p "${HOME}/bin"
|
|
615
|
+
ln -sf "${script_path}" "${HOME}/bin/relay"
|
|
616
|
+
ok "Installed at ${HOME}/bin/relay"
|
|
617
|
+
echo ""
|
|
618
|
+
warn "Make sure ~/bin is in your PATH (.zshrc / .bashrc):"
|
|
619
|
+
printf " ${CY}echo 'export PATH=\"\$HOME/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc${R}\n"
|
|
620
|
+
fi
|
|
621
|
+
|
|
622
|
+
echo ""
|
|
623
|
+
hdr "First-time setup"
|
|
624
|
+
printf " ${B}1. Add your first account${R}\n"
|
|
625
|
+
printf " ${CY}relay add personal${R} ${D}# opens browser login${R}\n\n"
|
|
626
|
+
printf " ${B}2. (optional) Add more accounts${R}\n"
|
|
627
|
+
printf " ${CY}relay add work${R}\n"
|
|
628
|
+
printf " ${CY}relay add backup${R}\n\n"
|
|
629
|
+
printf " ${B}3. Switch inside Claude Code${R}\n"
|
|
630
|
+
printf " ${CY}!relay${R} ${D}# menu + usage${R}\n"
|
|
631
|
+
printf " ${CY}!relay 2${R} ${D}# switch to account #2${R}\n"
|
|
632
|
+
printf " ${CY}!relay work${R} ${D}# switch to named account${R}\n\n"
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
cmd_rename() {
|
|
636
|
+
local old="${1:-}" new="${2:-}"
|
|
637
|
+
[[ -z "${old}" || -z "${new}" ]] && { err "usage: relay rename <old-name> <new-name>"; exit 1; }
|
|
638
|
+
case "${new}" in
|
|
639
|
+
*[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
|
|
640
|
+
esac
|
|
641
|
+
account_exists "${old}" || { err "Account '${old}' not found"; exit 1; }
|
|
642
|
+
account_exists "${new}" && { err "Account '${new}' already exists"; exit 1; }
|
|
643
|
+
|
|
644
|
+
mv "$(account_creds "${old}")" "$(account_creds "${new}")"
|
|
645
|
+
[[ -f "$(account_meta "${old}")" ]] && mv "$(account_meta "${old}")" "$(account_meta "${new}")"
|
|
646
|
+
[[ "$(current_name)" == "${old}" ]] && echo "${new}" > "${CURRENT_FILE}"
|
|
647
|
+
ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
_script_dir() {
|
|
651
|
+
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
652
|
+
local src="$0"
|
|
653
|
+
while [[ -L "${src}" ]]; do src=$(readlink "${src}"); done
|
|
654
|
+
cd "$(dirname "${src}")" && pwd -P
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_read_version() {
|
|
658
|
+
local pkg; pkg="$(_script_dir)/package.json"
|
|
659
|
+
[[ -f "${pkg}" ]] && \
|
|
660
|
+
"${PY}" -c "import json; print(json.load(open('${pkg}'))['version'])" 2>/dev/null \
|
|
661
|
+
|| echo "unknown"
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
cmd_version() {
|
|
665
|
+
printf "relay %s\n" "$(_read_version)"
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
cmd_update() {
|
|
669
|
+
hdr "Update relay"
|
|
670
|
+
|
|
671
|
+
local current; current=$(_read_version)
|
|
672
|
+
log "Current version: ${B}${current}${R}"
|
|
673
|
+
|
|
674
|
+
# Check latest GitHub release
|
|
675
|
+
local latest
|
|
676
|
+
latest=$("${PY}" - 2>/dev/null <<'PYEOF'
|
|
677
|
+
import urllib.request, json, sys
|
|
678
|
+
try:
|
|
679
|
+
req = urllib.request.Request(
|
|
680
|
+
'https://api.github.com/repos/darkstar1227/relay/releases/latest',
|
|
681
|
+
headers={'User-Agent': 'relay-update'})
|
|
682
|
+
with urllib.request.urlopen(req, timeout=6) as r:
|
|
683
|
+
print(json.loads(r.read())['tag_name'].lstrip('v'))
|
|
684
|
+
except Exception as e:
|
|
685
|
+
sys.exit(1)
|
|
686
|
+
PYEOF
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if [[ -z "${latest}" ]]; then
|
|
690
|
+
warn "Could not reach GitHub — skipping version check"
|
|
691
|
+
elif [[ "${current}" == "${latest}" ]]; then
|
|
692
|
+
ok "Already up to date (${current})"; return 0
|
|
693
|
+
else
|
|
694
|
+
log "Latest available: ${B}${latest}${R}"
|
|
695
|
+
fi
|
|
696
|
+
|
|
697
|
+
local relay_dir; relay_dir=$(_script_dir)
|
|
698
|
+
local npm_cmd; npm_cmd=$(command -v npm 2>/dev/null)
|
|
699
|
+
if [[ -n "${npm_cmd}" ]]; then
|
|
700
|
+
log "Installing via npm..."
|
|
701
|
+
npm install -g @dst-justin/relay@latest
|
|
702
|
+
ok "Updated to $(_read_version)"
|
|
703
|
+
elif [[ -d "${relay_dir}/.git" ]]; then
|
|
704
|
+
log "npm not found — updating via git..."
|
|
705
|
+
git -C "${relay_dir}" pull
|
|
706
|
+
else
|
|
707
|
+
err "Cannot update: npm not found and no .git directory"
|
|
708
|
+
log "Install via npm: ${CY}npm install -g @dst-justin/relay@latest${R}"
|
|
709
|
+
fi
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
cmd_uninstall() {
|
|
713
|
+
hdr "Uninstall relay"
|
|
714
|
+
warn "This will remove the relay command and all account credential data"
|
|
715
|
+
printf "\n ${YL}Continue? (y/N) ${R}"
|
|
716
|
+
read -r c
|
|
717
|
+
[[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
|
|
718
|
+
|
|
719
|
+
local removed=0
|
|
720
|
+
for p in "/usr/local/bin/relay" "${HOME}/bin/relay"; do
|
|
721
|
+
if [[ -L "${p}" || -f "${p}" ]]; then
|
|
722
|
+
rm -f "${p}" 2>/dev/null || sudo rm -f "${p}" 2>/dev/null || warn "Could not remove ${p} (try sudo)"
|
|
723
|
+
ok "Removed ${p}"
|
|
724
|
+
removed=1
|
|
725
|
+
fi
|
|
726
|
+
done
|
|
727
|
+
[[ ${removed} -eq 0 ]] && warn "No installed relay command found (delete the script file manually)"
|
|
728
|
+
|
|
729
|
+
if [[ -d "${RELAY_DIR}" ]]; then
|
|
730
|
+
rm -rf "${RELAY_DIR}"
|
|
731
|
+
ok "Removed ${RELAY_DIR} (all account data)"
|
|
732
|
+
fi
|
|
733
|
+
|
|
734
|
+
printf "\n ${GR}relay has been fully removed.${R}\n\n"
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
cmd_help() {
|
|
738
|
+
printf "\n${B}${CY} relay${R} ${D}%s — multi-account switcher for Claude Code${R}\n\n" "$(_read_version)"
|
|
739
|
+
printf " ${B}Inside Claude Code (prefix with !)${R}\n"
|
|
740
|
+
printf " %-32s %s\n" " !relay" "account menu + 5hr usage"
|
|
741
|
+
printf " %-32s %s\n" " !relay 2" "switch to account #2"
|
|
742
|
+
printf " %-32s %s\n" " !relay work" "switch to named account"
|
|
743
|
+
printf " %-32s %s\n" " !relay status" "detailed usage for current account"
|
|
744
|
+
echo ""
|
|
745
|
+
printf " ${B}Account management${R}\n"
|
|
746
|
+
printf " %-32s %s\n" " relay add <name>" "add account via browser login"
|
|
747
|
+
printf " %-32s %s\n" " relay add-force <name>" "force re-login for existing account"
|
|
748
|
+
printf " %-32s %s\n" " relay refresh <name>" "re-login to refresh an expired token"
|
|
749
|
+
printf " %-32s %s\n" " relay save <name>" "save current login state"
|
|
750
|
+
printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
|
|
751
|
+
printf " %-32s %s\n" " relay list" "full list with weekly usage"
|
|
752
|
+
printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
|
|
753
|
+
printf " %-32s %s\n" " relay remove <name>" "delete an account"
|
|
754
|
+
printf " %-32s %s\n" " relay sessions" "show all sessions"
|
|
755
|
+
printf " %-32s %s\n" " relay version" "show current version"
|
|
756
|
+
printf " %-32s %s\n" " relay update" "update to latest version"
|
|
757
|
+
printf " %-32s %s\n" " relay uninstall" "remove relay and all account data"
|
|
758
|
+
echo ""
|
|
759
|
+
printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
|
|
760
|
+
printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
# ══════════════════════════════════════════════════════════════════
|
|
764
|
+
# Dispatch — single entry point, no fall-through
|
|
765
|
+
# ══════════════════════════════════════════════════════════════════
|
|
766
|
+
CMD="${1:-}"
|
|
767
|
+
[[ $# -gt 0 ]] && shift
|
|
768
|
+
|
|
769
|
+
case "${CMD}" in
|
|
770
|
+
"") cmd_quick "$@" ;;
|
|
771
|
+
add) cmd_add "$@" ;;
|
|
772
|
+
add-force)
|
|
773
|
+
[[ -n "${1:-}" ]] && rm -f "$(account_creds "$1")" "$(account_meta "$1")"
|
|
774
|
+
cmd_add "$@" ;;
|
|
775
|
+
save) cmd_save "$@" ;;
|
|
776
|
+
refresh) cmd_refresh "$@" ;;
|
|
777
|
+
switch|sw|use)
|
|
778
|
+
if [[ -z "${1:-}" ]]; then cmd_quick
|
|
779
|
+
elif [[ "${1}" =~ ^[0-9]+$ ]]; then
|
|
780
|
+
name=$(account_by_index "$1") || { err "No account at index $1"; cmd_quick --no-usage; exit 1; }
|
|
781
|
+
do_switch "${name}"
|
|
782
|
+
else
|
|
783
|
+
account_exists "$1" || { err "Account '$1' not found"; cmd_quick --no-usage; exit 1; }
|
|
784
|
+
do_switch "$1"
|
|
785
|
+
fi ;;
|
|
786
|
+
list|ls) cmd_list "$@" ;;
|
|
787
|
+
status|st) cmd_status ;;
|
|
788
|
+
remove|rm|del) cmd_remove "$@" ;;
|
|
789
|
+
rename|mv) cmd_rename "$@" ;;
|
|
790
|
+
sessions|sess) cmd_sessions ;;
|
|
791
|
+
version|--version|-V) cmd_version ;;
|
|
792
|
+
update) cmd_update ;;
|
|
793
|
+
install) cmd_install ;;
|
|
794
|
+
uninstall) cmd_uninstall ;;
|
|
795
|
+
continue|cont|c)
|
|
796
|
+
if [[ -n "${1:-}" ]]; then
|
|
797
|
+
account_exists "$1" && do_switch "$1"
|
|
798
|
+
fi
|
|
799
|
+
require_claude
|
|
800
|
+
exec "${REAL_CLAUDE}" --continue ;;
|
|
801
|
+
help|--help|-h) cmd_help ;;
|
|
802
|
+
*)
|
|
803
|
+
# numeric → switch by index; name → switch by name; else → error
|
|
804
|
+
if [[ "${CMD}" =~ ^[0-9]+$ ]]; then
|
|
805
|
+
name=$(account_by_index "${CMD}") || { err "No account at index ${CMD}"; cmd_quick --no-usage; exit 1; }
|
|
806
|
+
do_switch "${name}"
|
|
807
|
+
elif account_exists "${CMD}"; then
|
|
808
|
+
do_switch "${CMD}"
|
|
809
|
+
else
|
|
810
|
+
err "Unknown command or account: ${CMD}"
|
|
811
|
+
cmd_quick --no-usage
|
|
812
|
+
printf " ${D}Run ${CY}relay help${R}${D} for usage${R}\n\n"
|
|
813
|
+
exit 1
|
|
814
|
+
fi ;;
|
|
815
|
+
esac
|