@ian2018cs/agenthub 0.1.70 → 0.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CyYbCDk1.js +192 -0
- package/dist/assets/index-DQaPJRqa.css +32 -0
- package/dist/assets/{vendor-icons-BWqhkbta.js → vendor-icons-CFgKYN6c.js} +77 -72
- package/dist/index.html +3 -3
- package/package.json +2 -2
- package/server/claude-sdk.js +156 -2
- package/server/index.js +28 -1
- package/server/routes/agents.js +3 -4
- package/server/routes/skills.js +258 -1
- package/server/services/system-agent-repo.js +19 -2
- package/server/services/system-mcp-repo.js +17 -21
- package/server/services/system-repo.js +17 -25
- package/dist/assets/index-DURCpZD_.css +0 -32
- package/dist/assets/index-DxBc5bLY.js +0 -186
package/server/claude-sdk.js
CHANGED
|
@@ -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
|
|
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;
|
package/server/routes/agents.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import AdmZip from 'adm-zip';
|
|
7
7
|
import { getUserPaths, getPublicPaths } from '../services/user-directories.js';
|
|
8
|
-
import { scanAgents, ensureAgentRepo, incrementPatchVersion, publishAgentToRepo } from '../services/system-agent-repo.js';
|
|
8
|
+
import { scanAgents, ensureAgentRepo, incrementPatchVersion, publishAgentToRepo, invalidateAgentCache } from '../services/system-agent-repo.js';
|
|
9
9
|
import { ensureSystemRepo, SYSTEM_REPO_URL } from '../services/system-repo.js';
|
|
10
10
|
import { ensureSystemMcpRepo, SYSTEM_MCP_REPO_URL } from '../services/system-mcp-repo.js';
|
|
11
11
|
import { addProjectManually, loadProjectConfig, saveProjectConfig } from '../projects.js';
|
|
@@ -444,8 +444,7 @@ router.get('/', async (req, res) => {
|
|
|
444
444
|
*/
|
|
445
445
|
router.post('/refresh', async (_req, res) => {
|
|
446
446
|
try {
|
|
447
|
-
await
|
|
448
|
-
const agents = await scanAgents();
|
|
447
|
+
const agents = await scanAgents(true);
|
|
449
448
|
res.json({ success: true, agentCount: agents.length });
|
|
450
449
|
} catch (error) {
|
|
451
450
|
console.error('Error refreshing agent repo:', error);
|
|
@@ -1295,7 +1294,7 @@ router.post('/submissions/:id/approve', async (req, res) => {
|
|
|
1295
1294
|
if (updateAllUsers) {
|
|
1296
1295
|
// Force-update all users who have this agent installed
|
|
1297
1296
|
try {
|
|
1298
|
-
const freshAgents = await scanAgents();
|
|
1297
|
+
const freshAgents = await scanAgents(true);
|
|
1299
1298
|
const publishedAgent = freshAgents.find(a =>
|
|
1300
1299
|
a.dirName === submission.agent_name || a.name === submission.agent_name
|
|
1301
1300
|
);
|
package/server/routes/skills.js
CHANGED
|
@@ -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
|
|
@@ -7,6 +7,14 @@ export const SYSTEM_AGENT_REPO_URL = 'git@git.amberweather.com:mcp-server/hupoer
|
|
|
7
7
|
export const SYSTEM_AGENT_REPO_OWNER = 'mcp-server';
|
|
8
8
|
export const SYSTEM_AGENT_REPO_NAME = 'hupoer-agents';
|
|
9
9
|
|
|
10
|
+
// ─── In-memory cache ──────────────────────────────────────────────────────────
|
|
11
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
let agentCache = { agents: null, cachedAt: 0 };
|
|
13
|
+
|
|
14
|
+
export function invalidateAgentCache() {
|
|
15
|
+
agentCache = { agents: null, cachedAt: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
function runGit(args, cwd = null) {
|
|
11
19
|
return new Promise((resolve, reject) => {
|
|
12
20
|
const opts = {
|
|
@@ -153,15 +161,23 @@ async function parseAgentYaml(agentDir) {
|
|
|
153
161
|
|
|
154
162
|
/**
|
|
155
163
|
* Scan the agent repo for available agents.
|
|
164
|
+
* @param {boolean} force - When true, always git pull and re-scan (bypasses cache).
|
|
165
|
+
* When false (default), returns cached result if < 5 min old.
|
|
156
166
|
* Returns array of agent metadata objects.
|
|
157
167
|
*/
|
|
158
|
-
export async function scanAgents() {
|
|
168
|
+
export async function scanAgents(force = false) {
|
|
169
|
+
// Return cached result if fresh and not forced
|
|
170
|
+
if (!force && agentCache.agents !== null && Date.now() - agentCache.cachedAt < CACHE_TTL_MS) {
|
|
171
|
+
return agentCache.agents;
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
let repoPath;
|
|
160
175
|
try {
|
|
161
176
|
repoPath = await ensureAgentRepo();
|
|
162
177
|
} catch (err) {
|
|
163
178
|
console.error('[AgentRepo] Could not access agent repo:', err.message);
|
|
164
|
-
|
|
179
|
+
// Return stale cache on network error rather than empty list
|
|
180
|
+
return agentCache.agents ?? [];
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
const agents = [];
|
|
@@ -195,6 +211,7 @@ export async function scanAgents() {
|
|
|
195
211
|
});
|
|
196
212
|
}
|
|
197
213
|
|
|
214
|
+
agentCache = { agents, cachedAt: Date.now() };
|
|
198
215
|
return agents;
|
|
199
216
|
}
|
|
200
217
|
|