coding-tool-x 3.3.7 → 3.3.8

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