claudeck 1.1.1 → 1.2.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.
Files changed (37) hide show
  1. package/README.md +30 -4
  2. package/config/skillsmp-config.json +5 -0
  3. package/db.js +248 -0
  4. package/package.json +11 -2
  5. package/public/css/panels/git-panel.css +220 -0
  6. package/public/css/panels/skills-manager.css +975 -0
  7. package/public/css/ui/input-history.css +109 -0
  8. package/public/css/ui/messages.css +51 -0
  9. package/public/css/ui/notification-bell.css +421 -0
  10. package/public/css/ui/sessions.css +41 -0
  11. package/public/css/ui/worktree.css +442 -0
  12. package/public/index.html +43 -10
  13. package/public/js/core/api.js +83 -0
  14. package/public/js/core/dom.js +15 -0
  15. package/public/js/features/background-sessions.js +11 -0
  16. package/public/js/features/chat.js +501 -3
  17. package/public/js/features/input-history.js +122 -0
  18. package/public/js/features/projects.js +16 -1
  19. package/public/js/features/sessions.js +77 -30
  20. package/public/js/main.js +3 -0
  21. package/public/js/panels/git-panel.js +385 -6
  22. package/public/js/panels/skills-manager.js +1005 -0
  23. package/public/js/ui/messages.js +58 -0
  24. package/public/js/ui/notification-bell.js +240 -0
  25. package/public/js/ui/notification-history.js +210 -0
  26. package/public/js/ui/parallel.js +11 -0
  27. package/public/js/ui/tab-sdk.js +1 -1
  28. package/public/style.css +4 -0
  29. package/server/agent-loop.js +13 -0
  30. package/server/notification-logger.js +27 -0
  31. package/server/routes/notifications.js +57 -1
  32. package/server/routes/sessions.js +41 -0
  33. package/server/routes/skills.js +454 -0
  34. package/server/routes/worktrees.js +93 -0
  35. package/server/utils/git-worktree.js +297 -0
  36. package/server/ws-handler.js +708 -629
  37. package/server.js +17 -1
@@ -1,5 +1,15 @@
1
1
  import { Router } from "express";
2
- import { upsertPushSubscription, deletePushSubscription } from "../../db.js";
2
+ import {
3
+ upsertPushSubscription,
4
+ deletePushSubscription,
5
+ getNotificationHistory,
6
+ getUnreadNotificationCount,
7
+ markNotificationsRead,
8
+ markAllNotificationsRead,
9
+ markNotificationsReadBefore,
10
+ purgeOldNotifications,
11
+ } from "../../db.js";
12
+ import { logNotification, broadcastReadUpdate } from "../notification-logger.js";
3
13
 
4
14
  const router = Router();
5
15
 
@@ -34,4 +44,50 @@ router.post("/unsubscribe", (req, res) => {
34
44
  res.json({ ok: true });
35
45
  });
36
46
 
47
+ // ── Create notification (from frontend) ───────────────────
48
+ router.post("/create", (req, res) => {
49
+ const { type, title, body, metadata, sourceSessionId, sourceAgentId } = req.body;
50
+ if (!type || !title) {
51
+ return res.status(400).json({ error: "type and title are required" });
52
+ }
53
+ const notification = logNotification(type, title, body || null, metadata || null, sourceSessionId || null, sourceAgentId || null);
54
+ res.json(notification);
55
+ });
56
+
57
+ // ── Notification history & management ─────────────────────
58
+ router.get("/history", (req, res) => {
59
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
60
+ const offset = parseInt(req.query.offset) || 0;
61
+ const unreadOnly = req.query.unread_only === "true";
62
+ const type = req.query.type || null;
63
+ const items = getNotificationHistory(limit, offset, unreadOnly, type);
64
+ res.json(items);
65
+ });
66
+
67
+ router.get("/unread-count", (_req, res) => {
68
+ res.json({ count: getUnreadNotificationCount() });
69
+ });
70
+
71
+ router.post("/read", (req, res) => {
72
+ const { ids, all, before } = req.body;
73
+ if (all) {
74
+ markAllNotificationsRead();
75
+ broadcastReadUpdate([]);
76
+ } else if (before) {
77
+ markNotificationsReadBefore(before);
78
+ broadcastReadUpdate([]);
79
+ } else if (Array.isArray(ids) && ids.length > 0) {
80
+ markNotificationsRead(ids);
81
+ broadcastReadUpdate(ids);
82
+ } else {
83
+ return res.status(400).json({ error: "Provide ids, all, or before" });
84
+ }
85
+ res.json({ ok: true, unreadCount: getUnreadNotificationCount() });
86
+ });
87
+
88
+ router.delete("/old", (_req, res) => {
89
+ purgeOldNotifications(90);
90
+ res.json({ ok: true });
91
+ });
92
+
37
93
  export default router;
@@ -5,6 +5,10 @@ import {
5
5
  updateSessionTitle,
6
6
  toggleSessionPin,
7
7
  searchSessions,
8
+ forkSession as dbForkSession,
9
+ getSession,
10
+ getSessionBranches,
11
+ getSessionLineage,
8
12
  } from "../../db.js";
9
13
  import { getActiveSessionIds } from "../ws-handler.js";
10
14
  import { generateSessionSummary } from "../summarizer.js";
@@ -100,4 +104,41 @@ router.post("/:id/summary", async (req, res) => {
100
104
  }
101
105
  });
102
106
 
107
+ // ── Session Branching / Forking ─────────────────────────
108
+
109
+ // Fork a session at a given message
110
+ router.post("/:id/fork", (req, res) => {
111
+ try {
112
+ const { messageId } = req.body || {};
113
+ const session = getSession(req.params.id);
114
+ if (!session) return res.status(404).json({ error: "Session not found" });
115
+ if (messageId != null && (typeof messageId !== "number" || messageId < 1)) {
116
+ return res.status(400).json({ error: "Invalid messageId" });
117
+ }
118
+ const forked = dbForkSession(req.params.id, messageId || null);
119
+ res.json(forked);
120
+ } catch (err) {
121
+ const status = err.message === "No messages to fork" || err.message === "Session not found" ? 400 : 500;
122
+ res.status(status).json({ error: err.message });
123
+ }
124
+ });
125
+
126
+ // List direct child forks of a session
127
+ router.get("/:id/branches", (req, res) => {
128
+ try {
129
+ res.json(getSessionBranches(req.params.id));
130
+ } catch (err) {
131
+ res.status(500).json({ error: err.message });
132
+ }
133
+ });
134
+
135
+ // Get full ancestor chain + siblings
136
+ router.get("/:id/lineage", (req, res) => {
137
+ try {
138
+ res.json(getSessionLineage(req.params.id));
139
+ } catch (err) {
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ });
143
+
103
144
  export default router;
@@ -0,0 +1,454 @@
1
+ // Skills Marketplace — SkillsMP integration routes
2
+ import { Router } from "express";
3
+ import { configPath } from "../paths.js";
4
+ import { readFile, writeFile, readdir, stat, mkdir, rm, rename } from "fs/promises";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { existsSync } from "fs";
8
+
9
+ const router = Router();
10
+
11
+ const SKILLSMP_BASE = "https://skillsmp.com/api/v1";
12
+ const KEY_PREFIX = "sk_live_skillsmp_";
13
+
14
+ // ── Helpers ─────────────────────────────────────────────
15
+
16
+ async function readSkillsConfig() {
17
+ try {
18
+ const raw = await readFile(configPath("skillsmp-config.json"), "utf-8");
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return { apiKey: "", defaultScope: "project", searchMode: "keyword" };
22
+ }
23
+ }
24
+
25
+ async function writeSkillsConfig(config) {
26
+ await writeFile(configPath("skillsmp-config.json"), JSON.stringify(config, null, 2));
27
+ }
28
+
29
+ function isActivated(config) {
30
+ return typeof config.apiKey === "string" && config.apiKey.startsWith(KEY_PREFIX);
31
+ }
32
+
33
+ function maskKey(key) {
34
+ if (!key || !key.startsWith(KEY_PREFIX)) return "";
35
+ return KEY_PREFIX + "..." + key.slice(-4);
36
+ }
37
+
38
+ // ── Middleware: require valid API key ────────────────────
39
+
40
+ async function requireApiKey(req, res, next) {
41
+ const config = await readSkillsConfig();
42
+ if (!isActivated(config)) {
43
+ return res.status(403).json({
44
+ error: "Skills Marketplace not activated. Add your API key to get started.",
45
+ code: "NO_API_KEY",
46
+ });
47
+ }
48
+ req.skillsConfig = config;
49
+ next();
50
+ }
51
+
52
+ // ── Config endpoints (always accessible) ────────────────
53
+
54
+ router.get("/config", async (_req, res) => {
55
+ try {
56
+ const config = await readSkillsConfig();
57
+ res.json({
58
+ activated: isActivated(config),
59
+ apiKey: maskKey(config.apiKey),
60
+ defaultScope: config.defaultScope || "project",
61
+ searchMode: config.searchMode || "keyword",
62
+ });
63
+ } catch (err) {
64
+ res.status(500).json({ error: err.message });
65
+ }
66
+ });
67
+
68
+ router.put("/config", async (req, res) => {
69
+ try {
70
+ const { apiKey, defaultScope, searchMode } = req.body;
71
+ const config = await readSkillsConfig();
72
+
73
+ // Update non-key fields if provided
74
+ if (defaultScope) config.defaultScope = defaultScope;
75
+ if (searchMode) config.searchMode = searchMode;
76
+
77
+ // Handle API key change
78
+ if (apiKey !== undefined) {
79
+ if (apiKey === "") {
80
+ // Deactivate
81
+ config.apiKey = "";
82
+ await writeSkillsConfig(config);
83
+ return res.json({ success: true, activated: false });
84
+ }
85
+
86
+ // Validate format
87
+ if (!apiKey.startsWith(KEY_PREFIX)) {
88
+ return res.status(400).json({
89
+ error: "Invalid API key format. Key must start with 'sk_live_skillsmp_'",
90
+ code: "INVALID_KEY",
91
+ });
92
+ }
93
+
94
+ // Test the key with a lightweight call
95
+ try {
96
+ const testRes = await fetch(
97
+ `${SKILLSMP_BASE}/skills/search?q=test&limit=1`,
98
+ { headers: { Authorization: `Bearer ${apiKey}` } }
99
+ );
100
+ if (!testRes.ok) {
101
+ const body = await testRes.json().catch(() => ({}));
102
+ if (testRes.status === 401 || body?.error?.code === "INVALID_API_KEY") {
103
+ return res.status(400).json({ error: "Invalid API key", code: "INVALID_KEY" });
104
+ }
105
+ return res.status(400).json({
106
+ error: body?.error?.message || "API key validation failed",
107
+ code: "VALIDATION_FAILED",
108
+ });
109
+ }
110
+ } catch (fetchErr) {
111
+ return res.status(400).json({
112
+ error: "Could not reach SkillsMP to validate key: " + fetchErr.message,
113
+ code: "NETWORK_ERROR",
114
+ });
115
+ }
116
+
117
+ config.apiKey = apiKey;
118
+ }
119
+
120
+ await writeSkillsConfig(config);
121
+ res.json({ success: true, activated: isActivated(config) });
122
+ } catch (err) {
123
+ res.status(500).json({ error: err.message });
124
+ }
125
+ });
126
+
127
+ // ── Search endpoints (gated) ────────────────────────────
128
+
129
+ router.get("/search", requireApiKey, async (req, res) => {
130
+ try {
131
+ const { q, page, limit, sortBy } = req.query;
132
+ if (!q) return res.status(400).json({ error: "Search query (q) is required" });
133
+
134
+ const params = new URLSearchParams({ q });
135
+ if (page) params.set("page", page);
136
+ if (limit) params.set("limit", limit);
137
+ if (sortBy) params.set("sortBy", sortBy);
138
+
139
+ const apiRes = await fetch(`${SKILLSMP_BASE}/skills/search?${params}`, {
140
+ headers: { Authorization: `Bearer ${req.skillsConfig.apiKey}` },
141
+ });
142
+
143
+ const body = await apiRes.json();
144
+
145
+ // Forward rate limit headers
146
+ const remaining = apiRes.headers.get("X-RateLimit-Daily-Remaining");
147
+ const dailyLimit = apiRes.headers.get("X-RateLimit-Daily-Limit");
148
+ if (remaining) res.set("X-RateLimit-Daily-Remaining", remaining);
149
+ if (dailyLimit) res.set("X-RateLimit-Daily-Limit", dailyLimit);
150
+
151
+ res.status(apiRes.status).json(body);
152
+ } catch (err) {
153
+ res.status(500).json({ error: err.message });
154
+ }
155
+ });
156
+
157
+ router.get("/ai-search", requireApiKey, async (req, res) => {
158
+ try {
159
+ const { q } = req.query;
160
+ if (!q) return res.status(400).json({ error: "Search query (q) is required" });
161
+
162
+ const apiRes = await fetch(`${SKILLSMP_BASE}/skills/ai-search?q=${encodeURIComponent(q)}`, {
163
+ headers: { Authorization: `Bearer ${req.skillsConfig.apiKey}` },
164
+ });
165
+
166
+ const body = await apiRes.json();
167
+
168
+ const remaining = apiRes.headers.get("X-RateLimit-Daily-Remaining");
169
+ const dailyLimit = apiRes.headers.get("X-RateLimit-Daily-Limit");
170
+ if (remaining) res.set("X-RateLimit-Daily-Remaining", remaining);
171
+ if (dailyLimit) res.set("X-RateLimit-Daily-Limit", dailyLimit);
172
+
173
+ res.status(apiRes.status).json(body);
174
+ } catch (err) {
175
+ res.status(500).json({ error: err.message });
176
+ }
177
+ });
178
+
179
+ // ── GitHub URL parsing ──────────────────────────────────
180
+
181
+ export function parseGithubUrl(githubUrl) {
182
+ // https://github.com/owner/repo/tree/branch/path/to/skill
183
+ const match = githubUrl.match(
184
+ /github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/
185
+ );
186
+ if (!match) return null;
187
+ return { owner: match[1], repo: match[2], branch: match[3], path: match[4] };
188
+ }
189
+
190
+ function buildRawUrl(owner, repo, branch, path, filename) {
191
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}/${filename}`;
192
+ }
193
+
194
+ async function fetchSkillFiles(owner, repo, branch, skillPath) {
195
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${skillPath}?ref=${branch}`;
196
+ const res = await fetch(url, {
197
+ headers: { Accept: "application/vnd.github.v3+json" },
198
+ });
199
+ if (!res.ok) {
200
+ if (res.status === 403) throw new Error("GitHub API rate limit exceeded");
201
+ throw new Error(`GitHub API error: ${res.status}`);
202
+ }
203
+ const items = await res.json();
204
+ if (!Array.isArray(items)) return [];
205
+ return items
206
+ .filter((i) => i.type === "file")
207
+ .map((i) => ({ name: i.name, download_url: i.download_url, type: i.type }));
208
+ }
209
+
210
+ // ── Install endpoint (gated) ────────────────────────────
211
+
212
+ const VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
213
+
214
+ // Normalize a skill name to a valid directory name: lowercase, alphanumeric + hyphens
215
+ function normalizeName(name) {
216
+ return name
217
+ .toLowerCase()
218
+ .replace(/[_\s]+/g, "-") // underscores/spaces → hyphens
219
+ .replace(/[^a-z0-9-]/g, "") // strip invalid chars
220
+ .replace(/^-+|-+$/g, "") // trim leading/trailing hyphens
221
+ || "skill";
222
+ }
223
+
224
+ // Ensure SKILL.md has frontmatter with name/description (inject if missing)
225
+ function ensureFrontmatter(content, name, description) {
226
+ const hasFm = /^---\n[\s\S]*?\n---/.test(content);
227
+ if (hasFm) {
228
+ // Check if description is missing in existing frontmatter
229
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
230
+ if (fmMatch && description) {
231
+ const fm = fmMatch[1];
232
+ if (!fm.match(/^description:/m)) {
233
+ // Add description to existing frontmatter
234
+ const newFm = fm.trimEnd() + `\ndescription: ${description}`;
235
+ return content.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`);
236
+ }
237
+ }
238
+ return content;
239
+ }
240
+ // No frontmatter — inject one
241
+ const lines = [`name: ${name}`];
242
+ if (description) lines.push(`description: ${description}`);
243
+ return `---\n${lines.join("\n")}\n---\n\n${content}`;
244
+ }
245
+
246
+ router.post("/install", requireApiKey, async (req, res) => {
247
+ try {
248
+ const { githubUrl, name, scope, projectPath, description } = req.body;
249
+
250
+ // Normalize name to valid directory format
251
+ const dirName = normalizeName(name);
252
+ if (!dirName) {
253
+ return res.status(400).json({ error: "Invalid skill name." });
254
+ }
255
+
256
+ // Validate scope
257
+ if (!["global", "project"].includes(scope)) {
258
+ return res.status(400).json({ error: "Scope must be 'global' or 'project'" });
259
+ }
260
+
261
+ // Validate projectPath for project scope
262
+ if (scope === "project") {
263
+ if (!projectPath || !projectPath.startsWith("/") || projectPath.includes("..")) {
264
+ return res.status(400).json({ error: "Invalid project path" });
265
+ }
266
+ }
267
+
268
+ // Parse GitHub URL
269
+ const parsed = parseGithubUrl(githubUrl);
270
+ if (!parsed) {
271
+ return res.status(400).json({ error: "Could not parse GitHub URL" });
272
+ }
273
+
274
+ // Determine target directory
275
+ const targetDir =
276
+ scope === "global"
277
+ ? join(homedir(), ".claude", "skills", dirName)
278
+ : join(projectPath, ".claude", "skills", dirName);
279
+
280
+ // Create directory
281
+ await mkdir(targetDir, { recursive: true });
282
+
283
+ let filesCount = 0;
284
+
285
+ // Try fetching all files from GitHub API first
286
+ try {
287
+ const files = await fetchSkillFiles(parsed.owner, parsed.repo, parsed.branch, parsed.path);
288
+ for (const file of files) {
289
+ if (!file.download_url) continue;
290
+ const fileRes = await fetch(file.download_url);
291
+ if (!fileRes.ok) continue;
292
+ let content = await fileRes.text();
293
+ // Normalize SKILL.md filename variations
294
+ const isSkillMd = file.name.toUpperCase() === "SKILL.MD";
295
+ const fileName = isSkillMd ? "SKILL.md" : file.name;
296
+ // Ensure SKILL.md has frontmatter with description
297
+ if (isSkillMd) {
298
+ content = ensureFrontmatter(content, name, description);
299
+ }
300
+ await writeFile(join(targetDir, fileName), content);
301
+ filesCount++;
302
+ }
303
+ } catch {
304
+ // Fallback: just fetch SKILL.md directly via raw URL
305
+ const rawUrl = buildRawUrl(parsed.owner, parsed.repo, parsed.branch, parsed.path, "SKILL.md");
306
+ const fileRes = await fetch(rawUrl);
307
+ if (!fileRes.ok) {
308
+ // Try skill.md (lowercase)
309
+ const altUrl = buildRawUrl(parsed.owner, parsed.repo, parsed.branch, parsed.path, "skill.md");
310
+ const altRes = await fetch(altUrl);
311
+ if (!altRes.ok) {
312
+ return res.status(404).json({ error: "Could not find SKILL.md in the repository" });
313
+ }
314
+ const content = ensureFrontmatter(await altRes.text(), name, description);
315
+ await writeFile(join(targetDir, "SKILL.md"), content);
316
+ } else {
317
+ const content = ensureFrontmatter(await fileRes.text(), name, description);
318
+ await writeFile(join(targetDir, "SKILL.md"), content);
319
+ }
320
+ filesCount = 1;
321
+ }
322
+
323
+ res.json({ success: true, path: targetDir, filesCount });
324
+ } catch (err) {
325
+ res.status(500).json({ error: err.message });
326
+ }
327
+ });
328
+
329
+ // ── Uninstall endpoint (gated) ──────────────────────────
330
+
331
+ router.delete("/:name", requireApiKey, async (req, res) => {
332
+ try {
333
+ const { name } = req.params;
334
+ const { scope, projectPath } = req.query;
335
+
336
+ if (!VALID_NAME.test(name)) {
337
+ return res.status(400).json({ error: "Invalid skill name" });
338
+ }
339
+
340
+ const skillDir =
341
+ scope === "global"
342
+ ? join(homedir(), ".claude", "skills", name)
343
+ : join(projectPath, ".claude", "skills", name);
344
+
345
+ // Safety check: verify it's a skill directory
346
+ const hasSkill =
347
+ existsSync(join(skillDir, "SKILL.md")) ||
348
+ existsSync(join(skillDir, "SKILL.md.disabled"));
349
+
350
+ if (!hasSkill) {
351
+ return res.status(404).json({ error: "Skill not found" });
352
+ }
353
+
354
+ await rm(skillDir, { recursive: true, force: true });
355
+ res.json({ success: true });
356
+ } catch (err) {
357
+ res.status(500).json({ error: err.message });
358
+ }
359
+ });
360
+
361
+ // ── Toggle endpoint (gated) ────────────────────────────
362
+
363
+ router.put("/:name/toggle", requireApiKey, async (req, res) => {
364
+ try {
365
+ const { name } = req.params;
366
+ const { scope, projectPath } = req.query;
367
+
368
+ const skillDir =
369
+ scope === "global"
370
+ ? join(homedir(), ".claude", "skills", name)
371
+ : join(projectPath, ".claude", "skills", name);
372
+
373
+ const enabledPath = join(skillDir, "SKILL.md");
374
+ const disabledPath = join(skillDir, "SKILL.md.disabled");
375
+
376
+ if (existsSync(enabledPath)) {
377
+ await rename(enabledPath, disabledPath);
378
+ res.json({ success: true, enabled: false });
379
+ } else if (existsSync(disabledPath)) {
380
+ await rename(disabledPath, enabledPath);
381
+ res.json({ success: true, enabled: true });
382
+ } else {
383
+ res.status(404).json({ error: "Skill not found" });
384
+ }
385
+ } catch (err) {
386
+ res.status(500).json({ error: err.message });
387
+ }
388
+ });
389
+
390
+ // ── Installed skills list (gated) ───────────────────────
391
+
392
+ router.get("/installed", requireApiKey, async (req, res) => {
393
+ try {
394
+ const { projectPath } = req.query;
395
+ const skills = [];
396
+
397
+ async function scanDir(baseDir, scope) {
398
+ try {
399
+ const entries = await readdir(baseDir);
400
+ for (const entry of entries) {
401
+ try {
402
+ const entryPath = join(baseDir, entry);
403
+ const s = await stat(entryPath);
404
+ if (!s.isDirectory()) continue;
405
+
406
+ const enabledPath = join(entryPath, "SKILL.md");
407
+ const disabledPath = join(entryPath, "SKILL.md.disabled");
408
+ let skillFile = null;
409
+ let enabled = true;
410
+
411
+ if (existsSync(enabledPath)) {
412
+ skillFile = enabledPath;
413
+ } else if (existsSync(disabledPath)) {
414
+ skillFile = disabledPath;
415
+ enabled = false;
416
+ } else {
417
+ continue;
418
+ }
419
+
420
+ const content = await readFile(skillFile, "utf-8");
421
+ let name = entry;
422
+ let description = "";
423
+
424
+ // Parse YAML frontmatter
425
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
426
+ if (fmMatch) {
427
+ const fm = fmMatch[1];
428
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
429
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
430
+ if (nameMatch) name = nameMatch[1].trim();
431
+ if (descMatch) description = descMatch[1].trim();
432
+ }
433
+
434
+ skills.push({ name, dirName: entry, description, scope, enabled, path: entryPath });
435
+ } catch { /* skip unreadable */ }
436
+ }
437
+ } catch { /* directory doesn't exist */ }
438
+ }
439
+
440
+ // Scan global skills
441
+ await scanDir(join(homedir(), ".claude", "skills"), "global");
442
+
443
+ // Scan project skills
444
+ if (projectPath) {
445
+ await scanDir(join(projectPath, ".claude", "skills"), "project");
446
+ }
447
+
448
+ res.json(skills);
449
+ } catch (err) {
450
+ res.status(500).json({ error: err.message });
451
+ }
452
+ });
453
+
454
+ export default router;
@@ -0,0 +1,93 @@
1
+ import { Router } from "express";
2
+ import {
3
+ getWorktreeRecord,
4
+ listWorktreesByProject,
5
+ updateWorktreeStatus,
6
+ } from "../../db.js";
7
+ import {
8
+ autoCommitWorktree,
9
+ getWorktreeDiff,
10
+ getWorktreeDiffStats,
11
+ squashMerge,
12
+ removeWorktree,
13
+ } from "../utils/git-worktree.js";
14
+
15
+ const router = Router();
16
+
17
+ // GET / — list worktrees for a project
18
+ router.get("/", (req, res) => {
19
+ try {
20
+ const projectPath = req.query.project_path;
21
+ if (!projectPath) return res.status(400).json({ error: "project_path is required" });
22
+ const worktrees = listWorktreesByProject(projectPath);
23
+ res.json(worktrees);
24
+ } catch (err) {
25
+ res.status(500).json({ error: err.message });
26
+ }
27
+ });
28
+
29
+ // GET /:id — get single worktree
30
+ router.get("/:id", (req, res) => {
31
+ try {
32
+ const wt = getWorktreeRecord(req.params.id);
33
+ if (!wt) return res.status(404).json({ error: "Worktree not found" });
34
+ res.json(wt);
35
+ } catch (err) {
36
+ res.status(500).json({ error: err.message });
37
+ }
38
+ });
39
+
40
+ // GET /:id/diff — get diff for a worktree
41
+ router.get("/:id/diff", async (req, res) => {
42
+ try {
43
+ const wt = getWorktreeRecord(req.params.id);
44
+ if (!wt) return res.status(404).json({ error: "Worktree not found" });
45
+
46
+ await autoCommitWorktree(wt.worktree_path, "claudeck: auto-commit for diff");
47
+ const diff = await getWorktreeDiff(wt.worktree_path, wt.base_branch);
48
+ const stats = await getWorktreeDiffStats(wt.worktree_path, wt.base_branch);
49
+
50
+ res.json({ diff, stats });
51
+ } catch (err) {
52
+ res.status(500).json({ error: err.message });
53
+ }
54
+ });
55
+
56
+ // POST /:id/merge — squash merge worktree back to main branch
57
+ router.post("/:id/merge", async (req, res) => {
58
+ try {
59
+ const wt = getWorktreeRecord(req.params.id);
60
+ if (!wt) return res.status(404).json({ error: "Worktree not found" });
61
+ if (wt.status === "merged") return res.status(400).json({ error: "Already merged" });
62
+ if (wt.status === "discarded") return res.status(400).json({ error: "Already discarded" });
63
+
64
+ const commitMessage = req.body.commitMessage
65
+ || `claudeck: ${(wt.user_prompt || "worktree changes").slice(0, 72)}`;
66
+
67
+ const result = await squashMerge(
68
+ wt.project_path, wt.worktree_path, wt.branch_name, commitMessage
69
+ );
70
+
71
+ updateWorktreeStatus(wt.id, "merged");
72
+ res.json({ ok: true, hash: result.hash });
73
+ } catch (err) {
74
+ res.status(500).json({ error: err.message });
75
+ }
76
+ });
77
+
78
+ // DELETE /:id — discard worktree
79
+ router.delete("/:id", async (req, res) => {
80
+ try {
81
+ const wt = getWorktreeRecord(req.params.id);
82
+ if (!wt) return res.status(404).json({ error: "Worktree not found" });
83
+
84
+ await removeWorktree(wt.project_path, wt.worktree_path, wt.branch_name);
85
+ updateWorktreeStatus(wt.id, "discarded");
86
+
87
+ res.json({ ok: true });
88
+ } catch (err) {
89
+ res.status(500).json({ error: err.message });
90
+ }
91
+ });
92
+
93
+ export default router;