@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.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
|
+
}
|