claude-comms 1.0.1 → 1.0.3

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,1092 @@
1
+ /**
2
+ * GitHub API client for fetching Claude setup files
3
+ * Implements the multi-strategy approach from architecture design
4
+ */
5
+
6
+ const fetch = require('node-fetch');
7
+ const tar = require('tar');
8
+ const { EventEmitter } = require('events');
9
+ const { GitHubAPIError } = require('../utils/errors');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ class GitHubFetcher extends EventEmitter {
15
+ constructor(options = {}) {
16
+ super();
17
+
18
+ this.repository = options.repository || {
19
+ owner: 'screwyforcepush',
20
+ repo: 'claude-code-subagent-bus',
21
+ branch: 'main',
22
+ paths: {
23
+ claudeDir: '.claude',
24
+ claudeMd: 'CLAUDE.md'
25
+ }
26
+ };
27
+
28
+ this.config = {
29
+ baseUrl: 'https://api.github.com',
30
+ rawUrl: 'https://raw.githubusercontent.com',
31
+ timeout: options.timeout || 10000,
32
+ retryCount: options.retryCount || 3,
33
+ retryDelay: options.retryDelay || 1000,
34
+ maxRetryDelay: options.maxRetryDelay || 10000,
35
+ userAgent: options.userAgent || '@claude-code/setup-installer/1.0.0',
36
+ token: options.token || process.env.GITHUB_TOKEN
37
+ };
38
+
39
+ this.rateLimitReset = null;
40
+ this.requestCount = 0;
41
+ }
42
+
43
+ /**
44
+ * Fetch repository files according to FetcherAPI interface
45
+ * @param {string} path - Directory path to fetch
46
+ * @param {Object} options - Fetch options
47
+ * @returns {Promise<FileTree>} Directory structure
48
+ */
49
+ async fetchDirectory(path, options = {}) {
50
+ const fetchOptions = this._prepareFetchOptions(options);
51
+
52
+ this.emit('progress', {
53
+ task: 'fetch_directory',
54
+ current: 0,
55
+ message: `Fetching directory: ${path}`,
56
+ status: 'running'
57
+ });
58
+
59
+ try {
60
+ // Strategy 1: GitHub Trees API (fastest for recursive fetch)
61
+ const result = await this._fetchWithTrees(path, fetchOptions);
62
+
63
+ this.emit('progress', {
64
+ task: 'fetch_directory',
65
+ current: 100,
66
+ message: `Successfully fetched ${path}`,
67
+ status: 'complete'
68
+ });
69
+
70
+ return result;
71
+ } catch (treesError) {
72
+ console.warn(`Trees API failed: ${treesError.message}`);
73
+
74
+ try {
75
+ // Strategy 2: GitHub Contents API (fallback)
76
+ const result = await this._fetchWithContents(path, fetchOptions);
77
+
78
+ this.emit('progress', {
79
+ task: 'fetch_directory',
80
+ current: 100,
81
+ message: `Successfully fetched ${path} via Contents API`,
82
+ status: 'complete'
83
+ });
84
+
85
+ return result;
86
+ } catch (contentsError) {
87
+ console.warn(`Contents API failed: ${contentsError.message}`);
88
+
89
+ // Strategy 3: Raw URLs (last resort)
90
+ const result = await this._fetchWithRawUrls(path, fetchOptions);
91
+
92
+ this.emit('progress', {
93
+ task: 'fetch_directory',
94
+ current: 100,
95
+ message: `Successfully fetched ${path} via raw URLs`,
96
+ status: 'complete'
97
+ });
98
+
99
+ return result;
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Fetch individual file according to FetcherAPI interface
106
+ * @param {string} path - File path to fetch
107
+ * @param {Object} options - Fetch options
108
+ * @returns {Promise<FileContent>} File content
109
+ */
110
+ async fetchFile(path, options = {}) {
111
+ const fetchOptions = this._prepareFetchOptions(options);
112
+
113
+ this.emit('progress', {
114
+ task: 'fetch_file',
115
+ current: 0,
116
+ message: `Fetching file: ${path}`,
117
+ status: 'running'
118
+ });
119
+
120
+ try {
121
+ // Try Contents API first for single files
122
+ const result = await this._fetchSingleFileContent(path, fetchOptions);
123
+
124
+ this.emit('progress', {
125
+ task: 'fetch_file',
126
+ current: 100,
127
+ message: `Successfully fetched ${path}`,
128
+ status: 'complete'
129
+ });
130
+
131
+ return result;
132
+ } catch (contentsError) {
133
+ console.warn(`Contents API failed for ${path}: ${contentsError.message}`);
134
+
135
+ // Fallback to raw URL
136
+ const result = await this._fetchSingleFileRaw(path, fetchOptions);
137
+
138
+ this.emit('progress', {
139
+ task: 'fetch_file',
140
+ current: 100,
141
+ message: `Successfully fetched ${path} via raw URL`,
142
+ status: 'complete'
143
+ });
144
+
145
+ return result;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Fetch repository as tarball (single request, fastest approach)
151
+ * @param {Object} options - Fetch options
152
+ * @returns {Promise<Object>} Repository files extracted from tarball
153
+ */
154
+ async fetchAsTarball(options = {}) {
155
+ const fetchOptions = this._prepareFetchOptions(options);
156
+
157
+ this.emit('progress', {
158
+ task: 'fetch_tarball',
159
+ current: 0,
160
+ message: 'Downloading repository tarball...',
161
+ status: 'running'
162
+ });
163
+
164
+ try {
165
+ // Download tarball from GitHub
166
+ const tarballBuffer = await this._downloadTarball(fetchOptions);
167
+
168
+ this.emit('progress', {
169
+ task: 'fetch_tarball',
170
+ current: 50,
171
+ message: 'Extracting files from tarball...',
172
+ status: 'running'
173
+ });
174
+
175
+ // Extract and filter files
176
+ const extractedFiles = await this._extractTarballFiles(tarballBuffer, fetchOptions);
177
+
178
+ this.emit('progress', {
179
+ task: 'fetch_tarball',
180
+ current: 100,
181
+ message: `Successfully extracted ${Object.keys(extractedFiles).length} file groups`,
182
+ status: 'complete'
183
+ });
184
+
185
+ return extractedFiles;
186
+
187
+ } catch (error) {
188
+ this.emit('progress', {
189
+ task: 'fetch_tarball',
190
+ current: 0,
191
+ message: `Tarball fetch failed: ${error.message}`,
192
+ status: 'failed'
193
+ });
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Validate GitHub connection
200
+ * @returns {Promise<boolean>} Connection status
201
+ */
202
+ async validateConnection() {
203
+ try {
204
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}`;
205
+ const response = await this._makeRequest(url, { method: 'HEAD' });
206
+ return response.status === 200;
207
+ } catch (error) {
208
+ console.warn(`GitHub connection validation failed: ${error.message}`);
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Strategy 1: GitHub Trees API for recursive directory fetch
215
+ * @private
216
+ */
217
+ async _fetchWithTrees(path, options) {
218
+ const { version } = options;
219
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/git/trees/${version}?recursive=1`;
220
+
221
+ const response = await this._makeRequest(url);
222
+ const data = await response.json();
223
+
224
+ if (data.truncated) {
225
+ throw new GitHubAPIError(
226
+ 'Repository tree is too large for Trees API',
227
+ { url, truncated: true }
228
+ );
229
+ }
230
+
231
+ // Filter and structure tree data
232
+ const claudeFiles = data.tree.filter(item => {
233
+ // Include files in the requested path or exact match for root files
234
+ const isInPath = item.path.startsWith(path + '/') || item.path === path;
235
+ const isBlob = item.type === 'blob';
236
+ const shouldSkip = this._shouldSkipFile(item.path.split('/').pop()) ||
237
+ this._shouldSkipDirectory(item.path.split('/').pop());
238
+
239
+ return isInPath && isBlob && !shouldSkip;
240
+ });
241
+
242
+ // Fetch file contents in parallel batches
243
+ const files = await this._fetchFilesBatch(claudeFiles, options);
244
+
245
+ return this._buildFileTree(path, files);
246
+ }
247
+
248
+ /**
249
+ * Strategy 2: GitHub Contents API for directory fetch
250
+ * @private
251
+ */
252
+ async _fetchWithContents(path, options) {
253
+ const { version } = options;
254
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${path}?ref=${version}`;
255
+
256
+ const response = await this._makeRequest(url);
257
+ const data = await response.json();
258
+
259
+ if (!Array.isArray(data)) {
260
+ throw new GitHubAPIError(
261
+ `Expected directory but got file: ${path}`,
262
+ { path, type: data.type }
263
+ );
264
+ }
265
+
266
+ const files = [];
267
+
268
+ // Process each item in directory
269
+ for (const item of data) {
270
+ if (item.type === 'file') {
271
+ // Skip binary files and cache files
272
+ if (!this._shouldSkipFile(item.name)) {
273
+ try {
274
+ const content = await this._fetchSingleFileContent(item.path, options);
275
+ files.push(content);
276
+ } catch (error) {
277
+ console.warn(`Failed to fetch file ${item.path}: ${error.message}`);
278
+ }
279
+ }
280
+ } else if (item.type === 'dir') {
281
+ // Skip __pycache__ and other generated directories
282
+ if (!this._shouldSkipDirectory(item.name)) {
283
+ try {
284
+ // Recursively fetch subdirectories
285
+ const subTree = await this._fetchWithContents(item.path, options);
286
+ files.push(...this._flattenFileTree(subTree));
287
+ } catch (error) {
288
+ console.warn(`Failed to fetch directory ${item.path}: ${error.message}`);
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ return this._buildFileTree(path, files);
295
+ }
296
+
297
+ /**
298
+ * Strategy 3: Raw URLs for direct file access
299
+ * @private
300
+ */
301
+ async _fetchWithRawUrls(path, options) {
302
+ // Complete .claude directory structure based on actual repository
303
+ const knownFiles = [
304
+ // Root configuration
305
+ '.claude/settings.json',
306
+ '.claude/settings.local.json',
307
+
308
+ // Core agents
309
+ '.claude/agents/core/agent-orchestrator.md',
310
+ '.claude/agents/core/architect.md',
311
+ '.claude/agents/core/business-analyst.md',
312
+ '.claude/agents/core/deep-researcher.md',
313
+ '.claude/agents/core/designer.md',
314
+ '.claude/agents/core/engineer.md',
315
+ '.claude/agents/core/gatekeeper.md',
316
+ '.claude/agents/core/planner.md',
317
+ '.claude/agents/meta-agent-builder.md',
318
+
319
+ // Commands
320
+ '.claude/commands/cook.md',
321
+ '.claude/commands/new-agents.md',
322
+
323
+ // Communication hooks
324
+ '.claude/hooks/comms/get_unread_messages.py',
325
+ '.claude/hooks/comms/register_subagent.py',
326
+ '.claude/hooks/comms/send_message.py',
327
+ '.claude/hooks/comms/update_subagent_completion.py',
328
+
329
+ // Context hooks
330
+ '.claude/hooks/context/repo_map.py',
331
+
332
+ // Observability hooks
333
+ '.claude/hooks/observability/send_event.py',
334
+
335
+ // Root CLAUDE.md
336
+ 'CLAUDE.md'
337
+ ];
338
+
339
+ const files = [];
340
+
341
+ for (const filePath of knownFiles) {
342
+ if (filePath.startsWith(path) || (path === '.claude' && filePath.startsWith('.claude')) || (path === 'CLAUDE.md' && filePath === 'CLAUDE.md')) {
343
+ try {
344
+ const content = await this._fetchSingleFileRaw(filePath, options);
345
+ files.push(content);
346
+ } catch (error) {
347
+ // Continue on individual file failures for raw URL strategy
348
+ console.warn(`Failed to fetch ${filePath}: ${error.message}`);
349
+ }
350
+ }
351
+ }
352
+
353
+ if (files.length === 0) {
354
+ throw new GitHubAPIError(
355
+ `No files found using raw URL strategy for path: ${path}`,
356
+ { path, strategy: 'raw_urls' }
357
+ );
358
+ }
359
+
360
+ return this._buildFileTree(path, files);
361
+ }
362
+
363
+ /**
364
+ * Fetch single file content via Contents API
365
+ * @private
366
+ */
367
+ async _fetchSingleFileContent(filePath, options) {
368
+ const { version } = options;
369
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${filePath}?ref=${version}`;
370
+
371
+ const response = await this._makeRequest(url);
372
+ const data = await response.json();
373
+
374
+ if (data.type !== 'file') {
375
+ throw new GitHubAPIError(
376
+ `Expected file but got ${data.type}: ${filePath}`,
377
+ { filePath, type: data.type }
378
+ );
379
+ }
380
+
381
+ return {
382
+ path: filePath,
383
+ content: data.encoding === 'base64' ?
384
+ Buffer.from(data.content, 'base64').toString('utf-8') :
385
+ data.content,
386
+ encoding: 'utf-8',
387
+ sha: data.sha
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Fetch single file via raw URL
393
+ * @private
394
+ */
395
+ async _fetchSingleFileRaw(filePath, options) {
396
+ const { version } = options;
397
+ const url = `${this.config.rawUrl}/${this.repository.owner}/${this.repository.repo}/${version}/${filePath}`;
398
+
399
+ const response = await this._makeRequest(url);
400
+ const content = await response.text();
401
+
402
+ return {
403
+ path: filePath,
404
+ content: content,
405
+ encoding: 'utf-8',
406
+ sha: null // Not available for raw URLs
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Fetch multiple files in parallel batches
412
+ * @private
413
+ */
414
+ async _fetchFilesBatch(fileItems, options, batchSize = 5) {
415
+ const files = [];
416
+
417
+ for (let i = 0; i < fileItems.length; i += batchSize) {
418
+ const batch = fileItems.slice(i, i + batchSize);
419
+
420
+ const batchPromises = batch.map(async (item) => {
421
+ try {
422
+ return await this._fetchSingleFileContent(item.path, options);
423
+ } catch (error) {
424
+ console.warn(`Failed to fetch ${item.path}: ${error.message}`);
425
+ return null;
426
+ }
427
+ });
428
+
429
+ const batchResults = await Promise.all(batchPromises);
430
+ files.push(...batchResults.filter(Boolean));
431
+
432
+ // Update progress
433
+ this.emit('progress', {
434
+ task: 'fetch_directory',
435
+ current: Math.min(100, Math.round((i + batchSize) / fileItems.length * 80)), // 80% for fetching
436
+ message: `Fetched ${Math.min(i + batchSize, fileItems.length)}/${fileItems.length} files`,
437
+ status: 'running'
438
+ });
439
+
440
+ // Rate limiting delay between batches
441
+ if (i + batchSize < fileItems.length) {
442
+ await this._rateLimitDelay();
443
+ }
444
+ }
445
+
446
+ return files;
447
+ }
448
+
449
+ /**
450
+ * Build file tree structure
451
+ * @private
452
+ */
453
+ _buildFileTree(rootPath, files) {
454
+ const tree = {
455
+ path: rootPath,
456
+ type: 'dir',
457
+ children: [],
458
+ files: files
459
+ };
460
+
461
+ // Add file tree structure for compatibility
462
+ const filePaths = files.map(f => f.path);
463
+ tree.children = this._buildTreeStructure(filePaths, rootPath);
464
+
465
+ return tree;
466
+ }
467
+
468
+ /**
469
+ * Build hierarchical tree structure
470
+ * @private
471
+ */
472
+ _buildTreeStructure(filePaths, rootPath) {
473
+ const tree = [];
474
+ const pathMap = new Map();
475
+
476
+ filePaths.forEach(filePath => {
477
+ const relativePath = filePath.startsWith(rootPath) ?
478
+ filePath.slice(rootPath.length + 1) : filePath;
479
+
480
+ const parts = relativePath.split('/');
481
+ let currentPath = rootPath;
482
+
483
+ parts.forEach((part, index) => {
484
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
485
+
486
+ if (!pathMap.has(currentPath)) {
487
+ const node = {
488
+ path: currentPath,
489
+ type: index === parts.length - 1 ? 'file' : 'dir',
490
+ sha: null
491
+ };
492
+
493
+ pathMap.set(currentPath, node);
494
+
495
+ if (index === 0) {
496
+ tree.push(node);
497
+ }
498
+ }
499
+ });
500
+ });
501
+
502
+ return tree;
503
+ }
504
+
505
+ /**
506
+ * Flatten file tree to file list
507
+ * @private
508
+ */
509
+ _flattenFileTree(tree) {
510
+ if (tree.files) {
511
+ return tree.files;
512
+ }
513
+
514
+ const files = [];
515
+ if (tree.children) {
516
+ tree.children.forEach(child => {
517
+ if (child.type === 'file') {
518
+ files.push(child);
519
+ } else {
520
+ files.push(...this._flattenFileTree(child));
521
+ }
522
+ });
523
+ }
524
+
525
+ return files;
526
+ }
527
+
528
+ /**
529
+ * Make HTTP request with retry logic and rate limiting
530
+ * @private
531
+ */
532
+ async _makeRequest(url, options = {}) {
533
+ const requestOptions = {
534
+ timeout: this.config.timeout,
535
+ headers: {
536
+ 'User-Agent': this.config.userAgent,
537
+ 'Accept': 'application/vnd.github.v3+json',
538
+ ...options.headers
539
+ },
540
+ ...options
541
+ };
542
+
543
+ if (this.config.token) {
544
+ requestOptions.headers['Authorization'] = `token ${this.config.token}`;
545
+ }
546
+
547
+ return await this._executeWithRetry(async () => {
548
+ // Check rate limit before request
549
+ await this._checkRateLimit();
550
+
551
+ this.requestCount++;
552
+ const response = await fetch(url, requestOptions);
553
+
554
+ // Handle rate limiting
555
+ if (response.status === 403) {
556
+ const rateLimitReset = response.headers.get('x-ratelimit-reset');
557
+ if (rateLimitReset) {
558
+ this.rateLimitReset = parseInt(rateLimitReset) * 1000;
559
+ const error = new GitHubAPIError(
560
+ 'GitHub API rate limit exceeded',
561
+ { resetTime: this.rateLimitReset, status: 403, headers: response.headers }
562
+ );
563
+ error.code = 'GITHUB_RATE_LIMIT';
564
+ throw error;
565
+ }
566
+ }
567
+
568
+ if (!response.ok) {
569
+ throw new GitHubAPIError(
570
+ `GitHub API request failed: ${response.status} ${response.statusText}`,
571
+ { url, status: response.status, statusText: response.statusText }
572
+ );
573
+ }
574
+
575
+ return response;
576
+ });
577
+ }
578
+
579
+ /**
580
+ * Execute function with exponential backoff retry
581
+ * @private
582
+ */
583
+ async _executeWithRetry(fn, attempt = 1) {
584
+ try {
585
+ return await fn();
586
+ } catch (error) {
587
+ if (attempt >= this.config.retryCount) {
588
+ throw error;
589
+ }
590
+
591
+ if (error.code === 'GITHUB_RATE_LIMIT') {
592
+ // Check if we should fail fast
593
+ this._shouldFailFastOnRateLimit();
594
+
595
+ // Calculate smart rate limit delay
596
+ const delay = this._calculateRateLimitDelay();
597
+
598
+ if (delay > 0) {
599
+ console.warn(`Rate limit exceeded. Waiting ${Math.round(delay / 1000)}s before retry ${attempt + 1}/${this.config.retryCount}`);
600
+
601
+ if (!this.config.token) {
602
+ console.warn('💡 Tip: Set GITHUB_TOKEN environment variable to increase rate limits (5,000/hour vs 60/hour)');
603
+ }
604
+
605
+ // Show countdown for longer waits
606
+ if (delay > 5000) {
607
+ await this._waitWithCountdown(delay);
608
+ } else {
609
+ await this._sleep(delay);
610
+ }
611
+ }
612
+ } else {
613
+ // Exponential backoff for other errors
614
+ const delay = this._calculateRetryDelay(attempt);
615
+ console.warn(`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.retryCount}): ${error.message}`);
616
+ await this._sleep(delay);
617
+ }
618
+
619
+ return this._executeWithRetry(fn, attempt + 1);
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Calculate exponential backoff delay
625
+ * @private
626
+ */
627
+ _calculateRetryDelay(attempt) {
628
+ const baseDelay = this.config.retryDelay * Math.pow(2, attempt - 1);
629
+ const jitter = Math.random() * 0.1 * baseDelay; // 10% jitter
630
+ return Math.min(baseDelay + jitter, this.config.maxRetryDelay);
631
+ }
632
+
633
+ /**
634
+ * Calculate smart rate limit delay with reasonable caps
635
+ * @private
636
+ */
637
+ _calculateRateLimitDelay() {
638
+ if (!this.rateLimitReset) {
639
+ return 0;
640
+ }
641
+
642
+ const now = Date.now();
643
+ const resetTime = this.rateLimitReset;
644
+ const timeUntilReset = resetTime - now;
645
+
646
+ // If reset time is in the past, no delay needed
647
+ if (timeUntilReset <= 0) {
648
+ this.rateLimitReset = null;
649
+ return 0;
650
+ }
651
+
652
+ // Cap rate limit delays at 30 seconds maximum
653
+ const cappedDelay = Math.min(timeUntilReset, 30000);
654
+
655
+ // If original delay would be over 5 minutes, suggest failing fast
656
+ if (timeUntilReset > 5 * 60 * 1000) {
657
+ throw new GitHubAPIError(
658
+ `Rate limit reset time too far in future (${Math.round(timeUntilReset / 1000)}s). Consider using a GitHub token or try again later.`,
659
+ {
660
+ resetTime: this.rateLimitReset,
661
+ delaySeconds: Math.round(timeUntilReset / 1000),
662
+ suggestion: 'Use GITHUB_TOKEN environment variable to increase rate limits'
663
+ }
664
+ );
665
+ }
666
+
667
+ return cappedDelay;
668
+ }
669
+
670
+ /**
671
+ * Check if we should fail fast on rate limit
672
+ * @private
673
+ */
674
+ _shouldFailFastOnRateLimit() {
675
+ if (!this.rateLimitReset) {
676
+ return false;
677
+ }
678
+
679
+ const timeUntilReset = this.rateLimitReset - Date.now();
680
+
681
+ // Fail fast if rate limit won't reset for more than 5 minutes
682
+ if (timeUntilReset > 5 * 60 * 1000) {
683
+ throw new GitHubAPIError(
684
+ `Rate limit reset time too far in future (${Math.round(timeUntilReset / 1000)}s). Installation cancelled.`,
685
+ {
686
+ resetTime: this.rateLimitReset,
687
+ delaySeconds: Math.round(timeUntilReset / 1000),
688
+ suggestion: 'Use GITHUB_TOKEN environment variable or try again later'
689
+ }
690
+ );
691
+ }
692
+
693
+ return false;
694
+ }
695
+
696
+ /**
697
+ * Check and wait for rate limit if necessary
698
+ * @private
699
+ */
700
+ async _checkRateLimit() {
701
+ if (this.rateLimitReset && Date.now() < this.rateLimitReset) {
702
+ // Use smart rate limit delay calculation
703
+ const delay = this._calculateRateLimitDelay();
704
+
705
+ if (delay > 0) {
706
+ console.warn(`Rate limit active, waiting ${Math.round(delay / 1000)}s`);
707
+
708
+ if (delay > 5000) {
709
+ await this._waitWithCountdown(delay);
710
+ } else {
711
+ await this._sleep(delay);
712
+ }
713
+ }
714
+
715
+ this.rateLimitReset = null;
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Rate limiting delay between requests
721
+ * @private
722
+ */
723
+ async _rateLimitDelay() {
724
+ // GitHub allows 5000 requests/hour for authenticated users
725
+ // Be conservative with 1 request per 100ms
726
+ await this._sleep(100);
727
+ }
728
+
729
+ /**
730
+ * Sleep utility
731
+ * @private
732
+ */
733
+ _sleep(ms) {
734
+ return new Promise(resolve => setTimeout(resolve, ms));
735
+ }
736
+
737
+ /**
738
+ * Wait with countdown display for better user experience
739
+ * @private
740
+ */
741
+ async _waitWithCountdown(totalMs) {
742
+ const intervalMs = 1000; // Update every second
743
+ let remainingMs = totalMs;
744
+
745
+ while (remainingMs > 0) {
746
+ const remainingSeconds = Math.ceil(remainingMs / 1000);
747
+ process.stdout.write(`\r⏳ Rate limit wait: ${remainingSeconds}s remaining...`);
748
+
749
+ const sleepTime = Math.min(intervalMs, remainingMs);
750
+ await this._sleep(sleepTime);
751
+ remainingMs -= sleepTime;
752
+ }
753
+
754
+ process.stdout.write('\r✅ Rate limit wait complete.\n');
755
+ }
756
+
757
+ /**
758
+ * Wait for rate limit with user feedback
759
+ * @private
760
+ */
761
+ async _waitForRateLimit() {
762
+ const delay = this._calculateRateLimitDelay();
763
+
764
+ if (delay > 0) {
765
+ console.warn(`Rate limit active, waiting ${Math.round(delay / 1000)}s`);
766
+
767
+ if (delay > 5000) {
768
+ await this._waitWithCountdown(delay);
769
+ } else {
770
+ await this._sleep(delay);
771
+ }
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Prepare fetch options with defaults
777
+ * @private
778
+ */
779
+ _prepareFetchOptions(options) {
780
+ return {
781
+ version: options.version || this.repository.branch,
782
+ useCache: options.useCache || false,
783
+ retryCount: options.retryCount || this.config.retryCount,
784
+ timeout: options.timeout || this.config.timeout
785
+ };
786
+ }
787
+
788
+ /**
789
+ * Check if file should be skipped during fetch
790
+ * @private
791
+ */
792
+ _shouldSkipFile(filename) {
793
+ const skipPatterns = [
794
+ /\.pyc$/, // Python bytecode
795
+ /\.pyo$/, // Python optimized bytecode
796
+ /\.pyd$/, // Python extension modules
797
+ /\.DS_Store$/, // macOS metadata
798
+ /Thumbs\.db$/, // Windows thumbnails
799
+ /\.git/, // Git files
800
+ /\.svn/, // SVN files
801
+ /~$/, // Backup files
802
+ /\.tmp$/, // Temporary files
803
+ /\.temp$/ // Temporary files
804
+ ];
805
+
806
+ return skipPatterns.some(pattern => pattern.test(filename));
807
+ }
808
+
809
+ /**
810
+ * Check if directory should be skipped during fetch
811
+ * @private
812
+ */
813
+ _shouldSkipDirectory(dirname) {
814
+ const skipDirs = [
815
+ '__pycache__', // Python cache
816
+ '.git', // Git directory
817
+ '.svn', // SVN directory
818
+ 'node_modules', // Node.js modules
819
+ '.vscode', // VS Code settings
820
+ '.idea', // IntelliJ settings
821
+ '.DS_Store', // macOS metadata
822
+ 'Thumbs.db' // Windows thumbnails
823
+ ];
824
+
825
+ return skipDirs.includes(dirname);
826
+ }
827
+
828
+ /**
829
+ * Download repository tarball from GitHub
830
+ * @private
831
+ */
832
+ async _downloadTarball(options) {
833
+ const { version } = options;
834
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/tarball/${version}`;
835
+
836
+ const response = await this._makeRequest(url);
837
+
838
+ if (!response.ok) {
839
+ throw new GitHubAPIError(
840
+ `Failed to download tarball: ${response.status} ${response.statusText}`,
841
+ { url, status: response.status }
842
+ );
843
+ }
844
+
845
+ // Download tarball as buffer
846
+ const buffer = await response.buffer();
847
+
848
+ if (buffer.length === 0) {
849
+ throw new GitHubAPIError(
850
+ 'Downloaded tarball is empty',
851
+ { url, size: buffer.length }
852
+ );
853
+ }
854
+
855
+ return buffer;
856
+ }
857
+
858
+ /**
859
+ * Extract relevant files from tarball buffer
860
+ * @private
861
+ */
862
+ async _extractTarballFiles(tarballBuffer, _options) {
863
+ return new Promise((resolve, reject) => {
864
+ const extractedFiles = {
865
+ '.claude': {
866
+ path: '.claude',
867
+ type: 'dir',
868
+ children: [],
869
+ files: []
870
+ }
871
+ };
872
+
873
+ const claudeMdFile = {
874
+ path: 'CLAUDE.md',
875
+ content: null,
876
+ encoding: 'utf-8'
877
+ };
878
+
879
+ // Create temporary directory for extraction
880
+ const tempDir = path.join(os.tmpdir(), `claude-extract-${Date.now()}-${Math.random().toString(36).slice(2)}`);
881
+
882
+ // Track files we want to keep
883
+ const wantedFiles = new Map();
884
+ let rootDirName = null;
885
+
886
+ const extractStream = tar.extract({
887
+ cwd: tempDir,
888
+ filter: (filePath, entry) => {
889
+ // Debug logging
890
+ console.log(`DEBUG: Processing ${filePath} (type: ${entry.type})`);
891
+
892
+ // First entry is usually the root directory (e.g., "screwyforcepush-claude-code-subagent-bus-abc123/")
893
+ if (!rootDirName && entry.type === 'Directory') {
894
+ rootDirName = filePath;
895
+ console.log(`DEBUG: Set root directory to: ${rootDirName}`);
896
+ return false; // Don't extract the root dir itself
897
+ }
898
+
899
+ // Skip if this doesn't belong to our repository root
900
+ if (rootDirName && !filePath.startsWith(rootDirName + '/')) {
901
+ return false;
902
+ }
903
+
904
+ // Remove root directory prefix to get actual file path
905
+ const actualPath = rootDirName ? filePath.slice(rootDirName.length + 1) : filePath;
906
+
907
+ // Only extract files we care about
908
+ const isClaudeFile = actualPath.startsWith('.claude/') || actualPath === '.claude';
909
+ const isClaudeMd = actualPath === 'CLAUDE.md';
910
+
911
+ console.log(`DEBUG: ${actualPath} -> claude: ${isClaudeFile}, md: ${isClaudeMd}, type: ${entry.type}`);
912
+
913
+ if ((isClaudeFile || isClaudeMd) && entry.type === 'File') {
914
+ wantedFiles.set(filePath, actualPath);
915
+ console.log(`DEBUG: Will extract: ${filePath} -> ${actualPath}`);
916
+ return true;
917
+ }
918
+
919
+ // Also allow .claude directories to be processed for their children
920
+ if (isClaudeFile && entry.type === 'Directory') {
921
+ return true;
922
+ }
923
+
924
+ return false;
925
+ }
926
+ });
927
+
928
+ extractStream.on('error', (error) => {
929
+ reject(new GitHubAPIError(
930
+ `Tarball extraction failed: ${error.message}`,
931
+ { error: error.message }
932
+ ));
933
+ });
934
+
935
+ extractStream.on('finish', async () => {
936
+ try {
937
+ // Process extracted files
938
+ for (const [extractedPath, actualPath] of wantedFiles) {
939
+ const fullPath = path.join(tempDir, extractedPath);
940
+
941
+ try {
942
+ const stats = await fs.promises.stat(fullPath);
943
+
944
+ if (stats.isFile()) {
945
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
946
+
947
+ if (actualPath === 'CLAUDE.md') {
948
+ claudeMdFile.content = content;
949
+ claudeMdFile.path = 'CLAUDE.md';
950
+ extractedFiles['CLAUDE.md'] = claudeMdFile;
951
+ } else if (actualPath.startsWith('.claude/')) {
952
+ // Add to .claude files
953
+ const relativePath = actualPath.slice('.claude/'.length);
954
+ const fileObj = {
955
+ path: actualPath,
956
+ content: content,
957
+ encoding: 'utf-8'
958
+ };
959
+
960
+ extractedFiles['.claude'].files.push(fileObj);
961
+
962
+ // Build directory structure for compatibility
963
+ this._addToDirectoryStructure(extractedFiles['.claude'], relativePath, fileObj);
964
+ }
965
+ }
966
+ } catch (fileError) {
967
+ // Skip files that can't be read
968
+ console.warn(`Warning: Could not read extracted file ${fullPath}: ${fileError.message}`);
969
+ }
970
+ }
971
+
972
+ // Clean up temp directory
973
+ try {
974
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
975
+ } catch (cleanupError) {
976
+ // Log but don't fail
977
+ console.warn(`Warning: Could not clean up temp directory ${tempDir}: ${cleanupError.message}`);
978
+ }
979
+
980
+ resolve(extractedFiles);
981
+
982
+ } catch (processingError) {
983
+ reject(new GitHubAPIError(
984
+ `File processing failed: ${processingError.message}`,
985
+ { error: processingError.message }
986
+ ));
987
+ }
988
+ });
989
+
990
+ // Create temp directory and start extraction
991
+ fs.promises.mkdir(tempDir, { recursive: true })
992
+ .then(() => {
993
+ // Pipe tarball buffer to extraction stream
994
+ const { Readable } = require('stream');
995
+ const readable = new Readable();
996
+ readable.push(tarballBuffer);
997
+ readable.push(null);
998
+ readable.pipe(extractStream);
999
+ })
1000
+ .catch(reject);
1001
+ });
1002
+ }
1003
+
1004
+ /**
1005
+ * Add file to directory structure for compatibility with existing code
1006
+ * @private
1007
+ */
1008
+ _addToDirectoryStructure(claudeDir, relativePath, fileObj) {
1009
+ const parts = relativePath.split('/');
1010
+ let currentLevel = claudeDir.children;
1011
+ let currentPath = '.claude';
1012
+
1013
+ for (let i = 0; i < parts.length; i++) {
1014
+ const part = parts[i];
1015
+ currentPath = `${currentPath}/${part}`;
1016
+
1017
+ if (i === parts.length - 1) {
1018
+ // This is the file
1019
+ currentLevel.push({
1020
+ path: currentPath,
1021
+ type: 'file',
1022
+ sha: null
1023
+ });
1024
+ } else {
1025
+ // This is a directory
1026
+ let dirEntry = currentLevel.find(item => item.path === currentPath && item.type === 'dir');
1027
+ if (!dirEntry) {
1028
+ dirEntry = {
1029
+ path: currentPath,
1030
+ type: 'dir',
1031
+ children: []
1032
+ };
1033
+ currentLevel.push(dirEntry);
1034
+ }
1035
+ currentLevel = dirEntry.children;
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Main function for backwards compatibility
1043
+ * @param {string} gitRef - Git reference (branch/tag/commit)
1044
+ * @param {Object} options - Additional options
1045
+ * @returns {Promise<Object>} Repository files
1046
+ */
1047
+ async function fetchRepository(gitRef = 'main', options = {}) {
1048
+ const fetcher = new GitHubFetcher(options);
1049
+
1050
+ try {
1051
+ // Strategy 1: Tarball approach (fastest, single request)
1052
+ console.log('Attempting tarball fetch strategy...');
1053
+ const tarballFiles = await fetcher.fetchAsTarball({ version: gitRef });
1054
+
1055
+ return {
1056
+ claudeDirectory: tarballFiles['.claude'],
1057
+ claudeFile: tarballFiles['CLAUDE.md'],
1058
+ files: [...(tarballFiles['.claude'].files || []), tarballFiles['CLAUDE.md']]
1059
+ };
1060
+
1061
+ } catch (tarballError) {
1062
+ console.warn(`Tarball fetch failed: ${tarballError.message}`);
1063
+ console.log('Falling back to individual file fetch strategy...');
1064
+
1065
+ try {
1066
+ // Strategy 2: Individual file fetch (fallback)
1067
+ const [claudeDir, claudeMd] = await Promise.all([
1068
+ fetcher.fetchDirectory('.claude', { version: gitRef }),
1069
+ fetcher.fetchFile('CLAUDE.md', { version: gitRef })
1070
+ ]);
1071
+
1072
+ return {
1073
+ claudeDirectory: claudeDir,
1074
+ claudeFile: claudeMd,
1075
+ files: [...(claudeDir.files || []), claudeMd]
1076
+ };
1077
+
1078
+ } catch (fallbackError) {
1079
+ console.error('Both tarball and individual fetch strategies failed');
1080
+ throw new Error(
1081
+ `All fetch strategies failed. Tarball: ${tarballError.message}. Individual: ${fallbackError.message}`
1082
+ );
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ module.exports = {
1088
+ GitHubFetcher,
1089
+ fetchRepository,
1090
+ // Backwards compatibility
1091
+ fetchClaudeSetup: fetchRepository
1092
+ };