@dst-justin/relay 2.1.0 → 2.1.2

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.
Files changed (4) hide show
  1. package/README.md +16 -0
  2. package/package.json +3 -2
  3. package/postinstall.js +84 -0
  4. package/relay +179 -39
package/README.md CHANGED
@@ -218,3 +218,19 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
218
218
  | `relay.ps1` | Full PowerShell implementation |
219
219
  | `relay.cmd` | Thin CMD wrapper — delegates to `relay.ps1` |
220
220
 
221
+
222
+ ---
223
+
224
+ ## Changelog
225
+
226
+ ### v2.1.1 — 2026-06-24
227
+ - Display current version and latest version at the end of `list`, `status`, `relay` (menu), `sessions`, and `help` commands
228
+ - Background version check (24h cache) — non-blocking, never slows down output
229
+ - `relay update` now detects original install method: uses `npm install -g` for npm installs, `git pull` for git clones, and direct GitHub download for bare script copies
230
+ - `npm install -g` post-install script automatically patches `~/.bashrc`, `~/.zshrc`, `~/.profile`, and fish `config.fish` if the npm bin dir is missing from PATH
231
+
232
+ ### v2.1.0 — 2026-06-24
233
+ - Upgraded GitHub Actions workflow to `actions/checkout@v6` and `actions/setup-node@v6` (Node 24 runtime, removes Node 20 deprecation warning)
234
+
235
+ ### v2.0.2 — 2026-06-23
236
+ - Skip `npm install` during `relay update` when already on the latest version or when version check fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Multi-account switcher for Claude Code — instant credential swap across macOS, Linux, and Windows",
5
5
  "bin": {
6
6
  "relay": "./relay.js"
@@ -10,10 +10,11 @@
10
10
  "relay.js",
11
11
  "relay.ps1",
12
12
  "relay.cmd",
13
+ "postinstall.js",
13
14
  "README.md"
14
15
  ],
15
16
  "scripts": {
16
- "postinstall": "node -e \"if(process.platform!=='win32'){try{require('fs').chmodSync(require('path').join(__dirname,'relay'),'755')}catch(_){}}\""
17
+ "postinstall": "node -e \"if(process.platform!=='win32'){try{require('fs').chmodSync(require('path').join(__dirname,'relay'),'755')}catch(_){}}\" && node postinstall.js"
17
18
  },
18
19
  "engines": {
19
20
  "node": ">=16"
package/postinstall.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // Runs after `npm install -g @dst-justin/relay`.
3
+ // If the npm global bin dir is not in PATH, appends it to shell config files.
4
+ 'use strict';
5
+
6
+ const { execSync } = require('child_process');
7
+ const { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } = require('fs');
8
+ const { join } = require('path');
9
+ const os = require('os');
10
+
11
+ // ── Windows ───────────────────────────────────────────────────────────────────
12
+ // npm on Windows already manages PATH via the installer; nothing to do here.
13
+ if (process.platform === 'win32') {
14
+ // Verify relay.js can reach relay.ps1 (sanity check)
15
+ if (!existsSync(join(__dirname, 'relay.ps1'))) {
16
+ console.warn(' relay: warning — relay.ps1 not found, Windows support may be broken');
17
+ }
18
+ process.exit(0);
19
+ }
20
+
21
+ // ── Get npm global bin dir ────────────────────────────────────────────────────
22
+ // npm sets npm_config_prefix during install; fall back to `npm prefix -g`
23
+ let prefix = process.env.npm_config_prefix;
24
+ if (!prefix) {
25
+ try {
26
+ prefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
27
+ } catch (_) {
28
+ process.exit(0); // can't determine, give up silently
29
+ }
30
+ }
31
+ const binDir = join(prefix, 'bin');
32
+
33
+ // Already in PATH — nothing to do
34
+ const pathDirs = (process.env.PATH || '').split(':');
35
+ if (pathDirs.includes(binDir)) process.exit(0);
36
+
37
+ const home = os.homedir();
38
+
39
+ // ── POSIX shells: bash / zsh / sh ────────────────────────────────────────────
40
+ const exportLine = `export PATH="${binDir}:$PATH"`;
41
+ const marker = '# added by relay';
42
+ const block = `\n${exportLine} ${marker}\n`;
43
+
44
+ const rcFiles = ['.bashrc', '.zshrc', '.profile'].map(f => join(home, f));
45
+ let patched = false;
46
+
47
+ for (const rc of rcFiles) {
48
+ if (!existsSync(rc)) continue;
49
+ try {
50
+ if (readFileSync(rc, 'utf8').includes(marker)) continue; // idempotent
51
+ appendFileSync(rc, block);
52
+ console.log(` relay: added ${binDir} to PATH in ~/${require('path').basename(rc)}`);
53
+ patched = true;
54
+ } catch (_) {}
55
+ }
56
+
57
+ // If no shell rc found, create ~/.bashrc as a last resort
58
+ if (!patched) {
59
+ const bashrc = join(home, '.bashrc');
60
+ try {
61
+ appendFileSync(bashrc, block);
62
+ console.log(` relay: created ~/.bashrc with PATH entry for ${binDir}`);
63
+ patched = true;
64
+ } catch (_) {}
65
+ }
66
+
67
+ // ── Fish shell ────────────────────────────────────────────────────────────────
68
+ const fishConfig = join(home, '.config', 'fish', 'config.fish');
69
+ const fishMarker = '# added by relay';
70
+ const fishLine = `\nset -gx PATH "${binDir}" $PATH ${fishMarker}\n`;
71
+
72
+ if (existsSync(fishConfig)) {
73
+ try {
74
+ if (!readFileSync(fishConfig, 'utf8').includes(fishMarker)) {
75
+ appendFileSync(fishConfig, fishLine);
76
+ console.log(` relay: added ${binDir} to PATH in ~/.config/fish/config.fish`);
77
+ patched = true;
78
+ }
79
+ } catch (_) {}
80
+ }
81
+
82
+ if (patched) {
83
+ console.log(` relay: restart your shell (or 'source ~/.bashrc' / 'source ~/.zshrc') then try: relay list`);
84
+ }
package/relay CHANGED
@@ -12,6 +12,7 @@ RELAY_DIR="${HOME}/.claude-relay"
12
12
  CREDS_STORE="${RELAY_DIR}/credentials"
13
13
  META_STORE="${RELAY_DIR}/meta"
14
14
  CURRENT_FILE="${RELAY_DIR}/current"
15
+ UPDATE_CACHE="${RELAY_DIR}/.update_cache"
15
16
  CLAUDE_DIR="${HOME}/.claude"
16
17
  CLAUDE_JSON="${HOME}/.claude.json"
17
18
  REAL_CLAUDE=$(command -v claude 2>/dev/null || echo "")
@@ -371,14 +372,21 @@ _sync_current_creds() {
371
372
  [[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
372
373
  }
373
374
 
374
- cmd_quick() { _sync_current_creds; render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"; }
375
+ cmd_quick() {
376
+ _check_update_bg
377
+ _sync_current_creds
378
+ render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
379
+ _show_update_notice
380
+ }
375
381
 
376
382
  cmd_list() {
383
+ _check_update_bg
377
384
  hdr "Account List"
378
385
  _sync_current_creds
379
386
  render_table full "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
380
387
  echo ""
381
388
  ok "Inside Claude Code: ${CY}!relay <index>${R} to switch"
389
+ _show_update_notice
382
390
  }
383
391
 
384
392
  cmd_status() {
@@ -450,6 +458,8 @@ EOF
450
458
  [[ -d "${CLAUDE_DIR}/projects" ]] && \
451
459
  n=$(find "${CLAUDE_DIR}/projects" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ')
452
460
  printf "\n ${B}Sessions:${R} %s (shared across all accounts in ~/.claude/projects/)\n" "${n}"
461
+ _check_update_bg
462
+ _show_update_notice
453
463
  }
454
464
 
455
465
  cmd_add() {
@@ -599,6 +609,8 @@ print()
599
609
  print(f' {total} session(s)' if total else ' No sessions found')
600
610
  EOF
601
611
  log "${CY}claude -c${R} resume last ${D}|${R} ${CY}claude -r${R} pick one ${D}|${R} ${CY}claude --resume <id>${R}"
612
+ _check_update_bg
613
+ _show_update_notice
602
614
  }
603
615
 
604
616
  cmd_install() {
@@ -896,47 +908,74 @@ cmd_autoswitch_config() {
896
908
  exit 1
897
909
  fi
898
910
 
911
+ # ── Step 1: switch order ───────────────────────────────────────
899
912
  echo ""
900
- printf " Accounts found: ${CY}%s${R}\n\n" "$(IFS=', '; echo "${accounts[*]}")"
901
-
902
- printf " ${B}Switch order${R} (comma-separated, only listed accounts join autoswitch):\n"
913
+ printf " ${B}Step 1 / 3 — Switch order${R}\n"
914
+ printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n"
915
+ local i=1
916
+ for acct in "${accounts[@]}"; do
917
+ printf " ${D}%d${R} %s\n" "${i}" "${acct}"
918
+ i=$((i+1))
919
+ done
920
+ echo ""
921
+ printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D} or just Enter to keep the order above):${R}\n"
903
922
  printf " > "; read -r order_input
904
- local order_str; order_str=$(echo "${order_input}" | "${PY}" -c "
905
- import sys,re
906
- raw=sys.stdin.read().strip()
907
- names=[x.strip() for x in raw.split(',') if x.strip()]
908
- print(','.join(names))")
909
923
 
924
+ # resolve numbers (space or comma) to names; empty = default order
925
+ local order_str; order_str=$(echo "${order_input}" | "${PY}" - "${accounts[@]}" <<'PYEOF'
926
+ import sys, re
927
+ raw = sys.stdin.read().strip()
928
+ accts = sys.argv[1:]
929
+ if not raw:
930
+ print(','.join(accts))
931
+ else:
932
+ tokens = re.split(r'[\s,]+', raw)
933
+ result = []
934
+ for t in tokens:
935
+ t = t.strip()
936
+ if t.isdigit():
937
+ idx = int(t) - 1
938
+ if 0 <= idx < len(accts): result.append(accts[idx])
939
+ elif t:
940
+ result.append(t)
941
+ print(','.join(result))
942
+ PYEOF
943
+ )
944
+ # show resolved order as a visual chain
945
+ local chain; chain=$(echo "${order_str}" | "${PY}" -c "
946
+ import sys; names=sys.stdin.read().strip().split(',')
947
+ print(' → '.join(names) + ' → (cycle)')")
948
+ printf " ${D}Order: ${CY}%s${R}\n" "${chain}"
949
+
950
+ # ── Step 2: thresholds ────────────────────────────────────────
910
951
  echo ""
911
- printf " ${B}Thresholds${R} (press enter to skip account it won't join autoswitch):\n"
952
+ printf " ${B}Step 2 / 3Thresholds${R}\n"
953
+ printf " ${D}Autoswitch triggers when an account's 5-hr usage exceeds this %%.${R}\n"
954
+ printf " ${D}Enter a number (e.g. 70), or press Enter for default 80%%.${R}\n\n"
955
+
912
956
  local thresholds_json="{"
913
957
  local order_json="["
914
- local first_order=1
915
- local first_thresh=1
958
+ local first=1
916
959
  IFS=',' read -ra order_arr <<< "${order_str}"
917
960
  for acct in "${order_arr[@]}"; do
918
- printf " %s threshold %% [default 80]: " "${acct}"
961
+ printf " ${CY}%s${R} threshold [80%%]: " "${acct}"
919
962
  read -r val
920
- if [[ -z "${val}" ]]; then
921
- continue
922
- fi
923
- [[ ${first_order} -eq 0 ]] && order_json+=","
963
+ [[ -z "${val}" ]] && val="80"
964
+ [[ ${first} -eq 0 ]] && { order_json+=","; thresholds_json+=","; }
924
965
  order_json+="\"${acct}\""
925
- first_order=0
926
- [[ ${first_thresh} -eq 0 ]] && thresholds_json+=","
927
966
  thresholds_json+="\"${acct}\":${val}"
928
- first_thresh=0
967
+ first=0
929
968
  done
930
969
  order_json+="]"
931
970
  thresholds_json+="}"
932
971
 
972
+ # ── Step 3: poll interval ─────────────────────────────────────
933
973
  echo ""
934
- printf " ${B}Poll interval${R} — low usage (minutes) [default 10]: "
935
- read -r low_min; low_min="${low_min:-10}"
936
- printf " ${B}Poll interval${R} high usage (minutes) [default 2]: "
937
- read -r high_min; high_min="${high_min:-2}"
938
- printf " ${B}High usage starts at${R} %% [default 50]: "
939
- read -r high_thr; high_thr="${high_thr:-50}"
974
+ printf " ${B}Step 3 / 3 — Check interval${R}\n"
975
+ printf " ${D}How often the daemon checks usage. Switches to faster polling near the threshold.${R}\n\n"
976
+ printf " Normal check every N minutes [10]: "; read -r low_min; low_min="${low_min:-10}"
977
+ printf " Fast check every N minutes [2]: "; read -r high_min; high_min="${high_min:-2}"
978
+ printf " Switch to fast polling at [50%%]: "; read -r high_thr; high_thr="${high_thr:-50}"
940
979
 
941
980
  local cfg_file="${RELAY_DIR}/autoswitch.json"
942
981
  printf '{"order":%s,"thresholds":%s,"poll":{"low_minutes":%s,"high_minutes":%s,"high_threshold":%s}}' \
@@ -945,7 +984,19 @@ print(','.join(names))")
945
984
  > "${cfg_file}"
946
985
 
947
986
  echo ""
948
- ok "Saved to ${cfg_file}"
987
+ ok "Config saved to ${cfg_file}"
988
+ echo ""
989
+ "${PY}" -c "
990
+ import json, sys
991
+ cfg = json.load(open(sys.argv[1]))
992
+ R='\033[0m'; B='\033[1m'; D='\033[2m'; CY='\033[36m'
993
+ for i, name in enumerate(cfg['order'], 1):
994
+ thr = cfg['thresholds'].get(name, '?')
995
+ print(f' {D}{i}.{R} {name:<14} → switch at {CY}{thr}%{R}')
996
+ p = cfg['poll']
997
+ print(f\"\n {D}polling: {p[\"low_minutes\"]}min normal / {p[\"high_minutes\"]}min fast (fast above {p[\"high_threshold\"]}%){R}\")
998
+ " "${cfg_file}"
999
+ echo ""
949
1000
  log "Run ${CY}relay autoswitch start${R} to activate"
950
1001
  }
951
1002
 
@@ -1190,6 +1241,68 @@ cmd_version() {
1190
1241
  printf "relay %s\n" "$(_read_version)"
1191
1242
  }
1192
1243
 
1244
+ # ── Update notification helpers ───────────────────────────────────────────────
1245
+ # Cache format: "<epoch>:<version>" TTL = 24h
1246
+ _check_update_bg() {
1247
+ (
1248
+ local ttl=86400
1249
+ if [[ -f "${UPDATE_CACHE}" ]]; then
1250
+ local cached; cached=$(cat "${UPDATE_CACHE}" 2>/dev/null)
1251
+ local ts="${cached%%:*}"
1252
+ local now; now=$(date +%s)
1253
+ [[ $(( now - ts )) -lt ${ttl} ]] && exit 0
1254
+ fi
1255
+ local ver
1256
+ ver=$("${PY}" - 2>/dev/null <<'PYEOF'
1257
+ import urllib.request, json, sys
1258
+ def fetch(url, h={}):
1259
+ r = urllib.request.Request(url, headers=h)
1260
+ with urllib.request.urlopen(r, timeout=6) as resp:
1261
+ return json.loads(resp.read())
1262
+ try:
1263
+ d = fetch('https://api.github.com/repos/darkstar1227/relay/releases/latest',
1264
+ {'User-Agent': 'relay-update'})
1265
+ print(d['tag_name'].lstrip('v')); sys.exit(0)
1266
+ except Exception: pass
1267
+ try:
1268
+ d = fetch('https://registry.npmjs.org/@dst-justin%2frelay/latest')
1269
+ print(d['version'])
1270
+ except Exception: sys.exit(1)
1271
+ PYEOF
1272
+ )
1273
+ [[ -n "${ver}" ]] && printf '%s:%s' "$(date +%s)" "${ver}" > "${UPDATE_CACHE}"
1274
+ ) >/dev/null 2>&1 &
1275
+ disown 2>/dev/null || true
1276
+ }
1277
+
1278
+ _show_update_notice() {
1279
+ local current; current=$(_read_version)
1280
+ local latest=""
1281
+ if [[ -f "${UPDATE_CACHE}" ]]; then
1282
+ local cached; cached=$(cat "${UPDATE_CACHE}" 2>/dev/null)
1283
+ latest="${cached#*:}"
1284
+ fi
1285
+ if [[ -n "${latest}" && "${latest}" != "${current}" ]]; then
1286
+ printf "\n ${D}relay version: ${B}${current}${R}${D} → ${CY}${B}${latest}${R}${D} available — run ${CY}relay update${R}${D} to install${R}\n"
1287
+ else
1288
+ local ver_display="${latest:-${current}}"
1289
+ printf "\n ${D}relay version: ${B}${ver_display}${R}${D} (up to date)${R}\n"
1290
+ fi
1291
+ }
1292
+
1293
+ # Detect how relay was originally installed:
1294
+ # npm — package.json present in script dir (npm unpacks the full package)
1295
+ # git — .git dir present in script dir
1296
+ # direct — bare script copy (no package.json, no .git)
1297
+ _detect_install_method() {
1298
+ local d; d=$(_script_dir)
1299
+ # .git check first: git clone has both .git AND package.json; npm publish strips .git
1300
+ if [[ -d "${d}/.git" ]]; then echo "git"
1301
+ elif [[ -f "${d}/package.json" ]]; then echo "npm"
1302
+ else echo "direct"
1303
+ fi
1304
+ }
1305
+
1193
1306
  cmd_update() {
1194
1307
  hdr "Update relay"
1195
1308
 
@@ -1229,18 +1342,43 @@ PYEOF
1229
1342
  fi
1230
1343
 
1231
1344
  local relay_dir; relay_dir=$(_script_dir)
1232
- local npm_cmd; npm_cmd=$(command -v npm 2>/dev/null)
1233
- if [[ -n "${npm_cmd}" ]]; then
1234
- log "Installing via npm..."
1235
- npm install -g @dst-justin/relay@latest
1236
- ok "Updated to $(_read_version)"
1237
- elif [[ -d "${relay_dir}/.git" ]]; then
1238
- log "npm not found updating via git..."
1239
- git -C "${relay_dir}" pull
1240
- else
1241
- err "Cannot update: npm not found and no .git directory"
1242
- log "Install via npm: ${CY}npm install -g @dst-justin/relay@latest${R}"
1243
- fi
1345
+ local method; method=$(_detect_install_method)
1346
+
1347
+ case "${method}" in
1348
+ npm)
1349
+ local npm_cmd; npm_cmd=$(command -v npm 2>/dev/null)
1350
+ if [[ -n "${npm_cmd}" ]]; then
1351
+ log "Updating via npm (original install method)..."
1352
+ npm install -g @dst-justin/relay@latest
1353
+ ok "Updated to $(_read_version)"
1354
+ else
1355
+ err "npm not found — reinstall npm and retry"
1356
+ log "Or update manually: ${CY}npm install -g @dst-justin/relay@latest${R}"
1357
+ fi ;;
1358
+ git)
1359
+ log "Updating via git pull (original install method)..."
1360
+ git -C "${relay_dir}" pull ;;
1361
+ direct)
1362
+ log "Updating via direct download (original install method)..."
1363
+ local script_path; script_path=$(readlink -f "$0" 2>/dev/null || echo "$0")
1364
+ "${PY}" - "${script_path}" "${latest}" <<'PYEOF'
1365
+ import urllib.request, sys, os, stat
1366
+ script_path, version = sys.argv[1], sys.argv[2]
1367
+ url = f'https://raw.githubusercontent.com/darkstar1227/relay/v{version}/relay'
1368
+ try:
1369
+ with urllib.request.urlopen(url, timeout=15) as r:
1370
+ content = r.read()
1371
+ tmp = script_path + '.tmp'
1372
+ with open(tmp, 'wb') as f: f.write(content)
1373
+ os.chmod(tmp, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
1374
+ os.replace(tmp, script_path)
1375
+ except Exception as e:
1376
+ print(f' download failed: {e}', file=sys.stderr); sys.exit(1)
1377
+ PYEOF
1378
+ ok "Updated ${script_path} to ${latest}" ;;
1379
+ esac
1380
+ # Invalidate update cache so next display shows fresh state
1381
+ rm -f "${UPDATE_CACHE}" 2>/dev/null || true
1244
1382
  }
1245
1383
 
1246
1384
  cmd_uninstall() {
@@ -1297,6 +1435,8 @@ cmd_help() {
1297
1435
  echo ""
1298
1436
  printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
1299
1437
  printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
1438
+ _check_update_bg
1439
+ _show_update_notice
1300
1440
  }
1301
1441
 
1302
1442
  # ══════════════════════════════════════════════════════════════════