@ian2018cs/agenthub 0.1.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 (136) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +330 -0
  3. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/assets/index-B4ru3EJb.css +32 -0
  63. package/dist/assets/index-DDFuyrpY.js +154 -0
  64. package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
  65. package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
  66. package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
  67. package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
  68. package/dist/assets/vendor-react-BeVl62c0.js +59 -0
  69. package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
  70. package/dist/assets/vendor-utils-00TdZexr.js +1 -0
  71. package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
  72. package/dist/clear-cache.html +85 -0
  73. package/dist/convert-icons.md +53 -0
  74. package/dist/favicon.png +0 -0
  75. package/dist/favicon.svg +9 -0
  76. package/dist/generate-icons.js +49 -0
  77. package/dist/icons/claude-ai-icon.svg +1 -0
  78. package/dist/icons/codex-white.svg +3 -0
  79. package/dist/icons/codex.svg +3 -0
  80. package/dist/icons/cursor-white.svg +12 -0
  81. package/dist/icons/cursor.svg +1 -0
  82. package/dist/icons/generate-icons.md +19 -0
  83. package/dist/icons/icon-128x128.png +0 -0
  84. package/dist/icons/icon-128x128.svg +12 -0
  85. package/dist/icons/icon-144x144.png +0 -0
  86. package/dist/icons/icon-144x144.svg +12 -0
  87. package/dist/icons/icon-152x152.png +0 -0
  88. package/dist/icons/icon-152x152.svg +12 -0
  89. package/dist/icons/icon-192x192.png +0 -0
  90. package/dist/icons/icon-192x192.svg +12 -0
  91. package/dist/icons/icon-384x384.png +0 -0
  92. package/dist/icons/icon-384x384.svg +12 -0
  93. package/dist/icons/icon-512x512.png +0 -0
  94. package/dist/icons/icon-512x512.svg +12 -0
  95. package/dist/icons/icon-72x72.png +0 -0
  96. package/dist/icons/icon-72x72.svg +12 -0
  97. package/dist/icons/icon-96x96.png +0 -0
  98. package/dist/icons/icon-96x96.svg +12 -0
  99. package/dist/icons/icon-template.svg +12 -0
  100. package/dist/index.html +57 -0
  101. package/dist/logo-128.png +0 -0
  102. package/dist/logo-256.png +0 -0
  103. package/dist/logo-32.png +0 -0
  104. package/dist/logo-512.png +0 -0
  105. package/dist/logo-64.png +0 -0
  106. package/dist/logo.svg +17 -0
  107. package/dist/manifest.json +61 -0
  108. package/dist/screenshots/cli-selection.png +0 -0
  109. package/dist/screenshots/desktop-main.png +0 -0
  110. package/dist/screenshots/mobile-chat.png +0 -0
  111. package/dist/screenshots/tools-modal.png +0 -0
  112. package/dist/sw.js +49 -0
  113. package/package.json +113 -0
  114. package/server/claude-sdk.js +791 -0
  115. package/server/cli.js +330 -0
  116. package/server/database/auth.db +0 -0
  117. package/server/database/db.js +523 -0
  118. package/server/database/init.sql +23 -0
  119. package/server/index.js +1678 -0
  120. package/server/load-env.js +27 -0
  121. package/server/middleware/auth.js +118 -0
  122. package/server/projects.js +899 -0
  123. package/server/routes/admin.js +89 -0
  124. package/server/routes/auth.js +144 -0
  125. package/server/routes/commands.js +570 -0
  126. package/server/routes/mcp-utils.js +37 -0
  127. package/server/routes/mcp.js +593 -0
  128. package/server/routes/projects.js +216 -0
  129. package/server/routes/skills.js +891 -0
  130. package/server/routes/usage.js +206 -0
  131. package/server/services/pricing.js +196 -0
  132. package/server/services/usage-scanner.js +283 -0
  133. package/server/services/user-directories.js +123 -0
  134. package/server/utils/commandParser.js +303 -0
  135. package/server/utils/mcp-detector.js +73 -0
  136. package/shared/modelConstants.js +23 -0
@@ -0,0 +1,891 @@
1
+ import express from 'express';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import multer from 'multer';
6
+ import AdmZip from 'adm-zip';
7
+ import { getUserPaths, getPublicPaths, DATA_DIR } from '../services/user-directories.js';
8
+
9
+ const router = express.Router();
10
+
11
+ // Skill name validation: letters, numbers, hyphens, underscores
12
+ const SKILL_NAME_REGEX = /^[a-zA-Z0-9_-]{1,100}$/;
13
+
14
+ // Trusted git hosting domains
15
+ const TRUSTED_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org'];
16
+
17
+ // Configure multer for zip file uploads
18
+ const upload = multer({
19
+ storage: multer.memoryStorage(),
20
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
21
+ fileFilter: (req, file, cb) => {
22
+ if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) {
23
+ cb(null, true);
24
+ } else {
25
+ cb(new Error('Only ZIP files are allowed'));
26
+ }
27
+ }
28
+ });
29
+
30
+ /**
31
+ * Parse skill metadata from SKILLS.md file
32
+ */
33
+ async function parseSkillMetadata(skillPath) {
34
+ try {
35
+ const skillsFile = path.join(skillPath, 'SKILLS.md');
36
+ const content = await fs.readFile(skillsFile, 'utf-8');
37
+
38
+ // Extract title from first # heading
39
+ const titleMatch = content.match(/^#\s+(.+)$/m);
40
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(skillPath);
41
+
42
+ // Extract description from content after title (first paragraph)
43
+ const lines = content.split('\n');
44
+ let description = '';
45
+ let foundTitle = false;
46
+ for (const line of lines) {
47
+ if (line.startsWith('#')) {
48
+ if (foundTitle) break;
49
+ foundTitle = true;
50
+ continue;
51
+ }
52
+ if (foundTitle && line.trim()) {
53
+ description = line.trim();
54
+ break;
55
+ }
56
+ }
57
+
58
+ return { title, description };
59
+ } catch {
60
+ return { title: path.basename(skillPath), description: '' };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check if a path is a valid skill directory
66
+ */
67
+ async function isValidSkill(skillPath) {
68
+ try {
69
+ const stat = await fs.stat(skillPath);
70
+ if (!stat.isDirectory()) return false;
71
+
72
+ // Check for SKILLS.md
73
+ try {
74
+ await fs.access(path.join(skillPath, 'SKILLS.md'));
75
+ return true;
76
+ } catch {
77
+ // Fallback: check for any .md files
78
+ const files = await fs.readdir(skillPath);
79
+ return files.some(f => f.endsWith('.md'));
80
+ }
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Validate GitHub URL
88
+ */
89
+ function validateGitUrl(url) {
90
+ if (!url || typeof url !== 'string') {
91
+ return { valid: false, error: 'Repository URL is required' };
92
+ }
93
+
94
+ let parsedUrl;
95
+ try {
96
+ parsedUrl = new URL(url);
97
+ } catch {
98
+ return { valid: false, error: 'Invalid URL format' };
99
+ }
100
+
101
+ if (parsedUrl.protocol !== 'https:') {
102
+ return { valid: false, error: 'Only HTTPS URLs are allowed' };
103
+ }
104
+
105
+ const host = parsedUrl.hostname.toLowerCase();
106
+ if (!TRUSTED_GIT_HOSTS.includes(host)) {
107
+ return {
108
+ valid: false,
109
+ error: `Only trusted git hosts are allowed: ${TRUSTED_GIT_HOSTS.join(', ')}`
110
+ };
111
+ }
112
+
113
+ return { valid: true };
114
+ }
115
+
116
+ /**
117
+ * Extract owner and repo from git URL
118
+ */
119
+ function parseGitUrl(url) {
120
+ const parsedUrl = new URL(url);
121
+ const pathParts = parsedUrl.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
122
+ if (pathParts.length >= 2) {
123
+ return { owner: pathParts[0], repo: pathParts[1] };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Clone a git repository
130
+ */
131
+ function cloneRepository(url, destinationPath) {
132
+ return new Promise((resolve, reject) => {
133
+ const gitProcess = spawn('git', ['clone', '--depth', '1', url, destinationPath], {
134
+ stdio: ['ignore', 'pipe', 'pipe'],
135
+ env: {
136
+ ...process.env,
137
+ GIT_TERMINAL_PROMPT: '0'
138
+ }
139
+ });
140
+
141
+ let stderr = '';
142
+ gitProcess.stderr.on('data', (data) => {
143
+ stderr += data.toString();
144
+ });
145
+
146
+ gitProcess.on('close', (code) => {
147
+ if (code === 0) {
148
+ resolve();
149
+ } else {
150
+ reject(new Error(stderr || `Git clone failed with code ${code}`));
151
+ }
152
+ });
153
+
154
+ gitProcess.on('error', (err) => {
155
+ reject(err);
156
+ });
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Update a git repository
162
+ */
163
+ function updateRepository(repoPath) {
164
+ return new Promise((resolve, reject) => {
165
+ const gitProcess = spawn('git', ['pull', '--ff-only'], {
166
+ cwd: repoPath,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ env: {
169
+ ...process.env,
170
+ GIT_TERMINAL_PROMPT: '0'
171
+ }
172
+ });
173
+
174
+ let stderr = '';
175
+ gitProcess.stderr.on('data', (data) => {
176
+ stderr += data.toString();
177
+ });
178
+
179
+ gitProcess.on('close', (code) => {
180
+ if (code === 0) {
181
+ resolve();
182
+ } else {
183
+ reject(new Error(stderr || `Git pull failed with code ${code}`));
184
+ }
185
+ });
186
+
187
+ gitProcess.on('error', (err) => {
188
+ reject(err);
189
+ });
190
+ });
191
+ }
192
+
193
+ /**
194
+ * GET /api/skills
195
+ * List user's installed skills
196
+ */
197
+ router.get('/', async (req, res) => {
198
+ try {
199
+ const userUuid = req.user?.uuid;
200
+ if (!userUuid) {
201
+ return res.status(401).json({ error: 'User authentication required' });
202
+ }
203
+
204
+ const userPaths = getUserPaths(userUuid);
205
+
206
+ // Ensure directory exists
207
+ await fs.mkdir(userPaths.skillsDir, { recursive: true });
208
+
209
+ const entries = await fs.readdir(userPaths.skillsDir, { withFileTypes: true });
210
+ const skills = [];
211
+
212
+ for (const entry of entries) {
213
+ // Skip hidden files and READMEs
214
+ if (entry.name.startsWith('.') || entry.name.toLowerCase().startsWith('readme')) {
215
+ continue;
216
+ }
217
+
218
+ const skillPath = path.join(userPaths.skillsDir, entry.name);
219
+ let realPath = skillPath;
220
+ let isSymlink = false;
221
+ let source = 'unknown';
222
+ let repository = null;
223
+
224
+ try {
225
+ const stat = await fs.lstat(skillPath);
226
+ isSymlink = stat.isSymbolicLink();
227
+
228
+ if (isSymlink) {
229
+ realPath = await fs.realpath(skillPath);
230
+
231
+ // Determine source based on realPath
232
+ if (realPath.includes('/skills-import/')) {
233
+ source = 'imported';
234
+ } else if (realPath.includes('/skills-repo/')) {
235
+ source = 'repo';
236
+ // Extract repository info from path
237
+ const repoMatch = realPath.match(/skills-repo\/([^/]+)\/([^/]+)/);
238
+ if (repoMatch) {
239
+ repository = `${repoMatch[1]}/${repoMatch[2]}`;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Check if it's a valid skill
245
+ if (!await isValidSkill(realPath)) {
246
+ continue;
247
+ }
248
+
249
+ const metadata = await parseSkillMetadata(realPath);
250
+
251
+ skills.push({
252
+ name: entry.name,
253
+ title: metadata.title,
254
+ description: metadata.description,
255
+ enabled: true,
256
+ source,
257
+ repository,
258
+ path: realPath
259
+ });
260
+ } catch (err) {
261
+ console.error(`Error reading skill ${entry.name}:`, err.message);
262
+ }
263
+ }
264
+
265
+ res.json({ skills, count: skills.length });
266
+ } catch (error) {
267
+ console.error('Error listing skills:', error);
268
+ res.status(500).json({ error: error.message });
269
+ }
270
+ });
271
+
272
+ /**
273
+ * POST /api/skills/enable/:name
274
+ * Enable a skill by creating symlink
275
+ */
276
+ router.post('/enable/:name', async (req, res) => {
277
+ try {
278
+ const userUuid = req.user?.uuid;
279
+ if (!userUuid) {
280
+ return res.status(401).json({ error: 'User authentication required' });
281
+ }
282
+
283
+ const { name } = req.params;
284
+ const { skillPath } = req.body;
285
+
286
+ if (!SKILL_NAME_REGEX.test(name)) {
287
+ return res.status(400).json({ error: 'Invalid skill name' });
288
+ }
289
+
290
+ if (!skillPath) {
291
+ return res.status(400).json({ error: 'Skill path is required' });
292
+ }
293
+
294
+ const userPaths = getUserPaths(userUuid);
295
+ const linkPath = path.join(userPaths.skillsDir, name);
296
+
297
+ // Check if already exists
298
+ try {
299
+ await fs.access(linkPath);
300
+ return res.status(400).json({ error: 'Skill is already enabled' });
301
+ } catch {
302
+ // Good, doesn't exist
303
+ }
304
+
305
+ // Create symlink
306
+ await fs.symlink(skillPath, linkPath);
307
+
308
+ res.json({ success: true, message: 'Skill enabled' });
309
+ } catch (error) {
310
+ console.error('Error enabling skill:', error);
311
+ res.status(500).json({ error: error.message });
312
+ }
313
+ });
314
+
315
+ /**
316
+ * DELETE /api/skills/disable/:name
317
+ * Disable a skill by removing symlink
318
+ */
319
+ router.delete('/disable/:name', async (req, res) => {
320
+ try {
321
+ const userUuid = req.user?.uuid;
322
+ if (!userUuid) {
323
+ return res.status(401).json({ error: 'User authentication required' });
324
+ }
325
+
326
+ const { name } = req.params;
327
+
328
+ if (!SKILL_NAME_REGEX.test(name)) {
329
+ return res.status(400).json({ error: 'Invalid skill name' });
330
+ }
331
+
332
+ const userPaths = getUserPaths(userUuid);
333
+ const linkPath = path.join(userPaths.skillsDir, name);
334
+
335
+ // Verify it's a symlink before removing
336
+ try {
337
+ const stat = await fs.lstat(linkPath);
338
+ if (!stat.isSymbolicLink()) {
339
+ return res.status(400).json({ error: 'Cannot disable non-symlink skill' });
340
+ }
341
+ } catch (err) {
342
+ return res.status(404).json({ error: 'Skill not found' });
343
+ }
344
+
345
+ await fs.unlink(linkPath);
346
+
347
+ res.json({ success: true, message: 'Skill disabled' });
348
+ } catch (error) {
349
+ console.error('Error disabling skill:', error);
350
+ res.status(500).json({ error: error.message });
351
+ }
352
+ });
353
+
354
+ /**
355
+ * DELETE /api/skills/:name
356
+ * Delete a skill completely
357
+ */
358
+ router.delete('/:name', async (req, res) => {
359
+ try {
360
+ const userUuid = req.user?.uuid;
361
+ if (!userUuid) {
362
+ return res.status(401).json({ error: 'User authentication required' });
363
+ }
364
+
365
+ const { name } = req.params;
366
+
367
+ if (!SKILL_NAME_REGEX.test(name)) {
368
+ return res.status(400).json({ error: 'Invalid skill name' });
369
+ }
370
+
371
+ const userPaths = getUserPaths(userUuid);
372
+ const linkPath = path.join(userPaths.skillsDir, name);
373
+
374
+ // Check the symlink target to determine source
375
+ let realPath = null;
376
+ let isImported = false;
377
+
378
+ try {
379
+ const stat = await fs.lstat(linkPath);
380
+ if (stat.isSymbolicLink()) {
381
+ realPath = await fs.realpath(linkPath);
382
+ isImported = realPath.includes('/skills-import/');
383
+ }
384
+ } catch (err) {
385
+ return res.status(404).json({ error: 'Skill not found' });
386
+ }
387
+
388
+ // Remove symlink
389
+ await fs.unlink(linkPath);
390
+
391
+ // If imported, also delete the actual files
392
+ if (isImported && realPath) {
393
+ try {
394
+ await fs.rm(realPath, { recursive: true, force: true });
395
+ } catch (err) {
396
+ console.error('Error removing imported skill files:', err);
397
+ }
398
+ }
399
+
400
+ res.json({ success: true, message: 'Skill deleted' });
401
+ } catch (error) {
402
+ console.error('Error deleting skill:', error);
403
+ res.status(500).json({ error: error.message });
404
+ }
405
+ });
406
+
407
+ /**
408
+ * POST /api/skills/import
409
+ * Import a skill from zip file
410
+ */
411
+ router.post('/import', upload.single('skillZip'), async (req, res) => {
412
+ try {
413
+ const userUuid = req.user?.uuid;
414
+ if (!userUuid) {
415
+ return res.status(401).json({ error: 'User authentication required' });
416
+ }
417
+
418
+ if (!req.file) {
419
+ return res.status(400).json({ error: 'ZIP file is required' });
420
+ }
421
+
422
+ const userPaths = getUserPaths(userUuid);
423
+
424
+ // Extract zip
425
+ const zip = new AdmZip(req.file.buffer);
426
+ const zipEntries = zip.getEntries();
427
+
428
+ if (zipEntries.length === 0) {
429
+ return res.status(400).json({ error: 'ZIP file is empty' });
430
+ }
431
+
432
+ // Determine skill name from zip structure
433
+ // Look for the root directory or first directory containing SKILLS.md
434
+ let skillName = null;
435
+ let rootDir = '';
436
+
437
+ for (const entry of zipEntries) {
438
+ if (entry.entryName.endsWith('SKILLS.md')) {
439
+ const parts = entry.entryName.split('/');
440
+ if (parts.length >= 2) {
441
+ skillName = parts[0];
442
+ rootDir = parts[0] + '/';
443
+ } else {
444
+ // SKILLS.md is at root, use original zip filename
445
+ skillName = path.basename(req.file.originalname, '.zip');
446
+ }
447
+ break;
448
+ }
449
+ }
450
+
451
+ if (!skillName) {
452
+ // Fallback to first directory or zip filename
453
+ const firstEntry = zipEntries.find(e => e.isDirectory);
454
+ if (firstEntry) {
455
+ skillName = firstEntry.entryName.replace(/\/$/, '').split('/')[0];
456
+ rootDir = skillName + '/';
457
+ } else {
458
+ skillName = path.basename(req.file.originalname, '.zip');
459
+ }
460
+ }
461
+
462
+ // Validate skill name
463
+ if (!SKILL_NAME_REGEX.test(skillName)) {
464
+ skillName = skillName.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 100);
465
+ }
466
+
467
+ const importDir = path.join(userPaths.skillsImportDir, skillName);
468
+
469
+ // Ensure import directory exists and is empty
470
+ await fs.rm(importDir, { recursive: true, force: true });
471
+ await fs.mkdir(importDir, { recursive: true });
472
+
473
+ // Extract files
474
+ for (const entry of zipEntries) {
475
+ if (entry.isDirectory) continue;
476
+
477
+ let targetPath = entry.entryName;
478
+ if (rootDir && targetPath.startsWith(rootDir)) {
479
+ targetPath = targetPath.slice(rootDir.length);
480
+ }
481
+
482
+ if (!targetPath) continue;
483
+
484
+ const fullPath = path.join(importDir, targetPath);
485
+ const dir = path.dirname(fullPath);
486
+
487
+ await fs.mkdir(dir, { recursive: true });
488
+ await fs.writeFile(fullPath, entry.getData());
489
+ }
490
+
491
+ // Create symlink in user's skills directory
492
+ const linkPath = path.join(userPaths.skillsDir, skillName);
493
+
494
+ // Remove existing symlink if any
495
+ try {
496
+ await fs.unlink(linkPath);
497
+ } catch {
498
+ // Ignore
499
+ }
500
+
501
+ await fs.symlink(importDir, linkPath);
502
+
503
+ const metadata = await parseSkillMetadata(importDir);
504
+
505
+ res.json({
506
+ success: true,
507
+ skill: {
508
+ name: skillName,
509
+ title: metadata.title,
510
+ description: metadata.description,
511
+ source: 'imported'
512
+ }
513
+ });
514
+ } catch (error) {
515
+ console.error('Error importing skill:', error);
516
+ res.status(500).json({ error: error.message });
517
+ }
518
+ });
519
+
520
+ /**
521
+ * GET /api/skills/available
522
+ * List all available skills from repositories
523
+ */
524
+ router.get('/available', async (req, res) => {
525
+ try {
526
+ const userUuid = req.user?.uuid;
527
+ if (!userUuid) {
528
+ return res.status(401).json({ error: 'User authentication required' });
529
+ }
530
+
531
+ const userPaths = getUserPaths(userUuid);
532
+ const publicPaths = getPublicPaths();
533
+
534
+ const skills = [];
535
+
536
+ // Get user's installed skills for comparison
537
+ const installedSkills = new Set();
538
+ try {
539
+ const installed = await fs.readdir(userPaths.skillsDir);
540
+ installed.forEach(s => installedSkills.add(s));
541
+ } catch {
542
+ // Ignore
543
+ }
544
+
545
+ // Scan user's repo symlinks
546
+ try {
547
+ await fs.mkdir(userPaths.skillsRepoDir, { recursive: true });
548
+ const owners = await fs.readdir(userPaths.skillsRepoDir);
549
+
550
+ for (const owner of owners) {
551
+ if (owner.startsWith('.')) continue;
552
+
553
+ const ownerPath = path.join(userPaths.skillsRepoDir, owner);
554
+ const stat = await fs.stat(ownerPath);
555
+ if (!stat.isDirectory()) continue;
556
+
557
+ const repos = await fs.readdir(ownerPath);
558
+
559
+ for (const repo of repos) {
560
+ if (repo.startsWith('.')) continue;
561
+
562
+ const repoPath = path.join(ownerPath, repo);
563
+ let realRepoPath = repoPath;
564
+
565
+ try {
566
+ const repoStat = await fs.lstat(repoPath);
567
+ if (repoStat.isSymbolicLink()) {
568
+ realRepoPath = await fs.realpath(repoPath);
569
+ }
570
+ } catch {
571
+ continue;
572
+ }
573
+
574
+ // Scan for skills in the repo
575
+ const entries = await fs.readdir(realRepoPath, { withFileTypes: true });
576
+
577
+ for (const entry of entries) {
578
+ // Skip hidden dirs, READMEs, and files
579
+ if (entry.name.startsWith('.') ||
580
+ entry.name.toLowerCase().startsWith('readme') ||
581
+ !entry.isDirectory()) {
582
+ continue;
583
+ }
584
+
585
+ const skillPath = path.join(realRepoPath, entry.name);
586
+
587
+ if (!await isValidSkill(skillPath)) {
588
+ continue;
589
+ }
590
+
591
+ const metadata = await parseSkillMetadata(skillPath);
592
+
593
+ skills.push({
594
+ name: entry.name,
595
+ title: metadata.title,
596
+ description: metadata.description,
597
+ repository: `${owner}/${repo}`,
598
+ installed: installedSkills.has(entry.name),
599
+ path: skillPath
600
+ });
601
+ }
602
+ }
603
+ }
604
+ } catch (err) {
605
+ console.error('Error scanning repos:', err);
606
+ }
607
+
608
+ res.json({ skills });
609
+ } catch (error) {
610
+ console.error('Error listing available skills:', error);
611
+ res.status(500).json({ error: error.message });
612
+ }
613
+ });
614
+
615
+ /**
616
+ * POST /api/skills/install/:name
617
+ * Install a skill from repository
618
+ */
619
+ router.post('/install/:name', async (req, res) => {
620
+ try {
621
+ const userUuid = req.user?.uuid;
622
+ if (!userUuid) {
623
+ return res.status(401).json({ error: 'User authentication required' });
624
+ }
625
+
626
+ const { name } = req.params;
627
+ const { skillPath } = req.body;
628
+
629
+ if (!SKILL_NAME_REGEX.test(name)) {
630
+ return res.status(400).json({ error: 'Invalid skill name' });
631
+ }
632
+
633
+ if (!skillPath) {
634
+ return res.status(400).json({ error: 'Skill path is required' });
635
+ }
636
+
637
+ const userPaths = getUserPaths(userUuid);
638
+
639
+ // Verify skill exists
640
+ if (!await isValidSkill(skillPath)) {
641
+ return res.status(404).json({ error: 'Skill not found or invalid' });
642
+ }
643
+
644
+ // Create symlink directly from user's skills directory to skill in repo
645
+ const userSkillLink = path.join(userPaths.skillsDir, name);
646
+
647
+ try {
648
+ await fs.unlink(userSkillLink);
649
+ } catch {
650
+ // Ignore
651
+ }
652
+
653
+ await fs.symlink(skillPath, userSkillLink);
654
+
655
+ const metadata = await parseSkillMetadata(skillPath);
656
+
657
+ res.json({
658
+ success: true,
659
+ skill: {
660
+ name,
661
+ title: metadata.title,
662
+ description: metadata.description,
663
+ source: 'repo'
664
+ }
665
+ });
666
+ } catch (error) {
667
+ console.error('Error installing skill:', error);
668
+ res.status(500).json({ error: error.message });
669
+ }
670
+ });
671
+
672
+ /**
673
+ * GET /api/skills/repos
674
+ * List user's added skill repositories
675
+ */
676
+ router.get('/repos', async (req, res) => {
677
+ try {
678
+ const userUuid = req.user?.uuid;
679
+ if (!userUuid) {
680
+ return res.status(401).json({ error: 'User authentication required' });
681
+ }
682
+
683
+ const userPaths = getUserPaths(userUuid);
684
+ const repos = [];
685
+
686
+ try {
687
+ await fs.mkdir(userPaths.skillsRepoDir, { recursive: true });
688
+ const owners = await fs.readdir(userPaths.skillsRepoDir);
689
+
690
+ for (const owner of owners) {
691
+ if (owner.startsWith('.')) continue;
692
+
693
+ const ownerPath = path.join(userPaths.skillsRepoDir, owner);
694
+ const stat = await fs.stat(ownerPath);
695
+ if (!stat.isDirectory()) continue;
696
+
697
+ const repoNames = await fs.readdir(ownerPath);
698
+
699
+ for (const repo of repoNames) {
700
+ if (repo.startsWith('.')) continue;
701
+
702
+ const repoPath = path.join(ownerPath, repo);
703
+ let realPath = repoPath;
704
+
705
+ try {
706
+ const repoStat = await fs.lstat(repoPath);
707
+ if (repoStat.isSymbolicLink()) {
708
+ realPath = await fs.realpath(repoPath);
709
+ }
710
+ } catch {
711
+ continue;
712
+ }
713
+
714
+ // Count skills in repo
715
+ let skillCount = 0;
716
+ try {
717
+ const entries = await fs.readdir(realPath, { withFileTypes: true });
718
+ for (const entry of entries) {
719
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
720
+ if (await isValidSkill(path.join(realPath, entry.name))) {
721
+ skillCount++;
722
+ }
723
+ }
724
+ } catch {
725
+ // Ignore
726
+ }
727
+
728
+ repos.push({
729
+ owner,
730
+ repo,
731
+ url: `https://github.com/${owner}/${repo}`,
732
+ skillCount,
733
+ path: realPath
734
+ });
735
+ }
736
+ }
737
+ } catch (err) {
738
+ console.error('Error reading repos:', err);
739
+ }
740
+
741
+ res.json({ repos });
742
+ } catch (error) {
743
+ console.error('Error listing repos:', error);
744
+ res.status(500).json({ error: error.message });
745
+ }
746
+ });
747
+
748
+ /**
749
+ * POST /api/skills/repos
750
+ * Add (clone) a skill repository
751
+ */
752
+ router.post('/repos', async (req, res) => {
753
+ try {
754
+ const userUuid = req.user?.uuid;
755
+ if (!userUuid) {
756
+ return res.status(401).json({ error: 'User authentication required' });
757
+ }
758
+
759
+ let { url, branch = 'main' } = req.body;
760
+
761
+ // Handle short format: owner/repo -> https://github.com/owner/repo
762
+ if (url && !url.includes('://') && /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(url.trim())) {
763
+ url = `https://github.com/${url.trim()}`;
764
+ }
765
+
766
+ // Validate URL
767
+ const validation = validateGitUrl(url);
768
+ if (!validation.valid) {
769
+ return res.status(400).json({ error: validation.error });
770
+ }
771
+
772
+ // Parse owner/repo from URL
773
+ const parsed = parseGitUrl(url);
774
+ if (!parsed) {
775
+ return res.status(400).json({ error: 'Could not parse repository URL' });
776
+ }
777
+
778
+ const { owner, repo } = parsed;
779
+ const userPaths = getUserPaths(userUuid);
780
+ const publicPaths = getPublicPaths();
781
+
782
+ // Public repo path
783
+ const publicRepoPath = path.join(publicPaths.skillsRepoDir, owner, repo);
784
+
785
+ // Check if already cloned publicly
786
+ let needsClone = true;
787
+ try {
788
+ await fs.access(publicRepoPath);
789
+ needsClone = false;
790
+ // Try to update
791
+ try {
792
+ await updateRepository(publicRepoPath);
793
+ } catch (err) {
794
+ console.log('Failed to update repo, using existing:', err.message);
795
+ }
796
+ } catch {
797
+ // Need to clone
798
+ }
799
+
800
+ if (needsClone) {
801
+ // Clone to public directory
802
+ await fs.mkdir(path.dirname(publicRepoPath), { recursive: true });
803
+ await cloneRepository(url, publicRepoPath);
804
+ }
805
+
806
+ // Create user symlink
807
+ const userRepoPath = path.join(userPaths.skillsRepoDir, owner, repo);
808
+ await fs.mkdir(path.dirname(userRepoPath), { recursive: true });
809
+
810
+ try {
811
+ await fs.unlink(userRepoPath);
812
+ } catch {
813
+ // Ignore
814
+ }
815
+
816
+ await fs.symlink(publicRepoPath, userRepoPath);
817
+
818
+ // Count skills
819
+ let skillCount = 0;
820
+ try {
821
+ const entries = await fs.readdir(publicRepoPath, { withFileTypes: true });
822
+ for (const entry of entries) {
823
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
824
+ if (await isValidSkill(path.join(publicRepoPath, entry.name))) {
825
+ skillCount++;
826
+ }
827
+ }
828
+ } catch {
829
+ // Ignore
830
+ }
831
+
832
+ res.json({
833
+ success: true,
834
+ repo: {
835
+ owner,
836
+ repo,
837
+ url,
838
+ skillCount,
839
+ path: publicRepoPath
840
+ }
841
+ });
842
+ } catch (error) {
843
+ console.error('Error adding repo:', error);
844
+ res.status(500).json({ error: error.message });
845
+ }
846
+ });
847
+
848
+ /**
849
+ * DELETE /api/skills/repos/:owner/:repo
850
+ * Remove a skill repository (user's symlink only)
851
+ */
852
+ router.delete('/repos/:owner/:repo', async (req, res) => {
853
+ try {
854
+ const userUuid = req.user?.uuid;
855
+ if (!userUuid) {
856
+ return res.status(401).json({ error: 'User authentication required' });
857
+ }
858
+
859
+ const { owner, repo } = req.params;
860
+
861
+ if (!owner || !repo) {
862
+ return res.status(400).json({ error: 'Owner and repo are required' });
863
+ }
864
+
865
+ const userPaths = getUserPaths(userUuid);
866
+ const userRepoPath = path.join(userPaths.skillsRepoDir, owner, repo);
867
+
868
+ try {
869
+ await fs.unlink(userRepoPath);
870
+ } catch (err) {
871
+ if (err.code === 'ENOENT') {
872
+ return res.status(404).json({ error: 'Repository not found' });
873
+ }
874
+ throw err;
875
+ }
876
+
877
+ // Try to remove empty parent directory
878
+ try {
879
+ await fs.rmdir(path.dirname(userRepoPath));
880
+ } catch {
881
+ // Ignore - directory not empty
882
+ }
883
+
884
+ res.json({ success: true, message: 'Repository removed' });
885
+ } catch (error) {
886
+ console.error('Error removing repo:', error);
887
+ res.status(500).json({ error: error.message });
888
+ }
889
+ });
890
+
891
+ export default router;