coding-tool-x 3.3.7 → 3.3.9
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 +20 -0
- package/README.md +253 -326
- package/dist/web/assets/{Analytics-IW6eAy9u.js → Analytics-D6LzK9hk.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-BPtkTMSc.js → ConfigTemplates-BUDYuxRi.js} +1 -1
- package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
- package/dist/web/assets/Home-D7KX7iF8.js +1 -0
- package/dist/web/assets/{PluginManager-BGx9MSDV.js → PluginManager-DTgQ--vB.js} +1 -1
- package/dist/web/assets/{ProjectList-BCn-mrCx.js → ProjectList-DMCiGmCT.js} +1 -1
- package/dist/web/assets/{SessionList-CzLfebJQ.js → SessionList-CRBsdVRe.js} +1 -1
- package/dist/web/assets/{SkillManager-CXz2vBQx.js → SkillManager-DMwx2Q4k.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-CHtgMfKc.js → WorkspaceManager-DapB4ljL.js} +1 -1
- package/dist/web/assets/{icons-B29onFfZ.js → icons-B5Pl4lrD.js} +1 -1
- package/dist/web/assets/index-CL-qpoJ_.js +2 -0
- package/dist/web/assets/index-D_5dRFOL.css +1 -0
- package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
- package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
- package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
- package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
- package/dist/web/index.html +7 -7
- package/docs/home.png +0 -0
- package/package.json +14 -5
- package/src/commands/daemon.js +3 -2
- package/src/commands/security.js +1 -2
- package/src/commands/toggle-proxy.js +100 -5
- package/src/config/paths.js +718 -90
- package/src/server/api/agents.js +1 -1
- package/src/server/api/channels.js +9 -0
- package/src/server/api/claude-hooks.js +13 -8
- package/src/server/api/codex-channels.js +9 -0
- package/src/server/api/codex-proxy.js +27 -15
- package/src/server/api/gemini-proxy.js +22 -11
- package/src/server/api/hooks.js +45 -0
- package/src/server/api/oauth-credentials.js +163 -0
- package/src/server/api/opencode-proxy.js +22 -10
- package/src/server/api/plugins.js +2 -1
- package/src/server/api/proxy.js +39 -44
- package/src/server/api/skills.js +91 -13
- package/src/server/api/ui-config.js +5 -0
- package/src/server/codex-proxy-server.js +90 -70
- package/src/server/gemini-proxy-server.js +107 -88
- package/src/server/index.js +2 -0
- package/src/server/opencode-proxy-server.js +381 -225
- package/src/server/proxy-server.js +86 -60
- package/src/server/services/alias.js +3 -3
- package/src/server/services/channels.js +21 -24
- package/src/server/services/codex-channels.js +158 -255
- package/src/server/services/codex-config.js +2 -5
- package/src/server/services/codex-env-manager.js +423 -0
- package/src/server/services/codex-settings-manager.js +21 -357
- package/src/server/services/codex-statistics-service.js +3 -27
- package/src/server/services/config-export-service.js +43 -9
- package/src/server/services/config-registry-service.js +3 -2
- package/src/server/services/config-sync-manager.js +1 -1
- package/src/server/services/favorites.js +4 -3
- package/src/server/services/gemini-channels.js +14 -12
- package/src/server/services/gemini-statistics-service.js +3 -25
- package/src/server/services/mcp-service.js +35 -19
- package/src/server/services/model-detector.js +4 -3
- package/src/server/services/native-keychain.js +243 -0
- package/src/server/services/native-oauth-adapters.js +891 -0
- package/src/server/services/network-access.js +39 -1
- package/src/server/services/notification-hooks.js +951 -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 +19 -15
- package/src/server/services/opencode-sessions.js +2 -2
- package/src/server/services/opencode-settings-manager.js +169 -16
- package/src/server/services/opencode-statistics-service.js +3 -27
- package/src/server/services/plugins-service.js +115 -15
- package/src/server/services/prompts-service.js +2 -3
- package/src/server/services/proxy-log-helper.js +242 -0
- package/src/server/services/proxy-runtime.js +6 -4
- package/src/server/services/repo-scanner-base.js +12 -4
- package/src/server/services/request-logger.js +7 -7
- package/src/server/services/security-config.js +4 -4
- package/src/server/services/session-cache.js +2 -2
- package/src/server/services/sessions.js +2 -2
- package/src/server/services/settings-manager.js +13 -0
- package/src/server/services/skill-service.js +867 -368
- package/src/server/services/statistics-service.js +5 -5
- package/src/server/services/ui-config.js +4 -3
- package/src/server/services/workspace-service.js +1 -1
- package/src/server/websocket-server.js +5 -4
- package/dist/web/assets/Home-BsSioaaB.css +0 -1
- package/dist/web/assets/Home-obifg_9E.js +0 -1
- package/dist/web/assets/index-C7LPdVsN.js +0 -2
- package/dist/web/assets/index-eEmjZKWP.css +0 -1
- package/docs/bannel.png +0 -0
- package/docs/model-redirection.md +0 -251
|
@@ -10,16 +10,19 @@ const path = require('path');
|
|
|
10
10
|
const os = require('os');
|
|
11
11
|
const https = require('https');
|
|
12
12
|
const http = require('http');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
13
14
|
const { createWriteStream } = require('fs');
|
|
14
15
|
const { pipeline } = require('stream/promises');
|
|
15
16
|
const AdmZip = require('adm-zip');
|
|
16
17
|
const {
|
|
17
18
|
parseSkillContent,
|
|
18
19
|
} = require('./format-converter');
|
|
19
|
-
const { NATIVE_PATHS, HOME_DIR } = require('../../config/paths');
|
|
20
|
+
const { NATIVE_PATHS, HOME_DIR, PATHS } = require('../../config/paths');
|
|
20
21
|
|
|
21
22
|
const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
|
|
22
|
-
const
|
|
23
|
+
const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
|
|
24
|
+
const DEFAULT_GITHUB_HOST = 'https://github.com';
|
|
25
|
+
const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
|
|
23
26
|
|
|
24
27
|
function normalizePlatform(platform) {
|
|
25
28
|
return SUPPORTED_PLATFORMS.includes(platform) ? platform : 'claude';
|
|
@@ -29,6 +32,126 @@ function cloneRepos(repos = []) {
|
|
|
29
32
|
return repos.map(repo => ({ ...repo }));
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
function normalizeRepoPath(input = '') {
|
|
36
|
+
return String(input || '')
|
|
37
|
+
.replace(/\\/g, '/')
|
|
38
|
+
.replace(/^\/+/, '')
|
|
39
|
+
.replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeRepoDirectory(directory = '') {
|
|
43
|
+
return normalizeRepoPath(directory);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stripGitSuffix(value = '') {
|
|
47
|
+
return String(value || '').replace(/\.git$/i, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isWindowsAbsolutePath(input = '') {
|
|
51
|
+
return /^[a-zA-Z]:[\\/]/.test(String(input || ''));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLikelyLocalPath(input = '') {
|
|
55
|
+
const normalized = String(input || '').trim();
|
|
56
|
+
if (!normalized) return false;
|
|
57
|
+
return (
|
|
58
|
+
normalized.startsWith('/') ||
|
|
59
|
+
normalized.startsWith('~/') ||
|
|
60
|
+
normalized.startsWith('./') ||
|
|
61
|
+
normalized.startsWith('../') ||
|
|
62
|
+
normalized.startsWith('file://') ||
|
|
63
|
+
isWindowsAbsolutePath(normalized)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expandHomePath(input = '') {
|
|
68
|
+
const normalized = String(input || '').trim();
|
|
69
|
+
if (!normalized) return '';
|
|
70
|
+
if (normalized.startsWith('~/')) {
|
|
71
|
+
return path.join(HOME_DIR, normalized.slice(2));
|
|
72
|
+
}
|
|
73
|
+
if (normalized === '~') {
|
|
74
|
+
return HOME_DIR;
|
|
75
|
+
}
|
|
76
|
+
if (normalized.startsWith('file://')) {
|
|
77
|
+
try {
|
|
78
|
+
return decodeURIComponent(new URL(normalized).pathname);
|
|
79
|
+
} catch {
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveLocalRepoPath(input = '') {
|
|
87
|
+
const expanded = expandHomePath(input);
|
|
88
|
+
if (!expanded) return '';
|
|
89
|
+
return path.resolve(expanded);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeRepoHost(host, provider = 'github') {
|
|
93
|
+
const fallback = provider === 'gitlab' ? DEFAULT_GITLAB_HOST : DEFAULT_GITHUB_HOST;
|
|
94
|
+
let normalized = String(host || '').trim();
|
|
95
|
+
if (!normalized) {
|
|
96
|
+
normalized = fallback;
|
|
97
|
+
}
|
|
98
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
99
|
+
normalized = `https://${normalized}`;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(normalized);
|
|
103
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
104
|
+
} catch {
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractHostname(host = '') {
|
|
110
|
+
const normalized = String(host || '').trim();
|
|
111
|
+
if (!normalized) return '';
|
|
112
|
+
try {
|
|
113
|
+
return new URL(normalized).hostname || '';
|
|
114
|
+
} catch {
|
|
115
|
+
return normalized.replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildRepoUrl(repo) {
|
|
120
|
+
if (repo.provider === 'local') {
|
|
121
|
+
return repo.localPath || '';
|
|
122
|
+
}
|
|
123
|
+
if (repo.provider === 'gitlab') {
|
|
124
|
+
return `${repo.host}/${repo.projectPath}`;
|
|
125
|
+
}
|
|
126
|
+
return `${repo.host}/${repo.owner}/${repo.name}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildRepoLabel(repo) {
|
|
130
|
+
if (repo.provider === 'local') {
|
|
131
|
+
return repo.localPath || '';
|
|
132
|
+
}
|
|
133
|
+
if (repo.provider === 'gitlab') {
|
|
134
|
+
return repo.projectPath || '';
|
|
135
|
+
}
|
|
136
|
+
return [repo.owner, repo.name].filter(Boolean).join('/');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildRepoId(repo) {
|
|
140
|
+
const directory = normalizeRepoDirectory(repo.directory);
|
|
141
|
+
const branch = String(repo.branch || 'main').trim() || 'main';
|
|
142
|
+
if (repo.provider === 'local') {
|
|
143
|
+
return `local:${repo.localPath}::${directory}`;
|
|
144
|
+
}
|
|
145
|
+
if (repo.provider === 'gitlab') {
|
|
146
|
+
return `gitlab:${repo.host}::${repo.projectPath}::${branch}::${directory}`;
|
|
147
|
+
}
|
|
148
|
+
return `github:${repo.host}::${repo.owner}/${repo.name}::${branch}::${directory}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isRootSkillFile(filePath = '') {
|
|
152
|
+
return filePath === 'SKILL.md' || filePath.endsWith('/SKILL.md');
|
|
153
|
+
}
|
|
154
|
+
|
|
32
155
|
const DEFAULT_REPOS_BY_PLATFORM = {
|
|
33
156
|
claude: [
|
|
34
157
|
{ owner: 'anthropics', name: 'skills', branch: 'main', directory: '', enabled: true }
|
|
@@ -47,23 +170,27 @@ const DEFAULT_REPOS_BY_PLATFORM = {
|
|
|
47
170
|
const PLATFORM_CONFIG = {
|
|
48
171
|
claude: {
|
|
49
172
|
installDir: path.join(HOME_DIR, '.claude', 'skills'),
|
|
50
|
-
|
|
51
|
-
|
|
173
|
+
storageDir: PATHS.localSkills.claude,
|
|
174
|
+
reposFile: PATHS.skillRepos.claude,
|
|
175
|
+
cacheFile: PATHS.skillCaches.claude
|
|
52
176
|
},
|
|
53
177
|
codex: {
|
|
54
178
|
installDir: path.join(HOME_DIR, '.codex', 'skills'),
|
|
55
|
-
|
|
56
|
-
|
|
179
|
+
storageDir: PATHS.localSkills.codex,
|
|
180
|
+
reposFile: PATHS.skillRepos.codex,
|
|
181
|
+
cacheFile: PATHS.skillCaches.codex
|
|
57
182
|
},
|
|
58
183
|
gemini: {
|
|
59
184
|
installDir: path.join(HOME_DIR, '.gemini', 'skills'),
|
|
60
|
-
|
|
61
|
-
|
|
185
|
+
storageDir: PATHS.localSkills.gemini,
|
|
186
|
+
reposFile: PATHS.skillRepos.gemini,
|
|
187
|
+
cacheFile: PATHS.skillCaches.gemini
|
|
62
188
|
},
|
|
63
189
|
opencode: {
|
|
64
190
|
installDir: path.join(NATIVE_PATHS.opencode.config, 'skills'),
|
|
65
|
-
|
|
66
|
-
|
|
191
|
+
storageDir: PATHS.localSkills.opencode,
|
|
192
|
+
reposFile: PATHS.skillRepos.opencode,
|
|
193
|
+
cacheFile: PATHS.skillCaches.opencode
|
|
67
194
|
}
|
|
68
195
|
};
|
|
69
196
|
|
|
@@ -73,12 +200,13 @@ const CACHE_TTL = 5 * 60 * 1000;
|
|
|
73
200
|
class SkillService {
|
|
74
201
|
constructor(platform = 'claude') {
|
|
75
202
|
this.platform = normalizePlatform(platform);
|
|
76
|
-
this.configDir =
|
|
203
|
+
this.configDir = PATHS.config;
|
|
77
204
|
|
|
78
205
|
const platformConfig = PLATFORM_CONFIG[this.platform];
|
|
79
206
|
this.installDir = platformConfig.installDir;
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
207
|
+
this.storageDir = platformConfig.storageDir;
|
|
208
|
+
this.reposConfigPath = platformConfig.reposFile;
|
|
209
|
+
this.cachePath = platformConfig.cacheFile;
|
|
82
210
|
|
|
83
211
|
// 内存缓存
|
|
84
212
|
this.skillsCache = null;
|
|
@@ -95,6 +223,122 @@ class SkillService {
|
|
|
95
223
|
if (!fs.existsSync(this.configDir)) {
|
|
96
224
|
fs.mkdirSync(this.configDir, { recursive: true });
|
|
97
225
|
}
|
|
226
|
+
if (!fs.existsSync(this.storageDir)) {
|
|
227
|
+
fs.mkdirSync(this.storageDir, { recursive: true });
|
|
228
|
+
}
|
|
229
|
+
const reposDir = path.dirname(this.reposConfigPath);
|
|
230
|
+
if (!fs.existsSync(reposDir)) {
|
|
231
|
+
fs.mkdirSync(reposDir, { recursive: true });
|
|
232
|
+
}
|
|
233
|
+
const cacheDir = path.dirname(this.cachePath);
|
|
234
|
+
if (!fs.existsSync(cacheDir)) {
|
|
235
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
clearCache({ removeFile = false } = {}) {
|
|
240
|
+
this.skillsCache = null;
|
|
241
|
+
this.cacheTime = 0;
|
|
242
|
+
|
|
243
|
+
if (removeFile) {
|
|
244
|
+
try {
|
|
245
|
+
if (fs.existsSync(this.cachePath)) {
|
|
246
|
+
fs.unlinkSync(this.cachePath);
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.warn('[SkillService] Failed to delete cache file:', err.message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
prepareSkills(skills = []) {
|
|
255
|
+
const preparedSkills = Array.isArray(skills)
|
|
256
|
+
? skills.map(skill => ({ ...skill }))
|
|
257
|
+
: [];
|
|
258
|
+
|
|
259
|
+
this.mergeLocalSkills(preparedSkills);
|
|
260
|
+
this.deduplicateSkills(preparedSkills);
|
|
261
|
+
preparedSkills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
262
|
+
this.updateInstallStatus(preparedSkills);
|
|
263
|
+
|
|
264
|
+
return preparedSkills;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getDefaultSkillDirectory(repo) {
|
|
268
|
+
if (repo.provider === 'local') {
|
|
269
|
+
return path.basename(repo.localPath || '') || 'skill';
|
|
270
|
+
}
|
|
271
|
+
if (repo.provider === 'gitlab') {
|
|
272
|
+
const projectPath = normalizeRepoPath(repo.projectPath);
|
|
273
|
+
return projectPath.split('/').pop() || 'skill';
|
|
274
|
+
}
|
|
275
|
+
return repo.name || 'skill';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
resolveSkillDirectory(fullDirectory, baseDir, repo) {
|
|
279
|
+
const normalizedFullDirectory = normalizeRepoPath(fullDirectory);
|
|
280
|
+
const normalizedBaseDir = normalizeRepoDirectory(baseDir);
|
|
281
|
+
|
|
282
|
+
if (normalizedBaseDir) {
|
|
283
|
+
if (normalizedFullDirectory === normalizedBaseDir) {
|
|
284
|
+
return normalizeRepoPath(path.basename(normalizedBaseDir)) || this.getDefaultSkillDirectory(repo);
|
|
285
|
+
}
|
|
286
|
+
if (normalizedFullDirectory.startsWith(`${normalizedBaseDir}/`)) {
|
|
287
|
+
return normalizedFullDirectory.slice(normalizedBaseDir.length + 1);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!normalizedFullDirectory) {
|
|
292
|
+
return this.getDefaultSkillDirectory(repo);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return normalizedFullDirectory;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
normalizeRepoConfig(repo = {}) {
|
|
299
|
+
const provider = SUPPORTED_REPO_PROVIDERS.includes(repo.provider)
|
|
300
|
+
? repo.provider
|
|
301
|
+
: (repo.localPath ? 'local' : (repo.projectPath ? 'gitlab' : 'github'));
|
|
302
|
+
|
|
303
|
+
const normalized = {
|
|
304
|
+
provider,
|
|
305
|
+
branch: String(repo.branch || 'main').trim() || 'main',
|
|
306
|
+
directory: normalizeRepoDirectory(repo.directory),
|
|
307
|
+
enabled: repo.enabled !== false
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
if (provider === 'local') {
|
|
311
|
+
normalized.localPath = resolveLocalRepoPath(repo.localPath || repo.path || repo.url || '');
|
|
312
|
+
if (!normalized.localPath) {
|
|
313
|
+
throw new Error('Missing local repository path');
|
|
314
|
+
}
|
|
315
|
+
normalized.name = path.basename(normalized.localPath) || 'local-repo';
|
|
316
|
+
} else if (provider === 'gitlab') {
|
|
317
|
+
normalized.host = normalizeRepoHost(repo.host, 'gitlab');
|
|
318
|
+
normalized.projectPath = normalizeRepoPath(repo.projectPath || [repo.owner, repo.name].filter(Boolean).join('/'));
|
|
319
|
+
if (!normalized.projectPath) {
|
|
320
|
+
throw new Error('Missing GitLab project path');
|
|
321
|
+
}
|
|
322
|
+
normalized.name = stripGitSuffix(normalized.projectPath.split('/').pop() || '');
|
|
323
|
+
normalized.owner = normalized.projectPath.split('/')[0] || '';
|
|
324
|
+
} else {
|
|
325
|
+
normalized.host = normalizeRepoHost(repo.host, 'github');
|
|
326
|
+
normalized.owner = String(repo.owner || '').trim();
|
|
327
|
+
normalized.name = stripGitSuffix(repo.name || '');
|
|
328
|
+
if (!normalized.owner || !normalized.name) {
|
|
329
|
+
throw new Error('Missing GitHub repo info');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
normalized.repoUrl = repo.repoUrl || buildRepoUrl(normalized);
|
|
334
|
+
normalized.label = buildRepoLabel(normalized);
|
|
335
|
+
normalized.id = buildRepoId(normalized);
|
|
336
|
+
|
|
337
|
+
return normalized;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
normalizeRepos(repos = []) {
|
|
341
|
+
return repos.map(repo => this.normalizeRepoConfig(repo));
|
|
98
342
|
}
|
|
99
343
|
|
|
100
344
|
/**
|
|
@@ -105,20 +349,21 @@ class SkillService {
|
|
|
105
349
|
if (fs.existsSync(this.reposConfigPath)) {
|
|
106
350
|
const data = JSON.parse(fs.readFileSync(this.reposConfigPath, 'utf-8'));
|
|
107
351
|
if (Array.isArray(data.repos)) {
|
|
108
|
-
return data.repos;
|
|
352
|
+
return this.normalizeRepos(data.repos);
|
|
109
353
|
}
|
|
110
354
|
}
|
|
111
355
|
} catch (err) {
|
|
112
356
|
console.error('[SkillService] Load repos config error:', err.message);
|
|
113
357
|
}
|
|
114
|
-
return cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude);
|
|
358
|
+
return this.normalizeRepos(cloneRepos(DEFAULT_REPOS_BY_PLATFORM[this.platform] || DEFAULT_REPOS_BY_PLATFORM.claude));
|
|
115
359
|
}
|
|
116
360
|
|
|
117
361
|
/**
|
|
118
362
|
* 保存仓库配置
|
|
119
363
|
*/
|
|
120
364
|
saveRepos(repos) {
|
|
121
|
-
|
|
365
|
+
const normalizedRepos = this.normalizeRepos(repos);
|
|
366
|
+
fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos: normalizedRepos }, null, 2));
|
|
122
367
|
}
|
|
123
368
|
|
|
124
369
|
/**
|
|
@@ -132,24 +377,18 @@ class SkillService {
|
|
|
132
377
|
*/
|
|
133
378
|
addRepo(repo) {
|
|
134
379
|
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
|
-
);
|
|
380
|
+
const normalizedRepo = this.normalizeRepoConfig(repo);
|
|
381
|
+
const existingIndex = repos.findIndex(r => r.id === normalizedRepo.id);
|
|
141
382
|
|
|
142
383
|
if (existingIndex >= 0) {
|
|
143
|
-
repos[existingIndex] =
|
|
384
|
+
repos[existingIndex] = normalizedRepo;
|
|
144
385
|
} else {
|
|
145
|
-
repos.push(
|
|
386
|
+
repos.push(normalizedRepo);
|
|
146
387
|
}
|
|
147
388
|
|
|
148
389
|
this.saveRepos(repos);
|
|
149
|
-
|
|
150
|
-
this.
|
|
151
|
-
this.cacheTime = 0;
|
|
152
|
-
return repos;
|
|
390
|
+
this.clearCache({ removeFile: true });
|
|
391
|
+
return this.loadRepos();
|
|
153
392
|
}
|
|
154
393
|
|
|
155
394
|
/**
|
|
@@ -158,18 +397,22 @@ class SkillService {
|
|
|
158
397
|
* @param {string} name - 仓库名称
|
|
159
398
|
* @param {string} [directory=''] - 子目录路径
|
|
160
399
|
*/
|
|
161
|
-
removeRepo(owner, name, directory = '') {
|
|
400
|
+
removeRepo(owner, name, directory = '', repoId = '') {
|
|
162
401
|
const repos = this.loadRepos();
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
402
|
+
const normalizedDirectory = normalizeRepoDirectory(directory);
|
|
403
|
+
const filtered = repos.filter(r => {
|
|
404
|
+
if (repoId) {
|
|
405
|
+
return r.id !== repoId;
|
|
406
|
+
}
|
|
407
|
+
return !(
|
|
408
|
+
(r.owner || '') === owner &&
|
|
409
|
+
(r.name || '') === name &&
|
|
410
|
+
normalizeRepoDirectory(r.directory) === normalizedDirectory
|
|
411
|
+
);
|
|
412
|
+
});
|
|
168
413
|
this.saveRepos(filtered);
|
|
169
|
-
|
|
170
|
-
this.
|
|
171
|
-
this.cacheTime = 0;
|
|
172
|
-
return filtered;
|
|
414
|
+
this.clearCache({ removeFile: true });
|
|
415
|
+
return this.loadRepos();
|
|
173
416
|
}
|
|
174
417
|
|
|
175
418
|
/**
|
|
@@ -179,54 +422,55 @@ class SkillService {
|
|
|
179
422
|
* @param {string} [directory=''] - 子目录路径
|
|
180
423
|
* @param {boolean} enabled - 是否启用
|
|
181
424
|
*/
|
|
182
|
-
toggleRepo(owner, name, directory = '', enabled) {
|
|
425
|
+
toggleRepo(owner, name, directory = '', enabled, repoId = '') {
|
|
183
426
|
const repos = this.loadRepos();
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
427
|
+
const normalizedDirectory = normalizeRepoDirectory(directory);
|
|
428
|
+
const repo = repos.find(r => {
|
|
429
|
+
if (repoId) {
|
|
430
|
+
return r.id === repoId;
|
|
431
|
+
}
|
|
432
|
+
return (
|
|
433
|
+
(r.owner || '') === owner &&
|
|
434
|
+
(r.name || '') === name &&
|
|
435
|
+
normalizeRepoDirectory(r.directory) === normalizedDirectory
|
|
436
|
+
);
|
|
437
|
+
});
|
|
189
438
|
if (repo) {
|
|
190
439
|
repo.enabled = enabled;
|
|
191
440
|
this.saveRepos(repos);
|
|
192
|
-
|
|
193
|
-
this.skillsCache = null;
|
|
194
|
-
this.cacheTime = 0;
|
|
441
|
+
this.clearCache({ removeFile: true });
|
|
195
442
|
}
|
|
196
|
-
return
|
|
443
|
+
return this.loadRepos();
|
|
197
444
|
}
|
|
198
445
|
|
|
199
446
|
/**
|
|
200
447
|
* 获取所有技能列表(带缓存)
|
|
201
448
|
*/
|
|
202
449
|
async listSkills(forceRefresh = false) {
|
|
203
|
-
//
|
|
450
|
+
// 强制刷新时仅清空内存缓存,保留磁盘缓存作为回退来源
|
|
204
451
|
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
|
-
}
|
|
452
|
+
this.clearCache();
|
|
215
453
|
}
|
|
216
454
|
|
|
455
|
+
const fileCache = this.loadCacheFromFile();
|
|
456
|
+
|
|
217
457
|
// 检查内存缓存
|
|
218
|
-
if (!forceRefresh && this.skillsCache &&
|
|
219
|
-
|
|
458
|
+
if (!forceRefresh && Array.isArray(this.skillsCache) && this.skillsCache.length > 0) {
|
|
459
|
+
if (Array.isArray(fileCache) && fileCache.length > this.skillsCache.length) {
|
|
460
|
+
this.skillsCache = this.prepareSkills(fileCache);
|
|
461
|
+
this.cacheTime = Date.now();
|
|
462
|
+
return this.skillsCache;
|
|
463
|
+
}
|
|
464
|
+
this.skillsCache = this.prepareSkills(this.skillsCache);
|
|
465
|
+
this.cacheTime = Date.now();
|
|
220
466
|
return this.skillsCache;
|
|
221
467
|
}
|
|
222
468
|
|
|
223
469
|
// 检查文件缓存
|
|
224
470
|
if (!forceRefresh) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
this.skillsCache = fileCache;
|
|
471
|
+
if (fileCache && fileCache.length > 0) {
|
|
472
|
+
this.skillsCache = this.prepareSkills(fileCache);
|
|
228
473
|
this.cacheTime = Date.now();
|
|
229
|
-
this.updateInstallStatus(this.skillsCache);
|
|
230
474
|
return this.skillsCache;
|
|
231
475
|
}
|
|
232
476
|
}
|
|
@@ -236,6 +480,8 @@ class SkillService {
|
|
|
236
480
|
|
|
237
481
|
// 并行获取所有启用仓库的技能(带超时保护)
|
|
238
482
|
const enabledRepos = repos.filter(r => r.enabled);
|
|
483
|
+
const enabledRemoteRepos = enabledRepos.filter(repo => repo.provider !== 'local');
|
|
484
|
+
let remoteFailureCount = 0;
|
|
239
485
|
|
|
240
486
|
if (enabledRepos.length > 0) {
|
|
241
487
|
const results = await Promise.allSettled(
|
|
@@ -251,28 +497,40 @@ class SkillService {
|
|
|
251
497
|
|
|
252
498
|
for (let i = 0; i < results.length; i++) {
|
|
253
499
|
const result = results[i];
|
|
254
|
-
const
|
|
500
|
+
const repo = enabledRepos[i];
|
|
501
|
+
const repoInfo = `${repo.owner}/${repo.name}`;
|
|
255
502
|
if (result.status === 'fulfilled') {
|
|
256
503
|
skills.push(...result.value);
|
|
257
504
|
} else {
|
|
258
505
|
console.warn(`[SkillService] Fetch repo ${repoInfo} failed:`, result.reason?.message);
|
|
506
|
+
if (repo.provider !== 'local') {
|
|
507
|
+
remoteFailureCount++;
|
|
508
|
+
}
|
|
259
509
|
}
|
|
260
510
|
}
|
|
261
511
|
}
|
|
262
512
|
|
|
263
|
-
|
|
264
|
-
|
|
513
|
+
const preparedSkills = this.prepareSkills(skills);
|
|
514
|
+
|
|
515
|
+
const hasUsableFileCache = Array.isArray(fileCache) && fileCache.length > 0;
|
|
516
|
+
const preparedFileCache = hasUsableFileCache ? this.prepareSkills(fileCache) : null;
|
|
517
|
+
const shouldUseStaleFileCache = hasUsableFileCache && (
|
|
518
|
+
(enabledRemoteRepos.length > 0 && remoteFailureCount === enabledRemoteRepos.length) ||
|
|
519
|
+
(remoteFailureCount > 0 && preparedFileCache.length > preparedSkills.length)
|
|
520
|
+
);
|
|
265
521
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
522
|
+
if (shouldUseStaleFileCache) {
|
|
523
|
+
this.skillsCache = preparedFileCache;
|
|
524
|
+
this.cacheTime = Date.now();
|
|
525
|
+
return this.skillsCache;
|
|
526
|
+
}
|
|
269
527
|
|
|
270
528
|
// 更新缓存
|
|
271
|
-
this.skillsCache =
|
|
529
|
+
this.skillsCache = preparedSkills;
|
|
272
530
|
this.cacheTime = Date.now();
|
|
273
|
-
this.saveCacheToFile(
|
|
531
|
+
this.saveCacheToFile(preparedSkills);
|
|
274
532
|
|
|
275
|
-
return
|
|
533
|
+
return preparedSkills;
|
|
276
534
|
}
|
|
277
535
|
|
|
278
536
|
/**
|
|
@@ -282,7 +540,7 @@ class SkillService {
|
|
|
282
540
|
try {
|
|
283
541
|
if (fs.existsSync(this.cachePath)) {
|
|
284
542
|
const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
|
|
285
|
-
if (
|
|
543
|
+
if (Array.isArray(data.skills)) {
|
|
286
544
|
return data.skills;
|
|
287
545
|
}
|
|
288
546
|
}
|
|
@@ -320,14 +578,24 @@ class SkillService {
|
|
|
320
578
|
* 支持指定子目录扫描
|
|
321
579
|
*/
|
|
322
580
|
async fetchRepoSkills(repo) {
|
|
581
|
+
if (repo.provider === 'local') {
|
|
582
|
+
return this.fetchLocalRepoSkills(repo);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (repo.provider === 'gitlab') {
|
|
586
|
+
return this.fetchGitLabRepoSkills(repo);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return this.fetchGitHubRepoSkills(repo);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async fetchGitHubRepoSkills(repo) {
|
|
323
593
|
const skills = [];
|
|
324
594
|
|
|
325
595
|
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);
|
|
596
|
+
const treeItems = await this.fetchGitHubRepoTree(repo);
|
|
329
597
|
|
|
330
|
-
if (!
|
|
598
|
+
if (!treeItems.length) {
|
|
331
599
|
console.warn(`[SkillService] Empty tree for ${repo.owner}/${repo.name}`);
|
|
332
600
|
return skills;
|
|
333
601
|
}
|
|
@@ -336,12 +604,10 @@ class SkillService {
|
|
|
336
604
|
const baseDir = repo.directory || '';
|
|
337
605
|
const baseDirPrefix = baseDir ? `${baseDir}/` : '';
|
|
338
606
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (item.type !== 'blob' || !item.path.endsWith('/SKILL.md')) {
|
|
607
|
+
const skillFiles = treeItems.filter(item => {
|
|
608
|
+
if (item.type !== 'blob' || !isRootSkillFile(item.path)) {
|
|
342
609
|
return false;
|
|
343
610
|
}
|
|
344
|
-
// 如果配置了子目录,只返回该子目录下的文件
|
|
345
611
|
if (baseDir && !item.path.startsWith(baseDirPrefix)) {
|
|
346
612
|
return false;
|
|
347
613
|
}
|
|
@@ -350,6 +616,8 @@ class SkillService {
|
|
|
350
616
|
|
|
351
617
|
// 并行获取所有 SKILL.md 的内容(限制并发数)
|
|
352
618
|
const batchSize = 5;
|
|
619
|
+
let successCount = 0;
|
|
620
|
+
let failCount = 0;
|
|
353
621
|
|
|
354
622
|
for (let i = 0; i < skillFiles.length; i += batchSize) {
|
|
355
623
|
const batch = skillFiles.slice(i, i + batchSize);
|
|
@@ -360,9 +628,14 @@ class SkillService {
|
|
|
360
628
|
for (const result of results) {
|
|
361
629
|
if (result.status === 'fulfilled' && result.value) {
|
|
362
630
|
skills.push(result.value);
|
|
631
|
+
successCount++;
|
|
632
|
+
} else {
|
|
633
|
+
failCount++;
|
|
363
634
|
}
|
|
364
635
|
}
|
|
365
636
|
}
|
|
637
|
+
|
|
638
|
+
console.log(`[SkillService] ${repo.owner}/${repo.name}: ${successCount} skills loaded, ${failCount} failed`);
|
|
366
639
|
} catch (err) {
|
|
367
640
|
console.error(`[SkillService] Fetch repo ${repo.owner}/${repo.name} error:`, err.message);
|
|
368
641
|
throw err;
|
|
@@ -371,6 +644,67 @@ class SkillService {
|
|
|
371
644
|
return skills;
|
|
372
645
|
}
|
|
373
646
|
|
|
647
|
+
async fetchGitLabRepoSkills(repo) {
|
|
648
|
+
const skills = [];
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const tree = await this.fetchGitLabTree(repo);
|
|
652
|
+
const baseDir = repo.directory || '';
|
|
653
|
+
const baseDirPrefix = baseDir ? `${baseDir}/` : '';
|
|
654
|
+
const skillFiles = tree.filter(item => {
|
|
655
|
+
if (item.type !== 'blob' || !isRootSkillFile(item.path)) {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
if (baseDir && !item.path.startsWith(baseDirPrefix)) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
return true;
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const batchSize = 5;
|
|
665
|
+
let successCount = 0;
|
|
666
|
+
let failCount = 0;
|
|
667
|
+
|
|
668
|
+
for (let i = 0; i < skillFiles.length; i += batchSize) {
|
|
669
|
+
const batch = skillFiles.slice(i, i + batchSize);
|
|
670
|
+
const results = await Promise.allSettled(
|
|
671
|
+
batch.map(file => this.fetchAndParseSkill(file, repo, baseDir))
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
for (const result of results) {
|
|
675
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
676
|
+
skills.push(result.value);
|
|
677
|
+
successCount++;
|
|
678
|
+
} else {
|
|
679
|
+
failCount++;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
console.log(`[SkillService] ${repo.projectPath}: ${successCount} skills loaded, ${failCount} failed`);
|
|
685
|
+
} catch (err) {
|
|
686
|
+
console.error(`[SkillService] Fetch GitLab repo ${repo.projectPath} error:`, err.message);
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return skills;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async fetchLocalRepoSkills(repo) {
|
|
694
|
+
const skills = [];
|
|
695
|
+
const repoRoot = repo.localPath;
|
|
696
|
+
const scanRoot = repo.directory
|
|
697
|
+
? path.join(repoRoot, repo.directory)
|
|
698
|
+
: repoRoot;
|
|
699
|
+
|
|
700
|
+
if (!fs.existsSync(scanRoot)) {
|
|
701
|
+
throw new Error(`Local repo path not found: ${scanRoot}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
this.scanRepoLocalDir(scanRoot, repoRoot, skills, repo);
|
|
705
|
+
return skills;
|
|
706
|
+
}
|
|
707
|
+
|
|
374
708
|
/**
|
|
375
709
|
* 获取并解析单个 SKILL.md
|
|
376
710
|
* @param {Object} file - GitHub tree 文件对象
|
|
@@ -379,104 +713,130 @@ class SkillService {
|
|
|
379
713
|
*/
|
|
380
714
|
async fetchAndParseSkill(file, repo, baseDir = '') {
|
|
381
715
|
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);
|
|
716
|
+
const fullDirectory = normalizeRepoPath(file.path.replace(/(^|\/)SKILL\.md$/, ''));
|
|
717
|
+
const directory = this.resolveSkillDirectory(fullDirectory, baseDir, repo);
|
|
718
|
+
const content = await this.fetchSkillFileContent(repo, file);
|
|
390
719
|
const metadata = this.parseSkillMd(content);
|
|
391
720
|
|
|
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
|
-
};
|
|
721
|
+
return this.createSkillListItem({
|
|
722
|
+
metadata,
|
|
723
|
+
repo,
|
|
724
|
+
directory,
|
|
725
|
+
fullDirectory
|
|
726
|
+
});
|
|
406
727
|
} catch (err) {
|
|
407
|
-
console.warn(`[SkillService] Parse skill ${file.path} error:`, err.message);
|
|
408
728
|
return null;
|
|
409
729
|
}
|
|
410
730
|
}
|
|
411
731
|
|
|
412
732
|
/**
|
|
413
|
-
*
|
|
733
|
+
* 递归扫描外部本地仓库目录
|
|
414
734
|
*/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
|
|
735
|
+
scanRepoLocalDir(currentDir, repoRoot, skills, repo) {
|
|
736
|
+
const skillMdPath = path.join(currentDir, 'SKILL.md');
|
|
418
737
|
|
|
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
|
-
}
|
|
738
|
+
if (fs.existsSync(skillMdPath)) {
|
|
739
|
+
try {
|
|
740
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
741
|
+
const metadata = this.parseSkillMd(content);
|
|
742
|
+
const fullDirectory = normalizeRepoPath(path.relative(repoRoot, currentDir));
|
|
743
|
+
const directory = this.resolveSkillDirectory(fullDirectory, repo.directory || '', repo);
|
|
744
|
+
|
|
745
|
+
skills.push(this.createSkillListItem({
|
|
746
|
+
metadata,
|
|
747
|
+
repo,
|
|
748
|
+
directory,
|
|
749
|
+
fullDirectory
|
|
750
|
+
}));
|
|
751
|
+
} catch (err) {
|
|
752
|
+
console.warn(`[SkillService] Parse local repo skill ${currentDir} error:`, err.message);
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
447
756
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
757
|
+
try {
|
|
758
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
759
|
+
for (const entry of entries) {
|
|
760
|
+
if (!entry.isDirectory()) continue;
|
|
761
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
762
|
+
this.scanRepoLocalDir(path.join(currentDir, entry.name), repoRoot, skills, repo);
|
|
763
|
+
}
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.warn(`[SkillService] Scan local repo ${currentDir} error:`, err.message);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
458
768
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
769
|
+
createSkillListItem({ metadata, repo, directory, fullDirectory }) {
|
|
770
|
+
const repoDirectory = normalizeRepoDirectory(repo.directory);
|
|
771
|
+
const labelFallback = directory.split('/').pop() || this.getDefaultSkillDirectory(repo);
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
key: `${repo.id}:${fullDirectory || directory}`,
|
|
775
|
+
name: metadata.name || labelFallback,
|
|
776
|
+
description: metadata.description || '',
|
|
777
|
+
directory,
|
|
778
|
+
fullDirectory,
|
|
779
|
+
installed: this.isInstalled(directory),
|
|
780
|
+
readmeUrl: this.buildSkillReadmeUrl(repo, fullDirectory),
|
|
781
|
+
repoProvider: repo.provider,
|
|
782
|
+
repoOwner: repo.owner || null,
|
|
783
|
+
repoName: repo.name || null,
|
|
784
|
+
repoBranch: repo.branch,
|
|
785
|
+
repoDirectory,
|
|
786
|
+
repoHost: repo.host || null,
|
|
787
|
+
repoProjectPath: repo.projectPath || null,
|
|
788
|
+
repoLocalPath: repo.localPath || null,
|
|
789
|
+
repoId: repo.id,
|
|
790
|
+
repoUrl: repo.repoUrl || buildRepoUrl(repo),
|
|
791
|
+
source: repo.provider === 'local' ? 'local-repo' : repo.provider,
|
|
792
|
+
license: metadata.license
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
buildSkillReadmeUrl(repo, fullDirectory = '') {
|
|
797
|
+
const normalizedDirectory = normalizeRepoPath(fullDirectory);
|
|
798
|
+
if (repo.provider === 'local') {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
if (repo.provider === 'gitlab') {
|
|
802
|
+
const suffix = normalizedDirectory ? `/-/tree/${repo.branch}/${normalizedDirectory}` : `/-/tree/${repo.branch}`;
|
|
803
|
+
return `${repo.host}/${repo.projectPath}${suffix}`;
|
|
804
|
+
}
|
|
805
|
+
const suffix = normalizedDirectory ? `tree/${repo.branch}/${normalizedDirectory}` : `tree/${repo.branch}`;
|
|
806
|
+
return `${repo.host}/${repo.owner}/${repo.name}/${suffix}`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async fetchSkillFileContent(repo, file) {
|
|
810
|
+
if (repo.provider === 'gitlab') {
|
|
811
|
+
return this.fetchGitLabFileContent(repo, file.path);
|
|
812
|
+
}
|
|
813
|
+
if (repo.provider === 'local') {
|
|
814
|
+
const localFilePath = path.join(repo.localPath, file.path);
|
|
815
|
+
return fs.readFileSync(localFilePath, 'utf-8');
|
|
816
|
+
}
|
|
817
|
+
return this.fetchGitHubBlobContent(file.sha, repo);
|
|
465
818
|
}
|
|
466
819
|
|
|
467
820
|
/**
|
|
468
|
-
*
|
|
821
|
+
* 使用 GitHub Blob API 获取文件内容
|
|
469
822
|
*/
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
823
|
+
async fetchGitHubBlobContent(sha, repo) {
|
|
824
|
+
const url = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/blobs/${sha}`;
|
|
825
|
+
const data = await this.fetchGitHubApi(url);
|
|
826
|
+
if (!data || typeof data.content !== 'string') {
|
|
827
|
+
throw new Error('Invalid GitHub blob response');
|
|
474
828
|
}
|
|
475
|
-
|
|
829
|
+
return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 获取 GitHub Token(从环境变量或配置文件)
|
|
834
|
+
*/
|
|
835
|
+
getTokenFromConfigFile(fileName) {
|
|
476
836
|
try {
|
|
477
|
-
const configPath = path.join(this.configDir,
|
|
837
|
+
const configPath = path.join(this.configDir, fileName);
|
|
478
838
|
if (fs.existsSync(configPath)) {
|
|
479
|
-
return fs.readFileSync(configPath, 'utf-8').trim();
|
|
839
|
+
return fs.readFileSync(configPath, 'utf-8').trim() || null;
|
|
480
840
|
}
|
|
481
841
|
} catch (err) {
|
|
482
842
|
// ignore
|
|
@@ -484,11 +844,101 @@ class SkillService {
|
|
|
484
844
|
return null;
|
|
485
845
|
}
|
|
486
846
|
|
|
847
|
+
getTokenFromCommand(command, args = []) {
|
|
848
|
+
try {
|
|
849
|
+
const output = execFileSync(command, args, {
|
|
850
|
+
encoding: 'utf-8',
|
|
851
|
+
timeout: 3000,
|
|
852
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
853
|
+
}).trim();
|
|
854
|
+
return output || null;
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
getTokenFromGitCredential(host) {
|
|
861
|
+
const hostname = extractHostname(host);
|
|
862
|
+
if (!hostname) return null;
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
const output = execFileSync('git', ['credential', 'fill'], {
|
|
866
|
+
input: `protocol=https\nhost=${hostname}\n\n`,
|
|
867
|
+
encoding: 'utf-8',
|
|
868
|
+
timeout: 3000,
|
|
869
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
870
|
+
});
|
|
871
|
+
const passwordLine = output
|
|
872
|
+
.split(/\r?\n/)
|
|
873
|
+
.find(line => line.startsWith('password='));
|
|
874
|
+
if (!passwordLine) return null;
|
|
875
|
+
return passwordLine.slice('password='.length).trim() || null;
|
|
876
|
+
} catch {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
getGitHubToken(host = DEFAULT_GITHUB_HOST) {
|
|
882
|
+
// 优先从环境变量获取
|
|
883
|
+
if (process.env.GITHUB_TOKEN) {
|
|
884
|
+
return process.env.GITHUB_TOKEN;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const configToken = this.getTokenFromConfigFile('github-token.txt');
|
|
888
|
+
if (configToken) {
|
|
889
|
+
return configToken;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const hostname = extractHostname(host);
|
|
893
|
+
if (hostname) {
|
|
894
|
+
const ghHostToken = this.getTokenFromCommand('gh', ['auth', 'token', '--hostname', hostname]);
|
|
895
|
+
if (ghHostToken) {
|
|
896
|
+
return ghHostToken;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const ghToken = this.getTokenFromCommand('gh', ['auth', 'token']);
|
|
901
|
+
if (ghToken) {
|
|
902
|
+
return ghToken;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return this.getTokenFromGitCredential(host);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
getGitLabToken(host = DEFAULT_GITLAB_HOST) {
|
|
909
|
+
if (process.env.GITLAB_TOKEN) {
|
|
910
|
+
return process.env.GITLAB_TOKEN;
|
|
911
|
+
}
|
|
912
|
+
if (process.env.GITLAB_PRIVATE_TOKEN) {
|
|
913
|
+
return process.env.GITLAB_PRIVATE_TOKEN;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const configToken = this.getTokenFromConfigFile('gitlab-token.txt');
|
|
917
|
+
if (configToken) {
|
|
918
|
+
return configToken;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const hostname = extractHostname(host);
|
|
922
|
+
if (hostname) {
|
|
923
|
+
const glabHostToken = this.getTokenFromCommand('glab', ['auth', 'token', '--hostname', hostname]);
|
|
924
|
+
if (glabHostToken) {
|
|
925
|
+
return glabHostToken;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const glabToken = this.getTokenFromCommand('glab', ['auth', 'token']);
|
|
930
|
+
if (glabToken) {
|
|
931
|
+
return glabToken;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return this.getTokenFromGitCredential(host);
|
|
935
|
+
}
|
|
936
|
+
|
|
487
937
|
/**
|
|
488
938
|
* 通用 GitHub API 请求
|
|
489
939
|
*/
|
|
490
940
|
async fetchGitHubApi(url) {
|
|
491
|
-
const token = this.getGitHubToken();
|
|
941
|
+
const token = this.getGitHubToken(url);
|
|
492
942
|
const headers = {
|
|
493
943
|
'User-Agent': 'cc-cli-skill-service',
|
|
494
944
|
'Accept': 'application/vnd.github.v3+json'
|
|
@@ -525,6 +975,90 @@ class SkillService {
|
|
|
525
975
|
});
|
|
526
976
|
}
|
|
527
977
|
|
|
978
|
+
async fetchGitHubRepoTree(repo) {
|
|
979
|
+
const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
|
|
980
|
+
const tree = await this.fetchGitHubApi(treeUrl);
|
|
981
|
+
if (tree?.truncated) {
|
|
982
|
+
console.warn(`[SkillService] GitHub tree truncated for ${repo.owner}/${repo.name}`);
|
|
983
|
+
}
|
|
984
|
+
return tree?.tree || [];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async fetchGitLabApi(url, { raw = false } = {}) {
|
|
988
|
+
const token = this.getGitLabToken(url);
|
|
989
|
+
const headers = {
|
|
990
|
+
'User-Agent': 'cc-cli-skill-service'
|
|
991
|
+
};
|
|
992
|
+
if (!raw) {
|
|
993
|
+
headers.Accept = 'application/json';
|
|
994
|
+
}
|
|
995
|
+
if (token) {
|
|
996
|
+
headers['PRIVATE-TOKEN'] = token;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return new Promise((resolve, reject) => {
|
|
1000
|
+
const transport = url.startsWith('http:') ? http : https;
|
|
1001
|
+
const req = transport.get(url, {
|
|
1002
|
+
headers,
|
|
1003
|
+
timeout: 15000
|
|
1004
|
+
}, (res) => {
|
|
1005
|
+
let data = '';
|
|
1006
|
+
res.on('data', chunk => data += chunk);
|
|
1007
|
+
res.on('end', () => {
|
|
1008
|
+
if (res.statusCode === 200) {
|
|
1009
|
+
if (raw) {
|
|
1010
|
+
resolve(data);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
resolve({
|
|
1015
|
+
data: JSON.parse(data),
|
|
1016
|
+
headers: res.headers
|
|
1017
|
+
});
|
|
1018
|
+
} catch (e) {
|
|
1019
|
+
reject(new Error('Invalid JSON response'));
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
reject(new Error(`GitLab API error: ${res.statusCode}`));
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
req.on('error', reject);
|
|
1028
|
+
req.on('timeout', () => {
|
|
1029
|
+
req.destroy();
|
|
1030
|
+
reject(new Error('Request timeout'));
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async fetchGitLabTree(repo) {
|
|
1036
|
+
const tree = [];
|
|
1037
|
+
const projectId = encodeURIComponent(repo.projectPath);
|
|
1038
|
+
let page = 1;
|
|
1039
|
+
|
|
1040
|
+
while (page) {
|
|
1041
|
+
const url = `${repo.host}/api/v4/projects/${projectId}/repository/tree?ref=${encodeURIComponent(repo.branch)}&recursive=true&per_page=100&page=${page}`;
|
|
1042
|
+
const response = await this.fetchGitLabApi(url);
|
|
1043
|
+
tree.push(...(response.data || []).map(item => ({
|
|
1044
|
+
...item,
|
|
1045
|
+
type: item.type === 'tree' ? 'tree' : 'blob'
|
|
1046
|
+
})));
|
|
1047
|
+
|
|
1048
|
+
const nextPage = Number(response.headers['x-next-page'] || 0);
|
|
1049
|
+
page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return tree;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async fetchGitLabFileContent(repo, filePath) {
|
|
1056
|
+
const projectId = encodeURIComponent(repo.projectPath);
|
|
1057
|
+
const normalizedFilePath = encodeURIComponent(normalizeRepoPath(filePath));
|
|
1058
|
+
const url = `${repo.host}/api/v4/projects/${projectId}/repository/files/${normalizedFilePath}/raw?ref=${encodeURIComponent(repo.branch)}`;
|
|
1059
|
+
return this.fetchGitLabApi(url, { raw: true });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
528
1062
|
/**
|
|
529
1063
|
* 使用 GitHub API 获取目录内容
|
|
530
1064
|
*/
|
|
@@ -675,41 +1209,6 @@ class SkillService {
|
|
|
675
1209
|
return String(directory).replace(/\\/g, '/').split('/').pop();
|
|
676
1210
|
}
|
|
677
1211
|
|
|
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
1212
|
/**
|
|
714
1213
|
* 检查技能是否已安装
|
|
715
1214
|
*/
|
|
@@ -720,13 +1219,13 @@ class SkillService {
|
|
|
720
1219
|
}
|
|
721
1220
|
|
|
722
1221
|
/**
|
|
723
|
-
*
|
|
1222
|
+
* 合并本地 cc-tool 托管的技能(扫描 storageDir,根据 installDir 判断安装状态)
|
|
724
1223
|
*/
|
|
725
1224
|
mergeLocalSkills(skills) {
|
|
726
|
-
if (!fs.existsSync(this.
|
|
1225
|
+
if (!fs.existsSync(this.storageDir)) return;
|
|
727
1226
|
|
|
728
|
-
//
|
|
729
|
-
this.scanLocalDir(this.
|
|
1227
|
+
// 递归扫描 cc-tool 存储目录
|
|
1228
|
+
this.scanLocalDir(this.storageDir, this.storageDir, skills);
|
|
730
1229
|
}
|
|
731
1230
|
|
|
732
1231
|
/**
|
|
@@ -741,16 +1240,19 @@ class SkillService {
|
|
|
741
1240
|
: path.relative(baseDir, currentDir);
|
|
742
1241
|
|
|
743
1242
|
// 检查是否已在列表中(比较目录名,去掉前缀路径)
|
|
744
|
-
const
|
|
1243
|
+
const normalizedDirectory = normalizeRepoPath(directory).toLowerCase();
|
|
745
1244
|
const existing = skills.find(s => {
|
|
746
|
-
|
|
747
|
-
return remoteDirName === dirName;
|
|
1245
|
+
return normalizeRepoPath(s.directory).toLowerCase() === normalizedDirectory;
|
|
748
1246
|
});
|
|
749
1247
|
|
|
1248
|
+
// 判断是否已安装到平台目录
|
|
1249
|
+
const isInstalled = fs.existsSync(path.join(this.installDir, directory, 'SKILL.md'));
|
|
1250
|
+
|
|
750
1251
|
if (existing) {
|
|
751
|
-
existing.installed =
|
|
1252
|
+
existing.installed = isInstalled;
|
|
1253
|
+
existing.isLocal = true;
|
|
752
1254
|
} else {
|
|
753
|
-
//
|
|
1255
|
+
// 添加 cc-tool 托管的技能
|
|
754
1256
|
try {
|
|
755
1257
|
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
756
1258
|
const metadata = this.parseSkillMd(content);
|
|
@@ -760,7 +1262,8 @@ class SkillService {
|
|
|
760
1262
|
name: metadata.name || directory,
|
|
761
1263
|
description: metadata.description || '',
|
|
762
1264
|
directory,
|
|
763
|
-
installed:
|
|
1265
|
+
installed: isInstalled,
|
|
1266
|
+
isLocal: true,
|
|
764
1267
|
readmeUrl: null,
|
|
765
1268
|
repoOwner: null,
|
|
766
1269
|
repoName: null,
|
|
@@ -796,11 +1299,13 @@ class SkillService {
|
|
|
796
1299
|
|
|
797
1300
|
for (let i = skills.length - 1; i >= 0; i--) {
|
|
798
1301
|
const skill = skills[i];
|
|
799
|
-
|
|
800
|
-
|
|
1302
|
+
const key = [
|
|
1303
|
+
normalizeRepoPath(skill.directory).toLowerCase(),
|
|
1304
|
+
skill.repoId || '',
|
|
1305
|
+
skill.installed ? 'installed' : 'remote'
|
|
1306
|
+
].join('::');
|
|
801
1307
|
|
|
802
1308
|
if (seen.has(key)) {
|
|
803
|
-
// 保留已安装的版本
|
|
804
1309
|
const existingIndex = seen.get(key);
|
|
805
1310
|
if (skill.installed && !skills[existingIndex].installed) {
|
|
806
1311
|
skills.splice(existingIndex, 1);
|
|
@@ -822,6 +1327,7 @@ class SkillService {
|
|
|
822
1327
|
*/
|
|
823
1328
|
async installSkill(directory, repo, fullDirectory = null) {
|
|
824
1329
|
const dest = path.join(this.installDir, directory);
|
|
1330
|
+
const normalizedRepo = this.normalizeRepoConfig(repo);
|
|
825
1331
|
|
|
826
1332
|
// 已安装则跳过
|
|
827
1333
|
if (fs.existsSync(dest)) {
|
|
@@ -831,16 +1337,48 @@ class SkillService {
|
|
|
831
1337
|
// 使用 fullDirectory(仓库中的完整路径)或 directory(向后兼容)
|
|
832
1338
|
const sourcePath = fullDirectory || directory;
|
|
833
1339
|
|
|
834
|
-
|
|
835
|
-
|
|
1340
|
+
if (normalizedRepo.provider === 'local') {
|
|
1341
|
+
const sourceDir = sourcePath
|
|
1342
|
+
? path.join(normalizedRepo.localPath, sourcePath)
|
|
1343
|
+
: normalizedRepo.localPath;
|
|
1344
|
+
|
|
1345
|
+
if (!fs.existsSync(sourceDir)) {
|
|
1346
|
+
throw new Error(`Skill directory not found: ${sourcePath || normalizedRepo.localPath}`);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1350
|
+
this.copyDirRecursive(sourceDir, dest);
|
|
1351
|
+
|
|
1352
|
+
this.clearCache({ removeFile: true });
|
|
1353
|
+
return { success: true, message: 'Installed successfully' };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
836
1356
|
const tempDir = path.join(os.tmpdir(), `skill-${Date.now()}`);
|
|
837
1357
|
const zipPath = path.join(tempDir, 'repo.zip');
|
|
838
1358
|
|
|
839
1359
|
try {
|
|
840
1360
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
841
1361
|
|
|
842
|
-
|
|
843
|
-
|
|
1362
|
+
let zipUrl = '';
|
|
1363
|
+
let zipHeaders = {};
|
|
1364
|
+
|
|
1365
|
+
if (normalizedRepo.provider === 'gitlab') {
|
|
1366
|
+
const projectId = encodeURIComponent(normalizedRepo.projectPath);
|
|
1367
|
+
zipUrl = `${normalizedRepo.host}/api/v4/projects/${projectId}/repository/archive.zip?sha=${encodeURIComponent(normalizedRepo.branch)}`;
|
|
1368
|
+
const token = this.getGitLabToken(normalizedRepo.host);
|
|
1369
|
+
if (token) {
|
|
1370
|
+
zipHeaders['PRIVATE-TOKEN'] = token;
|
|
1371
|
+
}
|
|
1372
|
+
} else {
|
|
1373
|
+
zipUrl = `https://api.github.com/repos/${normalizedRepo.owner}/${normalizedRepo.name}/zipball/${encodeURIComponent(normalizedRepo.branch)}`;
|
|
1374
|
+
const token = this.getGitHubToken(normalizedRepo.host);
|
|
1375
|
+
zipHeaders.Accept = 'application/vnd.github+json';
|
|
1376
|
+
if (token) {
|
|
1377
|
+
zipHeaders.Authorization = `token ${token}`;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
await this.downloadFile(zipUrl, zipPath, zipHeaders);
|
|
844
1382
|
|
|
845
1383
|
// 解压
|
|
846
1384
|
const zip = new AdmZip(zipPath);
|
|
@@ -866,23 +1404,7 @@ class SkillService {
|
|
|
866
1404
|
fs.mkdirSync(dest, { recursive: true });
|
|
867
1405
|
this.copyDirRecursive(sourceDir, dest);
|
|
868
1406
|
|
|
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;
|
|
1407
|
+
this.clearCache({ removeFile: true });
|
|
886
1408
|
|
|
887
1409
|
return { success: true, message: 'Installed successfully' };
|
|
888
1410
|
} finally {
|
|
@@ -898,18 +1420,22 @@ class SkillService {
|
|
|
898
1420
|
/**
|
|
899
1421
|
* 下载文件
|
|
900
1422
|
*/
|
|
901
|
-
async downloadFile(url, dest) {
|
|
1423
|
+
async downloadFile(url, dest, headers = {}) {
|
|
902
1424
|
return new Promise((resolve, reject) => {
|
|
903
1425
|
const file = createWriteStream(dest);
|
|
1426
|
+
const transport = url.startsWith('http:') ? http : https;
|
|
904
1427
|
|
|
905
|
-
const request =
|
|
906
|
-
headers: {
|
|
1428
|
+
const request = transport.get(url, {
|
|
1429
|
+
headers: {
|
|
1430
|
+
'User-Agent': 'cc-cli-skill-service',
|
|
1431
|
+
...headers
|
|
1432
|
+
},
|
|
907
1433
|
timeout: 60000
|
|
908
1434
|
}, (response) => {
|
|
909
1435
|
// 处理重定向
|
|
910
1436
|
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
911
1437
|
file.close();
|
|
912
|
-
this.downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
1438
|
+
this.downloadFile(response.headers.location, dest, headers).then(resolve).catch(reject);
|
|
913
1439
|
return;
|
|
914
1440
|
}
|
|
915
1441
|
|
|
@@ -964,51 +1490,21 @@ class SkillService {
|
|
|
964
1490
|
* 创建自定义技能
|
|
965
1491
|
*/
|
|
966
1492
|
createCustomSkill({ name, directory, description, content }) {
|
|
967
|
-
const dest = path.join(this.
|
|
968
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1493
|
+
const dest = path.join(this.storageDir, directory);
|
|
969
1494
|
|
|
970
1495
|
// 检查是否已存在
|
|
971
1496
|
if (fs.existsSync(dest)) {
|
|
972
1497
|
throw new Error(`技能目录 "${directory}" 已存在`);
|
|
973
1498
|
}
|
|
974
1499
|
|
|
975
|
-
if (this.platform === 'opencode') {
|
|
976
|
-
if (!OPENCODE_SKILL_NAME_REGEX.test(normalizedDirectory)) {
|
|
977
|
-
throw new Error('OpenCode skill 目录名必须是小写字母/数字,并使用单个连字符连接');
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
1500
|
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
|
-
}
|
|
1501
|
+
const skillName = name || directory;
|
|
998
1502
|
|
|
999
1503
|
// 创建目录
|
|
1000
1504
|
fs.mkdirSync(dest, { recursive: true });
|
|
1001
1505
|
|
|
1002
1506
|
// 生成 SKILL.md 内容
|
|
1003
|
-
const skillMdContent =
|
|
1004
|
-
? `---
|
|
1005
|
-
name: ${skillName}
|
|
1006
|
-
description: "${normalizedDescription}"
|
|
1007
|
-
---
|
|
1008
|
-
|
|
1009
|
-
${content}
|
|
1010
|
-
`
|
|
1011
|
-
: `---
|
|
1507
|
+
const skillMdContent = `---
|
|
1012
1508
|
name: "${skillName}"
|
|
1013
1509
|
description: "${normalizedDescription}"
|
|
1014
1510
|
---
|
|
@@ -1019,9 +1515,7 @@ ${content}
|
|
|
1019
1515
|
// 写入文件
|
|
1020
1516
|
fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
|
|
1021
1517
|
|
|
1022
|
-
|
|
1023
|
-
this.skillsCache = null;
|
|
1024
|
-
this.cacheTime = 0;
|
|
1518
|
+
this.clearCache({ removeFile: true });
|
|
1025
1519
|
|
|
1026
1520
|
return { success: true, message: '技能创建成功', directory };
|
|
1027
1521
|
}
|
|
@@ -1033,8 +1527,7 @@ ${content}
|
|
|
1033
1527
|
* @returns {Object} 创建结果
|
|
1034
1528
|
*/
|
|
1035
1529
|
createSkillWithFiles({ directory, files }) {
|
|
1036
|
-
const dest = path.join(this.
|
|
1037
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1530
|
+
const dest = path.join(this.storageDir, directory);
|
|
1038
1531
|
|
|
1039
1532
|
// 检查是否已存在
|
|
1040
1533
|
if (fs.existsSync(dest)) {
|
|
@@ -1049,21 +1542,6 @@ ${content}
|
|
|
1049
1542
|
throw new Error('技能必须包含 SKILL.md 文件');
|
|
1050
1543
|
}
|
|
1051
1544
|
|
|
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
1545
|
// 创建目录
|
|
1068
1546
|
fs.mkdirSync(dest, { recursive: true });
|
|
1069
1547
|
|
|
@@ -1086,9 +1564,7 @@ ${content}
|
|
|
1086
1564
|
}
|
|
1087
1565
|
}
|
|
1088
1566
|
|
|
1089
|
-
|
|
1090
|
-
this.skillsCache = null;
|
|
1091
|
-
this.cacheTime = 0;
|
|
1567
|
+
this.clearCache({ removeFile: true });
|
|
1092
1568
|
|
|
1093
1569
|
return {
|
|
1094
1570
|
success: true,
|
|
@@ -1190,25 +1666,11 @@ ${content}
|
|
|
1190
1666
|
*/
|
|
1191
1667
|
addSkillFiles(directory, files) {
|
|
1192
1668
|
const skillPath = path.join(this.installDir, directory);
|
|
1193
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1194
1669
|
|
|
1195
1670
|
if (!fs.existsSync(skillPath)) {
|
|
1196
1671
|
throw new Error(`技能 "${directory}" 不存在`);
|
|
1197
1672
|
}
|
|
1198
1673
|
|
|
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
1674
|
const added = [];
|
|
1213
1675
|
for (const file of files) {
|
|
1214
1676
|
const filePath = path.join(skillPath, file.path);
|
|
@@ -1228,9 +1690,7 @@ ${content}
|
|
|
1228
1690
|
added.push(file.path);
|
|
1229
1691
|
}
|
|
1230
1692
|
|
|
1231
|
-
|
|
1232
|
-
this.skillsCache = null;
|
|
1233
|
-
this.cacheTime = 0;
|
|
1693
|
+
this.clearCache({ removeFile: true });
|
|
1234
1694
|
|
|
1235
1695
|
return { success: true, added };
|
|
1236
1696
|
}
|
|
@@ -1265,9 +1725,7 @@ ${content}
|
|
|
1265
1725
|
fs.unlinkSync(fullPath);
|
|
1266
1726
|
}
|
|
1267
1727
|
|
|
1268
|
-
|
|
1269
|
-
this.skillsCache = null;
|
|
1270
|
-
this.cacheTime = 0;
|
|
1728
|
+
this.clearCache({ removeFile: true });
|
|
1271
1729
|
|
|
1272
1730
|
return { success: true, deleted: filePath };
|
|
1273
1731
|
}
|
|
@@ -1281,7 +1739,6 @@ ${content}
|
|
|
1281
1739
|
*/
|
|
1282
1740
|
updateSkillFile(directory, filePath, content, isBase64 = false) {
|
|
1283
1741
|
const skillPath = path.join(this.installDir, directory);
|
|
1284
|
-
const normalizedDirectory = this.normalizeSkillDirectoryName(directory);
|
|
1285
1742
|
|
|
1286
1743
|
if (!fs.existsSync(skillPath)) {
|
|
1287
1744
|
throw new Error(`技能 "${directory}" 不存在`);
|
|
@@ -1293,28 +1750,39 @@ ${content}
|
|
|
1293
1750
|
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1294
1751
|
}
|
|
1295
1752
|
|
|
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
1753
|
if (isBase64) {
|
|
1305
1754
|
fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
|
|
1306
1755
|
} else {
|
|
1307
1756
|
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1308
1757
|
}
|
|
1309
1758
|
|
|
1310
|
-
|
|
1311
|
-
this.skillsCache = null;
|
|
1312
|
-
this.cacheTime = 0;
|
|
1759
|
+
this.clearCache({ removeFile: true });
|
|
1313
1760
|
|
|
1314
1761
|
return { success: true, updated: filePath };
|
|
1315
1762
|
}
|
|
1316
1763
|
|
|
1317
1764
|
|
|
1765
|
+
/**
|
|
1766
|
+
* 安装 cc-tool 本地托管的技能(从 storageDir cp 到 installDir)
|
|
1767
|
+
*/
|
|
1768
|
+
installLocalSkill(directory) {
|
|
1769
|
+
const src = path.join(this.storageDir, directory);
|
|
1770
|
+
const dest = path.join(this.installDir, directory);
|
|
1771
|
+
|
|
1772
|
+
if (!fs.existsSync(src)) {
|
|
1773
|
+
throw new Error(`本地技能 "${directory}" 不存在`);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if (fs.existsSync(dest)) {
|
|
1777
|
+
return { success: true, message: 'Already installed' };
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
1781
|
+
this.copyDirRecursive(src, dest);
|
|
1782
|
+
this.clearCache({ removeFile: true });
|
|
1783
|
+
return { success: true, message: 'Installed successfully' };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1318
1786
|
/**
|
|
1319
1787
|
* 卸载技能
|
|
1320
1788
|
*/
|
|
@@ -1323,9 +1791,7 @@ ${content}
|
|
|
1323
1791
|
|
|
1324
1792
|
if (fs.existsSync(dest)) {
|
|
1325
1793
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
1326
|
-
|
|
1327
|
-
this.skillsCache = null;
|
|
1328
|
-
this.cacheTime = 0;
|
|
1794
|
+
this.clearCache({ removeFile: true });
|
|
1329
1795
|
return { success: true, message: 'Uninstalled successfully' };
|
|
1330
1796
|
}
|
|
1331
1797
|
|
|
@@ -1335,7 +1801,7 @@ ${content}
|
|
|
1335
1801
|
/**
|
|
1336
1802
|
* 获取技能详情(完整内容)
|
|
1337
1803
|
*/
|
|
1338
|
-
async getSkillDetail(directory) {
|
|
1804
|
+
async getSkillDetail(directory, repoHint = null, fullDirectoryHint = '') {
|
|
1339
1805
|
// 先检查本地是否安装
|
|
1340
1806
|
const localPath = path.join(this.installDir, directory, 'SKILL.md');
|
|
1341
1807
|
|
|
@@ -1358,13 +1824,7 @@ ${content}
|
|
|
1358
1824
|
};
|
|
1359
1825
|
}
|
|
1360
1826
|
|
|
1361
|
-
const
|
|
1362
|
-
String(input)
|
|
1363
|
-
.replace(/\\/g, '/')
|
|
1364
|
-
.replace(/^\/+/, '')
|
|
1365
|
-
.replace(/\/+$/, '');
|
|
1366
|
-
|
|
1367
|
-
const parseRemoteSkillContent = (content, repo) => {
|
|
1827
|
+
const parseRemoteSkillContent = (content, repo, fullDirectory = '') => {
|
|
1368
1828
|
const metadata = this.parseSkillMd(content);
|
|
1369
1829
|
const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
|
|
1370
1830
|
const body = bodyMatch ? bodyMatch[1].trim() : content;
|
|
@@ -1376,17 +1836,46 @@ ${content}
|
|
|
1376
1836
|
content: body,
|
|
1377
1837
|
fullContent: content,
|
|
1378
1838
|
installed: false,
|
|
1379
|
-
source: '
|
|
1380
|
-
|
|
1381
|
-
|
|
1839
|
+
source: repo.provider === 'local' ? 'local-repo' : repo.provider,
|
|
1840
|
+
fullDirectory,
|
|
1841
|
+
repoProvider: repo.provider,
|
|
1842
|
+
repoOwner: repo.owner || null,
|
|
1843
|
+
repoName: repo.name || null,
|
|
1844
|
+
repoBranch: repo.branch || 'main',
|
|
1845
|
+
repoDirectory: repo.directory || '',
|
|
1846
|
+
repoHost: repo.host || null,
|
|
1847
|
+
repoProjectPath: repo.projectPath || null,
|
|
1848
|
+
repoLocalPath: repo.localPath || null,
|
|
1849
|
+
repoId: repo.id,
|
|
1850
|
+
repoUrl: repo.repoUrl || buildRepoUrl(repo)
|
|
1382
1851
|
};
|
|
1383
1852
|
};
|
|
1384
1853
|
|
|
1385
1854
|
const tryLoadRemoteDetailFromRepo = async (repo, extraCandidateDirs = []) => {
|
|
1386
1855
|
try {
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1856
|
+
if (repo.provider === 'local') {
|
|
1857
|
+
const normalizedDirectory = normalizeRepoPath(directory);
|
|
1858
|
+
const candidateDirs = new Set([
|
|
1859
|
+
normalizedDirectory,
|
|
1860
|
+
normalizeRepoPath(fullDirectoryHint || ''),
|
|
1861
|
+
...extraCandidateDirs.map(candidate => normalizeRepoPath(candidate))
|
|
1862
|
+
]);
|
|
1863
|
+
|
|
1864
|
+
for (const candidateDir of candidateDirs) {
|
|
1865
|
+
const skillMdPath = candidateDir
|
|
1866
|
+
? path.join(repo.localPath, candidateDir, 'SKILL.md')
|
|
1867
|
+
: path.join(repo.localPath, 'SKILL.md');
|
|
1868
|
+
if (!fs.existsSync(skillMdPath)) continue;
|
|
1869
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
1870
|
+
return parseRemoteSkillContent(content, repo, candidateDir);
|
|
1871
|
+
}
|
|
1872
|
+
return null;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const treeItems = repo.provider === 'gitlab'
|
|
1876
|
+
? await this.fetchGitLabTree(repo)
|
|
1877
|
+
: await this.fetchGitHubRepoTree(repo);
|
|
1878
|
+
if (!treeItems?.length) return null;
|
|
1390
1879
|
|
|
1391
1880
|
const normalizedDirectory = normalizeRepoPath(directory);
|
|
1392
1881
|
const candidateDirs = new Set();
|
|
@@ -1403,44 +1892,59 @@ ${content}
|
|
|
1403
1892
|
|
|
1404
1893
|
let skillFile = null;
|
|
1405
1894
|
for (const candidateDir of candidateDirs) {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1895
|
+
skillFile = treeItems.find(item =>
|
|
1896
|
+
item.type === 'blob' && (
|
|
1897
|
+
candidateDir
|
|
1898
|
+
? item.path === `${candidateDir}/SKILL.md`
|
|
1899
|
+
: item.path === 'SKILL.md'
|
|
1900
|
+
)
|
|
1409
1901
|
);
|
|
1410
1902
|
if (skillFile) break;
|
|
1411
1903
|
}
|
|
1412
1904
|
|
|
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
1905
|
if (!skillFile) return null;
|
|
1424
1906
|
|
|
1425
|
-
const content = await this.
|
|
1426
|
-
|
|
1907
|
+
const content = await this.fetchSkillFileContent(repo, skillFile);
|
|
1908
|
+
const fullDirectory = normalizeRepoPath(skillFile.path.replace(/(^|\/)SKILL\.md$/, ''));
|
|
1909
|
+
return parseRemoteSkillContent(content, repo, fullDirectory);
|
|
1427
1910
|
} catch (err) {
|
|
1428
1911
|
console.warn('[SkillService] Fetch remote skill detail error:', err.message);
|
|
1429
1912
|
return null;
|
|
1430
1913
|
}
|
|
1431
1914
|
};
|
|
1432
1915
|
|
|
1916
|
+
if (repoHint) {
|
|
1917
|
+
try {
|
|
1918
|
+
const normalizedRepoHint = this.normalizeRepoConfig(repoHint);
|
|
1919
|
+
const detail = await tryLoadRemoteDetailFromRepo(normalizedRepoHint, [
|
|
1920
|
+
fullDirectoryHint || '',
|
|
1921
|
+
repoHint.directory ? `${repoHint.directory}/${directory}` : '',
|
|
1922
|
+
repoHint.fullDirectory || ''
|
|
1923
|
+
]);
|
|
1924
|
+
if (detail) return detail;
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
console.warn('[SkillService] Invalid repo hint for detail:', err.message);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1433
1930
|
// 先尝试使用缓存中的 repo 信息(最快)
|
|
1434
|
-
const cachedSkill = this.skillsCache?.find(s =>
|
|
1435
|
-
|
|
1436
|
-
|
|
1931
|
+
const cachedSkill = this.skillsCache?.find(s =>
|
|
1932
|
+
normalizeRepoPath(s.directory) === normalizeRepoPath(directory)
|
|
1933
|
+
);
|
|
1934
|
+
if (cachedSkill && (cachedSkill.repoOwner || cachedSkill.repoProjectPath || cachedSkill.repoLocalPath)) {
|
|
1935
|
+
const cachedRepo = this.normalizeRepoConfig({
|
|
1936
|
+
provider: cachedSkill.repoProvider || (cachedSkill.repoLocalPath ? 'local' : (cachedSkill.repoProjectPath ? 'gitlab' : 'github')),
|
|
1437
1937
|
owner: cachedSkill.repoOwner,
|
|
1438
1938
|
name: cachedSkill.repoName,
|
|
1439
1939
|
branch: cachedSkill.repoBranch || 'main',
|
|
1440
|
-
directory: cachedSkill.repoDirectory || ''
|
|
1441
|
-
|
|
1442
|
-
|
|
1940
|
+
directory: cachedSkill.repoDirectory || '',
|
|
1941
|
+
host: cachedSkill.repoHost,
|
|
1942
|
+
projectPath: cachedSkill.repoProjectPath,
|
|
1943
|
+
localPath: cachedSkill.repoLocalPath,
|
|
1944
|
+
repoUrl: cachedSkill.repoUrl
|
|
1945
|
+
});
|
|
1443
1946
|
const detail = await tryLoadRemoteDetailFromRepo(cachedRepo, [
|
|
1947
|
+
fullDirectoryHint || '',
|
|
1444
1948
|
cachedSkill.fullDirectory || '',
|
|
1445
1949
|
cachedSkill.repoDirectory ? `${cachedSkill.repoDirectory}/${directory}` : ''
|
|
1446
1950
|
]);
|
|
@@ -1451,12 +1955,7 @@ ${content}
|
|
|
1451
1955
|
const repos = this.loadRepos().filter(repo => repo.enabled !== false);
|
|
1452
1956
|
for (const repo of repos) {
|
|
1453
1957
|
const detail = await tryLoadRemoteDetailFromRepo(
|
|
1454
|
-
|
|
1455
|
-
owner: repo.owner,
|
|
1456
|
-
name: repo.name,
|
|
1457
|
-
branch: repo.branch || 'main',
|
|
1458
|
-
directory: repo.directory || ''
|
|
1459
|
-
},
|
|
1958
|
+
repo,
|
|
1460
1959
|
[repo.directory ? `${repo.directory}/${directory}` : '']
|
|
1461
1960
|
);
|
|
1462
1961
|
if (detail) return detail;
|