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.
Files changed (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-IW6eAy9u.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-BPtkTMSc.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-BGx9MSDV.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-BCn-mrCx.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-CzLfebJQ.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-CXz2vBQx.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-CHtgMfKc.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-B29onFfZ.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +14 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/commands/toggle-proxy.js +100 -5
  25. package/src/config/paths.js +718 -90
  26. package/src/server/api/agents.js +1 -1
  27. package/src/server/api/channels.js +9 -0
  28. package/src/server/api/claude-hooks.js +13 -8
  29. package/src/server/api/codex-channels.js +9 -0
  30. package/src/server/api/codex-proxy.js +27 -15
  31. package/src/server/api/gemini-proxy.js +22 -11
  32. package/src/server/api/hooks.js +45 -0
  33. package/src/server/api/oauth-credentials.js +163 -0
  34. package/src/server/api/opencode-proxy.js +22 -10
  35. package/src/server/api/plugins.js +2 -1
  36. package/src/server/api/proxy.js +39 -44
  37. package/src/server/api/skills.js +91 -13
  38. package/src/server/api/ui-config.js +5 -0
  39. package/src/server/codex-proxy-server.js +90 -70
  40. package/src/server/gemini-proxy-server.js +107 -88
  41. package/src/server/index.js +2 -0
  42. package/src/server/opencode-proxy-server.js +381 -225
  43. package/src/server/proxy-server.js +86 -60
  44. package/src/server/services/alias.js +3 -3
  45. package/src/server/services/channels.js +21 -24
  46. package/src/server/services/codex-channels.js +158 -255
  47. package/src/server/services/codex-config.js +2 -5
  48. package/src/server/services/codex-env-manager.js +423 -0
  49. package/src/server/services/codex-settings-manager.js +21 -357
  50. package/src/server/services/codex-statistics-service.js +3 -27
  51. package/src/server/services/config-export-service.js +43 -9
  52. package/src/server/services/config-registry-service.js +3 -2
  53. package/src/server/services/config-sync-manager.js +1 -1
  54. package/src/server/services/favorites.js +4 -3
  55. package/src/server/services/gemini-channels.js +14 -12
  56. package/src/server/services/gemini-statistics-service.js +3 -25
  57. package/src/server/services/mcp-service.js +35 -19
  58. package/src/server/services/model-detector.js +4 -3
  59. package/src/server/services/native-keychain.js +243 -0
  60. package/src/server/services/native-oauth-adapters.js +891 -0
  61. package/src/server/services/network-access.js +39 -1
  62. package/src/server/services/notification-hooks.js +951 -0
  63. package/src/server/services/oauth-credentials-service.js +786 -0
  64. package/src/server/services/oauth-utils.js +49 -0
  65. package/src/server/services/opencode-channels.js +19 -15
  66. package/src/server/services/opencode-sessions.js +2 -2
  67. package/src/server/services/opencode-settings-manager.js +169 -16
  68. package/src/server/services/opencode-statistics-service.js +3 -27
  69. package/src/server/services/plugins-service.js +115 -15
  70. package/src/server/services/prompts-service.js +2 -3
  71. package/src/server/services/proxy-log-helper.js +242 -0
  72. package/src/server/services/proxy-runtime.js +6 -4
  73. package/src/server/services/repo-scanner-base.js +12 -4
  74. package/src/server/services/request-logger.js +7 -7
  75. package/src/server/services/security-config.js +4 -4
  76. package/src/server/services/session-cache.js +2 -2
  77. package/src/server/services/sessions.js +2 -2
  78. package/src/server/services/settings-manager.js +13 -0
  79. package/src/server/services/skill-service.js +867 -368
  80. package/src/server/services/statistics-service.js +5 -5
  81. package/src/server/services/ui-config.js +4 -3
  82. package/src/server/services/workspace-service.js +1 -1
  83. package/src/server/websocket-server.js +5 -4
  84. package/dist/web/assets/Home-BsSioaaB.css +0 -1
  85. package/dist/web/assets/Home-obifg_9E.js +0 -1
  86. package/dist/web/assets/index-C7LPdVsN.js +0 -2
  87. package/dist/web/assets/index-eEmjZKWP.css +0 -1
  88. package/docs/bannel.png +0 -0
  89. 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 OPENCODE_SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
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
- reposFile: 'skill-repos.json',
51
- cacheFile: 'skills-cache.json'
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
- reposFile: 'codex-skill-repos.json',
56
- cacheFile: 'codex-skills-cache.json'
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
- reposFile: 'gemini-skill-repos.json',
61
- cacheFile: 'gemini-skills-cache.json'
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
- reposFile: 'opencode-skill-repos.json',
66
- cacheFile: 'opencode-skills-cache.json'
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 = path.join(HOME_DIR, '.cc-tool');
203
+ this.configDir = PATHS.config;
77
204
 
78
205
  const platformConfig = PLATFORM_CONFIG[this.platform];
79
206
  this.installDir = platformConfig.installDir;
80
- this.reposConfigPath = path.join(this.configDir, platformConfig.reposFile);
81
- this.cachePath = path.join(this.configDir, platformConfig.cacheFile);
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
- fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos }, null, 2));
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
- // 使用 owner/name/directory 作为唯一标识
136
- const existingIndex = repos.findIndex(r =>
137
- r.owner === repo.owner &&
138
- r.name === repo.name &&
139
- (r.directory || '') === (repo.directory || '')
140
- );
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] = repo;
384
+ repos[existingIndex] = normalizedRepo;
144
385
  } else {
145
- repos.push(repo);
386
+ repos.push(normalizedRepo);
146
387
  }
147
388
 
148
389
  this.saveRepos(repos);
149
- // 清除缓存
150
- this.skillsCache = null;
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 filtered = repos.filter(r => !(
164
- r.owner === owner &&
165
- r.name === name &&
166
- (r.directory || '') === directory
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.skillsCache = null;
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 repo = repos.find(r =>
185
- r.owner === owner &&
186
- r.name === name &&
187
- (r.directory || '') === directory
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 repos;
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.skillsCache = null;
206
- this.cacheTime = 0;
207
- // 删除文件缓存
208
- try {
209
- if (fs.existsSync(this.cachePath)) {
210
- fs.unlinkSync(this.cachePath);
211
- }
212
- } catch (err) {
213
- console.warn('[SkillService] Failed to delete cache file:', err.message);
214
- }
452
+ this.clearCache();
215
453
  }
216
454
 
455
+ const fileCache = this.loadCacheFromFile();
456
+
217
457
  // 检查内存缓存
218
- if (!forceRefresh && this.skillsCache && (Date.now() - this.cacheTime < CACHE_TTL)) {
219
- this.updateInstallStatus(this.skillsCache);
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
- const fileCache = this.loadCacheFromFile();
226
- if (fileCache) {
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 repoInfo = `${enabledRepos[i].owner}/${enabledRepos[i].name}`;
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
- this.mergeLocalSkills(skills);
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
- this.deduplicateSkills(skills);
268
- skills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
522
+ if (shouldUseStaleFileCache) {
523
+ this.skillsCache = preparedFileCache;
524
+ this.cacheTime = Date.now();
525
+ return this.skillsCache;
526
+ }
269
527
 
270
528
  // 更新缓存
271
- this.skillsCache = skills;
529
+ this.skillsCache = preparedSkills;
272
530
  this.cacheTime = Date.now();
273
- this.saveCacheToFile(skills);
531
+ this.saveCacheToFile(preparedSkills);
274
532
 
275
- return skills;
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 (data.time && (Date.now() - data.time < CACHE_TTL)) {
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
- // 使用 GitHub Tree API 一次性获取所有文件
327
- const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
328
- const tree = await this.fetchGitHubApi(treeUrl);
596
+ const treeItems = await this.fetchGitHubRepoTree(repo);
329
597
 
330
- if (!tree || !tree.tree) {
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
- // 找到所有 SKILL.md 文件(如果配置了子目录,只扫描该目录下的)
340
- const skillFiles = tree.tree.filter(item => {
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
- // 从路径提取目录名 (e.g., "algorithmic-art/SKILL.md" -> "algorithmic-art")
383
- const fullDirectory = file.path.replace(/\/SKILL\.md$/, '');
384
-
385
- // 计算相对于 baseDir 的目录名(用于显示和安装)
386
- const directory = baseDir ? fullDirectory.slice(baseDir.length + 1) : fullDirectory;
387
-
388
- // 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
389
- const content = await this.fetchBlobContent(file.sha, repo, file.path);
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
- key: `${repo.owner}/${repo.name}:${fullDirectory}`,
394
- name: metadata.name || directory.split('/').pop(),
395
- description: metadata.description || '',
396
- directory, // 相对目录(用于安装)
397
- fullDirectory, // 完整目录(用于从仓库下载)
398
- installed: this.isInstalled(directory),
399
- readmeUrl: `https://github.com/${repo.owner}/${repo.name}/tree/${repo.branch}/${fullDirectory}`,
400
- repoOwner: repo.owner,
401
- repoName: repo.name,
402
- repoBranch: repo.branch,
403
- repoDirectory: repo.directory || '', // 仓库配置的子目录
404
- license: metadata.license
405
- };
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
- * 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
733
+ * 递归扫描外部本地仓库目录
414
734
  */
415
- async fetchBlobContent(sha, repo, filePath) {
416
- // raw.githubusercontent.com 不走 API 限流
417
- const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
735
+ scanRepoLocalDir(currentDir, repoRoot, skills, repo) {
736
+ const skillMdPath = path.join(currentDir, 'SKILL.md');
418
737
 
419
- return new Promise((resolve, reject) => {
420
- const req = https.get(url, {
421
- headers: {
422
- 'User-Agent': 'cc-cli-skill-service'
423
- },
424
- timeout: 15000
425
- }, (res) => {
426
- // 处理重定向
427
- if (res.statusCode === 301 || res.statusCode === 302) {
428
- const redirectUrl = res.headers.location;
429
- if (redirectUrl) {
430
- https.get(redirectUrl, {
431
- headers: { 'User-Agent': 'cc-cli-skill-service' },
432
- timeout: 15000
433
- }, (res2) => {
434
- let data = '';
435
- res2.on('data', chunk => data += chunk);
436
- res2.on('end', () => {
437
- if (res2.statusCode === 200) {
438
- resolve(data);
439
- } else {
440
- reject(new Error(`Raw fetch error: ${res2.statusCode}`));
441
- }
442
- });
443
- }).on('error', reject);
444
- return;
445
- }
446
- }
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
- let data = '';
449
- res.on('data', chunk => data += chunk);
450
- res.on('end', () => {
451
- if (res.statusCode === 200) {
452
- resolve(data);
453
- } else {
454
- reject(new Error(`Raw fetch error: ${res.statusCode}`));
455
- }
456
- });
457
- });
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
- req.on('error', reject);
460
- req.on('timeout', () => {
461
- req.destroy();
462
- reject(new Error('Raw fetch timeout'));
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
- * 获取 GitHub Token(从环境变量或配置文件)
821
+ * 使用 GitHub Blob API 获取文件内容
469
822
  */
470
- getGitHubToken() {
471
- // 优先从环境变量获取
472
- if (process.env.GITHUB_TOKEN) {
473
- return process.env.GITHUB_TOKEN;
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, 'github-token.txt');
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.installDir)) return;
1225
+ if (!fs.existsSync(this.storageDir)) return;
727
1226
 
728
- // 递归扫描本地技能目录
729
- this.scanLocalDir(this.installDir, this.installDir, skills);
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 dirName = directory.split('/').pop().toLowerCase();
1243
+ const normalizedDirectory = normalizeRepoPath(directory).toLowerCase();
745
1244
  const existing = skills.find(s => {
746
- const remoteDirName = s.directory.split('/').pop().toLowerCase();
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 = true;
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: true,
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
- // 使用目录名(不含路径前缀)作为去重 key
800
- const key = skill.directory.split('/').pop().toLowerCase();
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
- // 下载仓库 ZIP
835
- const zipUrl = `https://github.com/${repo.owner}/${repo.name}/archive/refs/heads/${repo.branch}.zip`;
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
- // 下载 ZIP
843
- await this.downloadFile(zipUrl, zipPath);
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
- if (this.platform === 'opencode') {
870
- const skillMdPath = path.join(dest, 'SKILL.md');
871
- if (fs.existsSync(skillMdPath)) {
872
- const validationError = this.validateOpenCodeSkillContent(
873
- fs.readFileSync(skillMdPath, 'utf-8'),
874
- directory
875
- );
876
- if (validationError) {
877
- fs.rmSync(dest, { recursive: true, force: true });
878
- throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
879
- }
880
- }
881
- }
882
-
883
- // 清除缓存,让列表刷新
884
- this.skillsCache = null;
885
- this.cacheTime = 0;
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 = https.get(url, {
906
- headers: { 'User-Agent': 'cc-cli-skill-service' },
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.installDir, directory);
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 = this.platform === 'opencode'
983
- ? normalizedDirectory
984
- : (name || directory);
985
-
986
- if (this.platform === 'opencode') {
987
- const validationError = this.validateOpenCodeSkillMetadata(
988
- {
989
- name: skillName,
990
- description: normalizedDescription
991
- },
992
- normalizedDirectory
993
- );
994
- if (validationError) {
995
- throw new Error(`OpenCode skill 格式不符合要求: ${validationError}`);
996
- }
997
- }
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 = this.platform === 'opencode'
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.installDir, directory);
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 normalizeRepoPath = (input = '') =>
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: 'github',
1380
- repoOwner: repo.owner,
1381
- repoName: repo.name
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
- const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
1388
- const tree = await this.fetchGitHubApi(treeUrl);
1389
- if (!tree?.tree) return null;
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
- if (!candidateDir) continue;
1407
- skillFile = tree.tree.find(item =>
1408
- item.type === 'blob' && item.path === `${candidateDir}/SKILL.md`
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.fetchBlobContent(skillFile.sha, repo, skillFile.path);
1426
- return parseRemoteSkillContent(content, repo);
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 => s.directory === directory);
1435
- if (cachedSkill && cachedSkill.repoOwner && cachedSkill.repoName) {
1436
- const cachedRepo = {
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;