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.
- package/README.md +30 -4
- package/config/skillsmp-config.json +5 -0
- package/db.js +248 -0
- package/package.json +11 -2
- package/public/css/panels/git-panel.css +220 -0
- package/public/css/panels/skills-manager.css +975 -0
- package/public/css/ui/input-history.css +109 -0
- package/public/css/ui/messages.css +51 -0
- package/public/css/ui/notification-bell.css +421 -0
- package/public/css/ui/sessions.css +41 -0
- package/public/css/ui/worktree.css +442 -0
- package/public/index.html +43 -10
- package/public/js/core/api.js +83 -0
- package/public/js/core/dom.js +15 -0
- package/public/js/features/background-sessions.js +11 -0
- package/public/js/features/chat.js +501 -3
- package/public/js/features/input-history.js +122 -0
- package/public/js/features/projects.js +16 -1
- package/public/js/features/sessions.js +77 -30
- package/public/js/main.js +3 -0
- package/public/js/panels/git-panel.js +385 -6
- package/public/js/panels/skills-manager.js +1005 -0
- package/public/js/ui/messages.js +58 -0
- package/public/js/ui/notification-bell.js +240 -0
- package/public/js/ui/notification-history.js +210 -0
- package/public/js/ui/parallel.js +11 -0
- package/public/js/ui/tab-sdk.js +1 -1
- package/public/style.css +4 -0
- package/server/agent-loop.js +13 -0
- package/server/notification-logger.js +27 -0
- package/server/routes/notifications.js +57 -1
- package/server/routes/sessions.js +41 -0
- package/server/routes/skills.js +454 -0
- package/server/routes/worktrees.js +93 -0
- package/server/utils/git-worktree.js +297 -0
- package/server/ws-handler.js +708 -629
- package/server.js +17 -1
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
|
-
import {
|
|
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;
|