gh-load-pull-request 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-load-pull-request",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Download GitHub pull request and convert it to markdown",
5
5
  "type": "module",
6
6
  "main": "src/gh-load-pull-request.mjs",
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Formatters for gh-load-pull-request
3
+ * Contains functions for converting PR data to markdown and JSON formats
4
+ */
5
+
6
+ /**
7
+ * Format a date string for display
8
+ * @param {string} dateStr - ISO date string
9
+ * @returns {string} Formatted date string
10
+ */
11
+ export function formatDate(dateStr) {
12
+ if (!dateStr) {
13
+ return '';
14
+ }
15
+ const date = new Date(dateStr);
16
+ return date
17
+ .toISOString()
18
+ .replace('T', ' ')
19
+ .replace(/\.\d+Z$/, ' UTC');
20
+ }
21
+
22
+ /**
23
+ * Convert PR data to JSON format
24
+ * @param {Object} data - PR data from loadPullRequest
25
+ * @param {Array} downloadedImages - Array of downloaded image info
26
+ * @returns {string} JSON string
27
+ */
28
+ export function convertToJson(data, downloadedImages = []) {
29
+ const { pr, files, comments, reviewComments, reviews, commits } = data;
30
+
31
+ return JSON.stringify(
32
+ {
33
+ pullRequest: {
34
+ number: pr.number,
35
+ title: pr.title,
36
+ state: pr.state,
37
+ draft: pr.draft,
38
+ merged: pr.merged,
39
+ url: pr.html_url,
40
+ author: {
41
+ login: pr.user.login,
42
+ url: `https://github.com/${pr.user.login}`,
43
+ },
44
+ createdAt: pr.created_at,
45
+ updatedAt: pr.updated_at,
46
+ mergedAt: pr.merged_at,
47
+ closedAt: pr.closed_at,
48
+ mergedBy: pr.merged_by
49
+ ? {
50
+ login: pr.merged_by.login,
51
+ url: `https://github.com/${pr.merged_by.login}`,
52
+ }
53
+ : null,
54
+ base: {
55
+ ref: pr.base.ref,
56
+ sha: pr.base.sha,
57
+ },
58
+ head: {
59
+ ref: pr.head.ref,
60
+ sha: pr.head.sha,
61
+ },
62
+ additions: pr.additions,
63
+ deletions: pr.deletions,
64
+ changedFiles: pr.changed_files,
65
+ labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) || [],
66
+ assignees:
67
+ pr.assignees?.map((a) => ({
68
+ login: a.login,
69
+ url: `https://github.com/${a.login}`,
70
+ })) || [],
71
+ requestedReviewers:
72
+ pr.requested_reviewers?.map((r) => ({
73
+ login: r.login,
74
+ url: `https://github.com/${r.login}`,
75
+ })) || [],
76
+ milestone: pr.milestone
77
+ ? { title: pr.milestone.title, number: pr.milestone.number }
78
+ : null,
79
+ body: pr.body,
80
+ },
81
+ commits: commits.map((c) => ({
82
+ sha: c.sha,
83
+ message: c.commit.message,
84
+ author: c.author?.login || c.commit.author?.name || 'unknown',
85
+ url: c.html_url,
86
+ date: c.commit.author?.date,
87
+ })),
88
+ files: files.map((f) => ({
89
+ filename: f.filename,
90
+ status: f.status,
91
+ additions: f.additions,
92
+ deletions: f.deletions,
93
+ previousFilename: f.previous_filename,
94
+ patch: f.patch,
95
+ })),
96
+ reviews: reviews.map((r) => ({
97
+ id: r.id,
98
+ author: r.user.login,
99
+ state: r.state,
100
+ body: r.body,
101
+ submittedAt: r.submitted_at,
102
+ })),
103
+ reviewComments: reviewComments.map((c) => ({
104
+ id: c.id,
105
+ author: c.user.login,
106
+ body: c.body,
107
+ path: c.path,
108
+ line: c.line,
109
+ createdAt: c.created_at,
110
+ diffHunk: c.diff_hunk,
111
+ reviewId: c.pull_request_review_id,
112
+ })),
113
+ comments: comments.map((c) => ({
114
+ id: c.id,
115
+ author: c.user.login,
116
+ body: c.body,
117
+ createdAt: c.created_at,
118
+ })),
119
+ downloadedImages: downloadedImages.map((img) => ({
120
+ originalUrl: img.originalUrl,
121
+ localPath: img.relativePath,
122
+ format: img.format,
123
+ })),
124
+ },
125
+ null,
126
+ 2
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Generate markdown for metadata section
132
+ * @param {Object} pr - Pull request data
133
+ * @returns {string} Markdown content
134
+ */
135
+ export function generateMetadataMarkdown(pr) {
136
+ let markdown = `## Metadata\n\n`;
137
+
138
+ markdown += `| Field | Value |\n`;
139
+ markdown += `|-------|-------|\n`;
140
+ markdown += `| **Number** | #${pr.number} |\n`;
141
+ markdown += `| **URL** | ${pr.html_url} |\n`;
142
+ markdown += `| **Author** | [@${pr.user.login}](https://github.com/${pr.user.login}) |\n`;
143
+ markdown += `| **State** | ${pr.state}${pr.merged ? ' (merged)' : pr.draft ? ' (draft)' : ''} |\n`;
144
+ markdown += `| **Created** | ${formatDate(pr.created_at)} |\n`;
145
+ markdown += `| **Updated** | ${formatDate(pr.updated_at)} |\n`;
146
+
147
+ if (pr.merged_at) {
148
+ markdown += `| **Merged** | ${formatDate(pr.merged_at)} |\n`;
149
+ if (pr.merged_by) {
150
+ markdown += `| **Merged by** | [@${pr.merged_by.login}](https://github.com/${pr.merged_by.login}) |\n`;
151
+ }
152
+ }
153
+ if (pr.closed_at && !pr.merged_at) {
154
+ markdown += `| **Closed** | ${formatDate(pr.closed_at)} |\n`;
155
+ }
156
+
157
+ markdown += `| **Base** | \`${pr.base.ref}\` |\n`;
158
+ markdown += `| **Head** | \`${pr.head.ref}\` |\n`;
159
+ markdown += `| **Additions** | +${pr.additions} |\n`;
160
+ markdown += `| **Deletions** | -${pr.deletions} |\n`;
161
+ markdown += `| **Changed Files** | ${pr.changed_files} |\n`;
162
+ markdown += '\n';
163
+
164
+ if (pr.labels && pr.labels.length > 0) {
165
+ markdown += `**Labels:** ${pr.labels.map((l) => `\`${l.name}\``).join(', ')}\n\n`;
166
+ }
167
+
168
+ if (pr.assignees && pr.assignees.length > 0) {
169
+ markdown += `**Assignees:** ${pr.assignees.map((a) => `[@${a.login}](https://github.com/${a.login})`).join(', ')}\n\n`;
170
+ }
171
+
172
+ if (pr.requested_reviewers && pr.requested_reviewers.length > 0) {
173
+ markdown += `**Requested Reviewers:** ${pr.requested_reviewers.map((r) => `[@${r.login}](https://github.com/${r.login})`).join(', ')}\n\n`;
174
+ }
175
+
176
+ if (pr.milestone) {
177
+ markdown += `**Milestone:** ${pr.milestone.title}\n\n`;
178
+ }
179
+
180
+ return markdown;
181
+ }
182
+
183
+ /**
184
+ * Generate markdown for commits section
185
+ * @param {Array} commits - Array of commit data
186
+ * @returns {string} Markdown content
187
+ */
188
+ export function generateCommitsMarkdown(commits) {
189
+ if (commits.length === 0) {
190
+ return '';
191
+ }
192
+
193
+ let markdown = `## Commits (${commits.length})\n\n`;
194
+
195
+ for (const commit of commits) {
196
+ const message = commit.commit.message.split('\n')[0];
197
+ const sha = commit.sha.substring(0, 7);
198
+ const author =
199
+ commit.author?.login || commit.commit.author?.name || 'unknown';
200
+ const authorLink = commit.author
201
+ ? `[@${author}](https://github.com/${author})`
202
+ : author;
203
+ markdown += `- [\`${sha}\`](${commit.html_url}) ${message} — ${authorLink}\n`;
204
+ }
205
+
206
+ markdown += '\n';
207
+ return markdown;
208
+ }
209
+
210
+ /**
211
+ * Generate markdown for files changed section
212
+ * @param {Array} files - Array of file data
213
+ * @returns {string} Markdown content
214
+ */
215
+ export function generateFilesMarkdown(files) {
216
+ if (files.length === 0) {
217
+ return '';
218
+ }
219
+
220
+ let markdown = `## Files Changed (${files.length})\n\n`;
221
+ markdown += `| Status | File | Changes |\n`;
222
+ markdown += `|--------|------|--------:|\n`;
223
+
224
+ for (const file of files) {
225
+ const statusIcon =
226
+ file.status === 'added'
227
+ ? '🆕 Added'
228
+ : file.status === 'removed'
229
+ ? '🗑️ Removed'
230
+ : file.status === 'modified'
231
+ ? '✏️ Modified'
232
+ : file.status === 'renamed'
233
+ ? '📝 Renamed'
234
+ : `📄 ${file.status}`;
235
+ const changes = `+${file.additions} -${file.deletions}`;
236
+ let filename = file.filename;
237
+ if (file.status === 'renamed' && file.previous_filename) {
238
+ filename = `${file.previous_filename} → ${file.filename}`;
239
+ }
240
+ markdown += `| ${statusIcon} | \`${filename}\` | ${changes} |\n`;
241
+ }
242
+
243
+ markdown += '\n';
244
+ return markdown;
245
+ }
@@ -1,37 +1,38 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- // Import built-in Node.js modules
4
3
  import path from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import https from 'node:https';
7
6
  import http from 'node:http';
8
7
 
9
- // Get __dirname equivalent for ES modules
10
8
  const __filename = fileURLToPath(import.meta.url);
11
9
  const __dirname = path.dirname(__filename);
12
10
 
13
- // Import npm dependencies
14
11
  import { Octokit } from '@octokit/rest';
15
12
  import fs from 'fs-extra';
16
13
  import yargs from 'yargs';
17
14
  import { hideBin } from 'yargs/helpers';
18
15
 
19
- // Get version from package.json or fallback
20
- let version = '0.1.0'; // Fallback version
16
+ import {
17
+ formatDate,
18
+ convertToJson as formattersConvertToJson,
19
+ generateMetadataMarkdown,
20
+ generateCommitsMarkdown,
21
+ generateFilesMarkdown,
22
+ } from './formatters.mjs';
21
23
 
24
+ let version = '0.1.0';
22
25
  try {
23
- const packagePath = path.join(__dirname, 'package.json');
24
- // Use node:fs for Deno compatibility (fs-extra has issues with Deno)
26
+ const packagePath = path.join(__dirname, '..', 'package.json');
25
27
  const { readFileSync, existsSync } = await import('node:fs');
26
28
  if (existsSync(packagePath)) {
27
29
  const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
28
30
  version = packageJson.version;
29
31
  }
30
32
  } catch (_error) {
31
- // Use fallback version if package.json can't be read
33
+ /* Use fallback version */
32
34
  }
33
35
 
34
- // Colors for console output
35
36
  const colors = {
36
37
  green: '\x1b[32m',
37
38
  yellow: '\x1b[33m',
@@ -44,19 +45,32 @@ const colors = {
44
45
  reset: '\x1b[0m',
45
46
  };
46
47
 
47
- // Verbose logging flag (set by CLI option)
48
48
  let verboseMode = false;
49
+ let silentMode = false;
49
50
 
50
- const log = (color, message) =>
51
- console.error(`${colors[color]}${message}${colors.reset}`);
51
+ const log = (color, message) => {
52
+ if (!silentMode) {
53
+ console.error(`${colors[color]}${message}${colors.reset}`);
54
+ }
55
+ };
52
56
 
53
57
  const verboseLog = (color, message) => {
54
- if (verboseMode) {
58
+ if (verboseMode && !silentMode) {
55
59
  log(color, message);
56
60
  }
57
61
  };
58
62
 
59
- // Helper function to check if gh CLI is installed
63
+ /**
64
+ * Set logging mode for library usage
65
+ * @param {Object} options - Logging options
66
+ * @param {boolean} options.verbose - Enable verbose logging
67
+ * @param {boolean} options.silent - Disable all logging
68
+ */
69
+ export function setLoggingMode(options = {}) {
70
+ verboseMode = options.verbose || false;
71
+ silentMode = options.silent || false;
72
+ }
73
+
60
74
  async function isGhInstalled() {
61
75
  try {
62
76
  const { execSync } = await import('node:child_process');
@@ -67,8 +81,11 @@ async function isGhInstalled() {
67
81
  }
68
82
  }
69
83
 
70
- // Helper function to get GitHub token from gh CLI if available
71
- async function getGhToken() {
84
+ /**
85
+ * Get GitHub token from gh CLI if available
86
+ * @returns {Promise<string|null>} GitHub token or null
87
+ */
88
+ export async function getGhToken() {
72
89
  try {
73
90
  if (!(await isGhInstalled())) {
74
91
  return null;
@@ -85,14 +102,13 @@ async function getGhToken() {
85
102
  }
86
103
  }
87
104
 
88
- // Parse PR URL to extract owner, repo, and PR number
89
- function parsePrUrl(url) {
90
- // Support multiple formats:
91
- // https://github.com/owner/repo/pull/123
92
- // owner/repo#123
93
- // owner/repo/123
94
-
95
- // Try full URL format
105
+ /**
106
+ * Parse PR URL to extract owner, repo, and PR number
107
+ * @param {string} url - PR URL or shorthand (owner/repo#123, owner/repo/123, or full URL)
108
+ * @returns {{owner: string, repo: string, prNumber: number}|null} Parsed PR info or null
109
+ */
110
+ export function parsePrUrl(url) {
111
+ // Try full URL format (github.com/owner/repo/pull/123 or owner/repo#123 or owner/repo/123)
96
112
  const urlMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
97
113
  if (urlMatch) {
98
114
  return {
@@ -125,7 +141,6 @@ function parsePrUrl(url) {
125
141
  return null;
126
142
  }
127
143
 
128
- // Image magic bytes for validation
129
144
  const imageMagicBytes = {
130
145
  png: [0x89, 0x50, 0x4e, 0x47],
131
146
  jpg: [0xff, 0xd8, 0xff],
@@ -136,8 +151,13 @@ const imageMagicBytes = {
136
151
  svg: [0x3c, 0x3f, 0x78, 0x6d, 0x6c], // <?xml for SVG (though SVG can also start with <svg)
137
152
  };
138
153
 
139
- // Validate image by checking magic bytes
140
- function validateImageBuffer(buffer, url) {
154
+ /**
155
+ * Validate image by checking magic bytes
156
+ * @param {Buffer} buffer - Image buffer to validate
157
+ * @param {string} url - Original URL (for logging)
158
+ * @returns {{valid: boolean, format?: string, reason?: string}} Validation result
159
+ */
160
+ export function validateImageBuffer(buffer, url) {
141
161
  if (!buffer || buffer.length < 4) {
142
162
  return { valid: false, reason: 'Buffer too small' };
143
163
  }
@@ -185,8 +205,13 @@ function validateImageBuffer(buffer, url) {
185
205
  return { valid: true, format: 'unknown' };
186
206
  }
187
207
 
188
- // Get file extension from format or URL
189
- function getExtensionFromFormat(format, url) {
208
+ /**
209
+ * Get file extension from format or URL
210
+ * @param {string} format - Image format detected
211
+ * @param {string} url - Original URL
212
+ * @returns {string} File extension with leading dot
213
+ */
214
+ export function getExtensionFromFormat(format, url) {
190
215
  const formatExtensions = {
191
216
  png: '.png',
192
217
  jpg: '.jpg',
@@ -227,8 +252,14 @@ function getExtensionFromFormat(format, url) {
227
252
  return '.png'; // Default fallback
228
253
  }
229
254
 
230
- // Download a file with redirect support
231
- function downloadFile(url, token, maxRedirects = 5) {
255
+ /**
256
+ * Download a file with redirect support
257
+ * @param {string} url - URL to download
258
+ * @param {string} token - GitHub token for authenticated requests
259
+ * @param {number} maxRedirects - Maximum number of redirects to follow
260
+ * @returns {Promise<Buffer>} Downloaded file content
261
+ */
262
+ export function downloadFile(url, token, maxRedirects = 5) {
232
263
  return new Promise((resolve, reject) => {
233
264
  if (maxRedirects <= 0) {
234
265
  reject(new Error('Too many redirects'));
@@ -281,8 +312,12 @@ function downloadFile(url, token, maxRedirects = 5) {
281
312
  });
282
313
  }
283
314
 
284
- // Extract image URLs from markdown content
285
- function extractMarkdownImageUrls(content) {
315
+ /**
316
+ * Extract image URLs from markdown content
317
+ * @param {string} content - Markdown content to search
318
+ * @returns {Array<{url: string, alt: string}>} Array of image URLs with alt text
319
+ */
320
+ export function extractMarkdownImageUrls(content) {
286
321
  if (!content) {
287
322
  return [];
288
323
  }
@@ -305,8 +340,15 @@ function extractMarkdownImageUrls(content) {
305
340
  return urls;
306
341
  }
307
342
 
308
- // Download all images from content and update the markdown
309
- async function downloadImages(content, imagesDir, token, _prNumber) {
343
+ /**
344
+ * Download all images from content and update the markdown
345
+ * @param {string} content - Markdown content with image URLs
346
+ * @param {string} imagesDir - Directory to save images
347
+ * @param {string} token - GitHub token for authenticated requests
348
+ * @param {number} _prNumber - PR number (unused, kept for compatibility)
349
+ * @returns {Promise<{content: string, downloadedImages: Array}>} Updated content and downloaded images info
350
+ */
351
+ export async function downloadImages(content, imagesDir, token, _prNumber) {
310
352
  if (!content) {
311
353
  return { content, downloadedImages: [] };
312
354
  }
@@ -338,7 +380,7 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
338
380
  const ext = getExtensionFromFormat(validation.format, url);
339
381
  const filename = `image-${imageCounter}${ext}`;
340
382
  const localPath = path.join(imagesDir, filename);
341
- const relativePath = `./${path.basename(imagesDir)}/${filename}`;
383
+ const relativePath = `./images/${filename}`;
342
384
 
343
385
  await fs.writeFile(localPath, buffer);
344
386
  downloadedImages.push({
@@ -362,8 +404,19 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
362
404
  return { content: updatedContent, downloadedImages };
363
405
  }
364
406
 
365
- // Fetch pull request data from GitHub API
366
- async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
407
+ /**
408
+ * Fetch pull request data from GitHub API
409
+ * @param {Object} options - Options for fetching PR
410
+ * @param {string} options.owner - Repository owner
411
+ * @param {string} options.repo - Repository name
412
+ * @param {number} options.prNumber - Pull request number
413
+ * @param {string} options.token - GitHub token (optional for public repos)
414
+ * @param {boolean} options.includeReviews - Include PR reviews (default: true)
415
+ * @returns {Promise<Object>} PR data object with pr, files, comments, reviewComments, reviews, commits
416
+ */
417
+ export async function loadPullRequest(options) {
418
+ const { owner, repo, prNumber, token, includeReviews = true } = options;
419
+
367
420
  try {
368
421
  log('blue', `🔍 Fetching pull request ${owner}/${repo}#${prNumber}...`);
369
422
 
@@ -386,14 +439,14 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
386
439
  pull_number: prNumber,
387
440
  });
388
441
 
389
- // Fetch PR comments
442
+ // Fetch PR comments (issue comments)
390
443
  const { data: comments } = await octokit.rest.issues.listComments({
391
444
  owner,
392
445
  repo,
393
446
  issue_number: prNumber,
394
447
  });
395
448
 
396
- // Fetch PR review comments
449
+ // Fetch PR review comments (inline code comments)
397
450
  const { data: reviewComments } =
398
451
  await octokit.rest.pulls.listReviewComments({
399
452
  owner,
@@ -401,9 +454,9 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
401
454
  pull_number: prNumber,
402
455
  });
403
456
 
404
- // Fetch PR reviews (only if includeReviews is true)
457
+ // Fetch PR reviews
405
458
  let reviews = [];
406
- if (options.includeReviews !== false) {
459
+ if (includeReviews) {
407
460
  const { data: reviewsData } = await octokit.rest.pulls.listReviews({
408
461
  owner,
409
462
  repo,
@@ -431,19 +484,21 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
431
484
  };
432
485
  } catch (error) {
433
486
  if (error.status === 404) {
434
- log('red', `❌ Pull request not found: ${owner}/${repo}#${prNumber}`);
487
+ throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`);
435
488
  } else if (error.status === 401) {
436
- log(
437
- 'red',
438
- `❌ Authentication failed. Please provide a valid GitHub token`
489
+ throw new Error(
490
+ 'Authentication failed. Please provide a valid GitHub token'
439
491
  );
440
492
  } else {
441
- log('red', `❌ Failed to fetch pull request: ${error.message}`);
493
+ throw new Error(`Failed to fetch pull request: ${error.message}`);
442
494
  }
443
- process.exit(1);
444
495
  }
445
496
  }
446
497
 
498
+ // Alias for backwards compatibility
499
+ export const fetchPullRequest = (owner, repo, prNumber, token, options = {}) =>
500
+ loadPullRequest({ owner, repo, prNumber, token, ...options });
501
+
447
502
  // Process content and download images if enabled
448
503
  function processContent(
449
504
  content,
@@ -458,8 +513,17 @@ function processContent(
458
513
  return downloadImages(content, imagesDir, token, prNumber);
459
514
  }
460
515
 
461
- // Convert PR data to markdown
462
- async function convertToMarkdown(data, options = {}) {
516
+ /**
517
+ * Convert PR data to markdown format
518
+ * @param {Object} data - PR data from loadPullRequest
519
+ * @param {Object} options - Conversion options
520
+ * @param {boolean} options.downloadImagesFlag - Download embedded images (default: true)
521
+ * @param {string} options.imagesDir - Directory to save images
522
+ * @param {string} options.token - GitHub token for downloading images
523
+ * @param {number} options.prNumber - PR number
524
+ * @returns {Promise<{markdown: string, downloadedImages: Array}>} Markdown content and downloaded images
525
+ */
526
+ export async function convertToMarkdown(data, options = {}) {
463
527
  const { pr, files, comments, reviewComments, reviews, commits } = data;
464
528
  const {
465
529
  downloadImagesFlag = true,
@@ -486,125 +550,152 @@ async function convertToMarkdown(data, options = {}) {
486
550
  allDownloadedImages = [...allDownloadedImages, ...result.downloadedImages];
487
551
  }
488
552
 
489
- // Header
490
553
  markdown += `# ${pr.title}\n\n`;
491
554
 
492
- // Metadata
493
- markdown += `**Author:** @${pr.user.login}\n`;
494
- markdown += `**Created:** ${pr.created_at}\n`;
495
- markdown += `**State:** ${pr.state}\n`;
496
- markdown += `**Branch:** ${pr.head.ref} → ${pr.base.ref}\n`;
497
-
498
- // Labels
499
- if (pr.labels && pr.labels.length > 0) {
500
- markdown += `**Labels:** ${pr.labels.map((l) => l.name).join(', ')}\n`;
501
- }
555
+ markdown += generateMetadataMarkdown(pr);
556
+ markdown += '---\n\n';
502
557
 
503
- markdown += '\n## Description\n\n';
558
+ markdown += '## Description\n\n';
504
559
  markdown += prBody ? `${prBody}\n\n` : '_No description provided._\n\n';
505
560
 
506
561
  markdown += '---\n\n';
507
562
 
508
- // Comments
509
- if (comments.length > 0) {
510
- markdown += `## Comments\n\n`;
511
- for (const comment of comments) {
512
- let commentBody = comment.body || '';
513
- if (downloadImagesFlag && commentBody) {
514
- verboseLog(
515
- 'blue',
516
- `Processing images in comment by @${comment.user.login}...`
517
- );
518
- const result = await processContent(
519
- commentBody,
520
- imagesDir,
521
- token,
522
- prNumber,
523
- downloadImagesFlag
524
- );
525
- commentBody = result.content;
526
- allDownloadedImages = [
527
- ...allDownloadedImages,
528
- ...result.downloadedImages,
529
- ];
530
- }
563
+ markdown += '## Conversation\n\n';
531
564
 
532
- markdown += `### Comment by @${comment.user.login} (${comment.created_at})\n\n`;
533
- markdown += `${commentBody}\n\n`;
534
- markdown += '---\n\n';
565
+ const timelineEvents = [];
566
+
567
+ for (const comment of comments) {
568
+ timelineEvents.push({
569
+ type: 'comment',
570
+ timestamp: new Date(comment.created_at),
571
+ data: comment,
572
+ });
573
+ }
574
+
575
+ for (const review of reviews) {
576
+ if (review.submitted_at) {
577
+ timelineEvents.push({
578
+ type: 'review',
579
+ timestamp: new Date(review.submitted_at),
580
+ data: review,
581
+ });
535
582
  }
536
583
  }
537
584
 
538
- // Reviews
539
- if (reviews.length > 0) {
540
- markdown += `## Reviews\n\n`;
541
- for (const review of reviews) {
542
- let reviewBody = review.body || '';
543
- if (downloadImagesFlag && reviewBody) {
544
- verboseLog(
545
- 'blue',
546
- `Processing images in review by @${review.user.login}...`
547
- );
548
- const result = await processContent(
549
- reviewBody,
550
- imagesDir,
551
- token,
552
- prNumber,
553
- downloadImagesFlag
554
- );
555
- reviewBody = result.content;
556
- allDownloadedImages = [
557
- ...allDownloadedImages,
558
- ...result.downloadedImages,
559
- ];
560
- }
585
+ timelineEvents.sort((a, b) => a.timestamp - b.timestamp);
561
586
 
562
- markdown += `### Review by @${review.user.login} (${review.submitted_at})\n`;
563
- markdown += `**State:** ${review.state}\n\n`;
587
+ if (timelineEvents.length === 0) {
588
+ markdown += '_No comments or reviews._\n\n';
589
+ } else {
590
+ for (const event of timelineEvents) {
591
+ if (event.type === 'comment') {
592
+ const comment = event.data;
593
+ let commentBody = comment.body || '';
594
+ if (downloadImagesFlag && commentBody) {
595
+ verboseLog(
596
+ 'blue',
597
+ `Processing images in comment by @${comment.user.login}...`
598
+ );
599
+ const result = await processContent(
600
+ commentBody,
601
+ imagesDir,
602
+ token,
603
+ prNumber,
604
+ downloadImagesFlag
605
+ );
606
+ commentBody = result.content;
607
+ allDownloadedImages = [
608
+ ...allDownloadedImages,
609
+ ...result.downloadedImages,
610
+ ];
611
+ }
564
612
 
565
- if (reviewBody) {
566
- markdown += `${reviewBody}\n\n`;
567
- }
613
+ markdown += `### 💬 Comment by [@${comment.user.login}](https://github.com/${comment.user.login})\n`;
614
+ markdown += `*${formatDate(comment.created_at)}*\n\n`;
615
+ markdown += `${commentBody}\n\n`;
616
+ markdown += '---\n\n';
617
+ } else if (event.type === 'review') {
618
+ const review = event.data;
619
+ let reviewBody = review.body || '';
620
+ if (downloadImagesFlag && reviewBody) {
621
+ verboseLog(
622
+ 'blue',
623
+ `Processing images in review by @${review.user.login}...`
624
+ );
625
+ const result = await processContent(
626
+ reviewBody,
627
+ imagesDir,
628
+ token,
629
+ prNumber,
630
+ downloadImagesFlag
631
+ );
632
+ reviewBody = result.content;
633
+ allDownloadedImages = [
634
+ ...allDownloadedImages,
635
+ ...result.downloadedImages,
636
+ ];
637
+ }
568
638
 
569
- // Add review comments for this review
570
- const reviewReviewComments = reviewComments.filter(
571
- (rc) => rc.pull_request_review_id === review.id
572
- );
573
- if (reviewReviewComments.length > 0) {
574
- markdown += `#### Review Comments\n\n`;
575
- for (const rc of reviewReviewComments) {
576
- let rcBody = rc.body || '';
577
- if (downloadImagesFlag && rcBody) {
578
- const result = await processContent(
579
- rcBody,
580
- imagesDir,
581
- token,
582
- prNumber,
583
- downloadImagesFlag
584
- );
585
- rcBody = result.content;
586
- allDownloadedImages = [
587
- ...allDownloadedImages,
588
- ...result.downloadedImages,
589
- ];
590
- }
639
+ const stateEmoji =
640
+ review.state === 'APPROVED'
641
+ ? '✅'
642
+ : review.state === 'CHANGES_REQUESTED'
643
+ ? '❌'
644
+ : review.state === 'COMMENTED'
645
+ ? '💬'
646
+ : '📝';
647
+
648
+ markdown += `### ${stateEmoji} Review by [@${review.user.login}](https://github.com/${review.user.login})\n`;
649
+ markdown += `*${formatDate(review.submitted_at)}* — **${review.state}**\n\n`;
591
650
 
592
- const lineInfo = rc.line ? `:${rc.line}` : '';
593
- markdown += `**File:** ${rc.path}${lineInfo}\n`;
594
- markdown += `${rcBody}\n\n`;
651
+ if (reviewBody) {
652
+ markdown += `${reviewBody}\n\n`;
595
653
  }
596
- }
597
654
 
598
- markdown += '---\n\n';
655
+ // Add review comments for this review
656
+ const reviewReviewComments = reviewComments.filter(
657
+ (rc) => rc.pull_request_review_id === review.id
658
+ );
659
+ if (reviewReviewComments.length > 0) {
660
+ markdown += `#### Inline Comments\n\n`;
661
+ for (const rc of reviewReviewComments) {
662
+ let rcBody = rc.body || '';
663
+ if (downloadImagesFlag && rcBody) {
664
+ const result = await processContent(
665
+ rcBody,
666
+ imagesDir,
667
+ token,
668
+ prNumber,
669
+ downloadImagesFlag
670
+ );
671
+ rcBody = result.content;
672
+ allDownloadedImages = [
673
+ ...allDownloadedImages,
674
+ ...result.downloadedImages,
675
+ ];
676
+ }
677
+
678
+ const lineInfo = rc.line ? `:${rc.line}` : '';
679
+ markdown += `**\`${rc.path}${lineInfo}\`**\n\n`;
680
+ markdown += `${rcBody}\n\n`;
681
+ if (rc.diff_hunk) {
682
+ markdown += '```diff\n';
683
+ markdown += `${rc.diff_hunk}\n`;
684
+ markdown += '```\n\n';
685
+ }
686
+ }
687
+ }
688
+
689
+ markdown += '---\n\n';
690
+ }
599
691
  }
600
692
  }
601
693
 
602
- // Standalone review comments (not associated with a review)
603
694
  const standaloneReviewComments = reviewComments.filter(
604
695
  (rc) => !rc.pull_request_review_id
605
696
  );
606
697
  if (standaloneReviewComments.length > 0) {
607
- markdown += `## Review Comments\n\n`;
698
+ markdown += `## Inline Code Comments\n\n`;
608
699
  for (const comment of standaloneReviewComments) {
609
700
  let commentBody = comment.body || '';
610
701
  if (downloadImagesFlag && commentBody) {
@@ -622,191 +713,196 @@ async function convertToMarkdown(data, options = {}) {
622
713
  ];
623
714
  }
624
715
 
625
- markdown += `**@${comment.user.login}** commented on \`${comment.path}\``;
716
+ markdown += `### [@${comment.user.login}](https://github.com/${comment.user.login}) on \`${comment.path}\``;
626
717
  if (comment.line) {
627
718
  markdown += ` (line ${comment.line})`;
628
719
  }
629
- markdown += `:\n`;
630
- markdown += `*${comment.created_at}*\n\n`;
720
+ markdown += `\n`;
721
+ markdown += `*${formatDate(comment.created_at)}*\n\n`;
631
722
  markdown += `${commentBody}\n\n`;
632
723
  if (comment.diff_hunk) {
633
724
  markdown += '```diff\n';
634
725
  markdown += `${comment.diff_hunk}\n`;
635
726
  markdown += '```\n\n';
636
727
  }
728
+ markdown += '---\n\n';
637
729
  }
638
730
  }
639
731
 
640
- // Commits
641
- if (commits.length > 0) {
642
- markdown += `## Commits (${commits.length})\n\n`;
643
- for (const commit of commits) {
644
- const message = commit.commit.message.split('\n')[0]; // First line only
645
- const sha = commit.sha.substring(0, 7);
646
- markdown += `- [\`${sha}\`](${commit.html_url}) ${message} - @${commit.author?.login || 'unknown'}\n`;
647
- }
648
- markdown += '\n';
649
- }
650
-
651
- // Files changed
652
- if (files.length > 0) {
653
- markdown += `## Files Changed (${files.length})\n\n`;
654
- for (const file of files) {
655
- const statusIcon =
656
- file.status === 'added'
657
- ? '🆕'
658
- : file.status === 'removed'
659
- ? '🗑️'
660
- : file.status === 'modified'
661
- ? '✏️'
662
- : file.status === 'renamed'
663
- ? '📝'
664
- : '📄';
665
- markdown += `${statusIcon} **${file.filename}** (+${file.additions} -${file.deletions})\n`;
666
- if (file.status === 'renamed') {
667
- markdown += ` - Renamed from: \`${file.previous_filename}\`\n`;
668
- }
669
- }
670
- markdown += '\n';
671
- }
732
+ markdown += generateCommitsMarkdown(commits);
733
+
734
+ markdown += generateFilesMarkdown(files);
672
735
 
673
736
  return { markdown, downloadedImages: allDownloadedImages };
674
737
  }
675
738
 
676
- // Convert PR data to JSON format
677
- function convertToJson(data, downloadedImages = []) {
678
- const { pr, files, comments, reviewComments, reviews, commits } = data;
739
+ /**
740
+ * Convert PR data to JSON format
741
+ * @param {Object} data - PR data from loadPullRequest
742
+ * @param {Array} downloadedImages - Array of downloaded image info
743
+ * @returns {string} JSON string
744
+ */
745
+ export function convertToJson(data, downloadedImages = []) {
746
+ return formattersConvertToJson(data, downloadedImages);
747
+ }
748
+
749
+ /**
750
+ * Save PR data to a folder with all assets for offline viewing
751
+ * @param {Object} data - PR data from loadPullRequest
752
+ * @param {Object} options - Save options
753
+ * @param {string} options.outputDir - Output directory
754
+ * @param {string} options.format - Output format ('markdown' or 'json')
755
+ * @param {boolean} options.downloadImages - Download images (default: true)
756
+ * @param {string} options.token - GitHub token for downloading images
757
+ * @returns {Promise<{mdPath: string, jsonPath: string, imagesDir: string, downloadedImages: Array}>}
758
+ */
759
+ export async function savePullRequest(data, options = {}) {
760
+ const {
761
+ outputDir,
762
+ format = 'markdown',
763
+ downloadImages: downloadImagesFlag = true,
764
+ token = '',
765
+ } = options;
766
+
767
+ const prNumber = data.pr.number;
768
+ const prDir = path.join(outputDir, `pr-${prNumber}`);
769
+ const imagesDir = path.join(prDir, 'images');
770
+ const mdPath = path.join(prDir, `pr-${prNumber}.md`);
771
+ const jsonPath = path.join(prDir, `pr-${prNumber}.json`);
772
+
773
+ // Ensure directories exist
774
+ await fs.ensureDir(prDir);
775
+
776
+ let downloadedImages = [];
777
+
778
+ // Generate markdown
779
+ log('blue', `📝 Converting to ${format}...`);
780
+
781
+ const mdResult = await convertToMarkdown(data, {
782
+ downloadImagesFlag,
783
+ imagesDir,
784
+ token,
785
+ prNumber,
786
+ });
787
+ downloadedImages = mdResult.downloadedImages;
788
+
789
+ // Save markdown
790
+ await fs.writeFile(mdPath, mdResult.markdown, 'utf8');
791
+ log('green', `✅ Saved markdown to ${mdPath}`);
792
+
793
+ // Always save JSON as well for metadata
794
+ const jsonContent = convertToJson(data, downloadedImages);
795
+ await fs.writeFile(jsonPath, jsonContent, 'utf8');
796
+ log('green', `✅ Saved JSON metadata to ${jsonPath}`);
679
797
 
680
- return JSON.stringify(
681
- {
682
- pullRequest: {
683
- number: pr.number,
684
- title: pr.title,
685
- state: pr.state,
686
- url: pr.html_url,
687
- author: pr.user.login,
688
- createdAt: pr.created_at,
689
- updatedAt: pr.updated_at,
690
- mergedAt: pr.merged_at,
691
- closedAt: pr.closed_at,
692
- base: pr.base.ref,
693
- head: pr.head.ref,
694
- additions: pr.additions,
695
- deletions: pr.deletions,
696
- changedFiles: pr.changed_files,
697
- labels: pr.labels?.map((l) => l.name) || [],
698
- body: pr.body,
699
- },
700
- commits: commits.map((c) => ({
701
- sha: c.sha,
702
- message: c.commit.message,
703
- author: c.author?.login || 'unknown',
704
- url: c.html_url,
705
- })),
706
- files: files.map((f) => ({
707
- filename: f.filename,
708
- status: f.status,
709
- additions: f.additions,
710
- deletions: f.deletions,
711
- previousFilename: f.previous_filename,
712
- })),
713
- reviews: reviews.map((r) => ({
714
- id: r.id,
715
- author: r.user.login,
716
- state: r.state,
717
- body: r.body,
718
- submittedAt: r.submitted_at,
719
- })),
720
- reviewComments: reviewComments.map((c) => ({
721
- id: c.id,
722
- author: c.user.login,
723
- body: c.body,
724
- path: c.path,
725
- line: c.line,
726
- createdAt: c.created_at,
727
- diffHunk: c.diff_hunk,
728
- reviewId: c.pull_request_review_id,
729
- })),
730
- comments: comments.map((c) => ({
731
- id: c.id,
732
- author: c.user.login,
733
- body: c.body,
734
- createdAt: c.created_at,
735
- })),
736
- downloadedImages: downloadedImages.map((img) => ({
737
- originalUrl: img.originalUrl,
738
- localPath: img.relativePath,
739
- format: img.format,
740
- })),
741
- },
742
- null,
743
- 2
798
+ if (downloadedImages.length > 0) {
799
+ log(
800
+ 'green',
801
+ `📁 Downloaded ${downloadedImages.length} image(s) to ${imagesDir}`
802
+ );
803
+ }
804
+
805
+ return {
806
+ mdPath,
807
+ jsonPath,
808
+ imagesDir,
809
+ downloadedImages,
810
+ };
811
+ }
812
+
813
+ // CLI IMPLEMENTATION
814
+
815
+ // Only run CLI when executed directly, not when imported as a module
816
+ // Check if this module is the main entry point by comparing paths
817
+ function isRunningAsCli() {
818
+ // Get the actual script being run
819
+ const scriptArg = process.argv[1];
820
+ if (!scriptArg) {
821
+ return false;
822
+ }
823
+
824
+ // Normalize paths for comparison
825
+ const scriptPath = path.resolve(scriptArg);
826
+ const thisModulePath = path.resolve(__filename);
827
+
828
+ // Check if the script being run is this module
829
+ // This handles both direct execution and bun run
830
+ return (
831
+ scriptPath === thisModulePath ||
832
+ scriptPath.endsWith('gh-load-pull-request.mjs') ||
833
+ scriptPath.endsWith('gh-load-pull-request')
744
834
  );
745
835
  }
746
836
 
747
- // Configure CLI arguments
748
- const scriptName = path.basename(process.argv[1]);
749
- const argv = yargs(hideBin(process.argv))
750
- .scriptName(scriptName)
751
- .version(version)
752
- .usage('Usage: $0 <pr-url> [options]')
753
- .command(
754
- '$0 <pr>',
755
- 'Download a GitHub pull request and convert it to markdown',
756
- (yargs) => {
757
- yargs.positional('pr', {
758
- describe:
759
- 'Pull request URL or shorthand (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)',
760
- type: 'string',
761
- });
762
- }
763
- )
764
- .option('token', {
765
- alias: 't',
766
- type: 'string',
767
- describe: 'GitHub personal access token (optional for public PRs)',
768
- default: process.env.GITHUB_TOKEN,
769
- })
770
- .option('output', {
771
- alias: 'o',
772
- type: 'string',
773
- describe: 'Output directory (default: current directory)',
774
- })
775
- .option('download-images', {
776
- type: 'boolean',
777
- describe: 'Download embedded images',
778
- default: true,
779
- })
780
- .option('include-reviews', {
781
- type: 'boolean',
782
- describe: 'Include PR reviews',
783
- default: true,
784
- })
785
- .option('format', {
786
- type: 'string',
787
- describe: 'Output format: markdown, json',
788
- default: 'markdown',
789
- choices: ['markdown', 'json'],
790
- })
791
- .option('verbose', {
792
- alias: 'v',
793
- type: 'boolean',
794
- describe: 'Enable verbose logging',
795
- default: false,
796
- })
797
- .help('h')
798
- .alias('h', 'help')
799
- .example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
800
- .example('$0 owner/repo#123', 'Download PR using shorthand format')
801
- .example('$0 owner/repo#123 -o ./output', 'Save to output directory')
802
- .example('$0 owner/repo#123 --format json', 'Output as JSON')
803
- .example('$0 owner/repo#123 --no-download-images', 'Skip image download')
804
- .example(
805
- '$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
806
- 'Download private PR'
807
- ).argv;
837
+ /**
838
+ * Parse CLI arguments
839
+ * @returns {Object} Parsed CLI arguments
840
+ */
841
+ function parseCliArgs() {
842
+ const scriptName = path.basename(process.argv[1] || 'gh-load-pull-request');
843
+ return yargs(hideBin(process.argv))
844
+ .scriptName(scriptName)
845
+ .version(version)
846
+ .usage('Usage: $0 <pr-url> [options]')
847
+ .command(
848
+ '$0 <pr>',
849
+ 'Download a GitHub pull request and convert it to markdown',
850
+ (yargs) => {
851
+ yargs.positional('pr', {
852
+ describe:
853
+ 'Pull request URL or shorthand (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)',
854
+ type: 'string',
855
+ });
856
+ }
857
+ )
858
+ .option('token', {
859
+ alias: 't',
860
+ type: 'string',
861
+ describe: 'GitHub personal access token (optional for public PRs)',
862
+ default: process.env.GITHUB_TOKEN,
863
+ })
864
+ .option('output', {
865
+ alias: 'o',
866
+ type: 'string',
867
+ describe: 'Output directory (creates pr-<number>/ subfolder)',
868
+ })
869
+ .option('download-images', {
870
+ type: 'boolean',
871
+ describe: 'Download embedded images',
872
+ default: true,
873
+ })
874
+ .option('include-reviews', {
875
+ type: 'boolean',
876
+ describe: 'Include PR reviews',
877
+ default: true,
878
+ })
879
+ .option('format', {
880
+ type: 'string',
881
+ describe: 'Output format: markdown, json',
882
+ default: 'markdown',
883
+ choices: ['markdown', 'json'],
884
+ })
885
+ .option('verbose', {
886
+ alias: 'v',
887
+ type: 'boolean',
888
+ describe: 'Enable verbose logging',
889
+ default: false,
890
+ })
891
+ .help('h')
892
+ .alias('h', 'help')
893
+ .example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
894
+ .example('$0 owner/repo#123', 'Download PR using shorthand format')
895
+ .example('$0 owner/repo#123 -o ./output', 'Save to output directory')
896
+ .example('$0 owner/repo#123 --format json', 'Output as JSON')
897
+ .example('$0 owner/repo#123 --no-download-images', 'Skip image download')
898
+ .example(
899
+ '$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
900
+ 'Download private PR'
901
+ ).argv;
902
+ }
808
903
 
809
904
  async function main() {
905
+ const argv = parseCliArgs();
810
906
  const {
811
907
  pr: prInput,
812
908
  token: tokenArg,
@@ -844,80 +940,58 @@ async function main() {
844
940
 
845
941
  const { owner, repo, prNumber } = prInfo;
846
942
 
847
- // Fetch PR data
848
- const data = await fetchPullRequest(owner, repo, prNumber, token, {
849
- includeReviews,
850
- });
851
-
852
- // Determine output paths
853
- const outputDir = output || process.cwd();
854
- const imagesDir = path.join(outputDir, `pr-${prNumber}-images`);
855
- const mdOutputPath = path.join(outputDir, `pr-${prNumber}.md`);
856
- const jsonOutputPath = path.join(outputDir, `pr-${prNumber}.json`);
857
-
858
- // Convert to appropriate format
859
- log('blue', `📝 Converting to ${format}...`);
860
-
861
- let outputContent;
862
- let downloadedImages = [];
863
-
864
- if (format === 'json') {
865
- // For JSON, we might still want to download images
866
- if (downloadImagesFlag) {
867
- log('blue', '🖼️ Processing images...');
868
- // Process all content for images
869
- const allContent = [
870
- data.pr.body || '',
871
- ...data.comments.map((c) => c.body || ''),
872
- ...data.reviews.map((r) => r.body || ''),
873
- ...data.reviewComments.map((rc) => rc.body || ''),
874
- ].join('\n\n');
875
-
876
- const result = await downloadImages(
877
- allContent,
878
- imagesDir,
879
- token,
880
- prNumber
881
- );
882
- downloadedImages = result.downloadedImages;
883
- }
884
- outputContent = convertToJson(data, downloadedImages);
885
- } else {
886
- // Markdown format with image processing
887
- const result = await convertToMarkdown(data, {
888
- downloadImagesFlag,
889
- imagesDir,
890
- token,
943
+ try {
944
+ // Fetch PR data
945
+ const data = await loadPullRequest({
946
+ owner,
947
+ repo,
891
948
  prNumber,
949
+ token,
950
+ includeReviews,
892
951
  });
893
- outputContent = result.markdown;
894
- downloadedImages = result.downloadedImages;
895
- }
896
952
 
897
- // Output
898
- if (output) {
899
- await fs.ensureDir(outputDir);
900
- const outputPath = format === 'json' ? jsonOutputPath : mdOutputPath;
901
- await fs.writeFile(outputPath, outputContent, 'utf8');
902
- log('green', `✅ Saved to ${outputPath}`);
953
+ // Determine output paths
954
+ if (output) {
955
+ // Save to directory
956
+ await savePullRequest(data, {
957
+ outputDir: output,
958
+ format,
959
+ downloadImages: downloadImagesFlag,
960
+ token,
961
+ });
962
+ } else {
963
+ // Output to stdout
964
+ if (format === 'json') {
965
+ const jsonContent = convertToJson(data, []);
966
+ console.log(jsonContent);
967
+ } else {
968
+ const { markdown } = await convertToMarkdown(data, {
969
+ downloadImagesFlag: false, // Don't download images when outputting to stdout
970
+ imagesDir: '',
971
+ token: '',
972
+ prNumber,
973
+ });
974
+ console.log(markdown);
975
+ }
976
+ }
903
977
 
904
- if (downloadedImages.length > 0) {
905
- log(
906
- 'green',
907
- `📁 Downloaded ${downloadedImages.length} image(s) to ${imagesDir}`
908
- );
978
+ log('blue', '🎉 Done!');
979
+ } catch (error) {
980
+ log('red', `❌ ${error.message}`);
981
+ if (verboseMode) {
982
+ console.error(error.stack);
909
983
  }
910
- } else {
911
- console.log(outputContent);
984
+ process.exit(1);
912
985
  }
913
-
914
- log('blue', '🎉 Done!');
915
986
  }
916
987
 
917
- main().catch((error) => {
918
- log('red', `💥 Script failed: ${error.message}`);
919
- if (verboseMode) {
920
- console.error(error.stack);
921
- }
922
- process.exit(1);
923
- });
988
+ // Run CLI if this is the main module
989
+ if (isRunningAsCli()) {
990
+ main().catch((error) => {
991
+ log('red', `💥 Script failed: ${error.message}`);
992
+ if (verboseMode) {
993
+ console.error(error.stack);
994
+ }
995
+ process.exit(1);
996
+ });
997
+ }