voyageai-cli 1.30.0 → 1.30.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.
Files changed (82) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +8 -0
  4. package/src/commands/about.js +3 -3
  5. package/src/commands/chat.js +32 -11
  6. package/src/commands/code-search.js +751 -0
  7. package/src/commands/doctor.js +1 -1
  8. package/src/commands/export.js +124 -0
  9. package/src/commands/import.js +195 -0
  10. package/src/commands/index-workspace.js +243 -0
  11. package/src/commands/mcp-server.js +113 -3
  12. package/src/commands/playground.js +120 -4
  13. package/src/commands/quickstart.js +4 -4
  14. package/src/commands/workflow.js +132 -65
  15. package/src/lib/catalog.js +4 -2
  16. package/src/lib/code-search.js +315 -0
  17. package/src/lib/codegen.js +1 -1
  18. package/src/lib/explanations.js +3 -3
  19. package/src/lib/export/contexts/benchmark-export.js +27 -0
  20. package/src/lib/export/contexts/chat-export.js +41 -0
  21. package/src/lib/export/contexts/explore-export.js +22 -0
  22. package/src/lib/export/contexts/search-export.js +54 -0
  23. package/src/lib/export/contexts/workflow-export.js +80 -0
  24. package/src/lib/export/formats/clipboard-export.js +29 -0
  25. package/src/lib/export/formats/csv-export.js +45 -0
  26. package/src/lib/export/formats/json-export.js +50 -0
  27. package/src/lib/export/formats/markdown-export.js +189 -0
  28. package/src/lib/export/formats/mermaid-export.js +274 -0
  29. package/src/lib/export/formats/pdf-export.js +117 -0
  30. package/src/lib/export/formats/png-export.js +96 -0
  31. package/src/lib/export/formats/svg-export.js +116 -0
  32. package/src/lib/export/index.js +175 -0
  33. package/src/lib/github.js +226 -0
  34. package/src/lib/template-engine.js +154 -20
  35. package/src/lib/workflow-builder.js +753 -0
  36. package/src/lib/workflow-formatters.js +454 -0
  37. package/src/lib/workflow-input-cache.js +111 -0
  38. package/src/lib/workflow-scaffold.js +1 -1
  39. package/src/lib/workflow.js +297 -28
  40. package/src/mcp/install.js +280 -7
  41. package/src/mcp/schemas/index.js +170 -0
  42. package/src/mcp/server.js +19 -4
  43. package/src/mcp/tools/authoring.js +662 -0
  44. package/src/mcp/tools/code-search.js +620 -0
  45. package/src/mcp/tools/ingest.js +2 -5
  46. package/src/mcp/tools/retrieval.js +2 -15
  47. package/src/mcp/tools/workspace.js +452 -0
  48. package/src/mcp/utils.js +20 -0
  49. package/src/playground/announcements.md +52 -5
  50. package/src/playground/help/workflow-nodes.js +127 -2
  51. package/src/playground/index.html +17109 -12438
  52. package/src/playground/vendor/mermaid.min.js +2811 -0
  53. package/src/workflows/code-review.json +110 -0
  54. package/src/workflows/cost-analysis.json +5 -0
  55. package/src/workflows/rag-chat.json +165 -0
  56. package/src/workflows/tests/code-review.fresh-index.test.json +83 -0
  57. package/src/workflows/tests/code-review.happy-path.test.json +121 -0
  58. package/src/workflows/tests/code-review.no-question.test.json +70 -0
  59. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  60. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  61. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  62. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  63. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  64. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  65. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  66. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  67. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  68. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  69. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  70. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  71. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  72. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  73. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  74. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  75. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  76. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  77. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  78. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
  79. package/src/playground/assets/announcements/appstore.jpg +0 -0
  80. package/src/playground/assets/announcements/circuits.jpg +0 -0
  81. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  82. package/src/playground/assets/announcements/green-wave.jpg +0 -0
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render PNG from Mermaid diagram via Playwright.
5
+ *
6
+ * @param {object} normalized
7
+ * @param {object} options
8
+ * @param {string} [options.resolution='2x'] - '1x' | '2x' | '3x'
9
+ * @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
10
+ * @param {boolean} [options.includeWatermark=true]
11
+ * @param {boolean} [options.fitToContent=true]
12
+ * @returns {Promise<{ content: Buffer, mimeType: string }>}
13
+ */
14
+ async function renderPng(normalized, options = {}) {
15
+ // If raw PNG buffer provided (from Electron canvas capture)
16
+ if (normalized._pngBuffer) {
17
+ return { content: normalized._pngBuffer, mimeType: 'image/png' };
18
+ }
19
+
20
+ const { workflowToMermaid } = require('./mermaid-export');
21
+
22
+ if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
23
+ throw new Error(`PNG export not supported for context: ${normalized._context}`);
24
+ }
25
+
26
+ const mermaidSrc = normalized._context === 'workflow'
27
+ ? workflowToMermaid(normalized, options)
28
+ : null;
29
+
30
+ if (!mermaidSrc) {
31
+ throw new Error('No Mermaid source available for PNG rendering');
32
+ }
33
+
34
+ const png = await renderMermaidToPng(mermaidSrc, options);
35
+ return { content: png, mimeType: 'image/png' };
36
+ }
37
+
38
+ /**
39
+ * Render Mermaid syntax to PNG using Playwright.
40
+ */
41
+ async function renderMermaidToPng(mermaidSrc, options = {}) {
42
+ const { chromium } = require('playwright');
43
+ const resolution = options.resolution || '2x';
44
+ const scale = parseInt(resolution) || 2;
45
+ const background = options.background || 'transparent';
46
+ const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
47
+
48
+ const browser = await chromium.launch({ headless: true });
49
+ try {
50
+ const page = await browser.newPage({
51
+ deviceScaleFactor: scale,
52
+ });
53
+
54
+ const html = `<!DOCTYPE html>
55
+ <html><head>
56
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
57
+ </head><body style="margin:0;padding:16px;background:${bgColor};">
58
+ <pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
59
+ <script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
60
+ </body></html>`;
61
+
62
+ await page.setContent(html, { waitUntil: 'networkidle' });
63
+ await page.waitForSelector('svg', { timeout: 10000 });
64
+
65
+ const fitToContent = options.fitToContent !== false;
66
+
67
+ let screenshotOpts = { type: 'png' };
68
+ if (background === 'transparent') {
69
+ screenshotOpts.omitBackground = true;
70
+ }
71
+
72
+ if (fitToContent) {
73
+ const svgEl = await page.$('svg');
74
+ const box = await svgEl.boundingBox();
75
+ if (box) {
76
+ screenshotOpts.clip = {
77
+ x: Math.max(0, box.x - 8),
78
+ y: Math.max(0, box.y - 8),
79
+ width: box.width + 16,
80
+ height: box.height + 16,
81
+ };
82
+ }
83
+ }
84
+
85
+ const buffer = await page.screenshot(screenshotOpts);
86
+ return buffer;
87
+ } finally {
88
+ await browser.close();
89
+ }
90
+ }
91
+
92
+ function escapeHtml(str) {
93
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
94
+ }
95
+
96
+ module.exports = { renderPng, renderMermaidToPng };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render an SVG string for export.
5
+ * In CLI context, this generates SVG from Mermaid via Playwright.
6
+ * In Electron/browser context, this serializes and cleans the DOM SVG.
7
+ *
8
+ * @param {object} normalized - Normalized data (must have _context = 'workflow' or provide _svgContent)
9
+ * @param {object} options
10
+ * @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
11
+ * @param {boolean} [options.includeWatermark=true]
12
+ * @param {boolean} [options.fitToContent=true]
13
+ * @returns {{ content: string, mimeType: string }}
14
+ */
15
+ function renderSvg(normalized, options = {}) {
16
+ // If raw SVG content is provided (from browser/Electron capture), clean and return it
17
+ if (normalized._svgContent) {
18
+ const cleaned = cleanSvg(normalized._svgContent, options);
19
+ return { content: cleaned, mimeType: 'image/svg+xml' };
20
+ }
21
+
22
+ // For CLI: generate SVG from Mermaid (async path handled by renderSvgAsync)
23
+ throw new Error('SVG export requires either _svgContent (browser) or use renderSvgAsync (CLI)');
24
+ }
25
+
26
+ /**
27
+ * Async SVG render — generates SVG from Mermaid using Playwright.
28
+ * @param {object} normalized
29
+ * @param {object} options
30
+ * @returns {Promise<{ content: string, mimeType: string }>}
31
+ */
32
+ async function renderSvgAsync(normalized, options = {}) {
33
+ if (normalized._svgContent) {
34
+ return renderSvg(normalized, options);
35
+ }
36
+
37
+ // Generate Mermaid source, then render via Playwright
38
+ const { workflowToMermaid } = require('./mermaid-export');
39
+ if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
40
+ throw new Error(`SVG export not supported for context: ${normalized._context}`);
41
+ }
42
+
43
+ const mermaidSrc = normalized._context === 'workflow'
44
+ ? workflowToMermaid(normalized, options)
45
+ : null;
46
+
47
+ if (!mermaidSrc) {
48
+ throw new Error('No Mermaid source available for SVG rendering');
49
+ }
50
+
51
+ const svg = await renderMermaidToSvg(mermaidSrc, options);
52
+ return { content: svg, mimeType: 'image/svg+xml' };
53
+ }
54
+
55
+ /**
56
+ * Render Mermaid syntax to SVG using Playwright.
57
+ */
58
+ async function renderMermaidToSvg(mermaidSrc, options = {}) {
59
+ const { chromium } = require('playwright');
60
+ const background = options.background || 'transparent';
61
+ const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
62
+
63
+ const browser = await chromium.launch({ headless: true });
64
+ try {
65
+ const page = await browser.newPage();
66
+
67
+ const html = `<!DOCTYPE html>
68
+ <html><head>
69
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
70
+ </head><body style="margin:0;background:${bgColor};">
71
+ <pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
72
+ <script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
73
+ </body></html>`;
74
+
75
+ await page.setContent(html, { waitUntil: 'networkidle' });
76
+ await page.waitForSelector('svg', { timeout: 10000 });
77
+
78
+ const svg = await page.evaluate(() => {
79
+ const el = document.querySelector('svg');
80
+ // Remove interactive attributes
81
+ el.removeAttribute('style');
82
+ const scripts = el.querySelectorAll('script');
83
+ scripts.forEach(s => s.remove());
84
+ return el.outerHTML;
85
+ });
86
+
87
+ const cleaned = cleanSvg(svg, options);
88
+ return cleaned;
89
+ } finally {
90
+ await browser.close();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Clean an SVG string: remove scripts, event handlers, optionally add watermark.
96
+ */
97
+ function cleanSvg(svgStr, options = {}) {
98
+ // Strip event handlers
99
+ let svg = svgStr.replace(/\s+on\w+="[^"]*"/g, '');
100
+ // Strip <script> tags
101
+ svg = svg.replace(/<script[\s\S]*?<\/script>/gi, '');
102
+
103
+ if (options.includeWatermark !== false) {
104
+ // Insert watermark before closing </svg>
105
+ const watermark = '<text x="99%" y="99%" text-anchor="end" font-size="10" fill="#666" opacity="0.5">Generated by vai</text>';
106
+ svg = svg.replace('</svg>', `${watermark}</svg>`);
107
+ }
108
+
109
+ return svg;
110
+ }
111
+
112
+ function escapeHtml(str) {
113
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
114
+ }
115
+
116
+ module.exports = { renderSvg, renderSvgAsync, renderMermaidToSvg, cleanSvg };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { renderJson, renderJsonl } = require('./formats/json-export');
4
+ const { renderCsv } = require('./formats/csv-export');
5
+ const { renderMarkdown } = require('./formats/markdown-export');
6
+ const { renderMermaid } = require('./formats/mermaid-export');
7
+ const { copyToClipboard } = require('./formats/clipboard-export');
8
+ const { renderSvgAsync } = require('./formats/svg-export');
9
+ const { renderPng } = require('./formats/png-export');
10
+ const { renderPdf } = require('./formats/pdf-export');
11
+
12
+ const { normalizeWorkflow, WORKFLOW_FORMATS } = require('./contexts/workflow-export');
13
+ const { normalizeSearch, SEARCH_FORMATS } = require('./contexts/search-export');
14
+ const { normalizeChat, CHAT_FORMATS } = require('./contexts/chat-export');
15
+ const { normalizeBenchmark, BENCHMARK_FORMATS } = require('./contexts/benchmark-export');
16
+ const { normalizeExplore, EXPLORE_FORMATS } = require('./contexts/explore-export');
17
+
18
+ // ════════════════════════════════════════════════════════════════════
19
+ // Context → Formats mapping
20
+ // ════════════════════════════════════════════════════════════════════
21
+
22
+ const FORMAT_MAP = {
23
+ workflow: WORKFLOW_FORMATS,
24
+ search: SEARCH_FORMATS,
25
+ chat: CHAT_FORMATS,
26
+ benchmark: BENCHMARK_FORMATS,
27
+ explore: EXPLORE_FORMATS,
28
+ };
29
+
30
+ const TRANSFORMERS = {
31
+ workflow: normalizeWorkflow,
32
+ search: normalizeSearch,
33
+ chat: normalizeChat,
34
+ benchmark: normalizeBenchmark,
35
+ explore: normalizeExplore,
36
+ };
37
+
38
+ const RENDERERS = {
39
+ json: renderJson,
40
+ jsonl: renderJsonl,
41
+ csv: renderCsv,
42
+ markdown: renderMarkdown,
43
+ mermaid: renderMermaid,
44
+ svg: renderSvgAsync,
45
+ png: renderPng,
46
+ pdf: renderPdf,
47
+ };
48
+
49
+ // Formats that produce binary (Buffer) output
50
+ const BINARY_FORMATS = new Set(['png', 'pdf']);
51
+
52
+ const EXT_MAP = {
53
+ json: '.json',
54
+ jsonl: '.jsonl',
55
+ csv: '.csv',
56
+ markdown: '.md',
57
+ mermaid: '.mmd',
58
+ svg: '.svg',
59
+ png: '.png',
60
+ pdf: '.pdf',
61
+ };
62
+
63
+ // ════════════════════════════════════════════════════════════════════
64
+ // Public API
65
+ // ════════════════════════════════════════════════════════════════════
66
+
67
+ /**
68
+ * Get supported formats for a given context.
69
+ * @param {string} context
70
+ * @returns {string[]}
71
+ */
72
+ function getFormatsForContext(context) {
73
+ return FORMAT_MAP[context] || [];
74
+ }
75
+
76
+ /**
77
+ * Build a deterministic filename for the export.
78
+ * Pattern: {context}-{name}-{timestamp}.{ext}
79
+ * @param {string} context
80
+ * @param {object} data
81
+ * @param {string} format
82
+ * @returns {string}
83
+ */
84
+ function buildFilename(context, data, format) {
85
+ const name = (data.name || data.sessionId || data.query || context)
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, '-')
88
+ .replace(/^-|-$/g, '')
89
+ .slice(0, 40);
90
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
91
+ const ext = EXT_MAP[format] || '.txt';
92
+ return `${context}-${name}-${ts}${ext}`;
93
+ }
94
+
95
+ /**
96
+ * Main export entry point.
97
+ *
98
+ * @param {object} params
99
+ * @param {string} params.context - 'workflow' | 'search' | 'chat' | 'benchmark' | 'explore'
100
+ * @param {string} params.format - 'json' | 'jsonl' | 'csv' | 'markdown' | 'mermaid' | 'clipboard'
101
+ * @param {object} params.data - Raw source data
102
+ * @param {object} [params.options] - Format & context specific options
103
+ * @returns {{ content: string, mimeType: string, suggestedFilename: string, format: string }}
104
+ */
105
+ async function exportArtifact({ context, format, data, options = {} }) {
106
+ // Handle clipboard as a meta-format: pick the best underlying format and copy
107
+ const isClipboard = format === 'clipboard';
108
+ const effectiveFormat = isClipboard ? pickClipboardFormat(context) : format;
109
+
110
+ // Validate
111
+ const supported = getFormatsForContext(context);
112
+ if (!supported.includes(format)) {
113
+ throw new ExportError(
114
+ `Format "${format}" not supported for ${context}. Supported: ${supported.join(', ')}`
115
+ );
116
+ }
117
+
118
+ // Transform
119
+ const transformer = TRANSFORMERS[context];
120
+ if (!transformer) {
121
+ throw new ExportError(`Unknown export context: "${context}"`);
122
+ }
123
+ const normalized = transformer(data, options);
124
+
125
+ // Render
126
+ const renderer = RENDERERS[effectiveFormat];
127
+ if (!renderer) {
128
+ throw new ExportError(`No renderer for format: "${effectiveFormat}"`);
129
+ }
130
+ const output = await renderer(normalized, options);
131
+ const isBinary = BINARY_FORMATS.has(effectiveFormat);
132
+
133
+ // Clipboard side-effect (text only — binary clipboard handled by Electron)
134
+ if (isClipboard) {
135
+ const ok = copyToClipboard(typeof output.content === 'string' ? output.content : output.content.toString());
136
+ if (!ok) {
137
+ throw new ExportError('Failed to copy to clipboard — unsupported platform or missing clipboard tool');
138
+ }
139
+ }
140
+
141
+ return {
142
+ content: output.content,
143
+ mimeType: output.mimeType,
144
+ suggestedFilename: buildFilename(context, data, effectiveFormat),
145
+ format: effectiveFormat,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Pick the best text format for clipboard based on context.
151
+ */
152
+ function pickClipboardFormat(context) {
153
+ switch (context) {
154
+ case 'workflow': return 'mermaid';
155
+ case 'search': return 'json';
156
+ case 'chat': return 'markdown';
157
+ case 'benchmark': return 'json';
158
+ case 'explore': return 'json';
159
+ default: return 'json';
160
+ }
161
+ }
162
+
163
+ class ExportError extends Error {
164
+ constructor(message) {
165
+ super(message);
166
+ this.name = 'ExportError';
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ exportArtifact,
172
+ getFormatsForContext,
173
+ buildFilename,
174
+ ExportError,
175
+ };
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GitHub API fetcher for remote repository indexing.
5
+ * Uses native fetch (Node 18+) — no axios.
6
+ */
7
+
8
+ /**
9
+ * Get GitHub auth token from env or vai config.
10
+ * @returns {string|null}
11
+ */
12
+ function getAuthToken() {
13
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
14
+ try {
15
+ const { getConfigValue } = require('./config');
16
+ return getConfigValue('github.token') || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Check if a source string is a GitHub URL or shorthand.
24
+ * @param {string} source
25
+ * @returns {boolean}
26
+ */
27
+ function isGitHubUrl(source) {
28
+ if (!source || typeof source !== 'string') return false;
29
+ if (source.includes('github.com')) return true;
30
+ // owner/repo shorthand (must have exactly one slash, no spaces, no path separators at start)
31
+ if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(source)) return true;
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Parse a GitHub URL into owner and repo.
37
+ * Supports: https://github.com/owner/repo, github.com/owner/repo, owner/repo
38
+ * @param {string} source
39
+ * @returns {{ owner: string, repo: string }}
40
+ */
41
+ function parseGitHubUrl(source) {
42
+ if (!source) throw new Error('Empty GitHub source');
43
+
44
+ // Strip trailing .git
45
+ source = source.replace(/\.git$/, '');
46
+
47
+ // Full URL
48
+ const urlMatch = source.match(/github\.com[/:]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/);
49
+ if (urlMatch) {
50
+ return { owner: urlMatch[1], repo: urlMatch[2] };
51
+ }
52
+
53
+ // owner/repo shorthand
54
+ const shortMatch = source.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
55
+ if (shortMatch) {
56
+ return { owner: shortMatch[1], repo: shortMatch[2] };
57
+ }
58
+
59
+ throw new Error(`Cannot parse GitHub URL: ${source}`);
60
+ }
61
+
62
+ /**
63
+ * Make a GitHub API request with optional auth and backoff.
64
+ * @param {string} url
65
+ * @param {string|null} token
66
+ * @param {number} [retries=3]
67
+ * @returns {Promise<object>}
68
+ */
69
+ async function githubFetch(url, token, retries = 3) {
70
+ const headers = { 'Accept': 'application/vnd.github.v3+json' };
71
+ if (token) headers['Authorization'] = `Bearer ${token}`;
72
+
73
+ for (let attempt = 0; attempt <= retries; attempt++) {
74
+ const res = await fetch(url, { headers });
75
+
76
+ if (res.status === 403) {
77
+ const remaining = res.headers.get('x-ratelimit-remaining');
78
+ const resetAt = res.headers.get('x-ratelimit-reset');
79
+ if (remaining === '0' && resetAt) {
80
+ const waitMs = Math.max(0, (parseInt(resetAt) * 1000) - Date.now()) + 1000;
81
+ if (attempt < retries && waitMs < 120000) {
82
+ await new Promise(r => setTimeout(r, waitMs));
83
+ continue;
84
+ }
85
+ throw new Error(`GitHub rate limit exceeded. Resets at ${new Date(parseInt(resetAt) * 1000).toISOString()}`);
86
+ }
87
+ }
88
+
89
+ if (res.status === 404) {
90
+ throw new Error(`GitHub resource not found: ${url}. Is the repo public or do you have a valid GITHUB_TOKEN?`);
91
+ }
92
+
93
+ if (!res.ok) {
94
+ if (attempt < retries) {
95
+ await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
96
+ continue;
97
+ }
98
+ throw new Error(`GitHub API error ${res.status}: ${await res.text()}`);
99
+ }
100
+
101
+ return res.json();
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Fetch the recursive file tree for a repo.
107
+ * @param {string} owner
108
+ * @param {string} repo
109
+ * @param {string} branch
110
+ * @param {string|null} token
111
+ * @returns {Promise<Array<{path: string, size: number, sha: string}>>}
112
+ */
113
+ async function fetchRepoTree(owner, repo, branch, token) {
114
+ const data = await githubFetch(
115
+ `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
116
+ token
117
+ );
118
+
119
+ if (!data.tree) throw new Error('No tree data returned from GitHub');
120
+
121
+ return data.tree
122
+ .filter(entry => entry.type === 'blob')
123
+ .map(entry => ({ path: entry.path, size: entry.size || 0, sha: entry.sha }));
124
+ }
125
+
126
+ /**
127
+ * Fetch file contents from a GitHub repo.
128
+ * @param {string} owner
129
+ * @param {string} repo
130
+ * @param {string} filePath
131
+ * @param {string} branch
132
+ * @param {string|null} token
133
+ * @returns {Promise<string>}
134
+ */
135
+ async function fetchFileContents(owner, repo, filePath, branch, token) {
136
+ const data = await githubFetch(
137
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
138
+ token
139
+ );
140
+
141
+ if (data.encoding === 'base64' && data.content) {
142
+ return Buffer.from(data.content, 'base64').toString('utf-8');
143
+ }
144
+
145
+ throw new Error(`Unexpected encoding for ${filePath}: ${data.encoding}`);
146
+ }
147
+
148
+ /**
149
+ * Fetch changed files between two commits.
150
+ * @param {string} owner
151
+ * @param {string} repo
152
+ * @param {string} baseSha
153
+ * @param {string} headSha
154
+ * @param {string|null} token
155
+ * @returns {Promise<Array<{filename: string, status: string}>>}
156
+ */
157
+ async function fetchChangedFiles(owner, repo, baseSha, headSha, token) {
158
+ const data = await githubFetch(
159
+ `https://api.github.com/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`,
160
+ token
161
+ );
162
+
163
+ return (data.files || []).map(f => ({ filename: f.filename, status: f.status }));
164
+ }
165
+
166
+ /**
167
+ * Fetch multiple files concurrently with a pool limit.
168
+ * @param {string} owner
169
+ * @param {string} repo
170
+ * @param {string[]} filePaths
171
+ * @param {string} branch
172
+ * @param {string|null} token
173
+ * @param {number} [concurrency=5]
174
+ * @returns {Promise<Array<{path: string, content: string}|{path: string, error: string}>>}
175
+ */
176
+ async function fetchFilesBatch(owner, repo, filePaths, branch, token, concurrency = 5) {
177
+ const results = [];
178
+ let i = 0;
179
+
180
+ async function worker() {
181
+ while (i < filePaths.length) {
182
+ const idx = i++;
183
+ const fp = filePaths[idx];
184
+ try {
185
+ const content = await fetchFileContents(owner, repo, fp, branch, token);
186
+ results[idx] = { path: fp, content };
187
+ } catch (err) {
188
+ results[idx] = { path: fp, error: err.message };
189
+ }
190
+ }
191
+ }
192
+
193
+ const workers = [];
194
+ for (let w = 0; w < Math.min(concurrency, filePaths.length); w++) {
195
+ workers.push(worker());
196
+ }
197
+ await Promise.all(workers);
198
+ return results;
199
+ }
200
+
201
+ /**
202
+ * Resolve a branch name to its HEAD commit SHA.
203
+ * @param {string} owner
204
+ * @param {string} repo
205
+ * @param {string} branch
206
+ * @param {string|null} token
207
+ * @returns {Promise<string>} commit SHA
208
+ */
209
+ async function resolveCommitSha(owner, repo, branch, token) {
210
+ const data = await githubFetch(
211
+ `https://api.github.com/repos/${owner}/${repo}/commits/${branch}`,
212
+ token
213
+ );
214
+ return data.sha;
215
+ }
216
+
217
+ module.exports = {
218
+ getAuthToken,
219
+ isGitHubUrl,
220
+ parseGitHubUrl,
221
+ fetchRepoTree,
222
+ fetchFileContents,
223
+ fetchChangedFiles,
224
+ fetchFilesBatch,
225
+ resolveCommitSha,
226
+ };