@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.cmd ADDED
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ :: relay.cmd — Windows CMD wrapper for relay.ps1
3
+ :: Passes all arguments through to the PowerShell implementation.
4
+ :: Requires PowerShell 5.1+ (built into Windows 10/11).
5
+ setlocal
6
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0relay.ps1" %*
7
+ endlocal
package/relay.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // relay.js — cross-platform entry point for npm installs
3
+ // Routes to ./relay (bash) on macOS/Linux, or ./relay.ps1 via PowerShell on Windows.
4
+ 'use strict';
5
+
6
+ const { spawnSync } = require('child_process');
7
+ const { join } = require('path');
8
+ const { chmodSync, statSync } = require('fs');
9
+
10
+ const dir = __dirname;
11
+ const args = process.argv.slice(2);
12
+
13
+ let result;
14
+ if (process.platform === 'win32') {
15
+ result = spawnSync(
16
+ 'powershell.exe',
17
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(dir, 'relay.ps1'), ...args],
18
+ { stdio: 'inherit', shell: false }
19
+ );
20
+ } else {
21
+ const script = join(dir, 'relay');
22
+ // Ensure executable bit is set — npm publish from Windows strips +x
23
+ try {
24
+ if ((statSync(script).mode & 0o111) === 0) chmodSync(script, '755');
25
+ } catch (_) {}
26
+ result = spawnSync(script, args, { stdio: 'inherit', shell: false });
27
+ }
28
+
29
+ process.exit(result.status != null ? result.status : 0);
package/relay.ps1 ADDED
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env pwsh
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # relay.ps1 — multi-account switcher for Claude Code (Windows / PowerShell 5.1+)
4
+ # Usage: relay [command] [args...]
5
+ # relay list show accounts with usage
6
+ # relay add <name> add account via browser login
7
+ # relay 2 / relay work switch by index or name
8
+ # ─────────────────────────────────────────────────────────────────────────────
9
+ param(
10
+ [Parameter(Position = 0)] [string]$Cmd = "",
11
+ [Parameter(Position = 1, ValueFromRemainingArguments = $true)] [string[]]$Rest = @()
12
+ )
13
+ $ErrorActionPreference = "Stop"
14
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
15
+
16
+ $RELAY_DIR = Join-Path $HOME ".claude-relay"
17
+ $CREDS_STORE = Join-Path $RELAY_DIR "credentials"
18
+ $META_STORE = Join-Path $RELAY_DIR "meta"
19
+ $CURRENT_FILE = Join-Path $RELAY_DIR "current"
20
+ $CLAUDE_DIR = Join-Path $HOME ".claude"
21
+ $LIVE_CREDS = Join-Path $CLAUDE_DIR ".credentials.json"
22
+
23
+ # ── ANSI colors ───────────────────────────────────────────────────────────────
24
+ $e = [char]27
25
+ $R = "$e[0m"; $B = "$e[1m"; $D = "$e[2m"
26
+ $CY = "$e[36m"; $GR = "$e[32m"; $YL = "$e[33m"; $RD = "$e[31m"; $MG = "$e[35m"
27
+
28
+ # Enable VT processing on legacy Windows Console (no-op in Windows Terminal / pwsh)
29
+ if ($env:OS -eq "Windows_NT") {
30
+ try {
31
+ Add-Type -Name VTEnable -Namespace Win32 -MemberDefinition @'
32
+ [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int h);
33
+ [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);
34
+ [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);
35
+ '@ -ErrorAction SilentlyContinue
36
+ $h = [Win32.VTEnable]::GetStdHandle(-11); $m = 0u
37
+ [Win32.VTEnable]::GetConsoleMode($h, [ref]$m) | Out-Null
38
+ [Win32.VTEnable]::SetConsoleMode($h, ($m -bor 4)) | Out-Null
39
+ } catch {}
40
+ }
41
+
42
+ function fLog($t) { Write-Host " ${CY}-> ${R}$t" }
43
+ function fOk($t) { Write-Host " ${GR}✓${R} $t" }
44
+ function fWarn($t) { Write-Host " ${YL}⚠${R} $t" }
45
+ function fErr($t) { Write-Host " ${RD}✗${R} $t" }
46
+ function fHdr($t) {
47
+ Write-Host ""
48
+ Write-Host " ${B}${MG}${t}${R}"
49
+ Write-Host " ${D}─────────────────────────────────────${R}"
50
+ }
51
+
52
+ # ── Directory setup ───────────────────────────────────────────────────────────
53
+ foreach ($d in @($CREDS_STORE, $META_STORE, $CLAUDE_DIR)) {
54
+ if (-not (Test-Path $d)) { New-Item -ItemType Directory $d -Force | Out-Null }
55
+ }
56
+
57
+ # ── Core helpers ──────────────────────────────────────────────────────────────
58
+ function Get-CurrentName {
59
+ if (Test-Path $CURRENT_FILE) { ([System.IO.File]::ReadAllText($CURRENT_FILE)).Trim() }
60
+ else { "" }
61
+ }
62
+ function Get-CredsPath($n) { Join-Path $CREDS_STORE "$n.json" }
63
+ function Get-MetaPath($n) { Join-Path $META_STORE $n }
64
+ function Test-Account($n) { Test-Path (Get-CredsPath $n) }
65
+
66
+ function Get-AccountNames {
67
+ if (-not (Test-Path $CREDS_STORE)) { return @() }
68
+ @(Get-ChildItem $CREDS_STORE -Filter "*.json" | ForEach-Object { $_.BaseName } | Sort-Object)
69
+ }
70
+
71
+ function Get-AccountByIndex([int]$i) {
72
+ $names = Get-AccountNames
73
+ if ($i -lt 1 -or $i -gt $names.Count) { return $null }
74
+ $names[$i - 1]
75
+ }
76
+
77
+ # Write UTF-8 without BOM (JSON files must not have BOM)
78
+ function Write-NoBOM($path, $content) {
79
+ [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))
80
+ }
81
+
82
+ function Get-First($arr) { $arr | Where-Object { $_ -ne '--no-usage' } | Select-Object -First 1 }
83
+ function Get-Second($arr) { $arr | Where-Object { $_ -ne '--no-usage' } | Select-Object -Skip 1 -First 1 }
84
+
85
+ # ── Live credential store ──────────────────────────────────────────────────────
86
+ # Claude Code on Windows stores OAuth credentials at $HOME\.claude\.credentials.json
87
+ function Read-LiveCreds {
88
+ if (Test-Path $LIVE_CREDS) { return [System.IO.File]::ReadAllText($LIVE_CREDS).Trim() }
89
+ return $null
90
+ }
91
+
92
+ function Get-Token($json) {
93
+ try { return ($json | ConvertFrom-Json).claudeAiOauth.accessToken } catch { return "" }
94
+ }
95
+
96
+ # ── Email meta ────────────────────────────────────────────────────────────────
97
+ function Get-EmailFromClaude {
98
+ $f = Join-Path $HOME ".claude.json"
99
+ if (-not (Test-Path $f)) { return "" }
100
+ try { return (Get-Content $f -Raw | ConvertFrom-Json).oauthAccount.emailAddress } catch { return "" }
101
+ }
102
+
103
+ function Get-MetaEmail($n) {
104
+ $f = Get-MetaPath $n
105
+ if (Test-Path $f) { return [System.IO.File]::ReadAllText($f).Trim() }
106
+ return "—"
107
+ }
108
+
109
+ function Save-MetaEmail($n) {
110
+ $email = Get-EmailFromClaude
111
+ if ($email) { Write-NoBOM (Get-MetaPath $n) $email }
112
+ }
113
+
114
+ # ── Usage API ─────────────────────────────────────────────────────────────────
115
+ function Get-Usage($tok) {
116
+ try {
117
+ return Invoke-RestMethod "https://api.anthropic.com/api/oauth/usage" `
118
+ -Headers @{ Authorization = "Bearer $tok"; "User-Agent" = "relay/2.0" } `
119
+ -TimeoutSec 6
120
+ } catch { return $null }
121
+ }
122
+
123
+ function New-Bar([int]$pct, [int]$w = 10) {
124
+ $f = [int]([Math]::Round($pct / 100.0 * $w))
125
+ $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
126
+ return "${c}[$('█' * $f)$('░' * ($w - $f))]${R}"
127
+ }
128
+
129
+ function New-ResetStr($iso) {
130
+ try {
131
+ $ts = [System.DateTimeOffset]::Parse($iso)
132
+ $s = ($ts - [System.DateTimeOffset]::UtcNow).TotalSeconds
133
+ if ($s -le 0) { return "resetting" }
134
+ $h = [int]($s / 3600); $m = [int](($s % 3600) / 60)
135
+ return "${h}h$($m.ToString('00'))m"
136
+ } catch { return "—" }
137
+ }
138
+
139
+ function New-5hrStr($u) {
140
+ if (-not $u -or -not $u.five_hour) { return "—" }
141
+ $fh = $u.five_hour
142
+ $pct = if ($null -ne $fh.utilization) { [int]$fh.utilization } else { 0 }
143
+ $t = if ($fh.resets_at) { New-ResetStr $fh.resets_at } else { "—" }
144
+ $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
145
+ "$(New-Bar $pct) ${c}$($pct.ToString().PadLeft(3))%${R} ${D}($t)${R}"
146
+ }
147
+
148
+ function New-7dStr($u) {
149
+ if (-not $u -or -not $u.seven_day -or $null -eq $u.seven_day.utilization) { return "—" }
150
+ $pct = [int]$u.seven_day.utilization
151
+ $f = [int]([Math]::Round($pct / 100.0 * 8))
152
+ $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
153
+ "${c}[$('█' * $f)$('░' * (8 - $f))]${R} ${c}${pct}%${R}"
154
+ }
155
+
156
+ # ── Sync live → snapshot ──────────────────────────────────────────────────────
157
+ function Sync-Creds {
158
+ $cur = Get-CurrentName
159
+ if (-not $cur -or -not (Test-Account $cur)) { return }
160
+ $live = Read-LiveCreds
161
+ if ($live) { Write-NoBOM (Get-CredsPath $cur) $live }
162
+ }
163
+
164
+ # ── Table rendering ───────────────────────────────────────────────────────────
165
+ function Show-Table([string]$mode, [bool]$noUsage = $false) {
166
+ $names = Get-AccountNames
167
+ if (-not $names) {
168
+ fWarn "No accounts yet. Run: ${B}relay add <name>${R}"
169
+ return
170
+ }
171
+ $cur = Get-CurrentName
172
+ $usage = @{}
173
+
174
+ if (-not $noUsage) {
175
+ Write-Host " ${D}fetching usage...${R}" -NoNewline
176
+ foreach ($n in $names) {
177
+ $tok = Get-Token (Get-Content (Get-CredsPath $n) -Raw -ErrorAction SilentlyContinue)
178
+ $usage[$n] = if ($tok) { Get-Usage $tok } else { $null }
179
+ }
180
+ Write-Host "`r$(' ' * 25)`r" -NoNewline
181
+ }
182
+
183
+ if ($mode -eq 'quick') {
184
+ Write-Host ""
185
+ Write-Host " ${B}${MG}relay${R} ${D}— switch account${R}"
186
+ Write-Host " ${D}$('─' * 45)${R}"
187
+ for ($i = 0; $i -lt $names.Count; $i++) {
188
+ $n = $names[$i]; $isCur = ($n -eq $cur)
189
+ $marker = if ($isCur) { "${GR}${B}●${R}" } else { "${D}$($i+1)${R}" }
190
+ $nc = if ($isCur) { "${GR}${B}" } else { $B }
191
+ $u5 = if (-not $noUsage) { New-5hrStr $usage[$n] } else { "" }
192
+ Write-Host " $marker ${nc}$($n.PadRight(12))${R} ${D}$((Get-MetaEmail $n).PadRight(26))${R} $u5"
193
+ }
194
+ Write-Host ""
195
+ Write-Host " ${D}switch:${R} ${CY}relay <index or name>${R} ${D}details:${R} ${CY}relay status${R}"
196
+ Write-Host ""
197
+ } else {
198
+ Write-Host " ${B}$('#'.PadRight(3))$('account'.PadRight(13)) $('email'.PadRight(28)) 5hr usage 7d usage${R}"
199
+ Write-Host " ${D}$('─' * 80)${R}"
200
+ for ($i = 0; $i -lt $names.Count; $i++) {
201
+ $n = $names[$i]; $isCur = ($n -eq $cur)
202
+ $marker = if ($isCur) { "${GR}●${R}" } else { " " }
203
+ $nc = if ($isCur) { "${GR}${B}" } else { $B }
204
+ $u5 = if (-not $noUsage) { New-5hrStr $usage[$n] } else { "—" }
205
+ $u7 = if (-not $noUsage) { New-7dStr $usage[$n] } else { "—" }
206
+ Write-Host " $marker ${D}$($i+1) ${R}${nc}$($n.PadRight(12))${R} $((Get-MetaEmail $n).PadRight(28)) $u5 $u7"
207
+ }
208
+ Write-Host ""
209
+ }
210
+ }
211
+
212
+ # ── Switch account ────────────────────────────────────────────────────────────
213
+ function Invoke-Switch($name) {
214
+ $cur = Get-CurrentName
215
+ if ($cur -eq $name) { fOk "Already on account '${B}${name}${R}'"; return }
216
+
217
+ # Back up live token before switching (Claude Code refreshes tokens in-place)
218
+ if ($cur -and (Test-Account $cur)) {
219
+ $live = Read-LiveCreds
220
+ if ($live) { Write-NoBOM (Get-CredsPath $cur) $live }
221
+ }
222
+
223
+ Write-NoBOM $CURRENT_FILE $name
224
+ Write-NoBOM $LIVE_CREDS ([System.IO.File]::ReadAllText((Get-CredsPath $name)))
225
+
226
+ $email = Get-MetaEmail $name
227
+ Write-Host ""
228
+ Write-Host " ${GR}${B}✓ switched -> ${name}${R} ${D}${email}${R}"
229
+ Write-Host " ${D}Restart claude to apply. Resume last session: ${CY}claude -c${R}"
230
+ Write-Host ""
231
+ }
232
+
233
+ # ─────────────────────────────────────────────────────────────────────────────
234
+ # Commands
235
+ # ─────────────────────────────────────────────────────────────────────────────
236
+
237
+ function cmd_quick([bool]$noUsage) { Sync-Creds; Show-Table 'quick' $noUsage }
238
+
239
+ function cmd_list([bool]$noUsage) {
240
+ fHdr "Account List"; Sync-Creds; Show-Table 'full' $noUsage
241
+ Write-Host ""; fOk "Run ${CY}relay <index>${R} to switch"
242
+ }
243
+
244
+ function cmd_status {
245
+ Sync-Creds; $cur = Get-CurrentName; fHdr "Current Status"
246
+ if (-not $cur) { fWarn "No account set (using system default)"; return }
247
+ if (-not (Test-Account $cur)) { fWarn "Recorded account '$cur' no longer exists"; return }
248
+
249
+ Write-Host " ${B}Account:${R} ${GR}${B}${cur}${R}"
250
+ Write-Host " ${B}Email:${R} $(Get-MetaEmail $cur)"
251
+
252
+ $tok = Get-Token (Get-Content (Get-CredsPath $cur) -Raw)
253
+ if (-not $tok) { Write-Host ""; fWarn "No access token — please log in again"; return }
254
+
255
+ $u = Get-Usage $tok
256
+ if (-not $u) { Write-Host ""; fErr "Usage query failed"; return }
257
+
258
+ $fh = $u.five_hour
259
+ if ($fh) {
260
+ $pct = if ($null -ne $fh.utilization) { [int]$fh.utilization } else { 0 }
261
+ $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
262
+ Write-Host ""
263
+ Write-Host " ${B}5hr usage:${R}"
264
+ Write-Host " $(New-Bar $pct 24) ${c}${B}${pct}%${R}"
265
+ Write-Host " ${D}$(if ($fh.resets_at) { New-ResetStr $fh.resets_at } else { '—' })${R}"
266
+ }
267
+ $sd = $u.seven_day
268
+ if ($sd -and $null -ne $sd.utilization) {
269
+ $pct = [int]$sd.utilization
270
+ $c = if ($pct -lt 50) { $GR } elseif ($pct -lt 80) { $YL } else { $RD }
271
+ Write-Host ""
272
+ Write-Host " ${B}7d usage:${R}"
273
+ Write-Host " $(New-Bar $pct 24) ${c}${pct}%${R}"
274
+ if ($sd.resets_at) { Write-Host " ${D}$(New-ResetStr $sd.resets_at)${R}" }
275
+ }
276
+ Write-Host ""
277
+ $u5pct = if ($fh -and $null -ne $fh.utilization) { [int]$fh.utilization } else { 0 }
278
+ if ($u5pct -ge 90) { Write-Host " ${RD}${B}⚠ Approaching limit — consider switching: relay <other>${R}" }
279
+ elseif ($u5pct -ge 70) { Write-Host " ${YL}⚡ Usage is high — watch for rate limits${R}" }
280
+ else { fOk "Usage is normal" }
281
+
282
+ $projDir = Join-Path $CLAUDE_DIR "projects"
283
+ $n = if (Test-Path $projDir) {
284
+ @(Get-ChildItem $projDir -Recurse -Filter "*.jsonl" -ErrorAction SilentlyContinue).Count
285
+ } else { 0 }
286
+ Write-Host ""
287
+ Write-Host " ${B}Sessions:${R} $n (shared across all accounts in $projDir)"
288
+ }
289
+
290
+ function cmd_add($name, [bool]$force = $false) {
291
+ if (-not $name) { fErr "usage: relay add <name>"; exit 1 }
292
+ if ($name -notmatch '^[a-zA-Z0-9_-]+$') {
293
+ fErr "name must contain only letters, numbers, underscores, or hyphens"; exit 1
294
+ }
295
+ if (-not (Get-Command claude -ErrorAction SilentlyContinue)) {
296
+ fErr "claude not found — install from https://claude.ai/download"; exit 1
297
+ }
298
+ if (-not $force -and (Test-Account $name)) {
299
+ fWarn "Account '$name' already exists"
300
+ fLog "To re-login: ${B}relay add-force $name${R}"; return
301
+ }
302
+ fHdr "Add account: $name"
303
+ fWarn "Complete the browser login then return to this terminal"
304
+ Write-Host ""
305
+
306
+ $tokBefore = ""
307
+ $before = Read-LiveCreds
308
+ if ($before) { try { $tokBefore = Get-Token $before } catch {} }
309
+
310
+ & claude /login
311
+
312
+ $after = Read-LiveCreds
313
+ if (-not $after) {
314
+ fErr "No credentials found after login"
315
+ fLog "If login succeeded, run: ${B}relay save $name${R}"; exit 1
316
+ }
317
+ $tokAfter = Get-Token $after
318
+ if ($tokBefore -and $tokBefore -eq $tokAfter) {
319
+ fErr "Login did not complete (token unchanged)"
320
+ fWarn "Run relay add from a regular terminal outside Claude Code"
321
+ fLog "To save the current account: ${B}relay save $name${R}"; exit 1
322
+ }
323
+
324
+ Write-NoBOM (Get-CredsPath $name) $after
325
+ Save-MetaEmail $name
326
+ Write-NoBOM $CURRENT_FILE $name
327
+ fOk "Account '${B}${name}${R}' added ${D}$(Get-MetaEmail $name)${R}"
328
+ }
329
+
330
+ function cmd_save($name) {
331
+ if (-not $name) { fErr "usage: relay save <name>"; exit 1 }
332
+ fHdr "Save current account as: $name"
333
+ $creds = Read-LiveCreds
334
+ if (-not $creds) {
335
+ fErr "No credentials found — log in first with: claude /login"; exit 1
336
+ }
337
+ Write-NoBOM (Get-CredsPath $name) $creds
338
+ fOk "Saved from $LIVE_CREDS"
339
+ Save-MetaEmail $name
340
+ Write-NoBOM $CURRENT_FILE $name
341
+ fOk "Account '${B}${name}${R}' saved ${D}$(Get-MetaEmail $name)${R}"
342
+ }
343
+
344
+ function cmd_remove($name) {
345
+ if (-not $name) { fErr "usage: relay remove <name>"; exit 1 }
346
+ if (-not (Test-Account $name)) { fErr "Account '$name' not found"; exit 1 }
347
+ Write-Host " ${YL}Delete '${B}${name}${R}${YL}'? (y/N) ${R}" -NoNewline
348
+ $c = Read-Host
349
+ if ($c -notmatch '^[yY]$') { fLog "cancelled"; return }
350
+ Remove-Item (Get-CredsPath $name) -Force -ErrorAction SilentlyContinue
351
+ Remove-Item (Get-MetaPath $name) -Force -ErrorAction SilentlyContinue
352
+ if ((Get-CurrentName) -eq $name) { Remove-Item $CURRENT_FILE -Force -ErrorAction SilentlyContinue }
353
+ fOk "Deleted '$name' (sessions are unaffected)"
354
+ }
355
+
356
+ function cmd_rename($old, $new) {
357
+ if (-not $old -or -not $new) { fErr "usage: relay rename <old-name> <new-name>"; exit 1 }
358
+ if ($new -notmatch '^[a-zA-Z0-9_-]+$') {
359
+ fErr "name must contain only letters, numbers, underscores, or hyphens"; exit 1
360
+ }
361
+ if (-not (Test-Account $old)) { fErr "Account '$old' not found"; exit 1 }
362
+ if (Test-Account $new) { fErr "Account '$new' already exists"; exit 1 }
363
+ Rename-Item (Get-CredsPath $old) "$new.json"
364
+ if (Test-Path (Get-MetaPath $old)) { Rename-Item (Get-MetaPath $old) $new }
365
+ if ((Get-CurrentName) -eq $old) { Write-NoBOM $CURRENT_FILE $new }
366
+ fOk "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
367
+ }
368
+
369
+ function cmd_sessions {
370
+ fHdr "Sessions (shared across all accounts)"
371
+ $base = Join-Path $CLAUDE_DIR "projects"
372
+ if (-not (Test-Path $base)) { fWarn "No sessions found"; return }
373
+ $total = 0
374
+ foreach ($proj in Get-ChildItem $base -Directory -ErrorAction SilentlyContinue | Sort-Object Name) {
375
+ $files = @(Get-ChildItem $proj.FullName -Filter "*.jsonl" -ErrorAction SilentlyContinue |
376
+ Sort-Object LastWriteTime -Descending)
377
+ if (-not $files) { continue }
378
+ Write-Host " ${D}$($proj.Name)${R}"
379
+ foreach ($f in $files) {
380
+ $sz = if ($f.Length -gt 1MB) { "$([int]($f.Length / 1MB))M" } else { "$([int]($f.Length / 1KB))K" }
381
+ $mark = if ($total -eq 0) { " ${GR}<- latest${R}" } else { "" }
382
+ Write-Host " ${CY}$($f.BaseName.PadRight(40))${R} $($f.LastWriteTime.ToString('MM/dd HH:mm').PadRight(12)) $sz$mark"
383
+ $total++
384
+ }
385
+ }
386
+ Write-Host ""
387
+ Write-Host " $total session(s)"
388
+ fLog "${CY}claude -c${R} resume last ${D}|${R} ${CY}claude --resume <id>${R}"
389
+ }
390
+
391
+ function Get-RelayVersion {
392
+ $pkg = Join-Path $PSScriptRoot "package.json"
393
+ if (Test-Path $pkg) {
394
+ try { return (Get-Content $pkg -Raw | ConvertFrom-Json).version } catch {}
395
+ }
396
+ return "unknown"
397
+ }
398
+
399
+ function cmd_version {
400
+ Write-Host "relay $(Get-RelayVersion)"
401
+ }
402
+
403
+ function cmd_update {
404
+ fHdr "Update relay"
405
+
406
+ $current = Get-RelayVersion
407
+ fLog "Current version: ${B}${current}${R}"
408
+
409
+ # Check latest GitHub release
410
+ $latest = ""
411
+ try {
412
+ $r = Invoke-RestMethod "https://api.github.com/repos/darkstar1227/relay/releases/latest" `
413
+ -Headers @{ "User-Agent" = "relay-update" } -TimeoutSec 6
414
+ $latest = $r.tag_name.TrimStart('v')
415
+ } catch {}
416
+
417
+ if (-not $latest) {
418
+ fWarn "Could not reach GitHub — skipping version check"
419
+ } elseif ($current -eq $latest) {
420
+ fOk "Already up to date ($current)"; return
421
+ } else {
422
+ fLog "Latest available: ${B}${latest}${R}"
423
+ }
424
+
425
+ $npmCmd = Get-Command npm -ErrorAction SilentlyContinue
426
+ if ($npmCmd) {
427
+ fLog "Installing via npm..."
428
+ & npm install -g claude-relay@latest
429
+ fOk "Updated to $(Get-RelayVersion)"
430
+ } else {
431
+ fWarn "npm not found"
432
+ fLog "Install npm or manually: git pull in the relay source directory"
433
+ }
434
+ }
435
+
436
+ function cmd_help {
437
+ $ver = Get-RelayVersion
438
+ Write-Host ""
439
+ Write-Host " ${B}${CY}relay${R} ${D}${ver} — multi-account switcher for Claude Code (Windows)${R}"
440
+ Write-Host ""
441
+ Write-Host " ${B}Commands${R}"
442
+ @(
443
+ @(" relay <index|name>", "switch to account by number or name"),
444
+ @(" relay add <name>", "add account via browser login"),
445
+ @(" relay add-force <name>", "force re-login for existing account"),
446
+ @(" relay save <name>", "save current login state as named account"),
447
+ @(" relay list", "full list with weekly usage"),
448
+ @(" relay list --no-usage", "list without querying API"),
449
+ @(" relay status", "detailed usage for current account"),
450
+ @(" relay rename <old> <new>","rename an account"),
451
+ @(" relay remove <name>", "delete an account"),
452
+ @(" relay sessions", "show all sessions"),
453
+ @(" relay version", "show current version"),
454
+ @(" relay update", "update to latest version"),
455
+ @(" relay help", "show this help")
456
+ ) | ForEach-Object { Write-Host (" {0,-36} {1}" -f $_[0], $_[1]) }
457
+ Write-Host ""
458
+ Write-Host " ${D}credentials: $LIVE_CREDS${R}"
459
+ Write-Host " ${D}after switching: claude -c to resume last session${R}"
460
+ Write-Host ""
461
+ }
462
+
463
+ # ─────────────────────────────────────────────────────────────────────────────
464
+ # Dispatch
465
+ # ─────────────────────────────────────────────────────────────────────────────
466
+ $noUsage = $Rest -contains '--no-usage'
467
+
468
+ switch ($Cmd.ToLower()) {
469
+ "" { cmd_quick $noUsage }
470
+ "list" { cmd_list $noUsage }
471
+ "ls" { cmd_list $noUsage }
472
+ "add" { cmd_add (Get-First $Rest) $false }
473
+ "add-force" { cmd_add (Get-First $Rest) $true }
474
+ "save" { cmd_save (Get-First $Rest) }
475
+ "status" { cmd_status }
476
+ "st" { cmd_status }
477
+ "remove" { cmd_remove (Get-First $Rest) }
478
+ "rm" { cmd_remove (Get-First $Rest) }
479
+ "del" { cmd_remove (Get-First $Rest) }
480
+ "rename" { cmd_rename (Get-First $Rest) (Get-Second $Rest) }
481
+ "mv" { cmd_rename (Get-First $Rest) (Get-Second $Rest) }
482
+ "sessions" { cmd_sessions }
483
+ "sess" { cmd_sessions }
484
+ "version" { cmd_version }
485
+ "--version" { cmd_version }
486
+ "-v" { cmd_version }
487
+ "update" { cmd_update }
488
+ "help" { cmd_help }
489
+ "--help" { cmd_help }
490
+ "-h" { cmd_help }
491
+ default {
492
+ if ($Cmd -match '^\d+$') {
493
+ $n = Get-AccountByIndex ([int]$Cmd)
494
+ if (-not $n) { fErr "No account at index $Cmd"; cmd_quick $true; exit 1 }
495
+ Invoke-Switch $n
496
+ } elseif (Test-Account $Cmd) {
497
+ Invoke-Switch $Cmd
498
+ } else {
499
+ fErr "Unknown command or account: $Cmd"
500
+ cmd_quick $true
501
+ Write-Host " ${D}Run ${CY}relay help${R}${D} for usage${R}"
502
+ exit 1
503
+ }
504
+ }
505
+ }