@adversity/coding-tool-x 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/LICENSE +21 -0
  3. package/README.md +404 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
  6. package/dist/web/assets/index-aL3cKxSK.css +41 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/index.html +14 -0
  9. package/dist/web/logo.png +0 -0
  10. package/docs/CHANGELOG.md +582 -0
  11. package/docs/DIRECTORY_MIGRATION.md +112 -0
  12. package/docs/PROJECT_STRUCTURE.md +396 -0
  13. package/docs/bannel.png +0 -0
  14. package/docs/home.png +0 -0
  15. package/docs/logo.png +0 -0
  16. package/docs/multi-channel-load-balancing.md +249 -0
  17. package/package.json +73 -0
  18. package/src/commands/channels.js +504 -0
  19. package/src/commands/cli-type.js +99 -0
  20. package/src/commands/daemon.js +286 -0
  21. package/src/commands/doctor.js +332 -0
  22. package/src/commands/list.js +222 -0
  23. package/src/commands/logs.js +259 -0
  24. package/src/commands/port-config.js +115 -0
  25. package/src/commands/proxy-control.js +258 -0
  26. package/src/commands/proxy.js +152 -0
  27. package/src/commands/resume.js +137 -0
  28. package/src/commands/search.js +190 -0
  29. package/src/commands/stats.js +224 -0
  30. package/src/commands/switch.js +48 -0
  31. package/src/commands/toggle-proxy.js +222 -0
  32. package/src/commands/ui.js +92 -0
  33. package/src/commands/workspace.js +454 -0
  34. package/src/config/default.js +40 -0
  35. package/src/config/loader.js +75 -0
  36. package/src/config/paths.js +121 -0
  37. package/src/index.js +373 -0
  38. package/src/reset-config.js +92 -0
  39. package/src/server/api/agents.js +248 -0
  40. package/src/server/api/aliases.js +36 -0
  41. package/src/server/api/channels.js +258 -0
  42. package/src/server/api/claude-hooks.js +480 -0
  43. package/src/server/api/codex-channels.js +312 -0
  44. package/src/server/api/codex-projects.js +91 -0
  45. package/src/server/api/codex-proxy.js +182 -0
  46. package/src/server/api/codex-sessions.js +491 -0
  47. package/src/server/api/codex-statistics.js +57 -0
  48. package/src/server/api/commands.js +245 -0
  49. package/src/server/api/config-templates.js +182 -0
  50. package/src/server/api/config.js +147 -0
  51. package/src/server/api/convert.js +127 -0
  52. package/src/server/api/dashboard.js +125 -0
  53. package/src/server/api/env.js +144 -0
  54. package/src/server/api/favorites.js +77 -0
  55. package/src/server/api/gemini-channels.js +261 -0
  56. package/src/server/api/gemini-projects.js +91 -0
  57. package/src/server/api/gemini-proxy.js +160 -0
  58. package/src/server/api/gemini-sessions.js +397 -0
  59. package/src/server/api/gemini-statistics.js +57 -0
  60. package/src/server/api/health-check.js +118 -0
  61. package/src/server/api/mcp.js +336 -0
  62. package/src/server/api/pm2-autostart.js +269 -0
  63. package/src/server/api/projects.js +124 -0
  64. package/src/server/api/prompts.js +279 -0
  65. package/src/server/api/proxy.js +235 -0
  66. package/src/server/api/rules.js +271 -0
  67. package/src/server/api/sessions.js +595 -0
  68. package/src/server/api/settings.js +61 -0
  69. package/src/server/api/skills.js +305 -0
  70. package/src/server/api/statistics.js +91 -0
  71. package/src/server/api/terminal.js +202 -0
  72. package/src/server/api/ui-config.js +64 -0
  73. package/src/server/api/workspaces.js +407 -0
  74. package/src/server/codex-proxy-server.js +538 -0
  75. package/src/server/dev-server.js +26 -0
  76. package/src/server/gemini-proxy-server.js +518 -0
  77. package/src/server/index.js +305 -0
  78. package/src/server/proxy-server.js +469 -0
  79. package/src/server/services/agents-service.js +354 -0
  80. package/src/server/services/alias.js +71 -0
  81. package/src/server/services/channel-health.js +234 -0
  82. package/src/server/services/channel-scheduler.js +234 -0
  83. package/src/server/services/channels.js +347 -0
  84. package/src/server/services/codex-channels.js +625 -0
  85. package/src/server/services/codex-config.js +90 -0
  86. package/src/server/services/codex-parser.js +322 -0
  87. package/src/server/services/codex-sessions.js +665 -0
  88. package/src/server/services/codex-settings-manager.js +397 -0
  89. package/src/server/services/codex-speed-test-template.json +24 -0
  90. package/src/server/services/codex-statistics-service.js +255 -0
  91. package/src/server/services/commands-service.js +360 -0
  92. package/src/server/services/config-templates-service.js +732 -0
  93. package/src/server/services/env-checker.js +307 -0
  94. package/src/server/services/env-manager.js +300 -0
  95. package/src/server/services/favorites.js +163 -0
  96. package/src/server/services/gemini-channels.js +333 -0
  97. package/src/server/services/gemini-config.js +73 -0
  98. package/src/server/services/gemini-sessions.js +689 -0
  99. package/src/server/services/gemini-settings-manager.js +263 -0
  100. package/src/server/services/gemini-statistics-service.js +253 -0
  101. package/src/server/services/health-check.js +399 -0
  102. package/src/server/services/mcp-service.js +1188 -0
  103. package/src/server/services/prompts-service.js +492 -0
  104. package/src/server/services/proxy-runtime.js +79 -0
  105. package/src/server/services/pty-manager.js +435 -0
  106. package/src/server/services/rules-service.js +401 -0
  107. package/src/server/services/session-cache.js +127 -0
  108. package/src/server/services/session-converter.js +577 -0
  109. package/src/server/services/sessions.js +757 -0
  110. package/src/server/services/settings-manager.js +163 -0
  111. package/src/server/services/skill-service.js +965 -0
  112. package/src/server/services/speed-test.js +545 -0
  113. package/src/server/services/statistics-service.js +386 -0
  114. package/src/server/services/terminal-commands.js +155 -0
  115. package/src/server/services/terminal-config.js +140 -0
  116. package/src/server/services/terminal-detector.js +306 -0
  117. package/src/server/services/ui-config.js +130 -0
  118. package/src/server/services/workspace-service.js +662 -0
  119. package/src/server/utils/pricing.js +41 -0
  120. package/src/server/websocket-server.js +557 -0
  121. package/src/ui/menu.js +129 -0
  122. package/src/ui/prompts.js +100 -0
  123. package/src/utils/format.js +43 -0
  124. package/src/utils/port-helper.js +94 -0
  125. package/src/utils/session.js +239 -0
@@ -0,0 +1,965 @@
1
+ /**
2
+ * Skills 技能服务
3
+ *
4
+ * 管理 Claude Code Skills 的获取、安装、卸载
5
+ * Skills 安装目录: ~/.claude/skills/
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const { createWriteStream } = require('fs');
14
+ const { pipeline } = require('stream/promises');
15
+ const AdmZip = require('adm-zip');
16
+
17
+ // 默认仓库源 - 只预设官方仓库,其他由用户手动添加
18
+ const DEFAULT_REPOS = [
19
+ { owner: 'anthropics', name: 'skills', branch: 'main', enabled: true }
20
+ ];
21
+
22
+ // 缓存有效期(5分钟)
23
+ const CACHE_TTL = 5 * 60 * 1000;
24
+
25
+ class SkillService {
26
+ constructor() {
27
+ this.installDir = path.join(os.homedir(), '.claude', 'skills');
28
+ this.configDir = path.join(os.homedir(), '.claude', 'cc-tool');
29
+ this.reposConfigPath = path.join(this.configDir, 'skill-repos.json');
30
+ this.cachePath = path.join(this.configDir, 'skills-cache.json');
31
+
32
+ // 内存缓存
33
+ this.skillsCache = null;
34
+ this.cacheTime = 0;
35
+
36
+ // 确保目录存在
37
+ this.ensureDirs();
38
+ }
39
+
40
+ ensureDirs() {
41
+ if (!fs.existsSync(this.installDir)) {
42
+ fs.mkdirSync(this.installDir, { recursive: true });
43
+ }
44
+ if (!fs.existsSync(this.configDir)) {
45
+ fs.mkdirSync(this.configDir, { recursive: true });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 加载仓库配置
51
+ */
52
+ loadRepos() {
53
+ try {
54
+ if (fs.existsSync(this.reposConfigPath)) {
55
+ const data = JSON.parse(fs.readFileSync(this.reposConfigPath, 'utf-8'));
56
+ return data.repos || DEFAULT_REPOS;
57
+ }
58
+ } catch (err) {
59
+ console.error('[SkillService] Load repos config error:', err.message);
60
+ }
61
+ return DEFAULT_REPOS;
62
+ }
63
+
64
+ /**
65
+ * 保存仓库配置
66
+ */
67
+ saveRepos(repos) {
68
+ fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos }, null, 2));
69
+ }
70
+
71
+ /**
72
+ * 添加仓库
73
+ */
74
+ addRepo(repo) {
75
+ const repos = this.loadRepos();
76
+ const existingIndex = repos.findIndex(r => r.owner === repo.owner && r.name === repo.name);
77
+
78
+ if (existingIndex >= 0) {
79
+ repos[existingIndex] = repo;
80
+ } else {
81
+ repos.push(repo);
82
+ }
83
+
84
+ this.saveRepos(repos);
85
+ return repos;
86
+ }
87
+
88
+ /**
89
+ * 删除仓库
90
+ */
91
+ removeRepo(owner, name) {
92
+ const repos = this.loadRepos();
93
+ const filtered = repos.filter(r => !(r.owner === owner && r.name === name));
94
+ this.saveRepos(filtered);
95
+ return filtered;
96
+ }
97
+
98
+ /**
99
+ * 切换仓库启用状态
100
+ */
101
+ toggleRepo(owner, name, enabled) {
102
+ const repos = this.loadRepos();
103
+ const repo = repos.find(r => r.owner === owner && r.name === name);
104
+ if (repo) {
105
+ repo.enabled = enabled;
106
+ this.saveRepos(repos);
107
+ }
108
+ return repos;
109
+ }
110
+
111
+ /**
112
+ * 获取所有技能列表(带缓存)
113
+ */
114
+ async listSkills(forceRefresh = false) {
115
+ // 强制刷新时清除缓存
116
+ if (forceRefresh) {
117
+ this.skillsCache = null;
118
+ this.cacheTime = 0;
119
+ // 删除文件缓存
120
+ try {
121
+ if (fs.existsSync(this.cachePath)) {
122
+ fs.unlinkSync(this.cachePath);
123
+ }
124
+ } catch (err) {
125
+ console.warn('[SkillService] Failed to delete cache file:', err.message);
126
+ }
127
+ }
128
+
129
+ // 检查内存缓存
130
+ if (!forceRefresh && this.skillsCache && (Date.now() - this.cacheTime < CACHE_TTL)) {
131
+ this.updateInstallStatus(this.skillsCache);
132
+ return this.skillsCache;
133
+ }
134
+
135
+ // 检查文件缓存
136
+ if (!forceRefresh) {
137
+ const fileCache = this.loadCacheFromFile();
138
+ if (fileCache) {
139
+ this.skillsCache = fileCache;
140
+ this.cacheTime = Date.now();
141
+ this.updateInstallStatus(this.skillsCache);
142
+ return this.skillsCache;
143
+ }
144
+ }
145
+
146
+ const repos = this.loadRepos();
147
+ const skills = [];
148
+
149
+ // 并行获取所有启用仓库的技能(带超时保护)
150
+ const enabledRepos = repos.filter(r => r.enabled);
151
+
152
+ if (enabledRepos.length > 0) {
153
+ const results = await Promise.allSettled(
154
+ enabledRepos.map(repo =>
155
+ Promise.race([
156
+ this.fetchRepoSkills(repo),
157
+ new Promise((_, reject) =>
158
+ setTimeout(() => reject(new Error('Fetch timeout')), 30000) // 30秒超时
159
+ )
160
+ ])
161
+ )
162
+ );
163
+
164
+ for (let i = 0; i < results.length; i++) {
165
+ const result = results[i];
166
+ const repoInfo = `${enabledRepos[i].owner}/${enabledRepos[i].name}`;
167
+ if (result.status === 'fulfilled') {
168
+ skills.push(...result.value);
169
+ } else {
170
+ console.warn(`[SkillService] Fetch repo ${repoInfo} failed:`, result.reason?.message);
171
+ }
172
+ }
173
+ }
174
+
175
+ // 合并本地已安装的技能
176
+ this.mergeLocalSkills(skills);
177
+
178
+ // 去重并排序
179
+ this.deduplicateSkills(skills);
180
+ skills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
181
+
182
+ // 更新缓存
183
+ this.skillsCache = skills;
184
+ this.cacheTime = Date.now();
185
+ this.saveCacheToFile(skills);
186
+
187
+ return skills;
188
+ }
189
+
190
+ /**
191
+ * 从文件加载缓存
192
+ */
193
+ loadCacheFromFile() {
194
+ try {
195
+ if (fs.existsSync(this.cachePath)) {
196
+ const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
197
+ if (data.time && (Date.now() - data.time < CACHE_TTL)) {
198
+ return data.skills;
199
+ }
200
+ }
201
+ } catch (err) {
202
+ // 忽略缓存读取错误
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * 保存缓存到文件
209
+ */
210
+ saveCacheToFile(skills) {
211
+ try {
212
+ fs.writeFileSync(this.cachePath, JSON.stringify({
213
+ time: Date.now(),
214
+ skills
215
+ }));
216
+ } catch (err) {
217
+ // 忽略缓存写入错误
218
+ }
219
+ }
220
+
221
+ /**
222
+ * 更新技能的安装状态
223
+ */
224
+ updateInstallStatus(skills) {
225
+ for (const skill of skills) {
226
+ skill.installed = this.isInstalled(skill.directory);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 从 GitHub 仓库获取技能列表(使用 Tree API 一次性获取)
232
+ */
233
+ async fetchRepoSkills(repo) {
234
+ const skills = [];
235
+
236
+ try {
237
+ // 使用 GitHub Tree API 一次性获取所有文件
238
+ const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
239
+ const tree = await this.fetchGitHubApi(treeUrl);
240
+
241
+ if (!tree || !tree.tree) {
242
+ console.warn(`[SkillService] Empty tree for ${repo.owner}/${repo.name}`);
243
+ return skills;
244
+ }
245
+
246
+ // 找到所有 SKILL.md 文件
247
+ const skillFiles = tree.tree.filter(item =>
248
+ item.type === 'blob' && item.path.endsWith('/SKILL.md')
249
+ );
250
+
251
+ // 并行获取所有 SKILL.md 的内容(限制并发数)
252
+ const batchSize = 5;
253
+
254
+ for (let i = 0; i < skillFiles.length; i += batchSize) {
255
+ const batch = skillFiles.slice(i, i + batchSize);
256
+ const results = await Promise.allSettled(
257
+ batch.map(file => this.fetchAndParseSkill(file, repo))
258
+ );
259
+
260
+ for (const result of results) {
261
+ if (result.status === 'fulfilled' && result.value) {
262
+ skills.push(result.value);
263
+ }
264
+ }
265
+ }
266
+ } catch (err) {
267
+ console.error(`[SkillService] Fetch repo ${repo.owner}/${repo.name} error:`, err.message);
268
+ throw err;
269
+ }
270
+
271
+ return skills;
272
+ }
273
+
274
+ /**
275
+ * 获取并解析单个 SKILL.md
276
+ */
277
+ async fetchAndParseSkill(file, repo) {
278
+ try {
279
+ // 从路径提取目录名 (e.g., "algorithmic-art/SKILL.md" -> "algorithmic-art")
280
+ const directory = file.path.replace(/\/SKILL\.md$/, '');
281
+
282
+ // 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
283
+ const content = await this.fetchBlobContent(file.sha, repo, file.path);
284
+ const metadata = this.parseSkillMd(content);
285
+
286
+ return {
287
+ key: `${repo.owner}/${repo.name}:${directory}`,
288
+ name: metadata.name || directory.split('/').pop(),
289
+ description: metadata.description || '',
290
+ directory,
291
+ installed: this.isInstalled(directory),
292
+ readmeUrl: `https://github.com/${repo.owner}/${repo.name}/tree/${repo.branch}/${directory}`,
293
+ repoOwner: repo.owner,
294
+ repoName: repo.name,
295
+ repoBranch: repo.branch,
296
+ license: metadata.license
297
+ };
298
+ } catch (err) {
299
+ console.warn(`[SkillService] Parse skill ${file.path} error:`, err.message);
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * 使用 raw.githubusercontent.com 获取文件内容(不消耗 API 限额)
306
+ */
307
+ async fetchBlobContent(sha, repo, filePath) {
308
+ // raw.githubusercontent.com 不走 API 限流
309
+ const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
310
+
311
+ return new Promise((resolve, reject) => {
312
+ const req = https.get(url, {
313
+ headers: {
314
+ 'User-Agent': 'cc-cli-skill-service'
315
+ },
316
+ timeout: 15000
317
+ }, (res) => {
318
+ // 处理重定向
319
+ if (res.statusCode === 301 || res.statusCode === 302) {
320
+ const redirectUrl = res.headers.location;
321
+ if (redirectUrl) {
322
+ https.get(redirectUrl, {
323
+ headers: { 'User-Agent': 'cc-cli-skill-service' },
324
+ timeout: 15000
325
+ }, (res2) => {
326
+ let data = '';
327
+ res2.on('data', chunk => data += chunk);
328
+ res2.on('end', () => {
329
+ if (res2.statusCode === 200) {
330
+ resolve(data);
331
+ } else {
332
+ reject(new Error(`Raw fetch error: ${res2.statusCode}`));
333
+ }
334
+ });
335
+ }).on('error', reject);
336
+ return;
337
+ }
338
+ }
339
+
340
+ let data = '';
341
+ res.on('data', chunk => data += chunk);
342
+ res.on('end', () => {
343
+ if (res.statusCode === 200) {
344
+ resolve(data);
345
+ } else {
346
+ reject(new Error(`Raw fetch error: ${res.statusCode}`));
347
+ }
348
+ });
349
+ });
350
+
351
+ req.on('error', reject);
352
+ req.on('timeout', () => {
353
+ req.destroy();
354
+ reject(new Error('Raw fetch timeout'));
355
+ });
356
+ });
357
+ }
358
+
359
+ /**
360
+ * 获取 GitHub Token(从环境变量或配置文件)
361
+ */
362
+ getGitHubToken() {
363
+ // 优先从环境变量获取
364
+ if (process.env.GITHUB_TOKEN) {
365
+ return process.env.GITHUB_TOKEN;
366
+ }
367
+ // 从配置文件获取
368
+ try {
369
+ const configPath = path.join(this.configDir, 'github-token.txt');
370
+ if (fs.existsSync(configPath)) {
371
+ return fs.readFileSync(configPath, 'utf-8').trim();
372
+ }
373
+ } catch (err) {
374
+ // ignore
375
+ }
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * 通用 GitHub API 请求
381
+ */
382
+ async fetchGitHubApi(url) {
383
+ const token = this.getGitHubToken();
384
+ const headers = {
385
+ 'User-Agent': 'cc-cli-skill-service',
386
+ 'Accept': 'application/vnd.github.v3+json'
387
+ };
388
+ if (token) {
389
+ headers['Authorization'] = `token ${token}`;
390
+ }
391
+
392
+ return new Promise((resolve, reject) => {
393
+ const req = https.get(url, {
394
+ headers,
395
+ timeout: 15000
396
+ }, (res) => {
397
+ let data = '';
398
+ res.on('data', chunk => data += chunk);
399
+ res.on('end', () => {
400
+ if (res.statusCode === 200) {
401
+ try {
402
+ resolve(JSON.parse(data));
403
+ } catch (e) {
404
+ reject(new Error('Invalid JSON response'));
405
+ }
406
+ } else {
407
+ reject(new Error(`GitHub API error: ${res.statusCode}`));
408
+ }
409
+ });
410
+ });
411
+
412
+ req.on('error', reject);
413
+ req.on('timeout', () => {
414
+ req.destroy();
415
+ reject(new Error('Request timeout'));
416
+ });
417
+ });
418
+ }
419
+
420
+ /**
421
+ * 使用 GitHub API 获取目录内容
422
+ */
423
+ async fetchGitHubContents(owner, name, path, branch) {
424
+ const url = `https://api.github.com/repos/${owner}/${name}/contents/${path}?ref=${branch}`;
425
+
426
+ return new Promise((resolve, reject) => {
427
+ const req = https.get(url, {
428
+ headers: {
429
+ 'User-Agent': 'cc-cli-skill-service',
430
+ 'Accept': 'application/vnd.github.v3+json'
431
+ },
432
+ timeout: 15000
433
+ }, (res) => {
434
+ let data = '';
435
+ res.on('data', chunk => data += chunk);
436
+ res.on('end', () => {
437
+ if (res.statusCode === 200) {
438
+ try {
439
+ resolve(JSON.parse(data));
440
+ } catch (e) {
441
+ reject(new Error('Invalid JSON response'));
442
+ }
443
+ } else if (res.statusCode === 404) {
444
+ resolve([]);
445
+ } else {
446
+ reject(new Error(`GitHub API error: ${res.statusCode}`));
447
+ }
448
+ });
449
+ });
450
+
451
+ req.on('error', reject);
452
+ req.on('timeout', () => {
453
+ req.destroy();
454
+ reject(new Error('Request timeout'));
455
+ });
456
+ });
457
+ }
458
+
459
+ /**
460
+ * 递归扫描仓库内容查找 SKILL.md
461
+ */
462
+ async scanRepoContents(contents, repo, currentPath, skills) {
463
+ if (!Array.isArray(contents)) return;
464
+
465
+ // 检查当前目录是否有 SKILL.md
466
+ const skillMd = contents.find(item => item.name === 'SKILL.md' && item.type === 'file');
467
+
468
+ if (skillMd) {
469
+ // 找到技能,解析元数据
470
+ try {
471
+ const skillContent = await this.fetchFileContent(skillMd.download_url);
472
+ const metadata = this.parseSkillMd(skillContent);
473
+
474
+ const directory = currentPath || repo.name;
475
+
476
+ skills.push({
477
+ key: `${repo.owner}/${repo.name}:${directory}`,
478
+ name: metadata.name || directory,
479
+ description: metadata.description || '',
480
+ directory,
481
+ installed: this.isInstalled(directory),
482
+ readmeUrl: `https://github.com/${repo.owner}/${repo.name}/tree/${repo.branch}/${currentPath}`,
483
+ repoOwner: repo.owner,
484
+ repoName: repo.name,
485
+ repoBranch: repo.branch,
486
+ license: metadata.license
487
+ });
488
+ } catch (err) {
489
+ console.warn(`[SkillService] Parse SKILL.md at ${currentPath} error:`, err.message);
490
+ }
491
+
492
+ // 找到 SKILL.md 后不再递归子目录
493
+ return;
494
+ }
495
+
496
+ // 递归扫描子目录
497
+ const dirs = contents.filter(item => item.type === 'dir');
498
+ for (const dir of dirs) {
499
+ // 跳过隐藏目录和特殊目录
500
+ if (dir.name.startsWith('.') || dir.name === 'node_modules') continue;
501
+
502
+ try {
503
+ const subContents = await this.fetchGitHubContents(repo.owner, repo.name, dir.path, repo.branch);
504
+ await this.scanRepoContents(subContents, repo, dir.path, skills);
505
+ } catch (err) {
506
+ // 忽略子目录错误,继续扫描
507
+ }
508
+ }
509
+ }
510
+
511
+ /**
512
+ * 获取文件内容
513
+ */
514
+ async fetchFileContent(url) {
515
+ return new Promise((resolve, reject) => {
516
+ const protocol = url.startsWith('https') ? https : http;
517
+
518
+ const req = protocol.get(url, {
519
+ headers: { 'User-Agent': 'cc-cli-skill-service' },
520
+ timeout: 10000
521
+ }, (res) => {
522
+ // 处理重定向
523
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
524
+ this.fetchFileContent(res.headers.location).then(resolve).catch(reject);
525
+ return;
526
+ }
527
+
528
+ let data = '';
529
+ res.on('data', chunk => data += chunk);
530
+ res.on('end', () => {
531
+ if (res.statusCode === 200) {
532
+ resolve(data);
533
+ } else {
534
+ reject(new Error(`HTTP ${res.statusCode}`));
535
+ }
536
+ });
537
+ });
538
+
539
+ req.on('error', reject);
540
+ req.on('timeout', () => {
541
+ req.destroy();
542
+ reject(new Error('Request timeout'));
543
+ });
544
+ });
545
+ }
546
+
547
+ /**
548
+ * 解析 SKILL.md 文件
549
+ */
550
+ parseSkillMd(content) {
551
+ const result = {
552
+ name: null,
553
+ description: null,
554
+ license: null,
555
+ allowedTools: [],
556
+ metadata: {}
557
+ };
558
+
559
+ // 移除 BOM
560
+ content = content.trim().replace(/^\uFEFF/, '');
561
+
562
+ // 解析 YAML frontmatter
563
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
564
+ if (!match) return result;
565
+
566
+ const frontmatter = match[1];
567
+
568
+ // 简单解析 YAML
569
+ const lines = frontmatter.split('\n');
570
+ for (const line of lines) {
571
+ const colonIndex = line.indexOf(':');
572
+ if (colonIndex === -1) continue;
573
+
574
+ const key = line.slice(0, colonIndex).trim();
575
+ let value = line.slice(colonIndex + 1).trim();
576
+
577
+ // 去除引号
578
+ if ((value.startsWith('"') && value.endsWith('"')) ||
579
+ (value.startsWith("'") && value.endsWith("'"))) {
580
+ value = value.slice(1, -1);
581
+ }
582
+
583
+ switch (key) {
584
+ case 'name':
585
+ result.name = value;
586
+ break;
587
+ case 'description':
588
+ result.description = value;
589
+ break;
590
+ case 'license':
591
+ result.license = value;
592
+ break;
593
+ }
594
+ }
595
+
596
+ return result;
597
+ }
598
+
599
+ /**
600
+ * 检查技能是否已安装
601
+ */
602
+ isInstalled(directory) {
603
+ const skillPath = path.join(this.installDir, directory);
604
+ const skillMdPath = path.join(skillPath, 'SKILL.md');
605
+ return fs.existsSync(skillMdPath);
606
+ }
607
+
608
+ /**
609
+ * 合并本地已安装的技能
610
+ */
611
+ mergeLocalSkills(skills) {
612
+ if (!fs.existsSync(this.installDir)) return;
613
+
614
+ // 递归扫描本地技能目录
615
+ this.scanLocalDir(this.installDir, this.installDir, skills);
616
+ }
617
+
618
+ /**
619
+ * 递归扫描本地目录
620
+ */
621
+ scanLocalDir(currentDir, baseDir, skills) {
622
+ const skillMdPath = path.join(currentDir, 'SKILL.md');
623
+
624
+ if (fs.existsSync(skillMdPath)) {
625
+ const directory = currentDir === baseDir
626
+ ? path.basename(currentDir)
627
+ : path.relative(baseDir, currentDir);
628
+
629
+ // 检查是否已在列表中(比较目录名,去掉前缀路径)
630
+ const dirName = directory.split('/').pop().toLowerCase();
631
+ const existing = skills.find(s => {
632
+ const remoteDirName = s.directory.split('/').pop().toLowerCase();
633
+ return remoteDirName === dirName;
634
+ });
635
+
636
+ if (existing) {
637
+ existing.installed = true;
638
+ } else {
639
+ // 添加本地独有的技能
640
+ try {
641
+ const content = fs.readFileSync(skillMdPath, 'utf-8');
642
+ const metadata = this.parseSkillMd(content);
643
+
644
+ skills.push({
645
+ key: `local:${directory}`,
646
+ name: metadata.name || directory,
647
+ description: metadata.description || '',
648
+ directory,
649
+ installed: true,
650
+ readmeUrl: null,
651
+ repoOwner: null,
652
+ repoName: null,
653
+ repoBranch: null,
654
+ license: metadata.license
655
+ });
656
+ } catch (err) {
657
+ console.warn(`[SkillService] Parse local skill ${directory} error:`, err.message);
658
+ }
659
+ }
660
+
661
+ return; // 找到 SKILL.md 后不再递归
662
+ }
663
+
664
+ // 递归子目录
665
+ try {
666
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
667
+ for (const entry of entries) {
668
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
669
+ this.scanLocalDir(path.join(currentDir, entry.name), baseDir, skills);
670
+ }
671
+ }
672
+ } catch (err) {
673
+ // 忽略读取错误
674
+ }
675
+ }
676
+
677
+ /**
678
+ * 去重技能列表
679
+ */
680
+ deduplicateSkills(skills) {
681
+ const seen = new Map();
682
+
683
+ for (let i = skills.length - 1; i >= 0; i--) {
684
+ const skill = skills[i];
685
+ // 使用目录名(不含路径前缀)作为去重 key
686
+ const key = skill.directory.split('/').pop().toLowerCase();
687
+
688
+ if (seen.has(key)) {
689
+ // 保留已安装的版本
690
+ const existingIndex = seen.get(key);
691
+ if (skill.installed && !skills[existingIndex].installed) {
692
+ skills.splice(existingIndex, 1);
693
+ seen.set(key, i - 1);
694
+ } else {
695
+ skills.splice(i, 1);
696
+ }
697
+ } else {
698
+ seen.set(key, i);
699
+ }
700
+ }
701
+ }
702
+
703
+ /**
704
+ * 安装技能
705
+ */
706
+ async installSkill(directory, repo) {
707
+ const dest = path.join(this.installDir, directory);
708
+
709
+ // 已安装则跳过
710
+ if (fs.existsSync(dest)) {
711
+ return { success: true, message: 'Already installed' };
712
+ }
713
+
714
+ // 下载仓库 ZIP
715
+ const zipUrl = `https://github.com/${repo.owner}/${repo.name}/archive/refs/heads/${repo.branch}.zip`;
716
+ const tempDir = path.join(os.tmpdir(), `skill-${Date.now()}`);
717
+ const zipPath = path.join(tempDir, 'repo.zip');
718
+
719
+ try {
720
+ fs.mkdirSync(tempDir, { recursive: true });
721
+
722
+ // 下载 ZIP
723
+ await this.downloadFile(zipUrl, zipPath);
724
+
725
+ // 解压
726
+ const zip = new AdmZip(zipPath);
727
+ zip.extractAllTo(tempDir, true);
728
+
729
+ // 找到解压后的目录(GitHub ZIP 会有一个根目录)
730
+ const extractedDirs = fs.readdirSync(tempDir).filter(f =>
731
+ fs.statSync(path.join(tempDir, f)).isDirectory()
732
+ );
733
+
734
+ if (extractedDirs.length === 0) {
735
+ throw new Error('Empty archive');
736
+ }
737
+
738
+ const repoDir = path.join(tempDir, extractedDirs[0]);
739
+ const sourceDir = path.join(repoDir, directory);
740
+
741
+ if (!fs.existsSync(sourceDir)) {
742
+ throw new Error(`Skill directory not found: ${directory}`);
743
+ }
744
+
745
+ // 复制到安装目录
746
+ fs.mkdirSync(dest, { recursive: true });
747
+ this.copyDirRecursive(sourceDir, dest);
748
+
749
+ // 清除缓存,让列表刷新
750
+ this.skillsCache = null;
751
+ this.cacheTime = 0;
752
+
753
+ return { success: true, message: 'Installed successfully' };
754
+ } finally {
755
+ // 清理临时目录
756
+ try {
757
+ fs.rmSync(tempDir, { recursive: true, force: true });
758
+ } catch (e) {
759
+ // 忽略清理错误
760
+ }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * 下载文件
766
+ */
767
+ async downloadFile(url, dest) {
768
+ return new Promise((resolve, reject) => {
769
+ const file = createWriteStream(dest);
770
+
771
+ const request = https.get(url, {
772
+ headers: { 'User-Agent': 'cc-cli-skill-service' },
773
+ timeout: 60000
774
+ }, (response) => {
775
+ // 处理重定向
776
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
777
+ file.close();
778
+ this.downloadFile(response.headers.location, dest).then(resolve).catch(reject);
779
+ return;
780
+ }
781
+
782
+ if (response.statusCode !== 200) {
783
+ file.close();
784
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
785
+ return;
786
+ }
787
+
788
+ response.pipe(file);
789
+ file.on('finish', () => {
790
+ file.close();
791
+ resolve();
792
+ });
793
+ });
794
+
795
+ request.on('error', (err) => {
796
+ file.close();
797
+ fs.unlink(dest, () => {});
798
+ reject(err);
799
+ });
800
+
801
+ request.on('timeout', () => {
802
+ request.destroy();
803
+ file.close();
804
+ fs.unlink(dest, () => {});
805
+ reject(new Error('Download timeout'));
806
+ });
807
+ });
808
+ }
809
+
810
+ /**
811
+ * 递归复制目录
812
+ */
813
+ copyDirRecursive(src, dest) {
814
+ const entries = fs.readdirSync(src, { withFileTypes: true });
815
+
816
+ for (const entry of entries) {
817
+ const srcPath = path.join(src, entry.name);
818
+ const destPath = path.join(dest, entry.name);
819
+
820
+ if (entry.isDirectory()) {
821
+ fs.mkdirSync(destPath, { recursive: true });
822
+ this.copyDirRecursive(srcPath, destPath);
823
+ } else {
824
+ fs.copyFileSync(srcPath, destPath);
825
+ }
826
+ }
827
+ }
828
+
829
+ /**
830
+ * 创建自定义技能
831
+ */
832
+ createCustomSkill({ name, directory, description, content }) {
833
+ const dest = path.join(this.installDir, directory);
834
+
835
+ // 检查是否已存在
836
+ if (fs.existsSync(dest)) {
837
+ throw new Error(`技能目录 "${directory}" 已存在`);
838
+ }
839
+
840
+ // 创建目录
841
+ fs.mkdirSync(dest, { recursive: true });
842
+
843
+ // 生成 SKILL.md 内容
844
+ const skillMdContent = `---
845
+ name: "${name}"
846
+ description: "${description}"
847
+ ---
848
+
849
+ ${content}
850
+ `;
851
+
852
+ // 写入文件
853
+ fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
854
+
855
+ // 清除缓存,让列表刷新
856
+ this.skillsCache = null;
857
+ this.cacheTime = 0;
858
+
859
+ return { success: true, message: '技能创建成功', directory };
860
+ }
861
+
862
+ /**
863
+ * 卸载技能
864
+ */
865
+ uninstallSkill(directory) {
866
+ const dest = path.join(this.installDir, directory);
867
+
868
+ if (fs.existsSync(dest)) {
869
+ fs.rmSync(dest, { recursive: true, force: true });
870
+ // 清除缓存
871
+ this.skillsCache = null;
872
+ this.cacheTime = 0;
873
+ return { success: true, message: 'Uninstalled successfully' };
874
+ }
875
+
876
+ return { success: true, message: 'Not installed' };
877
+ }
878
+
879
+ /**
880
+ * 获取技能详情(完整内容)
881
+ */
882
+ async getSkillDetail(directory) {
883
+ // 先检查本地是否安装
884
+ const localPath = path.join(this.installDir, directory, 'SKILL.md');
885
+
886
+ if (fs.existsSync(localPath)) {
887
+ const content = fs.readFileSync(localPath, 'utf-8');
888
+ const metadata = this.parseSkillMd(content);
889
+
890
+ // 提取正文内容(去除 frontmatter)
891
+ const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
892
+ const body = bodyMatch ? bodyMatch[1].trim() : content;
893
+
894
+ return {
895
+ directory,
896
+ name: metadata.name || directory,
897
+ description: metadata.description || '',
898
+ content: body,
899
+ fullContent: content,
900
+ installed: true,
901
+ source: 'local'
902
+ };
903
+ }
904
+
905
+ // 如果本地没有,尝试从缓存的技能列表中获取仓库信息
906
+ const cachedSkill = this.skillsCache?.find(s => s.directory === directory);
907
+
908
+ if (cachedSkill && cachedSkill.repoOwner && cachedSkill.repoName) {
909
+ // 从 GitHub 获取内容
910
+ try {
911
+ const repo = {
912
+ owner: cachedSkill.repoOwner,
913
+ name: cachedSkill.repoName,
914
+ branch: cachedSkill.repoBranch || 'main'
915
+ };
916
+
917
+ // 获取文件树找到 SKILL.md 的 SHA
918
+ const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
919
+ const tree = await this.fetchGitHubApi(treeUrl);
920
+
921
+ const skillFile = tree.tree?.find(item =>
922
+ item.type === 'blob' && item.path === `${directory}/SKILL.md`
923
+ );
924
+
925
+ if (skillFile) {
926
+ const content = await this.fetchBlobContent(skillFile.sha, repo, skillFile.path);
927
+ const metadata = this.parseSkillMd(content);
928
+
929
+ const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
930
+ const body = bodyMatch ? bodyMatch[1].trim() : content;
931
+
932
+ return {
933
+ directory,
934
+ name: metadata.name || directory,
935
+ description: metadata.description || '',
936
+ content: body,
937
+ fullContent: content,
938
+ installed: false,
939
+ source: 'github',
940
+ repoOwner: repo.owner,
941
+ repoName: repo.name
942
+ };
943
+ }
944
+ } catch (err) {
945
+ console.warn('[SkillService] Fetch remote skill detail error:', err.message);
946
+ }
947
+ }
948
+
949
+ throw new Error('技能不存在或无法获取');
950
+ }
951
+
952
+ /**
953
+ * 获取已安装技能列表
954
+ */
955
+ getInstalledSkills() {
956
+ const skills = [];
957
+ this.scanLocalDir(this.installDir, this.installDir, skills);
958
+ return skills;
959
+ }
960
+ }
961
+
962
+ module.exports = {
963
+ SkillService,
964
+ DEFAULT_REPOS
965
+ };