@adversity/coding-tool-x 2.2.0 → 2.3.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.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * 仓库扫描基础服务
3
+ *
4
+ * 提供从 GitHub 仓库扫描配置文件的通用能力
5
+ * 支持指定仓库的子目录路径
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 AdmZip = require('adm-zip');
15
+
16
+ // 缓存有效期(5分钟)
17
+ const CACHE_TTL = 5 * 60 * 1000;
18
+
19
+ /**
20
+ * 仓库配置结构
21
+ * @typedef {Object} RepoConfig
22
+ * @property {string} owner - 仓库所有者
23
+ * @property {string} name - 仓库名称
24
+ * @property {string} branch - 分支名称
25
+ * @property {string} [directory] - 扫描的子目录路径(可选,默认为根目录)
26
+ * @property {boolean} enabled - 是否启用
27
+ */
28
+
29
+ class RepoScannerBase {
30
+ /**
31
+ * @param {Object} options
32
+ * @param {string} options.type - 类型标识(commands/rules/agents)
33
+ * @param {string} options.installDir - 本地安装目录
34
+ * @param {string} options.markerFile - 标识文件名(如 SKILL.md, COMMAND.md 等,可选)
35
+ * @param {string} options.fileExtension - 文件扩展名(如 .md)
36
+ * @param {RepoConfig[]} options.defaultRepos - 默认仓库列表
37
+ */
38
+ constructor(options) {
39
+ this.type = options.type;
40
+ this.installDir = options.installDir;
41
+ this.markerFile = options.markerFile || null;
42
+ this.fileExtension = options.fileExtension || '.md';
43
+ this.defaultRepos = options.defaultRepos || [];
44
+
45
+ this.configDir = path.join(os.homedir(), '.claude', 'cc-tool');
46
+ this.reposConfigPath = path.join(this.configDir, `${this.type}-repos.json`);
47
+ this.cachePath = path.join(this.configDir, `${this.type}-cache.json`);
48
+
49
+ // 内存缓存
50
+ this.itemsCache = null;
51
+ this.cacheTime = 0;
52
+
53
+ // 确保目录存在
54
+ this.ensureDirs();
55
+ }
56
+
57
+ ensureDirs() {
58
+ if (!fs.existsSync(this.installDir)) {
59
+ fs.mkdirSync(this.installDir, { recursive: true });
60
+ }
61
+ if (!fs.existsSync(this.configDir)) {
62
+ fs.mkdirSync(this.configDir, { recursive: true });
63
+ }
64
+ }
65
+
66
+ // ==================== 仓库配置管理 ====================
67
+
68
+ /**
69
+ * 加载仓库配置
70
+ */
71
+ loadRepos() {
72
+ try {
73
+ if (fs.existsSync(this.reposConfigPath)) {
74
+ const data = JSON.parse(fs.readFileSync(this.reposConfigPath, 'utf-8'));
75
+ return data.repos || this.defaultRepos;
76
+ }
77
+ } catch (err) {
78
+ console.error(`[${this.type}RepoScanner] Load repos config error:`, err.message);
79
+ }
80
+ return this.defaultRepos;
81
+ }
82
+
83
+ /**
84
+ * 保存仓库配置
85
+ */
86
+ saveRepos(repos) {
87
+ fs.writeFileSync(this.reposConfigPath, JSON.stringify({ repos }, null, 2));
88
+ }
89
+
90
+ /**
91
+ * 添加仓库
92
+ * @param {RepoConfig} repo
93
+ */
94
+ addRepo(repo) {
95
+ const repos = this.loadRepos();
96
+ // 使用 owner/name/directory 作为唯一标识
97
+ const existingIndex = repos.findIndex(r =>
98
+ r.owner === repo.owner &&
99
+ r.name === repo.name &&
100
+ (r.directory || '') === (repo.directory || '')
101
+ );
102
+
103
+ if (existingIndex >= 0) {
104
+ repos[existingIndex] = repo;
105
+ } else {
106
+ repos.push(repo);
107
+ }
108
+
109
+ this.saveRepos(repos);
110
+ // 清除缓存
111
+ this.clearCache();
112
+ return repos;
113
+ }
114
+
115
+ /**
116
+ * 删除仓库
117
+ */
118
+ removeRepo(owner, name, directory = '') {
119
+ const repos = this.loadRepos();
120
+ const filtered = repos.filter(r => !(
121
+ r.owner === owner &&
122
+ r.name === name &&
123
+ (r.directory || '') === directory
124
+ ));
125
+ this.saveRepos(filtered);
126
+ this.clearCache();
127
+ return filtered;
128
+ }
129
+
130
+ /**
131
+ * 切换仓库启用状态
132
+ */
133
+ toggleRepo(owner, name, directory = '', enabled) {
134
+ const repos = this.loadRepos();
135
+ const repo = repos.find(r =>
136
+ r.owner === owner &&
137
+ r.name === name &&
138
+ (r.directory || '') === directory
139
+ );
140
+ if (repo) {
141
+ repo.enabled = enabled;
142
+ this.saveRepos(repos);
143
+ this.clearCache();
144
+ }
145
+ return repos;
146
+ }
147
+
148
+ /**
149
+ * 清除缓存
150
+ */
151
+ clearCache() {
152
+ this.itemsCache = null;
153
+ this.cacheTime = 0;
154
+ try {
155
+ if (fs.existsSync(this.cachePath)) {
156
+ fs.unlinkSync(this.cachePath);
157
+ }
158
+ } catch (err) {
159
+ // 忽略
160
+ }
161
+ }
162
+
163
+ // ==================== 远程仓库扫描 ====================
164
+
165
+ /**
166
+ * 获取所有项目列表(带缓存)
167
+ * @param {boolean} forceRefresh - 强制刷新
168
+ */
169
+ async listRemoteItems(forceRefresh = false) {
170
+ if (forceRefresh) {
171
+ this.clearCache();
172
+ }
173
+
174
+ // 检查内存缓存
175
+ if (!forceRefresh && this.itemsCache && (Date.now() - this.cacheTime < CACHE_TTL)) {
176
+ return this.itemsCache;
177
+ }
178
+
179
+ // 检查文件缓存
180
+ if (!forceRefresh) {
181
+ const fileCache = this.loadCacheFromFile();
182
+ if (fileCache) {
183
+ this.itemsCache = fileCache;
184
+ this.cacheTime = Date.now();
185
+ return this.itemsCache;
186
+ }
187
+ }
188
+
189
+ const repos = this.loadRepos();
190
+ const items = [];
191
+
192
+ // 并行获取所有启用仓库的项目
193
+ const enabledRepos = repos.filter(r => r.enabled);
194
+
195
+ if (enabledRepos.length > 0) {
196
+ const results = await Promise.allSettled(
197
+ enabledRepos.map(repo =>
198
+ Promise.race([
199
+ this.fetchRepoItems(repo),
200
+ new Promise((_, reject) =>
201
+ setTimeout(() => reject(new Error('Fetch timeout')), 30000)
202
+ )
203
+ ])
204
+ )
205
+ );
206
+
207
+ for (let i = 0; i < results.length; i++) {
208
+ const result = results[i];
209
+ const repoInfo = `${enabledRepos[i].owner}/${enabledRepos[i].name}`;
210
+ if (result.status === 'fulfilled') {
211
+ items.push(...result.value);
212
+ } else {
213
+ console.warn(`[${this.type}RepoScanner] Fetch repo ${repoInfo} failed:`, result.reason?.message);
214
+ }
215
+ }
216
+ }
217
+
218
+ // 去重并排序
219
+ this.deduplicateItems(items);
220
+ items.sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()));
221
+
222
+ // 更新缓存
223
+ this.itemsCache = items;
224
+ this.cacheTime = Date.now();
225
+ this.saveCacheToFile(items);
226
+
227
+ return items;
228
+ }
229
+
230
+ /**
231
+ * 从 GitHub 仓库获取项目列表
232
+ * @param {RepoConfig} repo
233
+ */
234
+ async fetchRepoItems(repo) {
235
+ const items = [];
236
+
237
+ try {
238
+ // 使用 GitHub Tree API 获取文件列表
239
+ const treeUrl = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/trees/${repo.branch}?recursive=1`;
240
+ const tree = await this.fetchGitHubApi(treeUrl);
241
+
242
+ if (!tree || !tree.tree) {
243
+ console.warn(`[${this.type}RepoScanner] Empty tree for ${repo.owner}/${repo.name}`);
244
+ return items;
245
+ }
246
+
247
+ // 过滤出目标目录下的文件
248
+ const baseDir = repo.directory || '';
249
+ const baseDirPrefix = baseDir ? `${baseDir}/` : '';
250
+
251
+ let targetFiles;
252
+
253
+ if (this.markerFile) {
254
+ // 查找标识文件(如 SKILL.md)
255
+ targetFiles = tree.tree.filter(item =>
256
+ item.type === 'blob' &&
257
+ item.path.startsWith(baseDirPrefix) &&
258
+ item.path.endsWith(`/${this.markerFile}`)
259
+ );
260
+ } else {
261
+ // 直接查找指定扩展名的文件
262
+ targetFiles = tree.tree.filter(item =>
263
+ item.type === 'blob' &&
264
+ item.path.startsWith(baseDirPrefix) &&
265
+ item.path.endsWith(this.fileExtension)
266
+ );
267
+ }
268
+
269
+ // 并行获取文件内容(限制并发数)
270
+ const batchSize = 5;
271
+
272
+ for (let i = 0; i < targetFiles.length; i += batchSize) {
273
+ const batch = targetFiles.slice(i, i + batchSize);
274
+ const results = await Promise.allSettled(
275
+ batch.map(file => this.fetchAndParseItem(file, repo, baseDir))
276
+ );
277
+
278
+ for (const result of results) {
279
+ if (result.status === 'fulfilled' && result.value) {
280
+ items.push(result.value);
281
+ }
282
+ }
283
+ }
284
+ } catch (err) {
285
+ console.error(`[${this.type}RepoScanner] Fetch repo ${repo.owner}/${repo.name} error:`, err.message);
286
+ throw err;
287
+ }
288
+
289
+ return items;
290
+ }
291
+
292
+ /**
293
+ * 获取并解析单个文件(子类需要重写)
294
+ * @param {Object} file - GitHub tree 文件对象
295
+ * @param {RepoConfig} repo - 仓库配置
296
+ * @param {string} baseDir - 基础目录
297
+ * @returns {Promise<Object|null>}
298
+ */
299
+ async fetchAndParseItem(file, repo, baseDir) {
300
+ // 子类需要重写此方法
301
+ throw new Error('fetchAndParseItem must be implemented by subclass');
302
+ }
303
+
304
+ /**
305
+ * 去重项目列表(子类可重写)
306
+ * @param {Array} items
307
+ */
308
+ deduplicateItems(items) {
309
+ const seen = new Map();
310
+
311
+ for (let i = items.length - 1; i >= 0; i--) {
312
+ const item = items[i];
313
+ const key = this.getDedupeKey(item);
314
+
315
+ if (seen.has(key)) {
316
+ // 保留已安装的版本
317
+ const existingIndex = seen.get(key);
318
+ if (item.installed && !items[existingIndex].installed) {
319
+ items.splice(existingIndex, 1);
320
+ seen.set(key, i - 1);
321
+ } else {
322
+ items.splice(i, 1);
323
+ }
324
+ } else {
325
+ seen.set(key, i);
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 获取去重 key(子类可重写)
332
+ */
333
+ getDedupeKey(item) {
334
+ return (item.name || item.fileName || '').toLowerCase();
335
+ }
336
+
337
+ // ==================== 安装/卸载 ====================
338
+
339
+ /**
340
+ * 从仓库安装项目
341
+ * @param {string} itemPath - 项目在仓库中的路径
342
+ * @param {RepoConfig} repo - 仓库配置
343
+ * @param {string} targetName - 安装后的目标名称
344
+ */
345
+ async installFromRepo(itemPath, repo, targetName) {
346
+ const dest = path.join(this.installDir, targetName);
347
+
348
+ // 已存在则跳过
349
+ if (fs.existsSync(dest)) {
350
+ return { success: true, message: 'Already installed' };
351
+ }
352
+
353
+ // 下载仓库 ZIP
354
+ const zipUrl = `https://github.com/${repo.owner}/${repo.name}/archive/refs/heads/${repo.branch}.zip`;
355
+ const tempDir = path.join(os.tmpdir(), `${this.type}-${Date.now()}`);
356
+ const zipPath = path.join(tempDir, 'repo.zip');
357
+
358
+ try {
359
+ fs.mkdirSync(tempDir, { recursive: true });
360
+
361
+ // 下载 ZIP
362
+ await this.downloadFile(zipUrl, zipPath);
363
+
364
+ // 解压
365
+ const zip = new AdmZip(zipPath);
366
+ zip.extractAllTo(tempDir, true);
367
+
368
+ // 找到解压后的目录
369
+ const extractedDirs = fs.readdirSync(tempDir).filter(f =>
370
+ fs.statSync(path.join(tempDir, f)).isDirectory()
371
+ );
372
+
373
+ if (extractedDirs.length === 0) {
374
+ throw new Error('Empty archive');
375
+ }
376
+
377
+ const repoDir = path.join(tempDir, extractedDirs[0]);
378
+ const sourceFile = path.join(repoDir, itemPath);
379
+
380
+ if (!fs.existsSync(sourceFile)) {
381
+ throw new Error(`File not found: ${itemPath}`);
382
+ }
383
+
384
+ // 确保目标目录存在
385
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
386
+
387
+ // 复制文件或目录
388
+ if (fs.statSync(sourceFile).isDirectory()) {
389
+ fs.mkdirSync(dest, { recursive: true });
390
+ this.copyDirRecursive(sourceFile, dest);
391
+ } else {
392
+ fs.copyFileSync(sourceFile, dest);
393
+ }
394
+
395
+ // 清除缓存
396
+ this.clearCache();
397
+
398
+ return { success: true, message: 'Installed successfully' };
399
+ } finally {
400
+ // 清理临时目录
401
+ try {
402
+ fs.rmSync(tempDir, { recursive: true, force: true });
403
+ } catch (e) {
404
+ // 忽略清理错误
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * 卸载项目
411
+ */
412
+ uninstall(targetName) {
413
+ const dest = path.join(this.installDir, targetName);
414
+
415
+ if (fs.existsSync(dest)) {
416
+ if (fs.statSync(dest).isDirectory()) {
417
+ fs.rmSync(dest, { recursive: true, force: true });
418
+ } else {
419
+ fs.unlinkSync(dest);
420
+ }
421
+ this.clearCache();
422
+ return { success: true, message: 'Uninstalled successfully' };
423
+ }
424
+
425
+ return { success: true, message: 'Not installed' };
426
+ }
427
+
428
+ // ==================== 工具方法 ====================
429
+
430
+ /**
431
+ * 从文件加载缓存
432
+ */
433
+ loadCacheFromFile() {
434
+ try {
435
+ if (fs.existsSync(this.cachePath)) {
436
+ const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
437
+ if (data.time && (Date.now() - data.time < CACHE_TTL)) {
438
+ return data.items;
439
+ }
440
+ }
441
+ } catch (err) {
442
+ // 忽略
443
+ }
444
+ return null;
445
+ }
446
+
447
+ /**
448
+ * 保存缓存到文件
449
+ */
450
+ saveCacheToFile(items) {
451
+ try {
452
+ fs.writeFileSync(this.cachePath, JSON.stringify({
453
+ time: Date.now(),
454
+ items
455
+ }));
456
+ } catch (err) {
457
+ // 忽略
458
+ }
459
+ }
460
+
461
+ /**
462
+ * 获取 GitHub Token
463
+ */
464
+ getGitHubToken() {
465
+ if (process.env.GITHUB_TOKEN) {
466
+ return process.env.GITHUB_TOKEN;
467
+ }
468
+ try {
469
+ const configPath = path.join(this.configDir, 'github-token.txt');
470
+ if (fs.existsSync(configPath)) {
471
+ return fs.readFileSync(configPath, 'utf-8').trim();
472
+ }
473
+ } catch (err) {
474
+ // 忽略
475
+ }
476
+ return null;
477
+ }
478
+
479
+ /**
480
+ * GitHub API 请求
481
+ */
482
+ async fetchGitHubApi(url) {
483
+ const token = this.getGitHubToken();
484
+ const headers = {
485
+ 'User-Agent': 'cc-cli-repo-scanner',
486
+ 'Accept': 'application/vnd.github.v3+json'
487
+ };
488
+ if (token) {
489
+ headers['Authorization'] = `token ${token}`;
490
+ }
491
+
492
+ return new Promise((resolve, reject) => {
493
+ const req = https.get(url, {
494
+ headers,
495
+ timeout: 15000
496
+ }, (res) => {
497
+ let data = '';
498
+ res.on('data', chunk => data += chunk);
499
+ res.on('end', () => {
500
+ if (res.statusCode === 200) {
501
+ try {
502
+ resolve(JSON.parse(data));
503
+ } catch (e) {
504
+ reject(new Error('Invalid JSON response'));
505
+ }
506
+ } else {
507
+ reject(new Error(`GitHub API error: ${res.statusCode}`));
508
+ }
509
+ });
510
+ });
511
+
512
+ req.on('error', reject);
513
+ req.on('timeout', () => {
514
+ req.destroy();
515
+ reject(new Error('Request timeout'));
516
+ });
517
+ });
518
+ }
519
+
520
+ /**
521
+ * 获取原始文件内容
522
+ */
523
+ async fetchRawContent(repo, filePath) {
524
+ const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${repo.branch}/${filePath}`;
525
+
526
+ return new Promise((resolve, reject) => {
527
+ const req = https.get(url, {
528
+ headers: { 'User-Agent': 'cc-cli-repo-scanner' },
529
+ timeout: 15000
530
+ }, (res) => {
531
+ // 处理重定向
532
+ if (res.statusCode === 301 || res.statusCode === 302) {
533
+ const redirectUrl = res.headers.location;
534
+ if (redirectUrl) {
535
+ https.get(redirectUrl, {
536
+ headers: { 'User-Agent': 'cc-cli-repo-scanner' },
537
+ timeout: 15000
538
+ }, (res2) => {
539
+ let data = '';
540
+ res2.on('data', chunk => data += chunk);
541
+ res2.on('end', () => {
542
+ if (res2.statusCode === 200) {
543
+ resolve(data);
544
+ } else {
545
+ reject(new Error(`Raw fetch error: ${res2.statusCode}`));
546
+ }
547
+ });
548
+ }).on('error', reject);
549
+ return;
550
+ }
551
+ }
552
+
553
+ let data = '';
554
+ res.on('data', chunk => data += chunk);
555
+ res.on('end', () => {
556
+ if (res.statusCode === 200) {
557
+ resolve(data);
558
+ } else {
559
+ reject(new Error(`Raw fetch error: ${res.statusCode}`));
560
+ }
561
+ });
562
+ });
563
+
564
+ req.on('error', reject);
565
+ req.on('timeout', () => {
566
+ req.destroy();
567
+ reject(new Error('Raw fetch timeout'));
568
+ });
569
+ });
570
+ }
571
+
572
+ /**
573
+ * 下载文件
574
+ */
575
+ async downloadFile(url, dest) {
576
+ return new Promise((resolve, reject) => {
577
+ const file = createWriteStream(dest);
578
+
579
+ const request = https.get(url, {
580
+ headers: { 'User-Agent': 'cc-cli-repo-scanner' },
581
+ timeout: 60000
582
+ }, (response) => {
583
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
584
+ file.close();
585
+ this.downloadFile(response.headers.location, dest).then(resolve).catch(reject);
586
+ return;
587
+ }
588
+
589
+ if (response.statusCode !== 200) {
590
+ file.close();
591
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
592
+ return;
593
+ }
594
+
595
+ response.pipe(file);
596
+ file.on('finish', () => {
597
+ file.close();
598
+ resolve();
599
+ });
600
+ });
601
+
602
+ request.on('error', (err) => {
603
+ file.close();
604
+ fs.unlink(dest, () => {});
605
+ reject(err);
606
+ });
607
+
608
+ request.on('timeout', () => {
609
+ request.destroy();
610
+ file.close();
611
+ fs.unlink(dest, () => {});
612
+ reject(new Error('Download timeout'));
613
+ });
614
+ });
615
+ }
616
+
617
+ /**
618
+ * 递归复制目录
619
+ */
620
+ copyDirRecursive(src, dest) {
621
+ const entries = fs.readdirSync(src, { withFileTypes: true });
622
+
623
+ for (const entry of entries) {
624
+ const srcPath = path.join(src, entry.name);
625
+ const destPath = path.join(dest, entry.name);
626
+
627
+ if (entry.isDirectory()) {
628
+ fs.mkdirSync(destPath, { recursive: true });
629
+ this.copyDirRecursive(srcPath, destPath);
630
+ } else {
631
+ fs.copyFileSync(srcPath, destPath);
632
+ }
633
+ }
634
+ }
635
+
636
+ /**
637
+ * 解析 YAML frontmatter
638
+ */
639
+ parseFrontmatter(content) {
640
+ const result = {
641
+ frontmatter: {},
642
+ body: content
643
+ };
644
+
645
+ content = content.trim().replace(/^\uFEFF/, '');
646
+
647
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
648
+ if (!match) {
649
+ return result;
650
+ }
651
+
652
+ const frontmatterText = match[1];
653
+ result.body = match[2].trim();
654
+
655
+ const lines = frontmatterText.split('\n');
656
+ for (const line of lines) {
657
+ const colonIndex = line.indexOf(':');
658
+ if (colonIndex === -1) continue;
659
+
660
+ const key = line.slice(0, colonIndex).trim();
661
+ let value = line.slice(colonIndex + 1).trim();
662
+
663
+ if ((value.startsWith('"') && value.endsWith('"')) ||
664
+ (value.startsWith("'") && value.endsWith("'"))) {
665
+ value = value.slice(1, -1);
666
+ }
667
+
668
+ result.frontmatter[key] = value;
669
+ }
670
+
671
+ return result;
672
+ }
673
+ }
674
+
675
+ module.exports = {
676
+ RepoScannerBase,
677
+ CACHE_TTL
678
+ };