zen-gitsync 2.17.8 → 2.17.10
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/README.md +6 -0
- package/package.json +2 -1
- package/src/cli/ai/agent.js +210 -58
- package/src/cli/ai/context.js +58 -0
- package/src/cli/ai/context.test.js +117 -0
- package/src/cli/ai/extensions.js +131 -0
- package/src/cli/ai/extensions.test.js +158 -0
- package/src/cli/ai/mcp.js +498 -0
- package/src/cli/ai/mcp.test.js +275 -0
- package/src/cli/ai/runtime.test.js +32 -0
- package/src/cli/ai/skills.js +207 -0
- package/src/cli/ai/skills.test.js +126 -0
- package/src/cli/ai/termui.js +1 -0
- package/src/cli/ai/tools.js +94 -3
- package/src/cli/ai/tools.test.js +82 -0
- package/src/cli/ai/transport.js +18 -4
- package/src/cli/ai/turn.js +21 -5
- package/src/config.js +27 -3
- package/src/paths.js +11 -0
- package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
- package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
- package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
- package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
- package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
- package/src/ui/public/assets/index-CW3xs-af.css +1 -0
- package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
- package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
- package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
- package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
- package/src/ui/public/index.html +17 -17
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/config.js +16 -3
- package/src/ui/server/routes/exec.js +19 -11
- package/src/ui/server/routes/fileOpen.js +142 -2
- package/src/ui/server/routes/remoteRepos.js +604 -0
- package/src/ui/server/routes/remoteRepos.test.js +386 -0
- package/src/ui/server/routes/workbench/agentChat.js +86 -180
- package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
- package/src/ui/server/routes/workbench/jobStore.js +17 -3
- package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
- package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
- package/src/ui/server/routes/workbench/projectTool.js +198 -0
- package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
- package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
- package/src/ui/server/routes/workbench/taskRunner.js +269 -12
- package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
- package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
- package/src/utils/shellPath.js +253 -0
- package/src/utils/shellPath.test.js +221 -0
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
- package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
- package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
- package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
- package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
- package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
- package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
- package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
- package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
- package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
- package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
- package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
- package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
- package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
- package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
- package/src/ui/public/assets/index-cApp6w0b.css +0 -1
- package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
- package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
- package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
- package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 智能体页面的两个「广场」后端:Skill 广场 + MCP 广场。
|
|
16
|
+
//
|
|
17
|
+
// 设计要点
|
|
18
|
+
// ────────
|
|
19
|
+
// 1) **按来源分组**。每个来源是一个独立的 provider,有自己的抓取与归一化逻辑;
|
|
20
|
+
// 请求时并行 allSettled,单个来源失败只让那一组带上 error,不影响其他组 ——
|
|
21
|
+
// 第三方目录站掉线是很常见的事,不能让整页变空。
|
|
22
|
+
// 2) **内置精选离线可用**。每个类型的 builtin 组是静态数据,不联网也有内容;
|
|
23
|
+
// 联网来源失败时用户至少还能看到它们。内置条目的仓库与 npm 包名都经过实际校验,
|
|
24
|
+
// 不是凭印象写的占位数据。
|
|
25
|
+
// 3) **两个安装目标**。
|
|
26
|
+
// - project: <cwd>/.claude/skills/<id>/ 与 <cwd>/.mcp.json(项目级,生态通行约定)
|
|
27
|
+
// - global : ~/.zen-gitsync/ai/skills/<id>/ 与 ~/.zen-gitsync/ai/mcp.json
|
|
28
|
+
// (即「g ai 智能体」,对所有项目生效;路径常量在 src/paths.js)
|
|
29
|
+
// 路径与 g ai 的读取端(src/cli/ai/skills.js、src/cli/ai/mcp.js)共用同一份常量,
|
|
30
|
+
// 改这里不会出现"装了但读不到"。
|
|
31
|
+
// 4) 所有来自网络的值在落盘前都要过白名单校验(仓库名 / 包名 / 子路径),
|
|
32
|
+
// 因为这些值最终会变成 git clone 与 npm install 的参数 —— 网络数据不可信。
|
|
33
|
+
//
|
|
34
|
+
// 数据来源(2026-09 实测可用性见每个 provider 的注释)
|
|
35
|
+
// ────────────────────────────────────────────────
|
|
36
|
+
// Skill: Anthropic 官方仓库 / SkillsMP / 社区精选合集 / GitHub 搜索 / npm
|
|
37
|
+
// MCP : MCP 官方注册表 / Smithery / npm / GitHub 搜索
|
|
38
|
+
// 不可用:Glama(需 API key)、PulseMCP(v0.1 起需 key,v0beta 已下线)
|
|
39
|
+
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import os from 'node:os';
|
|
42
|
+
import { promises as fs } from 'node:fs';
|
|
43
|
+
import { execFile } from 'node:child_process';
|
|
44
|
+
import { promisify } from 'node:util';
|
|
45
|
+
import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
|
|
46
|
+
import { AI_SKILLS_DIR, AI_MCP_FILE } from '../../../../paths.js';
|
|
47
|
+
|
|
48
|
+
const execFileAsync = promisify(execFile);
|
|
49
|
+
|
|
50
|
+
const FETCH_TIMEOUT_MS = 12_000;
|
|
51
|
+
const CLONE_TIMEOUT_MS = 180_000;
|
|
52
|
+
const NPM_TIMEOUT_MS = 180_000;
|
|
53
|
+
// 单个来源最多返回多少条 —— 再多用户也不会翻,反而拖慢首屏
|
|
54
|
+
const MAX_PER_SOURCE = 24;
|
|
55
|
+
// npm 搜索接口的 size 上限设小一点:它的结果噪声比目录站大得多
|
|
56
|
+
const MAX_PER_NPM = 20;
|
|
57
|
+
// 联网结果的进程内缓存。目录站几乎没有秒级变化,5 分钟足够新鲜。
|
|
58
|
+
const CACHE_TTL_MS = 5 * 60_000;
|
|
59
|
+
|
|
60
|
+
// ── 校验白名单 ────────────────────────────────────────────────
|
|
61
|
+
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,80}$/;
|
|
62
|
+
const SAFE_PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
63
|
+
const SAFE_REPOSITORY = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
64
|
+
// 仓库内子路径:禁止绝对路径与任何 .. 片段
|
|
65
|
+
const SAFE_SUBPATH = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[A-Za-z0-9._\-/]{1,200}$/;
|
|
66
|
+
|
|
67
|
+
// ── 来源定义 ──────────────────────────────────────────────────
|
|
68
|
+
// kind: 'builtin' 静态数据 | 'live' 联网抓取
|
|
69
|
+
// live 的来源都会经过 provider 函数,失败时只影响本组。
|
|
70
|
+
|
|
71
|
+
const SKILL_SOURCES = [
|
|
72
|
+
{ id: 'builtin', label: '内置精选', kind: 'builtin', homepage: 'https://github.com/anthropics/skills' },
|
|
73
|
+
{ id: 'official', label: 'Anthropic 官方仓库', kind: 'live', homepage: 'https://github.com/anthropics/skills' },
|
|
74
|
+
{ id: 'skillsmp', label: 'SkillsMP', kind: 'live', homepage: 'https://skillsmp.com' },
|
|
75
|
+
{ id: 'awesome', label: '社区精选合集', kind: 'live', homepage: 'https://github.com/ComposioHQ/awesome-claude-skills' },
|
|
76
|
+
{ id: 'github', label: 'GitHub 搜索', kind: 'live', homepage: 'https://github.com/search?q=claude+skill' },
|
|
77
|
+
{ id: 'npm', label: 'npm', kind: 'live', homepage: 'https://www.npmjs.com/search?q=claude%20skill' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const MCP_SOURCES = [
|
|
81
|
+
{ id: 'builtin', label: '内置精选', kind: 'builtin', homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
82
|
+
{ id: 'registry', label: 'MCP 官方注册表', kind: 'live', homepage: 'https://registry.modelcontextprotocol.io' },
|
|
83
|
+
{ id: 'smithery', label: 'Smithery', kind: 'live', homepage: 'https://smithery.ai' },
|
|
84
|
+
{ id: 'npm', label: 'npm', kind: 'live', homepage: 'https://www.npmjs.com/search?q=mcp-server' },
|
|
85
|
+
{ id: 'github', label: 'GitHub 搜索', kind: 'live', homepage: 'https://github.com/search?q=mcp-server' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// ── 内置精选(离线可用,仓库/包名均已核验) ────────────────────
|
|
89
|
+
// Anthropic 官方仓库的 skills/ 目录下实际存在这些子目录。
|
|
90
|
+
const ANTHROPIC_SKILLS = [
|
|
91
|
+
['docx', '生成与编辑 Word 文档:修订、批注、样式与格式。'],
|
|
92
|
+
['pdf', '提取文本与表格、合并拆分、批注 PDF。'],
|
|
93
|
+
['pptx', '读取、生成与调整幻灯片版式与模板。'],
|
|
94
|
+
['xlsx', '电子表格处理:公式、图表、数据变换。'],
|
|
95
|
+
['skill-creator', '把一套可复用的工作流写成标准 SKILL.md(造 skill 的 skill)。'],
|
|
96
|
+
['mcp-builder', '按规范实现一个 MCP 服务器。'],
|
|
97
|
+
['webapp-testing', '用 Playwright 驱动浏览器,自动化测试本地 Web 应用。'],
|
|
98
|
+
['frontend-design', '做出视觉系统完整、可访问性达标的前端界面。'],
|
|
99
|
+
['canvas-design', '画布/图形类设计产出。'],
|
|
100
|
+
['doc-coauthoring', '与用户协作撰写长文档。'],
|
|
101
|
+
['algorithmic-art', '程序化生成艺术图形。'],
|
|
102
|
+
['brand-guidelines', '把品牌规范落到具体产出上。'],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// superpowers 是社区里最成体系的 skill 库(skills/ 目录下逐个可独立安装)
|
|
106
|
+
const SUPERPOWERS_SKILLS = [
|
|
107
|
+
['systematic-debugging', '结构化的缺陷定位流程:先找根因再提修复。'],
|
|
108
|
+
['test-driven-development', 'TDD 循环:先写失败测试,再实现。'],
|
|
109
|
+
['writing-plans', '把需求拆成可执行的实施计划。'],
|
|
110
|
+
['requesting-code-review', '发起代码评审时的组织与自检清单。'],
|
|
111
|
+
['verification-before-completion', '交付前的验证清单:证据驱动,不靠"应该没问题"。'],
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const BUILTIN_SKILLS = [
|
|
115
|
+
...ANTHROPIC_SKILLS.map(([name, description]) => ({
|
|
116
|
+
id: `anthropic-${name}`,
|
|
117
|
+
name,
|
|
118
|
+
description,
|
|
119
|
+
repository: 'anthropics/skills',
|
|
120
|
+
subpath: `skills/${name}`,
|
|
121
|
+
upstream: `https://github.com/anthropics/skills/tree/main/skills/${name}`,
|
|
122
|
+
tags: ['official'],
|
|
123
|
+
})),
|
|
124
|
+
...SUPERPOWERS_SKILLS.map(([name, description]) => ({
|
|
125
|
+
id: `superpowers-${name}`,
|
|
126
|
+
name,
|
|
127
|
+
description,
|
|
128
|
+
repository: 'obra/superpowers',
|
|
129
|
+
subpath: `skills/${name}`,
|
|
130
|
+
upstream: `https://github.com/obra/superpowers/tree/main/skills/${name}`,
|
|
131
|
+
tags: ['community', 'workflow'],
|
|
132
|
+
})),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const BUILTIN_MCPS = [
|
|
136
|
+
{
|
|
137
|
+
id: 'mcp-filesystem',
|
|
138
|
+
name: 'Filesystem',
|
|
139
|
+
description: '在显式授权的目录里读写文件。',
|
|
140
|
+
package: '@modelcontextprotocol/server-filesystem',
|
|
141
|
+
args: ['-y', '@modelcontextprotocol/server-filesystem', '${project}'],
|
|
142
|
+
upstream: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
|
|
143
|
+
tags: ['official', 'files'],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'mcp-memory',
|
|
147
|
+
name: 'Memory',
|
|
148
|
+
description: '基于知识图谱的长期记忆,跨会话记住事实与关系。',
|
|
149
|
+
package: '@modelcontextprotocol/server-memory',
|
|
150
|
+
upstream: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
|
|
151
|
+
tags: ['official', 'memory'],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'mcp-sequential-thinking',
|
|
155
|
+
name: 'Sequential Thinking',
|
|
156
|
+
description: '把复杂问题拆成可修订的连续思考步骤。',
|
|
157
|
+
package: '@modelcontextprotocol/server-sequential-thinking',
|
|
158
|
+
upstream: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking',
|
|
159
|
+
tags: ['official', 'reasoning'],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'mcp-everything',
|
|
163
|
+
name: 'Everything (示例服务器)',
|
|
164
|
+
description: '官方参考实现,涵盖 prompts/resources/tools 全套能力,适合验证链路。',
|
|
165
|
+
package: '@modelcontextprotocol/server-everything',
|
|
166
|
+
upstream: 'https://github.com/modelcontextprotocol/servers/tree/main/src/everything',
|
|
167
|
+
tags: ['official', 'reference'],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'mcp-github',
|
|
171
|
+
name: 'GitHub',
|
|
172
|
+
description: '操作仓库、Issue、Pull Request 与代码搜索。',
|
|
173
|
+
package: '@modelcontextprotocol/server-github',
|
|
174
|
+
envKeys: ['GITHUB_PERSONAL_ACCESS_TOKEN'],
|
|
175
|
+
upstream: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
|
|
176
|
+
tags: ['official', 'github'],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'mcp-context7',
|
|
180
|
+
name: 'Context7',
|
|
181
|
+
description: '拉取任意库的最新版文档与代码示例,避免模型凭记忆写过期 API。',
|
|
182
|
+
package: '@upstash/context7-mcp',
|
|
183
|
+
upstream: 'https://github.com/upstash/context7',
|
|
184
|
+
tags: ['docs', 'popular'],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'mcp-playwright',
|
|
188
|
+
name: 'Playwright',
|
|
189
|
+
description: '浏览器自动化:打开页面、点击、截图、取 DOM。',
|
|
190
|
+
package: '@playwright/mcp',
|
|
191
|
+
upstream: 'https://github.com/microsoft/playwright-mcp',
|
|
192
|
+
tags: ['browser', 'popular'],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'mcp-chrome-devtools',
|
|
196
|
+
name: 'Chrome DevTools',
|
|
197
|
+
description: '直接操作真实 Chrome:性能轨迹、网络、控制台。',
|
|
198
|
+
package: 'chrome-devtools-mcp',
|
|
199
|
+
upstream: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
|
|
200
|
+
tags: ['browser'],
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
// ── 工具函数 ──────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
function normalizeType(value) {
|
|
207
|
+
return value === 'mcp' ? 'mcp' : value === 'skill' ? 'skill' : '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sourcesFor(type) {
|
|
211
|
+
return type === 'mcp' ? MCP_SOURCES : SKILL_SOURCES;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function safeId(value) {
|
|
215
|
+
const id = String(value || '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
216
|
+
return id.slice(0, 80) || 'extension';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function clampText(value, max) {
|
|
220
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
221
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSafeSubpath(value) {
|
|
225
|
+
return SAFE_SUBPATH.test(String(value || ''));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resolveProjectPath(value, fallback) {
|
|
229
|
+
const raw = String(value || fallback || '').trim();
|
|
230
|
+
if (!raw) throw new HttpError(400, '项目路径不能为空');
|
|
231
|
+
return path.resolve(raw);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function assertDirectory(cwd) {
|
|
235
|
+
const stat = await fs.stat(cwd).catch(() => null);
|
|
236
|
+
if (!stat?.isDirectory()) throw new HttpError(400, `项目目录不存在: ${cwd}`);
|
|
237
|
+
return cwd;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function githubHeaders() {
|
|
241
|
+
// 未认证的 GitHub API 是 60 次/小时(按 IP),搜索接口更紧。
|
|
242
|
+
// 让用户可以用 GH_TOKEN / GITHUB_TOKEN 提额,没配也能用,只是更早限流。
|
|
243
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '';
|
|
244
|
+
const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'zen-gitsync-agent-marketplace' };
|
|
245
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
246
|
+
return headers;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function fetchJson(url, { headers = {}, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
252
|
+
try {
|
|
253
|
+
const response = await fetch(url, {
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
headers: { Accept: 'application/json', 'User-Agent': 'zen-gitsync-agent-marketplace', ...headers },
|
|
256
|
+
});
|
|
257
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
258
|
+
return await response.json();
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function fetchText(url, { headers = {}, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetch(url, {
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
headers: { 'User-Agent': 'zen-gitsync-agent-marketplace', ...headers },
|
|
271
|
+
});
|
|
272
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
273
|
+
return await response.text();
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 缓存 ──────────────────────────────────────────────────────
|
|
280
|
+
/** @type {Map<string, { expires: number, value: unknown }>} */
|
|
281
|
+
const cache = new Map();
|
|
282
|
+
|
|
283
|
+
async function cached(key, loader) {
|
|
284
|
+
const hit = cache.get(key);
|
|
285
|
+
if (hit && hit.expires > Date.now()) return hit.value;
|
|
286
|
+
// 失败不写缓存:目录站偶发抖动时,下一次搜索应该立刻重试而不是等 TTL
|
|
287
|
+
const value = await loader();
|
|
288
|
+
cache.set(key, { expires: Date.now() + CACHE_TTL_MS, value });
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── provider: Skill ───────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
// builtin:纯静态,不联网
|
|
295
|
+
function builtinSkills() {
|
|
296
|
+
return BUILTIN_SKILLS.map(item => ({ ...item, type: 'skill' }));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Anthropic 官方仓库:用 GitHub contents API 列出 skills/ 下的目录。
|
|
300
|
+
// 这是所有 skill 的"源头",目录结构稳定(path = skills/<name>)。
|
|
301
|
+
// contents API 不返回描述,所以用内置表里的描述补上(两边都以目录名为键),
|
|
302
|
+
// 补不到的条目在前端只显示名字,不会假装有描述。
|
|
303
|
+
async function officialSkills() {
|
|
304
|
+
const data = await fetchJson('https://api.github.com/repos/anthropics/skills/contents/skills', { headers: githubHeaders() });
|
|
305
|
+
if (!Array.isArray(data)) throw new Error('GitHub 返回结构异常');
|
|
306
|
+
const described = new Map(ANTHROPIC_SKILLS);
|
|
307
|
+
return data
|
|
308
|
+
.filter(entry => entry?.type === 'dir' && SAFE_ID.test(entry.name))
|
|
309
|
+
.map(entry => ({
|
|
310
|
+
id: `anthropic-${entry.name}`,
|
|
311
|
+
type: 'skill',
|
|
312
|
+
name: entry.name,
|
|
313
|
+
description: described.get(entry.name) || '',
|
|
314
|
+
repository: 'anthropics/skills',
|
|
315
|
+
subpath: `skills/${entry.name}`,
|
|
316
|
+
upstream: entry.html_url,
|
|
317
|
+
tags: ['official'],
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// SkillsMP:第三方 skill 目录站,给的是 owner/repo + 仓库内路径,可直接安装。
|
|
322
|
+
async function skillsmpSkills(query) {
|
|
323
|
+
const params = new URLSearchParams({ limit: String(MAX_PER_SOURCE) });
|
|
324
|
+
// 只在有关键词时才带 search 参数 —— 传空的 search= 会被它当非法请求打回 400(实测)
|
|
325
|
+
if (query) params.set('search', query);
|
|
326
|
+
const data = await fetchJson(`https://skillsmp.com/api/skills?${params}`);
|
|
327
|
+
const list = Array.isArray(data?.skills) ? data.skills : [];
|
|
328
|
+
return list.map(item => {
|
|
329
|
+
const repository = [item.route?.ownerSlug, item.route?.repoSlug].filter(Boolean).join('/');
|
|
330
|
+
if (!SAFE_REPOSITORY.test(repository)) return null;
|
|
331
|
+
// sourceSkillPath 形如 .agents/skills/agent-transcript/SKILL.md —— 取它所在的目录
|
|
332
|
+
const sourcePath = String(item.route?.sourceSkillPath || item.path || '');
|
|
333
|
+
const subpath = sourcePath.replace(/\/?SKILL\.md$/i, '');
|
|
334
|
+
return {
|
|
335
|
+
id: `skillsmp-${safeId(item.id || `${repository}-${item.name}`)}`,
|
|
336
|
+
type: 'skill',
|
|
337
|
+
name: clampText(item.name, 80),
|
|
338
|
+
description: clampText(item.description, 400),
|
|
339
|
+
author: item.author || '',
|
|
340
|
+
repository,
|
|
341
|
+
subpath: subpath && isSafeSubpath(subpath) ? subpath : '',
|
|
342
|
+
branch: item.branch || '',
|
|
343
|
+
stars: Number(item.stars) || 0,
|
|
344
|
+
upstream: item.githubUrl || `https://github.com/${repository}`,
|
|
345
|
+
tags: ['skillsmp'],
|
|
346
|
+
};
|
|
347
|
+
}).filter(Boolean);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 社区精选合集:README 是标准的 "### 分类" + "- [名字](链接) - 描述" 结构。
|
|
351
|
+
// 链接可能是 github 仓库/子目录,也可能是站点首页 —— 后者不可安装,只展示。
|
|
352
|
+
async function awesomeSkills(query) {
|
|
353
|
+
const readme = await fetchText('https://api.github.com/repos/ComposioHQ/awesome-claude-skills/readme', {
|
|
354
|
+
headers: { ...githubHeaders(), Accept: 'application/vnd.github.raw' },
|
|
355
|
+
});
|
|
356
|
+
const q = String(query || '').toLowerCase();
|
|
357
|
+
const items = [];
|
|
358
|
+
let category = '';
|
|
359
|
+
const lines = readme.split(/\r?\n/);
|
|
360
|
+
|
|
361
|
+
for (const line of lines) {
|
|
362
|
+
const heading = /^###\s+(.+)$/.exec(line);
|
|
363
|
+
if (heading) { category = heading[1].trim(); continue; }
|
|
364
|
+
const entry = /^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*(?:[-–—]\s*(.*))?$/.exec(line);
|
|
365
|
+
if (!entry) continue;
|
|
366
|
+
const [, rawName, rawUrl, rawDescription = ''] = entry;
|
|
367
|
+
// 跳过目录(TOC)条目:它们指向页内锚点
|
|
368
|
+
if (rawUrl.startsWith('#')) continue;
|
|
369
|
+
|
|
370
|
+
const name = clampText(rawName, 80);
|
|
371
|
+
const description = clampText(rawDescription.replace(/\*By \[[^\]]*\]\([^)]*\)\*/g, ''), 400);
|
|
372
|
+
if (q && !`${name} ${description} ${category}`.toLowerCase().includes(q)) continue;
|
|
373
|
+
|
|
374
|
+
const target = resolveSkillTarget(rawUrl);
|
|
375
|
+
if (!target) continue;
|
|
376
|
+
items.push({
|
|
377
|
+
id: `awesome-${safeId(`${target.repository}-${target.subpath || 'root'}`)}`,
|
|
378
|
+
type: 'skill',
|
|
379
|
+
name,
|
|
380
|
+
description: description || category,
|
|
381
|
+
repository: target.repository,
|
|
382
|
+
subpath: target.subpath,
|
|
383
|
+
upstream: target.upstream,
|
|
384
|
+
tags: [category].filter(Boolean),
|
|
385
|
+
});
|
|
386
|
+
if (items.length >= MAX_PER_SOURCE) break;
|
|
387
|
+
}
|
|
388
|
+
return items;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 把合集里的链接翻译成「仓库 + 仓库内子路径」。不认识的形状返回 null。
|
|
392
|
+
function resolveSkillTarget(rawUrl) {
|
|
393
|
+
const url = String(rawUrl || '').trim();
|
|
394
|
+
|
|
395
|
+
// 相对路径 → 相对该合集仓库自身(合集里也有自带 skill)
|
|
396
|
+
if (url.startsWith('./') || url.startsWith('../')) {
|
|
397
|
+
const subpath = url.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
398
|
+
if (!isSafeSubpath(subpath)) return null;
|
|
399
|
+
return { repository: 'ComposioHQ/awesome-claude-skills', subpath, upstream: `https://github.com/ComposioHQ/awesome-claude-skills/tree/main/${subpath}` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const github = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/]+)\/(.+?))?\/?$/.exec(url);
|
|
403
|
+
if (!github) return null;
|
|
404
|
+
const repository = `${github[1]}/${github[2]}`;
|
|
405
|
+
if (!SAFE_REPOSITORY.test(repository)) return null;
|
|
406
|
+
let subpath = (github[4] || '').replace(/\/?SKILL\.md$/i, '').replace(/\/+$/, '');
|
|
407
|
+
if (subpath && !isSafeSubpath(subpath)) subpath = '';
|
|
408
|
+
return { repository, subpath, upstream: url };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// GitHub 仓库搜索:补集用,搜"skill 集合型仓库"。
|
|
412
|
+
async function githubSkills(query) {
|
|
413
|
+
return searchGithubRepositories({ query, suffix: 'claude skill', type: 'skill' });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function githubMcps(query) {
|
|
417
|
+
return searchGithubRepositories({ query, suffix: 'mcp server', type: 'mcp' });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function searchGithubRepositories({ query, suffix, type }) {
|
|
421
|
+
const q = encodeURIComponent(`${query} ${suffix}`.trim());
|
|
422
|
+
const data = await fetchJson(`https://api.github.com/search/repositories?q=${q}&sort=stars&order=desc&per_page=${MAX_PER_SOURCE}`, { headers: githubHeaders() });
|
|
423
|
+
const list = Array.isArray(data?.items) ? data.items : [];
|
|
424
|
+
return list.map(item => {
|
|
425
|
+
const fullName = String(item.full_name || '');
|
|
426
|
+
if (!SAFE_REPOSITORY.test(fullName)) return null;
|
|
427
|
+
return {
|
|
428
|
+
id: `github-${safeId(fullName)}`,
|
|
429
|
+
type,
|
|
430
|
+
name: clampText(item.name || fullName, 80),
|
|
431
|
+
description: clampText(item.description || 'GitHub repository', 400),
|
|
432
|
+
repository: fullName,
|
|
433
|
+
subpath: '',
|
|
434
|
+
stars: Number(item.stargazers_count) || 0,
|
|
435
|
+
upstream: item.html_url || `https://github.com/${fullName}`,
|
|
436
|
+
tags: Array.isArray(item.topics) ? item.topics.slice(0, 5) : [],
|
|
437
|
+
};
|
|
438
|
+
}).filter(Boolean);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// npm:skill 这个品类在 npm 上没有规范约定,搜出来噪声很大,
|
|
442
|
+
// 所以只在用户真的输入了关键词时才查。
|
|
443
|
+
async function npmSkills(query) {
|
|
444
|
+
if (!query) return [];
|
|
445
|
+
const data = await fetchJson(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`${query} claude skill`)}&size=${MAX_PER_NPM}`);
|
|
446
|
+
return normalizeNpmSearch(data, 'skill');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function normalizeNpmSearch(data, type) {
|
|
450
|
+
const objects = Array.isArray(data?.objects) ? data.objects : [];
|
|
451
|
+
return objects.map(entry => {
|
|
452
|
+
const name = String(entry?.package?.name || '').trim();
|
|
453
|
+
if (!name || !SAFE_PACKAGE.test(name)) return null;
|
|
454
|
+
return {
|
|
455
|
+
id: `npm-${safeId(name.replace(/^@/, '').replace('/', '-'))}`,
|
|
456
|
+
type,
|
|
457
|
+
name: clampText(entry.package.name, 80),
|
|
458
|
+
description: clampText(entry.package.description || 'npm package', 400),
|
|
459
|
+
package: name,
|
|
460
|
+
repository: '',
|
|
461
|
+
upstream: entry.package?.links?.npm || `https://www.npmjs.com/package/${encodeURIComponent(name)}`,
|
|
462
|
+
downloads: Number(entry.downloads?.weekly) || 0,
|
|
463
|
+
tags: ['npm'],
|
|
464
|
+
};
|
|
465
|
+
}).filter(Boolean);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── provider: MCP ─────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
function builtinMcps() {
|
|
471
|
+
return BUILTIN_MCPS.map(item => ({ ...item, type: 'mcp', command: 'npx' }));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// MCP 官方注册表:canonical 数据源,直接给出 npm identifier 与所需环境变量,
|
|
475
|
+
// 是"能一键装"比例最高的来源。version=latest 过滤掉历史版本(否则同一个 server
|
|
476
|
+
// 会返回几十条)。
|
|
477
|
+
async function registryMcps(query) {
|
|
478
|
+
const params = new URLSearchParams({ version: 'latest', limit: String(MAX_PER_SOURCE) });
|
|
479
|
+
if (query) params.set('search', query);
|
|
480
|
+
const data = await fetchJson(`https://registry.modelcontextprotocol.io/v0.1/servers?${params}`);
|
|
481
|
+
const list = Array.isArray(data?.servers) ? data.servers : [];
|
|
482
|
+
|
|
483
|
+
return list.map(entry => {
|
|
484
|
+
const server = entry?.server;
|
|
485
|
+
if (!server) return null;
|
|
486
|
+
const displayName = server.title || String(server.name || '').split('/').pop() || server.name;
|
|
487
|
+
// 优先取 npm 包(能直接 npx 起来);其余(pypi/nuget/oci)标记为不可一键安装
|
|
488
|
+
const npmPackage = (server.packages || []).find(pkg => pkg?.registryType === 'npm' && SAFE_PACKAGE.test(String(pkg.identifier || '')));
|
|
489
|
+
const remote = (server.remotes || [])[0]?.url;
|
|
490
|
+
const env = npmPackage?.environmentVariables || [];
|
|
491
|
+
const requiredEnv = env.filter(item => item?.isRequired).map(item => String(item.name || '')).filter(Boolean);
|
|
492
|
+
|
|
493
|
+
const args = npmPackage
|
|
494
|
+
? ['-y', String(npmPackage.identifier), ...(npmPackage.runtimeArguments || []).map(arg => String(arg.value ?? ''))]
|
|
495
|
+
: null;
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
id: `registry-${safeId(String(server.name || displayName))}`,
|
|
499
|
+
type: 'mcp',
|
|
500
|
+
name: clampText(displayName, 80),
|
|
501
|
+
description: clampText(server.description, 400),
|
|
502
|
+
publisher: String(server.name || '').split('/')[0] || '',
|
|
503
|
+
version: server.version || '',
|
|
504
|
+
package: npmPackage ? String(npmPackage.identifier) : '',
|
|
505
|
+
command: npmPackage ? 'npx' : '',
|
|
506
|
+
args,
|
|
507
|
+
envKeys: requiredEnv,
|
|
508
|
+
// 只给了 remote 端点的 server:官方 stdio 客户端连不上,
|
|
509
|
+
// 前端据此显示"需远程桥接"而不是给一个会失败的安装按钮。
|
|
510
|
+
transport: npmPackage ? 'stdio' : (remote ? 'remote' : 'unknown'),
|
|
511
|
+
remoteUrl: remote || '',
|
|
512
|
+
repository: String(server.repository?.url || '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, ''),
|
|
513
|
+
upstream: server.repository?.url || `https://registry.modelcontextprotocol.io/v0.1/servers?search=${encodeURIComponent(server.name || '')}`,
|
|
514
|
+
tags: ['registry'],
|
|
515
|
+
};
|
|
516
|
+
}).filter(Boolean);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Smithery:偏"托管 + 一键接入"的目录站,自带 verified / useCount 等信号。
|
|
520
|
+
async function smitheryMcps(query) {
|
|
521
|
+
const params = new URLSearchParams({ pageSize: String(MAX_PER_SOURCE) });
|
|
522
|
+
if (query) params.set('q', query);
|
|
523
|
+
const data = await fetchJson(`https://registry.smithery.ai/servers?${params}`);
|
|
524
|
+
const list = Array.isArray(data?.servers) ? data.servers : [];
|
|
525
|
+
return list.filter(item => item && !item.unlisted && !item.inactive).map(item => {
|
|
526
|
+
const qualified = String(item.qualifiedName || item.displayName || '').trim();
|
|
527
|
+
return {
|
|
528
|
+
id: `smithery-${safeId(item.id || qualified)}`,
|
|
529
|
+
type: 'mcp',
|
|
530
|
+
name: clampText(item.displayName || qualified, 80),
|
|
531
|
+
description: clampText(item.description, 400),
|
|
532
|
+
publisher: item.namespace || '',
|
|
533
|
+
package: '',
|
|
534
|
+
command: '',
|
|
535
|
+
args: null,
|
|
536
|
+
envKeys: [],
|
|
537
|
+
// Smithery 上大量 server 是托管型(remote + isDeployed),没有本地 npm 包
|
|
538
|
+
transport: item.remote ? 'remote' : 'unknown',
|
|
539
|
+
remoteUrl: item.homepage || 'https://smithery.ai',
|
|
540
|
+
verified: !!item.verified,
|
|
541
|
+
uses: Number(item.useCount) || 0,
|
|
542
|
+
upstream: item.homepage || `https://smithery.ai/server/${encodeURIComponent(qualified)}`,
|
|
543
|
+
tags: ['smithery'],
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// npm:按 keywords 筛,比自由文本准得多
|
|
549
|
+
async function npmMcps(query) {
|
|
550
|
+
const text = query ? `${query} mcp-server` : 'keywords:mcp-server';
|
|
551
|
+
const data = await fetchJson(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=${MAX_PER_NPM}`);
|
|
552
|
+
return normalizeNpmSearch(data, 'mcp').map(item => ({
|
|
553
|
+
...item,
|
|
554
|
+
command: 'npx',
|
|
555
|
+
args: ['-y', item.package],
|
|
556
|
+
transport: 'stdio',
|
|
557
|
+
envKeys: [],
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── 分组建构 ──────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
const PROVIDERS = {
|
|
564
|
+
skill: {
|
|
565
|
+
builtin: () => builtinSkills(),
|
|
566
|
+
official: () => officialSkills(),
|
|
567
|
+
skillsmp: ({ query }) => skillsmpSkills(query),
|
|
568
|
+
awesome: ({ query }) => awesomeSkills(query),
|
|
569
|
+
github: ({ query }) => (query ? githubSkills(query) : Promise.resolve([])),
|
|
570
|
+
npm: ({ query }) => npmSkills(query),
|
|
571
|
+
},
|
|
572
|
+
mcp: {
|
|
573
|
+
builtin: () => builtinMcps(),
|
|
574
|
+
registry: ({ query }) => registryMcps(query),
|
|
575
|
+
smithery: ({ query }) => smitheryMcps(query),
|
|
576
|
+
npm: ({ query }) => npmMcps(query),
|
|
577
|
+
github: ({ query }) => (query ? githubMcps(query) : Promise.resolve([])),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
function cacheKey(type, source, query) {
|
|
582
|
+
return `${type}|${source}|${String(query || '').toLowerCase()}`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 并行抓取所有来源,按来源分组返回。
|
|
587
|
+
* 单个来源失败 → 该组 status='error' 并带上可读原因,其他组不受影响。
|
|
588
|
+
*
|
|
589
|
+
* @param {{ type: string, query?: string, sources?: string[] }} options
|
|
590
|
+
*/
|
|
591
|
+
async function buildGroups({ type, query = '', sources } = {}) {
|
|
592
|
+
const definitions = sourcesFor(type).filter(source => !sources || sources.includes(source.id));
|
|
593
|
+
const providers = PROVIDERS[type];
|
|
594
|
+
|
|
595
|
+
const settled = await Promise.all(definitions.map(async definition => {
|
|
596
|
+
const provider = providers[definition.id];
|
|
597
|
+
if (!provider) {
|
|
598
|
+
return { ...definition, status: 'error', error: '未实现的来源', items: [] };
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const items = await cached(cacheKey(type, definition.id, query), () => provider({ query }));
|
|
602
|
+
const limited = (Array.isArray(items) ? items : []).slice(0, MAX_PER_SOURCE);
|
|
603
|
+
return { ...definition, status: 'ok', items: limited, count: limited.length };
|
|
604
|
+
} catch (err) {
|
|
605
|
+
const message = err?.name === 'AbortError' ? '请求超时' : String(err?.message || err);
|
|
606
|
+
return { ...definition, status: 'error', error: message, items: [], count: 0 };
|
|
607
|
+
}
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
return settled;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── 安装目标 ──────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
function normalizeTarget(value) {
|
|
616
|
+
return value === 'global' ? 'global' : 'project';
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 把 target + type 翻译成落盘位置。
|
|
621
|
+
* 项目级与全局级的 skill 目录形状刻意保持一致,读取端才能共用一套解析逻辑。
|
|
622
|
+
*/
|
|
623
|
+
function targetPaths({ type, target, cwd }) {
|
|
624
|
+
if (target === 'global') {
|
|
625
|
+
return type === 'skill'
|
|
626
|
+
? { root: AI_SKILLS_DIR, file: '', label: 'g ai 智能体' }
|
|
627
|
+
: { root: '', file: AI_MCP_FILE, label: 'g ai 智能体' };
|
|
628
|
+
}
|
|
629
|
+
return type === 'skill'
|
|
630
|
+
? { root: path.join(cwd, '.claude', 'skills'), file: '', label: path.basename(cwd) || cwd }
|
|
631
|
+
: { root: '', file: path.join(cwd, '.mcp.json'), label: path.basename(cwd) || cwd };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── 已安装扫描 ────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
function parseFrontmatter(raw) {
|
|
637
|
+
const text = String(raw || '').replace(/^\uFEFF/, '');
|
|
638
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
639
|
+
if (!match) return {};
|
|
640
|
+
const attrs = {};
|
|
641
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
642
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+)$/.exec(line);
|
|
643
|
+
if (kv) attrs[kv[1].toLowerCase()] = kv[2].trim().replace(/^(['"])([\s\S]*)\1$/, '$2');
|
|
644
|
+
}
|
|
645
|
+
return attrs;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function installedSkillsIn(root, target) {
|
|
649
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
650
|
+
const items = [];
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
653
|
+
const file = path.join(root, entry.name, 'SKILL.md');
|
|
654
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => '');
|
|
655
|
+
if (!raw.trim()) continue;
|
|
656
|
+
const attrs = parseFrontmatter(raw);
|
|
657
|
+
items.push({
|
|
658
|
+
id: entry.name,
|
|
659
|
+
type: 'skill',
|
|
660
|
+
name: clampText(attrs.name || entry.name, 80),
|
|
661
|
+
description: clampText(attrs.description || '', 400),
|
|
662
|
+
dir: path.join(root, entry.name),
|
|
663
|
+
target,
|
|
664
|
+
installed: true,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return items;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function readMcpFile(file) {
|
|
671
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => '');
|
|
672
|
+
if (!raw.trim()) return { mcpServers: {} };
|
|
673
|
+
try {
|
|
674
|
+
const parsed = JSON.parse(raw);
|
|
675
|
+
const servers = parsed?.mcpServers;
|
|
676
|
+
return { ...parsed, mcpServers: servers && typeof servers === 'object' ? servers : {} };
|
|
677
|
+
} catch {
|
|
678
|
+
return { mcpServers: {} };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function installedMcpsIn(file, target) {
|
|
683
|
+
const config = await readMcpFile(file);
|
|
684
|
+
return Object.entries(config.mcpServers).map(([id, server]) => ({
|
|
685
|
+
id,
|
|
686
|
+
type: 'mcp',
|
|
687
|
+
name: clampText(server?.name || id, 80),
|
|
688
|
+
description: clampText(server?.description || '', 400),
|
|
689
|
+
package: server?.package || '',
|
|
690
|
+
command: server?.command || '',
|
|
691
|
+
args: Array.isArray(server?.args) ? server.args : [],
|
|
692
|
+
envKeys: Object.keys(server?.env || {}),
|
|
693
|
+
file,
|
|
694
|
+
target,
|
|
695
|
+
installed: true,
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** 同时列出项目级与全局级已安装项。/cwd 不可用时只列全局。 */
|
|
700
|
+
async function listInstalled({ type, cwd }) {
|
|
701
|
+
const items = [];
|
|
702
|
+
if (type === 'skill') {
|
|
703
|
+
if (cwd) items.push(...await installedSkillsIn(path.join(cwd, '.claude', 'skills'), 'project'));
|
|
704
|
+
items.push(...await installedSkillsIn(AI_SKILLS_DIR, 'global'));
|
|
705
|
+
} else {
|
|
706
|
+
if (cwd) items.push(...await installedMcpsIn(path.join(cwd, '.mcp.json'), 'project'));
|
|
707
|
+
items.push(...await installedMcpsIn(AI_MCP_FILE, 'global'));
|
|
708
|
+
}
|
|
709
|
+
return items;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── 安装 ──────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
async function exists(target) {
|
|
715
|
+
return await fs.stat(target).then(() => true).catch(() => false);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function gitBinary() {
|
|
719
|
+
return process.platform === 'win32' ? 'git.exe' : 'git';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function npmBinary() {
|
|
723
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 只拉取仓库里的某个子目录。
|
|
728
|
+
* 用 --filter=blob:none --sparse 是为了对付 monorepo ——
|
|
729
|
+
* 直接 --depth 1 全量克隆一个几万文件的仓库只为拿一个 skill 太浪费。
|
|
730
|
+
* sparse 不被支持(旧版 git / 服务端限制)时回落到普通浅克隆。
|
|
731
|
+
*/
|
|
732
|
+
async function cloneSubdirectory({ repository, subpath, dest }) {
|
|
733
|
+
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-gitsync-skill-'));
|
|
734
|
+
const url = `https://github.com/${repository}.git`;
|
|
735
|
+
try {
|
|
736
|
+
let sparseOk = false;
|
|
737
|
+
if (subpath) {
|
|
738
|
+
try {
|
|
739
|
+
await execFileAsync(gitBinary(), ['clone', '--depth', '1', '--filter=blob:none', '--sparse', url, temp], {
|
|
740
|
+
timeout: CLONE_TIMEOUT_MS, windowsHide: true,
|
|
741
|
+
});
|
|
742
|
+
await execFileAsync(gitBinary(), ['-C', temp, 'sparse-checkout', 'set', subpath], {
|
|
743
|
+
timeout: CLONE_TIMEOUT_MS, windowsHide: true,
|
|
744
|
+
});
|
|
745
|
+
sparseOk = true;
|
|
746
|
+
} catch {
|
|
747
|
+
// 目录里可能已经有半成品,清掉重来
|
|
748
|
+
await fs.rm(temp, { recursive: true, force: true }).catch(() => {});
|
|
749
|
+
await fs.mkdir(temp, { recursive: true });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (!sparseOk) {
|
|
753
|
+
await execFileAsync(gitBinary(), ['clone', '--depth', '1', url, temp], { timeout: CLONE_TIMEOUT_MS, windowsHide: true });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const requested = subpath ? path.join(temp, subpath) : '';
|
|
757
|
+
const candidates = [requested, path.join(temp, 'skills'), temp].filter(Boolean);
|
|
758
|
+
let source = null;
|
|
759
|
+
for (const candidate of candidates) {
|
|
760
|
+
if (await exists(path.join(candidate, 'SKILL.md'))) { source = candidate; break; }
|
|
761
|
+
}
|
|
762
|
+
// 再退一步:找仓库里任意一层含 SKILL.md 的目录(只扫两层,避免在 monorepo 里乱窜)
|
|
763
|
+
if (!source) {
|
|
764
|
+
const top = await fs.readdir(temp, { withFileTypes: true }).catch(() => []);
|
|
765
|
+
for (const dir of top.filter(entry => entry.isDirectory() && entry.name !== '.git')) {
|
|
766
|
+
if (await exists(path.join(temp, dir.name, 'SKILL.md'))) { source = path.join(temp, dir.name); break; }
|
|
767
|
+
const inner = await fs.readdir(path.join(temp, dir.name), { withFileTypes: true }).catch(() => []);
|
|
768
|
+
let hit = null;
|
|
769
|
+
for (const sub of inner.filter(entry => entry.isDirectory())) {
|
|
770
|
+
if (await exists(path.join(temp, dir.name, sub.name, 'SKILL.md'))) { hit = path.join(temp, dir.name, sub.name); break; }
|
|
771
|
+
}
|
|
772
|
+
if (hit) { source = hit; break; }
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (!source) throw new HttpError(422, `仓库 ${repository} 里没有找到 SKILL.md`);
|
|
776
|
+
|
|
777
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
778
|
+
await fs.cp(source, dest, { recursive: true, force: false });
|
|
779
|
+
} finally {
|
|
780
|
+
await fs.rm(temp, { recursive: true, force: true }).catch(() => {});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function installSkill({ item, target, cwd }) {
|
|
785
|
+
const paths = targetPaths({ type: 'skill', target, cwd });
|
|
786
|
+
const id = safeId(item.id || item.name);
|
|
787
|
+
if (!SAFE_ID.test(id)) throw new HttpError(400, 'Skill 名称不合法');
|
|
788
|
+
const dest = path.join(paths.root, id);
|
|
789
|
+
if (await exists(dest)) throw new HttpError(409, `该 Skill 已安装在${paths.label}(目录已存在)`);
|
|
790
|
+
|
|
791
|
+
// 来源一:GitHub 仓库(可选子目录)+ 仓库根还有 SKILL.md 的简单仓库
|
|
792
|
+
const repository = String(item.repository || '').trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/, '');
|
|
793
|
+
if (repository && SAFE_REPOSITORY.test(repository)) {
|
|
794
|
+
const subpath = item.subpath && isSafeSubpath(item.subpath) ? String(item.subpath) : '';
|
|
795
|
+
await cloneSubdirectory({ repository, subpath, dest });
|
|
796
|
+
return { id, type: 'skill', name: item.name || id, target, dir: dest, source: repository };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// 来源二:直接给一份 SKILL.md 正文(前端"粘贴导入"用)
|
|
800
|
+
if (typeof item.content === 'string' && item.content.trim()) {
|
|
801
|
+
await fs.mkdir(dest, { recursive: true });
|
|
802
|
+
await fs.writeFile(path.join(dest, 'SKILL.md'), item.content, 'utf8');
|
|
803
|
+
return { id, type: 'skill', name: item.name || id, target, dir: dest, source: 'inline' };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
throw new HttpError(400, '该条目没有可安装的仓库地址');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function installMcp({ item, target, cwd }) {
|
|
810
|
+
const packageName = String(item.package || '').trim();
|
|
811
|
+
const isStdio = !!packageName;
|
|
812
|
+
// 只有 remote 端点的 server:用 mcp-remote 桥接成 stdio,
|
|
813
|
+
// 否则 g ai 的 stdio 客户端根本连不上。这是生态里的通行做法。
|
|
814
|
+
const bridge = !isStdio && item.remoteUrl ? 'mcp-remote' : '';
|
|
815
|
+
|
|
816
|
+
if (!isStdio && !bridge) {
|
|
817
|
+
throw new HttpError(422, '该 MCP 服务没有提供 npm 包或远程端点,暂时无法一键安装');
|
|
818
|
+
}
|
|
819
|
+
if (isStdio && !SAFE_PACKAGE.test(packageName)) throw new HttpError(400, 'MCP npm 包名不合法');
|
|
820
|
+
|
|
821
|
+
const paths = targetPaths({ type: 'mcp', target, cwd });
|
|
822
|
+
const id = safeId(item.id || packageName || item.name);
|
|
823
|
+
if (!SAFE_ID.test(id)) throw new HttpError(400, 'MCP 标识不合法');
|
|
824
|
+
|
|
825
|
+
const config = await readMcpFile(paths.file);
|
|
826
|
+
if (Object.prototype.hasOwnProperty.call(config.mcpServers, id)) {
|
|
827
|
+
throw new HttpError(409, `该 MCP 服务已安装在${paths.label}`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// 项目级顺手 npm install 一次:把版本钉进 package.json,启动也更快。
|
|
831
|
+
// 失败不阻断 —— npx 运行时仍会按需下载,只是慢一点。
|
|
832
|
+
//
|
|
833
|
+
// 全局目标**故意不做**预装:g ai 起的 MCP 子进程 cwd 是当前项目,
|
|
834
|
+
// 装到 ~/.zen-gitsync/ai/node_modules 里 npx 根本解析不到,等于白装一份;
|
|
835
|
+
// npx 自己的缓存本来就是按用户全局共享的,首次启动下载一次即可。
|
|
836
|
+
let installWarning = '';
|
|
837
|
+
const primary = isStdio ? packageName : bridge;
|
|
838
|
+
if (target === 'project') {
|
|
839
|
+
try {
|
|
840
|
+
await execFileAsync(npmBinary(), ['install', '--save-dev', primary], { cwd, timeout: NPM_TIMEOUT_MS, windowsHide: true });
|
|
841
|
+
} catch (err) {
|
|
842
|
+
installWarning = `npm install 未成功(${clampText(err.message, 200)}),已写入配置,首次启动时会由 npx 按需下载`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const args = isStdio
|
|
847
|
+
? (Array.isArray(item.args) && item.args.length ? item.args : ['-y', packageName])
|
|
848
|
+
: ['-y', bridge, String(item.remoteUrl)];
|
|
849
|
+
const replaceProject = value => String(value).replaceAll('${project}', cwd || process.cwd());
|
|
850
|
+
|
|
851
|
+
// 安装时顺手收集环境变量(弹窗里用户填的)。只收合法的标识符键,
|
|
852
|
+
// 值为空的键跳过 —— 留给用户以后手改配置文件,而不是写一个空值进去让 server 起不来。
|
|
853
|
+
const env = {};
|
|
854
|
+
if (item.env && typeof item.env === 'object') {
|
|
855
|
+
for (const [key, value] of Object.entries(item.env)) {
|
|
856
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(key))) continue;
|
|
857
|
+
const text = String(value ?? '').trim();
|
|
858
|
+
if (text) env[key] = clampText(text, 2000);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// 还缺哪些必填环境变量,记下来给 UI 提示"装好了但还差配置"
|
|
862
|
+
const missingEnv = (Array.isArray(item.envKeys) ? item.envKeys : []).filter(key => !env[key]);
|
|
863
|
+
|
|
864
|
+
config.mcpServers[id] = {
|
|
865
|
+
name: item.name || id,
|
|
866
|
+
description: clampText(item.description || '', 400),
|
|
867
|
+
command: 'npx',
|
|
868
|
+
args: args.map(replaceProject),
|
|
869
|
+
env,
|
|
870
|
+
// 记下来给 UI 提示"这个 server 需要哪些环境变量",也便于卸载时回溯
|
|
871
|
+
requiredEnv: missingEnv,
|
|
872
|
+
package: isStdio ? packageName : bridge,
|
|
873
|
+
...(item.transport === 'remote' ? { transport: 'remote', remoteUrl: item.remoteUrl } : {}),
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
await fs.mkdir(path.dirname(paths.file), { recursive: true });
|
|
877
|
+
await fs.writeFile(paths.file, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
878
|
+
|
|
879
|
+
return { id, type: 'mcp', name: item.name || id, target, file: paths.file, envKeys: missingEnv, warning: installWarning };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function uninstall({ type, id, target, cwd }) {
|
|
883
|
+
if (!SAFE_ID.test(id)) throw new HttpError(400, '参数不合法');
|
|
884
|
+
const paths = targetPaths({ type, target, cwd });
|
|
885
|
+
if (type === 'skill') {
|
|
886
|
+
const root = path.resolve(paths.root);
|
|
887
|
+
const victim = path.resolve(root, id);
|
|
888
|
+
// 双重防线:即便前面的白名单被绕过,也不允许删到目录树外面
|
|
889
|
+
if (!victim.startsWith(`${root}${path.sep}`)) throw new HttpError(400, '路径不合法');
|
|
890
|
+
if (!(await exists(victim))) throw new HttpError(404, '该 Skill 未安装');
|
|
891
|
+
await fs.rm(victim, { recursive: true, force: true });
|
|
892
|
+
return { id, type, target };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const config = await readMcpFile(paths.file);
|
|
896
|
+
if (!Object.prototype.hasOwnProperty.call(config.mcpServers, id)) throw new HttpError(404, '该 MCP 服务未安装');
|
|
897
|
+
delete config.mcpServers[id];
|
|
898
|
+
await fs.writeFile(paths.file, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
899
|
+
return { id, type, target };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── 路由注册 ──────────────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
|
|
905
|
+
// 来源清单(前端用来渲染筛选器,不依赖具体抓取结果)
|
|
906
|
+
app.get('/api/agent/marketplace/sources', asyncRoute(async (req, res) => {
|
|
907
|
+
const type = normalizeType(req.query?.type);
|
|
908
|
+
if (!type) throw new HttpError(400, '未知扩展类型');
|
|
909
|
+
res.json({
|
|
910
|
+
success: true,
|
|
911
|
+
type,
|
|
912
|
+
sources: sourcesFor(type).map(({ id, label, kind, homepage }) => ({ id, label, kind, homepage })),
|
|
913
|
+
});
|
|
914
|
+
}));
|
|
915
|
+
|
|
916
|
+
// 广场主接口:按来源分组返回可安装条目
|
|
917
|
+
app.get('/api/agent/marketplace/catalog', asyncRoute(async (req, res) => {
|
|
918
|
+
const type = normalizeType(req.query?.type);
|
|
919
|
+
if (!type) throw new HttpError(400, '未知扩展类型');
|
|
920
|
+
const query = String(req.query?.q || '').trim().slice(0, 120);
|
|
921
|
+
const requested = String(req.query?.sources || '').split(',').map(part => part.trim()).filter(Boolean);
|
|
922
|
+
const known = new Set(sourcesFor(type).map(source => source.id));
|
|
923
|
+
const sources = requested.filter(id => known.has(id));
|
|
924
|
+
|
|
925
|
+
const cwd = resolveProjectPath(req.query?.cwd, getCurrentProjectPath?.());
|
|
926
|
+
const [groups, installed] = await Promise.all([
|
|
927
|
+
buildGroups({ type, query, sources: sources.length ? sources : undefined }),
|
|
928
|
+
listInstalled({ type, cwd }).catch(() => []),
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
// 已安装标记:同 id 或(package + target)命中
|
|
932
|
+
const installedKeys = new Set(installed.map(item => `${item.target}:${item.id}`));
|
|
933
|
+
const installedPackages = new Set(installed.map(item => item.package).filter(Boolean));
|
|
934
|
+
for (const group of groups) {
|
|
935
|
+
for (const item of group.items) {
|
|
936
|
+
item.installed = installedPackages.has(item.package)
|
|
937
|
+
|| installedKeys.has(`project:${safeId(item.id)}`)
|
|
938
|
+
|| installedKeys.has(`global:${safeId(item.id)}`);
|
|
939
|
+
item.installable = type === 'skill'
|
|
940
|
+
? !!(item.repository || item.content)
|
|
941
|
+
: !!(item.package || item.remoteUrl);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
res.json({ success: true, type, query, cwd, groups, installed });
|
|
946
|
+
}));
|
|
947
|
+
|
|
948
|
+
app.get('/api/agent/marketplace/installed', asyncRoute(async (req, res) => {
|
|
949
|
+
const type = normalizeType(req.query?.type);
|
|
950
|
+
if (!type) throw new HttpError(400, '未知扩展类型');
|
|
951
|
+
const cwd = resolveProjectPath(req.query?.cwd, getCurrentProjectPath?.());
|
|
952
|
+
const items = await listInstalled({ type, cwd });
|
|
953
|
+
res.json({ success: true, type, cwd, items });
|
|
954
|
+
}));
|
|
955
|
+
|
|
956
|
+
app.post('/api/agent/marketplace/install', asyncRoute(async (req, res) => {
|
|
957
|
+
const type = normalizeType(req.body?.type);
|
|
958
|
+
if (!type) throw new HttpError(400, '未知扩展类型');
|
|
959
|
+
const target = normalizeTarget(req.body?.target);
|
|
960
|
+
const cwd = resolveProjectPath(req.body?.cwd, getCurrentProjectPath?.());
|
|
961
|
+
const item = req.body?.item && typeof req.body.item === 'object' ? req.body.item : {};
|
|
962
|
+
if (!item.id && !item.name && !item.package) throw new HttpError(400, '缺少要安装的条目');
|
|
963
|
+
|
|
964
|
+
// 项目级安装必须落在真实存在的项目目录里;全局级只需要数据目录可写
|
|
965
|
+
if (target === 'project') await assertDirectory(cwd);
|
|
966
|
+
|
|
967
|
+
const installed = type === 'skill'
|
|
968
|
+
? await installSkill({ item, target, cwd })
|
|
969
|
+
: await installMcp({ item, target, cwd });
|
|
970
|
+
|
|
971
|
+
// 装完立刻让缓存失效,避免刚装的条目在列表里还是"未安装"
|
|
972
|
+
cache.clear();
|
|
973
|
+
res.json({ success: true, target, cwd, item: installed });
|
|
974
|
+
}));
|
|
975
|
+
|
|
976
|
+
app.delete('/api/agent/marketplace/item/:type/:id', asyncRoute(async (req, res) => {
|
|
977
|
+
const type = normalizeType(req.params.type);
|
|
978
|
+
if (!type) throw new HttpError(400, '未知扩展类型');
|
|
979
|
+
const target = normalizeTarget(req.query?.target);
|
|
980
|
+
const cwd = resolveProjectPath(req.query?.cwd, getCurrentProjectPath?.());
|
|
981
|
+
const id = decodeURIComponent(String(req.params.id || ''));
|
|
982
|
+
const result = await uninstall({ type, id, target, cwd });
|
|
983
|
+
cache.clear();
|
|
984
|
+
res.json({ success: true, ...result });
|
|
985
|
+
}));
|
|
986
|
+
}
|