clay-server 1.0.0-beta.1

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/bin/claude-relay.js +6 -0
  4. package/bin/cli.js +2602 -0
  5. package/lib/cli-sessions.js +265 -0
  6. package/lib/config.js +338 -0
  7. package/lib/daemon.js +802 -0
  8. package/lib/ipc.js +124 -0
  9. package/lib/notes.js +121 -0
  10. package/lib/pages.js +1308 -0
  11. package/lib/project.js +3172 -0
  12. package/lib/public/app.js +4795 -0
  13. package/lib/public/apple-touch-icon-dark.png +0 -0
  14. package/lib/public/apple-touch-icon.png +0 -0
  15. package/lib/public/clay-logo.png +0 -0
  16. package/lib/public/css/admin.css +576 -0
  17. package/lib/public/css/base.css +284 -0
  18. package/lib/public/css/diff.css +139 -0
  19. package/lib/public/css/filebrowser.css +1482 -0
  20. package/lib/public/css/highlight.css +144 -0
  21. package/lib/public/css/home-hub.css +455 -0
  22. package/lib/public/css/icon-strip.css +614 -0
  23. package/lib/public/css/input.css +654 -0
  24. package/lib/public/css/loop.css +898 -0
  25. package/lib/public/css/menus.css +823 -0
  26. package/lib/public/css/messages.css +1448 -0
  27. package/lib/public/css/mobile-nav.css +384 -0
  28. package/lib/public/css/overlays.css +893 -0
  29. package/lib/public/css/playbook.css +264 -0
  30. package/lib/public/css/profile.css +268 -0
  31. package/lib/public/css/rewind.css +528 -0
  32. package/lib/public/css/scheduler-modal.css +1429 -0
  33. package/lib/public/css/scheduler.css +1306 -0
  34. package/lib/public/css/server-settings.css +811 -0
  35. package/lib/public/css/sidebar.css +1189 -0
  36. package/lib/public/css/skills.css +789 -0
  37. package/lib/public/css/sticky-notes.css +848 -0
  38. package/lib/public/css/stt.css +155 -0
  39. package/lib/public/css/title-bar.css +517 -0
  40. package/lib/public/favicon-banded-32.png +0 -0
  41. package/lib/public/favicon-banded.png +0 -0
  42. package/lib/public/favicon-dark.svg +1 -0
  43. package/lib/public/favicon.svg +1 -0
  44. package/lib/public/icon-192-dark.png +0 -0
  45. package/lib/public/icon-192.png +0 -0
  46. package/lib/public/icon-512-dark.png +0 -0
  47. package/lib/public/icon-512.png +0 -0
  48. package/lib/public/icon-banded-76.png +0 -0
  49. package/lib/public/icon-banded-96.png +0 -0
  50. package/lib/public/icon-mono.svg +1 -0
  51. package/lib/public/index.html +1437 -0
  52. package/lib/public/manifest.json +27 -0
  53. package/lib/public/modules/admin.js +631 -0
  54. package/lib/public/modules/ascii-logo.js +442 -0
  55. package/lib/public/modules/diff.js +398 -0
  56. package/lib/public/modules/events.js +21 -0
  57. package/lib/public/modules/filebrowser.js +1535 -0
  58. package/lib/public/modules/fileicons.js +172 -0
  59. package/lib/public/modules/icons.js +54 -0
  60. package/lib/public/modules/input.js +661 -0
  61. package/lib/public/modules/markdown.js +378 -0
  62. package/lib/public/modules/notifications.js +548 -0
  63. package/lib/public/modules/playbook.js +578 -0
  64. package/lib/public/modules/profile.js +378 -0
  65. package/lib/public/modules/project-settings.js +901 -0
  66. package/lib/public/modules/qrcode.js +67 -0
  67. package/lib/public/modules/rewind.js +345 -0
  68. package/lib/public/modules/scheduler.js +2833 -0
  69. package/lib/public/modules/server-settings.js +928 -0
  70. package/lib/public/modules/sidebar.js +2264 -0
  71. package/lib/public/modules/skills.js +794 -0
  72. package/lib/public/modules/state.js +3 -0
  73. package/lib/public/modules/sticky-notes.js +1253 -0
  74. package/lib/public/modules/stt.js +272 -0
  75. package/lib/public/modules/terminal.js +736 -0
  76. package/lib/public/modules/theme.js +720 -0
  77. package/lib/public/modules/tools.js +1622 -0
  78. package/lib/public/modules/utils.js +56 -0
  79. package/lib/public/style.css +24 -0
  80. package/lib/public/sw.js +154 -0
  81. package/lib/public/wordmark-banded-20.png +0 -0
  82. package/lib/public/wordmark-banded-32.png +0 -0
  83. package/lib/public/wordmark-banded-64.png +0 -0
  84. package/lib/public/wordmark-banded-80.png +0 -0
  85. package/lib/push.js +130 -0
  86. package/lib/scheduler.js +402 -0
  87. package/lib/sdk-bridge.js +1035 -0
  88. package/lib/server.js +2055 -0
  89. package/lib/sessions.js +552 -0
  90. package/lib/smtp.js +221 -0
  91. package/lib/terminal-manager.js +187 -0
  92. package/lib/terminal.js +24 -0
  93. package/lib/themes/ayu-light.json +9 -0
  94. package/lib/themes/catppuccin-latte.json +9 -0
  95. package/lib/themes/catppuccin-mocha.json +9 -0
  96. package/lib/themes/clay-light.json +10 -0
  97. package/lib/themes/clay.json +10 -0
  98. package/lib/themes/dracula.json +9 -0
  99. package/lib/themes/everforest-light.json +9 -0
  100. package/lib/themes/everforest.json +9 -0
  101. package/lib/themes/github-light.json +9 -0
  102. package/lib/themes/gruvbox-dark.json +9 -0
  103. package/lib/themes/gruvbox-light.json +9 -0
  104. package/lib/themes/monokai.json +9 -0
  105. package/lib/themes/nord-light.json +9 -0
  106. package/lib/themes/nord.json +9 -0
  107. package/lib/themes/one-dark.json +9 -0
  108. package/lib/themes/one-light.json +9 -0
  109. package/lib/themes/rose-pine-dawn.json +9 -0
  110. package/lib/themes/rose-pine.json +9 -0
  111. package/lib/themes/solarized-dark.json +9 -0
  112. package/lib/themes/solarized-light.json +9 -0
  113. package/lib/themes/tokyo-night-light.json +9 -0
  114. package/lib/themes/tokyo-night.json +9 -0
  115. package/lib/updater.js +97 -0
  116. package/lib/users.js +459 -0
  117. package/lib/utils.js +64 -0
  118. package/package.json +56 -0
package/bin/cli.js ADDED
@@ -0,0 +1,2602 @@
1
+ #!/usr/bin/env node
2
+
3
+ // --- Node version check (must run before any require that may use Node 20+ features) ---
4
+ var _nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
5
+ if (_nodeMajor < 20) {
6
+ console.error("");
7
+ console.error("\x1b[31m[clay] Node.js 20+ is required (current: " + process.version + ")\x1b[0m");
8
+ console.error("[clay] The Claude Agent SDK 0.2.40+ requires Node 20 for Symbol.dispose support.");
9
+ console.error("[clay] If you cannot upgrade Node, use claude-relay@2.4.3 which supports Node 18.");
10
+ console.error("");
11
+ console.error(" Upgrade Node: nvm install 22 && nvm use 22");
12
+ console.error(" Or use older: npx claude-relay@2.4.3");
13
+ console.error("");
14
+ process.exit(78);
15
+ }
16
+
17
+ var os = require("os");
18
+ var fs = require("fs");
19
+ var path = require("path");
20
+ var { execSync, execFileSync, spawn } = require("child_process");
21
+ var qrcode = require("qrcode-terminal");
22
+ var net = require("net");
23
+
24
+ // Detect dev mode — dev and prod use separate daemon files so they can run simultaneously
25
+ var _isDev = (process.argv[1] && path.basename(process.argv[1]) === "clay-dev") || process.argv.includes("--dev");
26
+ if (_isDev) process.env.CLAY_DEV = "1";
27
+
28
+ var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
29
+ var { sendIPCCommand } = require("../lib/ipc");
30
+ var { generateAuthToken } = require("../lib/server");
31
+ var { enableMultiUser, disableMultiUser, hasAdmin, isMultiUser } = require("../lib/users");
32
+
33
+ function openUrl(url) {
34
+ try {
35
+ if (process.platform === "win32") {
36
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true, windowsHide: true }).unref();
37
+ } else {
38
+ var cmd = process.platform === "darwin" ? "open" : "xdg-open";
39
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
40
+ }
41
+ } catch (e) {}
42
+ }
43
+
44
+ var args = process.argv.slice(2);
45
+ var port = _isDev ? 2635 : 2633;
46
+ var useHttps = true;
47
+ var skipUpdate = false;
48
+ var debugMode = false;
49
+ var autoYes = false;
50
+ var cliPin = null;
51
+ var shutdownMode = false;
52
+ var restartMode = false;
53
+ var addPath = null;
54
+ var removePath = null;
55
+ var listMode = false;
56
+ var dangerouslySkipPermissions = false;
57
+ var headlessMode = false;
58
+ var watchMode = false;
59
+ var host = null;
60
+ var multiUserMode = false;
61
+
62
+ for (var i = 0; i < args.length; i++) {
63
+ if (args[i] === "-p" || args[i] === "--port") {
64
+ port = parseInt(args[i + 1], 10);
65
+ if (isNaN(port)) {
66
+ console.error("Invalid port number");
67
+ process.exit(1);
68
+ }
69
+ i++;
70
+ } else if (args[i] === "--host" || args[i] === "--bind") {
71
+ host = args[i + 1] || null;
72
+ i++;
73
+ } else if (args[i] === "--no-https") {
74
+ useHttps = false;
75
+ } else if (args[i] === "--no-update" || args[i] === "--skip-update") {
76
+ skipUpdate = true;
77
+ } else if (args[i] === "--dev") {
78
+ // Already handled above for CLAY_HOME, just skip
79
+ } else if (args[i] === "--watch" || args[i] === "-w") {
80
+ watchMode = true;
81
+ } else if (args[i] === "--debug") {
82
+ debugMode = true;
83
+ } else if (args[i] === "-y" || args[i] === "--yes") {
84
+ autoYes = true;
85
+ } else if (args[i] === "--pin") {
86
+ cliPin = args[i + 1] || null;
87
+ i++;
88
+ } else if (args[i] === "--shutdown") {
89
+ shutdownMode = true;
90
+ } else if (args[i] === "--restart") {
91
+ restartMode = true;
92
+ } else if (args[i] === "--add") {
93
+ addPath = args[i + 1] || ".";
94
+ i++;
95
+ } else if (args[i] === "--remove") {
96
+ removePath = args[i + 1] || null;
97
+ i++;
98
+ } else if (args[i] === "--list") {
99
+ listMode = true;
100
+ } else if (args[i] === "--headless") {
101
+ headlessMode = true;
102
+ autoYes = true;
103
+ } else if (args[i] === "--dangerously-skip-permissions") {
104
+ dangerouslySkipPermissions = true;
105
+ } else if (args[i] === "--multi-user") {
106
+ multiUserMode = true;
107
+ } else if (args[i] === "-h" || args[i] === "--help") {
108
+ console.log("Usage: clay-server [-p|--port <port>] [--host <address>] [--no-https] [--no-update] [--debug] [-y|--yes] [--pin <pin>] [--shutdown] [--restart]");
109
+ console.log(" clay-server --add <path> Add a project to the running daemon");
110
+ console.log(" clay-server --remove <path> Remove a project from the running daemon");
111
+ console.log(" clay-server --list List registered projects");
112
+ console.log("");
113
+ console.log("Options:");
114
+ console.log(" -p, --port <port> Port to listen on (default: 2633)");
115
+ console.log(" --host <address> Address to bind to (default: 0.0.0.0)");
116
+ console.log(" --no-https Disable HTTPS (enabled by default via mkcert)");
117
+ console.log(" --no-update Skip auto-update check on startup");
118
+ console.log(" --debug Enable debug panel in the web UI");
119
+ console.log(" -y, --yes Skip interactive prompts (accept defaults)");
120
+ console.log(" --pin <pin> Set 6-digit PIN (use with --yes)");
121
+ console.log(" --shutdown Shut down the running relay daemon");
122
+ console.log(" --restart Restart the running relay daemon");
123
+ console.log(" --add <path> Add a project directory (use '.' for current)");
124
+ console.log(" --remove <path> Remove a project directory");
125
+ console.log(" --list List all registered projects");
126
+ console.log(" --headless Start daemon and exit immediately (implies --yes)");
127
+ console.log(" --multi-user Enable multi-user mode (generates setup code)");
128
+ console.log(" --dangerously-skip-permissions");
129
+ console.log(" Bypass all permission prompts");
130
+ process.exit(0);
131
+ }
132
+ }
133
+
134
+ // Dev mode implies debug + skip update
135
+ if (_isDev) {
136
+ debugMode = true;
137
+ skipUpdate = true;
138
+ }
139
+
140
+ // --- Handle --shutdown before anything else ---
141
+ if (shutdownMode) {
142
+ var shutdownConfig = loadConfig();
143
+ isDaemonAliveAsync(shutdownConfig).then(function (alive) {
144
+ if (!alive) {
145
+ console.error("No running daemon found.");
146
+ process.exit(1);
147
+ }
148
+ sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
149
+ console.log("Server stopped.");
150
+ clearStaleConfig();
151
+ process.exit(0);
152
+ }).catch(function (err) {
153
+ console.error("Shutdown failed:", err.message);
154
+ process.exit(1);
155
+ });
156
+ });
157
+ return;
158
+ }
159
+
160
+ // --- Handle --restart before anything else ---
161
+ if (restartMode) {
162
+ var restartConfig = loadConfig();
163
+ isDaemonAliveAsync(restartConfig).then(function (alive) {
164
+ if (!alive) {
165
+ console.error("No running daemon found.");
166
+ process.exit(1);
167
+ }
168
+ sendIPCCommand(socketPath(), { cmd: "restart" }).then(function () {
169
+ console.log("Server restarted.");
170
+ process.exit(0);
171
+ }).catch(function (err) {
172
+ console.error("Restart failed:", err.message);
173
+ process.exit(1);
174
+ });
175
+ });
176
+ return;
177
+ }
178
+
179
+ // --- Handle --add before anything else ---
180
+ if (addPath !== null) {
181
+ var absAdd = path.resolve(addPath);
182
+ try {
183
+ var stat = fs.statSync(absAdd);
184
+ if (!stat.isDirectory()) {
185
+ console.error("Not a directory: " + absAdd);
186
+ process.exit(1);
187
+ }
188
+ } catch (e) {
189
+ console.error("Directory not found: " + absAdd);
190
+ process.exit(1);
191
+ }
192
+ var addConfig = loadConfig();
193
+ isDaemonAliveAsync(addConfig).then(function (alive) {
194
+ if (!alive) {
195
+ console.error("No running daemon. Start with: npx clay-server");
196
+ process.exit(1);
197
+ }
198
+ sendIPCCommand(socketPath(), { cmd: "add_project", path: absAdd }).then(function (res) {
199
+ if (res.ok) {
200
+ if (res.existing) {
201
+ console.log("Already registered: " + res.slug);
202
+ } else {
203
+ console.log("Added: " + res.slug + " \u2192 " + absAdd);
204
+ }
205
+ process.exit(0);
206
+ } else {
207
+ console.error("Failed: " + (res.error || "unknown error"));
208
+ process.exit(1);
209
+ }
210
+ });
211
+ });
212
+ return;
213
+ }
214
+
215
+ // --- Handle --remove before anything else ---
216
+ if (removePath !== null) {
217
+ var absRemove = path.resolve(removePath);
218
+ var removeConfig = loadConfig();
219
+ isDaemonAliveAsync(removeConfig).then(function (alive) {
220
+ if (!alive) {
221
+ console.error("No running daemon. Start with: npx clay-server");
222
+ process.exit(1);
223
+ }
224
+ sendIPCCommand(socketPath(), { cmd: "remove_project", path: absRemove }).then(function (res) {
225
+ if (res.ok) {
226
+ console.log("Removed: " + path.basename(absRemove));
227
+ process.exit(0);
228
+ } else {
229
+ console.error("Failed: " + (res.error || "project not found"));
230
+ process.exit(1);
231
+ }
232
+ });
233
+ });
234
+ return;
235
+ }
236
+
237
+ // --- Handle --list before anything else ---
238
+ if (listMode) {
239
+ var listConfig = loadConfig();
240
+ isDaemonAliveAsync(listConfig).then(function (alive) {
241
+ if (!alive) {
242
+ console.error("No running daemon. Start with: npx clay-server");
243
+ process.exit(1);
244
+ }
245
+ sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (res) {
246
+ if (!res.ok || !res.projects || res.projects.length === 0) {
247
+ console.log("No projects registered.");
248
+ process.exit(0);
249
+ return;
250
+ }
251
+ console.log("Projects (" + res.projects.length + "):\n");
252
+ for (var p = 0; p < res.projects.length; p++) {
253
+ var proj = res.projects[p];
254
+ var label = " " + proj.slug;
255
+ if (proj.title) label += " (" + proj.title + ")";
256
+ label += "\n " + proj.path;
257
+ console.log(label);
258
+ }
259
+ console.log("");
260
+ process.exit(0);
261
+ });
262
+ });
263
+ return;
264
+ }
265
+
266
+ // --- Handle --multi-user before anything else ---
267
+ if (multiUserMode) {
268
+ var muResult = enableMultiUser();
269
+ if (muResult.alreadyEnabled && muResult.hasAdmin) {
270
+ console.log("");
271
+ console.log("Multi-user mode is already enabled and an admin account exists.");
272
+ console.log("No changes made.");
273
+ console.log("");
274
+ } else if (muResult.setupCode) {
275
+ console.log("");
276
+ console.log("\x1b[33m⚠ Experimental Feature\x1b[0m");
277
+ console.log("");
278
+ console.log(" Multi-user mode is experimental and may change in future releases.");
279
+ console.log(" Sharing access to AI-powered tools may be subject to your provider's");
280
+ console.log(" terms of service. Please review the applicable usage policies before");
281
+ console.log(" granting access to other users.");
282
+ console.log("");
283
+ console.log("\x1b[32mMulti-user mode enabled.\x1b[0m");
284
+ console.log("");
285
+ console.log("Setup code: \x1b[1m" + muResult.setupCode + "\x1b[0m");
286
+ console.log("");
287
+ console.log("Open Clay in your browser and enter this code to create the admin account.");
288
+ console.log("The code is single-use and will be cleared once the admin is set up.");
289
+ console.log("");
290
+ }
291
+ process.exit(0);
292
+ }
293
+
294
+ var cwd = process.cwd();
295
+
296
+ // --- ANSI helpers ---
297
+ var isBasicTerm = process.env.TERM_PROGRAM === "Apple_Terminal";
298
+ var a = {
299
+ reset: "\x1b[0m",
300
+ bold: "\x1b[1m",
301
+ dim: "\x1b[2m",
302
+ clay: isBasicTerm ? "\x1b[34m" : "\x1b[38;2;88;87;252m", // #5857FC Indigo — active interaction
303
+ green: "\x1b[32m",
304
+ yellow: "\x1b[33m",
305
+ red: "\x1b[31m",
306
+ };
307
+
308
+ function gradient(text) {
309
+ if (isBasicTerm) {
310
+ return a.yellow + text + a.reset;
311
+ }
312
+ // Terracotta (#FE7150) → Warm brown (#D09558) — Clay earthy warmth
313
+ var r0 = 254, g0 = 113, b0 = 80;
314
+ var r1 = 208, g1 = 149, b1 = 88;
315
+ var out = "";
316
+ var len = text.length;
317
+ for (var i = 0; i < len; i++) {
318
+ var t = len > 1 ? i / (len - 1) : 0;
319
+ var r = Math.round(r0 + (r1 - r0) * t);
320
+ var g = Math.round(g0 + (g1 - g0) * t);
321
+ var b = Math.round(b0 + (b1 - b0) * t);
322
+ out += "\x1b[38;2;" + r + ";" + g + ";" + b + "m" + text[i];
323
+ }
324
+ return out + a.reset;
325
+ }
326
+
327
+ var sym = {
328
+ pointer: a.clay + "◆" + a.reset,
329
+ done: a.green + "◇" + a.reset,
330
+ bar: a.dim + "│" + a.reset,
331
+ end: a.dim + "└" + a.reset,
332
+ warn: a.yellow + "▲" + a.reset,
333
+ };
334
+
335
+ function log(s) { console.log(" " + s); }
336
+
337
+ function clearUp(n) {
338
+ for (var i = 0; i < n; i++) {
339
+ process.stdout.write("\x1b[1A\x1b[2K");
340
+ }
341
+ }
342
+
343
+ // --- Daemon watcher ---
344
+ // Polls daemon socket; if connection fails, the server is down.
345
+ var _daemonWatcher = null;
346
+
347
+ function startDaemonWatcher() {
348
+ if (_daemonWatcher) return;
349
+ _daemonWatcher = setInterval(function () {
350
+ var client = net.connect(socketPath());
351
+ var timer = setTimeout(function () {
352
+ client.destroy();
353
+ onDaemonDied();
354
+ }, 1500);
355
+ client.on("connect", function () {
356
+ clearTimeout(timer);
357
+ client.destroy();
358
+ });
359
+ client.on("error", function () {
360
+ clearTimeout(timer);
361
+ client.destroy();
362
+ onDaemonDied();
363
+ });
364
+ }, 3000);
365
+ }
366
+
367
+ function stopDaemonWatcher() {
368
+ if (_daemonWatcher) {
369
+ clearInterval(_daemonWatcher);
370
+ _daemonWatcher = null;
371
+ }
372
+ }
373
+
374
+ var _restartAttempts = 0;
375
+ var MAX_RESTART_ATTEMPTS = 5;
376
+ var _restartBackoffStart = 0;
377
+
378
+ function onDaemonDied() {
379
+ stopDaemonWatcher();
380
+ // Clean up stdin in case a prompt is active
381
+ try {
382
+ process.stdin.setRawMode(false);
383
+ process.stdin.pause();
384
+ process.stdin.removeAllListeners("data");
385
+ } catch (e) {}
386
+
387
+ // Check if this was a crash (crash.json exists) vs intentional shutdown
388
+ var crashInfo = readCrashInfo();
389
+ if (!crashInfo) {
390
+ // Intentional shutdown, no restart
391
+ log("");
392
+ log(sym.warn + " " + a.yellow + "Server has been shut down." + a.reset);
393
+ log(a.dim + " Run " + a.reset + "npx clay-server" + a.dim + " to start again." + a.reset);
394
+ log("");
395
+ process.exit(0);
396
+ return;
397
+ }
398
+
399
+ // Reset backoff counter if enough time has passed since last restart burst
400
+ var now = Date.now();
401
+ if (_restartBackoffStart && now - _restartBackoffStart > 60000) {
402
+ _restartAttempts = 0;
403
+ }
404
+
405
+ _restartAttempts++;
406
+ if (_restartAttempts > MAX_RESTART_ATTEMPTS) {
407
+ log("");
408
+ log(sym.warn + " " + a.red + "Server crashed too many times (" + MAX_RESTART_ATTEMPTS + " attempts). Giving up." + a.reset);
409
+ if (crashInfo.reason) {
410
+ log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
411
+ }
412
+ log(a.dim + " Check logs: " + a.reset + logPath());
413
+ log("");
414
+ process.exit(1);
415
+ return;
416
+ }
417
+
418
+ if (_restartAttempts === 1) _restartBackoffStart = now;
419
+
420
+ log("");
421
+ log(sym.warn + " " + a.yellow + "Server crashed. Restarting... (" + _restartAttempts + "/" + MAX_RESTART_ATTEMPTS + ")" + a.reset);
422
+ if (crashInfo.reason) {
423
+ log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
424
+ }
425
+
426
+ // Re-fork the daemon from saved config
427
+ restartDaemonFromConfig();
428
+ }
429
+
430
+ async function restartDaemonFromConfig() {
431
+ var lastConfig = loadConfig();
432
+ if (!lastConfig || !lastConfig.projects) {
433
+ log(a.red + " No config found. Cannot restart." + a.reset);
434
+ process.exit(1);
435
+ return;
436
+ }
437
+
438
+ clearStaleConfig();
439
+
440
+ // Wait for port to be released
441
+ var targetPort = lastConfig.port || port;
442
+ var waited = 0;
443
+ while (waited < 3000) {
444
+ var free = await isPortFree(targetPort);
445
+ if (free) break;
446
+ await new Promise(function (resolve) { setTimeout(resolve, 300); });
447
+ waited += 300;
448
+ }
449
+
450
+ // Rebuild config (preserve everything except pid)
451
+ var newConfig = {
452
+ pid: null,
453
+ port: targetPort,
454
+ pinHash: lastConfig.pinHash || null,
455
+ tls: lastConfig.tls !== undefined ? lastConfig.tls : useHttps,
456
+ debug: lastConfig.debug || false,
457
+ keepAwake: lastConfig.keepAwake || false,
458
+ dangerouslySkipPermissions: lastConfig.dangerouslySkipPermissions || false,
459
+ projects: (lastConfig.projects || []).filter(function (p) {
460
+ return fs.existsSync(p.path);
461
+ }),
462
+ };
463
+
464
+ ensureConfigDir();
465
+ saveConfig(newConfig);
466
+
467
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
468
+ var logFile = logPath();
469
+ var logFd = fs.openSync(logFile, "a");
470
+
471
+ var child = spawn(process.execPath, [daemonScript], {
472
+ detached: true,
473
+ windowsHide: true,
474
+ stdio: ["ignore", logFd, logFd],
475
+ env: Object.assign({}, process.env, {
476
+ CLAY_CONFIG: configPath(),
477
+ }),
478
+ });
479
+ child.unref();
480
+ fs.closeSync(logFd);
481
+
482
+ newConfig.pid = child.pid;
483
+ saveConfig(newConfig);
484
+
485
+ // Wait and verify (retry up to 5 seconds)
486
+ var alive = false;
487
+ for (var rc = 0; rc < 10; rc++) {
488
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
489
+ alive = await isDaemonAliveAsync(newConfig);
490
+ if (alive) break;
491
+ }
492
+ if (!alive) {
493
+ log(a.red + " Restart failed. Check logs: " + a.reset + logFile);
494
+ process.exit(1);
495
+ return;
496
+ }
497
+ var ip = getLocalIP();
498
+ log(sym.done + " " + a.green + "Server restarted successfully." + a.reset);
499
+ log("");
500
+ showMainMenu(newConfig, ip);
501
+ }
502
+
503
+ // --- Network ---
504
+ function getLocalIP() {
505
+ var interfaces = os.networkInterfaces();
506
+
507
+ // Prefer Tailscale IP
508
+ for (var name in interfaces) {
509
+ if (/^(tailscale|utun)/.test(name)) {
510
+ for (var j = 0; j < interfaces[name].length; j++) {
511
+ var addr = interfaces[name][j];
512
+ if (addr.family === "IPv4" && !addr.internal && addr.address.startsWith("100.")) {
513
+ return addr.address;
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ // All interfaces for Tailscale CGNAT range
520
+ for (var addrs of Object.values(interfaces)) {
521
+ for (var k = 0; k < addrs.length; k++) {
522
+ if (addrs[k].family === "IPv4" && !addrs[k].internal && addrs[k].address.startsWith("100.")) {
523
+ return addrs[k].address;
524
+ }
525
+ }
526
+ }
527
+
528
+ // Fall back to LAN IP
529
+ for (var addrs2 of Object.values(interfaces)) {
530
+ for (var m = 0; m < addrs2.length; m++) {
531
+ if (addrs2[m].family === "IPv4" && !addrs2[m].internal) {
532
+ return addrs2[m].address;
533
+ }
534
+ }
535
+ }
536
+
537
+ return "localhost";
538
+ }
539
+
540
+ // --- Certs ---
541
+ function isRoutableIP(addr) {
542
+ if (addr.startsWith("10.")) return true;
543
+ if (addr.startsWith("192.168.")) return true;
544
+ if (addr.startsWith("100.")) {
545
+ var second = parseInt(addr.split(".")[1], 10);
546
+ return second >= 64 && second <= 127; // CGNAT (Tailscale)
547
+ }
548
+ if (addr.startsWith("172.")) {
549
+ var second = parseInt(addr.split(".")[1], 10);
550
+ return second >= 16 && second <= 31;
551
+ }
552
+ return false;
553
+ }
554
+
555
+ function getAllIPs() {
556
+ var ips = [];
557
+ var ifaces = os.networkInterfaces();
558
+ for (var addrs of Object.values(ifaces)) {
559
+ for (var j = 0; j < addrs.length; j++) {
560
+ if (addrs[j].family === "IPv4" && !addrs[j].internal && isRoutableIP(addrs[j].address)) {
561
+ ips.push(addrs[j].address);
562
+ }
563
+ }
564
+ }
565
+ return ips;
566
+ }
567
+
568
+ function ensureCerts(ip) {
569
+ var homeDir = os.homedir();
570
+ var certDir = path.join(process.env.CLAY_HOME || path.join(homeDir, ".clay"), "certs");
571
+ var keyPath = path.join(certDir, "key.pem");
572
+ var certPath = path.join(certDir, "cert.pem");
573
+
574
+ var legacyDir = path.join(cwd, ".claude-relay", "certs");
575
+ var legacyKey = path.join(legacyDir, "key.pem");
576
+ var legacyCert = path.join(legacyDir, "cert.pem");
577
+ if (!fs.existsSync(keyPath) && fs.existsSync(legacyKey) && fs.existsSync(legacyCert)) {
578
+ fs.mkdirSync(certDir, { recursive: true });
579
+ fs.copyFileSync(legacyKey, keyPath);
580
+ fs.copyFileSync(legacyCert, certPath);
581
+ }
582
+
583
+ var caRoot = null;
584
+ try {
585
+ caRoot = path.join(
586
+ execSync("mkcert -CAROOT", { encoding: "utf8" }).trim(),
587
+ "rootCA.pem"
588
+ );
589
+ if (!fs.existsSync(caRoot)) caRoot = null;
590
+ } catch (e) {}
591
+
592
+ // Collect all IPv4 addresses (Tailscale + LAN)
593
+ var allIPs = getAllIPs();
594
+
595
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
596
+ var needRegen = false;
597
+ try {
598
+ var certText = execFileSync("openssl", ["x509", "-in", certPath, "-text", "-noout"], { encoding: "utf8" });
599
+ for (var i = 0; i < allIPs.length; i++) {
600
+ if (certText.indexOf(allIPs[i]) === -1) {
601
+ needRegen = true;
602
+ break;
603
+ }
604
+ }
605
+ } catch (e) { needRegen = true; }
606
+ if (!needRegen) return { key: keyPath, cert: certPath, caRoot: caRoot };
607
+ }
608
+
609
+ fs.mkdirSync(certDir, { recursive: true });
610
+
611
+ var domains = ["localhost", "127.0.0.1", "::1"];
612
+ for (var i = 0; i < allIPs.length; i++) {
613
+ if (domains.indexOf(allIPs[i]) === -1) domains.push(allIPs[i]);
614
+ }
615
+
616
+ try {
617
+ var mkcertArgs = ["-key-file", keyPath, "-cert-file", certPath].concat(domains);
618
+ execFileSync("mkcert", mkcertArgs, { stdio: "pipe" });
619
+ } catch (err) {
620
+ return null;
621
+ }
622
+
623
+ return { key: keyPath, cert: certPath, caRoot: caRoot };
624
+ }
625
+
626
+ // --- Logo ---
627
+ function printLogo() {
628
+ var r = a.reset;
629
+ var lines = [
630
+ "________/\\\\\\\\\\\\\\\\\\__/\\\\\\_________________/\\\\\\\\\\\\\\\\\\_____/\\\\\\________/\\\\\\",
631
+ " _____/\\\\\\////////__\\/\\\\\\_______________/\\\\\\\\\\\\\\\\\\\\\\\\\\__\\///\\\\\\____/\\\\\\/_",
632
+ " ___/\\\\\\/___________\\/\\\\\\______________/\\\\\\/////////\\\\\\___\\///\\\\\\/\\\\\\/___",
633
+ " __/\\\\\\_____________\\/\\\\\\_____________\\/\\\\\\_______\\/\\\\\\_____\\///\\\\\\/_____",
634
+ " _\\/\\\\\\_____________\\/\\\\\\_____________\\/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_______\\/\\\\\\______",
635
+ " _\\//\\\\\\____________\\/\\\\\\_____________\\/\\\\\\/////////\\\\\\_______\\/\\\\\\______",
636
+ " __\\///\\\\\\__________\\/\\\\\\_____________\\/\\\\\\_______\\/\\\\\\_______\\/\\\\\\______",
637
+ " ____\\////\\\\\\\\\\\\\\\\\\_\\/\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\_\\/\\\\\\_______\\/\\\\\\_______\\/\\\\\\______",
638
+ " _______\\/////////__\\///////////////__\\///________\\///________\\///_______",
639
+ ];
640
+ console.log("");
641
+ if (isBasicTerm) {
642
+ for (var i = 0; i < lines.length; i++) {
643
+ console.log(a.green + lines[i] + r);
644
+ }
645
+ return;
646
+ }
647
+ // Tri-accent vertical gradient: Green (#09E5A3) → Indigo (#5857FC) → Terracotta (#FE7150)
648
+ var stops = [
649
+ [9, 229, 163],
650
+ [88, 87, 252],
651
+ [254, 113, 80],
652
+ ];
653
+ for (var i = 0; i < lines.length; i++) {
654
+ var t = lines.length > 1 ? i / (lines.length - 1) : 0;
655
+ var cr, cg, cb;
656
+ if (t <= 0.5) {
657
+ var s = t * 2;
658
+ cr = Math.round(stops[0][0] + (stops[1][0] - stops[0][0]) * s);
659
+ cg = Math.round(stops[0][1] + (stops[1][1] - stops[0][1]) * s);
660
+ cb = Math.round(stops[0][2] + (stops[1][2] - stops[0][2]) * s);
661
+ } else {
662
+ var s = (t - 0.5) * 2;
663
+ cr = Math.round(stops[1][0] + (stops[2][0] - stops[1][0]) * s);
664
+ cg = Math.round(stops[1][1] + (stops[2][1] - stops[1][1]) * s);
665
+ cb = Math.round(stops[1][2] + (stops[2][2] - stops[1][2]) * s);
666
+ }
667
+ console.log("\x1b[38;2;" + cr + ";" + cg + ";" + cb + "m" + lines[i] + r);
668
+ }
669
+ }
670
+
671
+ // --- Interactive prompts ---
672
+ function promptToggle(title, desc, defaultValue, callback) {
673
+ var value = defaultValue || false;
674
+
675
+ function renderToggle() {
676
+ var yes = value
677
+ ? a.green + a.bold + "● Yes" + a.reset
678
+ : a.dim + "○ Yes" + a.reset;
679
+ var no = !value
680
+ ? a.green + a.bold + "● No" + a.reset
681
+ : a.dim + "○ No" + a.reset;
682
+ return yes + a.dim + " / " + a.reset + no;
683
+ }
684
+
685
+ var lines = 2;
686
+ log(sym.pointer + " " + a.bold + title + a.reset);
687
+ if (desc) {
688
+ log(sym.bar + " " + a.dim + desc + a.reset);
689
+ lines = 3;
690
+ }
691
+ process.stdout.write(" " + sym.bar + " " + renderToggle());
692
+
693
+ process.stdin.setRawMode(true);
694
+ process.stdin.resume();
695
+ process.stdin.setEncoding("utf8");
696
+
697
+ process.stdin.on("data", function onToggle(ch) {
698
+ if (ch === "\x1b[D" || ch === "\x1b[C" || ch === "\t") {
699
+ value = !value;
700
+ process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
701
+ } else if (ch === "y" || ch === "Y") {
702
+ value = true;
703
+ process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
704
+ } else if (ch === "n" || ch === "N") {
705
+ value = false;
706
+ process.stdout.write("\x1b[2K\r " + sym.bar + " " + renderToggle());
707
+ } else if (ch === "\r" || ch === "\n") {
708
+ process.stdin.setRawMode(false);
709
+ process.stdin.pause();
710
+ process.stdin.removeListener("data", onToggle);
711
+ process.stdout.write("\n");
712
+ clearUp(lines);
713
+ var result = value ? a.green + "Yes" + a.reset : a.dim + "No" + a.reset;
714
+ log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + result);
715
+ callback(value);
716
+ } else if (ch === "\x03") {
717
+ process.stdout.write("\n");
718
+ clearUp(lines);
719
+ log(sym.end + " " + a.dim + "Cancelled" + a.reset);
720
+ process.exit(0);
721
+ }
722
+ });
723
+ }
724
+
725
+ function promptPin(callback) {
726
+ log(sym.pointer + " " + a.bold + "PIN protection" + a.reset);
727
+ log(sym.bar + " " + a.dim + "Require a 6-digit PIN to access the web UI. Enter to skip." + a.reset);
728
+ process.stdout.write(" " + sym.bar + " ");
729
+
730
+ var pin = "";
731
+ process.stdin.setRawMode(true);
732
+ process.stdin.resume();
733
+ process.stdin.setEncoding("utf8");
734
+
735
+ process.stdin.on("data", function onPin(ch) {
736
+ if (ch === "\r" || ch === "\n") {
737
+ process.stdin.setRawMode(false);
738
+ process.stdin.pause();
739
+ process.stdin.removeListener("data", onPin);
740
+ process.stdout.write("\n");
741
+
742
+ if (pin !== "" && !/^\d{6}$/.test(pin)) {
743
+ clearUp(3);
744
+ log(sym.done + " PIN protection " + a.red + "Must be exactly 6 digits" + a.reset);
745
+ log(sym.end);
746
+ process.exit(1);
747
+ return;
748
+ }
749
+
750
+ clearUp(3);
751
+ if (pin) {
752
+ log(sym.done + " PIN protection " + a.dim + "·" + a.reset + " " + a.green + "Enabled" + a.reset);
753
+ } else {
754
+ log(sym.done + " PIN protection " + a.dim + "· Skipped" + a.reset);
755
+ }
756
+ log(sym.bar);
757
+ callback(pin || null);
758
+ } else if (ch === "\x03") {
759
+ process.stdout.write("\n");
760
+ clearUp(3);
761
+ log(sym.end + " " + a.dim + "Cancelled" + a.reset);
762
+ process.exit(0);
763
+ } else if (ch === "\x7f" || ch === "\b") {
764
+ if (pin.length > 0) {
765
+ pin = pin.slice(0, -1);
766
+ process.stdout.write("\b \b");
767
+ }
768
+ } else if (/\d/.test(ch) && pin.length < 6) {
769
+ pin += ch;
770
+ process.stdout.write(a.clay + "●" + a.reset);
771
+ }
772
+ });
773
+ }
774
+
775
+ /**
776
+ * Text input prompt with placeholder and Tab directory completion.
777
+ * title: prompt label, placeholder: dimmed hint, callback(value)
778
+ * Enter with empty input returns placeholder value.
779
+ * Tab completes directory paths.
780
+ */
781
+ function promptText(title, placeholder, callback) {
782
+ var prefix = " " + sym.bar + " ";
783
+ var hintLine = "";
784
+ var lineCount = 2;
785
+
786
+ log(sym.pointer + " " + a.bold + title + a.reset + " " + a.dim + "(esc to go back)" + a.reset);
787
+ process.stdout.write(prefix + a.dim + placeholder + a.reset);
788
+ // Move cursor to start of placeholder
789
+ process.stdout.write("\r" + prefix);
790
+
791
+ var text = "";
792
+ var showingPlaceholder = true;
793
+ process.stdin.setRawMode(true);
794
+ process.stdin.resume();
795
+ process.stdin.setEncoding("utf8");
796
+
797
+ function redrawInput() {
798
+ process.stdout.write("\x1b[2K\r" + prefix + text);
799
+ }
800
+
801
+ function clearHint() {
802
+ if (hintLine) {
803
+ // Erase the hint line below
804
+ process.stdout.write("\n\x1b[2K\x1b[1A");
805
+ hintLine = "";
806
+ lineCount = 2;
807
+ }
808
+ }
809
+
810
+ function showHint(msg) {
811
+ clearHint();
812
+ hintLine = msg;
813
+ lineCount = 3;
814
+ // Print hint below, then move cursor back up
815
+ process.stdout.write("\n" + prefix + a.dim + msg + a.reset + "\x1b[1A");
816
+ redrawInput();
817
+ }
818
+
819
+ function tabComplete() {
820
+ var current = text || "";
821
+ if (!current) current = "/";
822
+
823
+ // Resolve ~ to home
824
+ if (current.charAt(0) === "~") {
825
+ current = os.homedir() + current.substring(1);
826
+ }
827
+
828
+ var resolved = path.resolve(current);
829
+ var dir, partial;
830
+
831
+ try {
832
+ var st = fs.statSync(resolved);
833
+ if (st.isDirectory()) {
834
+ // Current text is a full directory — list its children
835
+ dir = resolved;
836
+ partial = "";
837
+ } else {
838
+ dir = path.dirname(resolved);
839
+ partial = path.basename(resolved);
840
+ }
841
+ } catch (e) {
842
+ // Path doesn't exist — complete from parent
843
+ dir = path.dirname(resolved);
844
+ partial = path.basename(resolved);
845
+ }
846
+
847
+ var entries;
848
+ try {
849
+ entries = fs.readdirSync(dir);
850
+ } catch (e) {
851
+ return; // Can't read directory
852
+ }
853
+
854
+ // Filter to directories only, matching partial prefix
855
+ var matches = [];
856
+ var lowerPartial = partial.toLowerCase();
857
+ for (var i = 0; i < entries.length; i++) {
858
+ if (entries[i].charAt(0) === "." && !partial.startsWith(".")) continue;
859
+ if (lowerPartial && entries[i].toLowerCase().indexOf(lowerPartial) !== 0) continue;
860
+ try {
861
+ var full = path.join(dir, entries[i]);
862
+ if (fs.statSync(full).isDirectory()) {
863
+ matches.push(entries[i]);
864
+ }
865
+ } catch (e) {}
866
+ }
867
+
868
+ if (matches.length === 0) return;
869
+
870
+ if (matches.length === 1) {
871
+ // Single match — complete it
872
+ var completed = path.join(dir, matches[0]) + path.sep;
873
+ text = completed;
874
+ showingPlaceholder = false;
875
+ clearHint();
876
+ redrawInput();
877
+ } else {
878
+ // Multiple matches — find longest common prefix and show candidates
879
+ var common = matches[0];
880
+ for (var m = 1; m < matches.length; m++) {
881
+ var k = 0;
882
+ while (k < common.length && k < matches[m].length && common.charAt(k) === matches[m].charAt(k)) k++;
883
+ common = common.substring(0, k);
884
+ }
885
+
886
+ if (common.length > partial.length) {
887
+ // Extend to common prefix
888
+ text = path.join(dir, common);
889
+ showingPlaceholder = false;
890
+ }
891
+
892
+ // Show candidates as hint
893
+ var display = matches.slice(0, 6).join(" ");
894
+ if (matches.length > 6) display += " " + a.dim + "+" + (matches.length - 6) + " more" + a.reset;
895
+ showHint(display);
896
+ }
897
+ }
898
+
899
+ process.stdin.on("data", function onText(ch) {
900
+ if (ch === "\r" || ch === "\n") {
901
+ process.stdin.setRawMode(false);
902
+ process.stdin.pause();
903
+ process.stdin.removeListener("data", onText);
904
+ var result = text || placeholder;
905
+ clearHint();
906
+ process.stdout.write("\n");
907
+ clearUp(2);
908
+ log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + result);
909
+ callback(result);
910
+ } else if (ch === "\x1b" || ch === "\x03") {
911
+ process.stdin.setRawMode(false);
912
+ process.stdin.pause();
913
+ process.stdin.removeListener("data", onText);
914
+ clearHint();
915
+ process.stdout.write("\n");
916
+ clearUp(2);
917
+ if (ch === "\x03") {
918
+ log(sym.end + " " + a.dim + "Cancelled" + a.reset);
919
+ process.exit(0);
920
+ }
921
+ callback(null);
922
+ } else if (ch === "\t") {
923
+ if (showingPlaceholder) {
924
+ // Accept placeholder first
925
+ text = placeholder;
926
+ showingPlaceholder = false;
927
+ redrawInput();
928
+ }
929
+ tabComplete();
930
+ } else if (ch === "\x7f" || ch === "\b") {
931
+ if (text.length > 0) {
932
+ text = text.slice(0, -1);
933
+ clearHint();
934
+ if (text.length === 0) {
935
+ // Re-show placeholder
936
+ showingPlaceholder = true;
937
+ process.stdout.write("\x1b[2K\r" + prefix + a.dim + placeholder + a.reset);
938
+ process.stdout.write("\r" + prefix);
939
+ } else {
940
+ redrawInput();
941
+ }
942
+ }
943
+ } else if (ch >= " ") {
944
+ if (showingPlaceholder) {
945
+ showingPlaceholder = false;
946
+ }
947
+ clearHint();
948
+ text += ch;
949
+ redrawInput();
950
+ }
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Select menu: arrow keys to navigate, enter to select.
956
+ * items: [{ label, value, desc? }]
957
+ */
958
+ function promptSelect(title, items, callback, opts) {
959
+ var idx = 0;
960
+ // Build hotkeys map: { key: handler }
961
+ var hotkeys = {};
962
+ if (opts && opts.key && opts.onKey) {
963
+ hotkeys[opts.key] = opts.onKey;
964
+ }
965
+ if (opts && opts.keys) {
966
+ for (var ki = 0; ki < opts.keys.length; ki++) {
967
+ hotkeys[opts.keys[ki].key] = opts.keys[ki].onKey;
968
+ }
969
+ }
970
+ var hintLines = null;
971
+ if (opts && opts.hint) {
972
+ hintLines = Array.isArray(opts.hint) ? opts.hint : [opts.hint];
973
+ }
974
+
975
+ function render() {
976
+ var out = "";
977
+ for (var i = 0; i < items.length; i++) {
978
+ var prefix = i === idx
979
+ ? a.green + a.bold + " ● " + a.reset
980
+ : a.dim + " ○ " + a.reset;
981
+ out += " " + sym.bar + prefix + items[i].label + "\n";
982
+ }
983
+ return out;
984
+ }
985
+
986
+ log(sym.pointer + " " + a.bold + title + a.reset);
987
+ process.stdout.write(render());
988
+
989
+ // Render hint lines below the menu tree
990
+ var hintBoxLines = 0;
991
+ if (hintLines) {
992
+ log(sym.end);
993
+ for (var h = 0; h < hintLines.length; h++) {
994
+ log(" " + gradient(hintLines[h]));
995
+ }
996
+ hintBoxLines = 1 + hintLines.length; // sym.end + lines
997
+ }
998
+
999
+ var lineCount = items.length + 1 + hintBoxLines;
1000
+
1001
+ process.stdin.setRawMode(true);
1002
+ process.stdin.resume();
1003
+ process.stdin.setEncoding("utf8");
1004
+
1005
+ process.stdin.on("data", function onSelect(ch) {
1006
+ if (ch === "\x1b[A") { // up
1007
+ if (idx > 0) idx--;
1008
+ } else if (ch === "\x1b[B") { // down
1009
+ if (idx < items.length - 1) idx++;
1010
+ } else if (ch === "\r" || ch === "\n") {
1011
+ process.stdin.setRawMode(false);
1012
+ process.stdin.pause();
1013
+ process.stdin.removeListener("data", onSelect);
1014
+ clearUp(lineCount);
1015
+ log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + items[idx].label);
1016
+ callback(items[idx].value);
1017
+ return;
1018
+ } else if (ch === "\x03") {
1019
+ process.stdout.write("\n");
1020
+ process.exit(0);
1021
+ } else if (hotkeys[ch]) {
1022
+ process.stdin.setRawMode(false);
1023
+ process.stdin.pause();
1024
+ process.stdin.removeListener("data", onSelect);
1025
+ clearUp(lineCount);
1026
+ hotkeys[ch]();
1027
+ return;
1028
+ } else if (ch === "\x7f" || ch === "\b") {
1029
+ // Backspace — trigger "back" if available
1030
+ for (var bi = 0; bi < items.length; bi++) {
1031
+ if (items[bi].value === "back") {
1032
+ process.stdin.setRawMode(false);
1033
+ process.stdin.pause();
1034
+ process.stdin.removeListener("data", onSelect);
1035
+ clearUp(lineCount);
1036
+ log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + items[bi].label);
1037
+ callback("back");
1038
+ return;
1039
+ }
1040
+ }
1041
+ return;
1042
+ } else {
1043
+ return;
1044
+ }
1045
+ // Redraw
1046
+ clearUp(items.length + hintBoxLines);
1047
+ process.stdout.write(render());
1048
+ // Re-render hint lines
1049
+ if (hintLines) {
1050
+ log(sym.end);
1051
+ for (var rh = 0; rh < hintLines.length; rh++) {
1052
+ log(" " + gradient(hintLines[rh]));
1053
+ }
1054
+ }
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * Multi-select menu: space to toggle, enter to confirm.
1060
+ * items: [{ label, value, checked? }]
1061
+ * callback(selectedValues[])
1062
+ */
1063
+ function promptMultiSelect(title, items, callback) {
1064
+ var selected = [];
1065
+ for (var si = 0; si < items.length; si++) {
1066
+ selected.push(items[si].checked !== false);
1067
+ }
1068
+ var idx = 0;
1069
+
1070
+ function render() {
1071
+ var out = "";
1072
+ for (var i = 0; i < items.length; i++) {
1073
+ var cursor = i === idx ? a.clay + ">" + a.reset : " ";
1074
+ var check = selected[i]
1075
+ ? a.green + a.bold + "■" + a.reset
1076
+ : a.dim + "□" + a.reset;
1077
+ out += " " + sym.bar + " " + cursor + " " + check + " " + items[i].label + "\n";
1078
+ }
1079
+ out += " " + sym.bar + " " + a.dim + "space: toggle · enter: confirm" + a.reset + "\n";
1080
+ return out;
1081
+ }
1082
+
1083
+ log(sym.pointer + " " + a.bold + title + a.reset);
1084
+ process.stdout.write(render());
1085
+
1086
+ var lineCount = items.length + 2; // title + items + hint
1087
+
1088
+ process.stdin.setRawMode(true);
1089
+ process.stdin.resume();
1090
+ process.stdin.setEncoding("utf8");
1091
+
1092
+ process.stdin.on("data", function onMulti(ch) {
1093
+ if (ch === "\x1b[A") { // up
1094
+ if (idx > 0) idx--;
1095
+ } else if (ch === "\x1b[B") { // down
1096
+ if (idx < items.length - 1) idx++;
1097
+ } else if (ch === " ") { // toggle
1098
+ selected[idx] = !selected[idx];
1099
+ } else if (ch === "a" || ch === "A") { // toggle all
1100
+ var allSelected = selected.every(function (s) { return s; });
1101
+ for (var ai = 0; ai < selected.length; ai++) selected[ai] = !allSelected;
1102
+ } else if (ch === "\r" || ch === "\n") {
1103
+ process.stdin.setRawMode(false);
1104
+ process.stdin.pause();
1105
+ process.stdin.removeListener("data", onMulti);
1106
+ clearUp(lineCount);
1107
+ var result = [];
1108
+ var labels = [];
1109
+ for (var ri = 0; ri < items.length; ri++) {
1110
+ if (selected[ri]) {
1111
+ result.push(items[ri].value);
1112
+ labels.push(items[ri].label);
1113
+ }
1114
+ }
1115
+ var summary = result.length === items.length
1116
+ ? "All (" + result.length + ")"
1117
+ : result.length + " of " + items.length;
1118
+ log(sym.done + " " + title + " " + a.dim + "·" + a.reset + " " + summary);
1119
+ callback(result);
1120
+ return;
1121
+ } else if (ch === "\x03") {
1122
+ process.stdout.write("\n");
1123
+ process.exit(0);
1124
+ } else if (ch === "\x1b") {
1125
+ // Escape — select none
1126
+ process.stdin.setRawMode(false);
1127
+ process.stdin.pause();
1128
+ process.stdin.removeListener("data", onMulti);
1129
+ clearUp(lineCount);
1130
+ log(sym.done + " " + title + " " + a.dim + "· Skipped" + a.reset);
1131
+ callback([]);
1132
+ return;
1133
+ } else {
1134
+ return;
1135
+ }
1136
+ // Redraw
1137
+ clearUp(items.length + 1); // items + hint (not title)
1138
+ process.stdout.write(render());
1139
+ });
1140
+ }
1141
+
1142
+ // --- Port availability ---
1143
+
1144
+ function isPortFree(p) {
1145
+ return new Promise(function (resolve) {
1146
+ var srv = net.createServer();
1147
+ srv.once("error", function () { resolve(false); });
1148
+ srv.once("listening", function () { srv.close(function () { resolve(true); }); });
1149
+ srv.listen(p);
1150
+ });
1151
+ }
1152
+
1153
+ // --- Detect tools ---
1154
+ function getTailscaleIP() {
1155
+ var interfaces = os.networkInterfaces();
1156
+ for (var name in interfaces) {
1157
+ if (/^(tailscale|utun)/.test(name)) {
1158
+ for (var i = 0; i < interfaces[name].length; i++) {
1159
+ var addr = interfaces[name][i];
1160
+ if (addr.family === "IPv4" && !addr.internal && addr.address.startsWith("100.")) {
1161
+ return addr.address;
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ for (var addrs of Object.values(interfaces)) {
1167
+ for (var j = 0; j < addrs.length; j++) {
1168
+ if (addrs[j].family === "IPv4" && !addrs[j].internal && addrs[j].address.startsWith("100.")) {
1169
+ return addrs[j].address;
1170
+ }
1171
+ }
1172
+ }
1173
+ return null;
1174
+ }
1175
+
1176
+ function hasTailscale() {
1177
+ return getTailscaleIP() !== null;
1178
+ }
1179
+
1180
+ function hasMkcert() {
1181
+ try {
1182
+ execSync("mkcert -CAROOT", { stdio: "pipe", encoding: "utf8" });
1183
+ return true;
1184
+ } catch (e) { return false; }
1185
+ }
1186
+
1187
+ // ==============================
1188
+ // Restore projects from ~/.clayrc
1189
+ // ==============================
1190
+ function promptRestoreProjects(projects, callback) {
1191
+ log(sym.bar);
1192
+ log(sym.pointer + " " + a.bold + "Previous projects found" + a.reset);
1193
+ log(sym.bar + " " + a.dim + "Restore projects from your last session?" + a.reset);
1194
+ log(sym.bar);
1195
+
1196
+ var items = projects.map(function (p) {
1197
+ var name = p.title || path.basename(p.path);
1198
+ return {
1199
+ label: a.bold + name + a.reset + " " + a.dim + p.path + a.reset,
1200
+ value: p,
1201
+ checked: true,
1202
+ };
1203
+ });
1204
+
1205
+ promptMultiSelect("Restore projects", items, function (selected) {
1206
+ // Remove unselected projects from ~/.clayrc
1207
+ if (selected.length < projects.length) {
1208
+ var selectedPaths = {};
1209
+ for (var si = 0; si < selected.length; si++) {
1210
+ selectedPaths[selected[si].path] = true;
1211
+ }
1212
+ try {
1213
+ var rc = loadClayrc();
1214
+ rc.recentProjects = (rc.recentProjects || []).filter(function (p) {
1215
+ return selectedPaths[p.path];
1216
+ });
1217
+ saveClayrc(rc);
1218
+ } catch (e) {}
1219
+ }
1220
+
1221
+ log(sym.bar);
1222
+ if (selected.length > 0) {
1223
+ log(sym.done + " " + a.green + "Restoring " + selected.length + (selected.length === 1 ? " project" : " projects") + a.reset);
1224
+ } else {
1225
+ log(sym.done + " " + a.dim + "Starting fresh" + a.reset);
1226
+ }
1227
+ log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
1228
+ log("");
1229
+ callback(selected);
1230
+ });
1231
+ }
1232
+
1233
+ // ==============================
1234
+ // First-run setup (no daemon)
1235
+ // ==============================
1236
+ function setup(callback) {
1237
+ console.clear();
1238
+ printLogo();
1239
+ log("");
1240
+ log(sym.pointer + " " + a.bold + "Clay" + a.reset + a.dim + " · Unofficial, open-source project" + a.reset);
1241
+ log(sym.bar);
1242
+ log(sym.bar + " " + a.dim + "Anyone with the URL gets full Claude Code access to this machine." + a.reset);
1243
+ log(sym.bar + " " + a.dim + "Use a private network (Tailscale, VPN)." + a.reset);
1244
+ log(sym.bar + " " + a.dim + "The authors assume no responsibility for any damage or data loss." + a.reset);
1245
+ log(sym.bar);
1246
+
1247
+ promptToggle("Accept and continue", null, true, function (accepted) {
1248
+ if (!accepted) {
1249
+ log(sym.end + " " + a.dim + "Aborted." + a.reset);
1250
+ log("");
1251
+ process.exit(0);
1252
+ return;
1253
+ }
1254
+ log(sym.bar);
1255
+
1256
+ function askPort() {
1257
+ promptText("Port", String(port), function (val) {
1258
+ if (val === null) {
1259
+ log(sym.end + " " + a.dim + "Aborted." + a.reset);
1260
+ log("");
1261
+ process.exit(0);
1262
+ return;
1263
+ }
1264
+ var p = parseInt(val, 10);
1265
+ if (!p || p < 1 || p > 65535) {
1266
+ log(sym.warn + " " + a.red + "Invalid port number" + a.reset);
1267
+ askPort();
1268
+ return;
1269
+ }
1270
+ isPortFree(p).then(function (free) {
1271
+ if (!free) {
1272
+ log(sym.warn + " " + a.yellow + "Port " + p + " is already in use" + a.reset);
1273
+ askPort();
1274
+ return;
1275
+ }
1276
+ port = p;
1277
+ log(sym.bar);
1278
+
1279
+ function askPin() {
1280
+ promptPin(function (pin) {
1281
+ if (dangerouslySkipPermissions && !pin) {
1282
+ log(sym.bar);
1283
+ log(sym.warn + " " + a.yellow + "WARNING: No PIN + skip permissions = anyone with the URL" + a.reset);
1284
+ log(sym.bar + " " + a.yellow + "can execute any command without approval." + a.reset);
1285
+ log(sym.bar);
1286
+ promptToggle("Continue without PIN?", null, false, function (confirmed) {
1287
+ if (!confirmed) {
1288
+ clearUp(6);
1289
+ log(sym.done + " PIN protection " + a.dim + "·" + a.reset + " " + a.yellow + "Required for skip permissions" + a.reset);
1290
+ log(sym.bar);
1291
+ askPin();
1292
+ return;
1293
+ }
1294
+ afterPin(pin);
1295
+ });
1296
+ } else {
1297
+ afterPin(pin);
1298
+ }
1299
+ });
1300
+ }
1301
+
1302
+ function afterPin(pin) {
1303
+ if (process.platform === "darwin") {
1304
+ promptToggle("Keep awake", "Prevent system sleep while relay is running", false, function (keepAwake) {
1305
+ callback(pin, keepAwake);
1306
+ });
1307
+ } else {
1308
+ callback(pin, false);
1309
+ }
1310
+ }
1311
+
1312
+ askPin();
1313
+ });
1314
+ });
1315
+ }
1316
+ askPort();
1317
+ });
1318
+ }
1319
+
1320
+ // ==============================
1321
+ // Fork the daemon process
1322
+ // ==============================
1323
+ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1324
+ var ip = getLocalIP();
1325
+ var hasTls = false;
1326
+
1327
+ if (useHttps) {
1328
+ var certPaths = ensureCerts(ip);
1329
+ if (certPaths) {
1330
+ hasTls = true;
1331
+ } else {
1332
+ log(sym.warn + " " + a.yellow + "HTTPS unavailable" + a.reset + a.dim + " · mkcert not installed" + a.reset);
1333
+ }
1334
+ }
1335
+
1336
+ // Check port availability
1337
+ var portFree = await isPortFree(port);
1338
+ if (!portFree) {
1339
+ log(a.red + "Port " + port + " is already in use." + a.reset);
1340
+ log(a.dim + "Is another Clay daemon running?" + a.reset);
1341
+ process.exit(1);
1342
+ return;
1343
+ }
1344
+
1345
+ var allProjects = [];
1346
+ var usedSlugs = [];
1347
+
1348
+ // Load previous config to preserve per-project settings (visibility, allowedUsers)
1349
+ var prevConfig = loadConfig();
1350
+ var prevProjectMap = {};
1351
+ if (prevConfig && prevConfig.projects) {
1352
+ for (var pi = 0; pi < prevConfig.projects.length; pi++) {
1353
+ prevProjectMap[prevConfig.projects[pi].path] = prevConfig.projects[pi];
1354
+ }
1355
+ }
1356
+
1357
+ // Only include cwd if explicitly requested
1358
+ if (addCwd) {
1359
+ var slug = generateSlug(cwd, []);
1360
+ var cwdEntry = { path: cwd, slug: slug, addedAt: Date.now() };
1361
+ // Restore title/icon from .clayrc if available
1362
+ var cwdRc = loadClayrc();
1363
+ var cwdRecent = cwdRc.recentProjects || [];
1364
+ for (var cr = 0; cr < cwdRecent.length; cr++) {
1365
+ if (cwdRecent[cr].path === cwd) {
1366
+ if (cwdRecent[cr].title) cwdEntry.title = cwdRecent[cr].title;
1367
+ if (cwdRecent[cr].icon) cwdEntry.icon = cwdRecent[cr].icon;
1368
+ break;
1369
+ }
1370
+ }
1371
+ // Restore access settings from previous config
1372
+ if (prevProjectMap[cwd]) {
1373
+ if (prevProjectMap[cwd].visibility) cwdEntry.visibility = prevProjectMap[cwd].visibility;
1374
+ if (prevProjectMap[cwd].allowedUsers) cwdEntry.allowedUsers = prevProjectMap[cwd].allowedUsers;
1375
+ }
1376
+ allProjects.push(cwdEntry);
1377
+ usedSlugs.push(slug);
1378
+ }
1379
+
1380
+ // Add restored projects (from ~/.clayrc)
1381
+ if (extraProjects && extraProjects.length > 0) {
1382
+ for (var ep = 0; ep < extraProjects.length; ep++) {
1383
+ var rp = extraProjects[ep];
1384
+ if (rp.path === cwd) continue; // skip if same as cwd
1385
+ if (!fs.existsSync(rp.path)) continue; // skip missing directories
1386
+ var rpSlug = generateSlug(rp.path, usedSlugs);
1387
+ usedSlugs.push(rpSlug);
1388
+ var rpEntry = { path: rp.path, slug: rpSlug, title: rp.title || undefined, icon: rp.icon || undefined, addedAt: rp.addedAt || Date.now() };
1389
+ // Restore access settings from previous config
1390
+ if (prevProjectMap[rp.path]) {
1391
+ if (prevProjectMap[rp.path].visibility) rpEntry.visibility = prevProjectMap[rp.path].visibility;
1392
+ if (prevProjectMap[rp.path].allowedUsers) rpEntry.allowedUsers = prevProjectMap[rp.path].allowedUsers;
1393
+ }
1394
+ allProjects.push(rpEntry);
1395
+ }
1396
+ }
1397
+
1398
+ var config = {
1399
+ pid: null,
1400
+ port: port,
1401
+ host: host,
1402
+ pinHash: pin ? generateAuthToken(pin) : null,
1403
+ tls: hasTls,
1404
+ debug: debugMode,
1405
+ keepAwake: keepAwake,
1406
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1407
+ projects: allProjects,
1408
+ };
1409
+
1410
+ ensureConfigDir();
1411
+ saveConfig(config);
1412
+
1413
+ // Fork daemon
1414
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1415
+ var logFile = logPath();
1416
+ var logFd = fs.openSync(logFile, "a");
1417
+
1418
+ var child = spawn(process.execPath, [daemonScript], {
1419
+ detached: true,
1420
+ windowsHide: true,
1421
+ stdio: ["ignore", logFd, logFd],
1422
+ env: Object.assign({}, process.env, {
1423
+ CLAY_CONFIG: configPath(),
1424
+ }),
1425
+ });
1426
+ child.unref();
1427
+ fs.closeSync(logFd);
1428
+
1429
+ // Update config with PID
1430
+ config.pid = child.pid;
1431
+ saveConfig(config);
1432
+
1433
+ // Wait for daemon to start (retry up to 5 seconds)
1434
+ var alive = false;
1435
+ for (var attempt = 0; attempt < 10; attempt++) {
1436
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1437
+ alive = await isDaemonAliveAsync(config);
1438
+ if (alive) break;
1439
+ }
1440
+ if (!alive) {
1441
+ log(a.red + "Failed to start daemon. Check logs:" + a.reset);
1442
+ log(a.dim + logFile + a.reset);
1443
+ clearStaleConfig();
1444
+ process.exit(1);
1445
+ return;
1446
+ }
1447
+
1448
+ // Headless mode — print status and exit immediately
1449
+ if (headlessMode) {
1450
+ var protocol = config.tls ? "https" : "http";
1451
+ var url = protocol + "://" + ip + ":" + config.port;
1452
+ console.log(" " + sym.done + " Daemon started (PID " + config.pid + ")");
1453
+ console.log(" " + sym.done + " " + url);
1454
+ console.log(" " + sym.done + " Headless mode — exiting CLI");
1455
+ process.exit(0);
1456
+ return;
1457
+ }
1458
+
1459
+ // Show success + QR
1460
+ showServerStarted(config, ip);
1461
+ }
1462
+
1463
+ // ==============================
1464
+ // Dev mode — foreground daemon with file watching
1465
+ // ==============================
1466
+ async function devMode(pin, keepAwake, existingPinHash) {
1467
+ var ip = getLocalIP();
1468
+ var hasTls = false;
1469
+
1470
+ if (useHttps) {
1471
+ var certPaths = ensureCerts(ip);
1472
+ if (certPaths) hasTls = true;
1473
+ }
1474
+
1475
+ var portFree = await isPortFree(port);
1476
+ if (!portFree) {
1477
+ console.log("\x1b[31m[dev] Port " + port + " is already in use.\x1b[0m");
1478
+ process.exit(1);
1479
+ return;
1480
+ }
1481
+
1482
+ var slug = generateSlug(cwd, []);
1483
+ var cwdDevEntry = { path: cwd, slug: slug, addedAt: Date.now() };
1484
+
1485
+ // Load previous config to preserve per-project settings (visibility, allowedUsers)
1486
+ var prevDevConfig = loadConfig();
1487
+ var prevDevProjectMap = {};
1488
+ if (prevDevConfig && prevDevConfig.projects) {
1489
+ for (var pdi = 0; pdi < prevDevConfig.projects.length; pdi++) {
1490
+ prevDevProjectMap[prevDevConfig.projects[pdi].path] = prevDevConfig.projects[pdi];
1491
+ }
1492
+ }
1493
+
1494
+ // Restore previous projects
1495
+ var rc = loadClayrc();
1496
+ var restorable = (rc.recentProjects || []).filter(function (p) {
1497
+ return p.path !== cwd && fs.existsSync(p.path);
1498
+ });
1499
+ // Restore title/icon for cwd from .clayrc
1500
+ var rcAll = rc.recentProjects || [];
1501
+ for (var ci = 0; ci < rcAll.length; ci++) {
1502
+ if (rcAll[ci].path === cwd) {
1503
+ if (rcAll[ci].title) cwdDevEntry.title = rcAll[ci].title;
1504
+ if (rcAll[ci].icon) cwdDevEntry.icon = rcAll[ci].icon;
1505
+ break;
1506
+ }
1507
+ }
1508
+ // Restore access settings for cwd from previous config
1509
+ if (prevDevProjectMap[cwd]) {
1510
+ if (prevDevProjectMap[cwd].visibility) cwdDevEntry.visibility = prevDevProjectMap[cwd].visibility;
1511
+ if (prevDevProjectMap[cwd].allowedUsers) cwdDevEntry.allowedUsers = prevDevProjectMap[cwd].allowedUsers;
1512
+ }
1513
+ var allProjects = [cwdDevEntry];
1514
+ var usedSlugs = [slug];
1515
+ for (var ri = 0; ri < restorable.length; ri++) {
1516
+ var rp = restorable[ri];
1517
+ var rpSlug = generateSlug(rp.path, usedSlugs);
1518
+ usedSlugs.push(rpSlug);
1519
+ var rpDevEntry = { path: rp.path, slug: rpSlug, title: rp.title || undefined, icon: rp.icon || undefined, addedAt: rp.addedAt || Date.now() };
1520
+ // Restore access settings from previous config
1521
+ if (prevDevProjectMap[rp.path]) {
1522
+ if (prevDevProjectMap[rp.path].visibility) rpDevEntry.visibility = prevDevProjectMap[rp.path].visibility;
1523
+ if (prevDevProjectMap[rp.path].allowedUsers) rpDevEntry.allowedUsers = prevDevProjectMap[rp.path].allowedUsers;
1524
+ }
1525
+ allProjects.push(rpDevEntry);
1526
+ }
1527
+
1528
+ var config = {
1529
+ pid: null,
1530
+ port: port,
1531
+ host: host,
1532
+ pinHash: existingPinHash || (pin ? generateAuthToken(pin) : null),
1533
+ tls: hasTls,
1534
+ debug: true,
1535
+ keepAwake: keepAwake || false,
1536
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1537
+ projects: allProjects,
1538
+ };
1539
+
1540
+ ensureConfigDir();
1541
+ saveConfig(config);
1542
+
1543
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1544
+ var libDir = path.join(__dirname, "..", "lib");
1545
+ var child = null;
1546
+ var intentionalKill = false;
1547
+ var debounceTimer = null;
1548
+
1549
+ function spawnDaemon() {
1550
+ child = spawn(process.execPath, [daemonScript], {
1551
+ stdio: ["ignore", "inherit", "inherit"],
1552
+ env: Object.assign({}, process.env, {
1553
+ CLAY_CONFIG: configPath(),
1554
+ }),
1555
+ });
1556
+
1557
+ child.on("exit", function (code) {
1558
+ child = null;
1559
+ if (intentionalKill) {
1560
+ intentionalKill = false;
1561
+ return;
1562
+ }
1563
+ // Exit code 120 = update restart — respawn daemon with current dev code
1564
+ if (code === 120) {
1565
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Update restart — respawning daemon...");
1566
+ console.log("");
1567
+ setTimeout(spawnDaemon, 500);
1568
+ return;
1569
+ }
1570
+ // Exit code 78 = fatal config error (e.g. Node version too old) — don't restart
1571
+ if (code === 78) {
1572
+ console.log("\x1b[31m[dev] Daemon exited with fatal error (code 78). Not restarting.\x1b[0m");
1573
+ process.exit(78);
1574
+ return;
1575
+ }
1576
+ // Unexpected exit — auto restart
1577
+ console.log("\x1b[33m[dev] Daemon exited (code " + code + "), restarting...\x1b[0m");
1578
+ setTimeout(spawnDaemon, 500);
1579
+ });
1580
+ }
1581
+
1582
+ function restartDaemon() {
1583
+ intentionalKill = true;
1584
+ if (child) {
1585
+ child.kill("SIGTERM");
1586
+ // Give it a moment to shut down, then spawn
1587
+ setTimeout(spawnDaemon, 300);
1588
+ } else {
1589
+ intentionalKill = false;
1590
+ spawnDaemon();
1591
+ }
1592
+ }
1593
+
1594
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Starting relay on port " + port + "...");
1595
+ if (watchMode) {
1596
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
1597
+ }
1598
+ console.log("");
1599
+
1600
+ spawnDaemon();
1601
+
1602
+ // Wait for daemon to be ready, then show CLI menu
1603
+ config.pid = child ? child.pid : null;
1604
+ saveConfig(config);
1605
+
1606
+ var daemonReady = false;
1607
+ for (var da = 0; da < 10; da++) {
1608
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1609
+ daemonReady = await isDaemonAliveAsync(config);
1610
+ if (daemonReady) break;
1611
+ }
1612
+ if (daemonReady) {
1613
+ showServerStarted(config, ip);
1614
+ }
1615
+
1616
+ // Watch lib/ for server-side file changes (only with --watch)
1617
+ var watcher = null;
1618
+ if (watchMode) {
1619
+ watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
1620
+ if (!filename) return;
1621
+ // Skip client-side files — they're served from disk
1622
+ if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
1623
+ // Skip non-JS files
1624
+ if (!filename.endsWith(".js")) return;
1625
+
1626
+ if (debounceTimer) clearTimeout(debounceTimer);
1627
+ debounceTimer = setTimeout(function () {
1628
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m File changed: lib/" + filename);
1629
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Restarting...");
1630
+ console.log("");
1631
+ restartDaemon();
1632
+ }, 300);
1633
+ });
1634
+ }
1635
+
1636
+ // Clean exit on Ctrl+C
1637
+ var shuttingDown = false;
1638
+ process.on("SIGINT", function () {
1639
+ if (shuttingDown) return;
1640
+ shuttingDown = true;
1641
+ console.log("\n\x1b[38;2;0;183;133m[dev]\x1b[0m Shutting down...");
1642
+ if (watcher) watcher.close();
1643
+ if (debounceTimer) clearTimeout(debounceTimer);
1644
+ intentionalKill = true;
1645
+ if (child) {
1646
+ child.kill("SIGTERM");
1647
+ child.on("exit", function () {
1648
+ clearStaleConfig();
1649
+ process.exit(0);
1650
+ });
1651
+ // Force kill after 3s
1652
+ setTimeout(function () { process.exit(0); }, 3000);
1653
+ } else {
1654
+ clearStaleConfig();
1655
+ process.exit(0);
1656
+ }
1657
+ });
1658
+ }
1659
+
1660
+ // ==============================
1661
+ // Restart daemon with TLS enabled
1662
+ // ==============================
1663
+ async function restartDaemonWithTLS(config, callback) {
1664
+ var ip = getLocalIP();
1665
+ var certPaths = ensureCerts(ip);
1666
+ if (!certPaths) {
1667
+ callback(config);
1668
+ return;
1669
+ }
1670
+
1671
+ // Shut down old daemon
1672
+ stopDaemonWatcher();
1673
+ try {
1674
+ await sendIPCCommand(socketPath(), { cmd: "shutdown" });
1675
+ } catch (e) {}
1676
+
1677
+ // Wait for port to be released
1678
+ var waited = 0;
1679
+ while (waited < 5000) {
1680
+ await new Promise(function (resolve) { setTimeout(resolve, 300); });
1681
+ waited += 300;
1682
+ var free = await isPortFree(config.port);
1683
+ if (free) break;
1684
+ }
1685
+ clearStaleConfig();
1686
+
1687
+ // Re-fork with TLS
1688
+ var newConfig = {
1689
+ pid: null,
1690
+ port: config.port,
1691
+ pinHash: config.pinHash || null,
1692
+ tls: true,
1693
+ debug: config.debug || false,
1694
+ keepAwake: config.keepAwake || false,
1695
+ dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
1696
+ projects: config.projects || [],
1697
+ };
1698
+
1699
+ ensureConfigDir();
1700
+ saveConfig(newConfig);
1701
+
1702
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1703
+ var logFile = logPath();
1704
+ var logFd = fs.openSync(logFile, "a");
1705
+
1706
+ var child = spawn(process.execPath, [daemonScript], {
1707
+ detached: true,
1708
+ windowsHide: true,
1709
+ stdio: ["ignore", logFd, logFd],
1710
+ env: Object.assign({}, process.env, {
1711
+ CLAY_CONFIG: configPath(),
1712
+ }),
1713
+ });
1714
+ child.unref();
1715
+ fs.closeSync(logFd);
1716
+
1717
+ newConfig.pid = child.pid;
1718
+ saveConfig(newConfig);
1719
+
1720
+ var alive = false;
1721
+ for (var ra = 0; ra < 10; ra++) {
1722
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1723
+ alive = await isDaemonAliveAsync(newConfig);
1724
+ if (alive) break;
1725
+ }
1726
+ if (!alive) {
1727
+ log(sym.warn + " " + a.yellow + "Failed to restart with HTTPS, falling back to HTTP..." + a.reset);
1728
+ // Re-fork without TLS so the server is at least running
1729
+ newConfig.tls = false;
1730
+ saveConfig(newConfig);
1731
+ var logFd2 = fs.openSync(logFile, "a");
1732
+ var child2 = spawn(process.execPath, [daemonScript], {
1733
+ detached: true,
1734
+ windowsHide: true,
1735
+ stdio: ["ignore", logFd2, logFd2],
1736
+ env: Object.assign({}, process.env, {
1737
+ CLAY_CONFIG: configPath(),
1738
+ }),
1739
+ });
1740
+ child2.unref();
1741
+ fs.closeSync(logFd2);
1742
+ newConfig.pid = child2.pid;
1743
+ saveConfig(newConfig);
1744
+ for (var rb = 0; rb < 10; rb++) {
1745
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1746
+ var retryAlive = await isDaemonAliveAsync(newConfig);
1747
+ if (retryAlive) break;
1748
+ }
1749
+ startDaemonWatcher();
1750
+ callback(newConfig);
1751
+ return;
1752
+ }
1753
+
1754
+ startDaemonWatcher();
1755
+ callback(newConfig);
1756
+ }
1757
+
1758
+ // ==============================
1759
+ // Show server started info
1760
+ // ==============================
1761
+ function showServerStarted(config, ip) {
1762
+ showMainMenu(config, ip);
1763
+ }
1764
+
1765
+ // ==============================
1766
+ // Main management menu
1767
+ // ==============================
1768
+ function showMainMenu(config, ip) {
1769
+ startDaemonWatcher();
1770
+ var protocol = config.tls ? "https" : "http";
1771
+ var url = protocol + "://" + ip + ":" + config.port;
1772
+
1773
+ sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
1774
+ var projs = (status && status.projects) || [];
1775
+ var totalSessions = 0;
1776
+ var totalAwaiting = 0;
1777
+ for (var i = 0; i < projs.length; i++) {
1778
+ totalSessions += projs[i].sessions || 0;
1779
+ if (projs[i].isProcessing) totalAwaiting++;
1780
+ }
1781
+
1782
+ console.clear();
1783
+ printLogo();
1784
+ log("");
1785
+
1786
+ function afterQr() {
1787
+ // Status line
1788
+ log(" " + a.dim + "clay" + a.reset + " " + a.dim + "v" + currentVersion + a.reset + a.dim + " — " + url + a.reset);
1789
+ var parts = [];
1790
+ parts.push(a.bold + projs.length + a.reset + a.dim + (projs.length === 1 ? " project" : " projects"));
1791
+ parts.push(a.reset + a.bold + totalSessions + a.reset + a.dim + (totalSessions === 1 ? " session" : " sessions"));
1792
+ if (totalAwaiting > 0) {
1793
+ parts.push(a.reset + a.yellow + a.bold + totalAwaiting + a.reset + a.yellow + " awaiting" + a.reset + a.dim);
1794
+ }
1795
+ log(" " + a.dim + parts.join(a.reset + a.dim + " · ") + a.reset);
1796
+ log(" Press " + a.bold + "o" + a.reset + " to open in browser");
1797
+ log("");
1798
+
1799
+ showMenuItems();
1800
+ }
1801
+
1802
+ if (ip !== "localhost") {
1803
+ qrcode.generate(url, { small: !isBasicTerm }, function (code) {
1804
+ var lines = code.split("\n").map(function (l) { return " " + l; }).join("\n");
1805
+ console.log(lines);
1806
+ afterQr();
1807
+ });
1808
+ } else {
1809
+ log(a.bold + " " + url + a.reset);
1810
+ log("");
1811
+ afterQr();
1812
+ }
1813
+
1814
+ function showMenuItems() {
1815
+ var items = [
1816
+ { label: "Setup notifications", value: "notifications" },
1817
+ { label: "Projects", value: "projects" },
1818
+ { label: "Settings", value: "settings" },
1819
+ { label: "Shut down server", value: "shutdown" },
1820
+ { label: "Keep server alive & exit", value: "exit" },
1821
+ ];
1822
+
1823
+ promptSelect("What would you like to do?", items, function (choice) {
1824
+ switch (choice) {
1825
+ case "notifications":
1826
+ showSetupGuide(config, ip, function () {
1827
+ config = loadConfig() || config;
1828
+ showMainMenu(config, ip);
1829
+ });
1830
+ break;
1831
+
1832
+ case "projects":
1833
+ showProjectsMenu(config, ip);
1834
+ break;
1835
+
1836
+ case "settings":
1837
+ showSettingsMenu(config, ip);
1838
+ break;
1839
+
1840
+ case "shutdown":
1841
+ log(sym.bar);
1842
+ log(sym.bar + " " + a.yellow + "This will stop the server completely." + a.reset);
1843
+ log(sym.bar + " " + a.dim + "All connected sessions will be disconnected." + a.reset);
1844
+ log(sym.bar);
1845
+ promptSelect("Are you sure?", [
1846
+ { label: "Cancel", value: "cancel" },
1847
+ { label: "Shut down", value: "confirm" },
1848
+ ], function (confirm) {
1849
+ if (confirm === "confirm") {
1850
+ stopDaemonWatcher();
1851
+ sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
1852
+ log(sym.done + " " + a.green + "Server stopped." + a.reset);
1853
+ log("");
1854
+ clearStaleConfig();
1855
+ process.exit(0);
1856
+ });
1857
+ } else {
1858
+ showMainMenu(config, ip);
1859
+ }
1860
+ });
1861
+ break;
1862
+
1863
+ case "exit":
1864
+ log("");
1865
+ log(" " + a.bold + "Bye!" + a.reset + " " + a.dim + "Server is still running in background." + a.reset);
1866
+ log(" " + a.dim + "Run " + a.reset + "npx clay-server" + a.dim + " to come back here." + a.reset);
1867
+ log("");
1868
+ process.exit(0);
1869
+ break;
1870
+ }
1871
+ }, {
1872
+ hint: [
1873
+ "claude-relay has been renamed to clay-server · npx clay-server",
1874
+ "Run npx clay-server in other directories to add more projects.",
1875
+ "★ github.com/chadbyte/claude-relay — Press s to star the repo",
1876
+ ],
1877
+ keys: [
1878
+ { key: "o", onKey: function () {
1879
+ openUrl(url);
1880
+ showMainMenu(config, ip);
1881
+ }},
1882
+ { key: "s", onKey: function () {
1883
+ openUrl("https://github.com/chadbyte/claude-relay");
1884
+ showMainMenu(config, ip);
1885
+ }},
1886
+ ],
1887
+ });
1888
+ }
1889
+ });
1890
+ }
1891
+
1892
+ // ==============================
1893
+ // Projects sub-menu
1894
+ // ==============================
1895
+ function showProjectsMenu(config, ip) {
1896
+ sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
1897
+ if (!status.ok) {
1898
+ log(a.red + "Failed to get status" + a.reset);
1899
+ showMainMenu(config, ip);
1900
+ return;
1901
+ }
1902
+
1903
+ console.clear();
1904
+ printLogo();
1905
+ log("");
1906
+ log(sym.pointer + " " + a.bold + "Projects" + a.reset);
1907
+ log(sym.bar);
1908
+
1909
+ var projs = status.projects || [];
1910
+ for (var i = 0; i < projs.length; i++) {
1911
+ var p = projs[i];
1912
+ var statusIcon = p.isProcessing ? "⚡" : (p.clients > 0 ? "🟢" : "⏸");
1913
+ var sessionLabel = p.sessions === 1 ? "1 session" : p.sessions + " sessions";
1914
+ var projName = p.title || p.project;
1915
+ log(sym.bar + " " + a.bold + projName + a.reset + " " + sessionLabel + " " + statusIcon);
1916
+ log(sym.bar + " " + a.dim + p.path + a.reset);
1917
+ if (i < projs.length - 1) log(sym.bar);
1918
+ }
1919
+ log(sym.bar);
1920
+
1921
+ // Build menu items
1922
+ var items = [];
1923
+
1924
+ // Check if cwd is already registered
1925
+ var cwdRegistered = false;
1926
+ for (var j = 0; j < projs.length; j++) {
1927
+ if (projs[j].path === cwd) {
1928
+ cwdRegistered = true;
1929
+ break;
1930
+ }
1931
+ }
1932
+ if (!cwdRegistered) {
1933
+ items.push({ label: "+ Add " + a.bold + path.basename(cwd) + a.reset + " " + a.dim + "(" + cwd + ")" + a.reset, value: "add_cwd" });
1934
+ }
1935
+ items.push({ label: "+ Add project...", value: "add_other" });
1936
+
1937
+ for (var k = 0; k < projs.length; k++) {
1938
+ var itemLabel = projs[k].title || projs[k].project;
1939
+ items.push({ label: itemLabel, value: "detail:" + projs[k].slug });
1940
+ }
1941
+ items.push({ label: "Back", value: "back" });
1942
+
1943
+ promptSelect("Select", items, function (choice) {
1944
+ if (choice === "back") {
1945
+ console.clear();
1946
+ printLogo();
1947
+ log("");
1948
+ showMainMenu(config, ip);
1949
+ } else if (choice === "add_cwd") {
1950
+ sendIPCCommand(socketPath(), { cmd: "add_project", path: cwd }).then(function (res) {
1951
+ if (res.ok) {
1952
+ log(sym.done + " " + a.green + "Added: " + res.slug + a.reset);
1953
+ config = loadConfig() || config;
1954
+ } else {
1955
+ log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1956
+ }
1957
+ log("");
1958
+ showProjectsMenu(config, ip);
1959
+ });
1960
+ } else if (choice === "add_other") {
1961
+ log(sym.bar);
1962
+ promptText("Directory path", cwd, function (dirPath) {
1963
+ if (dirPath === null) {
1964
+ showProjectsMenu(config, ip);
1965
+ return;
1966
+ }
1967
+ var absPath = path.resolve(dirPath);
1968
+ try {
1969
+ var stat = fs.statSync(absPath);
1970
+ if (!stat.isDirectory()) {
1971
+ log(sym.warn + " " + a.red + "Not a directory: " + absPath + a.reset);
1972
+ setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1973
+ return;
1974
+ }
1975
+ } catch (e) {
1976
+ log(sym.warn + " " + a.red + "Directory not found: " + absPath + a.reset);
1977
+ setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1978
+ return;
1979
+ }
1980
+ var alreadyExists = false;
1981
+ for (var pi = 0; pi < projs.length; pi++) {
1982
+ if (projs[pi].path === absPath) {
1983
+ alreadyExists = true;
1984
+ break;
1985
+ }
1986
+ }
1987
+ if (alreadyExists) {
1988
+ log(sym.done + " " + a.yellow + "Already added: " + path.basename(absPath) + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
1989
+ setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
1990
+ return;
1991
+ }
1992
+ sendIPCCommand(socketPath(), { cmd: "add_project", path: absPath }).then(function (res) {
1993
+ if (res.ok) {
1994
+ log(sym.done + " " + a.green + "Added: " + res.slug + a.reset + " " + a.dim + "(" + absPath + ")" + a.reset);
1995
+ config = loadConfig() || config;
1996
+ } else {
1997
+ log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
1998
+ }
1999
+ setTimeout(function () { showProjectsMenu(config, ip); }, 2000);
2000
+ });
2001
+ });
2002
+ } else if (choice.startsWith("detail:")) {
2003
+ var detailSlug = choice.substring(7);
2004
+ showProjectDetail(config, ip, detailSlug, projs);
2005
+ }
2006
+ });
2007
+ });
2008
+ }
2009
+
2010
+ // ==============================
2011
+ // Project detail
2012
+ // ==============================
2013
+ function showProjectDetail(config, ip, slug, projects) {
2014
+ var proj = null;
2015
+ for (var i = 0; i < projects.length; i++) {
2016
+ if (projects[i].slug === slug) {
2017
+ proj = projects[i];
2018
+ break;
2019
+ }
2020
+ }
2021
+ if (!proj) {
2022
+ showProjectsMenu(config, ip);
2023
+ return;
2024
+ }
2025
+
2026
+ var displayName = proj.title || proj.project;
2027
+
2028
+ console.clear();
2029
+ printLogo();
2030
+ log("");
2031
+ log(sym.pointer + " " + a.bold + displayName + a.reset + " " + a.dim + proj.slug + " · " + proj.path + a.reset);
2032
+ log(sym.bar);
2033
+ var sessionLabel = proj.sessions === 1 ? "1 session" : proj.sessions + " sessions";
2034
+ var clientLabel = proj.clients === 1 ? "1 client" : proj.clients + " clients";
2035
+ log(sym.bar + " " + sessionLabel + " · " + clientLabel);
2036
+ if (proj.title) {
2037
+ log(sym.bar + " " + a.dim + "Title: " + a.reset + proj.title);
2038
+ }
2039
+ log(sym.bar);
2040
+
2041
+ var items = [
2042
+ { label: proj.title ? "Change title" : "Set title", value: "title" },
2043
+ { label: "Remove project", value: "remove" },
2044
+ { label: "Back", value: "back" },
2045
+ ];
2046
+
2047
+ promptSelect("What would you like to do?", items, function (choice) {
2048
+ if (choice === "title") {
2049
+ log(sym.bar);
2050
+ promptText("Project title", proj.title || proj.project, function (newTitle) {
2051
+ if (newTitle === null) {
2052
+ showProjectDetail(config, ip, slug, projects);
2053
+ return;
2054
+ }
2055
+ var titleVal = newTitle.trim();
2056
+ // If same as directory name, clear custom title
2057
+ if (titleVal === proj.project || titleVal === "") {
2058
+ titleVal = null;
2059
+ }
2060
+ sendIPCCommand(socketPath(), { cmd: "set_project_title", slug: slug, title: titleVal }).then(function (res) {
2061
+ if (res.ok) {
2062
+ proj.title = titleVal;
2063
+ config = loadConfig() || config;
2064
+ log(sym.done + " " + a.green + "Title updated" + a.reset);
2065
+ } else {
2066
+ log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2067
+ }
2068
+ log("");
2069
+ showProjectDetail(config, ip, slug, projects);
2070
+ });
2071
+ });
2072
+ } else if (choice === "remove") {
2073
+ sendIPCCommand(socketPath(), { cmd: "remove_project", slug: slug }).then(function (res) {
2074
+ if (res.ok) {
2075
+ log(sym.done + " " + a.green + "Removed: " + slug + a.reset);
2076
+ config = loadConfig() || config;
2077
+ } else {
2078
+ log(sym.warn + " " + a.yellow + (res.error || "Failed") + a.reset);
2079
+ }
2080
+ log("");
2081
+ showProjectsMenu(config, ip);
2082
+ });
2083
+ } else {
2084
+ showProjectsMenu(config, ip);
2085
+ }
2086
+ });
2087
+ }
2088
+
2089
+ // ==============================
2090
+ // Setup guide (2x2 toggle flow)
2091
+ // ==============================
2092
+ function showSetupGuide(config, ip, goBack) {
2093
+ var protocol = config.tls ? "https" : "http";
2094
+ var wantRemote = false;
2095
+ var wantPush = false;
2096
+
2097
+ console.clear();
2098
+ printLogo();
2099
+ log("");
2100
+ log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
2101
+ log(sym.bar);
2102
+
2103
+ function redraw(renderFn) {
2104
+ console.clear();
2105
+ printLogo();
2106
+ log("");
2107
+ log(sym.pointer + " " + a.bold + "Setup Notifications" + a.reset);
2108
+ log(sym.bar);
2109
+ if (wantRemote) log(sym.done + " Access from outside your network? " + a.dim + "·" + a.reset + " " + a.green + "Yes" + a.reset);
2110
+ else log(sym.done + " Access from outside your network? " + a.dim + "· No" + a.reset);
2111
+ log(sym.bar);
2112
+ if (wantPush) log(sym.done + " Want push notifications? " + a.dim + "·" + a.reset + " " + a.green + "Yes" + a.reset);
2113
+ else log(sym.done + " Want push notifications? " + a.dim + "· No" + a.reset);
2114
+ log(sym.bar);
2115
+ renderFn();
2116
+ }
2117
+
2118
+ promptToggle("Access from outside your network?", "Requires Tailscale on both devices", false, function (remote) {
2119
+ wantRemote = remote;
2120
+ log(sym.bar);
2121
+ promptToggle("Want push notifications?", "Requires HTTPS (mkcert certificate)", false, function (push) {
2122
+ wantPush = push;
2123
+ log(sym.bar);
2124
+ afterToggles();
2125
+ });
2126
+ });
2127
+
2128
+ function afterToggles() {
2129
+ if (!wantRemote && !wantPush) {
2130
+ log(sym.done + " " + a.green + "All set!" + a.reset + a.dim + " · No additional setup needed." + a.reset);
2131
+ log(sym.end);
2132
+ log("");
2133
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2134
+ goBack();
2135
+ });
2136
+ return;
2137
+ }
2138
+ if (wantRemote) {
2139
+ renderTailscale();
2140
+ } else {
2141
+ renderHttps();
2142
+ }
2143
+ }
2144
+
2145
+ function renderTailscale() {
2146
+ var tsIP = getTailscaleIP();
2147
+
2148
+ log(sym.pointer + " " + a.bold + "Tailscale Setup" + a.reset);
2149
+ if (tsIP) {
2150
+ log(sym.bar + " " + a.green + "Tailscale is running" + a.reset + a.dim + " · " + tsIP + a.reset);
2151
+ log(sym.bar);
2152
+ log(sym.bar + " On your phone/tablet:");
2153
+ log(sym.bar + " " + a.dim + "1. Install Tailscale (App Store / Google Play)" + a.reset);
2154
+ log(sym.bar + " " + a.dim + "2. Sign in with the same account" + a.reset);
2155
+ log(sym.bar);
2156
+ renderHttps();
2157
+ } else {
2158
+ log(sym.bar + " " + a.yellow + "Tailscale not found on this machine." + a.reset);
2159
+ log(sym.bar + " " + a.dim + "Install: " + a.reset + "https://tailscale.com/download");
2160
+ log(sym.bar + " " + a.dim + "Then run: " + a.reset + "tailscale up");
2161
+ log(sym.bar);
2162
+ log(sym.bar + " On your phone/tablet:");
2163
+ log(sym.bar + " " + a.dim + "1. Install Tailscale (App Store / Google Play)" + a.reset);
2164
+ log(sym.bar + " " + a.dim + "2. Sign in with the same account" + a.reset);
2165
+ log(sym.bar);
2166
+ promptSelect("Select", [
2167
+ { label: "Re-check", value: "recheck" },
2168
+ { label: "Back", value: "back" },
2169
+ ], function (choice) {
2170
+ if (choice === "recheck") {
2171
+ redraw(renderTailscale);
2172
+ } else {
2173
+ goBack();
2174
+ }
2175
+ });
2176
+ }
2177
+ }
2178
+
2179
+ function renderHttps() {
2180
+ if (!wantPush) {
2181
+ showSetupQR();
2182
+ return;
2183
+ }
2184
+
2185
+ var mcReady = hasMkcert();
2186
+ log(sym.pointer + " " + a.bold + "HTTPS Setup (for push notifications)" + a.reset);
2187
+ if (mcReady) {
2188
+ log(sym.bar + " " + a.green + "mkcert is installed" + a.reset);
2189
+ if (!config.tls) {
2190
+ log(sym.bar + " " + a.dim + "Restarting server with HTTPS..." + a.reset);
2191
+ restartDaemonWithTLS(config, function (newConfig) {
2192
+ config = newConfig;
2193
+ log(sym.bar);
2194
+ showSetupQR();
2195
+ });
2196
+ return;
2197
+ }
2198
+ log(sym.bar);
2199
+ showSetupQR();
2200
+ } else {
2201
+ log(sym.bar + " " + a.yellow + "mkcert not found." + a.reset);
2202
+ var mkcertHint = process.platform === "win32"
2203
+ ? "choco install mkcert && mkcert -install"
2204
+ : process.platform === "darwin"
2205
+ ? "brew install mkcert && mkcert -install"
2206
+ : "apt install mkcert && mkcert -install";
2207
+ log(sym.bar + " " + a.dim + "Install: " + a.reset + mkcertHint);
2208
+ log(sym.bar);
2209
+ promptSelect("Select", [
2210
+ { label: "Re-check", value: "recheck" },
2211
+ { label: "Back", value: "back" },
2212
+ ], function (choice) {
2213
+ if (choice === "recheck") {
2214
+ redraw(renderHttps);
2215
+ } else {
2216
+ goBack();
2217
+ }
2218
+ });
2219
+ }
2220
+ }
2221
+
2222
+ function showSetupQR() {
2223
+ var tsIP = getTailscaleIP();
2224
+ var lanIP = null;
2225
+ if (!wantRemote) {
2226
+ var allIPs = getAllIPs();
2227
+ for (var j = 0; j < allIPs.length; j++) {
2228
+ if (!allIPs[j].startsWith("100.")) { lanIP = allIPs[j]; break; }
2229
+ }
2230
+ }
2231
+ var setupIP = wantRemote ? (tsIP || ip) : (lanIP || ip);
2232
+ var setupQuery = wantRemote ? "" : "?mode=lan";
2233
+ // Always use HTTP onboarding URL for QR/setup when TLS is active
2234
+ var setupUrl = config.tls
2235
+ ? "http://" + setupIP + ":" + (config.port + 1) + "/setup" + setupQuery
2236
+ : "http://" + setupIP + ":" + config.port + "/setup" + setupQuery;
2237
+ log(sym.pointer + " " + a.bold + "Continue on your device" + a.reset);
2238
+ log(sym.bar + " " + a.dim + "Scan the QR code or open:" + a.reset);
2239
+ log(sym.bar + " " + a.bold + setupUrl + a.reset);
2240
+ log(sym.bar);
2241
+ qrcode.generate(setupUrl, { small: !isBasicTerm }, function (code) {
2242
+ var lines = code.split("\n").map(function (l) { return " " + sym.bar + " " + l; }).join("\n");
2243
+ console.log(lines);
2244
+ log(sym.bar);
2245
+ if (wantRemote) {
2246
+ log(sym.bar + " " + a.dim + "Can't connect? Make sure Tailscale is installed on your phone too." + a.reset);
2247
+ } else {
2248
+ log(sym.bar + " " + a.dim + "Can't connect? Your phone must be on the same Wi-Fi network." + a.reset);
2249
+ }
2250
+ log(sym.bar);
2251
+ log(sym.done + " " + a.dim + "Setup complete." + a.reset);
2252
+ log(sym.end);
2253
+ log("");
2254
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2255
+ goBack();
2256
+ });
2257
+ });
2258
+ }
2259
+ }
2260
+
2261
+ // ==============================
2262
+ // Settings sub-menu
2263
+ // ==============================
2264
+ function showSettingsMenu(config, ip) {
2265
+ sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
2266
+ var isAwake = status && status.keepAwake;
2267
+
2268
+ console.clear();
2269
+ printLogo();
2270
+ log("");
2271
+ log(sym.pointer + " " + a.bold + "Settings" + a.reset);
2272
+ log(sym.bar);
2273
+
2274
+ // Detect current state
2275
+ var tsIP = getTailscaleIP();
2276
+ var tsOk = tsIP !== null;
2277
+ var mcOk = hasMkcert();
2278
+
2279
+ var tsStatus = tsOk
2280
+ ? a.green + "Connected" + a.reset + a.dim + " · " + tsIP + a.reset
2281
+ : a.dim + "Not detected" + a.reset;
2282
+ var mcStatus = mcOk
2283
+ ? a.green + "Installed" + a.reset
2284
+ : a.dim + "Not found" + a.reset;
2285
+ var tlsStatus = config.tls
2286
+ ? a.green + "Enabled" + a.reset
2287
+ : a.dim + "Disabled" + a.reset;
2288
+ var pinStatus = config.pinHash
2289
+ ? a.green + "Enabled" + a.reset
2290
+ : a.dim + "Off" + a.reset;
2291
+ var awakeStatus = isAwake
2292
+ ? a.green + "On" + a.reset
2293
+ : a.dim + "Off" + a.reset;
2294
+
2295
+ log(sym.bar + " Tailscale " + tsStatus);
2296
+ log(sym.bar + " mkcert " + mcStatus);
2297
+ log(sym.bar + " HTTPS " + tlsStatus);
2298
+ var muEnabled = isMultiUser();
2299
+ var muStatus = muEnabled
2300
+ ? a.green + "Enabled" + a.reset
2301
+ : a.dim + "Off" + a.reset;
2302
+
2303
+ log(sym.bar + " PIN " + pinStatus);
2304
+ log(sym.bar + " Multi-user " + muStatus);
2305
+ if (process.platform === "darwin") {
2306
+ log(sym.bar + " Keep awake " + awakeStatus);
2307
+ }
2308
+ log(sym.bar);
2309
+
2310
+ // Build items
2311
+ var items = [
2312
+ { label: "Setup notifications", value: "guide" },
2313
+ ];
2314
+
2315
+ if (config.pinHash) {
2316
+ items.push({ label: "Change PIN", value: "pin" });
2317
+ items.push({ label: "Remove PIN", value: "remove_pin" });
2318
+ } else {
2319
+ items.push({ label: "Set PIN", value: "pin" });
2320
+ }
2321
+ if (muEnabled) {
2322
+ items.push({ label: "Disable multi-user mode", value: "disable_multi_user" });
2323
+ } else {
2324
+ items.push({ label: "Enable multi-user mode", value: "multi_user" });
2325
+ }
2326
+ if (process.platform === "darwin") {
2327
+ items.push({ label: isAwake ? "Disable keep awake" : "Enable keep awake", value: "awake" });
2328
+ }
2329
+ items.push({ label: "View logs", value: "logs" });
2330
+ items.push({ label: "Back", value: "back" });
2331
+
2332
+ promptSelect("Select", items, function (choice) {
2333
+ switch (choice) {
2334
+ case "guide":
2335
+ showSetupGuide(config, ip, function () {
2336
+ config = loadConfig() || config;
2337
+ showSettingsMenu(config, ip);
2338
+ });
2339
+ break;
2340
+
2341
+ case "pin":
2342
+ log(sym.bar);
2343
+ promptPin(function (pin) {
2344
+ if (pin) {
2345
+ var hash = generateAuthToken(pin);
2346
+ sendIPCCommand(socketPath(), { cmd: "set_pin", pinHash: hash }).then(function () {
2347
+ config.pinHash = hash;
2348
+ log(sym.done + " " + a.green + "PIN updated" + a.reset);
2349
+ log("");
2350
+ showSettingsMenu(config, ip);
2351
+ });
2352
+ } else {
2353
+ showSettingsMenu(config, ip);
2354
+ }
2355
+ });
2356
+ break;
2357
+
2358
+ case "remove_pin":
2359
+ sendIPCCommand(socketPath(), { cmd: "set_pin", pinHash: null }).then(function () {
2360
+ config.pinHash = null;
2361
+ log(sym.done + " " + a.dim + "PIN removed" + a.reset);
2362
+ log("");
2363
+ showSettingsMenu(config, ip);
2364
+ });
2365
+ break;
2366
+
2367
+ case "multi_user":
2368
+ var muResult = enableMultiUser();
2369
+ log(sym.bar);
2370
+ log(sym.bar + " " + a.yellow + sym.warn + " Experimental Feature" + a.reset);
2371
+ log(sym.bar);
2372
+ log(sym.bar + " " + a.dim + "Multi-user mode is experimental and may change in future releases." + a.reset);
2373
+ log(sym.bar + " " + a.dim + "Sharing access to AI-powered tools may be subject to your provider's" + a.reset);
2374
+ log(sym.bar + " " + a.dim + "terms of service. Please review the applicable usage policies before" + a.reset);
2375
+ log(sym.bar + " " + a.dim + "granting access to other users." + a.reset);
2376
+ log(sym.bar);
2377
+ if (muResult.setupCode) {
2378
+ log(sym.bar + " " + a.green + "Multi-user mode enabled." + a.reset);
2379
+ log(sym.bar);
2380
+ log(sym.bar + " Setup code: " + a.bold + muResult.setupCode + a.reset);
2381
+ log(sym.bar);
2382
+ log(sym.bar + " " + a.dim + "Open Clay in your browser and enter this code to create the admin account." + a.reset);
2383
+ log(sym.bar + " " + a.dim + "The code is single-use and will be cleared once the admin is set up." + a.reset);
2384
+ } else {
2385
+ log(sym.bar + " " + a.dim + "Multi-user mode is already enabled." + a.reset);
2386
+ }
2387
+ log(sym.bar);
2388
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2389
+ showSettingsMenu(config, ip);
2390
+ });
2391
+ break;
2392
+
2393
+ case "disable_multi_user":
2394
+ log(sym.bar);
2395
+ log(sym.bar + " " + a.yellow + sym.warn + " Disable multi-user mode?" + a.reset);
2396
+ log(sym.bar);
2397
+ log(sym.bar + " " + a.dim + "Sessions created by other users will no longer be visible." + a.reset);
2398
+ log(sym.bar + " " + a.dim + "User accounts will be preserved and restored if re-enabled." + a.reset);
2399
+ log(sym.bar);
2400
+ promptSelect("Confirm", [
2401
+ { label: "Disable multi-user mode", value: "confirm" },
2402
+ { label: "Cancel", value: "cancel" },
2403
+ ], function (confirmChoice) {
2404
+ if (confirmChoice === "confirm") {
2405
+ disableMultiUser();
2406
+ log(sym.bar);
2407
+ log(sym.done + " " + a.green + "Multi-user mode disabled." + a.reset);
2408
+ log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
2409
+ log(sym.bar);
2410
+ }
2411
+ showSettingsMenu(config, ip);
2412
+ });
2413
+ break;
2414
+
2415
+ case "logs":
2416
+ console.clear();
2417
+ log(a.bold + "Daemon logs" + a.reset + " " + a.dim + "(" + logPath() + ")" + a.reset);
2418
+ log("");
2419
+ try {
2420
+ var logContent = fs.readFileSync(logPath(), "utf8");
2421
+ var logLines = logContent.split("\n").slice(-30);
2422
+ for (var li = 0; li < logLines.length; li++) {
2423
+ log(a.dim + logLines[li] + a.reset);
2424
+ }
2425
+ } catch (e) {
2426
+ log(a.dim + "(empty)" + a.reset);
2427
+ }
2428
+ log("");
2429
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2430
+ showSettingsMenu(config, ip);
2431
+ });
2432
+ break;
2433
+
2434
+ case "awake":
2435
+ sendIPCCommand(socketPath(), { cmd: "set_keep_awake", value: !isAwake }).then(function (res) {
2436
+ if (res.ok) {
2437
+ config.keepAwake = !isAwake;
2438
+ }
2439
+ showSettingsMenu(config, ip);
2440
+ });
2441
+ break;
2442
+
2443
+ case "back":
2444
+ showMainMenu(config, ip);
2445
+ break;
2446
+ }
2447
+ });
2448
+ });
2449
+ }
2450
+
2451
+ // ==============================
2452
+ // Main entry: daemon alive?
2453
+ // ==============================
2454
+ var { checkAndUpdate } = require("../lib/updater");
2455
+ var currentVersion = require("../package.json").version;
2456
+
2457
+ (async function () {
2458
+ var updated = await checkAndUpdate(currentVersion, skipUpdate);
2459
+ if (updated) return;
2460
+
2461
+ // Dev mode — foreground daemon with file watching
2462
+ if (_isDev) {
2463
+ var devConfig = loadConfig();
2464
+ var devAlive = devConfig ? await isDaemonAliveAsync(devConfig) : false;
2465
+ if (devAlive) {
2466
+ console.log("\x1b[38;2;0;183;133m[dev]\x1b[0m Shutting down existing daemon...");
2467
+ await sendIPCCommand(socketPath(), { cmd: "shutdown" });
2468
+ clearStaleConfig();
2469
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
2470
+ }
2471
+ // No running daemon — clear config so setup runs fresh
2472
+ if (!devAlive && devConfig) {
2473
+ if (devConfig.pid) clearStaleConfig();
2474
+ devConfig = null;
2475
+ }
2476
+ // No config — go through setup (disclaimer, port, PIN, etc.)
2477
+ if (!devConfig) {
2478
+ setup(function (pin, keepAwake) {
2479
+ devMode(pin, keepAwake, null);
2480
+ });
2481
+ } else {
2482
+ // Reuse existing PIN hash from previous config
2483
+ await devMode(cliPin || null, devConfig.keepAwake || false, devConfig.pinHash || null);
2484
+ }
2485
+ return;
2486
+ }
2487
+
2488
+ var config = loadConfig();
2489
+ var alive = config ? await isDaemonAliveAsync(config) : false;
2490
+
2491
+ if (!alive && config && config.pid) {
2492
+ // Stale config
2493
+ clearStaleConfig();
2494
+ config = null;
2495
+ }
2496
+
2497
+ if (alive) {
2498
+ // Headless mode — daemon already running, just report and exit
2499
+ if (headlessMode) {
2500
+ var protocol = config.tls ? "https" : "http";
2501
+ var ip = getLocalIP();
2502
+ var url = protocol + "://" + ip + ":" + config.port;
2503
+ console.log(" " + sym.done + " Daemon already running (PID " + config.pid + ")");
2504
+ console.log(" " + sym.done + " " + url);
2505
+ process.exit(0);
2506
+ return;
2507
+ }
2508
+
2509
+ // Daemon is running — auto-add cwd if needed, then show menu
2510
+ var ip = getLocalIP();
2511
+
2512
+ var status = await sendIPCCommand(socketPath(), { cmd: "get_status" });
2513
+ if (!status.ok) {
2514
+ log(a.red + "Daemon not responding" + a.reset);
2515
+ clearStaleConfig();
2516
+ process.exit(1);
2517
+ return;
2518
+ }
2519
+
2520
+ // Check if cwd needs to be added
2521
+ var projs = status.projects || [];
2522
+ var cwdRegistered = false;
2523
+ for (var j = 0; j < projs.length; j++) {
2524
+ if (projs[j].path === cwd) {
2525
+ cwdRegistered = true;
2526
+ break;
2527
+ }
2528
+ }
2529
+
2530
+ if (!cwdRegistered) {
2531
+ var slug = path.basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
2532
+ console.clear();
2533
+ printLogo();
2534
+ log("");
2535
+ log(sym.pointer + " " + a.bold + "Add this project?" + a.reset);
2536
+ log(sym.bar);
2537
+ log(sym.bar + " " + a.dim + cwd + a.reset);
2538
+ log(sym.bar);
2539
+ promptSelect("Add " + a.green + slug + a.reset + " to relay?", [
2540
+ { label: "Yes", value: "yes" },
2541
+ { label: "No", value: "no" },
2542
+ ], function (answer) {
2543
+ if (answer === "yes") {
2544
+ sendIPCCommand(socketPath(), { cmd: "add_project", path: cwd }).then(function (res) {
2545
+ if (res.ok) {
2546
+ config = loadConfig() || config;
2547
+ log(sym.done + " " + a.green + "Added: " + (res.slug || slug) + a.reset);
2548
+ }
2549
+ log("");
2550
+ showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2551
+ });
2552
+ } else {
2553
+ showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2554
+ }
2555
+ });
2556
+ } else {
2557
+ showMainMenu(config || { pid: status.pid, port: status.port, tls: status.tls }, ip);
2558
+ }
2559
+ } else {
2560
+ // No daemon running — first-time setup
2561
+ if (autoYes) {
2562
+ var pin = cliPin || null;
2563
+ console.log(" " + sym.done + " Auto-accepted disclaimer");
2564
+ console.log(" " + sym.done + " PIN: " + (pin ? "Enabled" : "Skipped"));
2565
+ if (dangerouslySkipPermissions) {
2566
+ console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + (pin ? "" : " (no PIN)") + a.reset);
2567
+ }
2568
+ var autoRc = loadClayrc();
2569
+ var autoRestorable = (autoRc.recentProjects || []).filter(function (p) {
2570
+ return p.path !== cwd && fs.existsSync(p.path);
2571
+ });
2572
+ if (autoRestorable.length > 0) {
2573
+ console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
2574
+ }
2575
+ // Add cwd if it has history in .clayrc, or if there are no other projects to restore
2576
+ var cwdInRc = (autoRc.recentProjects || []).some(function (p) {
2577
+ return p.path === cwd;
2578
+ });
2579
+ var addCwd = cwdInRc || autoRestorable.length === 0;
2580
+ await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined, addCwd);
2581
+ } else {
2582
+ setup(function (pin, keepAwake) {
2583
+ // Check ~/.clayrc for previous projects to restore
2584
+ var rc = loadClayrc();
2585
+ var restorable = (rc.recentProjects || []).filter(function (p) {
2586
+ return p.path !== cwd && fs.existsSync(p.path);
2587
+ });
2588
+
2589
+ if (restorable.length > 0) {
2590
+ promptRestoreProjects(restorable, function (selected) {
2591
+ forkDaemon(pin, keepAwake, selected, false);
2592
+ });
2593
+ } else {
2594
+ log(sym.bar);
2595
+ log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2596
+ log("");
2597
+ forkDaemon(pin, keepAwake, undefined, true);
2598
+ }
2599
+ });
2600
+ }
2601
+ }
2602
+ })();