coding-tool-x 3.3.6 → 3.3.8
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 +14 -0
- package/dist/web/assets/{Analytics-TtaduRqL.js → Analytics-DLpoDZ2M.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-BP2lLBMN.js → ConfigTemplates-D_hRb55W.js} +1 -1
- package/dist/web/assets/Home-BMoFdAwy.css +1 -0
- package/dist/web/assets/Home-DNwp-0J-.js +1 -0
- package/dist/web/assets/{PluginManager-HmISlyMK.js → PluginManager-JXsyym1s.js} +1 -1
- package/dist/web/assets/{ProjectList-DoN8Hjbu.js → ProjectList-DZWSeb-q.js} +1 -1
- package/dist/web/assets/{SessionList-Da8BYzNi.js → SessionList-Cs624DR3.js} +1 -1
- package/dist/web/assets/{SkillManager-DqLAXh9o.js → SkillManager-bEliz7qz.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-B_TxOgPW.js → WorkspaceManager-J3RecFGn.js} +1 -1
- package/dist/web/assets/{icons-B29onFfZ.js → icons-Cuc23WS7.js} +1 -1
- package/dist/web/assets/index-BXeSvAwU.js +2 -0
- package/dist/web/assets/index-DWAC3Tdv.css +1 -0
- package/dist/web/index.html +3 -3
- package/package.json +3 -2
- package/src/commands/daemon.js +44 -6
- package/src/commands/toggle-proxy.js +100 -5
- package/src/config/default.js +1 -1
- package/src/config/model-metadata.js +2 -2
- package/src/config/model-metadata.json +7 -2
- package/src/config/paths.js +102 -19
- package/src/server/api/channels.js +9 -0
- package/src/server/api/codex-channels.js +9 -0
- package/src/server/api/codex-proxy.js +22 -11
- package/src/server/api/gemini-proxy.js +22 -11
- package/src/server/api/mcp.js +26 -4
- package/src/server/api/oauth-credentials.js +163 -0
- package/src/server/api/opencode-proxy.js +22 -10
- package/src/server/api/plugins.js +3 -1
- package/src/server/api/proxy.js +39 -44
- package/src/server/api/skills.js +91 -13
- package/src/server/codex-proxy-server.js +1 -11
- package/src/server/index.js +26 -2
- package/src/server/services/channels.js +18 -22
- package/src/server/services/codex-channels.js +124 -175
- package/src/server/services/codex-config.js +2 -5
- package/src/server/services/codex-settings-manager.js +12 -348
- package/src/server/services/config-export-service.js +572 -117
- package/src/server/services/gemini-channels.js +11 -9
- package/src/server/services/mcp-client.js +70 -13
- package/src/server/services/mcp-service.js +74 -29
- package/src/server/services/model-detector.js +1 -0
- package/src/server/services/native-keychain.js +243 -0
- package/src/server/services/native-oauth-adapters.js +890 -0
- package/src/server/services/oauth-credentials-service.js +786 -0
- package/src/server/services/oauth-utils.js +49 -0
- package/src/server/services/opencode-channels.js +13 -9
- package/src/server/services/opencode-settings-manager.js +169 -16
- package/src/server/services/plugins-service.js +22 -1
- package/src/server/services/settings-manager.js +13 -0
- package/src/server/services/skill-service.js +712 -332
- package/src/utils/port-helper.js +87 -2
- package/dist/web/assets/Home-BsSioaaB.css +0 -1
- package/dist/web/assets/Home-CbbyopS-.js +0 -1
- package/dist/web/assets/index-By3mDEvx.js +0 -2
- package/dist/web/assets/index-CsWInMQV.css +0 -1
|
@@ -19,7 +19,9 @@ const {
|
|
|
19
19
|
const { NATIVE_PATHS, HOME_DIR } = require('../../config/paths');
|
|
20
20
|
|
|
21
21
|
const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
|
|
22
|
-
const
|
|
22
|
+
const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
|
|
23
|
+
const DEFAULT_GITHUB_HOST = 'https://github.com';
|
|
24
|
+
const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
|
|
23
25
|
|
|
24
26
|
function normalizePlatform(platform) {
|
|
25
27
|
return SUPPORTED_PLATFORMS.includes(platform) ? platform : 'claude';
|
|
@@ -29,6 +31,116 @@ function cloneRepos(repos = []) {
|
|
|
29
31
|
return repos.map(repo => ({ ...repo }));
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function normalizeRepoPath(input = '') {
|
|
35
|
+
return String(input || '')
|
|
36
|
+
.replace(/\\/g, '/')
|
|
37
|
+
.replace(/^\/+/, '')
|
|
38
|
+
.replace(/\/+$/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeRepoDirectory(directory = '') {
|
|
42
|
+
return normalizeRepoPath(directory);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stripGitSuffix(value = '') {
|
|
46
|
+
return String(value || '').replace(/\.git$/i, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isWindowsAbsolutePath(input = '') {
|
|
50
|
+
return /^[a-zA-Z]:[\\/]/.test(String(input || ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isLikelyLocalPath(input = '') {
|
|
54
|
+
const normalized = String(input || '').trim();
|
|
55
|
+
if (!normalized) return false;
|
|
56
|
+
return (
|
|
57
|
+
normalized.startsWith('/') ||
|
|
58
|
+
normalized.startsWith('~/') ||
|
|
59
|
+
normalized.startsWith('./') ||
|
|
60
|
+
normalized.startsWith('../') ||
|
|
61
|
+
normalized.startsWith('file://') ||
|
|
62
|
+
isWindowsAbsolutePath(normalized)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function expandHomePath(input = '') {
|
|
67
|
+
const normalized = String(input || '').trim();
|
|
68
|
+
if (!normalized) return '';
|
|
69
|
+
if (normalized.startsWith('~/')) {
|
|
70
|
+
return path.join(HOME_DIR, normalized.slice(2));
|
|
71
|
+
}
|
|
72
|
+
if (normalized === '~') {
|
|
73
|
+
return HOME_DIR;
|
|
74
|
+
}
|
|
75
|
+
if (normalized.startsWith('file://')) {
|
|
76
|
+
try {
|
|
77
|
+
return decodeURIComponent(new URL(normalized).pathname);
|
|
78
|
+
} catch {
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveLocalRepoPath(input = '') {
|
|
86
|
+
const expanded = expandHomePath(input);
|
|
87
|
+
if (!expanded) return '';
|
|
88
|
+
return path.resolve(expanded);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeRepoHost(host, provider = 'github') {
|
|
92
|
+
const fallback = provider === 'gitlab' ? DEFAULT_GITLAB_HOST : DEFAULT_GITHUB_HOST;
|
|
93
|
+
let normalized = String(host || '').trim();
|
|
94
|
+
if (!normalized) {
|
|
95
|
+
normalized = fallback;
|
|
96
|
+
}
|
|
97
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
98
|
+
normalized = `https://${normalized}`;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(normalized);
|
|
102
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
103
|
+
} catch {
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildRepoUrl(repo) {
|
|
109
|
+
if (repo.provider === 'local') {
|
|
110
|
+
return repo.localPath || '';
|
|
111
|
+
}
|
|
112
|
+
if (repo.provider === 'gitlab') {
|
|
113
|
+
return `${repo.host}/${repo.projectPath}`;
|
|
114
|
+
}
|
|
115
|
+
return `${repo.host}/${repo.owner}/${repo.name}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildRepoLabel(repo) {
|
|
119
|
+
if (repo.provider === 'local') {
|
|
120
|
+
return repo.localPath || '';
|
|
121
|
+
}
|
|
122
|
+
if (repo.provider === 'gitlab') {
|
|
123
|
+
return repo.projectPath || '';
|
|
124
|
+
}
|
|
125
|
+
return [repo.owner, repo.name].filter(Boolean).join('/');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildRepoId(repo) {
|
|
129
|
+
const directory = normalizeRepoDirectory(repo.directory);
|
|
130
|
+
const branch = String(repo.branch || 'main').trim() || 'main';
|
|
131
|
+
if (repo.provider === 'local') {
|
|
132
|
+
return `local:${repo.localPath}::${directory}`;
|
|
133
|
+
}
|
|
134
|
+
if (repo.provider === 'gitlab') {
|
|
135
|
+
return `gitlab:${repo.host}::${repo.projectPath}::${branch}::${directory}`;
|
|
136
|
+
}
|
|
137
|
+
return `github:${repo.host}::${repo.owner}/${repo.name}::${branch}::${directory}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isRootSkillFile(filePath = '') {
|
|
141
|
+
return filePath === 'SKILL.md' || filePath.endsWith('/SKILL.md');
|
|
142
|
+
}
|
|
143
|
+
|
|
32
144
|
const DEFAULT_REPOS_BY_PLATFORM = {
|
|
33
145
|
claude: [
|
|
34
146
|
{ owner: 'anthropics', name: 'skills', branch: 'main', directory: '', enabled: true }
|
|
@@ -47,21 +159,25 @@ const DEFAULT_REPOS_BY_PLATFORM = {
|
|
|
47
159
|
const PLATFORM_CONFIG = {
|
|
48
160
|
claude: {
|
|
49
161
|
installDir: path.join(HOME_DIR, '.claude', 'skills'),
|
|
162
|
+
storageDir: 'skills',
|
|
50
163
|
reposFile: 'skill-repos.json',
|
|
51
164
|
cacheFile: 'skills-cache.json'
|
|
52
165
|
},
|
|
53
166
|
codex: {
|
|
54
167
|
installDir: path.join(HOME_DIR, '.codex', 'skills'),
|
|
168
|
+
storageDir: 'codex-skills',
|
|
55
169
|
reposFile: 'codex-skill-repos.json',
|
|
56
170
|
cacheFile: 'codex-skills-cache.json'
|
|
57
171
|
},
|
|
58
172
|
gemini: {
|
|
59
173
|
installDir: path.join(HOME_DIR, '.gemini', 'skills'),
|
|
174
|
+
storageDir: 'gemini-skills',
|
|
60
175
|
reposFile: 'gemini-skill-repos.json',
|
|
61
176
|
cacheFile: 'gemini-skills-cache.json'
|
|
62
177
|
},
|
|
63
178
|
opencode: {
|
|
64
179
|
installDir: path.join(NATIVE_PATHS.opencode.config, 'skills'),
|
|
180
|
+
storageDir: 'opencode-skills',
|
|
65
181
|
reposFile: 'opencode-skill-repos.json',
|
|
66
182
|
cacheFile: 'opencode-skills-cache.json'
|
|
67
183
|
}
|
|
@@ -77,6 +193,7 @@ class SkillService {
|
|
|
77
193
|
|
|
78
194
|
const platformConfig = PLATFORM_CONFIG[this.platform];
|
|
79
195
|
this.installDir = platformConfig.installDir;
|
|
196
|
+
this.storageDir = path.join(this.configDir, platformConfig.storageDir);
|
|
80
197
|
this.reposConfigPath = path.join(this.configDir, platformConfig.reposFile);
|
|
81
198
|
this.cachePath = path.join(this.configDir, platformConfig.cacheFile);
|
|
82
199
|
|
|
@@ -95,6 +212,101 @@ class SkillService {
|
|
|
95
212
|
if (!fs.existsSync(this.configDir)) {
|
|
96
213
|
fs.mkdirSync(this.configDir, { recursive: true });
|
|
97
214
|
}
|
|
215
|
+
if (!fs.existsSync(this.storageDir)) {
|
|
216
|
+
fs.mkdirSync(this.storageDir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
clearCache({ removeFile = false } = {}) {
|
|
221
|
+
this.skillsCache = null;
|
|
222
|
+
this.cacheTime = 0;
|
|
223
|
+
|
|
224
|
+
if (removeFile) {
|
|
225
|
+
try {
|
|
226
|
+
if (fs.existsSync(this.cachePath)) {
|
|
227
|
+
fs.unlinkSync(this.cachePath);
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.warn('[SkillService] Failed to delete cache file:', err.message);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getDefaultSkillDirectory(repo) {
|
|
236
|
+
if (repo.provider === 'local') {
|
|
237
|
+
return path.basename(repo.localPath || '') || 'skill';
|
|
238
|
+
}
|
|
239
|
+
if (repo.provider === 'gitlab') {
|
|
240
|
+
const projectPath = normalizeRepoPath(repo.projectPath);
|
|
241
|
+
return projectPath.split('/').pop() || 'skill';
|
|
242
|
+
}
|
|
243
|
+
return repo.name || 'skill';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
resolveSkillDirectory(fullDirectory, baseDir, repo) {
|
|
247
|
+
const normalizedFullDirectory = normalizeRepoPath(fullDirectory);
|
|
248
|
+
const normalizedBaseDir = normalizeRepoDirectory(baseDir);
|
|
249
|
+
|
|
250
|
+
if (normalizedBaseDir) {
|
|
251
|
+
if (normalizedFullDirectory === normalizedBaseDir) {
|
|
252
|
+
return normalizeRepoPath(path.basename(normalizedBaseDir)) || this.getDefaultSkillDirectory(repo);
|
|
253
|
+
}
|
|
254
|
+
if (normalizedFullDirectory.startsWith(`${normalizedBaseDir}/`)) {
|
|
255
|
+
return normalizedFullDirectory.slice(normalizedBaseDir.length + 1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!normalizedFullDirectory) {
|
|
260
|
+
return this.getDefaultSkillDirectory(repo);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return normalizedFullDirectory;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
normalizeRepoConfig(repo = {}) {
|
|
267
|
+
const provider = SUPPORTED_REPO_PROVIDERS.includes(repo.provider)
|
|
268
|
+
? repo.provider
|
|
269
|
+
: (repo.localPath ? 'local' : (repo.projectPath ? 'gitlab' : 'github'));
|
|
270
|
+
|
|
271
|
+
const normalized = {
|
|
272
|
+
provider,
|
|
273
|
+
branch: String(repo.branch || 'main').trim() || 'main',
|
|
274
|
+
directory: normalizeRepoDirectory(repo.directory),
|
|
275
|
+
enabled: repo.enabled !== false
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (provider === 'local') {
|
|
279
|
+
normalized.localPath = resolveLocalRepoPath(repo.localPath || repo.path || repo.url || '');
|
|
280
|
+
if (!normalized.localPath) {
|
|
281
|
+
throw new Error('Missing local repository path');
|
|
282
|
+
}
|
|
283
|
+
normalized.name = path.basename(normalized.localPath) || 'local-repo';
|
|
284
|
+
} else if (provider === 'gitlab') {
|
|
285
|
+
normalized.host = normalizeRepoHost(repo.host, 'gitlab');
|
|
286
|
+
normalized.projectPath = normalizeRepoPath(repo.projectPath || [repo.owner, repo.name].filter(Boolean).join('/'));
|
|
287
|
+
if (!normalized.projectPath) {
|
|
288
|
+
throw new Error('Missing GitLab project path');
|
|
289
|
+
}
|
|
290
|
+
normalized.name = stripGitSuffix(normalized.projectPath.split('/').pop() || '');
|
|
291
|
+
normalized.owner = normalized.projectPath.split('/')[0] || '';
|
|
292
|
+
} else {
|
|
293
|
+
normalized.host = normalizeRepoHost(repo.host, 'github');
|
|
294
|
+
normalized.owner = String(repo.owner || '').trim();
|
|
295
|
+
normalized.name = stripGitSuffix(repo.name || '');
|
|
296
|
+
if (!normalized.owner || !normalized.name) {
|
|
297
|
+
throw new Error('Missing GitHub repo info');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
normalized.repoUrl = repo.repoUrl || buildRepoUrl(normalized);
|
|
302
|
+
normalized.label = buildRepoLabel(normalized);
|
|
303
|
+
normalized.id = buildRepoId(normalized);
|
|
304
|
+
|
|
305
|
+
return normalized;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
normalizeRepos(repos = []) {
|
|
309
|
+
return repos.map(repo => this.normalizeRepoConfig(repo));
|
|
98
310
|
}
|
|
99
311
|
|
|
100
312
|
/**
|
|
@@ -105,20 +317,21 @@ class SkillService {
|
|
|
105
317
|
if (fs.existsSync(this.reposConfigPath)) {
|
|
106
318
|
const data = JSON.parse(fs.readFileSync(this.reposConfigPath, 'utf-8'));
|
|
107
319
|
if (Array.isArray(data.repos)) {
|
|
108
|
-
return data.repos;
|
|
320
|
+
return this.normalizeRepos(data.repos);
|
|
109
321
|
}
|
|
110
322
|
}
|
|
111
323
|
} catch (err) {
|
|
112
324
|
console.error('[SkillService] Load repos config error:', err.message);
|
|
113
325
|
}
|
|
114
|
-
return cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude);
|
|
326
|
+
return this.normalizeRepos(cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude));
|
|
115
327
|
}
|
|
116
328
|
|
|
117
329
|
/**
|
|
118
330
|
* 保存仓库配置
|
|
119
331
|
*/
|
|
120
332
|
saveRepos(repos) {
|
|
121
|
-
|
|
333
|
+
const normalizedRepos = this.normalizeRepos(repos);
|
|
334
|
+
fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos: normalizedRepos }, null, 2));
|
|
122
335
|
}
|
|
123
336
|
|
|
124
337
|
/**
|
|
@@ -132,24 +345,18 @@ class SkillService {
|
|
|
132
345
|
*/
|
|
133
346
|
addRepo(repo) {
|
|
134
347
|
const repos = this.loadRepos();
|
|
135
|
-
|
|
136
|
-
const existingIndex = repos.findIndex(r =>
|
|
137
|
-
r.owner === repo.owner &&
|
|
138
|
-
r.name === repo.name &&
|
|
139
|
-
(r.directory || '') === (repo.directory || '')
|
|
140
|
-
);
|
|
348
|
+
const normalizedRepo = this.normalizeRepoConfig(repo);
|
|
349
|
+
const existingIndex = repos.findIndex(r => r.id === normalizedRepo.id);
|
|
141
350
|
|
|
142
351
|
if (existingIndex >= 0) {
|
|
143
|
-
repos[existingIndex] =
|
|
352
|
+
repos[existingIndex] = normalizedRepo;
|
|
144
353
|
} else {
|
|
145
|
-
repos.push(
|
|
354
|
+
repos.push(normalizedRepo);
|
|
146
355
|
}
|
|
147
356
|
|
|
148
357
|
this.saveRepos(repos);
|
|
149
|
-
|
|
150
|
-
this.
|
|
151
|
-
this.cacheTime = 0;
|
|
152
|
-
return repos;
|
|
358
|
+
this.clearCache({ removeFile: true });
|
|
359
|
+
return this.loadRepos();
|
|
153
360
|
}
|
|
154
361
|
|
|
155
362
|
/**
|
|
@@ -158,18 +365,22 @@ class SkillService {
|
|
|
158
365
|
* @param {string} name - 仓库名称
|
|
159
366
|
* @param {string} [directory=''] - 子目录路径
|
|
160
367
|
*/
|
|
161
|
-
removeRepo(owner, name, directory = '') {
|
|
368
|
+
removeRepo(owner, name, directory = '', repoId = '') {
|
|
162
369
|
const repos = this.loadRepos();
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
370
|
+
const normalizedDirectory = normalizeRepoDirectory(directory);
|
|
371
|
+
const filtered = repos.filter(r => {
|
|
372
|
+
if (repoId) {
|
|
373
|
+
return r.id !== repoId;
|
|
374
|
+
}
|
|
375
|
+
return !(
|
|
376
|
+
(r.owner || '') === owner &&
|
|
377
|
+
(r.name || '') === name &&
|
|
378
|
+
normalizeRepoDirectory(r.directory) === normalizedDirectory
|
|
379
|
+
);
|
|
380
|
+
});
|
|
168
381
|
this.saveRepos(filtered);
|
|
169
|
-
|
|
170
|
-
this.
|
|
171
|
-
this.cacheTime = 0;
|
|
172
|
-
return filtered;
|
|
382
|
+
this.clearCache({ removeFile: true });
|
|
383
|
+
return this.loadRepos();
|
|
173
384
|
}
|
|
174
385
|
|
|
175
386
|
/**
|
|
@@ -179,21 +390,25 @@ class SkillService {
|
|
|
179
390
|
* @param {string} [directory=''] - 子目录路径
|
|
180
391
|
* @param {boolean} enabled - 是否启用
|
|
181
392
|
*/
|
|
182
|
-
toggleRepo(owner, name, directory = '', enabled) {
|
|
393
|
+
toggleRepo(owner, name, directory = '', enabled, repoId = '') {
|
|
183
394
|
const repos = this.loadRepos();
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
395
|
+
const normalizedDirectory = normalizeRepoDirectory(directory);
|
|
396
|
+
const repo = repos.find(r => {
|
|
397
|
+
if (repoId) {
|
|
398
|
+
return r.id === repoId;
|
|
399
|
+
}
|
|
400
|
+
return (
|
|
401
|
+
(r.owner || '') === owner &&
|
|
402
|
+
(r.name || '') === name &&
|
|
403
|
+
normalizeRepoDirectory(r.directory) === normalizedDirectory
|
|
404
|
+
);
|
|
405
|
+
});
|
|
189
406
|
if (repo) {
|
|
190
407
|
repo.enabled = enabled;
|
|
191
408
|
this.saveRepos(repos);
|
|
192
|
-
|
|
193
|
-
this.skillsCache = null;
|
|
194
|
-
this.cacheTime = 0;
|
|
409
|
+
this.clearCache({ removeFile: true });
|
|
195
410
|
}
|
|
196
|
-
return
|
|
411
|
+
return this.loadRepos();
|
|
197
412
|
}
|
|
198
413
|
|
|
199
414
|
/**
|
|
@@ -202,20 +417,11 @@ class SkillService {
|
|
|
202
417
|
async listSkills(forceRefresh = false) {
|
|
203
418
|
// 强制刷新时清除缓存
|
|
204
419
|
if (forceRefresh) {
|
|
205
|
-
this.
|
|
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
|
-
}
|
|
420
|
+
this.clearCache({ removeFile: true });
|
|
215
421
|
}
|
|
216
422
|
|
|
217
423
|
// 检查内存缓存
|
|
218
|
-
if (!forceRefresh && this.skillsCache
|
|
424
|
+
if (!forceRefresh && this.skillsCache) {
|
|
219
425
|
this.updateInstallStatus(this.skillsCache);
|
|
220
426
|
return this.skillsCache;
|
|
221
427
|
}
|
|
@@ -282,7 +488,7 @@ class SkillService {
|
|
|
282
488
|
try {
|
|
283
489
|
if (fs.existsSync(this.cachePath)) {
|
|
284
490
|
const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
|
|
285
|
-
if (
|
|
491
|
+
if (Array.isArray(data.skills)) {
|
|
286
492
|
return data.skills;
|
|
287
493
|
}
|
|
288
494
|
}
|
|
@@ -320,14 +526,24 @@ class SkillService {
|
|
|
320
526
|
* 支持指定子目录扫描
|
|
321
527
|
*/
|
|
322
528
|
async fetchRepoSkills(repo) {
|
|
529
|
+
if (repo.provider === 'local') {
|
|
530
|
+
return this.fetchLocalRepoSkills(repo);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (repo.provider === 'gitlab') {
|
|
534
|
+
return this.fetchGitLabRepoSkills(repo);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return this.fetchGitHubRepoSkills(repo);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async fetchGitHubRepoSkills(repo) {
|
|
323
541
|
const skills = [];
|
|
324
542
|
|
|
325
543
|
try {
|
|
326
|
-
|
|
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);
|
|
544
|
+
const treeItems = await this.fetchGitHubRepoTree(repo);
|
|
329
545
|
|
|
330
|
-
if (!
|
|
546
|
+
if (!treeItems.length) {
|
|
331
547
|
console.warn(`[SkillService] Empty tree for ${repo.owner}/${repo.name}`);
|
|
332
548
|
return skills;
|
|
333
549
|
}
|
|
@@ -336,12 +552,10 @@ class SkillService {
|
|
|
336
552
|
const baseDir = repo.directory || '';
|
|
337
553
|
const baseDirPrefix = baseDir ? `${baseDir}/` : '';
|
|
338
554
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (item.type !== 'blob' || !item.path.endsWith('/SKILL.md')) {
|
|
555
|
+
const skillFiles = treeItems.filter(item => {
|
|
556
|
+
if (item.type !== 'blob' || !isRootSkillFile(item.path)) {
|
|
342
557
|
return false;
|
|
343
558
|
}
|
|
344
|
-
// 如果配置了子目录,只返回该子目录下的文件
|
|
345
559
|
if (baseDir && !item.path.startsWith(baseDirPrefix)) {
|
|
346
560
|
return false;
|
|
347
561
|
}
|
|
@@ -350,6 +564,8 @@ class SkillService {
|
|
|
350
564
|
|
|
351
565
|
// 并行获取所有 SKILL.md 的内容(限制并发数)
|
|
352
566
|
const batchSize = 5;
|
|
567
|
+
let successCount = 0;
|
|
568
|
+
let failCount = 0;
|
|
353
569
|
|
|
354
570
|
for (let i = 0; i < skillFiles.length; i += batchSize) {
|
|
355
571
|
const batch = skillFiles.slice(i, i + batchSize);
|
|
@@ -360,9 +576,14 @@ class SkillService {
|
|
|
360
576
|
for (const result of results) {
|
|
361
577
|
if (result.status === 'fulfilled' && result.value) {
|
|
362
578
|
skills.push(result.value);
|
|
579
|
+
successCount++;
|
|
580
|
+
} else {
|
|
581
|
+
failCount++;
|
|
363
582
|
}
|
|
364
583
|
}
|
|
365
584
|
}
|
|
585
|
+
|
|
586
|
+
console.log(`[SkillService] ${repo.owner}/${repo.name}: ${successCount} skills loaded, ${failCount} failed`);
|
|
366
587
|
} catch (err) {
|
|
367
588
|
console.error(`[SkillService] Fetch repo ${repo.owner}/${repo.name} error:`, err.message);
|
|
368
589
|
throw err;
|
|
@@ -371,6 +592,67 @@ class SkillService {
|
|
|
371
592
|
return skills;
|
|
372
593
|
}
|
|
373
594
|
|
|
595
|
+
async fetchGitLabRepoSkills(repo) {
|
|
596
|
+
const skills = [];
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
const tree = await this.fetchGitLabTree(repo);
|
|
600
|
+
const baseDir = repo.directory || '';
|
|
601
|
+
const baseDirPrefix = baseDir ? `${baseDir}/` : '';
|
|
602
|
+
const skillFiles = tree.filter(item => {
|
|
603
|
+
if (item.type !== 'blob' || !isRootSkillFile(item.path)) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
if (baseDir && !item.path.startsWith(baseDirPrefix)) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const batchSize = 5;
|
|
613
|
+
let successCount = 0;
|
|
614
|
+
let failCount = 0;
|
|
615
|
+
|
|
616
|
+
for (let i = 0; i < skillFiles.length; i += batchSize) {
|
|
617
|
+
const batch = skillFiles.slice(i, i + batchSize);
|
|
618
|
+
const results = await Promise.allSettled(
|
|
619
|
+
batch.map(file => this.fetchAndParseSkill(file, repo, baseDir))
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
for (const result of results) {
|
|
623
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
624
|
+
skills.push(result.value);
|
|
625
|
+
successCount++;
|
|
626
|
+
} else {
|
|
627
|
+
failCount++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
console.log(`[SkillService] ${repo.projectPath}: ${successCount} skills loaded, ${failCount} failed`);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.error(`[SkillService] Fetch GitLab repo ${repo.projectPath} error:`, err.message);
|
|
635
|
+
throw err;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return skills;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async fetchLocalRepoSkills(repo) {
|
|
642
|
+
const skills = [];
|
|
643
|
+
const repoRoot = repo.localPath;
|
|
644
|
+
const scanRoot = repo.directory
|
|
645
|
+
? path.join(repoRoot, repo.directory)
|
|
646
|
+
: repoRoot;
|
|
647
|
+
|
|
648
|
+
if (!fs.existsSync(scanRoot)) {
|
|
649
|
+
throw new Error(`Local repo path not found: ${scanRoot}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
this.scanRepoLocalDir(scanRoot, repoRoot, skills, repo);
|
|
653
|
+
return skills;
|
|
654
|
+
}
|
|
655
|
+
|
|
374
656
|
/**
|
|
375
657
|
* 获取并解析单个 SKILL.md
|
|
376
658
|
* @param {Object} file - GitHub tree 文件对象
|
|
@@ -379,89 +661,120 @@ class SkillService {
|
|
|
379
661
|
*/
|
|
380
662
|
async fetchAndParseSkill(file, repo, baseDir = '') {
|
|
381
663
|
try {
|
|
382
|
-
|
|
383
|
-
const
|
|
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);
|
|
664
|
+
const fullDirectory = normalizeRepoPath(file.path.replace(/(^|\/)SKILL\.md$/, ''));
|
|
665
|
+
const directory = this.resolveSkillDirectory(fullDirectory, baseDir, repo);
|
|
666
|
+
const content = await this.fetchSkillFileContent(repo, file);
|
|
390
667
|
const metadata = this.parseSkillMd(content);
|
|
391
668
|
|
|
392
|
-
return {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
};
|
|
669
|
+
return this.createSkillListItem({
|
|
670
|
+
metadata,
|
|
671
|
+
repo,
|
|
672
|
+
directory,
|
|
673
|
+
fullDirectory
|
|
674
|
+
});
|
|
406
675
|
} catch (err) {
|
|
407
|
-
console.warn(`[SkillService] Parse skill ${file.path} error:`, err.message);
|
|
408
676
|
return null;
|
|
409
677
|
}
|
|
410
678
|
}
|
|
411
679
|
|
|
412
680
|
/**
|
|
413
|
-
*
|
|
681
|
+
* 递归扫描外部本地仓库目录
|
|
414
682
|
*/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
|
|
683
|
+
scanRepoLocalDir(currentDir, repoRoot, skills, repo) {
|
|
684
|
+
const skillMdPath = path.join(currentDir, 'SKILL.md');
|
|
418
685
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
}
|
|
686
|
+
if (fs.existsSync(skillMdPath)) {
|
|
687
|
+
try {
|
|
688
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
689
|
+
const metadata = this.parseSkillMd(content);
|
|
690
|
+
const fullDirectory = normalizeRepoPath(path.relative(repoRoot, currentDir));
|
|
691
|
+
const directory = this.resolveSkillDirectory(fullDirectory, repo.directory || '', repo);
|
|
692
|
+
|
|
693
|
+
skills.push(this.createSkillListItem({
|
|
694
|
+
metadata,
|
|
695
|
+
repo,
|
|
696
|
+
directory,
|
|
697
|
+
fullDirectory
|
|
698
|
+
}));
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.warn(`[SkillService] Parse local repo skill ${currentDir} error:`, err.message);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
447
704
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
705
|
+
try {
|
|
706
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
707
|
+
for (const entry of entries) {
|
|
708
|
+
if (!entry.isDirectory()) continue;
|
|
709
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
710
|
+
this.scanRepoLocalDir(path.join(currentDir, entry.name), repoRoot, skills, repo);
|
|
711
|
+
}
|
|
712
|
+
} catch (err) {
|
|
713
|
+
console.warn(`[SkillService] Scan local repo ${currentDir} error:`, err.message);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
458
716
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
717
|
+
createSkillListItem({ metadata, repo, directory, fullDirectory }) {
|
|
718
|
+
const repoDirectory = normalizeRepoDirectory(repo.directory);
|
|
719
|
+
const labelFallback = directory.split('/').pop() || this.getDefaultSkillDirectory(repo);
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
key: `${repo.id}:${fullDirectory || directory}`,
|
|
723
|
+
name: metadata.name || labelFallback,
|
|
724
|
+
description: metadata.description || '',
|
|
725
|
+
directory,
|
|
726
|
+
fullDirectory,
|
|
727
|
+
installed: this.isInstalled(directory),
|
|
728
|
+
readmeUrl: this.buildSkillReadmeUrl(repo, fullDirectory),
|
|
729
|
+
repoProvider: repo.provider,
|
|
730
|
+
repoOwner: repo.owner || null,
|
|
731
|
+
repoName: repo.name || null,
|
|
732
|
+
repoBranch: repo.branch,
|
|
733
|
+
repoDirectory,
|
|
734
|
+
repoHost: repo.host || null,
|
|
735
|
+
repoProjectPath: repo.projectPath || null,
|
|
736
|
+
repoLocalPath: repo.localPath || null,
|
|
737
|
+
repoId: repo.id,
|
|
738
|
+
repoUrl: repo.repoUrl || buildRepoUrl(repo),
|
|
739
|
+
source: repo.provider === 'local' ? 'local-repo' : repo.provider,
|
|
740
|
+
license: metadata.license
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
buildSkillReadmeUrl(repo, fullDirectory = '') {
|
|
745
|
+
const normalizedDirectory = normalizeRepoPath(fullDirectory);
|
|
746
|
+
if (repo.provider === 'local') {
|
|
747
|
+
return repo.localPath || null;
|
|
748
|
+
}
|
|
749
|
+
if (repo.provider === 'gitlab') {
|
|
750
|
+
const suffix = normalizedDirectory ? `/-/tree/${repo.branch}/${normalizedDirectory}` : `/-/tree/${repo.branch}`;
|
|
751
|
+
return `${repo.host}/${repo.projectPath}${suffix}`;
|
|
752
|
+
}
|
|
753
|
+
const suffix = normalizedDirectory ? `tree/${repo.branch}/${normalizedDirectory}` : `tree/${repo.branch}`;
|
|
754
|
+
return `${repo.host}/${repo.owner}/${repo.name}/${suffix}`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async fetchSkillFileContent(repo, file) {
|
|
758
|
+
if (repo.provider === 'gitlab') {
|
|
759
|
+
return this.fetchGitLabFileContent(repo, file.path);
|
|
760
|
+
}
|
|
761
|
+
if (repo.provider === 'local') {
|
|
762
|
+
const localFilePath = path.join(repo.localPath, file.path);
|
|
763
|
+
return fs.readFileSync(localFilePath, 'utf-8');
|
|
764
|
+
}
|
|
765
|
+
return this.fetchGitHubBlobContent(file.sha, repo);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 使用 GitHub Blob API 获取文件内容
|
|
770
|
+
*/
|
|
771
|
+
async fetchGitHubBlobContent(sha, repo) {
|
|
772
|
+
const url = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/blobs/${sha}`;
|
|
773
|
+
const data = await this.fetchGitHubApi(url);
|
|
774
|
+
if (!data || typeof data.content !== 'string') {
|
|
775
|
+
throw new Error('Invalid GitHub blob response');
|
|
776
|
+
}
|
|
777
|
+
return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8');
|
|
465
778
|
}
|
|
466
779
|
|
|
467
780
|
/**
|
|
@@ -484,6 +797,24 @@ class SkillService {
|
|
|
484
797
|
return null;
|
|
485
798
|
}
|
|
486
799
|
|
|
800
|
+
getGitLabToken() {
|
|
801
|
+
if (process.env.GITLAB_TOKEN) {
|
|
802
|
+
return process.env.GITLAB_TOKEN;
|
|
803
|
+
}
|
|
804
|
+
if (process.env.GITLAB_PRIVATE_TOKEN) {
|
|
805
|
+
return process.env.GITLAB_PRIVATE_TOKEN;
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
const configPath = path.join(this.configDir, 'gitlab-token.txt');
|
|
809
|
+
if (fs.existsSync(configPath)) {
|
|
810
|
+
return fs.readFileSync(configPath, 'utf-8').trim();
|
|
811
|
+
}
|
|
812
|
+
} catch (err) {
|
|
813
|
+
// ignore
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
487
818
|
/**
|
|
488
819
|
* 通用 GitHub API 请求
|
|
489
820
|
*/
|
|
@@ -525,6 +856,90 @@ class SkillService {
|
|
|
525
856
|
});
|
|
526
857
|
}
|
|
527
858
|
|
|
859
|
+
async fetchGitHubRepoTree(repo) {
|
|
860
|
+
const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
|
|
861
|
+
const tree = await this.fetchGitHubApi(treeUrl);
|
|
862
|
+
if (tree?.truncated) {
|
|
863
|
+
console.warn(`[SkillService] GitHub tree truncated for ${repo.owner}/${repo.name}`);
|
|
864
|
+
}
|
|
865
|
+
return tree?.tree || [];
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async fetchGitLabApi(url, { raw = false } = {}) {
|
|
869
|
+
const token = this.getGitLabToken();
|
|
870
|
+
const headers = {
|
|
871
|
+
'User-Agent': 'cc-cli-skill-service'
|
|
872
|
+
};
|
|
873
|
+
if (!raw) {
|
|
874
|
+
headers.Accept = 'application/json';
|
|
875
|
+
}
|
|
876
|
+
if (token) {
|
|
877
|
+
headers['PRIVATE-TOKEN'] = token;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return new Promise((resolve, reject) => {
|
|
881
|
+
const transport = url.startsWith('http:') ? http : https;
|
|
882
|
+
const req = transport.get(url, {
|
|
883
|
+
headers,
|
|
884
|
+
timeout: 15000
|
|
885
|
+
}, (res) => {
|
|
886
|
+
let data = '';
|
|
887
|
+
res.on('data', chunk => data += chunk);
|
|
888
|
+
res.on('end', () => {
|
|
889
|
+
if (res.statusCode === 200) {
|
|
890
|
+
if (raw) {
|
|
891
|
+
resolve(data);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
resolve({
|
|
896
|
+
data: JSON.parse(data),
|
|
897
|
+
headers: res.headers
|
|
898
|
+
});
|
|
899
|
+
} catch (e) {
|
|
900
|
+
reject(new Error('Invalid JSON response'));
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
reject(new Error(`GitLab API error: ${res.statusCode}`));
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
req.on('error', reject);
|
|
909
|
+
req.on('timeout', () => {
|
|
910
|
+
req.destroy();
|
|
911
|
+
reject(new Error('Request timeout'));
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async fetchGitLabTree(repo) {
|
|
917
|
+
const tree = [];
|
|
918
|
+
const projectId = encodeURIComponent(repo.projectPath);
|
|
919
|
+
let page = 1;
|
|
920
|
+
|
|
921
|
+
while (page) {
|
|
922
|
+
const url = `${repo.host}/api/v4/projects/${projectId}/repository/tree?ref=${encodeURIComponent(repo.branch)}&recursive=true&per_page=100&page=${page}`;
|
|
923
|
+
const response = await this.fetchGitLabApi(url);
|
|
924
|
+
tree.push(...(response.data || []).map(item => ({
|
|
925
|
+
...item,
|
|
926
|
+
type: item.type === 'tree' ? 'tree' : 'blob'
|
|
927
|
+
})));
|
|
928
|
+
|
|
929
|
+
const nextPage = Number(response.headers['x-next-page'] || 0);
|
|
930
|
+
page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return tree;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async fetchGitLabFileContent(repo, filePath) {
|
|
937
|
+
const projectId = encodeURIComponent(repo.projectPath);
|
|
938
|
+
const normalizedFilePath = encodeURIComponent(normalizeRepoPath(filePath));
|
|
939
|
+
const url = `${repo.host}/api/v4/projects/${projectId}/repository/files/${normalizedFilePath}/raw?ref=${encodeURIComponent(repo.branch)}`;
|
|
940
|
+
return this.fetchGitLabApi(url, { raw: true });
|
|
941
|
+
}
|
|
942
|
+
|
|
528
943
|
/**
|
|
529
944
|
* 使用 GitHub API 获取目录内容
|
|
530
945
|
*/
|
|
@@ -675,41 +1090,6 @@ class SkillService {
|
|
|
675
1090
|
return String(directory).replace(/\\/g, '/').split('/').pop();
|
|
676
1091
|
}
|
|
677
1092
|
|
|
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
1093
|
/**
|
|
714
1094
|
* 检查技能是否已安装
|
|
715
1095
|
*/
|
|
@@ -720,13 +1100,13 @@ class SkillService {
|
|
|
720
1100
|
}
|
|
721
1101
|
|
|
722
1102
|
/**
|
|
723
|
-
*
|
|
1103
|
+
* 合并本地 cc-tool 托管的技能(扫描 storageDir,根据 installDir 判断安装状态)
|
|
724
1104
|
*/
|
|
725
1105
|
mergeLocalSkills(skills) {
|
|
726
|
-
if (!fs.existsSync(this.
|
|
1106
|
+
if (!fs.existsSync(this.storageDir)) return;
|
|
727
1107
|
|
|
728
|
-
//
|
|
729
|
-
this.scanLocalDir(this.
|
|
1108
|
+
// 递归扫描 cc-tool 存储目录
|
|
1109
|
+
this.scanLocalDir(this.storageDir, this.storageDir, skills);
|
|
730
1110
|
}
|
|
731
1111
|
|
|
732
1112
|
/**
|
|
@@ -741,16 +1121,19 @@ class SkillService {
|
|
|
741
1121
|
: path.relative(baseDir, currentDir);
|
|
742
1122
|
|
|
743
1123
|
// 检查是否已在列表中(比较目录名,去掉前缀路径)
|
|
744
|
-
const
|
|
1124
|
+
const normalizedDirectory = normalizeRepoPath(directory).toLowerCase();
|
|
745
1125
|
const existing = skills.find(s => {
|
|
746
|
-
|
|
747
|
-
return remoteDirName === dirName;
|
|
1126
|
+
return normalizeRepoPath(s.directory).toLowerCase() === normalizedDirectory;
|
|
748
1127
|
});
|
|
749
1128
|
|
|
1129
|
+
// 判断是否已安装到平台目录
|
|
1130
|
+
const isInstalled = fs.existsSync(path.join(this.installDir, directory, 'SKILL.md'));
|
|
1131
|
+
|
|
750
1132
|
if (existing) {
|
|
751
|
-
existing.installed =
|
|
1133
|
+
existing.installed = isInstalled;
|
|
1134
|
+
existing.isLocal = true;
|
|
752
1135
|
} else {
|
|
753
|
-
//
|
|
1136
|
+
// 添加 cc-tool 托管的技能
|
|
754
1137
|
try {
|
|
755
1138
|
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
756
1139
|
const metadata = this.parseSkillMd(content);
|
|
@@ -760,7 +1143,8 @@ class SkillService {
|
|
|
760
1143
|
name: metadata.name || directory,
|
|
761
1144
|
description: metadata.description || '',
|
|
762
1145
|
directory,
|
|
763
|
-
installed:
|
|
1146
|
+
installed: isInstalled,
|
|
1147
|
+
isLocal: true,
|
|
764
1148
|
readmeUrl: null,
|
|
765
1149
|
repoOwner: null,
|
|
766
1150
|
repoName: null,
|
|
@@ -796,11 +1180,13 @@ class SkillService {
|
|
|
796
1180
|
|
|
797
1181
|
for (let i = skills.length - 1; i >= 0; i--) {
|
|
798
1182
|
const skill = skills[i];
|
|
799
|
-
|
|
800
|
-
|
|
1183
|
+
const key = [
|
|
1184
|
+
normalizeRepoPath(skill.directory).toLowerCase(),
|
|
1185
|
+
skill.repoId || '',
|
|
1186
|
+
skill.installed ? 'installed' : 'remote'
|
|
1187
|
+
].join('::');
|
|
801
1188
|
|
|
802
1189
|
if (seen.has(key)) {
|
|
803
|
-
// 保留已安装的版本
|
|
804
1190
|
const existingIndex = seen.get(key);
|
|
805
1191
|
if (skill.installed && !skills[existingIndex].installed) {
|
|
806
1192
|
skills.splice(existingIndex, 1);
|
|
@@ -822,6 +1208,7 @@ class SkillService {
|
|
|
822
1208
|
*/
|
|
823
1209
|
async installSkill(directory, repo, fullDirectory = null) {
|
|
824
1210
|
const dest = path.join(this.installDir, directory);
|
|
1211
|
+
const normalizedRepo = this.normalizeRepoConfig(repo);
|
|
825
1212
|
|
|
826
1213
|
// 已安装则跳过
|
|
827
1214
|
if (fs.existsSync(dest)) {
|
|
@@ -831,16 +1218,48 @@ class SkillService {
|
|
|
831
1218
|
// 使用 fullDirectory(仓库中的完整路径)或 directory(向后兼容)
|
|
832
1219
|
const sourcePath = fullDirectory || directory;
|
|
833
1220
|
|
|
834
|
-
|
|
835
|
-
|
|
1221
|
+
if (normalizedRepo.provider === 'local') {
|
|
1222
|
+
const sourceDir = sourcePath
|
|
1223
|
+
? path.join(normalizedRepo.localPath, sourcePath)
|
|
1224
|
+
: normalizedRepo.localPath;
|
|
1225
|
+
|
|
1226
|
+
if (!fs.existsSync(sourceDir)) {
|
|
1227
|
+
throw new Error(`Skill directory not found: ${sourcePath || normalizedRepo.localPath}`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1231
|
+
this.copyDirRecursive(sourceDir, dest);
|
|
1232
|
+
|
|
1233
|
+
this.clearCache({ removeFile: true });
|
|
1234
|
+
return { success: true, message: 'Installed successfully' };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
836
1237
|
const tempDir = path.join(os.tmpdir(), `skill-${Date.now()}`);
|
|
837
1238
|
const zipPath = path.join(tempDir, 'repo.zip');
|
|
838
1239
|
|
|
839
1240
|
try {
|
|
840
1241
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
841
1242
|
|
|
842
|
-
|
|
843
|
-
|
|
1243
|
+
let zipUrl = '';
|
|
1244
|
+
let zipHeaders = {};
|
|
1245
|
+
|
|
1246
|
+
if (normalizedRepo.provider === 'gitlab') {
|
|
1247
|
+
const projectId = encodeURIComponent(normalizedRepo.projectPath);
|
|
1248
|
+
zipUrl = `${normalizedRepo.host}/api/v4/projects/${projectId}/repository/archive.zip?sha=${encodeURIComponent(normalizedRepo.branch)}`;
|
|
1249
|
+
const token = this.getGitLabToken();
|
|
1250
|
+
if (token) {
|
|
1251
|
+
zipHeaders['PRIVATE-TOKEN'] = token;
|
|
1252
|
+
}
|
|
1253
|
+
} else {
|
|
1254
|
+
zipUrl = `https://api.github.com/repos/${normalizedRepo.owner}/${normalizedRepo.name}/zipball/${encodeURIComponent(normalizedRepo.branch)}`;
|
|
1255
|
+
const token = this.getGitHubToken();
|
|
1256
|
+
zipHeaders.Accept = 'application/vnd.github+json';
|
|
1257
|
+
if (token) {
|
|
1258
|
+
zipHeaders.Authorization = `token ${token}`;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
await this.downloadFile(zipUrl, zipPath, zipHeaders);
|
|
844
1263
|
|
|
845
1264
|
// 解压
|
|
846
1265
|
const zip = new AdmZip(zipPath);
|
|
@@ -866,23 +1285,7 @@ class SkillService {
|
|
|
866
1285
|
fs.mkdirSync(dest, { recursive: true });
|
|
867
1286
|
this.copyDirRecursive(sourceDir, dest);
|
|
868
1287
|
|
|
869
|
-
|
|
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;
|
|
1288
|
+
this.clearCache({ removeFile: true });
|
|
886
1289
|
|
|
887
1290
|
return { success: true, message: 'Installed successfully' };
|
|
888
1291
|
} finally {
|
|
@@ -898,18 +1301,22 @@ class SkillService {
|
|
|
898
1301
|
/**
|
|
899
1302
|
* 下载文件
|
|
900
1303
|
*/
|
|
901
|
-
async downloadFile(url, dest) {
|
|
1304
|
+
async downloadFile(url, dest, headers = {}) {
|
|
902
1305
|
return new Promise((resolve, reject) => {
|
|
903
1306
|
const file = createWriteStream(dest);
|
|
1307
|
+
const transport = url.startsWith('http:') ? http : https;
|
|
904
1308
|
|
|
905
|
-
const request =
|
|
906
|
-
headers: {
|
|
1309
|
+
const request = transport.get(url, {
|
|
1310
|
+
headers: {
|
|
1311
|
+
'User-Agent': 'cc-cli-skill-service',
|
|
1312
|
+
...headers
|
|
1313
|
+
},
|
|
907
1314
|
timeout: 60000
|
|
908
1315
|
}, (response) => {
|
|
909
1316
|
// 处理重定向
|
|
910
1317
|
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
911
1318
|
file.close();
|
|
912
|
-
this.downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
1319
|
+
this.downloadFile(response.headers.location, dest, headers).then(resolve).catch(reject);
|
|
913
1320
|
return;
|
|
914
1321
|
}
|
|
915
1322
|
|
|
@@ -964,51 +1371,21 @@ class SkillService {
|
|
|
964
1371
|
* 创建自定义技能
|
|
965
1372
|
*/
|
|
966
1373
|
createCustomSkill({ name, directory, description, content }) {
|
|
967
|
-
const dest = path.join(this.
|
|
968
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1374
|
+
const dest = path.join(this.storageDir, directory);
|
|
969
1375
|
|
|
970
1376
|
// 检查是否已存在
|
|
971
1377
|
if (fs.existsSync(dest)) {
|
|
972
1378
|
throw new Error(`技能目录 "${directory}" 已存在`);
|
|
973
1379
|
}
|
|
974
1380
|
|
|
975
|
-
if (this.platform === 'opencode') {
|
|
976
|
-
if (!OPENCODE_SKILL_NAME_REGEX.test(normalizedDirectory)) {
|
|
977
|
-
throw new Error('OpenCode skill 目录名必须是小写字母/数字,并使用单个连字符连接');
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
1381
|
const normalizedDescription = (description || '').trim();
|
|
982
|
-
const skillName =
|
|
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
|
-
}
|
|
1382
|
+
const skillName = name || directory;
|
|
998
1383
|
|
|
999
1384
|
// 创建目录
|
|
1000
1385
|
fs.mkdirSync(dest, { recursive: true });
|
|
1001
1386
|
|
|
1002
1387
|
// 生成 SKILL.md 内容
|
|
1003
|
-
const skillMdContent =
|
|
1004
|
-
? `---
|
|
1005
|
-
name: ${skillName}
|
|
1006
|
-
description: "${normalizedDescription}"
|
|
1007
|
-
---
|
|
1008
|
-
|
|
1009
|
-
${content}
|
|
1010
|
-
`
|
|
1011
|
-
: `---
|
|
1388
|
+
const skillMdContent = `---
|
|
1012
1389
|
name: "${skillName}"
|
|
1013
1390
|
description: "${normalizedDescription}"
|
|
1014
1391
|
---
|
|
@@ -1019,9 +1396,7 @@ ${content}
|
|
|
1019
1396
|
// 写入文件
|
|
1020
1397
|
fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
|
|
1021
1398
|
|
|
1022
|
-
|
|
1023
|
-
this.skillsCache = null;
|
|
1024
|
-
this.cacheTime = 0;
|
|
1399
|
+
this.clearCache({ removeFile: true });
|
|
1025
1400
|
|
|
1026
1401
|
return { success: true, message: '技能创建成功', directory };
|
|
1027
1402
|
}
|
|
@@ -1033,8 +1408,7 @@ ${content}
|
|
|
1033
1408
|
* @returns {Object} 创建结果
|
|
1034
1409
|
*/
|
|
1035
1410
|
createSkillWithFiles({ directory, files }) {
|
|
1036
|
-
const dest = path.join(this.
|
|
1037
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1411
|
+
const dest = path.join(this.storageDir, directory);
|
|
1038
1412
|
|
|
1039
1413
|
// 检查是否已存在
|
|
1040
1414
|
if (fs.existsSync(dest)) {
|
|
@@ -1049,21 +1423,6 @@ ${content}
|
|
|
1049
1423
|
throw new Error('技能必须包含 SKILL.md 文件');
|
|
1050
1424
|
}
|
|
1051
1425
|
|
|
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
1426
|
// 创建目录
|
|
1068
1427
|
fs.mkdirSync(dest, { recursive: true });
|
|
1069
1428
|
|
|
@@ -1086,9 +1445,7 @@ ${content}
|
|
|
1086
1445
|
}
|
|
1087
1446
|
}
|
|
1088
1447
|
|
|
1089
|
-
|
|
1090
|
-
this.skillsCache = null;
|
|
1091
|
-
this.cacheTime = 0;
|
|
1448
|
+
this.clearCache({ removeFile: true });
|
|
1092
1449
|
|
|
1093
1450
|
return {
|
|
1094
1451
|
success: true,
|
|
@@ -1190,25 +1547,11 @@ ${content}
|
|
|
1190
1547
|
*/
|
|
1191
1548
|
addSkillFiles(directory, files) {
|
|
1192
1549
|
const skillPath = path.join(this.installDir, directory);
|
|
1193
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1194
1550
|
|
|
1195
1551
|
if (!fs.existsSync(skillPath)) {
|
|
1196
1552
|
throw new Error(`技能 "${directory}" 不存在`);
|
|
1197
1553
|
}
|
|
1198
1554
|
|
|
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
1555
|
const added = [];
|
|
1213
1556
|
for (const file of files) {
|
|
1214
1557
|
const filePath = path.join(skillPath, file.path);
|
|
@@ -1228,9 +1571,7 @@ ${content}
|
|
|
1228
1571
|
added.push(file.path);
|
|
1229
1572
|
}
|
|
1230
1573
|
|
|
1231
|
-
|
|
1232
|
-
this.skillsCache = null;
|
|
1233
|
-
this.cacheTime = 0;
|
|
1574
|
+
this.clearCache({ removeFile: true });
|
|
1234
1575
|
|
|
1235
1576
|
return { success: true, added };
|
|
1236
1577
|
}
|
|
@@ -1265,9 +1606,7 @@ ${content}
|
|
|
1265
1606
|
fs.unlinkSync(fullPath);
|
|
1266
1607
|
}
|
|
1267
1608
|
|
|
1268
|
-
|
|
1269
|
-
this.skillsCache = null;
|
|
1270
|
-
this.cacheTime = 0;
|
|
1609
|
+
this.clearCache({ removeFile: true });
|
|
1271
1610
|
|
|
1272
1611
|
return { success: true, deleted: filePath };
|
|
1273
1612
|
}
|
|
@@ -1281,7 +1620,6 @@ ${content}
|
|
|
1281
1620
|
*/
|
|
1282
1621
|
updateSkillFile(directory, filePath, content, isBase64 = false) {
|
|
1283
1622
|
const skillPath = path.join(this.installDir, directory);
|
|
1284
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1285
1623
|
|
|
1286
1624
|
if (!fs.existsSync(skillPath)) {
|
|
1287
1625
|
throw new Error(`技能 "${directory}" 不存在`);
|
|
@@ -1293,28 +1631,39 @@ ${content}
|
|
|
1293
1631
|
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1294
1632
|
}
|
|
1295
1633
|
|
|
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
1634
|
if (isBase64) {
|
|
1305
1635
|
fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
|
|
1306
1636
|
} else {
|
|
1307
1637
|
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1308
1638
|
}
|
|
1309
1639
|
|
|
1310
|
-
|
|
1311
|
-
this.skillsCache = null;
|
|
1312
|
-
this.cacheTime = 0;
|
|
1640
|
+
this.clearCache({ removeFile: true });
|
|
1313
1641
|
|
|
1314
1642
|
return { success: true, updated: filePath };
|
|
1315
1643
|
}
|
|
1316
1644
|
|
|
1317
1645
|
|
|
1646
|
+
/**
|
|
1647
|
+
* 安装 cc-tool 本地托管的技能(从 storageDir cp 到 installDir)
|
|
1648
|
+
*/
|
|
1649
|
+
installLocalSkill(directory) {
|
|
1650
|
+
const src = path.join(this.storageDir, directory);
|
|
1651
|
+
const dest = path.join(this.installDir, directory);
|
|
1652
|
+
|
|
1653
|
+
if (!fs.existsSync(src)) {
|
|
1654
|
+
throw new Error(`本地技能 "${directory}" 不存在`);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (fs.existsSync(dest)) {
|
|
1658
|
+
return { success: true, message: 'Already installed' };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1662
|
+
this.copyDirRecursive(src, dest);
|
|
1663
|
+
this.clearCache({ removeFile: true });
|
|
1664
|
+
return { success: true, message: 'Installed successfully' };
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1318
1667
|
/**
|
|
1319
1668
|
* 卸载技能
|
|
1320
1669
|
*/
|
|
@@ -1323,9 +1672,7 @@ ${content}
|
|
|
1323
1672
|
|
|
1324
1673
|
if (fs.existsSync(dest)) {
|
|
1325
1674
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
1326
|
-
|
|
1327
|
-
this.skillsCache = null;
|
|
1328
|
-
this.cacheTime = 0;
|
|
1675
|
+
this.clearCache({ removeFile: true });
|
|
1329
1676
|
return { success: true, message: 'Uninstalled successfully' };
|
|
1330
1677
|
}
|
|
1331
1678
|
|
|
@@ -1335,7 +1682,7 @@ ${content}
|
|
|
1335
1682
|
/**
|
|
1336
1683
|
* 获取技能详情(完整内容)
|
|
1337
1684
|
*/
|
|
1338
|
-
async getSkillDetail(directory) {
|
|
1685
|
+
async getSkillDetail(directory, repoHint = null, fullDirectoryHint = '') {
|
|
1339
1686
|
// 先检查本地是否安装
|
|
1340
1687
|
const localPath = path.join(this.installDir, directory, 'SKILL.md');
|
|
1341
1688
|
|
|
@@ -1358,13 +1705,7 @@ ${content}
|
|
|
1358
1705
|
};
|
|
1359
1706
|
}
|
|
1360
1707
|
|
|
1361
|
-
const
|
|
1362
|
-
String(input)
|
|
1363
|
-
.replace(/\\/g, '/')
|
|
1364
|
-
.replace(/^\/+/, '')
|
|
1365
|
-
.replace(/\/+$/, '');
|
|
1366
|
-
|
|
1367
|
-
const parseRemoteSkillContent = (content, repo) => {
|
|
1708
|
+
const parseRemoteSkillContent = (content, repo, fullDirectory = '') => {
|
|
1368
1709
|
const metadata = this.parseSkillMd(content);
|
|
1369
1710
|
const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
|
|
1370
1711
|
const body = bodyMatch ? bodyMatch[1].trim() : content;
|
|
@@ -1376,17 +1717,46 @@ ${content}
|
|
|
1376
1717
|
content: body,
|
|
1377
1718
|
fullContent: content,
|
|
1378
1719
|
installed: false,
|
|
1379
|
-
source: '
|
|
1380
|
-
|
|
1381
|
-
|
|
1720
|
+
source: repo.provider === 'local' ? 'local-repo' : repo.provider,
|
|
1721
|
+
fullDirectory,
|
|
1722
|
+
repoProvider: repo.provider,
|
|
1723
|
+
repoOwner: repo.owner || null,
|
|
1724
|
+
repoName: repo.name || null,
|
|
1725
|
+
repoBranch: repo.branch || 'main',
|
|
1726
|
+
repoDirectory: repo.directory || '',
|
|
1727
|
+
repoHost: repo.host || null,
|
|
1728
|
+
repoProjectPath: repo.projectPath || null,
|
|
1729
|
+
repoLocalPath: repo.localPath || null,
|
|
1730
|
+
repoId: repo.id,
|
|
1731
|
+
repoUrl: repo.repoUrl || buildRepoUrl(repo)
|
|
1382
1732
|
};
|
|
1383
1733
|
};
|
|
1384
1734
|
|
|
1385
1735
|
const tryLoadRemoteDetailFromRepo = async (repo, extraCandidateDirs = []) => {
|
|
1386
1736
|
try {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1737
|
+
if (repo.provider === 'local') {
|
|
1738
|
+
const normalizedDirectory = normalizeRepoPath(directory);
|
|
1739
|
+
const candidateDirs = new Set([
|
|
1740
|
+
normalizedDirectory,
|
|
1741
|
+
normalizeRepoPath(fullDirectoryHint || ''),
|
|
1742
|
+
...extraCandidateDirs.map(candidate => normalizeRepoPath(candidate))
|
|
1743
|
+
]);
|
|
1744
|
+
|
|
1745
|
+
for (const candidateDir of candidateDirs) {
|
|
1746
|
+
const skillMdPath = candidateDir
|
|
1747
|
+
? path.join(repo.localPath, candidateDir, 'SKILL.md')
|
|
1748
|
+
: path.join(repo.localPath, 'SKILL.md');
|
|
1749
|
+
if (!fs.existsSync(skillMdPath)) continue;
|
|
1750
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
1751
|
+
return parseRemoteSkillContent(content, repo, candidateDir);
|
|
1752
|
+
}
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const treeItems = repo.provider === 'gitlab'
|
|
1757
|
+
? await this.fetchGitLabTree(repo)
|
|
1758
|
+
: await this.fetchGitHubRepoTree(repo);
|
|
1759
|
+
if (!treeItems?.length) return null;
|
|
1390
1760
|
|
|
1391
1761
|
const normalizedDirectory = normalizeRepoPath(directory);
|
|
1392
1762
|
const candidateDirs = new Set();
|
|
@@ -1403,44 +1773,59 @@ ${content}
|
|
|
1403
1773
|
|
|
1404
1774
|
let skillFile = null;
|
|
1405
1775
|
for (const candidateDir of candidateDirs) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1776
|
+
skillFile = treeItems.find(item =>
|
|
1777
|
+
item.type === 'blob' && (
|
|
1778
|
+
candidateDir
|
|
1779
|
+
? item.path === `${candidateDir}/SKILL.md`
|
|
1780
|
+
: item.path === 'SKILL.md'
|
|
1781
|
+
)
|
|
1409
1782
|
);
|
|
1410
1783
|
if (skillFile) break;
|
|
1411
1784
|
}
|
|
1412
1785
|
|
|
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
1786
|
if (!skillFile) return null;
|
|
1424
1787
|
|
|
1425
|
-
const content = await this.
|
|
1426
|
-
|
|
1788
|
+
const content = await this.fetchSkillFileContent(repo, skillFile);
|
|
1789
|
+
const fullDirectory = normalizeRepoPath(skillFile.path.replace(/(^|\/)SKILL\.md$/, ''));
|
|
1790
|
+
return parseRemoteSkillContent(content, repo, fullDirectory);
|
|
1427
1791
|
} catch (err) {
|
|
1428
1792
|
console.warn('[SkillService] Fetch remote skill detail error:', err.message);
|
|
1429
1793
|
return null;
|
|
1430
1794
|
}
|
|
1431
1795
|
};
|
|
1432
1796
|
|
|
1797
|
+
if (repoHint) {
|
|
1798
|
+
try {
|
|
1799
|
+
const normalizedRepoHint = this.normalizeRepoConfig(repoHint);
|
|
1800
|
+
const detail = await tryLoadRemoteDetailFromRepo(normalizedRepoHint, [
|
|
1801
|
+
fullDirectoryHint || '',
|
|
1802
|
+
repoHint.directory ? `${repoHint.directory}/${directory}` : '',
|
|
1803
|
+
repoHint.fullDirectory || ''
|
|
1804
|
+
]);
|
|
1805
|
+
if (detail) return detail;
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
console.warn('[SkillService] Invalid repo hint for detail:', err.message);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1433
1811
|
// 先尝试使用缓存中的 repo 信息(最快)
|
|
1434
|
-
const cachedSkill = this.skillsCache?.find(s =>
|
|
1435
|
-
|
|
1436
|
-
|
|
1812
|
+
const cachedSkill = this.skillsCache?.find(s =>
|
|
1813
|
+
normalizeRepoPath(s.directory) === normalizeRepoPath(directory)
|
|
1814
|
+
);
|
|
1815
|
+
if (cachedSkill && (cachedSkill.repoOwner || cachedSkill.repoProjectPath || cachedSkill.repoLocalPath)) {
|
|
1816
|
+
const cachedRepo = this.normalizeRepoConfig({
|
|
1817
|
+
provider: cachedSkill.repoProvider || (cachedSkill.repoLocalPath ? 'local' : (cachedSkill.repoProjectPath ? 'gitlab' : 'github')),
|
|
1437
1818
|
owner: cachedSkill.repoOwner,
|
|
1438
1819
|
name: cachedSkill.repoName,
|
|
1439
1820
|
branch: cachedSkill.repoBranch || 'main',
|
|
1440
|
-
directory: cachedSkill.repoDirectory || ''
|
|
1441
|
-
|
|
1442
|
-
|
|
1821
|
+
directory: cachedSkill.repoDirectory || '',
|
|
1822
|
+
host: cachedSkill.repoHost,
|
|
1823
|
+
projectPath: cachedSkill.repoProjectPath,
|
|
1824
|
+
localPath: cachedSkill.repoLocalPath,
|
|
1825
|
+
repoUrl: cachedSkill.repoUrl
|
|
1826
|
+
});
|
|
1443
1827
|
const detail = await tryLoadRemoteDetailFromRepo(cachedRepo, [
|
|
1828
|
+
fullDirectoryHint || '',
|
|
1444
1829
|
cachedSkill.fullDirectory || '',
|
|
1445
1830
|
cachedSkill.repoDirectory ? `${cachedSkill.repoDirectory}/${directory}` : ''
|
|
1446
1831
|
]);
|
|
@@ -1451,12 +1836,7 @@ ${content}
|
|
|
1451
1836
|
const repos = this.loadRepos().filter(repo => repo.enabled !== false);
|
|
1452
1837
|
for (const repo of repos) {
|
|
1453
1838
|
const detail = await tryLoadRemoteDetailFromRepo(
|
|
1454
|
-
|
|
1455
|
-
owner: repo.owner,
|
|
1456
|
-
name: repo.name,
|
|
1457
|
-
branch: repo.branch || 'main',
|
|
1458
|
-
directory: repo.directory || ''
|
|
1459
|
-
},
|
|
1839
|
+
repo,
|
|
1460
1840
|
[repo.directory ? `${repo.directory}/${directory}` : '']
|
|
1461
1841
|
);
|
|
1462
1842
|
if (detail) return detail;
|