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/server.js CHANGED
@@ -8,10 +8,160 @@ var { createProjectContext } = require("./project");
8
8
 
9
9
  var { CONFIG_DIR } = require("./config");
10
10
 
11
+ var https = require("https");
12
+
11
13
  var publicDir = path.join(__dirname, "public");
12
14
  var bundledThemesDir = path.join(__dirname, "themes");
13
15
  var userThemesDir = path.join(CONFIG_DIR, "themes");
14
16
 
17
+ // --- Skills proxy cache & helpers ---
18
+ var skillsCache = {};
19
+
20
+ function httpGet(url) {
21
+ return new Promise(function (resolve, reject) {
22
+ var mod = url.startsWith("https") ? https : http;
23
+ mod.get(url, { headers: { "User-Agent": "Clay/1.0" } }, function (resp) {
24
+ if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
25
+ return httpGet(resp.headers.location).then(resolve, reject);
26
+ }
27
+ var chunks = [];
28
+ resp.on("data", function (c) { chunks.push(c); });
29
+ resp.on("end", function () { resolve(Buffer.concat(chunks).toString("utf8")); });
30
+ resp.on("error", reject);
31
+ }).on("error", reject);
32
+ });
33
+ }
34
+
35
+ function fetchSkillsPage(url) {
36
+ return httpGet(url).then(function (html) {
37
+ // Data is inside self.__next_f.push() with escaped quotes: \"initialSkills\":[{\"source\":...}]
38
+ var marker = 'initialSkills';
39
+ var idx = html.indexOf(marker);
40
+ if (idx < 0) return { skills: [] };
41
+
42
+ // Find the start of the array: look for \\\":[
43
+ var arrStart = html.indexOf(':[', idx);
44
+ if (arrStart < 0) return { skills: [] };
45
+ arrStart += 1; // point to '['
46
+
47
+ // Find matching ']' — track bracket depth
48
+ var depth = 0;
49
+ var arrEnd = -1;
50
+ for (var i = arrStart; i < html.length; i++) {
51
+ var ch = html[i];
52
+ if (ch === '[') depth++;
53
+ else if (ch === ']') {
54
+ depth--;
55
+ if (depth === 0) { arrEnd = i + 1; break; }
56
+ }
57
+ }
58
+ if (arrEnd < 0) return { skills: [] };
59
+
60
+ var raw = html.substring(arrStart, arrEnd);
61
+ // Unescape: \\\" → " and \\\\ → backslash
62
+ var unescaped = raw.replace(/\\\\"/g, '__BSLASH_QUOTE__').replace(/\\"/g, '"').replace(/__BSLASH_QUOTE__/g, '\\"');
63
+
64
+ try {
65
+ return { skills: JSON.parse(unescaped) };
66
+ } catch (e) {
67
+ return { skills: [] };
68
+ }
69
+ });
70
+ }
71
+
72
+ function fetchSkillDetail(url) {
73
+ return httpGet(url).then(function (html) {
74
+ var result = {};
75
+
76
+ // Title: "skill-name by owner/repo"
77
+ var titleMatch = html.match(/<title>([^<]+)<\/title>/);
78
+ if (titleMatch) {
79
+ var parts = titleMatch[1].split(" by ");
80
+ result.name = parts[0].trim();
81
+ }
82
+
83
+ // Description from meta
84
+ var descMatch = html.match(/meta name="description" content="([^"]+)"/);
85
+ if (descMatch) result.description = descMatch[1];
86
+
87
+ // Install command
88
+ var cmdMatch = html.match(/npx skills add [^ ]+ --skill [^ "<]+/);
89
+ if (cmdMatch) result.command = cmdMatch[0];
90
+
91
+ // Weekly installs: "Weekly Installs</span></div><div ...>VALUE</div>"
92
+ var wiMatch = html.match(/Weekly Installs<\/span><\/div><div[^>]*>([\d,.]+K?)<\/div>/);
93
+ if (wiMatch) result.weeklyInstalls = wiMatch[1];
94
+
95
+ // GitHub Stars: after SVG icon, inside <span>X.XK</span>
96
+ var gsIdx = html.indexOf("GitHub Stars");
97
+ if (gsIdx > 0) {
98
+ var gsRegion = html.substring(gsIdx, gsIdx + 1000);
99
+ var gsVal = gsRegion.match(/<span>(\d[\d,.]*K?)<\/span>/);
100
+ if (gsVal) result.githubStars = gsVal[1];
101
+ }
102
+
103
+ // First Seen
104
+ var fsMatch = html.match(/First Seen<\/span><\/div><div[^>]*>([^<]+)<\/div>/);
105
+ if (fsMatch) result.firstSeen = fsMatch[1].trim();
106
+
107
+ // Repository: from title "by owner/repo"
108
+ if (titleMatch) {
109
+ var byParts = titleMatch[1].split(" by ");
110
+ if (byParts[1]) result.repository = byParts[1].trim();
111
+ }
112
+
113
+ // Security audits: "text-foreground truncate">NAME</span><span ...>STATUS</span>"
114
+ var audits = [];
115
+ var auditRegex = /class="text-sm font-medium text-foreground truncate">([^<]+)<\/span><span class="[^"]*">(\w+)<\/span>/g;
116
+ var am;
117
+ while ((am = auditRegex.exec(html)) !== null) {
118
+ audits.push({ name: am[1], status: am[2].toLowerCase() });
119
+ }
120
+ if (audits.length) result.audits = audits;
121
+
122
+ // Installed on: "text-foreground">NAME</span><span class="text-muted-foreground font-mono">COUNT</span>
123
+ var ioIdx = html.indexOf("Installed On");
124
+ if (ioIdx > 0) {
125
+ var ioRegion = html.substring(ioIdx, ioIdx + 3000);
126
+ var platforms = [];
127
+ var platRegex = /text-foreground">([^<]+)<\/span><span class="text-muted-foreground font-mono">([\d,.]+K?)<\/span>/g;
128
+ var pm;
129
+ while ((pm = platRegex.exec(ioRegion)) !== null) {
130
+ platforms.push({ name: pm[1], installs: pm[2] });
131
+ }
132
+ if (platforms.length) result.installedOn = platforms;
133
+ }
134
+
135
+ // SKILL.md content: rendered HTML inside the main content area
136
+ var skillMdIdx = html.indexOf("SKILL.md");
137
+ if (skillMdIdx > 0) {
138
+ // Find the prose content div after SKILL.md marker
139
+ var proseIdx = html.indexOf("prose", skillMdIdx);
140
+ if (proseIdx > 0) {
141
+ var proseStart = html.indexOf(">", proseIdx) + 1;
142
+ // Find the closing of the prose div (heuristic: next major section boundary)
143
+ var endMarkers = ["<div class=\"bg-background", "<div class=\"sticky"];
144
+ var proseEnd = html.length;
145
+ for (var em = 0; em < endMarkers.length; em++) {
146
+ var endIdx = html.indexOf(endMarkers[em], proseStart);
147
+ if (endIdx > 0 && endIdx < proseEnd) proseEnd = endIdx;
148
+ }
149
+ var rawMd = html.substring(proseStart, proseEnd);
150
+ // Rebase relative URLs to absolute (skills.sh base)
151
+ result.skillMd = rawMd
152
+ .replace(/src="(?!https?:\/\/|data:)([^"]+)"/g, function (m, p) {
153
+ return 'src="' + new URL(p, url).href + '"';
154
+ })
155
+ .replace(/href="(?!https?:\/\/|mailto:|#)([^"]+)"/g, function (m, p) {
156
+ return 'href="' + new URL(p, url).href + '"';
157
+ });
158
+ }
159
+ }
160
+
161
+ return result;
162
+ });
163
+ }
164
+
15
165
  var MIME_TYPES = {
16
166
  ".html": "text/html",
17
167
  ".css": "text/css",
@@ -130,6 +280,21 @@ function createServer(opts) {
130
280
  var lanHost = opts.lanHost || null;
131
281
  var onAddProject = opts.onAddProject || null;
132
282
  var onRemoveProject = opts.onRemoveProject || null;
283
+ var onReorderProjects = opts.onReorderProjects || null;
284
+ var onSetProjectTitle = opts.onSetProjectTitle || null;
285
+ var onSetProjectIcon = opts.onSetProjectIcon || null;
286
+ var onGetServerDefaultEffort = opts.onGetServerDefaultEffort || null;
287
+ var onSetServerDefaultEffort = opts.onSetServerDefaultEffort || null;
288
+ var onGetProjectDefaultEffort = opts.onGetProjectDefaultEffort || null;
289
+ var onSetProjectDefaultEffort = opts.onSetProjectDefaultEffort || null;
290
+ var onGetServerDefaultModel = opts.onGetServerDefaultModel || null;
291
+ var onSetServerDefaultModel = opts.onSetServerDefaultModel || null;
292
+ var onGetProjectDefaultModel = opts.onGetProjectDefaultModel || null;
293
+ var onSetProjectDefaultModel = opts.onSetProjectDefaultModel || null;
294
+ var onGetServerDefaultMode = opts.onGetServerDefaultMode || null;
295
+ var onSetServerDefaultMode = opts.onSetServerDefaultMode || null;
296
+ var onGetProjectDefaultMode = opts.onGetProjectDefaultMode || null;
297
+ var onSetProjectDefaultMode = opts.onSetProjectDefaultMode || null;
133
298
  var onGetDaemonConfig = opts.onGetDaemonConfig || null;
134
299
  var onSetPin = opts.onSetPin || null;
135
300
  var onSetKeepAwake = opts.onSetKeepAwake || null;
@@ -286,6 +451,89 @@ function createServer(opts) {
286
451
  return;
287
452
  }
288
453
 
454
+ // Skills proxy: leaderboard list
455
+ if (req.method === "GET" && fullUrl === "/api/skills") {
456
+ var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
457
+ var tabParam = new URLSearchParams(qs).get("tab") || "all";
458
+ var tabPath = tabParam === "trending" ? "/trending" : tabParam === "hot" ? "/hot" : "/";
459
+ var cacheKey = "skills_" + tabParam;
460
+ var cached = skillsCache[cacheKey];
461
+ if (cached && Date.now() - cached.ts < 300000) {
462
+ res.writeHead(200, { "Content-Type": "application/json" });
463
+ res.end(cached.data);
464
+ return;
465
+ }
466
+ fetchSkillsPage("https://skills.sh" + tabPath).then(function (data) {
467
+ var json = JSON.stringify(data);
468
+ skillsCache[cacheKey] = { ts: Date.now(), data: json };
469
+ res.writeHead(200, { "Content-Type": "application/json" });
470
+ res.end(json);
471
+ }).catch(function (err) {
472
+ res.writeHead(502, { "Content-Type": "application/json" });
473
+ res.end(JSON.stringify({ error: "Failed to fetch skills: " + (err.message || err) }));
474
+ });
475
+ return;
476
+ }
477
+
478
+ // Skills proxy: search
479
+ if (req.method === "GET" && fullUrl.startsWith("/api/skills/search")) {
480
+ var sqsRaw = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
481
+ var searchQ = new URLSearchParams(sqsRaw).get("q") || "";
482
+ if (!searchQ) {
483
+ res.writeHead(400, { "Content-Type": "application/json" });
484
+ res.end('{"error":"missing q param"}');
485
+ return;
486
+ }
487
+ var searchCacheKey = "search_" + searchQ.toLowerCase();
488
+ var searchCached = skillsCache[searchCacheKey];
489
+ if (searchCached && Date.now() - searchCached.ts < 300000) {
490
+ res.writeHead(200, { "Content-Type": "application/json" });
491
+ res.end(searchCached.data);
492
+ return;
493
+ }
494
+ fetchSkillsPage("https://skills.sh/?q=" + encodeURIComponent(searchQ)).then(function (data) {
495
+ var json = JSON.stringify(data);
496
+ skillsCache[searchCacheKey] = { ts: Date.now(), data: json };
497
+ res.writeHead(200, { "Content-Type": "application/json" });
498
+ res.end(json);
499
+ }).catch(function (err) {
500
+ res.writeHead(502, { "Content-Type": "application/json" });
501
+ res.end(JSON.stringify({ error: "Failed to search skills: " + (err.message || err) }));
502
+ });
503
+ return;
504
+ }
505
+
506
+ // Skills proxy: skill detail
507
+ if (req.method === "GET" && fullUrl.startsWith("/api/skills/detail")) {
508
+ var qs2 = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
509
+ var params2 = new URLSearchParams(qs2);
510
+ var detailSource = params2.get("source");
511
+ var detailSkill = params2.get("skill");
512
+ if (!detailSource || !detailSkill) {
513
+ res.writeHead(400, { "Content-Type": "application/json" });
514
+ res.end('{"error":"missing source or skill param"}');
515
+ return;
516
+ }
517
+ var detailCacheKey = "detail_" + detailSource + "_" + detailSkill;
518
+ var detailCached = skillsCache[detailCacheKey];
519
+ if (detailCached && Date.now() - detailCached.ts < 300000) {
520
+ res.writeHead(200, { "Content-Type": "application/json" });
521
+ res.end(detailCached.data);
522
+ return;
523
+ }
524
+ var detailUrl = "https://skills.sh/" + encodeURIComponent(detailSource).replace(/%2F/g, "/") + "/" + encodeURIComponent(detailSkill);
525
+ fetchSkillDetail(detailUrl).then(function (data) {
526
+ var json = JSON.stringify(data);
527
+ skillsCache[detailCacheKey] = { ts: Date.now(), data: json };
528
+ res.writeHead(200, { "Content-Type": "application/json" });
529
+ res.end(json);
530
+ }).catch(function (err) {
531
+ res.writeHead(502, { "Content-Type": "application/json" });
532
+ res.end(JSON.stringify({ error: "Failed to fetch skill detail: " + (err.message || err) }));
533
+ });
534
+ return;
535
+ }
536
+
289
537
  // Root path — redirect to first project
290
538
  if (fullUrl === "/" && req.method === "GET") {
291
539
  if (!isAuthed(req, authToken)) {
@@ -494,13 +742,28 @@ function createServer(opts) {
494
742
  });
495
743
  });
496
744
 
745
+ // --- Debounced broadcast for processing status changes ---
746
+ var processingUpdateTimer = null;
747
+ function broadcastProcessingChange() {
748
+ if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
749
+ processingUpdateTimer = setTimeout(function () {
750
+ processingUpdateTimer = null;
751
+ broadcastAll({
752
+ type: "projects_updated",
753
+ projects: getProjects(),
754
+ projectCount: projects.size,
755
+ });
756
+ }, 200);
757
+ }
758
+
497
759
  // --- Project management ---
498
- function addProject(cwd, slug, title) {
760
+ function addProject(cwd, slug, title, icon) {
499
761
  if (projects.has(slug)) return false;
500
762
  var ctx = createProjectContext({
501
763
  cwd: cwd,
502
764
  slug: slug,
503
765
  title: title || null,
766
+ icon: icon || null,
504
767
  pushModule: pushModule,
505
768
  debug: debug,
506
769
  dangerouslySkipPermissions: dangerouslySkipPermissions,
@@ -512,8 +775,24 @@ function createServer(opts) {
512
775
  projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
513
776
  return list;
514
777
  },
778
+ onProcessingChanged: broadcastProcessingChange,
515
779
  onAddProject: onAddProject,
516
780
  onRemoveProject: onRemoveProject,
781
+ onReorderProjects: onReorderProjects,
782
+ onSetProjectTitle: onSetProjectTitle,
783
+ onSetProjectIcon: onSetProjectIcon,
784
+ onGetServerDefaultEffort: onGetServerDefaultEffort,
785
+ onSetServerDefaultEffort: onSetServerDefaultEffort,
786
+ onGetProjectDefaultEffort: onGetProjectDefaultEffort,
787
+ onSetProjectDefaultEffort: onSetProjectDefaultEffort,
788
+ onGetServerDefaultModel: onGetServerDefaultModel,
789
+ onSetServerDefaultModel: onSetServerDefaultModel,
790
+ onGetProjectDefaultModel: onGetProjectDefaultModel,
791
+ onSetProjectDefaultModel: onSetProjectDefaultModel,
792
+ onGetServerDefaultMode: onGetServerDefaultMode,
793
+ onSetServerDefaultMode: onSetServerDefaultMode,
794
+ onGetProjectDefaultMode: onGetProjectDefaultMode,
795
+ onSetProjectDefaultMode: onSetProjectDefaultMode,
517
796
  onGetDaemonConfig: onGetDaemonConfig,
518
797
  onSetPin: onSetPin,
519
798
  onSetKeepAwake: onSetKeepAwake,
@@ -540,6 +819,22 @@ function createServer(opts) {
540
819
  return list;
541
820
  }
542
821
 
822
+ function reorderProjects(slugs) {
823
+ var ordered = new Map();
824
+ for (var i = 0; i < slugs.length; i++) {
825
+ var ctx = projects.get(slugs[i]);
826
+ if (ctx) ordered.set(slugs[i], ctx);
827
+ }
828
+ // Append any remaining (safety)
829
+ projects.forEach(function (ctx, slug) {
830
+ if (!ordered.has(slug)) ordered.set(slug, ctx);
831
+ });
832
+ projects.clear();
833
+ ordered.forEach(function (ctx, slug) {
834
+ projects.set(slug, ctx);
835
+ });
836
+ }
837
+
543
838
  function setProjectTitle(slug, title) {
544
839
  var ctx = projects.get(slug);
545
840
  if (!ctx) return false;
@@ -547,6 +842,13 @@ function createServer(opts) {
547
842
  return true;
548
843
  }
549
844
 
845
+ function setProjectIcon(slug, icon) {
846
+ var ctx = projects.get(slug);
847
+ if (!ctx) return false;
848
+ ctx.setIcon(icon);
849
+ return true;
850
+ }
851
+
550
852
  function setAuthToken(hash) {
551
853
  authToken = hash;
552
854
  }
@@ -572,7 +874,9 @@ function createServer(opts) {
572
874
  addProject: addProject,
573
875
  removeProject: removeProject,
574
876
  getProjects: getProjects,
877
+ reorderProjects: reorderProjects,
575
878
  setProjectTitle: setProjectTitle,
879
+ setProjectIcon: setProjectIcon,
576
880
  setAuthToken: setAuthToken,
577
881
  broadcastAll: broadcastAll,
578
882
  destroyAll: destroyAll,
package/lib/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const config = require("./config");
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var config = require("./config");
4
+ var utils = require("./utils");
4
5
 
5
6
  function createSessionManager(opts) {
6
7
  var cwd = opts.cwd;
@@ -15,7 +16,7 @@ function createSessionManager(opts) {
15
16
  var skillNames = null; // Claude-only skills to filter from slash menu
16
17
 
17
18
  // --- Session persistence (centralized in ~/.clay/sessions/{encoded-cwd}/) ---
18
- var encodedCwd = cwd.replace(/\//g, "-");
19
+ var encodedCwd = utils.encodeCwd(cwd);
19
20
  var sessionsDir = path.join(config.CONFIG_DIR, "sessions", encodedCwd);
20
21
  fs.mkdirSync(sessionsDir, { recursive: true });
21
22
 
@@ -310,6 +311,10 @@ function createSessionManager(opts) {
310
311
  appendToSessionFile(session, obj);
311
312
  if (session.localId === activeSessionId) {
312
313
  send(obj);
314
+ } else if (session.isProcessing && !session._ioThrottle) {
315
+ session._ioThrottle = true;
316
+ send({ type: "session_io", id: session.localId });
317
+ setTimeout(function () { session._ioThrottle = false; }, 80);
313
318
  }
314
319
  }
315
320
 
package/lib/utils.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared utility functions.
3
+ */
4
+
5
+ /**
6
+ * Encode a cwd path into a filesystem-safe directory/file name.
7
+ * Replaces forward slashes and dots with hyphens so that usernames
8
+ * like "jon.doe" don't break session/note lookups.
9
+ *
10
+ * Example: "/Users/jon.doe/my-project" -> "-Users-jon-doe-my-project"
11
+ */
12
+ function encodeCwd(cwd) {
13
+ return cwd.replace(/[\/\.]/g, "-");
14
+ }
15
+
16
+ module.exports = {
17
+ encodeCwd: encodeCwd,
18
+ };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
- "clay-server": "./bin/cli.js"
6
+ "clay-server": "./bin/cli.js",
7
+ "claude-relay": "./bin/claude-relay.js"
7
8
  },
8
9
  "scripts": {
9
10
  "dev": "node bin/cli.js --dev",