@ian2018cs/agenthub 0.1.70 → 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.
@@ -12,7 +12,7 @@
12
12
  * - WebSocket message streaming
13
13
  */
14
14
 
15
- import { query, renameSession } from '@anthropic-ai/claude-agent-sdk';
15
+ import { query, renameSession, forkSession } from '@anthropic-ai/claude-agent-sdk';
16
16
  // Used to mint unique approval request IDs when randomUUID is not available.
17
17
  // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18
18
  import crypto from 'crypto';
@@ -24,6 +24,7 @@ import { getUserPaths } from './services/user-directories.js';
24
24
  import { usageDb } from './database/db.js';
25
25
  import { calculateCost, normalizeModelName } from './services/pricing.js';
26
26
  import { evaluate as evaluateToolGuard } from './services/tool-guard/index.js';
27
+ import { loadProjectConfig, addProjectManually } from './projects.js';
27
28
 
28
29
  // Session tracking: Map of session IDs to active query instances
29
30
  const activeSessions = new Map();
@@ -77,6 +78,30 @@ const pendingToolApprovals = new Map();
77
78
  // introduced to avoid hanging the run when no decision arrives.
78
79
  const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
79
80
 
81
+ // ─── 分享项目模板:pending 请求管理 ───
82
+ const pendingShareTemplateRequests = new Map();
83
+ const SHARE_TEMPLATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟(用户需要时间审阅和修改模板)
84
+
85
+ function waitForShareTemplateResponse(requestId) {
86
+ return new Promise(resolve => {
87
+ let settled = false;
88
+ const finalize = (result) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ pendingShareTemplateRequests.delete(requestId);
92
+ clearTimeout(timeout);
93
+ resolve(result);
94
+ };
95
+ const timeout = setTimeout(() => finalize(null), SHARE_TEMPLATE_TIMEOUT_MS);
96
+ pendingShareTemplateRequests.set(requestId, finalize);
97
+ });
98
+ }
99
+
100
+ function resolveShareTemplateRequest(requestId, result) {
101
+ const resolver = pendingShareTemplateRequests.get(requestId);
102
+ if (resolver) resolver(result);
103
+ }
104
+
80
105
  // Generate a stable request ID for UI approval flows.
81
106
  // This does not encode tool details or get shown to users; it exists so the UI
82
107
  // can respond to the correct pending request without collisions.
@@ -246,7 +271,28 @@ function mapCliOptionsToSDK(options = {}) {
246
271
  console.log(`Using model: ${sdkOptions.model}`);
247
272
 
248
273
  // Map system prompt configuration
249
- const baseAppend = PRODUCT_SYSTEM_DESC;
274
+ const SHARE_PROJECT_TEMPLATE_TOOL_DESC = `
275
+
276
+ ## 分享项目模板工具
277
+
278
+ 当用户想要分享项目、创建项目模板、发布共享项目时,使用以下 Bash 命令打开分享弹窗:
279
+
280
+ \`\`\`bash
281
+ __share_project_template__ '{"path":"/项目路径","displayName":"显示名称","description":"描述","updateNotes":"更新说明","skills":["skill1"],"mcps":["mcp1"],"files":["file1.md"]}'
282
+ \`\`\`
283
+
284
+ 参数说明:
285
+ - path (必填): 项目文件夹路径,可以是当前项目、子文件夹或其他项目路径
286
+ - displayName (可选): 模板显示名称,仅首次创建时使用(更新时忽略)
287
+ - description (可选): 模板描述,仅首次创建时使用(更新时忽略)
288
+ - updateNotes (可选): 更新说明,仅更新已有模板时使用
289
+ - skills (可选): 要包含的技能名称数组,空数组=让用户在弹窗中选择
290
+ - mcps (可选): 要包含的 MCP 服务名称数组,空数组=让用户在弹窗中选择
291
+ - files (可选): 要包含的文件相对路径数组,空数组=让用户在弹窗中选择
292
+
293
+ 该命令会打开一个 UI 弹窗供用户确认和修改,等待用户操作完成后返回结果。
294
+ `;
295
+ const baseAppend = PRODUCT_SYSTEM_DESC + SHARE_PROJECT_TEMPLATE_TOOL_DESC;
250
296
  const extraAppend = options.appendSystemPrompt ? `\n\n${options.appendSystemPrompt}` : '';
251
297
  sdkOptions.systemPrompt = {
252
298
  type: 'preset',
@@ -601,6 +647,98 @@ async function queryClaudeSDK(command, options = {}, ws) {
601
647
  ...((sdkOptions.hooks || {}).PreToolUse || []),
602
648
  {
603
649
  hooks: [async (hookInput) => {
650
+ // ===== 分享项目模板拦截(在 ToolGuard 之前,匹配后直接 return) =====
651
+ if (hookInput.tool_name === 'Bash' &&
652
+ hookInput.tool_input?.command?.trimStart().startsWith('__share_project_template__')) {
653
+ try {
654
+ // 检测飞书模式:MutableWriter.current 有 .ws 属性(WebSocketWriter),FakeSendWriter 没有
655
+ if (!mutableWriter?.current?.ws) {
656
+ return {
657
+ hookSpecificOutput: {
658
+ hookEventName: 'PreToolUse',
659
+ permissionDecision: 'deny',
660
+ permissionDecisionReason: '分享项目模板功能仅支持网页端使用。',
661
+ }
662
+ };
663
+ }
664
+
665
+ // 解析参数
666
+ const rawArgs = hookInput.tool_input.command.replace(/^\s*__share_project_template__\s*/, '');
667
+ let jsonStr = rawArgs.trim();
668
+ if ((jsonStr.startsWith("'") && jsonStr.endsWith("'")) ||
669
+ (jsonStr.startsWith('"') && jsonStr.endsWith('"'))) {
670
+ jsonStr = jsonStr.slice(1, -1);
671
+ }
672
+ const params = JSON.parse(jsonStr);
673
+
674
+ if (!params.path) {
675
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
676
+ permissionDecisionReason: '缺少必需参数 path(项目路径)。' } };
677
+ }
678
+
679
+ // 解析 path → projectKey,并判断是否为已有项目
680
+ const config = await loadProjectConfig(userUuid);
681
+ let projectKey = null;
682
+ let isExistingProject = false;
683
+ for (const [key, entry] of Object.entries(config)) {
684
+ if ((entry.originalPath || key.replace(/-/g, '/')) === params.path) {
685
+ projectKey = key;
686
+ isExistingProject = true;
687
+ break;
688
+ }
689
+ }
690
+ if (!projectKey) {
691
+ // 路径不在项目列表中(可能是子文件夹),自动添加为新项目
692
+ try {
693
+ const project = await addProjectManually(params.path, params.displayName, userUuid);
694
+ projectKey = project.name;
695
+ isExistingProject = false;
696
+ } catch (e) {
697
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
698
+ permissionDecisionReason: `无法解析项目路径: ${e.message}` } };
699
+ }
700
+ }
701
+
702
+ // 发送 WebSocket 消息打开分享弹窗
703
+ const shareRequestId = createRequestId();
704
+ mutableWriter.send({
705
+ type: 'share-project-template-request',
706
+ requestId: shareRequestId,
707
+ prefillData: {
708
+ projectKey,
709
+ projectPath: params.path,
710
+ isExistingProject,
711
+ displayName: params.displayName || '',
712
+ description: params.description || '',
713
+ updateNotes: params.updateNotes || '',
714
+ skills: params.skills || [],
715
+ mcps: params.mcps || [],
716
+ files: params.files || [],
717
+ }
718
+ });
719
+
720
+ // 等待前端响应(5 分钟超时)
721
+ const response = await waitForShareTemplateResponse(shareRequestId);
722
+
723
+ let reason;
724
+ if (!response) {
725
+ reason = '分享项目模板请求超时(5分钟内未收到响应)。';
726
+ } else if (response.cancelled) {
727
+ reason = '用户取消了分享项目模板操作。';
728
+ } else if (response.success) {
729
+ reason = `项目模板已成功提交!提交 ID: ${response.submissionId}。${response.message || ''}`;
730
+ } else {
731
+ reason = `提交失败: ${response.error || '未知错误'}`;
732
+ }
733
+
734
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
735
+ } catch (err) {
736
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
737
+ permissionDecisionReason: `分享项目模板失败: ${err.message}` } };
738
+ }
739
+ }
740
+
741
+ // ===== 安全守卫(Tool Guard)=====
604
742
  try {
605
743
  const guardResult = await evaluateToolGuard(hookInput.tool_name, hookInput.tool_input, {
606
744
  userUuid,
@@ -969,6 +1107,20 @@ async function renameSessionForUser(sessionId, title, userUuid) {
969
1107
  await renameSession(sessionId, title);
970
1108
  }
971
1109
 
1110
+ /**
1111
+ * Fork a session at a given message, creating a new branch conversation.
1112
+ * @param {string} sessionId - Session to fork
1113
+ * @param {string} userUuid - User UUID for directory isolation
1114
+ * @param {object} opts - Fork options (upToMessageId, title)
1115
+ * @returns {Promise<string>} New session UUID
1116
+ */
1117
+ async function forkSessionForUser(sessionId, userUuid, opts = {}) {
1118
+ const userPaths = getUserPaths(userUuid);
1119
+ process.env.CLAUDE_CONFIG_DIR = userPaths.claudeDir;
1120
+ const result = await forkSession(sessionId, opts);
1121
+ return result.sessionId;
1122
+ }
1123
+
972
1124
  // Export public API
973
1125
  export {
974
1126
  queryClaudeSDK,
@@ -976,6 +1128,8 @@ export {
976
1128
  isClaudeSDKSessionActive,
977
1129
  getActiveClaudeSDKSessions,
978
1130
  resolveToolApproval,
1131
+ resolveShareTemplateRequest,
979
1132
  renameSessionForUser,
1133
+ forkSessionForUser,
980
1134
  updateSessionWriter
981
1135
  };
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, updateSessionWriter } from './claude-sdk.js';
46
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, resolveShareTemplateRequest, renameSessionForUser, forkSessionForUser, 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';
@@ -455,6 +455,22 @@ app.put('/api/projects/:projectName/sessions/:sessionId/rename', authenticateTok
455
455
  }
456
456
  });
457
457
 
458
+ // Fork session endpoint — creates a new branch conversation from a given point
459
+ app.post('/api/projects/:projectName/sessions/:sessionId/fork', authenticateToken, async (req, res) => {
460
+ try {
461
+ const { sessionId } = req.params;
462
+ const opts = {};
463
+ if (req.body?.upToMessageId) {
464
+ opts.upToMessageId = req.body.upToMessageId;
465
+ }
466
+ const newSessionId = await forkSessionForUser(sessionId, req.user.uuid, opts);
467
+ res.json({ newSessionId });
468
+ } catch (error) {
469
+ console.error(`[API] Error forking session ${req.params.sessionId}:`, error);
470
+ res.status(500).json({ error: error.message });
471
+ }
472
+ });
473
+
458
474
  // Delete project endpoint
459
475
  app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
460
476
  try {
@@ -803,6 +819,17 @@ function handleChatConnection(ws, userData) {
803
819
  rememberEntry: data.rememberEntry
804
820
  });
805
821
  }
822
+ } else if (data.type === 'share-project-template-response') {
823
+ // Relay the user's share-project-template decision back to the PreToolUse hook.
824
+ if (data.requestId) {
825
+ resolveShareTemplateRequest(data.requestId, {
826
+ success: Boolean(data.success),
827
+ cancelled: Boolean(data.cancelled),
828
+ submissionId: data.submissionId || null,
829
+ message: data.message || '',
830
+ error: data.error || '',
831
+ });
832
+ }
806
833
  } else if (data.type === 'check-session-status') {
807
834
  // Check if a specific session is currently processing
808
835
  const sessionId = data.sessionId;
@@ -5,7 +5,7 @@ import { spawn } from 'child_process';
5
5
  import multer from 'multer';
6
6
  import AdmZip from 'adm-zip';
7
7
  import { getUserPaths, getPublicPaths, DATA_DIR } from '../services/user-directories.js';
8
- import { SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME } from '../services/system-repo.js';
8
+ import { ensureSystemRepo, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME, SYSTEM_REPO_URL } from '../services/system-repo.js';
9
9
 
10
10
  const router = express.Router();
11
11
 
@@ -292,6 +292,83 @@ function updateRepository(repoPath) {
292
292
  });
293
293
  }
294
294
 
295
+ /**
296
+ * Run a git command and return a promise
297
+ */
298
+ function runGit(args, cwd) {
299
+ return new Promise((resolve, reject) => {
300
+ const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
301
+ if (cwd) opts.cwd = cwd;
302
+ const proc = spawn('git', args, opts);
303
+ let stderr = '';
304
+ proc.stderr.on('data', d => { stderr += d.toString(); });
305
+ proc.on('close', code => {
306
+ if (code === 0) resolve();
307
+ else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
308
+ });
309
+ proc.on('error', err => reject(err));
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Run a git command and return stdout as a string
315
+ */
316
+ function runGitOutput(args, cwd) {
317
+ return new Promise((resolve, reject) => {
318
+ const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
319
+ if (cwd) opts.cwd = cwd;
320
+ const proc = spawn('git', args, opts);
321
+ let stdout = '';
322
+ let stderr = '';
323
+ proc.stdout.on('data', d => { stdout += d.toString(); });
324
+ proc.stderr.on('data', d => { stderr += d.toString(); });
325
+ proc.on('close', code => {
326
+ if (code === 0) resolve(stdout);
327
+ else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
328
+ });
329
+ proc.on('error', err => reject(err));
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Recursively copy all files and directories from src to dst.
335
+ */
336
+ async function copyDirRecursive(src, dst) {
337
+ await fs.mkdir(dst, { recursive: true });
338
+ const entries = await fs.readdir(src, { withFileTypes: true });
339
+ for (const entry of entries) {
340
+ const srcPath = path.join(src, entry.name);
341
+ const dstPath = path.join(dst, entry.name);
342
+ if (entry.isDirectory()) {
343
+ await copyDirRecursive(srcPath, dstPath);
344
+ } else {
345
+ await fs.copyFile(srcPath, dstPath);
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Find a skill directory in the system repo (supports direct and nested structures).
352
+ * Returns absolute path to skill directory, or null if not found.
353
+ */
354
+ async function findSkillInSystemRepo(systemRepoPath, skillName) {
355
+ // Direct: {systemRepoPath}/{skillName}/SKILL.md
356
+ const directPath = path.join(systemRepoPath, skillName);
357
+ try {
358
+ await fs.access(path.join(directPath, 'SKILL.md'));
359
+ return directPath;
360
+ } catch {}
361
+
362
+ // Nested: {systemRepoPath}/skills/{skillName}/SKILL.md
363
+ const nestedPath = path.join(systemRepoPath, 'skills', skillName);
364
+ try {
365
+ await fs.access(path.join(nestedPath, 'SKILL.md'));
366
+ return nestedPath;
367
+ } catch {}
368
+
369
+ return null;
370
+ }
371
+
295
372
  /**
296
373
  * GET /api/skills
297
374
  * List user's installed skills
@@ -373,6 +450,43 @@ router.get('/', async (req, res) => {
373
450
  }
374
451
  }
375
452
 
453
+ // Compute sync status for each skill
454
+ // For builtin skills, batch-check git status once
455
+ const builtinChangedSkills = new Set();
456
+ const systemRepoTag = `${SYSTEM_REPO_OWNER}/${SYSTEM_REPO_NAME}`;
457
+ const hasBuiltinSkills = skills.some(s => s.repository === systemRepoTag);
458
+ if (hasBuiltinSkills) {
459
+ const publicPaths = getPublicPaths();
460
+ const systemRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
461
+ try {
462
+ const statusOutput = await runGitOutput(['status', '--porcelain'], systemRepoPath);
463
+ for (const line of statusOutput.split('\n')) {
464
+ if (!line) continue;
465
+ const filePath = line.slice(3).trim();
466
+ const topDir = filePath.split('/')[0];
467
+ if (topDir) builtinChangedSkills.add(topDir);
468
+ }
469
+ } catch {
470
+ // System repo may not exist locally or git may fail — skip sync detection
471
+ }
472
+ }
473
+
474
+ for (const skill of skills) {
475
+ if (skill.source === 'imported' || skill.source === 'unknown') {
476
+ skill.syncable = true;
477
+ skill.syncType = 'local';
478
+ } else if (skill.repository === systemRepoTag) {
479
+ skill.syncable = builtinChangedSkills.has(skill.name);
480
+ skill.syncType = 'builtin';
481
+ } else if (skill.source === 'repo') {
482
+ skill.syncable = true;
483
+ skill.syncType = 'external';
484
+ } else {
485
+ skill.syncable = false;
486
+ skill.syncType = null;
487
+ }
488
+ }
489
+
376
490
  res.json({ skills, count: skills.length });
377
491
  } catch (error) {
378
492
  console.error('Error listing skills:', error);
@@ -506,6 +620,149 @@ router.get('/:name/download', async (req, res) => {
506
620
  }
507
621
  });
508
622
 
623
+ /**
624
+ * POST /api/skills/:name/sync
625
+ * Sync a skill to the built-in system repository
626
+ */
627
+ router.post('/:name/sync', async (req, res) => {
628
+ try {
629
+ const userUuid = req.user?.uuid;
630
+ if (!userUuid) {
631
+ return res.status(401).json({ error: 'User authentication required' });
632
+ }
633
+
634
+ const { name } = req.params;
635
+ if (!SKILL_NAME_REGEX.test(name)) {
636
+ return res.status(400).json({ error: 'Invalid skill name' });
637
+ }
638
+
639
+ const userPaths = getUserPaths(userUuid);
640
+ const publicPaths = getPublicPaths();
641
+ const linkPath = path.join(userPaths.skillsDir, name);
642
+
643
+ // Resolve skill info
644
+ let realPath;
645
+ let source = 'unknown';
646
+ let repository = null;
647
+ let isSymlink = false;
648
+
649
+ try {
650
+ const stat = await fs.lstat(linkPath);
651
+ isSymlink = stat.isSymbolicLink();
652
+ if (isSymlink) {
653
+ realPath = await fs.realpath(linkPath);
654
+ if (realPath.includes('/skills-import/')) {
655
+ source = 'imported';
656
+ } else if (realPath.includes('/skills-repo/')) {
657
+ source = 'repo';
658
+ const repoMatch = realPath.match(/skills-repo\/([^/]+)\/([^/]+)/);
659
+ if (repoMatch) repository = `${repoMatch[1]}/${repoMatch[2]}`;
660
+ }
661
+ } else {
662
+ realPath = linkPath;
663
+ }
664
+ } catch {
665
+ return res.status(404).json({ error: 'Skill not found' });
666
+ }
667
+
668
+ if (!await isValidSkill(realPath)) {
669
+ return res.status(400).json({ error: 'Invalid skill: missing SKILL.md' });
670
+ }
671
+
672
+ const systemRepoTag = `${SYSTEM_REPO_OWNER}/${SYSTEM_REPO_NAME}`;
673
+ const username = req.user?.email || req.user?.username || req.user?.uuid;
674
+ const now = new Date();
675
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
676
+
677
+ // === Case 2: Built-in system repo skill — push local changes ===
678
+ if (source === 'repo' && repository === systemRepoTag) {
679
+ const systemRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
680
+ try {
681
+ await runGit(['add', name], systemRepoPath);
682
+ await runGit(['commit', '-m', `feat: update skill ${name} by user ${username} at ${timestamp}`], systemRepoPath);
683
+ await runGit(['push'], systemRepoPath);
684
+ console.log(`[SkillSync] Pushed changes for built-in skill "${name}" by ${username}`);
685
+ } catch (e) {
686
+ console.error(`[SkillSync] Failed to push skill "${name}":`, e.message);
687
+ return res.status(500).json({ error: `Git push failed: ${e.message}` });
688
+ }
689
+ return res.json({ success: true, message: '技能修改已同步到云端' });
690
+ }
691
+
692
+ // === Case 1 & 3: Local/imported or external repo skill — migrate to system repo ===
693
+ let skillRepoPath;
694
+ try {
695
+ skillRepoPath = await ensureSystemRepo();
696
+ } catch (e) {
697
+ console.error('[SkillSync] Failed to ensure system repo:', e.message);
698
+ return res.status(500).json({ error: `无法访问系统仓库: ${e.message}` });
699
+ }
700
+
701
+ const destSkillDir = path.join(skillRepoPath, name);
702
+
703
+ // Copy skill files to system repo (overwrite if exists)
704
+ await fs.rm(destSkillDir, { recursive: true, force: true });
705
+ await copyDirRecursive(realPath, destSkillDir);
706
+ console.log(`[SkillSync] Copied skill "${name}" to system repo`);
707
+
708
+ // Git: add, commit, push
709
+ try {
710
+ await runGit(['add', name], skillRepoPath);
711
+ await runGit(['commit', '-m', `feat: add skill ${name} by user ${username} at ${timestamp}`], skillRepoPath);
712
+ await runGit(['push'], skillRepoPath);
713
+ console.log(`[SkillSync] Pushed skill "${name}" to system repo`);
714
+ } catch (e) {
715
+ // Rollback: remove the files we just copied
716
+ await fs.rm(destSkillDir, { recursive: true, force: true }).catch(() => {});
717
+ console.error(`[SkillSync] Git push failed for skill "${name}":`, e.message);
718
+ return res.status(500).json({ error: `Git push failed: ${e.message}` });
719
+ }
720
+
721
+ // For external repo: restore original files via git checkout
722
+ if (source === 'repo' && repository && repository !== systemRepoTag) {
723
+ const repoMatch = repository.match(/^([^/]+)\/(.+)$/);
724
+ if (repoMatch) {
725
+ const externalRepoPath = path.join(publicPaths.skillsRepoDir, repoMatch[1], repoMatch[2]);
726
+ const skillRelPath = path.relative(externalRepoPath, realPath);
727
+ try {
728
+ await runGit(['checkout', '--', skillRelPath], externalRepoPath);
729
+ console.log(`[SkillSync] Restored external repo skill "${name}" via git checkout`);
730
+ } catch (e) {
731
+ console.warn(`[SkillSync] git checkout on external repo failed (non-fatal):`, e.message);
732
+ }
733
+ }
734
+ }
735
+
736
+ // Clean up local import directory (Case 1: imported)
737
+ if (source === 'imported') {
738
+ try {
739
+ await fs.rm(path.join(userPaths.skillsImportDir, name), { recursive: true, force: true });
740
+ } catch {}
741
+ }
742
+
743
+ // Remove current symlink/directory
744
+ if (isSymlink) {
745
+ await fs.unlink(linkPath);
746
+ } else {
747
+ await fs.rm(linkPath, { recursive: true, force: true });
748
+ }
749
+
750
+ // Re-install from system repo: find skill path and create symlink
751
+ const newSkillPath = await findSkillInSystemRepo(skillRepoPath, name);
752
+ if (newSkillPath) {
753
+ await fs.symlink(newSkillPath, linkPath);
754
+ console.log(`[SkillSync] Re-installed skill "${name}" from system repo`);
755
+ } else {
756
+ console.warn(`[SkillSync] Could not find skill "${name}" in system repo after push — skipping re-install`);
757
+ }
758
+
759
+ return res.json({ success: true, message: '技能已成功同步到共享仓库' });
760
+ } catch (error) {
761
+ console.error('Error syncing skill:', error);
762
+ res.status(500).json({ error: error.message });
763
+ }
764
+ });
765
+
509
766
  /**
510
767
  * DELETE /api/skills/disable/:name
511
768
  * Disable a skill by removing symlink