@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.
- package/LICENSE +675 -0
- package/README.md +330 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-B4ru3EJb.css +32 -0
- package/dist/assets/index-DDFuyrpY.js +154 -0
- package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
- package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
- package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
- package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
- package/dist/assets/vendor-react-BeVl62c0.js +59 -0
- package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
- package/dist/assets/vendor-utils-00TdZexr.js +1 -0
- package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +9 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-128x128.svg +12 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-144x144.svg +12 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-152x152.svg +12 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-192x192.svg +12 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-384x384.svg +12 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-512x512.svg +12 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-72x72.svg +12 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/icon-96x96.svg +12 -0
- package/dist/icons/icon-template.svg +12 -0
- package/dist/index.html +57 -0
- package/dist/logo-128.png +0 -0
- package/dist/logo-256.png +0 -0
- package/dist/logo-32.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-64.png +0 -0
- package/dist/logo.svg +17 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/cli-selection.png +0 -0
- package/dist/screenshots/desktop-main.png +0 -0
- package/dist/screenshots/mobile-chat.png +0 -0
- package/dist/screenshots/tools-modal.png +0 -0
- package/dist/sw.js +49 -0
- package/package.json +113 -0
- package/server/claude-sdk.js +791 -0
- package/server/cli.js +330 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +523 -0
- package/server/database/init.sql +23 -0
- package/server/index.js +1678 -0
- package/server/load-env.js +27 -0
- package/server/middleware/auth.js +118 -0
- package/server/projects.js +899 -0
- package/server/routes/admin.js +89 -0
- package/server/routes/auth.js +144 -0
- package/server/routes/commands.js +570 -0
- package/server/routes/mcp-utils.js +37 -0
- package/server/routes/mcp.js +593 -0
- package/server/routes/projects.js +216 -0
- package/server/routes/skills.js +891 -0
- package/server/routes/usage.js +206 -0
- package/server/services/pricing.js +196 -0
- package/server/services/usage-scanner.js +283 -0
- package/server/services/user-directories.js +123 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/mcp-detector.js +73 -0
- 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;
|