@ssm-08/relay 0.4.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.
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import url from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
8
+
9
+ function isMain(metaUrl) {
10
+ if (!process.argv[1]) return false;
11
+ try {
12
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
13
+ const meta = fs.realpathSync(path.resolve(url.fileURLToPath(metaUrl)));
14
+ return argv1 === meta;
15
+ } catch {
16
+ return path.resolve(process.argv[1]) === path.resolve(url.fileURLToPath(metaUrl));
17
+ }
18
+ }
19
+
20
+ const NPM_PACKAGE = '@ssm-08/relay';
21
+
22
+ // --------------------------------------------------------------------------
23
+ // Logging
24
+ // --------------------------------------------------------------------------
25
+
26
+ function log(level, phase, msg) {
27
+ const tag = level === 'err' ? ' ERR' : level === 'warn' ? ' !!' : ' OK';
28
+ process.stderr.write(`[relay] [${phase}]${tag} ${msg}\n`);
29
+ }
30
+
31
+ function reporter(phase) {
32
+ return {
33
+ ok: (msg) => log('ok', phase, msg),
34
+ warn: (msg) => log('warn', phase, msg),
35
+ err: (msg) => log('err', phase, msg),
36
+ };
37
+ }
38
+
39
+ // --------------------------------------------------------------------------
40
+ // Path helpers
41
+ // --------------------------------------------------------------------------
42
+
43
+ export function resolveHome(env = process.env) {
44
+ return env.RELAY_HOME ?? env.CLAUDE_HOME ?? path.join(os.homedir(), '.claude');
45
+ }
46
+
47
+ export function relayPaths(home) {
48
+ return {
49
+ home,
50
+ plugin: path.join(home, 'plugins', 'relay'),
51
+ settings: path.join(home, 'settings.json'),
52
+ };
53
+ }
54
+
55
+ // --------------------------------------------------------------------------
56
+ // Preflight
57
+ // --------------------------------------------------------------------------
58
+
59
+ class PreflightError extends Error {}
60
+
61
+ export function preflight() {
62
+ const r = reporter('preflight');
63
+
64
+ const [major] = process.versions.node.split('.').map(Number);
65
+ if (major < 20) {
66
+ throw new PreflightError(
67
+ `Node ${process.versions.node} found but >= 20 required.\n` +
68
+ `Install from https://nodejs.org/ or via nvm: https://github.com/nvm-sh/nvm`
69
+ );
70
+ }
71
+ r.ok(`Node v${process.versions.node}`);
72
+
73
+ // git is required by relay's sync mechanism at runtime
74
+ const gitResult = spawnSync('git', ['--version'], { encoding: 'utf8', windowsHide: true });
75
+ if (gitResult.error || gitResult.status !== 0) {
76
+ throw new PreflightError(
77
+ 'git not found on PATH. Install from https://git-scm.com/'
78
+ );
79
+ }
80
+ r.ok((gitResult.stdout || '').trim());
81
+
82
+ const claudeResult = process.platform === 'win32'
83
+ ? spawnSync('cmd', ['/c', 'claude', '--version'], { encoding: 'utf8', windowsHide: true })
84
+ : spawnSync('claude', ['--version'], { encoding: 'utf8' });
85
+ if (claudeResult.error || claudeResult.status !== 0) {
86
+ r.warn('claude CLI not found — distiller will fail at runtime. Install Claude Code first.');
87
+ } else {
88
+ r.ok(`claude ${(claudeResult.stdout || '').trim()}`);
89
+ }
90
+ }
91
+
92
+ // --------------------------------------------------------------------------
93
+ // Repo resolution
94
+ // --------------------------------------------------------------------------
95
+
96
+ export function validateRelayCheckout(dir) {
97
+ const markers = ['bin/relay.mjs', 'hooks/run-hook.cmd', '.claude-plugin/plugin.json'];
98
+ for (const m of markers) {
99
+ if (!fs.existsSync(path.join(dir, m))) {
100
+ throw new Error(
101
+ `Not a valid Relay checkout (missing ${m}): ${dir}\n` +
102
+ 'Pass --from-local to the Relay repo root.'
103
+ );
104
+ }
105
+ }
106
+ }
107
+
108
+ // Resolve the directory where relay source files live.
109
+ // - --from-local: user-provided path (dev/local checkout)
110
+ // - default: package root derived from __dirname (works for npm global install)
111
+ export function resolvePackageDir(fromLocal) {
112
+ if (fromLocal) {
113
+ const abs = path.resolve(fromLocal);
114
+ validateRelayCheckout(abs);
115
+ return abs;
116
+ }
117
+ return path.resolve(path.join(__dirname, '..'));
118
+ }
119
+
120
+ // --------------------------------------------------------------------------
121
+ // Symlink / junction
122
+ // --------------------------------------------------------------------------
123
+
124
+ function stripWinPathPrefix(target) {
125
+ return target.replace(/^(?:\\\\[?]|\\[?][?])\\/, '');
126
+ }
127
+
128
+ export function inspectLink(p) {
129
+ try {
130
+ const stat = fs.lstatSync(p);
131
+ if (stat.isSymbolicLink()) {
132
+ let target = fs.readlinkSync(p);
133
+ target = stripWinPathPrefix(target);
134
+ return { kind: 'symlink', target };
135
+ }
136
+ if (stat.isDirectory()) {
137
+ try {
138
+ let target = fs.readlinkSync(p);
139
+ target = stripWinPathPrefix(target);
140
+ return { kind: 'junction', target };
141
+ } catch {
142
+ return { kind: 'dir', target: null };
143
+ }
144
+ }
145
+ return { kind: 'file', target: null };
146
+ } catch (e) {
147
+ if (e.code === 'ENOENT') return { kind: 'missing', target: null };
148
+ throw e;
149
+ }
150
+ }
151
+
152
+ export function createLink(src, dst) {
153
+ const r = reporter('link');
154
+
155
+ if (!fs.existsSync(src)) {
156
+ throw new Error(`Source does not exist: ${src}`);
157
+ }
158
+
159
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
160
+
161
+ const existing = inspectLink(dst);
162
+ if (existing.kind !== 'missing') {
163
+ const normalSrc = src.replace(/\\/g, '/').replace(/\/$/, '');
164
+ const normalTarget = (existing.target || '').replace(/\\/g, '/').replace(/\/$/, '');
165
+ if (normalTarget === normalSrc) {
166
+ r.ok(`Link already correct — skipping`);
167
+ return;
168
+ }
169
+ throw new Error(
170
+ `${dst} exists and points to: ${existing.target}\n` +
171
+ `Expected: ${src}\n` +
172
+ 'Run "relay uninstall" first, or delete the link manually.'
173
+ );
174
+ }
175
+
176
+ if (process.platform === 'win32') {
177
+ fs.symlinkSync(src, dst, 'junction');
178
+ r.ok(`Junction created: ${dst} → ${src}`);
179
+ } else {
180
+ fs.symlinkSync(src, dst, 'dir');
181
+ r.ok(`Symlink created: ${dst} → ${src}`);
182
+ }
183
+ }
184
+
185
+ export function removeLink(p) {
186
+ const info = inspectLink(p);
187
+ if (info.kind === 'missing') return;
188
+ if (process.platform === 'win32' && info.kind === 'junction') {
189
+ fs.rmdirSync(p);
190
+ } else {
191
+ fs.unlinkSync(p);
192
+ }
193
+ }
194
+
195
+ // --------------------------------------------------------------------------
196
+ // settings.json patching
197
+ // --------------------------------------------------------------------------
198
+
199
+ const HOOK_DETECTION_PATTERNS = ['run-hook.cmd', 'run-hook.sh', 'plugins/relay/hooks/'];
200
+
201
+ function isRelayHookEntry(entry) {
202
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [entry.hooks].filter(Boolean);
203
+ return hooks.some((h) =>
204
+ h && h.command && HOOK_DETECTION_PATTERNS.some((p) => h.command.includes(p))
205
+ );
206
+ }
207
+
208
+ function buildHookEntries(repoDir) {
209
+ const dispatcher = path.join(repoDir, 'hooks', 'run-hook.cmd');
210
+ const q = `"${dispatcher}"`;
211
+ return {
212
+ SessionStart: {
213
+ matcher: '',
214
+ hooks: [{ type: 'command', command: `${q} session-start`, timeout: 2, statusMessage: 'Loading relay memory...' }],
215
+ },
216
+ Stop: {
217
+ matcher: '',
218
+ hooks: [{ type: 'command', command: `${q} stop`, timeout: 5 }],
219
+ },
220
+ UserPromptSubmit: {
221
+ matcher: '',
222
+ hooks: [{ type: 'command', command: `${q} user-prompt-submit`, timeout: 3 }],
223
+ },
224
+ };
225
+ }
226
+
227
+ function stripJsoncComments(src) {
228
+ let out = '';
229
+ let i = 0;
230
+ let inString = false;
231
+ while (i < src.length) {
232
+ if (!inString && src[i] === '"') {
233
+ inString = true; out += src[i++]; continue;
234
+ }
235
+ if (inString) {
236
+ if (src[i] === '\\') { out += src[i++] + src[i++]; continue; }
237
+ if (src[i] === '"') inString = false;
238
+ out += src[i++]; continue;
239
+ }
240
+ if (src[i] === '/' && src[i + 1] === '/') {
241
+ while (i < src.length && src[i] !== '\n') i++;
242
+ continue;
243
+ }
244
+ if (src[i] === '/' && src[i + 1] === '*') {
245
+ i += 2;
246
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
247
+ i += 2; continue;
248
+ }
249
+ out += src[i++];
250
+ }
251
+ return out.replace(/,(\s*[}\]])/g, '$1');
252
+ }
253
+
254
+ export function readSettings(p) {
255
+ if (!fs.existsSync(p)) return {};
256
+ const raw = fs.readFileSync(p, 'utf8').replace(/^/, '');
257
+ try { return JSON.parse(raw); } catch {}
258
+ try { return JSON.parse(stripJsoncComments(raw)); } catch (e2) {
259
+ throw new Error(`Failed to parse ${p}: ${e2.message}`);
260
+ }
261
+ }
262
+
263
+ export function patchSettingsInMemory(settings, { mode, repoDir = '' }) {
264
+ const out = JSON.parse(JSON.stringify(settings));
265
+
266
+ if (!out.hooks || typeof out.hooks !== 'object') out.hooks = {};
267
+ const hooks = out.hooks;
268
+
269
+ for (const event of ['SessionStart', 'Stop', 'UserPromptSubmit']) {
270
+ let current = hooks[event];
271
+ if (!current) current = [];
272
+ else if (!Array.isArray(current)) current = [current];
273
+
274
+ const filtered = current.filter((entry) => !isRelayHookEntry(entry));
275
+
276
+ if (mode === 'install') {
277
+ const fresh = buildHookEntries(repoDir);
278
+ hooks[event] = [...filtered, fresh[event]];
279
+ } else {
280
+ hooks[event] = filtered;
281
+ if (filtered.length === 0) delete hooks[event];
282
+ }
283
+ }
284
+
285
+ if (mode === 'uninstall' && Object.keys(hooks).length === 0) {
286
+ delete out.hooks;
287
+ }
288
+
289
+ return out;
290
+ }
291
+
292
+ export function writeSettingsAtomic(p, obj, { backup = true } = {}) {
293
+ const dir = path.dirname(p);
294
+ fs.mkdirSync(dir, { recursive: true });
295
+
296
+ if (backup && fs.existsSync(p)) {
297
+ const base = path.basename(p);
298
+ const old = fs.readdirSync(dir)
299
+ .filter((f) => f.startsWith(`${base}.relay-backup-`))
300
+ .map((f) => path.join(dir, f));
301
+ for (const f of old) { try { fs.unlinkSync(f); } catch {} }
302
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
303
+ fs.copyFileSync(p, `${p}.relay-backup-${ts}`);
304
+ }
305
+
306
+ const tmp = `${p}.tmp`;
307
+ const content = JSON.stringify(obj, null, 2).replace(/\r\n/g, '\n') + '\n';
308
+ fs.writeFileSync(tmp, content, { encoding: 'utf8' });
309
+ fs.renameSync(tmp, p);
310
+ }
311
+
312
+ // --------------------------------------------------------------------------
313
+ // chmod (POSIX only)
314
+ // --------------------------------------------------------------------------
315
+
316
+ export function chmodHookDispatcher(repoDir) {
317
+ if (process.platform === 'win32') return;
318
+ const dispatcherPath = path.join(repoDir, 'hooks', 'run-hook.cmd');
319
+ if (fs.existsSync(dispatcherPath)) {
320
+ fs.chmodSync(dispatcherPath, 0o755);
321
+ }
322
+ }
323
+
324
+ // --------------------------------------------------------------------------
325
+ // Verify
326
+ // --------------------------------------------------------------------------
327
+
328
+ export function verifyInstall(paths) {
329
+ const issues = [];
330
+
331
+ const info = inspectLink(paths.plugin);
332
+ if (info.kind === 'missing') {
333
+ issues.push(`Plugin link missing: ${paths.plugin}`);
334
+ }
335
+
336
+ const linkedDir = (info.target && info.kind !== 'missing') ? info.target : null;
337
+ if (linkedDir) {
338
+ const relayBin = path.join(linkedDir, 'bin', 'relay.mjs');
339
+ if (fs.existsSync(relayBin)) {
340
+ const r = spawnSync('node', [relayBin, 'status'], {
341
+ encoding: 'utf8',
342
+ timeout: 5_000,
343
+ windowsHide: true,
344
+ stdio: ['pipe', 'pipe', 'pipe'],
345
+ });
346
+ if (r.status !== 0) {
347
+ issues.push(`relay CLI failed: ${(r.stderr || '').trim()}`);
348
+ }
349
+ } else {
350
+ issues.push(`relay CLI not found at: ${relayBin}`);
351
+ }
352
+ }
353
+
354
+ try {
355
+ const settings = readSettings(paths.settings);
356
+ const hooksObj = settings.hooks || {};
357
+ for (const event of ['SessionStart', 'Stop', 'UserPromptSubmit']) {
358
+ const entries = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
359
+ const relayEntries = entries.filter(isRelayHookEntry);
360
+ if (relayEntries.length === 0) {
361
+ issues.push(`settings.json missing Relay ${event} hook`);
362
+ } else if (relayEntries.length > 1) {
363
+ issues.push(`settings.json has ${relayEntries.length} Relay ${event} hooks (expected 1)`);
364
+ }
365
+ }
366
+ } catch (e) {
367
+ issues.push(`settings.json unreadable: ${e.message}`);
368
+ }
369
+
370
+ if (process.platform !== 'win32' && linkedDir) {
371
+ const dispatcher = path.join(linkedDir, 'hooks', 'run-hook.cmd');
372
+ if (fs.existsSync(dispatcher)) {
373
+ try {
374
+ fs.accessSync(dispatcher, fs.constants.X_OK);
375
+ } catch {
376
+ issues.push(`Hook dispatcher not executable: ${dispatcher} — run: chmod +x ${dispatcher}`);
377
+ }
378
+ }
379
+ }
380
+
381
+ return { ok: issues.length === 0, issues };
382
+ }
383
+
384
+ // --------------------------------------------------------------------------
385
+ // CLI registration — for --from-local dev installs only
386
+ // --------------------------------------------------------------------------
387
+
388
+ export function registerCli(repoDir, r) {
389
+ const [npmExe, npmArgs] = process.platform === 'win32'
390
+ ? ['cmd', ['/c', 'npm', 'link']]
391
+ : ['npm', ['link']];
392
+ const result = spawnSync(npmExe, npmArgs, {
393
+ cwd: repoDir,
394
+ encoding: 'utf8',
395
+ windowsHide: true,
396
+ timeout: 30_000,
397
+ });
398
+ if (result.error || result.status !== 0) {
399
+ const npmErr = (result.stderr || result.stdout || '').trim().slice(0, 500);
400
+ r.warn(
401
+ `Could not register relay CLI globally (npm link failed).\n` +
402
+ (npmErr ? ` npm: ${npmErr}\n` : '') +
403
+ ` Run manually: cd "${repoDir}" && npm link\n` +
404
+ ` Or invoke directly: node "${path.join(repoDir, 'bin', 'relay.mjs')}" <command>`
405
+ );
406
+ } else {
407
+ r.ok('relay CLI registered globally via npm link (relay <command> now works)');
408
+ }
409
+ }
410
+
411
+ // --------------------------------------------------------------------------
412
+ // Orchestrators
413
+ // --------------------------------------------------------------------------
414
+
415
+ export function install(opts) {
416
+ const { home, fromLocal, dryRun } = opts;
417
+ const r = reporter('install');
418
+ const paths = relayPaths(home);
419
+
420
+ r.ok(`Home: ${home}`);
421
+ preflight();
422
+
423
+ const repoDir = resolvePackageDir(fromLocal);
424
+ r.ok(`${fromLocal ? 'Repo (local)' : 'Repo'}: ${repoDir}`);
425
+
426
+ chmodHookDispatcher(repoDir);
427
+
428
+ if (!dryRun) {
429
+ createLink(repoDir, paths.plugin);
430
+
431
+ const settings = readSettings(paths.settings);
432
+ const patched = patchSettingsInMemory(settings, { mode: 'install', repoDir });
433
+ writeSettingsAtomic(paths.settings, patched);
434
+ r.ok(`settings.json updated`);
435
+ } else {
436
+ r.ok('[dry-run] would create link + patch settings');
437
+ }
438
+
439
+ const result = verifyInstall(paths);
440
+ if (!dryRun && !result.ok) {
441
+ for (const issue of result.issues) r.warn(`Verify: ${issue}`);
442
+ } else if (!dryRun) {
443
+ r.ok('Verified install');
444
+ }
445
+
446
+ // For --from-local dev installs, wire the CLI via npm link.
447
+ // npm global installs already have relay on PATH — skip.
448
+ if (!dryRun && fromLocal) {
449
+ registerCli(repoDir, reporter('cli'));
450
+ }
451
+
452
+ if (!dryRun) {
453
+ let repoHint = '';
454
+ try {
455
+ const gitTop = spawnSync('git', ['rev-parse', '--show-toplevel'], {
456
+ encoding: 'utf8', windowsHide: true, timeout: 3_000,
457
+ });
458
+ const top = (gitTop.stdout || '').trim();
459
+ if (gitTop.status === 0 && top && !top.startsWith(repoDir)) {
460
+ repoHint = top;
461
+ }
462
+ } catch {}
463
+
464
+ process.stderr.write('\n[relay] Install complete.\n\n');
465
+ if (repoHint) {
466
+ process.stderr.write(` Detected repo: ${repoHint}\n`);
467
+ process.stderr.write(` Run from that directory:\n\n`);
468
+ } else {
469
+ process.stderr.write(' Next — cd into your project repo, then:\n\n');
470
+ }
471
+ process.stderr.write(' relay init\n');
472
+ process.stderr.write(' git add .relay/ .gitignore\n');
473
+ process.stderr.write(' git commit -m "chore: add relay shared memory"\n');
474
+ process.stderr.write(' git push\n\n');
475
+ process.stderr.write(' Verify: relay doctor\n');
476
+ }
477
+ return result;
478
+ }
479
+
480
+ export function uninstall(opts) {
481
+ const { home, dryRun } = opts;
482
+ const r = reporter('uninstall');
483
+ const paths = relayPaths(home);
484
+
485
+ if (!dryRun) {
486
+ removeLink(paths.plugin);
487
+ r.ok(`Removed link: ${paths.plugin}`);
488
+
489
+ if (fs.existsSync(paths.settings)) {
490
+ const settings = readSettings(paths.settings);
491
+ const patched = patchSettingsInMemory(settings, { mode: 'uninstall' });
492
+ writeSettingsAtomic(paths.settings, patched, { backup: false });
493
+ r.ok(`Removed relay entries from settings.json`);
494
+ }
495
+
496
+ // Deregister global CLI — fail-open
497
+ const [npmExe, npmArgs] = process.platform === 'win32'
498
+ ? ['cmd', ['/c', 'npm', 'uninstall', '-g', NPM_PACKAGE]]
499
+ : ['npm', ['uninstall', '-g', NPM_PACKAGE]];
500
+ const npmResult = spawnSync(npmExe, npmArgs, { encoding: 'utf8', windowsHide: true, timeout: 15_000 });
501
+ if (!npmResult.error && npmResult.status === 0) {
502
+ r.ok('relay CLI deregistered from global PATH');
503
+ } else {
504
+ r.warn(`Could not deregister relay CLI — remove manually: npm uninstall -g ${NPM_PACKAGE}`);
505
+ }
506
+
507
+ // Clean up settings backup files
508
+ const settingsDir = path.dirname(paths.settings);
509
+ const settingsBase = path.basename(paths.settings);
510
+ try {
511
+ const backups = fs.readdirSync(settingsDir)
512
+ .filter((f) => f.startsWith(`${settingsBase}.relay-backup-`));
513
+ for (const f of backups) {
514
+ try { fs.unlinkSync(path.join(settingsDir, f)); } catch {}
515
+ }
516
+ if (backups.length > 0) r.ok(`Removed ${backups.length} settings backup(s)`);
517
+ } catch {}
518
+ } else {
519
+ r.ok(`[dry-run] would remove link + strip settings entries + npm uninstall -g ${NPM_PACKAGE} + remove backups`);
520
+ }
521
+ }
522
+
523
+ export function update(opts) {
524
+ const { home } = opts;
525
+ const r = reporter('update');
526
+ const paths = relayPaths(home);
527
+
528
+ const linkInfo = inspectLink(paths.plugin);
529
+ if (linkInfo.kind === 'missing') {
530
+ throw new Error('Relay not installed. Run: relay install');
531
+ }
532
+
533
+ // Detect --from-local install: link target does not pass through node_modules
534
+ const isNpmInstall = linkInfo.target && linkInfo.target.includes('node_modules');
535
+ if (!isNpmInstall) {
536
+ throw new Error(
537
+ `Local install detected — plugin points to: ${linkInfo.target}\n` +
538
+ ` relay update only works for npm installs.\n` +
539
+ ` Pull your local checkout manually, then re-run:\n` +
540
+ ` relay install --from-local "${linkInfo.target}"`
541
+ );
542
+ }
543
+
544
+ r.ok(`Updating ${NPM_PACKAGE} via npm...`);
545
+ const [npmExe, npmArgs] = process.platform === 'win32'
546
+ ? ['cmd', ['/c', 'npm', 'update', '-g', NPM_PACKAGE]]
547
+ : ['npm', ['update', '-g', NPM_PACKAGE]];
548
+ const npmResult = spawnSync(npmExe, npmArgs, {
549
+ encoding: 'utf8',
550
+ windowsHide: true,
551
+ timeout: 60_000,
552
+ stdio: 'inherit',
553
+ });
554
+ if (npmResult.error || npmResult.status !== 0) {
555
+ throw new Error(`npm update failed. Try manually: npm update -g ${NPM_PACKAGE}`);
556
+ }
557
+
558
+ // Re-derive repoDir from __dirname — path is stable across npm updates for global installs
559
+ const repoDir = path.resolve(path.join(__dirname, '..'));
560
+ chmodHookDispatcher(repoDir);
561
+
562
+ const settings = readSettings(paths.settings);
563
+ const patched = patchSettingsInMemory(settings, { mode: 'install', repoDir });
564
+ writeSettingsAtomic(paths.settings, patched);
565
+ r.ok('settings.json refreshed');
566
+
567
+ const result = verifyInstall(paths);
568
+ if (!result.ok) {
569
+ for (const issue of result.issues) r.warn(`Verify: ${issue}`);
570
+ } else {
571
+ r.ok('Verified install after update');
572
+ }
573
+ return result;
574
+ }
575
+
576
+ function issueHint(issue) {
577
+ if (issue.includes('Plugin link missing')) return 'relay install';
578
+ if (issue.includes('relay CLI')) return 'relay install';
579
+ if (issue.includes('settings.json missing Relay')) return 'relay install';
580
+ if (issue.includes('settings.json has') && issue.includes('hooks')) return 'relay uninstall && relay install';
581
+ if (issue.includes('not executable')) {
582
+ const m = issue.match(/: (.+?) —/);
583
+ return m ? `chmod +x "${m[1]}"` : 'relay install';
584
+ }
585
+ if (issue.includes('settings.json unreadable')) return 'Check settings.json is valid JSON, then: relay install';
586
+ return 'relay install';
587
+ }
588
+
589
+ export function doctor(opts) {
590
+ const { home } = opts;
591
+ const paths = relayPaths(home);
592
+ const result = verifyInstall(paths);
593
+
594
+ const claudeCheck = process.platform === 'win32'
595
+ ? spawnSync('cmd', ['/c', 'claude', '--version'], { encoding: 'utf8', windowsHide: true, timeout: 3_000 })
596
+ : spawnSync('claude', ['--version'], { encoding: 'utf8', timeout: 3_000 });
597
+ const claudeOk = !claudeCheck.error && claudeCheck.status === 0;
598
+
599
+ if (result.ok && claudeOk) {
600
+ process.stdout.write('[relay] doctor: all checks passed\n');
601
+ } else {
602
+ if (!result.ok) {
603
+ process.stdout.write(`[relay] doctor: ${result.issues.length} issue(s) found:\n`);
604
+ for (const issue of result.issues) {
605
+ process.stdout.write(` - ${issue}\n`);
606
+ process.stdout.write(` Fix: ${issueHint(issue)}\n`);
607
+ }
608
+ }
609
+ if (!claudeOk) {
610
+ process.stdout.write(' ! claude CLI not found — distiller will not run\n');
611
+ process.stdout.write(' Fix: Install Claude Code from https://claude.ai/download\n');
612
+ }
613
+ if (!result.ok) {
614
+ process.stdout.write('\n Run: relay install to repair most issues.\n');
615
+ }
616
+ }
617
+ return result;
618
+ }
619
+
620
+ // --------------------------------------------------------------------------
621
+ // CLI entry
622
+ // --------------------------------------------------------------------------
623
+
624
+ function parseArgs(argv) {
625
+ const out = { _: [] };
626
+ for (let i = 0; i < argv.length; i++) {
627
+ const a = argv[i];
628
+ if (!a.startsWith('--')) { out._.push(a); continue; }
629
+ const key = a.slice(2);
630
+ const val = argv[i + 1];
631
+ if (!val || val.startsWith('--')) {
632
+ out[key] = true;
633
+ } else {
634
+ out[key] = val;
635
+ i++;
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+
641
+ export async function main(argv) {
642
+ const args = parseArgs(argv);
643
+ const [command] = args._;
644
+ const home = resolveHome(
645
+ args.home
646
+ ? { ...process.env, RELAY_HOME: args.home }
647
+ : process.env
648
+ );
649
+
650
+ const opts = {
651
+ home,
652
+ fromLocal: args['from-local'] && args['from-local'] !== true ? args['from-local'] : null,
653
+ force: !!args.force,
654
+ dryRun: !!args['dry-run'],
655
+ };
656
+
657
+ try {
658
+ switch (command) {
659
+ case 'install': {
660
+ const installResult = install(opts);
661
+ if (installResult && !installResult.ok) process.exit(1);
662
+ break;
663
+ }
664
+ case 'uninstall': {
665
+ uninstall(opts);
666
+ break;
667
+ }
668
+ case 'update': {
669
+ const updateResult = update(opts);
670
+ if (updateResult && !updateResult.ok) process.exit(1);
671
+ break;
672
+ }
673
+ case 'verify':
674
+ case 'doctor': {
675
+ const result = doctor(opts);
676
+ process.exit(result.ok ? 0 : 1);
677
+ break;
678
+ }
679
+ default:
680
+ process.stderr.write(
681
+ 'Usage: node scripts/installer.mjs <install|uninstall|update|doctor>\n' +
682
+ ' --from-local <path> Use local relay checkout (dev only)\n' +
683
+ ' --home <path> Override ~/.claude/ location (for testing)\n' +
684
+ ' --dry-run Preview actions without making changes\n'
685
+ );
686
+ process.exit(1);
687
+ }
688
+ } catch (e) {
689
+ if (e instanceof Error && e.constructor.name === 'PreflightError') {
690
+ process.stderr.write(`[relay] preflight failed: ${e.message}\n`);
691
+ } else {
692
+ process.stderr.write(`[relay] error: ${e && e.message ? e.message : e}\n`);
693
+ }
694
+ process.exit(1);
695
+ }
696
+ }
697
+
698
+ if (isMain(import.meta.url)) {
699
+ await main(process.argv.slice(2));
700
+ }