coding-tool-x 3.2.0
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/CHANGELOG.md +599 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
- package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
- package/dist/web/assets/Home-38JTUlYt.js +1 -0
- package/dist/web/assets/Home-CjupSEWE.css +1 -0
- package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
- package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
- package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
- package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
- package/dist/web/assets/icons-DRrXwWZi.js +1 -0
- package/dist/web/assets/index-CetESrXw.css +1 -0
- package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
- package/dist/web/assets/markdown-BfC0goYb.css +10 -0
- package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
- package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
- package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
- package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +20 -0
- package/dist/web/logo.png +0 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/model-redirection.md +251 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +80 -0
- package/src/commands/channels.js +551 -0
- package/src/commands/cli-type.js +101 -0
- package/src/commands/daemon.js +365 -0
- package/src/commands/doctor.js +333 -0
- package/src/commands/export-config.js +205 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +261 -0
- package/src/commands/plugin.js +585 -0
- package/src/commands/port-config.js +135 -0
- package/src/commands/proxy-control.js +264 -0
- package/src/commands/proxy.js +152 -0
- package/src/commands/resume.js +137 -0
- package/src/commands/search.js +190 -0
- package/src/commands/security.js +37 -0
- package/src/commands/stats.js +398 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +247 -0
- package/src/commands/ui.js +99 -0
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +69 -0
- package/src/config/loader.js +149 -0
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +35 -0
- package/src/config/paths.js +190 -0
- package/src/index.js +680 -0
- package/src/plugins/constants.js +15 -0
- package/src/plugins/event-bus.js +54 -0
- package/src/plugins/manifest-validator.js +129 -0
- package/src/plugins/plugin-api.js +128 -0
- package/src/plugins/plugin-installer.js +601 -0
- package/src/plugins/plugin-loader.js +229 -0
- package/src/plugins/plugin-manager.js +170 -0
- package/src/plugins/registry.js +152 -0
- package/src/plugins/schema/plugin-manifest.json +115 -0
- package/src/reset-config.js +94 -0
- package/src/server/api/agents.js +826 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +368 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +417 -0
- package/src/server/api/codex-projects.js +104 -0
- package/src/server/api/codex-proxy.js +195 -0
- package/src/server/api/codex-sessions.js +483 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +482 -0
- package/src/server/api/config-export.js +212 -0
- package/src/server/api/config-registry.js +357 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/config-templates.js +248 -0
- package/src/server/api/config.js +521 -0
- package/src/server/api/convert.js +260 -0
- package/src/server/api/dashboard.js +142 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +366 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +173 -0
- package/src/server/api/gemini-sessions.js +376 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +31 -0
- package/src/server/api/mcp.js +399 -0
- package/src/server/api/opencode-channels.js +419 -0
- package/src/server/api/opencode-projects.js +99 -0
- package/src/server/api/opencode-proxy.js +207 -0
- package/src/server/api/opencode-sessions.js +327 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +463 -0
- package/src/server/api/pm2-autostart.js +269 -0
- package/src/server/api/projects.js +124 -0
- package/src/server/api/prompts.js +279 -0
- package/src/server/api/proxy.js +306 -0
- package/src/server/api/security.js +53 -0
- package/src/server/api/sessions.js +514 -0
- package/src/server/api/settings.js +142 -0
- package/src/server/api/skills.js +570 -0
- package/src/server/api/statistics.js +238 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +456 -0
- package/src/server/codex-proxy-server.js +681 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +610 -0
- package/src/server/index.js +422 -0
- package/src/server/opencode-proxy-server.js +4771 -0
- package/src/server/proxy-server.js +669 -0
- package/src/server/services/agents-service.js +1137 -0
- package/src/server/services/alias.js +71 -0
- package/src/server/services/channel-health.js +234 -0
- package/src/server/services/channel-scheduler.js +240 -0
- package/src/server/services/channels.js +447 -0
- package/src/server/services/codex-channels.js +705 -0
- package/src/server/services/codex-config.js +90 -0
- package/src/server/services/codex-parser.js +322 -0
- package/src/server/services/codex-sessions.js +936 -0
- package/src/server/services/codex-settings-manager.js +619 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +161 -0
- package/src/server/services/commands-service.js +574 -0
- package/src/server/services/config-export-service.js +1165 -0
- package/src/server/services/config-registry-service.js +828 -0
- package/src/server/services/config-sync-manager.js +941 -0
- package/src/server/services/config-sync-service.js +504 -0
- package/src/server/services/config-templates-service.js +913 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/env-checker.js +409 -0
- package/src/server/services/env-manager.js +436 -0
- package/src/server/services/favorites.js +165 -0
- package/src/server/services/format-converter.js +620 -0
- package/src/server/services/gemini-channels.js +459 -0
- package/src/server/services/gemini-config.js +73 -0
- package/src/server/services/gemini-sessions.js +689 -0
- package/src/server/services/gemini-settings-manager.js +263 -0
- package/src/server/services/gemini-statistics-service.js +157 -0
- package/src/server/services/health-check.js +85 -0
- package/src/server/services/mcp-client.js +790 -0
- package/src/server/services/mcp-service.js +1732 -0
- package/src/server/services/model-detector.js +1245 -0
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +366 -0
- package/src/server/services/opencode-gateway-adapters.js +1168 -0
- package/src/server/services/opencode-gateway-converter.js +639 -0
- package/src/server/services/opencode-sessions.js +931 -0
- package/src/server/services/opencode-settings-manager.js +478 -0
- package/src/server/services/opencode-statistics-service.js +161 -0
- package/src/server/services/plugins-service.js +1268 -0
- package/src/server/services/prompts-service.js +534 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/repo-scanner-base.js +708 -0
- package/src/server/services/request-logger.js +130 -0
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +131 -0
- package/src/server/services/session-cache.js +127 -0
- package/src/server/services/session-converter.js +577 -0
- package/src/server/services/sessions.js +900 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +1482 -0
- package/src/server/services/speed-test.js +1146 -0
- package/src/server/services/statistics-service.js +1043 -0
- package/src/server/services/ui-config.js +132 -0
- package/src/server/services/workspace-service.js +830 -0
- package/src/server/utils/pricing.js +73 -0
- package/src/server/websocket-server.js +513 -0
- package/src/ui/menu.js +139 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +108 -0
- package/src/utils/session.js +240 -0
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills 技能服务
|
|
3
|
+
*
|
|
4
|
+
* 管理 Claude Code Skills 的获取、安装、卸载
|
|
5
|
+
* Skills 安装目录: ~/.claude/skills/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { createWriteStream } = require('fs');
|
|
14
|
+
const { pipeline } = require('stream/promises');
|
|
15
|
+
const AdmZip = require('adm-zip');
|
|
16
|
+
const {
|
|
17
|
+
parseSkillContent,
|
|
18
|
+
} = require('./format-converter');
|
|
19
|
+
const { NATIVE_PATHS } = require('../../config/paths');
|
|
20
|
+
|
|
21
|
+
const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
|
|
22
|
+
const OPENCODE_SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
23
|
+
|
|
24
|
+
function normalizePlatform(platform) {
|
|
25
|
+
return SUPPORTED_PLATFORMS.includes(platform) ? platform : 'claude';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cloneRepos(repos = []) {
|
|
29
|
+
return repos.map(repo => ({ ...repo }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_REPOS_BY_PLATFORM = {
|
|
33
|
+
claude: [
|
|
34
|
+
{ owner: 'anthropics', name: 'skills', branch: 'main', directory: '', enabled: true }
|
|
35
|
+
],
|
|
36
|
+
codex: [
|
|
37
|
+
{ owner: 'anthropics', name: 'skills', branch: 'main', directory: '', enabled: true }
|
|
38
|
+
],
|
|
39
|
+
gemini: [
|
|
40
|
+
{ owner: 'google-gemini', name: 'gemini-cli', branch: 'main', directory: '.gemini/skills', enabled: true }
|
|
41
|
+
],
|
|
42
|
+
opencode: [
|
|
43
|
+
{ owner: 'darrenhinde', name: 'OpenAgentsControl', branch: 'main', directory: '.opencode/skill', enabled: true }
|
|
44
|
+
]
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const PLATFORM_CONFIG = {
|
|
48
|
+
claude: {
|
|
49
|
+
installDir: path.join(os.homedir(), '.claude', 'skills'),
|
|
50
|
+
reposFile: 'skill-repos.json',
|
|
51
|
+
cacheFile: 'skills-cache.json'
|
|
52
|
+
},
|
|
53
|
+
codex: {
|
|
54
|
+
installDir: path.join(os.homedir(), '.codex', 'skills'),
|
|
55
|
+
reposFile: 'codex-skill-repos.json',
|
|
56
|
+
cacheFile: 'codex-skills-cache.json'
|
|
57
|
+
},
|
|
58
|
+
gemini: {
|
|
59
|
+
installDir: path.join(os.homedir(), '.gemini', 'skills'),
|
|
60
|
+
reposFile: 'gemini-skill-repos.json',
|
|
61
|
+
cacheFile: 'gemini-skills-cache.json'
|
|
62
|
+
},
|
|
63
|
+
opencode: {
|
|
64
|
+
installDir: path.join(NATIVE_PATHS.opencode.config, 'skills'),
|
|
65
|
+
reposFile: 'opencode-skill-repos.json',
|
|
66
|
+
cacheFile: 'opencode-skills-cache.json'
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// 缓存有效期(5分钟)
|
|
71
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
class SkillService {
|
|
74
|
+
constructor(platform = 'claude') {
|
|
75
|
+
this.platform = normalizePlatform(platform);
|
|
76
|
+
this.configDir = path.join(os.homedir(), '.cc-tool');
|
|
77
|
+
|
|
78
|
+
const platformConfig = PLATFORM_CONFIG[this.platform];
|
|
79
|
+
this.installDir = platformConfig.installDir;
|
|
80
|
+
this.reposConfigPath = path.join(this.configDir, platformConfig.reposFile);
|
|
81
|
+
this.cachePath = path.join(this.configDir, platformConfig.cacheFile);
|
|
82
|
+
|
|
83
|
+
// 内存缓存
|
|
84
|
+
this.skillsCache = null;
|
|
85
|
+
this.cacheTime = 0;
|
|
86
|
+
|
|
87
|
+
// 确保目录存在
|
|
88
|
+
this.ensureDirs();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ensureDirs() {
|
|
92
|
+
if (!fs.existsSync(this.installDir)) {
|
|
93
|
+
fs.mkdirSync(this.installDir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
if (!fs.existsSync(this.configDir)) {
|
|
96
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 加载仓库配置
|
|
102
|
+
*/
|
|
103
|
+
loadRepos() {
|
|
104
|
+
try {
|
|
105
|
+
if (fs.existsSync(this.reposConfigPath)) {
|
|
106
|
+
const data = JSON.parse(fs.readFileSync(this.reposConfigPath, 'utf-8'));
|
|
107
|
+
if (Array.isArray(data.repos)) {
|
|
108
|
+
return data.repos;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error('[SkillService] Load repos config error:', err.message);
|
|
113
|
+
}
|
|
114
|
+
return cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 保存仓库配置
|
|
119
|
+
*/
|
|
120
|
+
saveRepos(repos) {
|
|
121
|
+
fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos }, null, 2));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 添加仓库
|
|
126
|
+
* @param {Object} repo - 仓库配置
|
|
127
|
+
* @param {string} repo.owner - 仓库所有者
|
|
128
|
+
* @param {string} repo.name - 仓库名称
|
|
129
|
+
* @param {string} repo.branch - 分支名称
|
|
130
|
+
* @param {string} [repo.directory] - 扫描的子目录路径(可选)
|
|
131
|
+
* @param {boolean} repo.enabled - 是否启用
|
|
132
|
+
*/
|
|
133
|
+
addRepo(repo) {
|
|
134
|
+
const repos = this.loadRepos();
|
|
135
|
+
// 使用 owner/name/directory 作为唯一标识
|
|
136
|
+
const existingIndex = repos.findIndex(r =>
|
|
137
|
+
r.owner === repo.owner &&
|
|
138
|
+
r.name === repo.name &&
|
|
139
|
+
(r.directory || '') === (repo.directory || '')
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (existingIndex >= 0) {
|
|
143
|
+
repos[existingIndex] = repo;
|
|
144
|
+
} else {
|
|
145
|
+
repos.push(repo);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.saveRepos(repos);
|
|
149
|
+
// 清除缓存
|
|
150
|
+
this.skillsCache = null;
|
|
151
|
+
this.cacheTime = 0;
|
|
152
|
+
return repos;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 删除仓库
|
|
157
|
+
* @param {string} owner - 仓库所有者
|
|
158
|
+
* @param {string} name - 仓库名称
|
|
159
|
+
* @param {string} [directory=''] - 子目录路径
|
|
160
|
+
*/
|
|
161
|
+
removeRepo(owner, name, directory = '') {
|
|
162
|
+
const repos = this.loadRepos();
|
|
163
|
+
const filtered = repos.filter(r => !(
|
|
164
|
+
r.owner === owner &&
|
|
165
|
+
r.name === name &&
|
|
166
|
+
(r.directory || '') === directory
|
|
167
|
+
));
|
|
168
|
+
this.saveRepos(filtered);
|
|
169
|
+
// 清除缓存
|
|
170
|
+
this.skillsCache = null;
|
|
171
|
+
this.cacheTime = 0;
|
|
172
|
+
return filtered;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 切换仓库启用状态
|
|
177
|
+
* @param {string} owner - 仓库所有者
|
|
178
|
+
* @param {string} name - 仓库名称
|
|
179
|
+
* @param {string} [directory=''] - 子目录路径
|
|
180
|
+
* @param {boolean} enabled - 是否启用
|
|
181
|
+
*/
|
|
182
|
+
toggleRepo(owner, name, directory = '', enabled) {
|
|
183
|
+
const repos = this.loadRepos();
|
|
184
|
+
const repo = repos.find(r =>
|
|
185
|
+
r.owner === owner &&
|
|
186
|
+
r.name === name &&
|
|
187
|
+
(r.directory || '') === directory
|
|
188
|
+
);
|
|
189
|
+
if (repo) {
|
|
190
|
+
repo.enabled = enabled;
|
|
191
|
+
this.saveRepos(repos);
|
|
192
|
+
// 清除缓存
|
|
193
|
+
this.skillsCache = null;
|
|
194
|
+
this.cacheTime = 0;
|
|
195
|
+
}
|
|
196
|
+
return repos;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 获取所有技能列表(带缓存)
|
|
201
|
+
*/
|
|
202
|
+
async listSkills(forceRefresh = false) {
|
|
203
|
+
// 强制刷新时清除缓存
|
|
204
|
+
if (forceRefresh) {
|
|
205
|
+
this.skillsCache = null;
|
|
206
|
+
this.cacheTime = 0;
|
|
207
|
+
// 删除文件缓存
|
|
208
|
+
try {
|
|
209
|
+
if (fs.existsSync(this.cachePath)) {
|
|
210
|
+
fs.unlinkSync(this.cachePath);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.warn('[SkillService] Failed to delete cache file:', err.message);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 检查内存缓存
|
|
218
|
+
if (!forceRefresh && this.skillsCache && (Date.now() - this.cacheTime < CACHE_TTL)) {
|
|
219
|
+
this.updateInstallStatus(this.skillsCache);
|
|
220
|
+
return this.skillsCache;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 检查文件缓存
|
|
224
|
+
if (!forceRefresh) {
|
|
225
|
+
const fileCache = this.loadCacheFromFile();
|
|
226
|
+
if (fileCache) {
|
|
227
|
+
this.skillsCache = fileCache;
|
|
228
|
+
this.cacheTime = Date.now();
|
|
229
|
+
this.updateInstallStatus(this.skillsCache);
|
|
230
|
+
return this.skillsCache;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const repos = this.loadRepos();
|
|
235
|
+
const skills = [];
|
|
236
|
+
|
|
237
|
+
// 并行获取所有启用仓库的技能(带超时保护)
|
|
238
|
+
const enabledRepos = repos.filter(r => r.enabled);
|
|
239
|
+
|
|
240
|
+
if (enabledRepos.length > 0) {
|
|
241
|
+
const results = await Promise.allSettled(
|
|
242
|
+
enabledRepos.map(repo =>
|
|
243
|
+
Promise.race([
|
|
244
|
+
this.fetchRepoSkills(repo),
|
|
245
|
+
new Promise((_, reject) =>
|
|
246
|
+
setTimeout(() => reject(new Error('Fetch timeout')), 30000) // 30秒超时
|
|
247
|
+
)
|
|
248
|
+
])
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < results.length; i++) {
|
|
253
|
+
const result = results[i];
|
|
254
|
+
const repoInfo = `${enabledRepos[i].owner}/${enabledRepos[i].name}`;
|
|
255
|
+
if (result.status === 'fulfilled') {
|
|
256
|
+
skills.push(...result.value);
|
|
257
|
+
} else {
|
|
258
|
+
console.warn(`[SkillService] Fetch repo ${repoInfo} failed:`, result.reason?.message);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 合并本地已安装的技能
|
|
264
|
+
this.mergeLocalSkills(skills);
|
|
265
|
+
|
|
266
|
+
// 去重并排序
|
|
267
|
+
this.deduplicateSkills(skills);
|
|
268
|
+
skills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
269
|
+
|
|
270
|
+
// 更新缓存
|
|
271
|
+
this.skillsCache = skills;
|
|
272
|
+
this.cacheTime = Date.now();
|
|
273
|
+
this.saveCacheToFile(skills);
|
|
274
|
+
|
|
275
|
+
return skills;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 从文件加载缓存
|
|
280
|
+
*/
|
|
281
|
+
loadCacheFromFile() {
|
|
282
|
+
try {
|
|
283
|
+
if (fs.existsSync(this.cachePath)) {
|
|
284
|
+
const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
|
|
285
|
+
if (data.time && (Date.now() - data.time < CACHE_TTL)) {
|
|
286
|
+
return data.skills;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// 忽略缓存读取错误
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 保存缓存到文件
|
|
297
|
+
*/
|
|
298
|
+
saveCacheToFile(skills) {
|
|
299
|
+
try {
|
|
300
|
+
fs.writeFileSync(this.cachePath, JSON.stringify({
|
|
301
|
+
time: Date.now(),
|
|
302
|
+
skills
|
|
303
|
+
}));
|
|
304
|
+
} catch (err) {
|
|
305
|
+
// 忽略缓存写入错误
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 更新技能的安装状态
|
|
311
|
+
*/
|
|
312
|
+
updateInstallStatus(skills) {
|
|
313
|
+
for (const skill of skills) {
|
|
314
|
+
skill.installed = this.isInstalled(skill.directory);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 从 GitHub 仓库获取技能列表(使用 Tree API 一次性获取)
|
|
320
|
+
* 支持指定子目录扫描
|
|
321
|
+
*/
|
|
322
|
+
async fetchRepoSkills(repo) {
|
|
323
|
+
const skills = [];
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
// 使用 GitHub Tree API 一次性获取所有文件
|
|
327
|
+
const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
|
|
328
|
+
const tree = await this.fetchGitHubApi(treeUrl);
|
|
329
|
+
|
|
330
|
+
if (!tree || !tree.tree) {
|
|
331
|
+
console.warn(`[SkillService] Empty tree for ${repo.owner}/${repo.name}`);
|
|
332
|
+
return skills;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 获取基础目录(如果配置了 directory)
|
|
336
|
+
const baseDir = repo.directory || '';
|
|
337
|
+
const baseDirPrefix = baseDir ? `${baseDir}/` : '';
|
|
338
|
+
|
|
339
|
+
// 找到所有 SKILL.md 文件(如果配置了子目录,只扫描该目录下的)
|
|
340
|
+
const skillFiles = tree.tree.filter(item => {
|
|
341
|
+
if (item.type !== 'blob' || !item.path.endsWith('/SKILL.md')) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
// 如果配置了子目录,只返回该子目录下的文件
|
|
345
|
+
if (baseDir && !item.path.startsWith(baseDirPrefix)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// 并行获取所有 SKILL.md 的内容(限制并发数)
|
|
352
|
+
const batchSize = 5;
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < skillFiles.length; i += batchSize) {
|
|
355
|
+
const batch = skillFiles.slice(i, i + batchSize);
|
|
356
|
+
const results = await Promise.allSettled(
|
|
357
|
+
batch.map(file => this.fetchAndParseSkill(file, repo, baseDir))
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
for (const result of results) {
|
|
361
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
362
|
+
skills.push(result.value);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(`[SkillService] Fetch repo ${repo.owner}/${repo.name} error:`, err.message);
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return skills;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 获取并解析单个 SKILL.md
|
|
376
|
+
* @param {Object} file - GitHub tree 文件对象
|
|
377
|
+
* @param {Object} repo - 仓库配置
|
|
378
|
+
* @param {string} baseDir - 基础目录(用于计算相对路径)
|
|
379
|
+
*/
|
|
380
|
+
async fetchAndParseSkill(file, repo, baseDir = '') {
|
|
381
|
+
try {
|
|
382
|
+
// 从路径提取目录名 (e.g., "algorithmic-art/SKILL.md" -> "algorithmic-art")
|
|
383
|
+
const fullDirectory = file.path.replace(/\/SKILL\.md$/, '');
|
|
384
|
+
|
|
385
|
+
// 计算相对于 baseDir 的目录名(用于显示和安装)
|
|
386
|
+
const directory = baseDir ? fullDirectory.slice(baseDir.length + 1) : fullDirectory;
|
|
387
|
+
|
|
388
|
+
// 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
|
|
389
|
+
const content = await this.fetchBlobContent(file.sha, repo, file.path);
|
|
390
|
+
const metadata = this.parseSkillMd(content);
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
key: `${repo.owner}/${repo.name}:${fullDirectory}`,
|
|
394
|
+
name: metadata.name || directory.split('/').pop(),
|
|
395
|
+
description: metadata.description || '',
|
|
396
|
+
directory, // 相对目录(用于安装)
|
|
397
|
+
fullDirectory, // 完整目录(用于从仓库下载)
|
|
398
|
+
installed: this.isInstalled(directory),
|
|
399
|
+
readmeUrl: `https://github.com/${repo.owner}/${repo.name}/tree/${repo.branch}/${fullDirectory}`,
|
|
400
|
+
repoOwner: repo.owner,
|
|
401
|
+
repoName: repo.name,
|
|
402
|
+
repoBranch: repo.branch,
|
|
403
|
+
repoDirectory: repo.directory || '', // 仓库配置的子目录
|
|
404
|
+
license: metadata.license
|
|
405
|
+
};
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.warn(`[SkillService] Parse skill ${file.path} error:`, err.message);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
|
|
414
|
+
*/
|
|
415
|
+
async fetchBlobContent(sha, repo, filePath) {
|
|
416
|
+
// raw.githubusercontent.com 不走 API 限流
|
|
417
|
+
const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
|
|
418
|
+
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const req = https.get(url, {
|
|
421
|
+
headers: {
|
|
422
|
+
'User-Agent': 'cc-cli-skill-service'
|
|
423
|
+
},
|
|
424
|
+
timeout: 15000
|
|
425
|
+
}, (res) => {
|
|
426
|
+
// 处理重定向
|
|
427
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
428
|
+
const redirectUrl = res.headers.location;
|
|
429
|
+
if (redirectUrl) {
|
|
430
|
+
https.get(redirectUrl, {
|
|
431
|
+
headers: { 'User-Agent': 'cc-cli-skill-service' },
|
|
432
|
+
timeout: 15000
|
|
433
|
+
}, (res2) => {
|
|
434
|
+
let data = '';
|
|
435
|
+
res2.on('data', chunk => data += chunk);
|
|
436
|
+
res2.on('end', () => {
|
|
437
|
+
if (res2.statusCode === 200) {
|
|
438
|
+
resolve(data);
|
|
439
|
+
} else {
|
|
440
|
+
reject(new Error(`Raw fetch error: ${res2.statusCode}`));
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}).on('error', reject);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let data = '';
|
|
449
|
+
res.on('data', chunk => data += chunk);
|
|
450
|
+
res.on('end', () => {
|
|
451
|
+
if (res.statusCode === 200) {
|
|
452
|
+
resolve(data);
|
|
453
|
+
} else {
|
|
454
|
+
reject(new Error(`Raw fetch error: ${res.statusCode}`));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
req.on('error', reject);
|
|
460
|
+
req.on('timeout', () => {
|
|
461
|
+
req.destroy();
|
|
462
|
+
reject(new Error('Raw fetch timeout'));
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 获取 GitHub Token(从环境变量或配置文件)
|
|
469
|
+
*/
|
|
470
|
+
getGitHubToken() {
|
|
471
|
+
// 优先从环境变量获取
|
|
472
|
+
if (process.env.GITHUB_TOKEN) {
|
|
473
|
+
return process.env.GITHUB_TOKEN;
|
|
474
|
+
}
|
|
475
|
+
// 从配置文件获取
|
|
476
|
+
try {
|
|
477
|
+
const configPath = path.join(this.configDir, 'github-token.txt');
|
|
478
|
+
if (fs.existsSync(configPath)) {
|
|
479
|
+
return fs.readFileSync(configPath, 'utf-8').trim();
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 通用 GitHub API 请求
|
|
489
|
+
*/
|
|
490
|
+
async fetchGitHubApi(url) {
|
|
491
|
+
const token = this.getGitHubToken();
|
|
492
|
+
const headers = {
|
|
493
|
+
'User-Agent': 'cc-cli-skill-service',
|
|
494
|
+
'Accept': 'application/vnd.github.v3+json'
|
|
495
|
+
};
|
|
496
|
+
if (token) {
|
|
497
|
+
headers['Authorization'] = `token ${token}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const req = https.get(url, {
|
|
502
|
+
headers,
|
|
503
|
+
timeout: 15000
|
|
504
|
+
}, (res) => {
|
|
505
|
+
let data = '';
|
|
506
|
+
res.on('data', chunk => data += chunk);
|
|
507
|
+
res.on('end', () => {
|
|
508
|
+
if (res.statusCode === 200) {
|
|
509
|
+
try {
|
|
510
|
+
resolve(JSON.parse(data));
|
|
511
|
+
} catch (e) {
|
|
512
|
+
reject(new Error('Invalid JSON response'));
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
reject(new Error(`GitHub API error: ${res.statusCode}`));
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
req.on('error', reject);
|
|
521
|
+
req.on('timeout', () => {
|
|
522
|
+
req.destroy();
|
|
523
|
+
reject(new Error('Request timeout'));
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 使用 GitHub API 获取目录内容
|
|
530
|
+
*/
|
|
531
|
+
async fetchGitHubContents(owner, name, path, branch) {
|
|
532
|
+
const url = `https://api.github.com/repos/${owner}/${name}/contents/${path}?ref=${branch}`;
|
|
533
|
+
|
|
534
|
+
return new Promise((resolve, reject) => {
|
|
535
|
+
const req = https.get(url, {
|
|
536
|
+
headers: {
|
|
537
|
+
'User-Agent': 'cc-cli-skill-service',
|
|
538
|
+
'Accept': 'application/vnd.github.v3+json'
|
|
539
|
+
},
|
|
540
|
+
timeout: 15000
|
|
541
|
+
}, (res) => {
|
|
542
|
+
let data = '';
|
|
543
|
+
res.on('data', chunk => data += chunk);
|
|
544
|
+
res.on('end', () => {
|
|
545
|
+
if (res.statusCode === 200) {
|
|
546
|
+
try {
|
|
547
|
+
resolve(JSON.parse(data));
|
|
548
|
+
} catch (e) {
|
|
549
|
+
reject(new Error('Invalid JSON response'));
|
|
550
|
+
}
|
|
551
|
+
} else if (res.statusCode === 404) {
|
|
552
|
+
resolve([]);
|
|
553
|
+
} else {
|
|
554
|
+
reject(new Error(`GitHub API error: ${res.statusCode}`));
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
req.on('error', reject);
|
|
560
|
+
req.on('timeout', () => {
|
|
561
|
+
req.destroy();
|
|
562
|
+
reject(new Error('Request timeout'));
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 递归扫描仓库内容查找 SKILL.md
|
|
569
|
+
*/
|
|
570
|
+
async scanRepoContents(contents, repo, currentPath, skills) {
|
|
571
|
+
if (!Array.isArray(contents)) return;
|
|
572
|
+
|
|
573
|
+
// 检查当前目录是否有 SKILL.md
|
|
574
|
+
const skillMd = contents.find(item => item.name === 'SKILL.md' && item.type === 'file');
|
|
575
|
+
|
|
576
|
+
if (skillMd) {
|
|
577
|
+
// 找到技能,解析元数据
|
|
578
|
+
try {
|
|
579
|
+
const skillContent = await this.fetchFileContent(skillMd.download_url);
|
|
580
|
+
const metadata = this.parseSkillMd(skillContent);
|
|
581
|
+
|
|
582
|
+
const directory = currentPath || repo.name;
|
|
583
|
+
|
|
584
|
+
skills.push({
|
|
585
|
+
key: `${repo.owner}/${repo.name}:${directory}`,
|
|
586
|
+
name: metadata.name || directory,
|
|
587
|
+
description: metadata.description || '',
|
|
588
|
+
directory,
|
|
589
|
+
installed: this.isInstalled(directory),
|
|
590
|
+
readmeUrl: `https://github.com/${repo.owner}/${repo.name}/tree/${repo.branch}/${currentPath}`,
|
|
591
|
+
repoOwner: repo.owner,
|
|
592
|
+
repoName: repo.name,
|
|
593
|
+
repoBranch: repo.branch,
|
|
594
|
+
license: metadata.license
|
|
595
|
+
});
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.warn(`[SkillService] Parse SKILL.md at ${currentPath} error:`, err.message);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 找到 SKILL.md 后不再递归子目录
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 递归扫描子目录
|
|
605
|
+
const dirs = contents.filter(item => item.type === 'dir');
|
|
606
|
+
for (const dir of dirs) {
|
|
607
|
+
// 跳过隐藏目录和特殊目录
|
|
608
|
+
if (dir.name.startsWith('.') || dir.name === 'node_modules') continue;
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const subContents = await this.fetchGitHubContents(repo.owner, repo.name, dir.path, repo.branch);
|
|
612
|
+
await this.scanRepoContents(subContents, repo, dir.path, skills);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
// 忽略子目录错误,继续扫描
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 获取文件内容
|
|
621
|
+
*/
|
|
622
|
+
async fetchFileContent(url) {
|
|
623
|
+
return new Promise((resolve, reject) => {
|
|
624
|
+
const protocol = url.startsWith('https') ? https : http;
|
|
625
|
+
|
|
626
|
+
const req = protocol.get(url, {
|
|
627
|
+
headers: { 'User-Agent': 'cc-cli-skill-service' },
|
|
628
|
+
timeout: 10000
|
|
629
|
+
}, (res) => {
|
|
630
|
+
// 处理重定向
|
|
631
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
632
|
+
this.fetchFileContent(res.headers.location).then(resolve).catch(reject);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
let data = '';
|
|
637
|
+
res.on('data', chunk => data += chunk);
|
|
638
|
+
res.on('end', () => {
|
|
639
|
+
if (res.statusCode === 200) {
|
|
640
|
+
resolve(data);
|
|
641
|
+
} else {
|
|
642
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
req.on('error', reject);
|
|
648
|
+
req.on('timeout', () => {
|
|
649
|
+
req.destroy();
|
|
650
|
+
reject(new Error('Request timeout'));
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* 解析 SKILL.md 文件(支持 Claude Code 和 Codex CLI 格式)
|
|
657
|
+
*/
|
|
658
|
+
parseSkillMd(content) {
|
|
659
|
+
// 使用格式转换器统一解析
|
|
660
|
+
const parsed = parseSkillContent(content);
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
name: parsed.name || null,
|
|
664
|
+
description: parsed.description || null,
|
|
665
|
+
license: parsed.license || null,
|
|
666
|
+
allowedTools: parsed.allowedTools ? [parsed.allowedTools] : [],
|
|
667
|
+
metadata: parsed.metadata || {},
|
|
668
|
+
shortDescription: parsed.shortDescription || null,
|
|
669
|
+
format: parsed.format
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
normalizeSkillDirectoryName(directory) {
|
|
674
|
+
if (!directory) return '';
|
|
675
|
+
return String(directory).replace(/\\/g, '/').split('/').pop();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
validateOpenCodeSkillMetadata({ name, description }, directory) {
|
|
679
|
+
const expectedName = this.normalizeSkillDirectoryName(directory);
|
|
680
|
+
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
|
681
|
+
const normalizedDescription = typeof description === 'string' ? description.trim() : '';
|
|
682
|
+
|
|
683
|
+
if (!expectedName) {
|
|
684
|
+
return '技能目录不能为空';
|
|
685
|
+
}
|
|
686
|
+
if (!normalizedName) {
|
|
687
|
+
return 'SKILL.md frontmatter 缺少 name';
|
|
688
|
+
}
|
|
689
|
+
if (!normalizedDescription) {
|
|
690
|
+
return 'SKILL.md frontmatter 缺少 description';
|
|
691
|
+
}
|
|
692
|
+
if (normalizedName.length < 1 || normalizedName.length > 64) {
|
|
693
|
+
return 'name 必须为 1-64 个字符';
|
|
694
|
+
}
|
|
695
|
+
if (!OPENCODE_SKILL_NAME_REGEX.test(normalizedName)) {
|
|
696
|
+
return 'name 必须为小写字母/数字,并使用单个连字符连接';
|
|
697
|
+
}
|
|
698
|
+
if (normalizedName !== expectedName) {
|
|
699
|
+
return `name 必须与目录名一致(期望: ${expectedName})`;
|
|
700
|
+
}
|
|
701
|
+
if (normalizedDescription.length < 1 || normalizedDescription.length > 1024) {
|
|
702
|
+
return 'description 必须为 1-1024 个字符';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
validateOpenCodeSkillContent(content, directory) {
|
|
709
|
+
const metadata = this.parseSkillMd(content);
|
|
710
|
+
return this.validateOpenCodeSkillMetadata(metadata, directory);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 检查技能是否已安装
|
|
715
|
+
*/
|
|
716
|
+
isInstalled(directory) {
|
|
717
|
+
const skillPath = path.join(this.installDir, directory);
|
|
718
|
+
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
719
|
+
return fs.existsSync(skillMdPath);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 合并本地已安装的技能
|
|
724
|
+
*/
|
|
725
|
+
mergeLocalSkills(skills) {
|
|
726
|
+
if (!fs.existsSync(this.installDir)) return;
|
|
727
|
+
|
|
728
|
+
// 递归扫描本地技能目录
|
|
729
|
+
this.scanLocalDir(this.installDir, this.installDir, skills);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* 递归扫描本地目录
|
|
734
|
+
*/
|
|
735
|
+
scanLocalDir(currentDir, baseDir, skills) {
|
|
736
|
+
const skillMdPath = path.join(currentDir, 'SKILL.md');
|
|
737
|
+
|
|
738
|
+
if (fs.existsSync(skillMdPath)) {
|
|
739
|
+
const directory = currentDir === baseDir
|
|
740
|
+
? path.basename(currentDir)
|
|
741
|
+
: path.relative(baseDir, currentDir);
|
|
742
|
+
|
|
743
|
+
// 检查是否已在列表中(比较目录名,去掉前缀路径)
|
|
744
|
+
const dirName = directory.split('/').pop().toLowerCase();
|
|
745
|
+
const existing = skills.find(s => {
|
|
746
|
+
const remoteDirName = s.directory.split('/').pop().toLowerCase();
|
|
747
|
+
return remoteDirName === dirName;
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (existing) {
|
|
751
|
+
existing.installed = true;
|
|
752
|
+
} else {
|
|
753
|
+
// 添加本地独有的技能
|
|
754
|
+
try {
|
|
755
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
756
|
+
const metadata = this.parseSkillMd(content);
|
|
757
|
+
|
|
758
|
+
skills.push({
|
|
759
|
+
key: `local:${directory}`,
|
|
760
|
+
name: metadata.name || directory,
|
|
761
|
+
description: metadata.description || '',
|
|
762
|
+
directory,
|
|
763
|
+
installed: true,
|
|
764
|
+
readmeUrl: null,
|
|
765
|
+
repoOwner: null,
|
|
766
|
+
repoName: null,
|
|
767
|
+
repoBranch: null,
|
|
768
|
+
license: metadata.license
|
|
769
|
+
});
|
|
770
|
+
} catch (err) {
|
|
771
|
+
console.warn(`[SkillService] Parse local skill ${directory} error:`, err.message);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return; // 找到 SKILL.md 后不再递归
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 递归子目录
|
|
779
|
+
try {
|
|
780
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
781
|
+
for (const entry of entries) {
|
|
782
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
783
|
+
this.scanLocalDir(path.join(currentDir, entry.name), baseDir, skills);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
// 忽略读取错误
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 去重技能列表
|
|
793
|
+
*/
|
|
794
|
+
deduplicateSkills(skills) {
|
|
795
|
+
const seen = new Map();
|
|
796
|
+
|
|
797
|
+
for (let i = skills.length - 1; i >= 0; i--) {
|
|
798
|
+
const skill = skills[i];
|
|
799
|
+
// 使用目录名(不含路径前缀)作为去重 key
|
|
800
|
+
const key = skill.directory.split('/').pop().toLowerCase();
|
|
801
|
+
|
|
802
|
+
if (seen.has(key)) {
|
|
803
|
+
// 保留已安装的版本
|
|
804
|
+
const existingIndex = seen.get(key);
|
|
805
|
+
if (skill.installed && !skills[existingIndex].installed) {
|
|
806
|
+
skills.splice(existingIndex, 1);
|
|
807
|
+
seen.set(key, i - 1);
|
|
808
|
+
} else {
|
|
809
|
+
skills.splice(i, 1);
|
|
810
|
+
}
|
|
811
|
+
} else {
|
|
812
|
+
seen.set(key, i);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* 安装技能
|
|
819
|
+
* @param {string} directory - 本地安装目录(相对于 installDir)
|
|
820
|
+
* @param {Object} repo - 仓库配置
|
|
821
|
+
* @param {string} [fullDirectory] - 仓库中的完整路径(可选,默认与 directory 相同)
|
|
822
|
+
*/
|
|
823
|
+
async installSkill(directory, repo, fullDirectory = null) {
|
|
824
|
+
const dest = path.join(this.installDir, directory);
|
|
825
|
+
|
|
826
|
+
// 已安装则跳过
|
|
827
|
+
if (fs.existsSync(dest)) {
|
|
828
|
+
return { success: true, message: 'Already installed' };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 使用 fullDirectory(仓库中的完整路径)或 directory(向后兼容)
|
|
832
|
+
const sourcePath = fullDirectory || directory;
|
|
833
|
+
|
|
834
|
+
// 下载仓库 ZIP
|
|
835
|
+
const zipUrl = `https://github.com/${repo.owner}/${repo.name}/archive/refs/heads/${repo.branch}.zip`;
|
|
836
|
+
const tempDir = path.join(os.tmpdir(), `skill-${Date.now()}`);
|
|
837
|
+
const zipPath = path.join(tempDir, 'repo.zip');
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
841
|
+
|
|
842
|
+
// 下载 ZIP
|
|
843
|
+
await this.downloadFile(zipUrl, zipPath);
|
|
844
|
+
|
|
845
|
+
// 解压
|
|
846
|
+
const zip = new AdmZip(zipPath);
|
|
847
|
+
zip.extractAllTo(tempDir, true);
|
|
848
|
+
|
|
849
|
+
// 找到解压后的目录(GitHub ZIP 会有一个根目录)
|
|
850
|
+
const extractedDirs = fs.readdirSync(tempDir).filter(f =>
|
|
851
|
+
fs.statSync(path.join(tempDir, f)).isDirectory()
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
if (extractedDirs.length === 0) {
|
|
855
|
+
throw new Error('Empty archive');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const repoDir = path.join(tempDir, extractedDirs[0]);
|
|
859
|
+
const sourceDir = path.join(repoDir, sourcePath);
|
|
860
|
+
|
|
861
|
+
if (!fs.existsSync(sourceDir)) {
|
|
862
|
+
throw new Error(`Skill directory not found: ${sourcePath}`);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 复制到安装目录
|
|
866
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
867
|
+
this.copyDirRecursive(sourceDir, dest);
|
|
868
|
+
|
|
869
|
+
if (this.platform === 'opencode') {
|
|
870
|
+
const skillMdPath = path.join(dest, 'SKILL.md');
|
|
871
|
+
if (fs.existsSync(skillMdPath)) {
|
|
872
|
+
const validationError = this.validateOpenCodeSkillContent(
|
|
873
|
+
fs.readFileSync(skillMdPath, 'utf-8'),
|
|
874
|
+
directory
|
|
875
|
+
);
|
|
876
|
+
if (validationError) {
|
|
877
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
878
|
+
throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// 清除缓存,让列表刷新
|
|
884
|
+
this.skillsCache = null;
|
|
885
|
+
this.cacheTime = 0;
|
|
886
|
+
|
|
887
|
+
return { success: true, message: 'Installed successfully' };
|
|
888
|
+
} finally {
|
|
889
|
+
// 清理临时目录
|
|
890
|
+
try {
|
|
891
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
892
|
+
} catch (e) {
|
|
893
|
+
// 忽略清理错误
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 下载文件
|
|
900
|
+
*/
|
|
901
|
+
async downloadFile(url, dest) {
|
|
902
|
+
return new Promise((resolve, reject) => {
|
|
903
|
+
const file = createWriteStream(dest);
|
|
904
|
+
|
|
905
|
+
const request = https.get(url, {
|
|
906
|
+
headers: { 'User-Agent': 'cc-cli-skill-service' },
|
|
907
|
+
timeout: 60000
|
|
908
|
+
}, (response) => {
|
|
909
|
+
// 处理重定向
|
|
910
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
911
|
+
file.close();
|
|
912
|
+
this.downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (response.statusCode !== 200) {
|
|
917
|
+
file.close();
|
|
918
|
+
reject(new Error(`Download failed: HTTP ${response.statusCode}`));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
response.pipe(file);
|
|
923
|
+
file.on('finish', () => {
|
|
924
|
+
file.close();
|
|
925
|
+
resolve();
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
request.on('error', (err) => {
|
|
930
|
+
file.close();
|
|
931
|
+
fs.unlink(dest, () => { });
|
|
932
|
+
reject(err);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
request.on('timeout', () => {
|
|
936
|
+
request.destroy();
|
|
937
|
+
file.close();
|
|
938
|
+
fs.unlink(dest, () => { });
|
|
939
|
+
reject(new Error('Download timeout'));
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 递归复制目录
|
|
946
|
+
*/
|
|
947
|
+
copyDirRecursive(src, dest) {
|
|
948
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
949
|
+
|
|
950
|
+
for (const entry of entries) {
|
|
951
|
+
const srcPath = path.join(src, entry.name);
|
|
952
|
+
const destPath = path.join(dest, entry.name);
|
|
953
|
+
|
|
954
|
+
if (entry.isDirectory()) {
|
|
955
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
956
|
+
this.copyDirRecursive(srcPath, destPath);
|
|
957
|
+
} else {
|
|
958
|
+
fs.copyFileSync(srcPath, destPath);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* 创建自定义技能
|
|
965
|
+
*/
|
|
966
|
+
createCustomSkill({ name, directory, description, content }) {
|
|
967
|
+
const dest = path.join(this.installDir, directory);
|
|
968
|
+
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
969
|
+
|
|
970
|
+
// 检查是否已存在
|
|
971
|
+
if (fs.existsSync(dest)) {
|
|
972
|
+
throw new Error(`技能目录 "${directory}" 已存在`);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (this.platform === 'opencode') {
|
|
976
|
+
if (!OPENCODE_SKILL_NAME_REGEX.test(normalizedDirectory)) {
|
|
977
|
+
throw new Error('OpenCode skill 目录名必须是小写字母/数字,并使用单个连字符连接');
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const normalizedDescription = (description || '').trim();
|
|
982
|
+
const skillName = this.platform === 'opencode'
|
|
983
|
+
? normalizedDirectory
|
|
984
|
+
: (name || directory);
|
|
985
|
+
|
|
986
|
+
if (this.platform === 'opencode') {
|
|
987
|
+
const validationError = this.validateOpenCodeSkillMetadata(
|
|
988
|
+
{
|
|
989
|
+
name: skillName,
|
|
990
|
+
description: normalizedDescription
|
|
991
|
+
},
|
|
992
|
+
normalizedDirectory
|
|
993
|
+
);
|
|
994
|
+
if (validationError) {
|
|
995
|
+
throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// 创建目录
|
|
1000
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1001
|
+
|
|
1002
|
+
// 生成 SKILL.md 内容
|
|
1003
|
+
const skillMdContent = this.platform === 'opencode'
|
|
1004
|
+
? `---
|
|
1005
|
+
name: ${skillName}
|
|
1006
|
+
description: "${normalizedDescription}"
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
${content}
|
|
1010
|
+
`
|
|
1011
|
+
: `---
|
|
1012
|
+
name: "${skillName}"
|
|
1013
|
+
description: "${normalizedDescription}"
|
|
1014
|
+
---
|
|
1015
|
+
|
|
1016
|
+
${content}
|
|
1017
|
+
`;
|
|
1018
|
+
|
|
1019
|
+
// 写入文件
|
|
1020
|
+
fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
|
|
1021
|
+
|
|
1022
|
+
// 清除缓存,让列表刷新
|
|
1023
|
+
this.skillsCache = null;
|
|
1024
|
+
this.cacheTime = 0;
|
|
1025
|
+
|
|
1026
|
+
return { success: true, message: '技能创建成功', directory };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* 创建带多文件的技能
|
|
1031
|
+
* @param {string} directory - 技能目录名
|
|
1032
|
+
* @param {Array<{path: string, content: string}>} files - 文件数组
|
|
1033
|
+
* @returns {Object} 创建结果
|
|
1034
|
+
*/
|
|
1035
|
+
createSkillWithFiles({ directory, files }) {
|
|
1036
|
+
const dest = path.join(this.installDir, directory);
|
|
1037
|
+
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1038
|
+
|
|
1039
|
+
// 检查是否已存在
|
|
1040
|
+
if (fs.existsSync(dest)) {
|
|
1041
|
+
throw new Error(`技能目录 "${directory}" 已存在`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// 验证必须包含 SKILL.md
|
|
1045
|
+
const hasSkillMd = files.some(f =>
|
|
1046
|
+
f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md')
|
|
1047
|
+
);
|
|
1048
|
+
if (!hasSkillMd) {
|
|
1049
|
+
throw new Error('技能必须包含 SKILL.md 文件');
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (this.platform === 'opencode') {
|
|
1053
|
+
if (!OPENCODE_SKILL_NAME_REGEX.test(normalizedDirectory)) {
|
|
1054
|
+
throw new Error('OpenCode skill 目录名必须是小写字母/数字,并使用单个连字符连接');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const skillMdFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md'));
|
|
1058
|
+
const skillMdContent = skillMdFile
|
|
1059
|
+
? (skillMdFile.isBase64 ? Buffer.from(skillMdFile.content, 'base64').toString('utf-8') : skillMdFile.content)
|
|
1060
|
+
: '';
|
|
1061
|
+
const validationError = this.validateOpenCodeSkillContent(skillMdContent, normalizedDirectory);
|
|
1062
|
+
if (validationError) {
|
|
1063
|
+
throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// 创建目录
|
|
1068
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1069
|
+
|
|
1070
|
+
// 写入所有文件
|
|
1071
|
+
for (const file of files) {
|
|
1072
|
+
const filePath = path.join(dest, file.path);
|
|
1073
|
+
const fileDir = path.dirname(filePath);
|
|
1074
|
+
|
|
1075
|
+
// 确保父目录存在
|
|
1076
|
+
if (!fs.existsSync(fileDir)) {
|
|
1077
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// 写入文件内容
|
|
1081
|
+
if (file.isBase64) {
|
|
1082
|
+
// 二进制文件使用 base64 编码
|
|
1083
|
+
fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
|
|
1084
|
+
} else {
|
|
1085
|
+
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 清除缓存
|
|
1090
|
+
this.skillsCache = null;
|
|
1091
|
+
this.cacheTime = 0;
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
success: true,
|
|
1095
|
+
message: '技能创建成功',
|
|
1096
|
+
directory,
|
|
1097
|
+
fileCount: files.length
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* 获取技能目录下所有文件列表
|
|
1103
|
+
* @param {string} directory - 技能目录名
|
|
1104
|
+
* @returns {Array<{path: string, size: number, isDirectory: boolean}>}
|
|
1105
|
+
*/
|
|
1106
|
+
getSkillFiles(directory) {
|
|
1107
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1108
|
+
|
|
1109
|
+
if (!fs.existsSync(skillPath)) {
|
|
1110
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const files = [];
|
|
1114
|
+
this._scanFilesRecursive(skillPath, skillPath, files);
|
|
1115
|
+
return files;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* 递归扫描目录获取文件列表
|
|
1120
|
+
*/
|
|
1121
|
+
_scanFilesRecursive(currentDir, baseDir, files) {
|
|
1122
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
1123
|
+
|
|
1124
|
+
for (const entry of entries) {
|
|
1125
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
1126
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
1127
|
+
|
|
1128
|
+
if (entry.isDirectory()) {
|
|
1129
|
+
files.push({
|
|
1130
|
+
path: relativePath,
|
|
1131
|
+
size: 0,
|
|
1132
|
+
isDirectory: true
|
|
1133
|
+
});
|
|
1134
|
+
this._scanFilesRecursive(fullPath, baseDir, files);
|
|
1135
|
+
} else {
|
|
1136
|
+
const stats = fs.statSync(fullPath);
|
|
1137
|
+
files.push({
|
|
1138
|
+
path: relativePath,
|
|
1139
|
+
size: stats.size,
|
|
1140
|
+
isDirectory: false
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* 获取技能文件内容
|
|
1148
|
+
* @param {string} directory - 技能目录名
|
|
1149
|
+
* @param {string} filePath - 文件相对路径
|
|
1150
|
+
* @returns {Object} 文件内容
|
|
1151
|
+
*/
|
|
1152
|
+
getSkillFileContent(directory, filePath) {
|
|
1153
|
+
const fullPath = path.join(this.installDir, directory, filePath);
|
|
1154
|
+
|
|
1155
|
+
if (!fs.existsSync(fullPath)) {
|
|
1156
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const stats = fs.statSync(fullPath);
|
|
1160
|
+
if (stats.isDirectory()) {
|
|
1161
|
+
throw new Error(`"${filePath}" 是目录,不是文件`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// 判断是否是文本文件
|
|
1165
|
+
const textExtensions = ['.md', '.txt', '.json', '.js', '.ts', '.py', '.sh', '.yaml', '.yml', '.toml', '.xml', '.html', '.css'];
|
|
1166
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1167
|
+
const isText = textExtensions.includes(ext);
|
|
1168
|
+
|
|
1169
|
+
if (isText) {
|
|
1170
|
+
return {
|
|
1171
|
+
path: filePath,
|
|
1172
|
+
content: fs.readFileSync(fullPath, 'utf-8'),
|
|
1173
|
+
isBase64: false,
|
|
1174
|
+
size: stats.size
|
|
1175
|
+
};
|
|
1176
|
+
} else {
|
|
1177
|
+
return {
|
|
1178
|
+
path: filePath,
|
|
1179
|
+
content: fs.readFileSync(fullPath).toString('base64'),
|
|
1180
|
+
isBase64: true,
|
|
1181
|
+
size: stats.size
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* 添加文件到现有技能
|
|
1188
|
+
* @param {string} directory - 技能目录名
|
|
1189
|
+
* @param {Array<{path: string, content: string, isBase64?: boolean}>} files - 文件数组
|
|
1190
|
+
*/
|
|
1191
|
+
addSkillFiles(directory, files) {
|
|
1192
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1193
|
+
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1194
|
+
|
|
1195
|
+
if (!fs.existsSync(skillPath)) {
|
|
1196
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (this.platform === 'opencode') {
|
|
1200
|
+
const incomingSkillMd = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md'));
|
|
1201
|
+
if (incomingSkillMd) {
|
|
1202
|
+
const content = incomingSkillMd.isBase64
|
|
1203
|
+
? Buffer.from(incomingSkillMd.content, 'base64').toString('utf-8')
|
|
1204
|
+
: incomingSkillMd.content;
|
|
1205
|
+
const validationError = this.validateOpenCodeSkillContent(content, normalizedDirectory);
|
|
1206
|
+
if (validationError) {
|
|
1207
|
+
throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const added = [];
|
|
1213
|
+
for (const file of files) {
|
|
1214
|
+
const filePath = path.join(skillPath, file.path);
|
|
1215
|
+
const fileDir = path.dirname(filePath);
|
|
1216
|
+
|
|
1217
|
+
// 确保父目录存在
|
|
1218
|
+
if (!fs.existsSync(fileDir)) {
|
|
1219
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// 写入文件
|
|
1223
|
+
if (file.isBase64) {
|
|
1224
|
+
fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
|
|
1225
|
+
} else {
|
|
1226
|
+
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
1227
|
+
}
|
|
1228
|
+
added.push(file.path);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// 清除缓存
|
|
1232
|
+
this.skillsCache = null;
|
|
1233
|
+
this.cacheTime = 0;
|
|
1234
|
+
|
|
1235
|
+
return { success: true, added };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* 删除技能中的文件
|
|
1240
|
+
* @param {string} directory - 技能目录名
|
|
1241
|
+
* @param {string} filePath - 文件相对路径
|
|
1242
|
+
*/
|
|
1243
|
+
deleteSkillFile(directory, filePath) {
|
|
1244
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1245
|
+
|
|
1246
|
+
if (!fs.existsSync(skillPath)) {
|
|
1247
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// 不允许删除 SKILL.md
|
|
1251
|
+
if (filePath === 'SKILL.md') {
|
|
1252
|
+
throw new Error('不能删除 SKILL.md 文件');
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const fullPath = path.join(skillPath, filePath);
|
|
1256
|
+
|
|
1257
|
+
if (!fs.existsSync(fullPath)) {
|
|
1258
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const stats = fs.statSync(fullPath);
|
|
1262
|
+
if (stats.isDirectory()) {
|
|
1263
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
1264
|
+
} else {
|
|
1265
|
+
fs.unlinkSync(fullPath);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// 清除缓存
|
|
1269
|
+
this.skillsCache = null;
|
|
1270
|
+
this.cacheTime = 0;
|
|
1271
|
+
|
|
1272
|
+
return { success: true, deleted: filePath };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* 更新技能文件内容
|
|
1277
|
+
* @param {string} directory - 技能目录名
|
|
1278
|
+
* @param {string} filePath - 文件相对路径
|
|
1279
|
+
* @param {string} content - 新内容
|
|
1280
|
+
* @param {boolean} isBase64 - 是否为 base64 编码
|
|
1281
|
+
*/
|
|
1282
|
+
updateSkillFile(directory, filePath, content, isBase64 = false) {
|
|
1283
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1284
|
+
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1285
|
+
|
|
1286
|
+
if (!fs.existsSync(skillPath)) {
|
|
1287
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const fullPath = path.join(skillPath, filePath);
|
|
1291
|
+
|
|
1292
|
+
if (!fs.existsSync(fullPath)) {
|
|
1293
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
if (this.platform === 'opencode' && /(^|\/)SKILL\.md$/i.test(filePath)) {
|
|
1297
|
+
const textContent = isBase64 ? Buffer.from(content, 'base64').toString('utf-8') : content;
|
|
1298
|
+
const validationError = this.validateOpenCodeSkillContent(textContent, normalizedDirectory);
|
|
1299
|
+
if (validationError) {
|
|
1300
|
+
throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (isBase64) {
|
|
1305
|
+
fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
|
|
1306
|
+
} else {
|
|
1307
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// 清除缓存
|
|
1311
|
+
this.skillsCache = null;
|
|
1312
|
+
this.cacheTime = 0;
|
|
1313
|
+
|
|
1314
|
+
return { success: true, updated: filePath };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* 卸载技能
|
|
1320
|
+
*/
|
|
1321
|
+
uninstallSkill(directory) {
|
|
1322
|
+
const dest = path.join(this.installDir, directory);
|
|
1323
|
+
|
|
1324
|
+
if (fs.existsSync(dest)) {
|
|
1325
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
1326
|
+
// 清除缓存
|
|
1327
|
+
this.skillsCache = null;
|
|
1328
|
+
this.cacheTime = 0;
|
|
1329
|
+
return { success: true, message: 'Uninstalled successfully' };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return { success: true, message: 'Not installed' };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* 获取技能详情(完整内容)
|
|
1337
|
+
*/
|
|
1338
|
+
async getSkillDetail(directory) {
|
|
1339
|
+
// 先检查本地是否安装
|
|
1340
|
+
const localPath = path.join(this.installDir, directory, 'SKILL.md');
|
|
1341
|
+
|
|
1342
|
+
if (fs.existsSync(localPath)) {
|
|
1343
|
+
const content = fs.readFileSync(localPath, 'utf-8');
|
|
1344
|
+
const metadata = this.parseSkillMd(content);
|
|
1345
|
+
|
|
1346
|
+
// 提取正文内容(去除 frontmatter)
|
|
1347
|
+
const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
|
|
1348
|
+
const body = bodyMatch ? bodyMatch[1].trim() : content;
|
|
1349
|
+
|
|
1350
|
+
return {
|
|
1351
|
+
directory,
|
|
1352
|
+
name: metadata.name || directory,
|
|
1353
|
+
description: metadata.description || '',
|
|
1354
|
+
content: body,
|
|
1355
|
+
fullContent: content,
|
|
1356
|
+
installed: true,
|
|
1357
|
+
source: 'local'
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const normalizeRepoPath = (input = '') =>
|
|
1362
|
+
String(input)
|
|
1363
|
+
.replace(/\\/g, '/')
|
|
1364
|
+
.replace(/^\/+/, '')
|
|
1365
|
+
.replace(/\/+$/, '');
|
|
1366
|
+
|
|
1367
|
+
const parseRemoteSkillContent = (content, repo) => {
|
|
1368
|
+
const metadata = this.parseSkillMd(content);
|
|
1369
|
+
const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
|
|
1370
|
+
const body = bodyMatch ? bodyMatch[1].trim() : content;
|
|
1371
|
+
|
|
1372
|
+
return {
|
|
1373
|
+
directory,
|
|
1374
|
+
name: metadata.name || directory,
|
|
1375
|
+
description: metadata.description || '',
|
|
1376
|
+
content: body,
|
|
1377
|
+
fullContent: content,
|
|
1378
|
+
installed: false,
|
|
1379
|
+
source: 'github',
|
|
1380
|
+
repoOwner: repo.owner,
|
|
1381
|
+
repoName: repo.name
|
|
1382
|
+
};
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
const tryLoadRemoteDetailFromRepo = async (repo, extraCandidateDirs = []) => {
|
|
1386
|
+
try {
|
|
1387
|
+
const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
|
|
1388
|
+
const tree = await this.fetchGitHubApi(treeUrl);
|
|
1389
|
+
if (!tree?.tree) return null;
|
|
1390
|
+
|
|
1391
|
+
const normalizedDirectory = normalizeRepoPath(directory);
|
|
1392
|
+
const candidateDirs = new Set();
|
|
1393
|
+
candidateDirs.add(normalizedDirectory);
|
|
1394
|
+
|
|
1395
|
+
for (const candidate of extraCandidateDirs) {
|
|
1396
|
+
const normalized = normalizeRepoPath(candidate);
|
|
1397
|
+
if (normalized) candidateDirs.add(normalized);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (repo.directory) {
|
|
1401
|
+
candidateDirs.add(normalizeRepoPath(`${repo.directory}/${normalizedDirectory}`));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
let skillFile = null;
|
|
1405
|
+
for (const candidateDir of candidateDirs) {
|
|
1406
|
+
if (!candidateDir) continue;
|
|
1407
|
+
skillFile = tree.tree.find(item =>
|
|
1408
|
+
item.type === 'blob' && item.path === `${candidateDir}/SKILL.md`
|
|
1409
|
+
);
|
|
1410
|
+
if (skillFile) break;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (!skillFile) {
|
|
1414
|
+
const targetBaseName = normalizedDirectory.split('/').pop();
|
|
1415
|
+
skillFile = tree.tree.find(item => {
|
|
1416
|
+
if (item.type !== 'blob' || !item.path.endsWith('/SKILL.md')) return false;
|
|
1417
|
+
const parts = item.path.split('/');
|
|
1418
|
+
const parentDir = parts.length >= 2 ? parts[parts.length - 2] : '';
|
|
1419
|
+
return parentDir === targetBaseName;
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (!skillFile) return null;
|
|
1424
|
+
|
|
1425
|
+
const content = await this.fetchBlobContent(skillFile.sha, repo, skillFile.path);
|
|
1426
|
+
return parseRemoteSkillContent(content, repo);
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
console.warn('[SkillService] Fetch remote skill detail error:', err.message);
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
// 先尝试使用缓存中的 repo 信息(最快)
|
|
1434
|
+
const cachedSkill = this.skillsCache?.find(s => s.directory === directory);
|
|
1435
|
+
if (cachedSkill && cachedSkill.repoOwner && cachedSkill.repoName) {
|
|
1436
|
+
const cachedRepo = {
|
|
1437
|
+
owner: cachedSkill.repoOwner,
|
|
1438
|
+
name: cachedSkill.repoName,
|
|
1439
|
+
branch: cachedSkill.repoBranch || 'main',
|
|
1440
|
+
directory: cachedSkill.repoDirectory || ''
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
const detail = await tryLoadRemoteDetailFromRepo(cachedRepo, [
|
|
1444
|
+
cachedSkill.fullDirectory || '',
|
|
1445
|
+
cachedSkill.repoDirectory ? `${cachedSkill.repoDirectory}/${directory}` : ''
|
|
1446
|
+
]);
|
|
1447
|
+
if (detail) return detail;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// 缓存缺失或过期时,回退到遍历仓库配置,避免详情页报错
|
|
1451
|
+
const repos = this.loadRepos().filter(repo => repo.enabled !== false);
|
|
1452
|
+
for (const repo of repos) {
|
|
1453
|
+
const detail = await tryLoadRemoteDetailFromRepo(
|
|
1454
|
+
{
|
|
1455
|
+
owner: repo.owner,
|
|
1456
|
+
name: repo.name,
|
|
1457
|
+
branch: repo.branch || 'main',
|
|
1458
|
+
directory: repo.directory || ''
|
|
1459
|
+
},
|
|
1460
|
+
[repo.directory ? `${repo.directory}/${directory}` : '']
|
|
1461
|
+
);
|
|
1462
|
+
if (detail) return detail;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
throw new Error('技能不存在或无法获取');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 获取已安装技能列表
|
|
1470
|
+
*/
|
|
1471
|
+
getInstalledSkills() {
|
|
1472
|
+
const skills = [];
|
|
1473
|
+
this.scanLocalDir(this.installDir, this.installDir, skills);
|
|
1474
|
+
return skills;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
module.exports = {
|
|
1479
|
+
SkillService,
|
|
1480
|
+
DEFAULT_REPOS: DEFAULT_REPOS_BY_PLATFORM.claude,
|
|
1481
|
+
DEFAULT_REPOS_BY_PLATFORM
|
|
1482
|
+
};
|