@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.
@@ -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;