@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/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