clay-server 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/project.js CHANGED
@@ -1,11 +1,15 @@
1
1
  var fs = require("fs");
2
2
  var path = require("path");
3
+ var os = require("os");
4
+ var crypto = require("crypto");
3
5
  var { createSessionManager } = require("./sessions");
4
6
  var { createSDKBridge } = require("./sdk-bridge");
5
7
  var { createTerminalManager } = require("./terminal-manager");
6
8
  var { createNotesManager } = require("./notes");
7
9
  var { fetchLatestVersion, isNewer } = require("./updater");
8
- var { execFileSync } = require("child_process");
10
+ var { execFileSync, spawn } = require("child_process");
11
+
12
+ var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
9
13
 
10
14
  // SDK loaded dynamically (ESM module)
11
15
  var sdkModule = null;
@@ -63,6 +67,7 @@ function createProjectContext(opts) {
63
67
  var slug = opts.slug;
64
68
  var project = path.basename(cwd);
65
69
  var title = opts.title || null;
70
+ var icon = opts.icon || null;
66
71
  var pushModule = opts.pushModule || null;
67
72
  var debug = opts.debug || false;
68
73
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
@@ -70,6 +75,7 @@ function createProjectContext(opts) {
70
75
  var lanHost = opts.lanHost || null;
71
76
  var getProjectCount = opts.getProjectCount || function () { return 1; };
72
77
  var getProjectList = opts.getProjectList || function () { return []; };
78
+ var onProcessingChanged = opts.onProcessingChanged || function () {};
73
79
  var latestVersion = null;
74
80
 
75
81
  // --- Per-project clients ---
@@ -192,8 +198,17 @@ function createProjectContext(opts) {
192
198
 
193
199
  // --- Session manager ---
194
200
  var sm = createSessionManager({ cwd: cwd, send: send });
195
- sm.currentPermissionMode = "default";
196
- sm.currentEffort = "high";
201
+ var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
202
+ var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
203
+ sm.currentPermissionMode = (_projMode && _projMode.mode) || (_srvMode && _srvMode.mode) || "default";
204
+
205
+ var _projEffort = typeof opts.onGetProjectDefaultEffort === "function" ? opts.onGetProjectDefaultEffort(slug) : null;
206
+ var _srvEffort = typeof opts.onGetServerDefaultEffort === "function" ? opts.onGetServerDefaultEffort() : null;
207
+ sm.currentEffort = (_projEffort && _projEffort.effort) || (_srvEffort && _srvEffort.effort) || "medium";
208
+
209
+ var _projModel = typeof opts.onGetProjectDefaultModel === "function" ? opts.onGetProjectDefaultModel(slug) : null;
210
+ var _srvModel = typeof opts.onGetServerDefaultModel === "function" ? opts.onGetServerDefaultModel() : null;
211
+ sm._savedDefaultModel = (_projModel && _projModel.model) || (_srvModel && _srvModel.model) || null;
197
212
 
198
213
  // --- SDK bridge ---
199
214
  var sdk = createSDKBridge({
@@ -204,6 +219,7 @@ function createProjectContext(opts) {
204
219
  pushModule: pushModule,
205
220
  getSDK: getSDK,
206
221
  dangerouslySkipPermissions: dangerouslySkipPermissions,
222
+ onProcessingChanged: onProcessingChanged,
207
223
  });
208
224
 
209
225
  // --- Terminal manager ---
@@ -234,7 +250,7 @@ function createProjectContext(opts) {
234
250
  if (sm.currentModel) {
235
251
  sendTo(ws, { type: "model_info", model: sm.currentModel, models: sm.availableModels || [] });
236
252
  }
237
- sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
253
+ sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
238
254
  sendTo(ws, { type: "term_list", terminals: tm.list() });
239
255
  sendTo(ws, { type: "notes_list", notes: nm.list() });
240
256
 
@@ -489,13 +505,70 @@ function createProjectContext(opts) {
489
505
  return;
490
506
  }
491
507
 
508
+ if (msg.type === "set_server_default_model" && msg.model) {
509
+ if (typeof opts.onSetServerDefaultModel === "function") {
510
+ opts.onSetServerDefaultModel(msg.model);
511
+ }
512
+ var session = sm.getActiveSession();
513
+ if (session) {
514
+ sdk.setModel(session, msg.model);
515
+ }
516
+ return;
517
+ }
518
+
519
+ if (msg.type === "set_project_default_model" && msg.model) {
520
+ if (typeof opts.onSetProjectDefaultModel === "function") {
521
+ opts.onSetProjectDefaultModel(slug, msg.model);
522
+ }
523
+ var session = sm.getActiveSession();
524
+ if (session) {
525
+ sdk.setModel(session, msg.model);
526
+ }
527
+ return;
528
+ }
529
+
492
530
  if (msg.type === "set_permission_mode" && msg.mode) {
531
+ // When dangerouslySkipPermissions is active, don't allow UI to change mode
532
+ if (dangerouslySkipPermissions) {
533
+ send({ type: "config_state", model: sm.currentModel || "", mode: "bypassPermissions", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
534
+ return;
535
+ }
493
536
  sm.currentPermissionMode = msg.mode;
494
537
  var session = sm.getActiveSession();
495
538
  if (session) {
496
539
  sdk.setPermissionMode(session, msg.mode);
497
540
  }
498
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
541
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
542
+ return;
543
+ }
544
+
545
+ if (msg.type === "set_server_default_mode" && msg.mode) {
546
+ if (typeof opts.onSetServerDefaultMode === "function") {
547
+ opts.onSetServerDefaultMode(msg.mode);
548
+ }
549
+ if (!dangerouslySkipPermissions) {
550
+ sm.currentPermissionMode = msg.mode;
551
+ var session = sm.getActiveSession();
552
+ if (session) {
553
+ sdk.setPermissionMode(session, msg.mode);
554
+ }
555
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
556
+ }
557
+ return;
558
+ }
559
+
560
+ if (msg.type === "set_project_default_mode" && msg.mode) {
561
+ if (typeof opts.onSetProjectDefaultMode === "function") {
562
+ opts.onSetProjectDefaultMode(slug, msg.mode);
563
+ }
564
+ if (!dangerouslySkipPermissions) {
565
+ sm.currentPermissionMode = msg.mode;
566
+ var session = sm.getActiveSession();
567
+ if (session) {
568
+ sdk.setPermissionMode(session, msg.mode);
569
+ }
570
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
571
+ }
499
572
  return;
500
573
  }
501
574
 
@@ -505,9 +578,27 @@ function createProjectContext(opts) {
505
578
  return;
506
579
  }
507
580
 
581
+ if (msg.type === "set_server_default_effort" && msg.effort) {
582
+ if (typeof opts.onSetServerDefaultEffort === "function") {
583
+ opts.onSetServerDefaultEffort(msg.effort);
584
+ }
585
+ sm.currentEffort = msg.effort;
586
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
587
+ return;
588
+ }
589
+
590
+ if (msg.type === "set_project_default_effort" && msg.effort) {
591
+ if (typeof opts.onSetProjectDefaultEffort === "function") {
592
+ opts.onSetProjectDefaultEffort(slug, msg.effort);
593
+ }
594
+ sm.currentEffort = msg.effort;
595
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
596
+ return;
597
+ }
598
+
508
599
  if (msg.type === "set_betas") {
509
600
  sm.currentBetas = msg.betas || [];
510
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "high", betas: sm.currentBetas });
601
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas });
511
602
  return;
512
603
  }
513
604
 
@@ -593,6 +684,7 @@ function createProjectContext(opts) {
593
684
  session.pendingPermissions = {};
594
685
  session.pendingAskUser = {};
595
686
  session.isProcessing = false;
687
+ onProcessingChanged();
596
688
 
597
689
  sm.saveSessionFile(session);
598
690
  sm.switchSession(session.localId);
@@ -641,7 +733,7 @@ function createProjectContext(opts) {
641
733
  if (decision === "allow_accept_edits") {
642
734
  sdk.setPermissionMode(session, "acceptEdits");
643
735
  sm.currentPermissionMode = "acceptEdits";
644
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "high", betas: sm.currentBetas || [] });
736
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
645
737
  pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
646
738
  sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
647
739
  return;
@@ -653,14 +745,24 @@ function createProjectContext(opts) {
653
745
  pending.resolve({ behavior: "deny", message: "User chose to clear context and restart" });
654
746
  sm.sendAndRecord(session, { type: "permission_resolved", requestId: requestId, decision: decision });
655
747
 
656
- // Abort the old session's query so it stops processing immediately
657
- if (session.abortController) {
658
- session.abortController.abort();
659
- }
748
+ // Abort the old session's query but defer to next tick so the SDK's
749
+ // deny write (scheduled as microtask by pending.resolve) completes first.
750
+ // Aborting synchronously would kill the subprocess before the write,
751
+ // causing an "Operation aborted" crash in the SDK.
660
752
  session.isProcessing = false;
753
+ onProcessingChanged();
661
754
  session.pendingPermissions = {};
662
755
  session.pendingAskUser = {};
663
756
  sm.broadcastSessionList();
757
+ setImmediate(function () {
758
+ if (session.abortController) {
759
+ session.abortController.abort();
760
+ }
761
+ });
762
+
763
+ // Update permission mode for the new session
764
+ sm.currentPermissionMode = "acceptEdits";
765
+ send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
664
766
 
665
767
  // Build prompt from plan content (sent from client) or plan file path
666
768
  var clientPlanContent = msg.planContent || "";
@@ -672,24 +774,34 @@ function createProjectContext(opts) {
672
774
  planPrompt = "Execute the plan in " + planFilePath + ". Do NOT re-enter plan mode — read the plan file and implement it step by step.";
673
775
  }
674
776
 
675
- // Wait a tick for the deny to propagate, then create new session + send plan
676
- setTimeout(function () {
677
- var newSession = sm.createSession();
678
- // Send the plan as the first user message (with planContent for UI rendering)
679
- var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
680
- newSession.history.push(userMsg);
681
- sm.appendToSessionFile(newSession, userMsg);
682
- newSession.title = "Plan execution (cleared context)";
683
- sm.saveSessionFile(newSession);
684
- sm.broadcastSessionList();
685
- send(userMsg);
777
+ // Wait for old query stream to fully terminate, then create new session + send plan
778
+ var oldStreamPromise = session.streamPromise || Promise.resolve();
779
+ Promise.race([
780
+ oldStreamPromise,
781
+ new Promise(function (resolve) { setTimeout(resolve, 3000); }),
782
+ ]).then(function () {
783
+ try {
784
+ var newSession = sm.createSession();
785
+ // Send the plan as the first user message (with planContent for UI rendering)
786
+ var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
787
+ newSession.history.push(userMsg);
788
+ sm.appendToSessionFile(newSession, userMsg);
789
+ newSession.title = "Plan execution (cleared context)";
790
+ sm.saveSessionFile(newSession);
791
+ sm.broadcastSessionList();
792
+ send(userMsg);
686
793
 
687
- newSession.isProcessing = true;
688
- newSession.sentToolResults = {};
689
- send({ type: "status", status: "processing" });
690
- newSession.acceptEditsAfterStart = true;
691
- sdk.startQuery(newSession, planPrompt);
692
- }, 200);
794
+ newSession.isProcessing = true;
795
+ onProcessingChanged();
796
+ newSession.sentToolResults = {};
797
+ send({ type: "status", status: "processing" });
798
+ newSession.acceptEditsAfterStart = true;
799
+ sdk.startQuery(newSession, planPrompt);
800
+ } catch (e) {
801
+ console.error("[project] Error starting plan execution:", e);
802
+ send({ type: "error", text: "Failed to start plan execution: " + (e.message || e) });
803
+ }
804
+ });
693
805
  return;
694
806
  }
695
807
 
@@ -709,6 +821,7 @@ function createProjectContext(opts) {
709
821
 
710
822
  if (!session.isProcessing) {
711
823
  session.isProcessing = true;
824
+ onProcessingChanged();
712
825
  session.sentToolResults = {};
713
826
  send({ type: "status", status: "processing" });
714
827
  if (!session.queryInstance) {
@@ -820,6 +933,52 @@ function createProjectContext(opts) {
820
933
  return;
821
934
  }
822
935
 
936
+ // --- Reorder projects ---
937
+ if (msg.type === "reorder_projects") {
938
+ var slugs = msg.slugs;
939
+ if (!Array.isArray(slugs) || slugs.length === 0) {
940
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Missing slugs" });
941
+ return;
942
+ }
943
+ if (typeof opts.onReorderProjects === "function") {
944
+ var reorderResult = opts.onReorderProjects(slugs);
945
+ sendTo(ws, { type: "reorder_projects_result", ok: reorderResult.ok, error: reorderResult.error });
946
+ } else {
947
+ sendTo(ws, { type: "reorder_projects_result", ok: false, error: "Not supported" });
948
+ }
949
+ return;
950
+ }
951
+
952
+ // --- Set project title (rename) ---
953
+ if (msg.type === "set_project_title") {
954
+ if (!msg.slug) {
955
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Missing slug" });
956
+ return;
957
+ }
958
+ if (typeof opts.onSetProjectTitle === "function") {
959
+ var titleResult = opts.onSetProjectTitle(msg.slug, msg.title || null);
960
+ sendTo(ws, { type: "set_project_title_result", ok: titleResult.ok, slug: msg.slug, error: titleResult.error });
961
+ } else {
962
+ sendTo(ws, { type: "set_project_title_result", ok: false, error: "Not supported" });
963
+ }
964
+ return;
965
+ }
966
+
967
+ // --- Set project icon (emoji) ---
968
+ if (msg.type === "set_project_icon") {
969
+ if (!msg.slug) {
970
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Missing slug" });
971
+ return;
972
+ }
973
+ if (typeof opts.onSetProjectIcon === "function") {
974
+ var iconResult = opts.onSetProjectIcon(msg.slug, msg.icon || null);
975
+ sendTo(ws, { type: "set_project_icon_result", ok: iconResult.ok, slug: msg.slug, error: iconResult.error });
976
+ } else {
977
+ sendTo(ws, { type: "set_project_icon_result", ok: false, error: "Not supported" });
978
+ }
979
+ return;
980
+ }
981
+
823
982
  // --- Daemon config (server settings) ---
824
983
  if (msg.type === "get_daemon_config") {
825
984
  if (typeof opts.onGetDaemonConfig === "function") {
@@ -915,6 +1074,98 @@ function createProjectContext(opts) {
915
1074
  return;
916
1075
  }
917
1076
 
1077
+ // --- File write ---
1078
+ if (msg.type === "fs_write") {
1079
+ var fsWriteFile = safePath(cwd, msg.path);
1080
+ if (!fsWriteFile) {
1081
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
1082
+ return;
1083
+ }
1084
+ try {
1085
+ fs.writeFileSync(fsWriteFile, msg.content || "", "utf8");
1086
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
1087
+ } catch (e) {
1088
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
1089
+ }
1090
+ return;
1091
+ }
1092
+
1093
+ // --- Project environment variables ---
1094
+ if (msg.type === "get_project_env") {
1095
+ var envrc = "";
1096
+ var hasEnvrc = false;
1097
+ if (typeof opts.onGetProjectEnv === "function") {
1098
+ var envResult = opts.onGetProjectEnv(msg.slug);
1099
+ envrc = envResult.envrc || "";
1100
+ }
1101
+ try {
1102
+ var envrcPath = path.join(cwd, ".envrc");
1103
+ hasEnvrc = fs.existsSync(envrcPath);
1104
+ } catch (e) {}
1105
+ sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc });
1106
+ return;
1107
+ }
1108
+
1109
+ if (msg.type === "set_project_env") {
1110
+ if (typeof opts.onSetProjectEnv === "function") {
1111
+ var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
1112
+ sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
1113
+ } else {
1114
+ sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" });
1115
+ }
1116
+ return;
1117
+ }
1118
+
1119
+ // --- Global CLAUDE.md ---
1120
+ if (msg.type === "read_global_claude_md") {
1121
+ var os = require("os");
1122
+ var globalMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
1123
+ try {
1124
+ var globalMdContent = fs.readFileSync(globalMdPath, "utf8");
1125
+ sendTo(ws, { type: "global_claude_md_result", content: globalMdContent });
1126
+ } catch (e) {
1127
+ sendTo(ws, { type: "global_claude_md_result", error: e.message });
1128
+ }
1129
+ return;
1130
+ }
1131
+
1132
+ if (msg.type === "write_global_claude_md") {
1133
+ var os2 = require("os");
1134
+ var globalMdDir = path.join(os2.homedir(), ".claude");
1135
+ var globalMdWritePath = path.join(globalMdDir, "CLAUDE.md");
1136
+ try {
1137
+ if (!fs.existsSync(globalMdDir)) {
1138
+ fs.mkdirSync(globalMdDir, { recursive: true });
1139
+ }
1140
+ fs.writeFileSync(globalMdWritePath, msg.content || "", "utf8");
1141
+ sendTo(ws, { type: "write_global_claude_md_result", ok: true });
1142
+ } catch (e) {
1143
+ sendTo(ws, { type: "write_global_claude_md_result", ok: false, error: e.message });
1144
+ }
1145
+ return;
1146
+ }
1147
+
1148
+ // --- Shared environment variables ---
1149
+ if (msg.type === "get_shared_env") {
1150
+ var sharedEnvrc = "";
1151
+ if (typeof opts.onGetSharedEnv === "function") {
1152
+ var sharedResult = opts.onGetSharedEnv();
1153
+ sharedEnvrc = sharedResult.envrc || "";
1154
+ }
1155
+ sendTo(ws, { type: "shared_env_result", envrc: sharedEnvrc });
1156
+ return;
1157
+ }
1158
+
1159
+ if (msg.type === "set_shared_env") {
1160
+ if (typeof opts.onSetSharedEnv === "function") {
1161
+ var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
1162
+ sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
1163
+ } else {
1164
+ sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Not supported" });
1165
+ }
1166
+ return;
1167
+ }
1168
+
918
1169
  // --- File watcher ---
919
1170
  if (msg.type === "fs_watch") {
920
1171
  if (msg.path) startFileWatch(msg.path);
@@ -1228,6 +1479,7 @@ function createProjectContext(opts) {
1228
1479
 
1229
1480
  if (!session.isProcessing) {
1230
1481
  session.isProcessing = true;
1482
+ onProcessingChanged();
1231
1483
  session.sentToolResults = {};
1232
1484
  send({ type: "status", status: "processing" });
1233
1485
  if (!session.queryInstance) {
@@ -1254,6 +1506,54 @@ function createProjectContext(opts) {
1254
1506
 
1255
1507
  // --- Handle project-scoped HTTP requests ---
1256
1508
  function handleHTTP(req, res, urlPath) {
1509
+ // File upload
1510
+ if (req.method === "POST" && urlPath === "/api/upload") {
1511
+ parseJsonBody(req).then(function (body) {
1512
+ var fileName = body.name;
1513
+ var fileData = body.data; // base64
1514
+ if (!fileName || !fileData) {
1515
+ res.writeHead(400, { "Content-Type": "application/json" });
1516
+ res.end('{"error":"missing name or data"}');
1517
+ return;
1518
+ }
1519
+ // Sanitize filename — strip path separators
1520
+ var safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._\-\(\)\[\] ]/g, "_");
1521
+ if (!safeName) safeName = "upload";
1522
+
1523
+ // Check size
1524
+ var estimatedBytes = fileData.length * 0.75;
1525
+ if (estimatedBytes > MAX_UPLOAD_BYTES) {
1526
+ res.writeHead(413, { "Content-Type": "application/json" });
1527
+ res.end('{"error":"file too large (max 50MB)"}');
1528
+ return;
1529
+ }
1530
+
1531
+ // Create tmp dir: os.tmpdir()/clay-{hash}/
1532
+ var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
1533
+ var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
1534
+ try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
1535
+
1536
+ // Add timestamp prefix to avoid collisions
1537
+ var ts = Date.now();
1538
+ var destName = ts + "-" + safeName;
1539
+ var destPath = path.join(tmpDir, destName);
1540
+
1541
+ try {
1542
+ var buf = Buffer.from(fileData, "base64");
1543
+ fs.writeFileSync(destPath, buf);
1544
+ res.writeHead(200, { "Content-Type": "application/json" });
1545
+ res.end(JSON.stringify({ path: destPath, name: safeName }));
1546
+ } catch (e) {
1547
+ res.writeHead(500, { "Content-Type": "application/json" });
1548
+ res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
1549
+ }
1550
+ }).catch(function () {
1551
+ res.writeHead(400);
1552
+ res.end("Bad request");
1553
+ });
1554
+ return true;
1555
+ }
1556
+
1257
1557
  // Push subscribe
1258
1558
  if (req.method === "POST" && urlPath === "/api/push-subscribe") {
1259
1559
  parseJsonBody(req).then(function (body) {
@@ -1342,6 +1642,131 @@ function createProjectContext(opts) {
1342
1642
  return true;
1343
1643
  }
1344
1644
 
1645
+ // Install a skill (background spawn)
1646
+ if (req.method === "POST" && urlPath === "/api/install-skill") {
1647
+ parseJsonBody(req).then(function (body) {
1648
+ var url = body.url;
1649
+ var skill = body.skill;
1650
+ var scope = body.scope; // "global" or "project"
1651
+ if (!url || !skill || !scope) {
1652
+ res.writeHead(400, { "Content-Type": "application/json" });
1653
+ res.end('{"error":"missing url, skill, or scope"}');
1654
+ return;
1655
+ }
1656
+ var spawnCwd = scope === "global" ? os.homedir() : cwd;
1657
+ var child = spawn("npx", ["skills", "add", url, "--skill", skill], {
1658
+ cwd: spawnCwd,
1659
+ stdio: "ignore",
1660
+ detached: false,
1661
+ });
1662
+ child.on("close", function (code) {
1663
+ var success = code === 0;
1664
+ send({
1665
+ type: "skill_installed",
1666
+ skill: skill,
1667
+ scope: scope,
1668
+ success: success,
1669
+ error: success ? null : "Process exited with code " + code,
1670
+ });
1671
+ });
1672
+ child.on("error", function (err) {
1673
+ send({
1674
+ type: "skill_installed",
1675
+ skill: skill,
1676
+ scope: scope,
1677
+ success: false,
1678
+ error: err.message,
1679
+ });
1680
+ });
1681
+ res.writeHead(200, { "Content-Type": "application/json" });
1682
+ res.end('{"ok":true}');
1683
+ }).catch(function () {
1684
+ res.writeHead(400);
1685
+ res.end("Bad request");
1686
+ });
1687
+ return true;
1688
+ }
1689
+
1690
+ // Uninstall a skill (remove directory)
1691
+ if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
1692
+ parseJsonBody(req).then(function (body) {
1693
+ var skill = body.skill;
1694
+ var scope = body.scope; // "global" or "project"
1695
+ if (!skill || !scope) {
1696
+ res.writeHead(400, { "Content-Type": "application/json" });
1697
+ res.end('{"error":"missing skill or scope"}');
1698
+ return;
1699
+ }
1700
+ var baseDir = scope === "global" ? os.homedir() : cwd;
1701
+ var skillDir = path.join(baseDir, ".claude", "skills", skill);
1702
+ // Safety: ensure skillDir is inside the expected .claude/skills directory
1703
+ var expectedParent = path.join(baseDir, ".claude", "skills");
1704
+ var resolved = path.resolve(skillDir);
1705
+ if (!resolved.startsWith(expectedParent + path.sep)) {
1706
+ res.writeHead(403, { "Content-Type": "application/json" });
1707
+ res.end('{"error":"invalid skill path"}');
1708
+ return;
1709
+ }
1710
+ try {
1711
+ fs.rmSync(resolved, { recursive: true, force: true });
1712
+ send({
1713
+ type: "skill_uninstalled",
1714
+ skill: skill,
1715
+ scope: scope,
1716
+ success: true,
1717
+ });
1718
+ res.writeHead(200, { "Content-Type": "application/json" });
1719
+ res.end('{"ok":true}');
1720
+ } catch (err) {
1721
+ send({
1722
+ type: "skill_uninstalled",
1723
+ skill: skill,
1724
+ scope: scope,
1725
+ success: false,
1726
+ error: err.message,
1727
+ });
1728
+ res.writeHead(500, { "Content-Type": "application/json" });
1729
+ res.end(JSON.stringify({ error: err.message }));
1730
+ }
1731
+ }).catch(function () {
1732
+ res.writeHead(400);
1733
+ res.end("Bad request");
1734
+ });
1735
+ return true;
1736
+ }
1737
+
1738
+ // Installed skills (global + project)
1739
+ if (req.method === "GET" && urlPath === "/api/installed-skills") {
1740
+ var installed = {};
1741
+ var globalDir = path.join(os.homedir(), ".claude", "skills");
1742
+ var projectDir = path.join(cwd, ".claude", "skills");
1743
+ var scanDirs = [
1744
+ { dir: globalDir, scope: "global" },
1745
+ { dir: projectDir, scope: "project" },
1746
+ ];
1747
+ for (var sd = 0; sd < scanDirs.length; sd++) {
1748
+ var entries;
1749
+ try { entries = fs.readdirSync(scanDirs[sd].dir, { withFileTypes: true }); } catch (e) { continue; }
1750
+ for (var si = 0; si < entries.length; si++) {
1751
+ var ent = entries[si];
1752
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
1753
+ var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
1754
+ try {
1755
+ fs.accessSync(mdPath, fs.constants.R_OK);
1756
+ if (!installed[ent.name]) {
1757
+ installed[ent.name] = { scope: scanDirs[sd].scope };
1758
+ } else {
1759
+ // project-level adds to existing global entry
1760
+ installed[ent.name].scope = "both";
1761
+ }
1762
+ } catch (e) {}
1763
+ }
1764
+ }
1765
+ res.writeHead(200, { "Content-Type": "application/json" });
1766
+ res.end(JSON.stringify({ installed: installed }));
1767
+ return true;
1768
+ }
1769
+
1345
1770
  // Info endpoint
1346
1771
  if (req.method === "GET" && urlPath === "/info") {
1347
1772
  res.writeHead(200, {
@@ -1374,6 +1799,12 @@ function createProjectContext(opts) {
1374
1799
  try { ws.close(); } catch (e) {}
1375
1800
  }
1376
1801
  clients.clear();
1802
+ // Cleanup tmp upload directory
1803
+ try {
1804
+ var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
1805
+ var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
1806
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1807
+ } catch (e) {}
1377
1808
  }
1378
1809
 
1379
1810
  // --- Status info ---
@@ -1388,6 +1819,7 @@ function createProjectContext(opts) {
1388
1819
  path: cwd,
1389
1820
  project: project,
1390
1821
  title: title,
1822
+ icon: icon,
1391
1823
  clients: clients.size,
1392
1824
  sessions: sessionCount,
1393
1825
  isProcessing: hasProcessing,
@@ -1399,6 +1831,10 @@ function createProjectContext(opts) {
1399
1831
  send({ type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, lanHost: lanHost, projectCount: getProjectCount(), projects: getProjectList() });
1400
1832
  }
1401
1833
 
1834
+ function setIcon(newIcon) {
1835
+ icon = newIcon || null;
1836
+ }
1837
+
1402
1838
  return {
1403
1839
  cwd: cwd,
1404
1840
  slug: slug,
@@ -1414,6 +1850,7 @@ function createProjectContext(opts) {
1414
1850
  handleHTTP: handleHTTP,
1415
1851
  getStatus: getStatus,
1416
1852
  setTitle: setTitle,
1853
+ setIcon: setIcon,
1417
1854
  warmup: function () { sdk.warmup(); },
1418
1855
  destroy: destroy,
1419
1856
  };