claude-comms 1.0.1 → 1.0.2

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,676 @@
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 { EventEmitter } = require('events');
8
+ const { GitHubAPIError } = require('../utils/errors');
9
+
10
+ class GitHubFetcher extends EventEmitter {
11
+ constructor(options = {}) {
12
+ super();
13
+
14
+ this.repository = options.repository || {
15
+ owner: 'screwyforcepush',
16
+ repo: 'claude-code-subagent-bus',
17
+ branch: 'main',
18
+ paths: {
19
+ claudeDir: '.claude',
20
+ claudeMd: 'CLAUDE.md'
21
+ }
22
+ };
23
+
24
+ this.config = {
25
+ baseUrl: 'https://api.github.com',
26
+ rawUrl: 'https://raw.githubusercontent.com',
27
+ timeout: options.timeout || 10000,
28
+ retryCount: options.retryCount || 3,
29
+ retryDelay: options.retryDelay || 1000,
30
+ maxRetryDelay: options.maxRetryDelay || 10000,
31
+ userAgent: options.userAgent || '@claude-code/setup-installer/1.0.0',
32
+ token: options.token || process.env.GITHUB_TOKEN
33
+ };
34
+
35
+ this.rateLimitReset = null;
36
+ this.requestCount = 0;
37
+ }
38
+
39
+ /**
40
+ * Fetch repository files according to FetcherAPI interface
41
+ * @param {string} path - Directory path to fetch
42
+ * @param {Object} options - Fetch options
43
+ * @returns {Promise<FileTree>} Directory structure
44
+ */
45
+ async fetchDirectory(path, options = {}) {
46
+ const fetchOptions = this._prepareFetchOptions(options);
47
+
48
+ this.emit('progress', {
49
+ task: 'fetch_directory',
50
+ current: 0,
51
+ message: `Fetching directory: ${path}`,
52
+ status: 'running'
53
+ });
54
+
55
+ try {
56
+ // Strategy 1: GitHub Trees API (fastest for recursive fetch)
57
+ const result = await this._fetchWithTrees(path, fetchOptions);
58
+
59
+ this.emit('progress', {
60
+ task: 'fetch_directory',
61
+ current: 100,
62
+ message: `Successfully fetched ${path}`,
63
+ status: 'complete'
64
+ });
65
+
66
+ return result;
67
+ } catch (treesError) {
68
+ console.warn(`Trees API failed: ${treesError.message}`);
69
+
70
+ try {
71
+ // Strategy 2: GitHub Contents API (fallback)
72
+ const result = await this._fetchWithContents(path, fetchOptions);
73
+
74
+ this.emit('progress', {
75
+ task: 'fetch_directory',
76
+ current: 100,
77
+ message: `Successfully fetched ${path} via Contents API`,
78
+ status: 'complete'
79
+ });
80
+
81
+ return result;
82
+ } catch (contentsError) {
83
+ console.warn(`Contents API failed: ${contentsError.message}`);
84
+
85
+ // Strategy 3: Raw URLs (last resort)
86
+ const result = await this._fetchWithRawUrls(path, fetchOptions);
87
+
88
+ this.emit('progress', {
89
+ task: 'fetch_directory',
90
+ current: 100,
91
+ message: `Successfully fetched ${path} via raw URLs`,
92
+ status: 'complete'
93
+ });
94
+
95
+ return result;
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Fetch individual file according to FetcherAPI interface
102
+ * @param {string} path - File path to fetch
103
+ * @param {Object} options - Fetch options
104
+ * @returns {Promise<FileContent>} File content
105
+ */
106
+ async fetchFile(path, options = {}) {
107
+ const fetchOptions = this._prepareFetchOptions(options);
108
+
109
+ this.emit('progress', {
110
+ task: 'fetch_file',
111
+ current: 0,
112
+ message: `Fetching file: ${path}`,
113
+ status: 'running'
114
+ });
115
+
116
+ try {
117
+ // Try Contents API first for single files
118
+ const result = await this._fetchSingleFileContent(path, fetchOptions);
119
+
120
+ this.emit('progress', {
121
+ task: 'fetch_file',
122
+ current: 100,
123
+ message: `Successfully fetched ${path}`,
124
+ status: 'complete'
125
+ });
126
+
127
+ return result;
128
+ } catch (contentsError) {
129
+ console.warn(`Contents API failed for ${path}: ${contentsError.message}`);
130
+
131
+ // Fallback to raw URL
132
+ const result = await this._fetchSingleFileRaw(path, fetchOptions);
133
+
134
+ this.emit('progress', {
135
+ task: 'fetch_file',
136
+ current: 100,
137
+ message: `Successfully fetched ${path} via raw URL`,
138
+ status: 'complete'
139
+ });
140
+
141
+ return result;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Validate GitHub connection
147
+ * @returns {Promise<boolean>} Connection status
148
+ */
149
+ async validateConnection() {
150
+ try {
151
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}`;
152
+ const response = await this._makeRequest(url, { method: 'HEAD' });
153
+ return response.status === 200;
154
+ } catch (error) {
155
+ console.warn(`GitHub connection validation failed: ${error.message}`);
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Strategy 1: GitHub Trees API for recursive directory fetch
162
+ * @private
163
+ */
164
+ async _fetchWithTrees(path, options) {
165
+ const { version } = options;
166
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/git/trees/${version}?recursive=1`;
167
+
168
+ const response = await this._makeRequest(url);
169
+ const data = await response.json();
170
+
171
+ if (data.truncated) {
172
+ throw new GitHubAPIError(
173
+ 'Repository tree is too large for Trees API',
174
+ { url, truncated: true }
175
+ );
176
+ }
177
+
178
+ // Filter and structure tree data
179
+ const claudeFiles = data.tree.filter(item => {
180
+ // Include files in the requested path or exact match for root files
181
+ const isInPath = item.path.startsWith(path + '/') || item.path === path;
182
+ const isBlob = item.type === 'blob';
183
+ const shouldSkip = this._shouldSkipFile(item.path.split('/').pop()) ||
184
+ this._shouldSkipDirectory(item.path.split('/').pop());
185
+
186
+ return isInPath && isBlob && !shouldSkip;
187
+ });
188
+
189
+ // Fetch file contents in parallel batches
190
+ const files = await this._fetchFilesBatch(claudeFiles, options);
191
+
192
+ return this._buildFileTree(path, files);
193
+ }
194
+
195
+ /**
196
+ * Strategy 2: GitHub Contents API for directory fetch
197
+ * @private
198
+ */
199
+ async _fetchWithContents(path, options) {
200
+ const { version } = options;
201
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${path}?ref=${version}`;
202
+
203
+ const response = await this._makeRequest(url);
204
+ const data = await response.json();
205
+
206
+ if (!Array.isArray(data)) {
207
+ throw new GitHubAPIError(
208
+ `Expected directory but got file: ${path}`,
209
+ { path, type: data.type }
210
+ );
211
+ }
212
+
213
+ const files = [];
214
+
215
+ // Process each item in directory
216
+ for (const item of data) {
217
+ if (item.type === 'file') {
218
+ // Skip binary files and cache files
219
+ if (!this._shouldSkipFile(item.name)) {
220
+ try {
221
+ const content = await this._fetchSingleFileContent(item.path, options);
222
+ files.push(content);
223
+ } catch (error) {
224
+ console.warn(`Failed to fetch file ${item.path}: ${error.message}`);
225
+ }
226
+ }
227
+ } else if (item.type === 'dir') {
228
+ // Skip __pycache__ and other generated directories
229
+ if (!this._shouldSkipDirectory(item.name)) {
230
+ try {
231
+ // Recursively fetch subdirectories
232
+ const subTree = await this._fetchWithContents(item.path, options);
233
+ files.push(...this._flattenFileTree(subTree));
234
+ } catch (error) {
235
+ console.warn(`Failed to fetch directory ${item.path}: ${error.message}`);
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ return this._buildFileTree(path, files);
242
+ }
243
+
244
+ /**
245
+ * Strategy 3: Raw URLs for direct file access
246
+ * @private
247
+ */
248
+ async _fetchWithRawUrls(path, options) {
249
+ // Complete .claude directory structure based on actual repository
250
+ const knownFiles = [
251
+ // Root configuration
252
+ '.claude/settings.json',
253
+ '.claude/settings.local.json',
254
+
255
+ // Core agents
256
+ '.claude/agents/core/agent-orchestrator.md',
257
+ '.claude/agents/core/architect.md',
258
+ '.claude/agents/core/business-analyst.md',
259
+ '.claude/agents/core/deep-researcher.md',
260
+ '.claude/agents/core/designer.md',
261
+ '.claude/agents/core/engineer.md',
262
+ '.claude/agents/core/gatekeeper.md',
263
+ '.claude/agents/core/planner.md',
264
+ '.claude/agents/meta-agent-builder.md',
265
+
266
+ // Commands
267
+ '.claude/commands/cook.md',
268
+ '.claude/commands/new-agents.md',
269
+
270
+ // Communication hooks
271
+ '.claude/hooks/comms/get_unread_messages.py',
272
+ '.claude/hooks/comms/register_subagent.py',
273
+ '.claude/hooks/comms/send_message.py',
274
+ '.claude/hooks/comms/update_subagent_completion.py',
275
+
276
+ // Context hooks
277
+ '.claude/hooks/context/repo_map.py',
278
+
279
+ // Observability hooks
280
+ '.claude/hooks/observability/send_event.py',
281
+
282
+ // Root CLAUDE.md
283
+ 'CLAUDE.md'
284
+ ];
285
+
286
+ const files = [];
287
+
288
+ for (const filePath of knownFiles) {
289
+ if (filePath.startsWith(path) || (path === '.claude' && filePath.startsWith('.claude')) || (path === 'CLAUDE.md' && filePath === 'CLAUDE.md')) {
290
+ try {
291
+ const content = await this._fetchSingleFileRaw(filePath, options);
292
+ files.push(content);
293
+ } catch (error) {
294
+ // Continue on individual file failures for raw URL strategy
295
+ console.warn(`Failed to fetch ${filePath}: ${error.message}`);
296
+ }
297
+ }
298
+ }
299
+
300
+ if (files.length === 0) {
301
+ throw new GitHubAPIError(
302
+ `No files found using raw URL strategy for path: ${path}`,
303
+ { path, strategy: 'raw_urls' }
304
+ );
305
+ }
306
+
307
+ return this._buildFileTree(path, files);
308
+ }
309
+
310
+ /**
311
+ * Fetch single file content via Contents API
312
+ * @private
313
+ */
314
+ async _fetchSingleFileContent(filePath, options) {
315
+ const { version } = options;
316
+ const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${filePath}?ref=${version}`;
317
+
318
+ const response = await this._makeRequest(url);
319
+ const data = await response.json();
320
+
321
+ if (data.type !== 'file') {
322
+ throw new GitHubAPIError(
323
+ `Expected file but got ${data.type}: ${filePath}`,
324
+ { filePath, type: data.type }
325
+ );
326
+ }
327
+
328
+ return {
329
+ path: filePath,
330
+ content: data.encoding === 'base64' ?
331
+ Buffer.from(data.content, 'base64').toString('utf-8') :
332
+ data.content,
333
+ encoding: 'utf-8',
334
+ sha: data.sha
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Fetch single file via raw URL
340
+ * @private
341
+ */
342
+ async _fetchSingleFileRaw(filePath, options) {
343
+ const { version } = options;
344
+ const url = `${this.config.rawUrl}/${this.repository.owner}/${this.repository.repo}/${version}/${filePath}`;
345
+
346
+ const response = await this._makeRequest(url);
347
+ const content = await response.text();
348
+
349
+ return {
350
+ path: filePath,
351
+ content: content,
352
+ encoding: 'utf-8',
353
+ sha: null // Not available for raw URLs
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Fetch multiple files in parallel batches
359
+ * @private
360
+ */
361
+ async _fetchFilesBatch(fileItems, options, batchSize = 5) {
362
+ const files = [];
363
+
364
+ for (let i = 0; i < fileItems.length; i += batchSize) {
365
+ const batch = fileItems.slice(i, i + batchSize);
366
+
367
+ const batchPromises = batch.map(async (item) => {
368
+ try {
369
+ return await this._fetchSingleFileContent(item.path, options);
370
+ } catch (error) {
371
+ console.warn(`Failed to fetch ${item.path}: ${error.message}`);
372
+ return null;
373
+ }
374
+ });
375
+
376
+ const batchResults = await Promise.all(batchPromises);
377
+ files.push(...batchResults.filter(Boolean));
378
+
379
+ // Update progress
380
+ this.emit('progress', {
381
+ task: 'fetch_directory',
382
+ current: Math.min(100, Math.round((i + batchSize) / fileItems.length * 80)), // 80% for fetching
383
+ message: `Fetched ${Math.min(i + batchSize, fileItems.length)}/${fileItems.length} files`,
384
+ status: 'running'
385
+ });
386
+
387
+ // Rate limiting delay between batches
388
+ if (i + batchSize < fileItems.length) {
389
+ await this._rateLimitDelay();
390
+ }
391
+ }
392
+
393
+ return files;
394
+ }
395
+
396
+ /**
397
+ * Build file tree structure
398
+ * @private
399
+ */
400
+ _buildFileTree(rootPath, files) {
401
+ const tree = {
402
+ path: rootPath,
403
+ type: 'dir',
404
+ children: [],
405
+ files: files
406
+ };
407
+
408
+ // Add file tree structure for compatibility
409
+ const filePaths = files.map(f => f.path);
410
+ tree.children = this._buildTreeStructure(filePaths, rootPath);
411
+
412
+ return tree;
413
+ }
414
+
415
+ /**
416
+ * Build hierarchical tree structure
417
+ * @private
418
+ */
419
+ _buildTreeStructure(filePaths, rootPath) {
420
+ const tree = [];
421
+ const pathMap = new Map();
422
+
423
+ filePaths.forEach(filePath => {
424
+ const relativePath = filePath.startsWith(rootPath) ?
425
+ filePath.slice(rootPath.length + 1) : filePath;
426
+
427
+ const parts = relativePath.split('/');
428
+ let currentPath = rootPath;
429
+
430
+ parts.forEach((part, index) => {
431
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
432
+
433
+ if (!pathMap.has(currentPath)) {
434
+ const node = {
435
+ path: currentPath,
436
+ type: index === parts.length - 1 ? 'file' : 'dir',
437
+ sha: null
438
+ };
439
+
440
+ pathMap.set(currentPath, node);
441
+
442
+ if (index === 0) {
443
+ tree.push(node);
444
+ }
445
+ }
446
+ });
447
+ });
448
+
449
+ return tree;
450
+ }
451
+
452
+ /**
453
+ * Flatten file tree to file list
454
+ * @private
455
+ */
456
+ _flattenFileTree(tree) {
457
+ if (tree.files) {
458
+ return tree.files;
459
+ }
460
+
461
+ const files = [];
462
+ if (tree.children) {
463
+ tree.children.forEach(child => {
464
+ if (child.type === 'file') {
465
+ files.push(child);
466
+ } else {
467
+ files.push(...this._flattenFileTree(child));
468
+ }
469
+ });
470
+ }
471
+
472
+ return files;
473
+ }
474
+
475
+ /**
476
+ * Make HTTP request with retry logic and rate limiting
477
+ * @private
478
+ */
479
+ async _makeRequest(url, options = {}) {
480
+ const requestOptions = {
481
+ timeout: this.config.timeout,
482
+ headers: {
483
+ 'User-Agent': this.config.userAgent,
484
+ 'Accept': 'application/vnd.github.v3+json',
485
+ ...options.headers
486
+ },
487
+ ...options
488
+ };
489
+
490
+ if (this.config.token) {
491
+ requestOptions.headers['Authorization'] = `token ${this.config.token}`;
492
+ }
493
+
494
+ return await this._executeWithRetry(async () => {
495
+ // Check rate limit before request
496
+ await this._checkRateLimit();
497
+
498
+ this.requestCount++;
499
+ const response = await fetch(url, requestOptions);
500
+
501
+ // Handle rate limiting
502
+ if (response.status === 403) {
503
+ const rateLimitReset = response.headers.get('x-ratelimit-reset');
504
+ if (rateLimitReset) {
505
+ this.rateLimitReset = parseInt(rateLimitReset) * 1000;
506
+ throw new GitHubAPIError(
507
+ 'GitHub API rate limit exceeded',
508
+ { resetTime: this.rateLimitReset, status: 403, headers: response.headers }
509
+ );
510
+ }
511
+ }
512
+
513
+ if (!response.ok) {
514
+ throw new GitHubAPIError(
515
+ `GitHub API request failed: ${response.status} ${response.statusText}`,
516
+ { url, status: response.status, statusText: response.statusText }
517
+ );
518
+ }
519
+
520
+ return response;
521
+ });
522
+ }
523
+
524
+ /**
525
+ * Execute function with exponential backoff retry
526
+ * @private
527
+ */
528
+ async _executeWithRetry(fn, attempt = 1) {
529
+ try {
530
+ return await fn();
531
+ } catch (error) {
532
+ if (attempt >= this.config.retryCount) {
533
+ throw error;
534
+ }
535
+
536
+ if (error.code === 'GITHUB_RATE_LIMIT') {
537
+ // Wait for rate limit reset
538
+ const delay = this.rateLimitReset ?
539
+ Math.max(this.rateLimitReset - Date.now(), 0) :
540
+ this._calculateRetryDelay(attempt);
541
+
542
+ console.warn(`Rate limited, waiting ${delay}ms before retry ${attempt + 1}/${this.config.retryCount}`);
543
+ await this._sleep(delay);
544
+ } else {
545
+ // Exponential backoff for other errors
546
+ const delay = this._calculateRetryDelay(attempt);
547
+ console.warn(`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.retryCount}): ${error.message}`);
548
+ await this._sleep(delay);
549
+ }
550
+
551
+ return this._executeWithRetry(fn, attempt + 1);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Calculate exponential backoff delay
557
+ * @private
558
+ */
559
+ _calculateRetryDelay(attempt) {
560
+ const baseDelay = this.config.retryDelay * Math.pow(2, attempt - 1);
561
+ const jitter = Math.random() * 0.1 * baseDelay; // 10% jitter
562
+ return Math.min(baseDelay + jitter, this.config.maxRetryDelay);
563
+ }
564
+
565
+ /**
566
+ * Check and wait for rate limit if necessary
567
+ * @private
568
+ */
569
+ async _checkRateLimit() {
570
+ if (this.rateLimitReset && Date.now() < this.rateLimitReset) {
571
+ const delay = this.rateLimitReset - Date.now();
572
+ console.warn(`Rate limit active, waiting ${delay}ms`);
573
+ await this._sleep(delay);
574
+ this.rateLimitReset = null;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Rate limiting delay between requests
580
+ * @private
581
+ */
582
+ async _rateLimitDelay() {
583
+ // GitHub allows 5000 requests/hour for authenticated users
584
+ // Be conservative with 1 request per 100ms
585
+ await this._sleep(100);
586
+ }
587
+
588
+ /**
589
+ * Sleep utility
590
+ * @private
591
+ */
592
+ _sleep(ms) {
593
+ return new Promise(resolve => setTimeout(resolve, ms));
594
+ }
595
+
596
+ /**
597
+ * Prepare fetch options with defaults
598
+ * @private
599
+ */
600
+ _prepareFetchOptions(options) {
601
+ return {
602
+ version: options.version || this.repository.branch,
603
+ useCache: options.useCache || false,
604
+ retryCount: options.retryCount || this.config.retryCount,
605
+ timeout: options.timeout || this.config.timeout
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Check if file should be skipped during fetch
611
+ * @private
612
+ */
613
+ _shouldSkipFile(filename) {
614
+ const skipPatterns = [
615
+ /\.pyc$/, // Python bytecode
616
+ /\.pyo$/, // Python optimized bytecode
617
+ /\.pyd$/, // Python extension modules
618
+ /\.DS_Store$/, // macOS metadata
619
+ /Thumbs\.db$/, // Windows thumbnails
620
+ /\.git/, // Git files
621
+ /\.svn/, // SVN files
622
+ /~$/, // Backup files
623
+ /\.tmp$/, // Temporary files
624
+ /\.temp$/ // Temporary files
625
+ ];
626
+
627
+ return skipPatterns.some(pattern => pattern.test(filename));
628
+ }
629
+
630
+ /**
631
+ * Check if directory should be skipped during fetch
632
+ * @private
633
+ */
634
+ _shouldSkipDirectory(dirname) {
635
+ const skipDirs = [
636
+ '__pycache__', // Python cache
637
+ '.git', // Git directory
638
+ '.svn', // SVN directory
639
+ 'node_modules', // Node.js modules
640
+ '.vscode', // VS Code settings
641
+ '.idea', // IntelliJ settings
642
+ '.DS_Store', // macOS metadata
643
+ 'Thumbs.db' // Windows thumbnails
644
+ ];
645
+
646
+ return skipDirs.includes(dirname);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Main function for backwards compatibility
652
+ * @param {string} gitRef - Git reference (branch/tag/commit)
653
+ * @param {Object} options - Additional options
654
+ * @returns {Promise<Object>} Repository files
655
+ */
656
+ async function fetchRepository(gitRef = 'main', options = {}) {
657
+ const fetcher = new GitHubFetcher(options);
658
+
659
+ const [claudeDir, claudeMd] = await Promise.all([
660
+ fetcher.fetchDirectory('.claude', { version: gitRef }),
661
+ fetcher.fetchFile('CLAUDE.md', { version: gitRef })
662
+ ]);
663
+
664
+ return {
665
+ claudeDirectory: claudeDir,
666
+ claudeFile: claudeMd,
667
+ files: [...(claudeDir.files || []), claudeMd]
668
+ };
669
+ }
670
+
671
+ module.exports = {
672
+ GitHubFetcher,
673
+ fetchRepository,
674
+ // Backwards compatibility
675
+ fetchClaudeSetup: fetchRepository
676
+ };