apteva 0.4.3 → 0.4.5

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,538 @@
1
+ import { json } from "./helpers";
2
+ import { AgentDB, SkillDB, type Skill } from "../../db";
3
+ import { ProviderKeys } from "../../providers";
4
+ import { SkillsmpProvider, parseSkillMd } from "../../integrations/skillsmp";
5
+ import { buildAgentConfig, pushConfigToAgent, pushSkillsToAgent } from "./agent-utils";
6
+
7
+ export async function handleSkillRoutes(
8
+ req: Request,
9
+ path: string,
10
+ method: string,
11
+ ): Promise<Response | null> {
12
+ // ============ Skills CRUD ============
13
+
14
+ // GET /api/skills - List skills (optionally filtered by project)
15
+ if (path === "/api/skills" && method === "GET") {
16
+ const url = new URL(req.url);
17
+ const projectFilter = url.searchParams.get("project"); // "all", "global", or project ID
18
+ const forAgent = url.searchParams.get("forAgent"); // agent's project ID (shows global + project)
19
+
20
+ let skills;
21
+ if (forAgent !== null) {
22
+ // Get skills available for an agent (global + agent's project)
23
+ skills = SkillDB.findForAgent(forAgent || null);
24
+ } else if (projectFilter === "global") {
25
+ skills = SkillDB.findGlobal();
26
+ } else if (projectFilter && projectFilter !== "all") {
27
+ skills = SkillDB.findByProject(projectFilter);
28
+ } else {
29
+ skills = SkillDB.findAll();
30
+ }
31
+ return json({ skills });
32
+ }
33
+
34
+ // POST /api/skills - Create a new skill
35
+ if (path === "/api/skills" && method === "POST") {
36
+ try {
37
+ const body = await req.json();
38
+ const { name, description, content, version, license, compatibility, metadata, allowed_tools, source, source_url, enabled, project_id } = body;
39
+
40
+ if (!name || !description || !content) {
41
+ return json({ error: "name, description, and content are required" }, 400);
42
+ }
43
+
44
+ // Validate name format (lowercase, hyphens only)
45
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(name)) {
46
+ return json({ error: "name must be lowercase letters, numbers, and hyphens only" }, 400);
47
+ }
48
+
49
+ if (SkillDB.exists(name)) {
50
+ return json({ error: "A skill with this name already exists" }, 400);
51
+ }
52
+
53
+ const skill = SkillDB.create({
54
+ name,
55
+ description,
56
+ content,
57
+ version: version || "1.0.0",
58
+ license: license || null,
59
+ compatibility: compatibility || null,
60
+ metadata: metadata || {},
61
+ allowed_tools: allowed_tools || [],
62
+ source: source || "local",
63
+ source_url: source_url || null,
64
+ enabled: enabled !== false,
65
+ project_id: project_id || null,
66
+ });
67
+
68
+ return json({ skill }, 201);
69
+ } catch (err) {
70
+ console.error("Failed to create skill:", err);
71
+ return json({ error: `Failed to create skill: ${err}` }, 500);
72
+ }
73
+ }
74
+
75
+ // POST /api/skills/import - Import a skill from SKILL.md content
76
+ if (path === "/api/skills/import" && method === "POST") {
77
+ try {
78
+ const body = await req.json();
79
+ const { content, source, source_url } = body;
80
+
81
+ if (!content) {
82
+ return json({ error: "content is required" }, 400);
83
+ }
84
+
85
+ const parsed = parseSkillMd(content);
86
+ if (!parsed) {
87
+ return json({ error: "Invalid SKILL.md format. Must have YAML frontmatter with name and description." }, 400);
88
+ }
89
+
90
+ if (SkillDB.exists(parsed.name)) {
91
+ return json({ error: `A skill named "${parsed.name}" already exists` }, 400);
92
+ }
93
+
94
+ const skill = SkillDB.create({
95
+ name: parsed.name,
96
+ description: parsed.description,
97
+ content: content, // Store full content including frontmatter
98
+ version: (parsed as any).version || "1.0.0",
99
+ license: parsed.license || null,
100
+ compatibility: parsed.compatibility || null,
101
+ metadata: parsed.metadata || {},
102
+ allowed_tools: parsed.allowedTools || [],
103
+ source: source || "import",
104
+ source_url: source_url || null,
105
+ enabled: true,
106
+ project_id: null,
107
+ });
108
+
109
+ return json({ skill }, 201);
110
+ } catch (err) {
111
+ console.error("Failed to import skill:", err);
112
+ return json({ error: `Failed to import skill: ${err}` }, 500);
113
+ }
114
+ }
115
+
116
+ // GET /api/skills/:id - Get a skill
117
+ const skillMatch = path.match(/^\/api\/skills\/([^/]+)$/);
118
+
119
+ // GET /api/skills/:id/export - Export a skill as SKILL.md
120
+ const skillExportMatch = path.match(/^\/api\/skills\/([^/]+)\/export$/);
121
+ if (skillExportMatch && method === "GET") {
122
+ const skill = SkillDB.findById(skillExportMatch[1]);
123
+ if (!skill) {
124
+ return json({ error: "Skill not found" }, 404);
125
+ }
126
+
127
+ // Return the raw content
128
+ return new Response(skill.content, {
129
+ headers: {
130
+ "Content-Type": "text/markdown",
131
+ "Content-Disposition": `attachment; filename="${skill.name}-SKILL.md"`,
132
+ },
133
+ });
134
+ }
135
+
136
+ // POST /api/skills/:id/toggle - Toggle skill enabled/disabled
137
+ const skillToggleMatch = path.match(/^\/api\/skills\/([^/]+)\/toggle$/);
138
+ if (skillToggleMatch && method === "POST") {
139
+ const skill = SkillDB.findById(skillToggleMatch[1]);
140
+ if (!skill) {
141
+ return json({ error: "Skill not found" }, 404);
142
+ }
143
+
144
+ const updated = SkillDB.setEnabled(skillToggleMatch[1], !skill.enabled);
145
+ return json({ skill: updated });
146
+ }
147
+
148
+ // ============ SkillsMP Marketplace ============
149
+
150
+ // GET /api/skills/marketplace/search - Search skills marketplace
151
+ if (path === "/api/skills/marketplace/search" && method === "GET") {
152
+ const url = new URL(req.url);
153
+ const query = url.searchParams.get("q") || "";
154
+ const page = parseInt(url.searchParams.get("page") || "1", 10);
155
+
156
+ // Get SkillsMP API key if configured
157
+ const skillsmpKey = ProviderKeys.getDecrypted("skillsmp");
158
+
159
+ const result = await SkillsmpProvider.search(skillsmpKey || "", query, page);
160
+ return json(result);
161
+ }
162
+
163
+ // GET /api/skills/marketplace/featured - Get featured skills
164
+ if (path === "/api/skills/marketplace/featured" && method === "GET") {
165
+ const skillsmpKey = ProviderKeys.getDecrypted("skillsmp");
166
+ const skills = await SkillsmpProvider.getFeatured(skillsmpKey || "");
167
+ return json({ skills });
168
+ }
169
+
170
+ // GET /api/skills/marketplace/:id - Get skill details from marketplace
171
+ const marketplaceSkillMatch = path.match(/^\/api\/skills\/marketplace\/([^/]+)$/);
172
+ if (marketplaceSkillMatch && method === "GET") {
173
+ const skillsmpKey = ProviderKeys.getDecrypted("skillsmp");
174
+ const skill = await SkillsmpProvider.getSkill(skillsmpKey || "", marketplaceSkillMatch[1]);
175
+ if (!skill) {
176
+ return json({ error: "Skill not found in marketplace" }, 404);
177
+ }
178
+ return json({ skill });
179
+ }
180
+
181
+ // POST /api/skills/marketplace/:id/install - Install a skill from marketplace
182
+ const marketplaceInstallMatch = path.match(/^\/api\/skills\/marketplace\/([^/]+)\/install$/);
183
+ if (marketplaceInstallMatch && method === "POST") {
184
+ const skillsmpKey = ProviderKeys.getDecrypted("skillsmp");
185
+ const marketplaceSkill = await SkillsmpProvider.getSkill(skillsmpKey || "", marketplaceInstallMatch[1]);
186
+
187
+ if (!marketplaceSkill) {
188
+ return json({ error: "Skill not found in marketplace" }, 404);
189
+ }
190
+
191
+ if (SkillDB.exists(marketplaceSkill.name)) {
192
+ return json({ error: `A skill named "${marketplaceSkill.name}" already exists` }, 400);
193
+ }
194
+
195
+ const skill = SkillDB.create({
196
+ name: marketplaceSkill.name,
197
+ description: marketplaceSkill.description,
198
+ content: marketplaceSkill.content,
199
+ version: marketplaceSkill.version || "1.0.0",
200
+ license: marketplaceSkill.license,
201
+ compatibility: marketplaceSkill.compatibility,
202
+ metadata: {
203
+ author: marketplaceSkill.author,
204
+ version: marketplaceSkill.version,
205
+ ...(marketplaceSkill.repository ? { repository: marketplaceSkill.repository } : {}),
206
+ },
207
+ allowed_tools: [],
208
+ source: "skillsmp",
209
+ project_id: null,
210
+ source_url: marketplaceSkill.repository || `https://skillsmp.com/skills/${marketplaceSkill.id}`,
211
+ enabled: true,
212
+ });
213
+
214
+ return json({ skill }, 201);
215
+ }
216
+
217
+ // ============ GitHub Skills ============
218
+
219
+ // GET /api/skills/github/:owner/:repo - List skills from a GitHub repo
220
+ const githubRepoMatch = path.match(/^\/api\/skills\/github\/([^/]+)\/([^/]+)$/);
221
+ if (githubRepoMatch && method === "GET") {
222
+ const [, owner, repo] = githubRepoMatch;
223
+
224
+ const githubHeaders = {
225
+ "Accept": "application/vnd.github.v3+json",
226
+ "User-Agent": "Apteva-Skills-Browser",
227
+ };
228
+
229
+ // Helper to fetch directory contents
230
+ const fetchDir = async (dirPath: string) => {
231
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}`;
232
+ const res = await fetch(url, { headers: githubHeaders });
233
+ if (!res.ok) return [];
234
+ return await res.json() as Array<{
235
+ name: string;
236
+ path: string;
237
+ type: "file" | "dir";
238
+ size?: number;
239
+ download_url?: string;
240
+ }>;
241
+ };
242
+
243
+ // Helper to find skills in a directory (looks for subdirs with SKILL.md)
244
+ const findSkillsInDir = async (basePath: string) => {
245
+ const skills: Array<{
246
+ name: string;
247
+ description: string;
248
+ path: string;
249
+ size: number;
250
+ downloadUrl: string;
251
+ }> = [];
252
+
253
+ const contents = await fetchDir(basePath);
254
+ const skillDirs = contents.filter(item => item.type === "dir");
255
+
256
+ for (const dir of skillDirs) {
257
+ try {
258
+ const dirContents = await fetchDir(dir.path);
259
+ const skillFile = dirContents.find(
260
+ f => f.type === "file" && f.name.toLowerCase() === "skill.md"
261
+ );
262
+
263
+ if (skillFile && skillFile.download_url) {
264
+ const skillResponse = await fetch(skillFile.download_url);
265
+ if (skillResponse.ok) {
266
+ const content = await skillResponse.text();
267
+
268
+ // Parse frontmatter for description
269
+ let description = "";
270
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
271
+ if (frontmatterMatch) {
272
+ const descMatch = frontmatterMatch[1].match(/description:\s*["']?([^"'\n]+)["']?/);
273
+ if (descMatch) {
274
+ description = descMatch[1].trim();
275
+ }
276
+ }
277
+
278
+ // If no frontmatter description, try to get first paragraph
279
+ if (!description) {
280
+ const contentWithoutFrontmatter = content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
281
+ const firstPara = contentWithoutFrontmatter.split("\n\n")[0];
282
+ if (firstPara && !firstPara.startsWith("#")) {
283
+ description = firstPara.slice(0, 200);
284
+ }
285
+ }
286
+
287
+ skills.push({
288
+ name: dir.name,
289
+ description: description || `Skill from ${dir.name}`,
290
+ path: skillFile.path,
291
+ size: skillFile.size || 0,
292
+ downloadUrl: skillFile.download_url,
293
+ });
294
+ }
295
+ }
296
+ } catch (e) {
297
+ // Skip this directory on error
298
+ }
299
+ }
300
+
301
+ return skills;
302
+ };
303
+
304
+ try {
305
+ // Fetch root contents
306
+ const rootContents = await fetchDir("");
307
+
308
+ if (rootContents.length === 0) {
309
+ return json({ error: "Repository not found or empty" }, 404);
310
+ }
311
+
312
+ let skills: Array<{
313
+ name: string;
314
+ description: string;
315
+ path: string;
316
+ size: number;
317
+ downloadUrl: string;
318
+ }> = [];
319
+
320
+ // Check common skill directory patterns: skills/, src/skills/, .claude/skills/
321
+ const skillsDirs = ["skills", "src/skills", ".claude/skills"];
322
+ for (const skillsDir of skillsDirs) {
323
+ const dirExists = rootContents.find(
324
+ item => item.type === "dir" && item.name === skillsDir.split("/")[0]
325
+ );
326
+ if (dirExists || skillsDir.includes("/")) {
327
+ const foundSkills = await findSkillsInDir(skillsDir);
328
+ if (foundSkills.length > 0) {
329
+ skills = foundSkills;
330
+ break;
331
+ }
332
+ }
333
+ }
334
+
335
+ // If no skills found in common dirs, check root level subdirectories
336
+ if (skills.length === 0) {
337
+ skills = await findSkillsInDir("");
338
+ }
339
+
340
+ // Also check for SKILL.md in root (single-skill repo)
341
+ const rootSkillFile = rootContents.find(
342
+ f => f.type === "file" && f.name.toLowerCase() === "skill.md"
343
+ );
344
+ if (rootSkillFile && rootSkillFile.download_url) {
345
+ const skillResponse = await fetch(rootSkillFile.download_url);
346
+ if (skillResponse.ok) {
347
+ const content = await skillResponse.text();
348
+ let name = repo;
349
+ let description = "";
350
+
351
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
352
+ if (frontmatterMatch) {
353
+ const nameMatch = frontmatterMatch[1].match(/name:\s*["']?([^"'\n]+)["']?/);
354
+ const descMatch = frontmatterMatch[1].match(/description:\s*["']?([^"'\n]+)["']?/);
355
+ if (nameMatch) name = nameMatch[1].trim();
356
+ if (descMatch) description = descMatch[1].trim();
357
+ }
358
+
359
+ skills.unshift({
360
+ name,
361
+ description: description || `Skill from ${repo}`,
362
+ path: rootSkillFile.path,
363
+ size: rootSkillFile.size || 0,
364
+ downloadUrl: rootSkillFile.download_url,
365
+ });
366
+ }
367
+ }
368
+
369
+ return json({
370
+ skills,
371
+ repo: { owner, repo, url: `https://github.com/${owner}/${repo}` }
372
+ });
373
+ } catch (e) {
374
+ console.error("GitHub API error:", e);
375
+ return json({ error: "Failed to fetch from GitHub" }, 500);
376
+ }
377
+ }
378
+
379
+ // POST /api/skills/github/install - Install a skill from GitHub
380
+ if (path === "/api/skills/github/install" && method === "POST") {
381
+ try {
382
+ const body = await req.json() as {
383
+ owner: string;
384
+ repo: string;
385
+ skillName: string;
386
+ downloadUrl: string;
387
+ projectId?: string | null;
388
+ };
389
+
390
+ const { owner, repo, skillName, downloadUrl, projectId } = body;
391
+
392
+ if (!owner || !repo || !skillName || !downloadUrl) {
393
+ return json({ error: "owner, repo, skillName, and downloadUrl are required" }, 400);
394
+ }
395
+
396
+ // Check if skill already exists
397
+ if (SkillDB.exists(skillName)) {
398
+ return json({ error: `A skill named "${skillName}" already exists` }, 400);
399
+ }
400
+
401
+ // Fetch the skill content
402
+ const response = await fetch(downloadUrl);
403
+ if (!response.ok) {
404
+ return json({ error: "Failed to fetch skill content" }, 500);
405
+ }
406
+
407
+ const content = await response.text();
408
+
409
+ // Parse frontmatter
410
+ let name = skillName;
411
+ let description = "";
412
+ let version = "1.0.0";
413
+ let license = null;
414
+ let compatibility = null;
415
+ let skillContent = content;
416
+
417
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
418
+ if (frontmatterMatch) {
419
+ const frontmatter = frontmatterMatch[1];
420
+ skillContent = frontmatterMatch[2].trim();
421
+
422
+ const nameMatch = frontmatter.match(/name:\s*["']?([^"'\n]+)["']?/);
423
+ const descMatch = frontmatter.match(/description:\s*["']?([^"'\n]+)["']?/);
424
+ const versionMatch = frontmatter.match(/version:\s*["']?([^"'\n]+)["']?/);
425
+ const licenseMatch = frontmatter.match(/license:\s*["']?([^"'\n]+)["']?/);
426
+ const compatMatch = frontmatter.match(/compatibility:\s*["']?([^"'\n]+)["']?/);
427
+
428
+ if (nameMatch) name = nameMatch[1].trim();
429
+ if (descMatch) description = descMatch[1].trim();
430
+ if (versionMatch) version = versionMatch[1].trim();
431
+ if (licenseMatch) license = licenseMatch[1].trim();
432
+ if (compatMatch) compatibility = compatMatch[1].trim();
433
+ }
434
+
435
+ // Create the skill in DB
436
+ const skill = SkillDB.create({
437
+ name,
438
+ description: description || `Skill from ${owner}/${repo}`,
439
+ content: skillContent,
440
+ version,
441
+ license,
442
+ compatibility,
443
+ metadata: { owner, repo, originalName: skillName },
444
+ allowed_tools: [],
445
+ source: "github",
446
+ source_url: `https://github.com/${owner}/${repo}/blob/main/${skillName}/SKILL.md`,
447
+ enabled: true,
448
+ project_id: projectId || null,
449
+ });
450
+
451
+ return json({ skill }, 201);
452
+ } catch (e) {
453
+ console.error("GitHub install error:", e);
454
+ return json({ error: "Failed to install skill from GitHub" }, 500);
455
+ }
456
+ }
457
+
458
+ // Skill CRUD by ID (must come after more specific routes like /toggle, /export, /marketplace, /github)
459
+ if (skillMatch && method === "GET") {
460
+ const skill = SkillDB.findById(skillMatch[1]);
461
+ if (!skill) {
462
+ return json({ error: "Skill not found" }, 404);
463
+ }
464
+ return json({ skill });
465
+ }
466
+
467
+ // PUT /api/skills/:id - Update a skill
468
+ if (skillMatch && method === "PUT") {
469
+ const skill = SkillDB.findById(skillMatch[1]);
470
+ if (!skill) {
471
+ return json({ error: "Skill not found" }, 404);
472
+ }
473
+
474
+ try {
475
+ const body = await req.json();
476
+ const updates: Partial<Skill> = {};
477
+
478
+ if (body.name !== undefined) updates.name = body.name;
479
+ if (body.description !== undefined) updates.description = body.description;
480
+ if (body.content !== undefined) updates.content = body.content;
481
+ if (body.license !== undefined) updates.license = body.license;
482
+ if (body.compatibility !== undefined) updates.compatibility = body.compatibility;
483
+ if (body.metadata !== undefined) updates.metadata = body.metadata;
484
+ if (body.allowed_tools !== undefined) updates.allowed_tools = body.allowed_tools;
485
+ if (body.enabled !== undefined) updates.enabled = body.enabled;
486
+ if (body.project_id !== undefined) updates.project_id = body.project_id;
487
+
488
+ // Auto-increment version if content changed
489
+ if (body.content !== undefined && body.content !== skill.content) {
490
+ const [major, minor, patch] = (skill.version || "1.0.0").split(".").map(Number);
491
+ updates.version = `${major}.${minor}.${patch + 1}`;
492
+ } else if (body.version !== undefined) {
493
+ updates.version = body.version;
494
+ }
495
+
496
+ const updated = SkillDB.update(skillMatch[1], updates);
497
+
498
+ // Push updated skill to all running agents that have it
499
+ const agentsWithSkill = AgentDB.findBySkill(skillMatch[1]);
500
+ const runningAgents = agentsWithSkill.filter(a => a.status === "running" && a.port);
501
+
502
+ for (const agent of runningAgents) {
503
+ try {
504
+ const providerKey = ProviderKeys.getDecrypted(agent.provider);
505
+ if (providerKey) {
506
+ const config = buildAgentConfig(agent, providerKey);
507
+ await pushConfigToAgent(agent.id, agent.port!, config);
508
+ // Push skills via /skills endpoint
509
+ if (config.skills?.definitions?.length > 0) {
510
+ await pushSkillsToAgent(agent.id, agent.port!, config.skills.definitions);
511
+ }
512
+ console.log(`Pushed skill update to agent ${agent.name}`);
513
+ }
514
+ } catch (err) {
515
+ console.error(`Failed to push skill update to agent ${agent.name}:`, err);
516
+ }
517
+ }
518
+
519
+ return json({ skill: updated, agents_updated: runningAgents.length });
520
+ } catch (err) {
521
+ console.error("Failed to update skill:", err);
522
+ return json({ error: `Failed to update skill: ${err}` }, 500);
523
+ }
524
+ }
525
+
526
+ // DELETE /api/skills/:id - Delete a skill
527
+ if (skillMatch && method === "DELETE") {
528
+ const skill = SkillDB.findById(skillMatch[1]);
529
+ if (!skill) {
530
+ return json({ error: "Skill not found" }, 404);
531
+ }
532
+
533
+ SkillDB.delete(skillMatch[1]);
534
+ return json({ success: true });
535
+ }
536
+
537
+ return null;
538
+ }