@ian2018cs/agenthub 0.1.52 → 0.1.53
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/dist/assets/index-BdtjtPre.css +32 -0
- package/dist/assets/index-_a9nlevD.js +162 -0
- package/dist/assets/{vendor-icons-KP5LHo3O.js → vendor-icons-D0_WToWG.js} +93 -73
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/database/db.js +77 -1
- package/server/database/init.sql +23 -1
- package/server/index.js +4 -0
- package/server/projects.js +2 -1
- package/server/routes/agents.js +838 -0
- package/server/services/llm.js +46 -0
- package/server/services/system-agent-repo.js +276 -0
- package/server/services/user-directories.js +5 -1
- package/dist/assets/index-BXrxw5Li.js +0 -152
- package/dist/assets/index-DsfWMhMj.css +0 -32
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import AdmZip from 'adm-zip';
|
|
7
|
+
import { getUserPaths, getPublicPaths } from '../services/user-directories.js';
|
|
8
|
+
import { scanAgents, ensureAgentRepo, incrementPatchVersion, publishAgentToRepo } from '../services/system-agent-repo.js';
|
|
9
|
+
import { addProjectManually, loadProjectConfig, saveProjectConfig } from '../projects.js';
|
|
10
|
+
import { agentSubmissionDb } from '../database/db.js';
|
|
11
|
+
import { chatCompletion } from '../services/llm.js';
|
|
12
|
+
|
|
13
|
+
const router = express.Router();
|
|
14
|
+
|
|
15
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function isSshUrl(url) {
|
|
18
|
+
return /^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+:.+$/.test(url);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseSshUrl(url) {
|
|
22
|
+
const match = url.match(/^[a-zA-Z0-9_-]+@([a-zA-Z0-9.-]+):(.+)$/);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
const host = match[1].toLowerCase();
|
|
25
|
+
const pathPart = match[2].replace(/\.git$/, '');
|
|
26
|
+
const parts = pathPart.split('/');
|
|
27
|
+
if (parts.length >= 2) return { host, owner: parts[0], repo: parts[1] };
|
|
28
|
+
return { host, owner: null, repo: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseGitUrl(url) {
|
|
32
|
+
if (isSshUrl(url)) {
|
|
33
|
+
const parsed = parseSshUrl(url);
|
|
34
|
+
if (parsed && parsed.owner && parsed.repo) return { owner: parsed.owner, repo: parsed.repo };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(url);
|
|
39
|
+
const parts = u.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
|
|
40
|
+
if (parts.length >= 2) return { owner: parts[0], repo: parts[1] };
|
|
41
|
+
} catch {}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runGit(args, cwd = null) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
|
|
48
|
+
if (cwd) opts.cwd = cwd;
|
|
49
|
+
const proc = spawn('git', args, opts);
|
|
50
|
+
let stderr = '';
|
|
51
|
+
proc.stderr.on('data', d => { stderr += d.toString(); });
|
|
52
|
+
proc.on('close', code => {
|
|
53
|
+
if (code === 0) resolve();
|
|
54
|
+
else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
|
|
55
|
+
});
|
|
56
|
+
proc.on('error', err => reject(err));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensure a skill repo is available for the user. Clone if needed.
|
|
62
|
+
* Returns the local path to the skill directory inside the repo.
|
|
63
|
+
*/
|
|
64
|
+
async function ensureSkillRepo(repoUrl, userUuid) {
|
|
65
|
+
const parsed = parseGitUrl(repoUrl);
|
|
66
|
+
if (!parsed) throw new Error(`Cannot parse git URL: ${repoUrl}`);
|
|
67
|
+
|
|
68
|
+
const { owner, repo } = parsed;
|
|
69
|
+
const publicPaths = getPublicPaths();
|
|
70
|
+
const userPaths = getUserPaths(userUuid);
|
|
71
|
+
|
|
72
|
+
const publicRepoPath = path.join(publicPaths.skillsRepoDir, owner, repo);
|
|
73
|
+
const userRepoPath = path.join(userPaths.skillsRepoDir, owner, repo);
|
|
74
|
+
|
|
75
|
+
// Clone to public dir if not present
|
|
76
|
+
try {
|
|
77
|
+
await fs.access(publicRepoPath);
|
|
78
|
+
} catch {
|
|
79
|
+
await fs.mkdir(path.dirname(publicRepoPath), { recursive: true });
|
|
80
|
+
await runGit(['clone', '--depth', '1', repoUrl, publicRepoPath]);
|
|
81
|
+
console.log(`[AgentInstall] Cloned skill repo ${repoUrl}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Create user symlink if not present
|
|
85
|
+
try {
|
|
86
|
+
await fs.lstat(userRepoPath);
|
|
87
|
+
} catch {
|
|
88
|
+
await fs.mkdir(path.dirname(userRepoPath), { recursive: true });
|
|
89
|
+
await fs.symlink(publicRepoPath, userRepoPath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return publicRepoPath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Install a single skill by name from a given repo URL.
|
|
97
|
+
*/
|
|
98
|
+
async function installSkill(skillName, repoUrl, userUuid) {
|
|
99
|
+
const publicRepoPath = await ensureSkillRepo(repoUrl, userUuid);
|
|
100
|
+
const userPaths = getUserPaths(userUuid);
|
|
101
|
+
|
|
102
|
+
// Search for skill directory in repo (supports 1 level nesting)
|
|
103
|
+
const skillPath = await findSkillInRepo(publicRepoPath, skillName);
|
|
104
|
+
if (!skillPath) {
|
|
105
|
+
console.warn(`[AgentInstall] Skill "${skillName}" not found in repo`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const linkPath = path.join(userPaths.skillsDir, skillName);
|
|
110
|
+
try { await fs.unlink(linkPath); } catch {}
|
|
111
|
+
await fs.symlink(skillPath, linkPath);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Get the git remote origin URL for a given local repo directory. */
|
|
116
|
+
async function getGitRemoteUrl(repoDir) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const proc = spawn('git', ['remote', 'get-url', 'origin'], {
|
|
119
|
+
cwd: repoDir,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
121
|
+
});
|
|
122
|
+
let out = '';
|
|
123
|
+
proc.stdout.on('data', d => { out += d.toString(); });
|
|
124
|
+
proc.on('close', code => resolve(code === 0 ? out.trim() : ''));
|
|
125
|
+
proc.on('error', () => resolve(''));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Given a skill symlink path, resolve it and find the git repo root inside the
|
|
131
|
+
* public skills repo dir, then return its remote URL.
|
|
132
|
+
*/
|
|
133
|
+
async function getSkillRepoUrl(skillLinkPath, publicSkillsRepoDir) {
|
|
134
|
+
try {
|
|
135
|
+
const realTarget = await fs.realpath(skillLinkPath);
|
|
136
|
+
// Walk up from the symlink target until we find a .git directory
|
|
137
|
+
// that is still inside publicSkillsRepoDir
|
|
138
|
+
let dir = path.dirname(realTarget);
|
|
139
|
+
while (dir.startsWith(publicSkillsRepoDir) && dir !== publicSkillsRepoDir) {
|
|
140
|
+
try {
|
|
141
|
+
await fs.access(path.join(dir, '.git'));
|
|
142
|
+
return await getGitRemoteUrl(dir);
|
|
143
|
+
} catch {
|
|
144
|
+
dir = path.dirname(dir);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Scan the public mcp repo dir to find which cloned repo contains a given
|
|
153
|
+
* MCP service directory, and return that repo's remote URL.
|
|
154
|
+
*/
|
|
155
|
+
async function getMcpRepoUrl(mcpName, publicMcpRepoDir) {
|
|
156
|
+
try {
|
|
157
|
+
const owners = await fs.readdir(publicMcpRepoDir);
|
|
158
|
+
for (const owner of owners) {
|
|
159
|
+
const ownerDir = path.join(publicMcpRepoDir, owner);
|
|
160
|
+
let stat;
|
|
161
|
+
try { stat = await fs.stat(ownerDir); } catch { continue; }
|
|
162
|
+
if (!stat.isDirectory()) continue;
|
|
163
|
+
const repos = await fs.readdir(ownerDir);
|
|
164
|
+
for (const repo of repos) {
|
|
165
|
+
const repoDir = path.join(ownerDir, repo);
|
|
166
|
+
const mcpJsonPath = path.join(repoDir, mcpName, 'mcp.json');
|
|
167
|
+
try {
|
|
168
|
+
await fs.access(mcpJsonPath);
|
|
169
|
+
return await getGitRemoteUrl(repoDir);
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Scan CLAUDE.md for referenced local files (markdown links, excluding URLs).
|
|
179
|
+
* Returns array of { path: relativePath, exists: bool }.
|
|
180
|
+
*/
|
|
181
|
+
async function scanClaudeMdRefs(projectPath) {
|
|
182
|
+
let content;
|
|
183
|
+
try {
|
|
184
|
+
content = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf-8');
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Match [text](path) — skip http/https/mailto/# links and anchors
|
|
190
|
+
const linkRe = /\[[^\]]*\]\(([^)]+)\)/g;
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const results = [];
|
|
193
|
+
let m;
|
|
194
|
+
while ((m = linkRe.exec(content)) !== null) {
|
|
195
|
+
let ref = m[1].split('#')[0].trim(); // strip fragment
|
|
196
|
+
if (!ref) continue;
|
|
197
|
+
if (/^https?:\/\/|^mailto:|^\/\//.test(ref)) continue; // skip absolute URLs
|
|
198
|
+
if (path.isAbsolute(ref)) continue; // skip absolute paths
|
|
199
|
+
if (seen.has(ref)) continue;
|
|
200
|
+
seen.add(ref);
|
|
201
|
+
|
|
202
|
+
const absPath = path.join(projectPath, ref);
|
|
203
|
+
let exists = false;
|
|
204
|
+
try { await fs.access(absPath); exists = true; } catch {}
|
|
205
|
+
results.push({ path: ref, exists });
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function findSkillInRepo(repoPath, skillName) {
|
|
211
|
+
// Direct match
|
|
212
|
+
const direct = path.join(repoPath, skillName);
|
|
213
|
+
try {
|
|
214
|
+
await fs.access(path.join(direct, 'SKILL.md'));
|
|
215
|
+
return direct;
|
|
216
|
+
} catch {}
|
|
217
|
+
// Nested under skills/
|
|
218
|
+
const nested = path.join(repoPath, 'skills', skillName);
|
|
219
|
+
try {
|
|
220
|
+
await fs.access(path.join(nested, 'SKILL.md'));
|
|
221
|
+
return nested;
|
|
222
|
+
} catch {}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Ensure an MCP repo is available for the user. Clone if needed.
|
|
228
|
+
*/
|
|
229
|
+
async function ensureMcpRepo(repoUrl, userUuid) {
|
|
230
|
+
const parsed = parseGitUrl(repoUrl);
|
|
231
|
+
if (!parsed) throw new Error(`Cannot parse git URL: ${repoUrl}`);
|
|
232
|
+
|
|
233
|
+
const { owner, repo } = parsed;
|
|
234
|
+
const publicPaths = getPublicPaths();
|
|
235
|
+
const userPaths = getUserPaths(userUuid);
|
|
236
|
+
|
|
237
|
+
const publicRepoPath = path.join(publicPaths.mcpRepoDir, owner, repo);
|
|
238
|
+
const userRepoPath = path.join(userPaths.mcpRepoDir, owner, repo);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await fs.access(publicRepoPath);
|
|
242
|
+
} catch {
|
|
243
|
+
await fs.mkdir(path.dirname(publicRepoPath), { recursive: true });
|
|
244
|
+
await runGit(['clone', '--depth', '1', repoUrl, publicRepoPath]);
|
|
245
|
+
console.log(`[AgentInstall] Cloned MCP repo ${repoUrl}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await fs.lstat(userRepoPath);
|
|
250
|
+
} catch {
|
|
251
|
+
await fs.mkdir(path.dirname(userRepoPath), { recursive: true });
|
|
252
|
+
await fs.symlink(publicRepoPath, userRepoPath);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return publicRepoPath;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Install a single MCP server by service dir name from a given repo URL.
|
|
260
|
+
*/
|
|
261
|
+
async function installMcp(mcpName, repoUrl, userUuid) {
|
|
262
|
+
const publicRepoPath = await ensureMcpRepo(repoUrl, userUuid);
|
|
263
|
+
const userPaths = getUserPaths(userUuid);
|
|
264
|
+
|
|
265
|
+
// Find service directory
|
|
266
|
+
const servicePath = path.join(publicRepoPath, mcpName);
|
|
267
|
+
const mcpJsonPath = path.join(servicePath, 'mcp.json');
|
|
268
|
+
let mcpJson;
|
|
269
|
+
try {
|
|
270
|
+
mcpJson = JSON.parse(await fs.readFile(mcpJsonPath, 'utf-8'));
|
|
271
|
+
} catch {
|
|
272
|
+
console.warn(`[AgentInstall] mcp.json not found for "${mcpName}"`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Write to user's .claude.json
|
|
277
|
+
const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
|
|
278
|
+
let claudeConfig = {};
|
|
279
|
+
try {
|
|
280
|
+
claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
|
|
281
|
+
} catch {
|
|
282
|
+
claudeConfig = { hasCompletedOnboarding: true };
|
|
283
|
+
}
|
|
284
|
+
claudeConfig.mcpServers = { ...(claudeConfig.mcpServers || {}), ...mcpJson };
|
|
285
|
+
await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Routes ──────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* GET /api/agents
|
|
293
|
+
* List all available agents from the system agent repo
|
|
294
|
+
*/
|
|
295
|
+
router.get('/', async (_req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const agents = await scanAgents();
|
|
298
|
+
res.json({ agents });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error('Error listing agents:', error);
|
|
301
|
+
res.status(500).json({ error: 'Failed to list agents', details: error.message });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* POST /api/agents/refresh
|
|
307
|
+
* Pull the latest changes from the agent repo
|
|
308
|
+
*/
|
|
309
|
+
router.post('/refresh', async (_req, res) => {
|
|
310
|
+
try {
|
|
311
|
+
await ensureAgentRepo();
|
|
312
|
+
const agents = await scanAgents();
|
|
313
|
+
res.json({ success: true, agentCount: agents.length });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error('Error refreshing agent repo:', error);
|
|
316
|
+
res.status(500).json({ error: 'Failed to refresh agent repo', details: error.message });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* POST /api/agents/install
|
|
322
|
+
* Install an agent: create project, copy CLAUDE.md, install Skills + MCPs
|
|
323
|
+
*/
|
|
324
|
+
router.post('/install', async (req, res) => {
|
|
325
|
+
try {
|
|
326
|
+
const userUuid = req.user?.uuid;
|
|
327
|
+
if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
|
|
328
|
+
|
|
329
|
+
const { agentName, force = false } = req.body;
|
|
330
|
+
if (!agentName) return res.status(400).json({ error: 'agentName is required' });
|
|
331
|
+
|
|
332
|
+
// Scan to find the agent
|
|
333
|
+
const agents = await scanAgents();
|
|
334
|
+
const agent = agents.find(a => a.name === agentName || a.dirName === agentName);
|
|
335
|
+
if (!agent) return res.status(404).json({ error: `Agent "${agentName}" not found` });
|
|
336
|
+
|
|
337
|
+
const userPaths = getUserPaths(userUuid);
|
|
338
|
+
const projectDir = path.join(userPaths.projectsDir, agentName);
|
|
339
|
+
|
|
340
|
+
// Create project directory
|
|
341
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
342
|
+
|
|
343
|
+
// Register project (idempotent)
|
|
344
|
+
let project;
|
|
345
|
+
try {
|
|
346
|
+
project = await addProjectManually(projectDir, agent.display_name || agentName, userUuid);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// Project already exists — load from config
|
|
349
|
+
const config = await loadProjectConfig(userUuid);
|
|
350
|
+
const projectKey = projectDir.replace(/\//g, '-');
|
|
351
|
+
const entry = config[projectKey] || {};
|
|
352
|
+
project = {
|
|
353
|
+
name: projectKey,
|
|
354
|
+
path: projectDir,
|
|
355
|
+
fullPath: projectDir,
|
|
356
|
+
displayName: entry.displayName || agentName,
|
|
357
|
+
isManuallyAdded: true,
|
|
358
|
+
sessions: []
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// CLAUDE.md conflict check (only when updating an existing install)
|
|
363
|
+
let claudeMdHash = '';
|
|
364
|
+
if (agent.hasClaudeMd && !force) {
|
|
365
|
+
const existingConfig = await loadProjectConfig(userUuid);
|
|
366
|
+
const existingKey = projectDir.replace(/\//g, '-');
|
|
367
|
+
const storedHash = existingConfig[existingKey]?.agentInfo?.claudeMdHash;
|
|
368
|
+
if (storedHash) {
|
|
369
|
+
try {
|
|
370
|
+
const localContent = await fs.readFile(path.join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
371
|
+
const localHash = createHash('sha256').update(localContent).digest('hex');
|
|
372
|
+
if (localHash !== storedHash) {
|
|
373
|
+
let repoContent = '';
|
|
374
|
+
try { repoContent = await fs.readFile(path.join(agent.path, 'CLAUDE.md'), 'utf-8'); } catch {}
|
|
375
|
+
return res.status(409).json({
|
|
376
|
+
conflict: true,
|
|
377
|
+
error: '本地 CLAUDE.md 已被修改,更新将覆盖本地改动',
|
|
378
|
+
localContent,
|
|
379
|
+
repoContent
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Copy all agent files to project directory (excluding agent.yaml metadata)
|
|
387
|
+
// This includes CLAUDE.md and any referenced files in subdirectories
|
|
388
|
+
try {
|
|
389
|
+
const agentEntries = await fs.readdir(agent.path, { withFileTypes: true });
|
|
390
|
+
for (const entry of agentEntries) {
|
|
391
|
+
if (entry.name === 'agent.yaml') continue;
|
|
392
|
+
const src = path.join(agent.path, entry.name);
|
|
393
|
+
const dst = path.join(projectDir, entry.name);
|
|
394
|
+
if (entry.isDirectory()) {
|
|
395
|
+
await fs.cp(src, dst, { recursive: true, force: true });
|
|
396
|
+
} else {
|
|
397
|
+
await fs.copyFile(src, dst);
|
|
398
|
+
if (entry.name === 'CLAUDE.md') {
|
|
399
|
+
try {
|
|
400
|
+
const content = await fs.readFile(dst, 'utf-8');
|
|
401
|
+
claudeMdHash = createHash('sha256').update(content).digest('hex');
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.error('[AgentInstall] Failed to copy agent files:', err.message);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Install skills
|
|
411
|
+
const skillResults = [];
|
|
412
|
+
for (const skill of agent.skills || []) {
|
|
413
|
+
if (!skill.name || !skill.repo) continue;
|
|
414
|
+
try {
|
|
415
|
+
const ok = await installSkill(skill.name, skill.repo, userUuid);
|
|
416
|
+
skillResults.push({ name: skill.name, success: ok });
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.error(`[AgentInstall] Failed to install skill "${skill.name}":`, err.message);
|
|
419
|
+
skillResults.push({ name: skill.name, success: false, error: err.message });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Install MCPs
|
|
424
|
+
const mcpResults = [];
|
|
425
|
+
for (const mcp of agent.mcps || []) {
|
|
426
|
+
if (!mcp.name || !mcp.repo) continue;
|
|
427
|
+
try {
|
|
428
|
+
const ok = await installMcp(mcp.name, mcp.repo, userUuid);
|
|
429
|
+
mcpResults.push({ name: mcp.name, success: ok });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error(`[AgentInstall] Failed to install MCP "${mcp.name}":`, err.message);
|
|
432
|
+
mcpResults.push({ name: mcp.name, success: false, error: err.message });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Mark project as agent in project-config.json
|
|
437
|
+
const config = await loadProjectConfig(userUuid);
|
|
438
|
+
const projectKey = projectDir.replace(/\//g, '-');
|
|
439
|
+
config[projectKey] = {
|
|
440
|
+
...(config[projectKey] || {}),
|
|
441
|
+
agentInfo: {
|
|
442
|
+
agentName: agent.dirName || agentName,
|
|
443
|
+
installedVersion: agent.version,
|
|
444
|
+
installedAt: new Date().toISOString(),
|
|
445
|
+
isAgent: true,
|
|
446
|
+
claudeMdHash
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
await saveProjectConfig(config, userUuid);
|
|
450
|
+
|
|
451
|
+
res.json({
|
|
452
|
+
success: true,
|
|
453
|
+
project: { ...project, agentInfo: config[projectKey].agentInfo },
|
|
454
|
+
skills: skillResults,
|
|
455
|
+
mcps: mcpResults
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error('Error installing agent:', error);
|
|
459
|
+
res.status(500).json({ error: 'Failed to install agent', details: error.message });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* GET /api/agents/preview
|
|
465
|
+
* Preview what files/skills/MCPs a project would include if submitted as an Agent
|
|
466
|
+
*/
|
|
467
|
+
router.get('/preview', async (req, res) => {
|
|
468
|
+
try {
|
|
469
|
+
const userUuid = req.user?.uuid;
|
|
470
|
+
if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
|
|
471
|
+
|
|
472
|
+
const { projectKey } = req.query;
|
|
473
|
+
if (!projectKey) return res.status(400).json({ error: 'projectKey is required' });
|
|
474
|
+
|
|
475
|
+
const config = await loadProjectConfig(userUuid);
|
|
476
|
+
const entry = config[projectKey];
|
|
477
|
+
if (!entry) return res.status(404).json({ error: 'Project not found' });
|
|
478
|
+
|
|
479
|
+
const projectPath = entry.originalPath || projectKey.replace(/-/g, '/');
|
|
480
|
+
const userPaths = getUserPaths(userUuid);
|
|
481
|
+
|
|
482
|
+
// Check CLAUDE.md
|
|
483
|
+
let hasClaudeMd = false;
|
|
484
|
+
try { await fs.access(path.join(projectPath, 'CLAUDE.md')); hasClaudeMd = true; } catch {}
|
|
485
|
+
|
|
486
|
+
// Get installed skills with repo URLs
|
|
487
|
+
const skills = [];
|
|
488
|
+
const publicPaths = getPublicPaths();
|
|
489
|
+
try {
|
|
490
|
+
const skillNames = await fs.readdir(userPaths.skillsDir);
|
|
491
|
+
for (const name of skillNames) {
|
|
492
|
+
if (name.startsWith('.')) continue;
|
|
493
|
+
const linkPath = path.join(userPaths.skillsDir, name);
|
|
494
|
+
const repo = await getSkillRepoUrl(linkPath, publicPaths.skillsRepoDir);
|
|
495
|
+
skills.push({ name, repo });
|
|
496
|
+
}
|
|
497
|
+
} catch {}
|
|
498
|
+
|
|
499
|
+
// Get configured MCPs from .claude.json with repo URLs
|
|
500
|
+
const mcps = [];
|
|
501
|
+
try {
|
|
502
|
+
const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
|
|
503
|
+
const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
|
|
504
|
+
for (const name of Object.keys(claudeConfig.mcpServers || {})) {
|
|
505
|
+
const repo = await getMcpRepoUrl(name, publicPaths.mcpRepoDir);
|
|
506
|
+
mcps.push({ name, repo });
|
|
507
|
+
}
|
|
508
|
+
} catch {}
|
|
509
|
+
|
|
510
|
+
// Scan CLAUDE.md for referenced local files
|
|
511
|
+
const refFiles = await scanClaudeMdRefs(projectPath);
|
|
512
|
+
|
|
513
|
+
res.json({ hasClaudeMd, skills, mcps, refFiles });
|
|
514
|
+
} catch (error) {
|
|
515
|
+
console.error('Error getting agent preview:', error);
|
|
516
|
+
res.status(500).json({ error: 'Failed to get preview', details: error.message });
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* POST /api/agents/generate-description
|
|
522
|
+
* Use LLM to generate an Agent description based on CLAUDE.md + selected skills/MCPs.
|
|
523
|
+
*/
|
|
524
|
+
router.post('/generate-description', async (req, res) => {
|
|
525
|
+
try {
|
|
526
|
+
const userUuid = req.user?.uuid;
|
|
527
|
+
if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
|
|
528
|
+
|
|
529
|
+
const { projectKey, skills = [], mcps = [] } = req.body;
|
|
530
|
+
if (!projectKey) return res.status(400).json({ error: 'projectKey is required' });
|
|
531
|
+
|
|
532
|
+
const config = await loadProjectConfig(userUuid);
|
|
533
|
+
const entry = config[projectKey];
|
|
534
|
+
if (!entry) return res.status(404).json({ error: 'Project not found' });
|
|
535
|
+
|
|
536
|
+
const projectPath = entry.originalPath || projectKey.replace(/-/g, '/');
|
|
537
|
+
|
|
538
|
+
// Read CLAUDE.md if available
|
|
539
|
+
let claudeMdContent = '';
|
|
540
|
+
try {
|
|
541
|
+
claudeMdContent = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf-8');
|
|
542
|
+
} catch { /* optional */ }
|
|
543
|
+
|
|
544
|
+
// Build context for LLM
|
|
545
|
+
const skillList = skills.length > 0 ? skills.map(s => `- ${s}`).join('\n') : '(无)';
|
|
546
|
+
const mcpList = mcps.length > 0 ? mcps.map(m => `- ${m}`).join('\n') : '(无)';
|
|
547
|
+
|
|
548
|
+
const systemPrompt = `你是一个技术文档专家,专门为 AI Agent 编写简洁、清晰的功能描述。
|
|
549
|
+
描述应该:
|
|
550
|
+
- 简明扼要,2-4 句话
|
|
551
|
+
- 说明 Agent 的主要用途和能力
|
|
552
|
+
- 自然流畅,不使用模板化套话
|
|
553
|
+
- 使用中文
|
|
554
|
+
只输出描述内容,不要有前缀或标题。`;
|
|
555
|
+
|
|
556
|
+
const userPrompt = `请根据以下信息,为这个 Claude Agent 生成一段功能描述:
|
|
557
|
+
|
|
558
|
+
${claudeMdContent ? `## CLAUDE.md 内容\n${claudeMdContent.slice(0, 3000)}\n` : '## CLAUDE.md\n(未找到)\n'}
|
|
559
|
+
## 已集成的 Skills
|
|
560
|
+
${skillList}
|
|
561
|
+
|
|
562
|
+
## 已集成的 MCP 服务
|
|
563
|
+
${mcpList}`;
|
|
564
|
+
|
|
565
|
+
const description = await chatCompletion({
|
|
566
|
+
systemPrompt,
|
|
567
|
+
userPrompt,
|
|
568
|
+
model: process.env.LLM_DEFAULT_MODEL || 'gemini-3.1-flash-lite-preview',
|
|
569
|
+
maxTokens: 300,
|
|
570
|
+
temperature: 0.7
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
res.json({ description });
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error('Error generating description:', error);
|
|
576
|
+
res.status(500).json({ error: error.message || 'Failed to generate description' });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* POST /api/agents/submit
|
|
582
|
+
* Submit a project as an agent for review.
|
|
583
|
+
* Builds the ZIP server-side from the project's CLAUDE.md + agent.yaml (provided in body).
|
|
584
|
+
*/
|
|
585
|
+
router.post('/submit', async (req, res) => {
|
|
586
|
+
try {
|
|
587
|
+
const userUuid = req.user?.uuid;
|
|
588
|
+
const userId = req.user?.id;
|
|
589
|
+
if (!userUuid || !userId) return res.status(401).json({ error: 'User authentication required' });
|
|
590
|
+
|
|
591
|
+
const { agentName, displayName, description, projectKey, agentYaml, refFiles = [] } = req.body;
|
|
592
|
+
if (!agentName) return res.status(400).json({ error: 'agentName is required' });
|
|
593
|
+
if (!displayName) return res.status(400).json({ error: 'displayName is required' });
|
|
594
|
+
if (!projectKey) return res.status(400).json({ error: 'projectKey is required' });
|
|
595
|
+
if (!agentYaml) return res.status(400).json({ error: 'agentYaml is required' });
|
|
596
|
+
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(agentName)) {
|
|
597
|
+
return res.status(400).json({ error: 'agentName must be 1-100 alphanumeric, hyphen, or underscore characters' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Resolve project path from config
|
|
601
|
+
const config = await loadProjectConfig(userUuid);
|
|
602
|
+
const entry = config[projectKey];
|
|
603
|
+
if (!entry) return res.status(404).json({ error: 'Project not found' });
|
|
604
|
+
const projectPath = entry.originalPath || projectKey.replace(/-/g, '/');
|
|
605
|
+
|
|
606
|
+
// Build ZIP: agent.yaml + CLAUDE.md (if exists)
|
|
607
|
+
const zip = new AdmZip();
|
|
608
|
+
zip.addFile('agent.yaml', Buffer.from(agentYaml, 'utf-8'));
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const claudeMdContent = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf-8');
|
|
612
|
+
zip.addFile('CLAUDE.md', Buffer.from(claudeMdContent, 'utf-8'));
|
|
613
|
+
} catch {
|
|
614
|
+
// CLAUDE.md is optional - add a placeholder
|
|
615
|
+
zip.addFile('CLAUDE.md', Buffer.from('# Agent\n', 'utf-8'));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Include selected referenced files, preserving relative paths
|
|
619
|
+
for (const refPath of refFiles) {
|
|
620
|
+
if (!refPath || path.isAbsolute(refPath)) continue;
|
|
621
|
+
const absRef = path.resolve(projectPath, refPath);
|
|
622
|
+
// Security: ensure the resolved path stays within the project directory
|
|
623
|
+
if (!absRef.startsWith(path.resolve(projectPath) + path.sep)) continue;
|
|
624
|
+
try {
|
|
625
|
+
const content = await fs.readFile(absRef);
|
|
626
|
+
zip.addFile(refPath, content);
|
|
627
|
+
} catch {
|
|
628
|
+
// Skip files that can't be read
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Save ZIP to disk
|
|
633
|
+
const publicPaths = getPublicPaths();
|
|
634
|
+
const userSubmitDir = path.join(publicPaths.agentSubmissionsDir, String(userId));
|
|
635
|
+
await fs.mkdir(userSubmitDir, { recursive: true });
|
|
636
|
+
|
|
637
|
+
const timestamp = Date.now();
|
|
638
|
+
const zipPath = path.join(userSubmitDir, `${agentName}-${timestamp}.zip`);
|
|
639
|
+
await fs.writeFile(zipPath, zip.toBuffer());
|
|
640
|
+
|
|
641
|
+
// Save to DB
|
|
642
|
+
const submissionId = agentSubmissionDb.create({
|
|
643
|
+
userId,
|
|
644
|
+
agentName,
|
|
645
|
+
displayName,
|
|
646
|
+
description: description || '',
|
|
647
|
+
zipPath
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
res.json({
|
|
651
|
+
success: true,
|
|
652
|
+
submissionId,
|
|
653
|
+
message: 'Agent 已提交审核,管理员审核通过后将发布到仓库。'
|
|
654
|
+
});
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.error('Error submitting agent:', error);
|
|
657
|
+
res.status(500).json({ error: 'Failed to submit agent', details: error.message });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* GET /api/agents/submissions/my
|
|
663
|
+
* Get current user's submissions
|
|
664
|
+
*/
|
|
665
|
+
router.get('/submissions/my', async (req, res) => {
|
|
666
|
+
try {
|
|
667
|
+
const userId = req.user?.id;
|
|
668
|
+
if (!userId) return res.status(401).json({ error: 'User authentication required' });
|
|
669
|
+
|
|
670
|
+
const submissions = agentSubmissionDb.listByUser(userId);
|
|
671
|
+
res.json({ submissions });
|
|
672
|
+
} catch (error) {
|
|
673
|
+
console.error('Error fetching submissions:', error);
|
|
674
|
+
res.status(500).json({ error: 'Failed to fetch submissions', details: error.message });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* GET /api/agents/submissions
|
|
680
|
+
* List all submissions (admin only)
|
|
681
|
+
*/
|
|
682
|
+
router.get('/submissions', async (req, res) => {
|
|
683
|
+
try {
|
|
684
|
+
const userRole = req.user?.role;
|
|
685
|
+
if (userRole !== 'admin' && userRole !== 'super_admin') {
|
|
686
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { status } = req.query;
|
|
690
|
+
const submissions = agentSubmissionDb.listAll(status || null);
|
|
691
|
+
res.json({ submissions });
|
|
692
|
+
} catch (error) {
|
|
693
|
+
console.error('Error fetching submissions:', error);
|
|
694
|
+
res.status(500).json({ error: 'Failed to fetch submissions', details: error.message });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* GET /api/agents/submissions/:id/contents
|
|
700
|
+
* Return the text content of files inside the submission ZIP (admin only)
|
|
701
|
+
*/
|
|
702
|
+
router.get('/submissions/:id/contents', async (req, res) => {
|
|
703
|
+
try {
|
|
704
|
+
const userRole = req.user?.role;
|
|
705
|
+
if (userRole !== 'admin' && userRole !== 'super_admin') {
|
|
706
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const submission = agentSubmissionDb.getById(req.params.id);
|
|
710
|
+
if (!submission) return res.status(404).json({ error: 'Submission not found' });
|
|
711
|
+
|
|
712
|
+
let zipBuffer;
|
|
713
|
+
try {
|
|
714
|
+
zipBuffer = await fs.readFile(submission.zip_path);
|
|
715
|
+
} catch {
|
|
716
|
+
return res.status(404).json({ error: 'Submission ZIP file not found' });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const zip = new AdmZip(zipBuffer);
|
|
720
|
+
const files = [];
|
|
721
|
+
for (const entry of zip.getEntries()) {
|
|
722
|
+
if (entry.isDirectory) continue;
|
|
723
|
+
const name = entry.entryName;
|
|
724
|
+
const content = entry.getData().toString('utf-8');
|
|
725
|
+
files.push({ name, content });
|
|
726
|
+
}
|
|
727
|
+
// Sort: agent.yaml first, CLAUDE.md second, rest alphabetically
|
|
728
|
+
files.sort((a, b) => {
|
|
729
|
+
const order = { 'agent.yaml': 0, 'CLAUDE.md': 1 };
|
|
730
|
+
const oa = order[a.name] ?? 99;
|
|
731
|
+
const ob = order[b.name] ?? 99;
|
|
732
|
+
if (oa !== ob) return oa - ob;
|
|
733
|
+
return a.name.localeCompare(b.name);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
res.json({ files });
|
|
737
|
+
} catch (error) {
|
|
738
|
+
console.error('Error reading submission contents:', error);
|
|
739
|
+
res.status(500).json({ error: 'Failed to read submission contents', details: error.message });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* POST /api/agents/submissions/:id/approve
|
|
745
|
+
* Approve a submission and publish to git repo (admin only)
|
|
746
|
+
*/
|
|
747
|
+
router.post('/submissions/:id/approve', async (req, res) => {
|
|
748
|
+
try {
|
|
749
|
+
const userRole = req.user?.role;
|
|
750
|
+
const reviewerId = req.user?.id;
|
|
751
|
+
if (userRole !== 'admin' && userRole !== 'super_admin') {
|
|
752
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const submission = agentSubmissionDb.getById(req.params.id);
|
|
756
|
+
if (!submission) return res.status(404).json({ error: 'Submission not found' });
|
|
757
|
+
if (submission.status !== 'pending') {
|
|
758
|
+
return res.status(400).json({ error: 'Submission is not pending' });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Read ZIP and extract
|
|
762
|
+
let zipBuffer;
|
|
763
|
+
try {
|
|
764
|
+
zipBuffer = await fs.readFile(submission.zip_path);
|
|
765
|
+
} catch {
|
|
766
|
+
return res.status(500).json({ error: 'Could not read submission ZIP' });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const zip = new AdmZip(zipBuffer);
|
|
770
|
+
const publicPaths = getPublicPaths();
|
|
771
|
+
const extractDir = path.join(publicPaths.agentSubmissionsDir, 'extracted', String(submission.id));
|
|
772
|
+
await fs.mkdir(extractDir, { recursive: true });
|
|
773
|
+
zip.extractAllTo(extractDir, true);
|
|
774
|
+
|
|
775
|
+
// Determine new version
|
|
776
|
+
const agents = await scanAgents();
|
|
777
|
+
const existing = agents.find(a => a.dirName === submission.agent_name || a.name === submission.agent_name);
|
|
778
|
+
const newVersion = existing ? incrementPatchVersion(existing.version) : '1.0.0';
|
|
779
|
+
|
|
780
|
+
// Get submitter name for commit message
|
|
781
|
+
const submitter = submission.username || submission.email || `user-${submission.user_id}`;
|
|
782
|
+
|
|
783
|
+
// Publish to git repo
|
|
784
|
+
try {
|
|
785
|
+
await publishAgentToRepo(submission.agent_name, extractDir, newVersion, submitter);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
return res.status(500).json({ error: 'Failed to publish to git repo', details: err.message });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Update DB
|
|
791
|
+
agentSubmissionDb.updateStatus(submission.id, 'approved', {
|
|
792
|
+
version: newVersion,
|
|
793
|
+
reviewedBy: reviewerId
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Cleanup extracted dir
|
|
797
|
+
fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
|
798
|
+
|
|
799
|
+
res.json({ success: true, version: newVersion });
|
|
800
|
+
} catch (error) {
|
|
801
|
+
console.error('Error approving submission:', error);
|
|
802
|
+
res.status(500).json({ error: 'Failed to approve submission', details: error.message });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* POST /api/agents/submissions/:id/reject
|
|
808
|
+
* Reject a submission (admin only)
|
|
809
|
+
*/
|
|
810
|
+
router.post('/submissions/:id/reject', async (req, res) => {
|
|
811
|
+
try {
|
|
812
|
+
const userRole = req.user?.role;
|
|
813
|
+
const reviewerId = req.user?.id;
|
|
814
|
+
if (userRole !== 'admin' && userRole !== 'super_admin') {
|
|
815
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const submission = agentSubmissionDb.getById(req.params.id);
|
|
819
|
+
if (!submission) return res.status(404).json({ error: 'Submission not found' });
|
|
820
|
+
if (submission.status !== 'pending') {
|
|
821
|
+
return res.status(400).json({ error: 'Submission is not pending' });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const { reason } = req.body;
|
|
825
|
+
|
|
826
|
+
agentSubmissionDb.updateStatus(submission.id, 'rejected', {
|
|
827
|
+
rejectReason: reason || '',
|
|
828
|
+
reviewedBy: reviewerId
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
res.json({ success: true });
|
|
832
|
+
} catch (error) {
|
|
833
|
+
console.error('Error rejecting submission:', error);
|
|
834
|
+
res.status(500).json({ error: 'Failed to reject submission', details: error.message });
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
export default router;
|