@ian2018cs/agenthub 0.1.68 → 0.1.70

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.
@@ -378,4 +378,4 @@ import{a as y}from"./vendor-react-Bv0Nkan8.js";/**
378
378
  *
379
379
  * This source code is licensed under the ISC license.
380
380
  * See the LICENSE file in the root directory of this source tree.
381
- */const Z1=[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]],t0=e("zap",Z1);export{u2 as $,B1 as A,E1 as B,W1 as C,c2 as D,o2 as E,i2 as F,r2 as G,E2 as H,x2 as I,K1 as J,Q1 as K,m2 as L,f2 as M,X1 as N,q2 as O,b2 as P,T2 as Q,j2 as R,L2 as S,O2 as T,K2 as U,F1 as V,M2 as W,a0 as X,_2 as Y,t0 as Z,a2 as _,l2 as a,V2 as a0,z2 as a1,J2 as a2,T1 as a3,t2 as a4,D1 as a5,X2 as a6,F2 as a7,n2 as a8,Q2 as a9,W2 as aa,Y2 as ab,Z2 as ac,g2 as ad,e0 as ae,I2 as af,B2 as ag,v2 as ah,k2 as b,G1 as c,R2 as d,G2 as e,I1 as f,O1 as g,e2 as h,C2 as i,H2 as j,U2 as k,P2 as l,Y1 as m,J1 as n,R1 as o,A2 as p,w2 as q,$2 as r,y2 as s,s2 as t,d2 as u,N2 as v,D2 as w,S2 as x,p2 as y,h2 as z};
381
+ */const Z1=[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]],t0=e("zap",Z1);export{u2 as $,B1 as A,E1 as B,W1 as C,c2 as D,o2 as E,i2 as F,r2 as G,h2 as H,x2 as I,K1 as J,Q1 as K,m2 as L,f2 as M,X1 as N,q2 as O,b2 as P,T2 as Q,j2 as R,L2 as S,O2 as T,K2 as U,F1 as V,M2 as W,a0 as X,_2 as Y,t0 as Z,a2 as _,E2 as a,V2 as a0,z2 as a1,J2 as a2,T1 as a3,t2 as a4,D1 as a5,X2 as a6,F2 as a7,n2 as a8,Q2 as a9,W2 as aa,Y2 as ab,Z2 as ac,g2 as ad,e0 as ae,I2 as af,B2 as ag,v2 as ah,s2 as b,S2 as c,I1 as d,O1 as e,l2 as f,k2 as g,G1 as h,R2 as i,G2 as j,e2 as k,C2 as l,H2 as m,U2 as n,P2 as o,Y1 as p,J1 as q,R1 as r,A2 as s,w2 as t,$2 as u,y2 as v,d2 as w,N2 as x,D2 as y,p2 as z};
package/dist/index.html CHANGED
@@ -25,16 +25,16 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-C9BhBQzI.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-DxBc5bLY.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-Bv0Nkan8.js">
30
30
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-sVRjxPVQ.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-utils-00TdZexr.js">
32
- <link rel="modulepreload" crossorigin href="/assets/vendor-icons-DxBNDMja.js">
32
+ <link rel="modulepreload" crossorigin href="/assets/vendor-icons-BWqhkbta.js">
33
33
  <link rel="modulepreload" crossorigin href="/assets/vendor-katex-DK8hFnhL.js">
34
34
  <link rel="modulepreload" crossorigin href="/assets/vendor-markdown-CjscLcYM.js">
35
35
  <link rel="modulepreload" crossorigin href="/assets/vendor-syntax-BKENXTeY.js">
36
36
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CvdiG4-n.js">
37
- <link rel="stylesheet" crossorigin href="/assets/index-HOTjBpXH.css">
37
+ <link rel="stylesheet" crossorigin href="/assets/index-DURCpZD_.css">
38
38
  </head>
39
39
  <body>
40
40
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian2018cs/agenthub",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -27,6 +27,46 @@ import { evaluate as evaluateToolGuard } from './services/tool-guard/index.js';
27
27
 
28
28
  // Session tracking: Map of session IDs to active query instances
29
29
  const activeSessions = new Map();
30
+
31
+ /**
32
+ * MutableWriter wraps a WebSocketWriter and buffers messages while the client
33
+ * is disconnected. When the client reconnects, switchTo() replays the buffer
34
+ * to the new writer so no streaming chunks are lost.
35
+ */
36
+ class MutableWriter {
37
+ constructor(ws) {
38
+ this.current = ws;
39
+ this.buffer = [];
40
+ this.MAX_BUFFER = 500;
41
+ }
42
+
43
+ send(data) {
44
+ if (this.current?.ws?.readyState === 1) {
45
+ this.current.send(data);
46
+ } else if (this.buffer.length < this.MAX_BUFFER) {
47
+ this.buffer.push(data);
48
+ }
49
+ }
50
+
51
+ setSessionId(sessionId) {
52
+ this.current?.setSessionId(sessionId);
53
+ }
54
+
55
+ getSessionId() {
56
+ return this.current?.getSessionId();
57
+ }
58
+
59
+ /** Attach a new WebSocketWriter and replay any buffered messages to it. */
60
+ switchTo(newWs) {
61
+ this.current = newWs;
62
+ const buf = this.buffer;
63
+ this.buffer = [];
64
+ for (const msg of buf) {
65
+ newWs.send(msg);
66
+ }
67
+ }
68
+ }
69
+
30
70
  // In-memory registry of pending tool approvals keyed by requestId.
31
71
  // This does not persist approvals or share across processes; it exists so the
32
72
  // SDK can pause tool execution while the UI decides what to do.
@@ -233,14 +273,15 @@ function mapCliOptionsToSDK(options = {}) {
233
273
  * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
234
274
  * @param {string} tempDir - Temp directory for cleanup
235
275
  */
236
- function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, abortController = null) {
276
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, abortController = null, mutableWriter = null) {
237
277
  activeSessions.set(sessionId, {
238
278
  instance: queryInstance,
239
279
  startTime: Date.now(),
240
280
  status: 'active',
241
281
  tempImagePaths,
242
282
  tempDir,
243
- abortController
283
+ abortController,
284
+ mutableWriter
244
285
  });
245
286
  }
246
287
 
@@ -261,6 +302,22 @@ function getSession(sessionId) {
261
302
  return activeSessions.get(sessionId);
262
303
  }
263
304
 
305
+ /**
306
+ * Attaches a new WebSocketWriter to an active session, replaying any messages
307
+ * that were buffered while the previous connection was closed.
308
+ * @param {string} sessionId - Session identifier
309
+ * @param {Object} newWs - New WebSocketWriter instance
310
+ * @returns {boolean} True if the session was found and updated
311
+ */
312
+ function updateSessionWriter(sessionId, newWs) {
313
+ const session = activeSessions.get(sessionId);
314
+ if (session?.mutableWriter) {
315
+ session.mutableWriter.switchTo(newWs);
316
+ return true;
317
+ }
318
+ return false;
319
+ }
320
+
264
321
  /**
265
322
  * Gets all active session IDs
266
323
  * @returns {Array<string>} Array of active session IDs
@@ -497,6 +554,10 @@ async function loadMcpConfig(cwd, userUuid) {
497
554
  */
498
555
  async function queryClaudeSDK(command, options = {}, ws) {
499
556
  const { sessionId, userUuid } = options;
557
+
558
+ // Wrap the WebSocketWriter in a MutableWriter so messages are buffered
559
+ // if the client disconnects mid-stream and replayed on reconnect.
560
+ const mutableWriter = new MutableWriter(ws);
500
561
  let capturedSessionId = sessionId;
501
562
  let sessionCreatedSent = false;
502
563
  let tempImagePaths = [];
@@ -603,7 +664,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
603
664
  }
604
665
 
605
666
  const requestId = createRequestId();
606
- ws.send({
667
+ mutableWriter.send({
607
668
  type: 'claude-permission-request',
608
669
  requestId,
609
670
  toolName,
@@ -619,7 +680,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
619
680
  timeoutMs: approvalTimeoutMs,
620
681
  signal: context?.signal,
621
682
  onCancel: (reason) => {
622
- ws.send({
683
+ mutableWriter.send({
623
684
  type: 'claude-permission-cancelled',
624
685
  requestId,
625
686
  reason,
@@ -660,7 +721,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
660
721
 
661
722
  // Track the query instance for abort capability
662
723
  if (capturedSessionId) {
663
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController);
724
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController, mutableWriter);
664
725
  }
665
726
 
666
727
  // Process streaming messages
@@ -670,17 +731,17 @@ async function queryClaudeSDK(command, options = {}, ws) {
670
731
  if (message.session_id && !capturedSessionId) {
671
732
 
672
733
  capturedSessionId = message.session_id;
673
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController);
734
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, abortController, mutableWriter);
674
735
 
675
736
  // Set session ID on writer
676
737
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
677
- ws.setSessionId(capturedSessionId);
738
+ mutableWriter.setSessionId(capturedSessionId);
678
739
  }
679
740
 
680
741
  // Send session-created event only once for new sessions
681
742
  if (!sessionId && !sessionCreatedSent) {
682
743
  sessionCreatedSent = true;
683
- ws.send({
744
+ mutableWriter.send({
684
745
  type: 'session-created',
685
746
  sessionId: capturedSessionId
686
747
  });
@@ -693,7 +754,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
693
754
 
694
755
  // Transform and send message to WebSocket
695
756
  const transformedMessage = transformMessage(message);
696
- ws.send({
757
+ mutableWriter.send({
697
758
  type: 'claude-response',
698
759
  data: transformedMessage
699
760
  });
@@ -703,7 +764,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
703
764
  const tokenBudget = extractTokenBudget(message);
704
765
  if (tokenBudget) {
705
766
  console.log('Token budget from modelUsage:', tokenBudget);
706
- ws.send({
767
+ mutableWriter.send({
707
768
  type: 'token-budget',
708
769
  data: tokenBudget
709
770
  });
@@ -789,7 +850,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
789
850
  // to the frontend, so we must not send a duplicate 'claude-complete'.
790
851
  if (!abortController.signal.aborted) {
791
852
  console.log('Streaming complete, sending claude-complete event');
792
- ws.send({
853
+ mutableWriter.send({
793
854
  type: 'claude-complete',
794
855
  sessionId: capturedSessionId,
795
856
  exitCode: 0,
@@ -822,7 +883,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
822
883
  await cleanupTempFiles(tempImagePaths, tempDir);
823
884
 
824
885
  // Send error to WebSocket
825
- ws.send({
886
+ mutableWriter.send({
826
887
  type: 'claude-error',
827
888
  error: error.message
828
889
  });
@@ -915,5 +976,6 @@ export {
915
976
  isClaudeSDKSessionActive,
916
977
  getActiveClaudeSDKSessions,
917
978
  resolveToolApproval,
918
- renameSessionForUser
979
+ renameSessionForUser,
980
+ updateSessionWriter
919
981
  };
package/server/index.js CHANGED
@@ -43,7 +43,7 @@ import fetch from 'node-fetch';
43
43
  import mime from 'mime-types';
44
44
 
45
45
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, updateProjectLastActivity } from './projects.js';
46
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, renameSessionForUser } from './claude-sdk.js';
46
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, renameSessionForUser, updateSessionWriter } from './claude-sdk.js';
47
47
  import authRoutes from './routes/auth.js';
48
48
  import mcpRoutes from './routes/mcp.js';
49
49
  import mcpUtilsRoutes from './routes/mcp-utils.js';
@@ -808,6 +808,11 @@ function handleChatConnection(ws, userData) {
808
808
  const sessionId = data.sessionId;
809
809
  const isActive = isClaudeSDKSessionActive(sessionId);
810
810
 
811
+ // If still running, attach new writer to receive buffered + future messages
812
+ if (isActive) {
813
+ updateSessionWriter(sessionId, writer);
814
+ }
815
+
811
816
  writer.send({
812
817
  type: 'session-status',
813
818
  sessionId,
@@ -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,9 +895,44 @@ 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
- const userSubmitDir = path.join(publicPaths.agentSubmissionsDir, String(userId));
935
+ const userSubmitDir = path.join(publicPaths.agentSubmissionsDir, userUuid);
798
936
  await fs.mkdir(userSubmitDir, { recursive: true });
799
937
 
800
938
  const timestamp = Date.now();
@@ -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);