clay-server 2.26.1-beta.1 → 2.27.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var os = require("os");
4
+ var crypto = require("crypto");
5
+ var { execFileSync, spawn } = require("child_process");
6
+ var { fsAsUser } = require("./os-users");
7
+ var usersModule = require("./users");
8
+
9
+ var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
10
+ var MIME_TYPES = {
11
+ ".html": "text/html",
12
+ ".css": "text/css",
13
+ ".js": "application/javascript",
14
+ ".json": "application/json",
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".svg": "image/svg+xml",
21
+ ".bmp": "image/bmp",
22
+ ".ico": "image/x-icon",
23
+ };
24
+ var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
25
+
26
+ function parseJsonBody(req) {
27
+ return new Promise(function (resolve, reject) {
28
+ var body = "";
29
+ req.on("data", function (chunk) { body += chunk; });
30
+ req.on("end", function () {
31
+ try { resolve(JSON.parse(body)); }
32
+ catch (e) { reject(e); }
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Attach HTTP request handler to a project context.
39
+ *
40
+ * ctx fields:
41
+ * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule,
42
+ * safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny,
43
+ * _extToken, _browserTabList
44
+ */
45
+ function attachHTTP(ctx) {
46
+ var cwd = ctx.cwd;
47
+ var slug = ctx.slug;
48
+ var project = ctx.project;
49
+ var sm = ctx.sm;
50
+ var send = ctx.send;
51
+ var imagesDir = ctx.imagesDir;
52
+ var osUsers = ctx.osUsers;
53
+ var pushModule = ctx.pushModule;
54
+ var safePath = ctx.safePath;
55
+ var safeAbsPath = ctx.safeAbsPath;
56
+ var getOsUserInfoForReq = ctx.getOsUserInfoForReq;
57
+ var sendExtensionCommandAny = ctx.sendExtensionCommandAny;
58
+ var _extToken = ctx._extToken;
59
+ var _browserTabList = ctx._browserTabList;
60
+
61
+ function handleHTTP(req, res, urlPath) {
62
+ // Browser MCP extension bridge: forward commands to Chrome extension
63
+ if (req.method === "POST" && urlPath === "/ext-command") {
64
+ parseJsonBody(req).then(function (body) {
65
+ // Validate auth token
66
+ if (!body.token || body.token !== _extToken) {
67
+ res.writeHead(403, { "Content-Type": "application/json" });
68
+ res.end('{"error":"Invalid token"}');
69
+ return;
70
+ }
71
+ var command = body.command;
72
+ var args = body.args || {};
73
+ var timeout = Math.min(body.timeout || 5000, 30000); // max 30s
74
+
75
+ // Special command: list_tabs (no extension round-trip needed)
76
+ if (command === "list_tabs") {
77
+ var tabArr = [];
78
+ for (var tid in _browserTabList) {
79
+ tabArr.push(_browserTabList[tid]);
80
+ }
81
+ res.writeHead(200, { "Content-Type": "application/json" });
82
+ res.end(JSON.stringify({ result: { tabs: tabArr } }));
83
+ return;
84
+ }
85
+
86
+ sendExtensionCommandAny(command, args, timeout).then(function (result) {
87
+ res.writeHead(200, { "Content-Type": "application/json" });
88
+ res.end(JSON.stringify({ result: result || {} }));
89
+ }).catch(function (err) {
90
+ res.writeHead(200, { "Content-Type": "application/json" });
91
+ res.end(JSON.stringify({ error: err.message || "Extension command failed" }));
92
+ });
93
+ }).catch(function () {
94
+ res.writeHead(400, { "Content-Type": "application/json" });
95
+ res.end('{"error":"Invalid JSON body"}');
96
+ });
97
+ return true;
98
+ }
99
+
100
+ // Serve chat images
101
+ if (req.method === "GET" && urlPath.indexOf("/images/") === 0) {
102
+ var imgName = path.basename(urlPath);
103
+ // Sanitize: only allow expected filename pattern
104
+ if (!/^\d+-[a-f0-9]+\.\w+$/.test(imgName)) {
105
+ res.writeHead(400);
106
+ res.end("Bad request");
107
+ return true;
108
+ }
109
+ var imgPath = path.join(imagesDir, imgName);
110
+ try {
111
+ var imgBuf = fs.readFileSync(imgPath);
112
+ var ext = path.extname(imgName).toLowerCase();
113
+ var mime = ext === ".png" ? "image/png" : ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/jpeg";
114
+ res.writeHead(200, { "Content-Type": mime, "Cache-Control": "public, max-age=86400" });
115
+ res.end(imgBuf);
116
+ } catch (e) {
117
+ res.writeHead(404);
118
+ res.end("Not found");
119
+ }
120
+ return true;
121
+ }
122
+
123
+ // File upload
124
+ if (req.method === "POST" && urlPath === "/api/upload") {
125
+ parseJsonBody(req).then(function (body) {
126
+ var fileName = body.name;
127
+ var fileData = body.data; // base64
128
+ if (!fileName || !fileData) {
129
+ res.writeHead(400, { "Content-Type": "application/json" });
130
+ res.end('{"error":"missing name or data"}');
131
+ return;
132
+ }
133
+ // Sanitize filename — strip path separators
134
+ var safeName = path.basename(fileName).replace(/[\x00-\x1f\/\\:*?"<>|]/g, "_");
135
+ if (!safeName) safeName = "upload";
136
+
137
+ // Check size
138
+ var estimatedBytes = fileData.length * 0.75;
139
+ if (estimatedBytes > MAX_UPLOAD_BYTES) {
140
+ res.writeHead(413, { "Content-Type": "application/json" });
141
+ res.end('{"error":"file too large (max 50MB)"}');
142
+ return;
143
+ }
144
+
145
+ // Create tmp dir: os.tmpdir()/clay-{hash}/
146
+ var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
147
+ var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
148
+ try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
149
+
150
+ // Add timestamp prefix to avoid collisions
151
+ var ts = Date.now();
152
+ var destName = ts + "-" + safeName;
153
+ var destPath = path.join(tmpDir, destName);
154
+
155
+ try {
156
+ var buf = Buffer.from(fileData, "base64");
157
+ fs.writeFileSync(destPath, buf);
158
+ // Make readable by all local users and chown to session owner
159
+ try { fs.chmodSync(destPath, 0o644); } catch (e2) {}
160
+ try { fs.chmodSync(tmpDir, 0o755); } catch (e2) {}
161
+ if (req._clayUser && req._clayUser.linuxUser) {
162
+ try {
163
+ var _osUM = require("./os-users");
164
+ var _uid = _osUM.getLinuxUserUid(req._clayUser.linuxUser);
165
+ if (_uid != null) {
166
+ require("child_process").execSync("chown " + _uid + " " + JSON.stringify(destPath));
167
+ require("child_process").execSync("chown " + _uid + " " + JSON.stringify(tmpDir));
168
+ }
169
+ } catch (e2) {}
170
+ }
171
+ res.writeHead(200, { "Content-Type": "application/json" });
172
+ res.end(JSON.stringify({ path: destPath, name: safeName }));
173
+ } catch (e) {
174
+ res.writeHead(500, { "Content-Type": "application/json" });
175
+ res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
176
+ }
177
+ }).catch(function () {
178
+ res.writeHead(400);
179
+ res.end("Bad request");
180
+ });
181
+ return true;
182
+ }
183
+
184
+ // Push subscribe
185
+ if (req.method === "POST" && urlPath === "/api/push-subscribe") {
186
+ parseJsonBody(req).then(function (body) {
187
+ var sub = body.subscription || body;
188
+ if (pushModule) pushModule.addSubscription(sub, body.replaceEndpoint);
189
+ res.writeHead(200, { "Content-Type": "application/json" });
190
+ res.end('{"ok":true}');
191
+ }).catch(function () {
192
+ res.writeHead(400);
193
+ res.end("Bad request");
194
+ });
195
+ return true;
196
+ }
197
+
198
+ // Permission response from push notification
199
+ if (req.method === "POST" && urlPath === "/api/permission-response") {
200
+ parseJsonBody(req).then(function (data) {
201
+ var requestId = data.requestId;
202
+ var decision = data.decision;
203
+ if (!requestId || !decision) {
204
+ res.writeHead(400, { "Content-Type": "application/json" });
205
+ res.end('{"error":"missing requestId or decision"}');
206
+ return;
207
+ }
208
+ var found = false;
209
+ sm.sessions.forEach(function (session) {
210
+ var pending = session.pendingPermissions[requestId];
211
+ if (!pending) return;
212
+ found = true;
213
+ delete session.pendingPermissions[requestId];
214
+ if (decision === "allow") {
215
+ pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
216
+ } else {
217
+ pending.resolve({ behavior: "deny", message: "Denied via push notification" });
218
+ }
219
+ sm.sendAndRecord(session, {
220
+ type: "permission_resolved",
221
+ requestId: requestId,
222
+ decision: decision,
223
+ });
224
+ });
225
+ if (found) {
226
+ res.writeHead(200, { "Content-Type": "application/json" });
227
+ res.end('{"ok":true}');
228
+ } else {
229
+ res.writeHead(404, { "Content-Type": "application/json" });
230
+ res.end('{"error":"permission request not found"}');
231
+ }
232
+ }).catch(function () {
233
+ res.writeHead(400);
234
+ res.end("Bad request");
235
+ });
236
+ return true;
237
+ }
238
+
239
+ // VAPID public key
240
+ if (req.method === "GET" && urlPath === "/api/vapid-public-key") {
241
+ if (pushModule) {
242
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store" });
243
+ res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
244
+ } else {
245
+ res.writeHead(404, { "Content-Type": "application/json" });
246
+ res.end('{"error":"push not available"}');
247
+ }
248
+ return true;
249
+ }
250
+
251
+ // File browser: serve project images
252
+ if (req.method === "GET" && urlPath.startsWith("/api/file?")) {
253
+ var qIdx = urlPath.indexOf("?");
254
+ var params = new URLSearchParams(urlPath.substring(qIdx));
255
+ var reqFilePath = params.get("path");
256
+ if (!reqFilePath) { res.writeHead(400); res.end("Missing path"); return true; }
257
+ var absFile = safePath(cwd, reqFilePath);
258
+ if (!absFile && getOsUserInfoForReq(req)) {
259
+ absFile = safeAbsPath(reqFilePath);
260
+ }
261
+ if (!absFile) { res.writeHead(403); res.end("Access denied"); return true; }
262
+ var fileExt = path.extname(absFile).toLowerCase();
263
+ if (!IMAGE_EXTS.has(fileExt)) { res.writeHead(403); res.end("Only image files"); return true; }
264
+ try {
265
+ var fileServeUserInfo = getOsUserInfoForReq(req);
266
+ var fileContent;
267
+ if (fileServeUserInfo) {
268
+ var binResult = fsAsUser("read_binary", { file: absFile }, fileServeUserInfo);
269
+ fileContent = binResult.buffer;
270
+ } else {
271
+ fileContent = fs.readFileSync(absFile);
272
+ }
273
+ var fileMime = MIME_TYPES[fileExt] || "application/octet-stream";
274
+ res.writeHead(200, { "Content-Type": fileMime, "Cache-Control": "no-cache" });
275
+ res.end(fileContent);
276
+ } catch (e) {
277
+ res.writeHead(404); res.end("Not found");
278
+ }
279
+ return true;
280
+ }
281
+
282
+ // Skills permission gate
283
+ if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
284
+ if (req._clayUser) {
285
+ var skPerms = usersModule.getEffectivePermissions(req._clayUser, osUsers);
286
+ if (!skPerms.skills) {
287
+ res.writeHead(403, { "Content-Type": "application/json" });
288
+ res.end('{"error":"Skills access is not permitted"}');
289
+ return true;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Install a skill (background spawn)
295
+ if (req.method === "POST" && urlPath === "/api/install-skill") {
296
+ parseJsonBody(req).then(function (body) {
297
+ var url = body.url;
298
+ var skill = body.skill;
299
+ var scope = body.scope; // "global" or "project"
300
+ if (!url || !skill || !scope) {
301
+ res.writeHead(400, { "Content-Type": "application/json" });
302
+ res.end('{"error":"missing url, skill, or scope"}');
303
+ return;
304
+ }
305
+ // Validate skill name: alphanumeric, hyphens, underscores only
306
+ if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
307
+ res.writeHead(400, { "Content-Type": "application/json" });
308
+ res.end('{"error":"invalid skill name"}');
309
+ return;
310
+ }
311
+ // Validate URL: must be https://
312
+ if (!/^https:\/\//i.test(url)) {
313
+ res.writeHead(400, { "Content-Type": "application/json" });
314
+ res.end('{"error":"only https:// URLs are allowed"}');
315
+ return;
316
+ }
317
+ var skillUserInfo = getOsUserInfoForReq(req);
318
+ var spawnCwd = scope === "global" ? (skillUserInfo ? skillUserInfo.home : require("./config").REAL_HOME) : cwd;
319
+ var scopeFlag = scope === "global" ? "--global" : "--project";
320
+ var skillSpawnOpts = {
321
+ cwd: spawnCwd,
322
+ stdio: ["ignore", "pipe", "pipe"],
323
+ detached: false,
324
+ };
325
+ if (skillUserInfo) {
326
+ skillSpawnOpts.uid = skillUserInfo.uid;
327
+ skillSpawnOpts.gid = skillUserInfo.gid;
328
+ skillSpawnOpts.env = Object.assign({}, process.env, {
329
+ HOME: skillUserInfo.home,
330
+ npm_config_cache: require("path").join(skillUserInfo.home, ".npm"),
331
+ });
332
+ }
333
+ console.log("[skill-install] spawning: npx skills add " + url + " --skill " + skill + " --yes " + scopeFlag + " (cwd: " + spawnCwd + ")");
334
+ var child = spawn("npx", ["skills", "add", url, "--skill", skill, "--yes", scopeFlag], skillSpawnOpts);
335
+ var stdoutBuf = "";
336
+ var stderrBuf = "";
337
+ child.stdout.on("data", function (chunk) {
338
+ stdoutBuf += chunk.toString();
339
+ console.log("[skill-install] " + skill + " stdout chunk: " + chunk.toString().trim().slice(0, 500));
340
+ });
341
+ child.stderr.on("data", function (chunk) {
342
+ stderrBuf += chunk.toString();
343
+ console.log("[skill-install] " + skill + " stderr chunk: " + chunk.toString().trim().slice(0, 500));
344
+ });
345
+ // Timeout after 60 seconds
346
+ var installTimeout = setTimeout(function () {
347
+ console.error("[skill-install] " + skill + " timed out after 60s, killing process");
348
+ try { child.kill("SIGTERM"); } catch (e) {}
349
+ try {
350
+ send({ type: "skill_installed", skill: skill, scope: scope, success: false, error: "Installation timed out after 60 seconds" });
351
+ } catch (e) {}
352
+ }, 60000);
353
+ child.on("close", function (code) {
354
+ clearTimeout(installTimeout);
355
+ console.log("[skill-install] " + skill + " exited with code " + code + " (stdout=" + stdoutBuf.length + "b, stderr=" + stderrBuf.length + "b)");
356
+ if (stdoutBuf) console.log("[skill-install] stdout: " + stdoutBuf.slice(0, 2000));
357
+ if (stderrBuf) console.log("[skill-install] stderr: " + stderrBuf.slice(0, 2000));
358
+ try {
359
+ var success = code === 0;
360
+ send({
361
+ type: "skill_installed",
362
+ skill: skill,
363
+ scope: scope,
364
+ success: success,
365
+ error: success ? null : "Process exited with code " + code,
366
+ });
367
+ } catch (e) {
368
+ console.error("[project] skill_installed send failed:", e.message || e);
369
+ }
370
+ });
371
+ child.on("error", function (err) {
372
+ clearTimeout(installTimeout);
373
+ console.error("[skill-install] " + skill + " spawn error:", err.message || err);
374
+ try {
375
+ send({
376
+ type: "skill_installed",
377
+ skill: skill,
378
+ scope: scope,
379
+ success: false,
380
+ error: err.message,
381
+ });
382
+ } catch (e) {
383
+ console.error("[skill-install] " + skill + " send failed:", e.message || e);
384
+ }
385
+ });
386
+ res.writeHead(200, { "Content-Type": "application/json" });
387
+ res.end('{"ok":true}');
388
+ }).catch(function () {
389
+ res.writeHead(400);
390
+ res.end("Bad request");
391
+ });
392
+ return true;
393
+ }
394
+
395
+ // Uninstall a skill (remove directory)
396
+ if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
397
+ parseJsonBody(req).then(function (body) {
398
+ var skill = body.skill;
399
+ var scope = body.scope; // "global" or "project"
400
+ if (!skill || !scope) {
401
+ res.writeHead(400, { "Content-Type": "application/json" });
402
+ res.end('{"error":"missing skill or scope"}');
403
+ return;
404
+ }
405
+ // Validate skill name
406
+ if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
407
+ res.writeHead(400, { "Content-Type": "application/json" });
408
+ res.end('{"error":"invalid skill name"}');
409
+ return;
410
+ }
411
+ var uninstallUserInfo = getOsUserInfoForReq(req);
412
+ var baseDir = scope === "global" ? (uninstallUserInfo ? uninstallUserInfo.home : require("./config").REAL_HOME) : cwd;
413
+ var skillDir = path.join(baseDir, ".claude", "skills", skill);
414
+ // Safety: ensure skillDir is inside the expected .claude/skills directory
415
+ var expectedParent = path.join(baseDir, ".claude", "skills");
416
+ var resolved = path.resolve(skillDir);
417
+ if (!resolved.startsWith(expectedParent + path.sep)) {
418
+ res.writeHead(403, { "Content-Type": "application/json" });
419
+ res.end('{"error":"invalid skill path"}');
420
+ return;
421
+ }
422
+ try {
423
+ if (uninstallUserInfo) {
424
+ // Run rm as target user to respect permissions
425
+ var rmScript = "var fs = require('fs'); fs.rmSync(" + JSON.stringify(resolved) + ", { recursive: true, force: true });";
426
+ execFileSync(process.execPath, ["-e", rmScript], {
427
+ uid: uninstallUserInfo.uid,
428
+ gid: uninstallUserInfo.gid,
429
+ timeout: 10000,
430
+ });
431
+ } else {
432
+ fs.rmSync(resolved, { recursive: true, force: true });
433
+ }
434
+ send({
435
+ type: "skill_uninstalled",
436
+ skill: skill,
437
+ scope: scope,
438
+ success: true,
439
+ });
440
+ res.writeHead(200, { "Content-Type": "application/json" });
441
+ res.end('{"ok":true}');
442
+ } catch (err) {
443
+ send({
444
+ type: "skill_uninstalled",
445
+ skill: skill,
446
+ scope: scope,
447
+ success: false,
448
+ error: err.message,
449
+ });
450
+ res.writeHead(500, { "Content-Type": "application/json" });
451
+ res.end(JSON.stringify({ error: err.message }));
452
+ }
453
+ }).catch(function () {
454
+ res.writeHead(400);
455
+ res.end("Bad request");
456
+ });
457
+ return true;
458
+ }
459
+
460
+ // Installed skills (global + project)
461
+ if (req.method === "GET" && urlPath === "/api/installed-skills") {
462
+ var installed = {};
463
+ var globalDir = path.join(require("./config").REAL_HOME, ".claude", "skills");
464
+ var projectDir = path.join(cwd, ".claude", "skills");
465
+ var scanDirs = [
466
+ { dir: globalDir, scope: "global" },
467
+ { dir: projectDir, scope: "project" },
468
+ ];
469
+ for (var sd = 0; sd < scanDirs.length; sd++) {
470
+ var entries;
471
+ try { entries = fs.readdirSync(scanDirs[sd].dir, { withFileTypes: true }); } catch (e) { continue; }
472
+ for (var si = 0; si < entries.length; si++) {
473
+ var ent = entries[si];
474
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
475
+ var mdPath = path.join(scanDirs[sd].dir, ent.name, "SKILL.md");
476
+ try {
477
+ var mdContent = fs.readFileSync(mdPath, "utf8");
478
+ var desc = "";
479
+ // Parse YAML frontmatter for description
480
+ var version = "";
481
+ if (mdContent.startsWith("---")) {
482
+ var endIdx = mdContent.indexOf("---", 3);
483
+ if (endIdx !== -1) {
484
+ var frontmatter = mdContent.substring(3, endIdx);
485
+ var descMatch = frontmatter.match(/^description:\s*(.+)/m);
486
+ if (descMatch) desc = descMatch[1].trim();
487
+ var verMatch = frontmatter.match(/version:\s*"?([^"\n]+)"?/m);
488
+ if (verMatch) version = verMatch[1].trim();
489
+ }
490
+ }
491
+ if (!installed[ent.name]) {
492
+ installed[ent.name] = { scope: scanDirs[sd].scope, description: desc, version: version, path: path.join(scanDirs[sd].dir, ent.name) };
493
+ } else {
494
+ // project-level adds to existing global entry
495
+ installed[ent.name].scope = "both";
496
+ if (desc && !installed[ent.name].description) installed[ent.name].description = desc;
497
+ if (version && !installed[ent.name].version) installed[ent.name].version = version;
498
+ }
499
+ } catch (e) {}
500
+ }
501
+ }
502
+ res.writeHead(200, { "Content-Type": "application/json" });
503
+ res.end(JSON.stringify({ installed: installed }));
504
+ return true;
505
+ }
506
+
507
+ // Check skill updates (compare installed vs remote versions)
508
+ if (req.method === "POST" && urlPath === "/api/check-skill-updates") {
509
+ parseJsonBody(req).then(function (body) {
510
+ var skills = body.skills; // [{ name, url, scope }]
511
+ if (!Array.isArray(skills) || skills.length === 0) {
512
+ res.writeHead(400, { "Content-Type": "application/json" });
513
+ res.end('{"error":"missing skills array"}');
514
+ return;
515
+ }
516
+ // Read installed versions (use requesting user's home in multi-user setups)
517
+ var skillUserHome = (function () {
518
+ var sui = getOsUserInfoForReq(req);
519
+ return sui ? sui.home : require("./config").REAL_HOME;
520
+ })();
521
+ var globalSkillsDir = path.join(skillUserHome, ".claude", "skills");
522
+ var projectSkillsDir = path.join(cwd, ".claude", "skills");
523
+ var results = [];
524
+ var pending = skills.length;
525
+
526
+ function parseVersionFromSkillMd(content) {
527
+ if (!content || !content.startsWith("---")) return "";
528
+ var endIdx = content.indexOf("---", 3);
529
+ if (endIdx === -1) return "";
530
+ var fm = content.substring(3, endIdx);
531
+ var m = fm.match(/version:\s*"?([^"\n]+)"?/m);
532
+ return m ? m[1].trim() : "";
533
+ }
534
+
535
+ function getInstalledVersion(name) {
536
+ var dirs = [path.join(globalSkillsDir, name, "SKILL.md"), path.join(projectSkillsDir, name, "SKILL.md")];
537
+ for (var d = 0; d < dirs.length; d++) {
538
+ try {
539
+ var c = fs.readFileSync(dirs[d], "utf8");
540
+ var v = parseVersionFromSkillMd(c);
541
+ if (v) return v;
542
+ } catch (e) {}
543
+ }
544
+ return "";
545
+ }
546
+
547
+ function compareVersions(a, b) {
548
+ // returns -1 if a < b, 0 if equal, 1 if a > b
549
+ if (!a && !b) return 0;
550
+ if (!a) return -1;
551
+ if (!b) return 1;
552
+ var pa = a.split(".").map(Number);
553
+ var pb = b.split(".").map(Number);
554
+ for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
555
+ var va = pa[i] || 0;
556
+ var vb = pb[i] || 0;
557
+ if (va < vb) return -1;
558
+ if (va > vb) return 1;
559
+ }
560
+ return 0;
561
+ }
562
+
563
+ function finishOne() {
564
+ pending--;
565
+ if (pending === 0) {
566
+ res.writeHead(200, { "Content-Type": "application/json" });
567
+ res.end(JSON.stringify({ results: results }));
568
+ }
569
+ }
570
+
571
+ for (var si = 0; si < skills.length; si++) {
572
+ (function (skill) {
573
+ var installedVer = getInstalledVersion(skill.name);
574
+ var installed = !!installedVer;
575
+ console.log("[skill-check] " + skill.name + " installed=" + installed + " localVersion=" + (installedVer || "none"));
576
+ // Convert GitHub repo URL to raw SKILL.md URL
577
+ var rawUrl = "";
578
+ var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
579
+ if (ghMatch) {
580
+ rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
581
+ }
582
+ if (!rawUrl) {
583
+ console.log("[skill-check] " + skill.name + " no valid GitHub URL, skipping remote check");
584
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
585
+ finishOne();
586
+ return;
587
+ }
588
+ console.log("[skill-check] " + skill.name + " fetching remote: " + rawUrl);
589
+ // Fetch remote SKILL.md
590
+ var https = require("https");
591
+ https.get(rawUrl, function (resp) {
592
+ console.log("[skill-check] " + skill.name + " remote response status=" + resp.statusCode);
593
+ var data = "";
594
+ resp.on("data", function (chunk) { data += chunk; });
595
+ resp.on("end", function () {
596
+ try {
597
+ var remoteVer = parseVersionFromSkillMd(data);
598
+ var status = "ok";
599
+ if (!installed) {
600
+ status = "missing";
601
+ } else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
602
+ status = "outdated";
603
+ }
604
+ console.log("[skill-check] " + skill.name + " remoteVersion=" + remoteVer + " status=" + status);
605
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status });
606
+ finishOne();
607
+ } catch (e) {
608
+ console.error("[skill-check] " + skill.name + " version parse failed:", e.message || e);
609
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "error" });
610
+ finishOne();
611
+ }
612
+ });
613
+ }).on("error", function (err) {
614
+ console.error("[skill-check] " + skill.name + " fetch error:", err.message || err);
615
+ results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
616
+ finishOne();
617
+ });
618
+ })(skills[si]);
619
+ }
620
+ }).catch(function () {
621
+ res.writeHead(400);
622
+ res.end("Bad request");
623
+ });
624
+ return true;
625
+ }
626
+
627
+ // Git dirty check
628
+ if (req.method === "GET" && urlPath === "/api/git-dirty") {
629
+ var execSync = require("child_process").execSync;
630
+ try {
631
+ var out = execSync("git status --porcelain", { cwd: cwd, encoding: "utf8", timeout: 5000 });
632
+ var dirty = out.trim().split("\n").some(function (line) {
633
+ return line.trim().length > 0 && !line.startsWith("??");
634
+ });
635
+ res.writeHead(200, { "Content-Type": "application/json" });
636
+ res.end(JSON.stringify({ dirty: dirty }));
637
+ } catch (e) {
638
+ res.writeHead(200, { "Content-Type": "application/json" });
639
+ res.end(JSON.stringify({ dirty: false }));
640
+ }
641
+ return true;
642
+ }
643
+
644
+ // List branches for worktree modal
645
+ if (req.method === "GET" && urlPath === "/api/branches") {
646
+ try {
647
+ var brRaw = execFileSync("git", ["branch", "-a", "--format=%(refname:short)"], {
648
+ cwd: cwd, timeout: 5000, encoding: "utf8"
649
+ });
650
+ var brList = brRaw.trim().split("\n").filter(Boolean);
651
+ var defBr = "main";
652
+ try {
653
+ var hrRef = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
654
+ cwd: cwd, timeout: 3000, encoding: "utf8"
655
+ }).trim();
656
+ defBr = hrRef.replace(/^origin\//, "");
657
+ } catch (e) {}
658
+ res.writeHead(200, { "Content-Type": "application/json" });
659
+ res.end(JSON.stringify({ branches: brList, defaultBranch: defBr }));
660
+ } catch (e) {
661
+ res.writeHead(200, { "Content-Type": "application/json" });
662
+ res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main" }));
663
+ }
664
+ return true;
665
+ }
666
+
667
+ // Info endpoint
668
+ if (req.method === "GET" && urlPath === "/info") {
669
+ res.writeHead(200, {
670
+ "Content-Type": "application/json",
671
+ "Access-Control-Allow-Origin": "*",
672
+ });
673
+ res.end(JSON.stringify({ cwd: cwd, project: project, slug: slug }));
674
+ return true;
675
+ }
676
+
677
+ return false; // not handled
678
+ }
679
+
680
+ return {
681
+ handleHTTP: handleHTTP,
682
+ };
683
+ }
684
+
685
+ module.exports = { attachHTTP: attachHTTP, parseJsonBody: parseJsonBody };