@ian2018cs/agenthub 0.1.69 → 0.1.71

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.
@@ -6,6 +6,8 @@ import { spawn } from 'child_process';
6
6
  import AdmZip from 'adm-zip';
7
7
  import { getUserPaths, getPublicPaths } from '../services/user-directories.js';
8
8
  import { scanAgents, ensureAgentRepo, incrementPatchVersion, publishAgentToRepo } from '../services/system-agent-repo.js';
9
+ import { ensureSystemRepo, SYSTEM_REPO_URL } from '../services/system-repo.js';
10
+ import { ensureSystemMcpRepo, SYSTEM_MCP_REPO_URL } from '../services/system-mcp-repo.js';
9
11
  import { addProjectManually, loadProjectConfig, saveProjectConfig } from '../projects.js';
10
12
  import { agentSubmissionDb, userDb } from '../database/db.js';
11
13
  import { chatCompletion } from '../services/llm.js';
@@ -77,6 +79,86 @@ function runGit(args, cwd = null) {
77
79
  });
78
80
  }
79
81
 
82
+ /**
83
+ * Recursively copy all files and directories from src to dst.
84
+ */
85
+ async function copyDirRecursive(src, dst) {
86
+ await fs.mkdir(dst, { recursive: true });
87
+ const entries = await fs.readdir(src, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ const srcPath = path.join(src, entry.name);
90
+ const dstPath = path.join(dst, entry.name);
91
+ if (entry.isDirectory()) {
92
+ await copyDirRecursive(srcPath, dstPath);
93
+ } else {
94
+ await fs.copyFile(srcPath, dstPath);
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Recursively add all files under dirPath into a ZIP object under the given zipPrefix.
101
+ */
102
+ async function addDirToZip(zip, dirPath, zipPrefix) {
103
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const fullPath = path.join(dirPath, entry.name);
106
+ const zipPath = zipPrefix ? `${zipPrefix}/${entry.name}` : entry.name;
107
+ if (entry.isDirectory()) {
108
+ await addDirToZip(zip, fullPath, zipPath);
109
+ } else {
110
+ const content = await fs.readFile(fullPath);
111
+ zip.addFile(zipPath, content);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Parse skills list from agent.yaml content string.
118
+ * Returns array of { name, repo }.
119
+ */
120
+ function parseYamlSkills(yamlContent) {
121
+ const skills = [];
122
+ const section = yamlContent.match(/^skills:\s*\n((?:[ \t]+.+\n?)*)/m);
123
+ if (!section) return skills;
124
+ const lines = section[1].split('\n');
125
+ let current = null;
126
+ for (const line of lines) {
127
+ const nameMatch = line.match(/^\s+-\s+name:\s*["']?(.+?)["']?\s*$/);
128
+ if (nameMatch) {
129
+ if (current) skills.push(current);
130
+ current = { name: nameMatch[1].trim(), repo: '' };
131
+ }
132
+ const repoMatch = line.match(/^\s+repo:\s*["']?(.*?)["']?\s*$/);
133
+ if (repoMatch && current) current.repo = repoMatch[1].trim();
134
+ }
135
+ if (current) skills.push(current);
136
+ return skills;
137
+ }
138
+
139
+ /**
140
+ * Parse MCPs list from agent.yaml content string.
141
+ * Returns array of { name, repo }.
142
+ */
143
+ function parseYamlMcps(yamlContent) {
144
+ const mcps = [];
145
+ const section = yamlContent.match(/^mcps:\s*\n((?:[ \t]+.+\n?)*)/m);
146
+ if (!section) return mcps;
147
+ const lines = section[1].split('\n');
148
+ let current = null;
149
+ for (const line of lines) {
150
+ const nameMatch = line.match(/^\s+-\s+name:\s*["']?(.+?)["']?\s*$/);
151
+ if (nameMatch) {
152
+ if (current) mcps.push(current);
153
+ current = { name: nameMatch[1].trim(), repo: '' };
154
+ }
155
+ const repoMatch = line.match(/^\s+repo:\s*["']?(.*?)["']?\s*$/);
156
+ if (repoMatch && current) current.repo = repoMatch[1].trim();
157
+ }
158
+ if (current) mcps.push(current);
159
+ return mcps;
160
+ }
161
+
80
162
  /**
81
163
  * Ensure a skill repo is available for the user. Clone if needed.
82
164
  * Returns the local path to the skill directory inside the repo.
@@ -549,7 +631,15 @@ router.get('/preview', async (req, res) => {
549
631
  if (name.startsWith('.')) continue;
550
632
  const linkPath = path.join(userPaths.skillsDir, name);
551
633
  const repo = await getSkillRepoUrl(linkPath, publicPaths.skillsRepoDir);
552
- skills.push({ name, repo });
634
+ let localPath = '';
635
+ if (!repo) {
636
+ // Skill not from a git repo — check if it's a user-imported skill
637
+ try {
638
+ const realPath = await fs.realpath(linkPath);
639
+ if (realPath.includes('/skills-import/')) localPath = realPath;
640
+ } catch {}
641
+ }
642
+ skills.push({ name, repo, localPath });
553
643
  }
554
644
  } catch {}
555
645
 
@@ -558,9 +648,22 @@ router.get('/preview', async (req, res) => {
558
648
  try {
559
649
  const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
560
650
  const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
651
+
652
+ // User-scoped MCPs (top-level mcpServers)
561
653
  for (const name of Object.keys(claudeConfig.mcpServers || {})) {
562
654
  const repo = await getMcpRepoUrl(name, publicPaths.mcpRepoDir);
563
- mcps.push({ name, repo });
655
+ const config = !repo ? (claudeConfig.mcpServers[name] || null) : null;
656
+ mcps.push({ name, repo, config, scope: 'user', mcpProjectPath: null });
657
+ }
658
+
659
+ // Project-scoped MCPs (projects[projectPath].mcpServers)
660
+ const projectMcpServers = claudeConfig.projects?.[projectPath]?.mcpServers || {};
661
+ for (const name of Object.keys(projectMcpServers)) {
662
+ // Skip if already listed as user-scoped
663
+ if (mcps.some(m => m.name === name)) continue;
664
+ const repo = await getMcpRepoUrl(name, publicPaths.mcpRepoDir);
665
+ const config = !repo ? (projectMcpServers[name] || null) : null;
666
+ mcps.push({ name, repo, config, scope: 'local', mcpProjectPath: projectPath });
564
667
  }
565
668
  } catch {}
566
669
 
@@ -708,15 +811,15 @@ router.post('/generate-description', async (req, res) => {
708
811
  const skillList = skills.length > 0 ? skills.map(s => `- ${s}`).join('\n') : '(无)';
709
812
  const mcpList = mcps.length > 0 ? mcps.map(m => `- ${m}`).join('\n') : '(无)';
710
813
 
711
- const systemPrompt = `你是一个技术文档专家,专门为 AI Agent 编写简洁、清晰的功能描述。
814
+ const systemPrompt = `你是一个技术文档专家,专门为 共享项目 编写简洁、清晰的功能描述。
712
815
  描述应该:
713
816
  - 简明扼要,2-4 句话
714
- - 说明 Agent 的主要用途和能力
817
+ - 说明项目的主要用途和能力
715
818
  - 自然流畅,不使用模板化套话
716
819
  - 使用中文
717
820
  只输出描述内容,不要有前缀或标题。`;
718
821
 
719
- const userPrompt = `请根据以下信息,为这个 Claude Agent 生成一段功能描述:
822
+ const userPrompt = `请根据以下信息,为这个项目生成一段功能描述:
720
823
 
721
824
  ${claudeMdContent ? `## CLAUDE.md 内容\n${claudeMdContent.slice(0, 3000)}\n` : '## CLAUDE.md\n(未找到)\n'}
722
825
  ## 已集成的 Skills
@@ -792,6 +895,41 @@ router.post('/submit', async (req, res) => {
792
895
  }
793
896
  }
794
897
 
898
+ // Include local skill files for imported skills (repo is absolute path)
899
+ const parsedSkills = parseYamlSkills(agentYaml);
900
+ for (const skill of parsedSkills) {
901
+ if (!skill.repo.startsWith('/')) continue; // Only handle absolute local paths
902
+ try {
903
+ await addDirToZip(zip, skill.repo, `_skill_files/${skill.name}`);
904
+ console.log(`[AgentSubmit] Included local skill files for "${skill.name}" from ${skill.repo}`);
905
+ } catch (e) {
906
+ console.warn(`[AgentSubmit] Could not include skill files for "${skill.name}": ${e.message}`);
907
+ }
908
+ }
909
+
910
+ // Capture user-configured MCP configs (repo is empty)
911
+ const parsedMcps = parseYamlMcps(agentYaml);
912
+ if (parsedMcps.some(m => !m.repo)) {
913
+ try {
914
+ const userPaths = getUserPaths(userUuid);
915
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
916
+ const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
917
+ const projectMcpServers = claudeConfig.projects?.[projectPath]?.mcpServers || {};
918
+ for (const mcp of parsedMcps) {
919
+ if (mcp.repo) continue; // Already has a repo URL
920
+ // Check user-scoped first, then project-scoped
921
+ const serverConfig = claudeConfig.mcpServers?.[mcp.name] ?? projectMcpServers[mcp.name];
922
+ if (serverConfig) {
923
+ const mcpConfig = { [mcp.name]: serverConfig };
924
+ zip.addFile(`_mcp_configs/${mcp.name}/mcp.json`, Buffer.from(JSON.stringify(mcpConfig, null, 2)));
925
+ console.log(`[AgentSubmit] Captured user-configured MCP config for "${mcp.name}"`);
926
+ }
927
+ }
928
+ } catch (e) {
929
+ console.warn(`[AgentSubmit] Could not capture MCP configs: ${e.message}`);
930
+ }
931
+ }
932
+
795
933
  // Save ZIP to disk
796
934
  const publicPaths = getPublicPaths();
797
935
  const userSubmitDir = path.join(publicPaths.agentSubmissionsDir, userUuid);
@@ -946,6 +1084,199 @@ router.post('/submissions/:id/approve', async (req, res) => {
946
1084
  // Get submitter name for commit message
947
1085
  const submitter = submission.username || submission.email || `user-${submission.user_id}`;
948
1086
 
1087
+ // Get submitter's UUID for skill/MCP operations
1088
+ const allUsers = userDb.getAllUsers();
1089
+ const submitterUser = allUsers.find(u => u.id === submission.user_id);
1090
+ const submitterUuid = submitterUser?.uuid;
1091
+
1092
+ // Migrate local skills and user-configured MCPs before publishing
1093
+ const agentYamlPath = path.join(extractDir, 'agent.yaml');
1094
+ let agentYamlContent;
1095
+ try {
1096
+ agentYamlContent = await fs.readFile(agentYamlPath, 'utf-8');
1097
+ } catch {
1098
+ agentYamlContent = null;
1099
+ }
1100
+
1101
+ if (agentYamlContent && submitterUuid) {
1102
+ // === A. Migrate local skills (repo is absolute path starting with "/") ===
1103
+ const yamlSkills = parseYamlSkills(agentYamlContent);
1104
+ const localSkills = yamlSkills.filter(s => s.repo.startsWith('/'));
1105
+
1106
+ if (localSkills.length > 0) {
1107
+ let skillRepoPath;
1108
+ try {
1109
+ skillRepoPath = await ensureSystemRepo(); // git pull or clone
1110
+ } catch (e) {
1111
+ console.error('[AgentApprove] Failed to ensure system skill repo:', e.message);
1112
+ }
1113
+
1114
+ if (skillRepoPath) {
1115
+ for (const skill of localSkills) {
1116
+ try {
1117
+ // Copy skill folder from extracted ZIP to system skill repo
1118
+ const srcSkillDir = path.join(extractDir, '_skill_files', skill.name);
1119
+ const destSkillDir = path.join(skillRepoPath, skill.name);
1120
+
1121
+ // Verify source exists in ZIP
1122
+ try {
1123
+ await fs.access(srcSkillDir);
1124
+ } catch {
1125
+ console.warn(`[AgentApprove] Skill files for "${skill.name}" not found in submission ZIP, skipping`);
1126
+ continue;
1127
+ }
1128
+
1129
+ // Remove old version in repo if it exists
1130
+ await fs.rm(destSkillDir, { recursive: true, force: true });
1131
+ await copyDirRecursive(srcSkillDir, destSkillDir);
1132
+ console.log(`[AgentApprove] Copied skill "${skill.name}" to system skill repo`);
1133
+
1134
+ // Git: add, commit, push
1135
+ await runGit(['add', skill.name], skillRepoPath);
1136
+ await runGit(
1137
+ ['commit', '-m', `feat: add skill ${skill.name} from approved agent submission #${submission.id}`],
1138
+ skillRepoPath
1139
+ );
1140
+ await runGit(['push'], skillRepoPath);
1141
+ console.log(`[AgentApprove] Pushed skill "${skill.name}" to remote`);
1142
+
1143
+ // Clean up submitter's skills-import directory for this skill
1144
+ const userPaths = getUserPaths(submitterUuid);
1145
+ try {
1146
+ await fs.rm(path.join(userPaths.skillsImportDir, skill.name), { recursive: true, force: true });
1147
+ } catch {}
1148
+
1149
+ // Re-install skill for submitter from the system repo
1150
+ await installSkill(skill.name, SYSTEM_REPO_URL, submitterUuid);
1151
+ console.log(`[AgentApprove] Re-installed skill "${skill.name}" for submitter from system repo`);
1152
+
1153
+ // Update agent.yaml: replace absolute path with system repo git URL
1154
+ const escapedName = skill.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1155
+ agentYamlContent = agentYamlContent.replace(
1156
+ new RegExp(`(- name: "${escapedName}"\\s*\\n\\s+repo: )"[^"]*"`, 'gm'),
1157
+ `$1"${SYSTEM_REPO_URL}"`
1158
+ );
1159
+ } catch (e) {
1160
+ console.error(`[AgentApprove] Failed to migrate skill "${skill.name}":`, e.message);
1161
+ // Continue with other skills even if one fails
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ // === B. Migrate user-configured MCPs (repo is empty, config in _mcp_configs/) ===
1168
+ const yamlMcps = parseYamlMcps(agentYamlContent);
1169
+ const localMcps = yamlMcps.filter(m => !m.repo);
1170
+
1171
+ if (localMcps.length > 0) {
1172
+ let mcpRepoPath;
1173
+ try {
1174
+ mcpRepoPath = await ensureSystemMcpRepo(); // git pull or clone
1175
+ } catch (e) {
1176
+ console.error('[AgentApprove] Failed to ensure system MCP repo:', e.message);
1177
+ }
1178
+
1179
+ if (mcpRepoPath) {
1180
+ for (const mcp of localMcps) {
1181
+ try {
1182
+ // Read captured MCP config from ZIP extraction
1183
+ const mcpConfigPath = path.join(extractDir, '_mcp_configs', mcp.name, 'mcp.json');
1184
+ let mcpConfig;
1185
+ try {
1186
+ mcpConfig = JSON.parse(await fs.readFile(mcpConfigPath, 'utf-8'));
1187
+ } catch {
1188
+ console.warn(`[AgentApprove] No captured config for MCP "${mcp.name}", skipping`);
1189
+ continue;
1190
+ }
1191
+
1192
+ // Create MCP folder in system MCP repo
1193
+ const mcpFolderPath = path.join(mcpRepoPath, mcp.name);
1194
+ await fs.mkdir(mcpFolderPath, { recursive: true });
1195
+
1196
+ // Write mcp.json
1197
+ await fs.writeFile(path.join(mcpFolderPath, 'mcp.json'), JSON.stringify(mcpConfig, null, 2));
1198
+
1199
+ // Write mcp.yaml with basic metadata
1200
+ const mcpYaml = `name: "${mcp.name}"\ndescription: "MCP service migrated from shared project '${submission.display_name}' (submission #${submission.id})"\n`;
1201
+ await fs.writeFile(path.join(mcpFolderPath, 'mcp.yaml'), mcpYaml);
1202
+ console.log(`[AgentApprove] Created MCP "${mcp.name}" in system MCP repo`);
1203
+
1204
+ // Git: add, commit, push
1205
+ await runGit(['add', mcp.name], mcpRepoPath);
1206
+ await runGit(
1207
+ ['commit', '-m', `feat: add mcp ${mcp.name} from approved agent submission #${submission.id}`],
1208
+ mcpRepoPath
1209
+ );
1210
+ await runGit(['push'], mcpRepoPath);
1211
+ console.log(`[AgentApprove] Pushed MCP "${mcp.name}" to remote`);
1212
+
1213
+ // Remove MCP from submitter's .claude.json (user-scoped or project-scoped)
1214
+ const userPaths = getUserPaths(submitterUuid);
1215
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
1216
+ try {
1217
+ const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
1218
+ let changed = false;
1219
+ // Remove from user-scoped
1220
+ if (claudeConfig.mcpServers) {
1221
+ for (const serverKey of Object.keys(mcpConfig)) {
1222
+ if (serverKey in claudeConfig.mcpServers) {
1223
+ delete claudeConfig.mcpServers[serverKey];
1224
+ changed = true;
1225
+ }
1226
+ }
1227
+ }
1228
+ // Remove from all project-scoped entries
1229
+ if (claudeConfig.projects) {
1230
+ for (const projectConfig of Object.values(claudeConfig.projects)) {
1231
+ if (projectConfig?.mcpServers) {
1232
+ for (const serverKey of Object.keys(mcpConfig)) {
1233
+ if (serverKey in projectConfig.mcpServers) {
1234
+ delete projectConfig.mcpServers[serverKey];
1235
+ changed = true;
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ }
1241
+ if (changed) {
1242
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
1243
+ }
1244
+ } catch (e) {
1245
+ console.warn(`[AgentApprove] Could not remove MCP "${mcp.name}" from submitter .claude.json:`, e.message);
1246
+ }
1247
+
1248
+ // Re-install MCP for submitter from system repo
1249
+ await installMcp(mcp.name, SYSTEM_MCP_REPO_URL, submitterUuid);
1250
+ console.log(`[AgentApprove] Re-installed MCP "${mcp.name}" for submitter from system repo`);
1251
+
1252
+ // Update agent.yaml: replace empty repo with system MCP repo git URL
1253
+ const escapedName = mcp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1254
+ agentYamlContent = agentYamlContent.replace(
1255
+ new RegExp(`(- name: "${escapedName}"\\s*\\n\\s+repo: )""`, 'gm'),
1256
+ `$1"${SYSTEM_MCP_REPO_URL}"`
1257
+ );
1258
+ } catch (e) {
1259
+ console.error(`[AgentApprove] Failed to migrate MCP "${mcp.name}":`, e.message);
1260
+ // Continue with other MCPs even if one fails
1261
+ }
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ // Write updated agent.yaml back to extractDir so publishAgentToRepo uses correct repo URLs
1267
+ if (localSkills.length > 0 || localMcps.length > 0) {
1268
+ try {
1269
+ await fs.writeFile(agentYamlPath, agentYamlContent, 'utf-8');
1270
+ } catch (e) {
1271
+ console.error('[AgentApprove] Failed to write updated agent.yaml:', e.message);
1272
+ }
1273
+ }
1274
+
1275
+ // Remove temporary migration directories from extractDir before publishing to agent repo
1276
+ await fs.rm(path.join(extractDir, '_skill_files'), { recursive: true, force: true });
1277
+ await fs.rm(path.join(extractDir, '_mcp_configs'), { recursive: true, force: true });
1278
+ }
1279
+
949
1280
  // Publish to git repo
950
1281
  try {
951
1282
  await publishAgentToRepo(submission.agent_name, extractDir, newVersion, submitter);