@xelth/eck-snapshot 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ // .ecksnapshot.config.js
2
+
3
+ // Я заменил "module.exports =" на "export default"
4
+ export default {
5
+ filesToIgnore: [
6
+ 'package-lock.json',
7
+ 'yarn.lock',
8
+ 'pnpm-lock.yaml'
9
+ ],
10
+ extensionsToIgnore: [
11
+ '.sqlite3',
12
+ '.db',
13
+ '.DS_Store',
14
+ '.env',
15
+ '.log',
16
+ '.tmp',
17
+ '.bak',
18
+ '.swp',
19
+ '.ico',
20
+ '.png',
21
+ '.jpg',
22
+ '.jpeg',
23
+ '.gif',
24
+ '.svg'
25
+ ],
26
+ dirsToIgnore: [
27
+ 'node_modules/',
28
+ '.git/',
29
+ '.idea/',
30
+ 'snapshots/',
31
+ 'dist/',
32
+ 'build/',
33
+ 'coverage/'
34
+ ],
35
+ };
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+
2
+ # eckSnapshot
3
+
4
+ A CLI tool to create a single-file text snapshot of a Git repository. It generates a single `.txt` file containing the directory structure and the content of all text-based files, which is ideal for providing context to Large Language Models (LLMs) like GPT or Claude.
5
+
6
+ ## Features
7
+
8
+ - **Git Integration**: Automatically includes all files tracked by Git.
9
+ - **Intelligent Ignoring**: Respects `.gitignore` rules and has its own configurable ignore lists for files, extensions, and directories.
10
+ - **Directory Tree**: Generates a clean, readable tree of the repository structure at the top of the snapshot.
11
+ - **Configurable**: Customize behavior using an `.ecksnapshot.config.js` file.
12
+ - **Progress and Stats**: Provides a progress bar and a detailed summary of what was included and skipped.
13
+
14
+ ## Installation
15
+
16
+ To install the tool globally, run the following command:
17
+
18
+ ```bash
19
+ npm install -g @username/eck-snapshot
20
+ ````
21
+
22
+ *(Note: Replace `@username/eck-snapshot` with the actual package name you publish to npm).*
23
+
24
+ ## Usage
25
+
26
+ Once installed, you can run the tool from any directory in your terminal. While the project is named `eckSnapshot`, the command-line tool is invoked as **`eck-snapshot`** to follow standard CLI conventions.
27
+
28
+ **Generate a snapshot of the current directory:**
29
+
30
+ ```bash
31
+ eck-snapshot
32
+ ```
33
+
34
+ **Specify a path to another repository:**
35
+
36
+ ```bash
37
+ eck-snapshot /path/to/your/other/project
38
+ ```
39
+
40
+ **Common Options:**
41
+
42
+ - `-o, --output <dir>`: Specify a different output directory for the snapshot file.
43
+ - `-v, --verbose`: Show detailed processing information, including skipped files.
44
+ - `--no-tree`: Exclude the directory tree from the snapshot.
45
+ - `--config <path>`: Use a configuration file from a specific path.
46
+
47
+ ## Configuration
48
+
49
+ You can create a `.ecksnapshot.config.js` file in your project's root directory to customize the tool's behavior.
50
+
51
+ **Example `.ecksnapshot.config.js`:**
52
+
53
+ ```javascript
54
+ export default {
55
+ // Files to ignore by name or pattern
56
+ filesToIgnore: [
57
+ 'package-lock.json',
58
+ '*.log',
59
+ ],
60
+ // File extensions to ignore
61
+ extensionsToIgnore: [
62
+ '.sqlite3',
63
+ '.env',
64
+ ],
65
+ // Directories to ignore (must have a trailing slash)
66
+ dirsToIgnore: [
67
+ 'node_modules/',
68
+ '.git/',
69
+ 'dist/',
70
+ ],
71
+ // Maximum size for individual files
72
+ maxFileSize: '10MB',
73
+ };
74
+ ```
75
+
76
+ ## License
77
+
78
+ This project is licensed under the MIT License.
package/index.js ADDED
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { execa } from 'execa';
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import isBinaryPath from 'is-binary-path';
8
+ import { fileURLToPath } from 'url';
9
+ import ignore from 'ignore';
10
+ import { SingleBar, Presets } from 'cli-progress';
11
+ import pLimit from 'p-limit';
12
+ import zlib from 'zlib';
13
+ import { promisify } from 'util';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const gzip = promisify(zlib.gzip);
17
+
18
+ const DEFAULT_CONFIG = {
19
+ filesToIgnore: ['package-lock.json', '*.log', 'yarn.lock'],
20
+ extensionsToIgnore: ['.sqlite3', '.db', '.DS_Store', '.env', '.pyc', '.class', '.o', '.so', '.dylib'],
21
+ dirsToIgnore: ['node_modules/', '.git/', 'dist/', 'build/', '.next/', '.nuxt/', 'target/', 'bin/', 'obj/'],
22
+ maxFileSize: '10MB',
23
+ maxTotalSize: '100MB',
24
+ maxDepth: 10,
25
+ concurrency: 10
26
+ };
27
+
28
+ function parseSize(sizeStr) {
29
+ const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
30
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
31
+ if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
32
+ const [, size, unit = 'B'] = match;
33
+ return Math.floor(parseFloat(size) * units[unit.toUpperCase()]);
34
+ }
35
+
36
+ function formatSize(bytes) {
37
+ const units = ['B', 'KB', 'MB', 'GB'];
38
+ let size = bytes;
39
+ let unitIndex = 0;
40
+ while (size >= 1024 && unitIndex < units.length - 1) {
41
+ size /= 1024;
42
+ unitIndex++;
43
+ }
44
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
45
+ }
46
+
47
+ /**
48
+ * Correctly matches a file path against glob-like patterns.
49
+ * @param {string} filePath - The path of the file to check.
50
+ * @param {string[]} patterns - An array of patterns to match against.
51
+ * @returns {boolean} - True if the file path matches any of the patterns.
52
+ */
53
+ function matchesPattern(filePath, patterns) {
54
+ const fileName = path.basename(filePath);
55
+ return patterns.some(pattern => {
56
+ // This is a robust way to convert simple globs to regex
57
+ // 1. Escape all special regex characters.
58
+ // 2. Convert the glob star '*' into the regex '.*'.
59
+ // 3. Anchor the pattern to match the whole string.
60
+ const regexPattern = '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$';
61
+ try {
62
+ const regex = new RegExp(regexPattern);
63
+ return regex.test(fileName);
64
+ } catch (e) {
65
+ console.warn(`⚠️ Invalid regex pattern in config: "${pattern}"`);
66
+ return false;
67
+ }
68
+ });
69
+ }
70
+
71
+
72
+ async function loadConfig(configPath) {
73
+ let config = { ...DEFAULT_CONFIG };
74
+
75
+ if (configPath) {
76
+ try {
77
+ const configModule = await import(path.resolve(configPath));
78
+ config = { ...config, ...configModule.default };
79
+ console.log(`✅ Configuration loaded from: ${configPath}`);
80
+ } catch (error) {
81
+ console.warn(`⚠️ Warning: Could not load config file: ${configPath}`);
82
+ }
83
+ } else {
84
+ const possibleConfigs = [
85
+ '.ecksnapshot.config.js',
86
+ '.ecksnapshot.config.mjs',
87
+ 'ecksnapshot.config.js'
88
+ ];
89
+
90
+ for (const configFile of possibleConfigs) {
91
+ try {
92
+ await fs.access(configFile);
93
+ const configModule = await import(path.resolve(configFile));
94
+ config = { ...config, ...configModule.default };
95
+ console.log(`✅ Configuration loaded from: ${configFile}`);
96
+ break;
97
+ } catch {
98
+ // Config file doesn't exist, continue
99
+ }
100
+ }
101
+ }
102
+
103
+ return config;
104
+ }
105
+
106
+ async function checkGitAvailability() {
107
+ try {
108
+ await execa('git', ['--version']);
109
+ } catch (error) {
110
+ throw new Error('Git is not installed or not available in PATH');
111
+ }
112
+ }
113
+
114
+ async function checkGitRepository(repoPath) {
115
+ try {
116
+ await execa('git', ['rev-parse', '--git-dir'], { cwd: repoPath });
117
+ } catch (error) {
118
+ throw new Error(`Not a git repository: ${repoPath}`);
119
+ }
120
+ }
121
+
122
+ async function loadGitignore(repoPath) {
123
+ try {
124
+ const gitignoreContent = await fs.readFile(path.join(repoPath, '.gitignore'), 'utf-8');
125
+ const ig = ignore().add(gitignoreContent);
126
+ console.log('✅ .gitignore patterns loaded');
127
+ return ig;
128
+ } catch {
129
+ console.log('ℹ️ No .gitignore file found or could not be read');
130
+ return ignore();
131
+ }
132
+ }
133
+
134
+ async function readFileWithSizeCheck(filePath, maxFileSize) {
135
+ try {
136
+ const stats = await fs.stat(filePath);
137
+ if (stats.size > maxFileSize) {
138
+ throw new Error(`File too large: ${formatSize(stats.size)}`);
139
+ }
140
+ return await fs.readFile(filePath, 'utf-8');
141
+ } catch (error) {
142
+ if (error.message.includes('too large')) throw error;
143
+ throw new Error(`Could not read file: ${error.message}`);
144
+ }
145
+ }
146
+
147
+ async function generateDirectoryTree(dir, prefix = '', allFiles, depth = 0, maxDepth = 10, config) {
148
+ if (depth > maxDepth) return '';
149
+
150
+ try {
151
+ const entries = await fs.readdir(dir, { withFileTypes: true });
152
+ const sortedEntries = entries.sort((a, b) => {
153
+ if (a.isDirectory() && !b.isDirectory()) return -1;
154
+ if (!a.isDirectory() && b.isDirectory()) return 1;
155
+ return a.name.localeCompare(b.name);
156
+ });
157
+
158
+ let tree = '';
159
+ const validEntries = [];
160
+
161
+ for (const entry of sortedEntries) {
162
+ if (config.dirsToIgnore.some(d => entry.name.includes(d.replace('/', '')))) continue;
163
+
164
+ const fullPath = path.join(dir, entry.name);
165
+ const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
166
+
167
+ if (entry.isDirectory() || allFiles.includes(relativePath)) {
168
+ validEntries.push({ entry, fullPath, relativePath });
169
+ }
170
+ }
171
+
172
+ for (let i = 0; i < validEntries.length; i++) {
173
+ const { entry, fullPath, relativePath } = validEntries[i];
174
+ const isLast = i === validEntries.length - 1;
175
+
176
+ const connector = isLast ? '└── ' : '├── ';
177
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
178
+
179
+ if (entry.isDirectory()) {
180
+ tree += `${prefix}${connector}${entry.name}/\n`;
181
+ tree += await generateDirectoryTree(fullPath, nextPrefix, allFiles, depth + 1, maxDepth, config);
182
+ } else {
183
+ tree += `${prefix}${connector}${entry.name}\n`;
184
+ }
185
+ }
186
+ return tree;
187
+ } catch (error) {
188
+ console.warn(`⚠️ Warning: Could not read directory: ${dir}`);
189
+ return '';
190
+ }
191
+ }
192
+
193
+ async function processFile(filePath, config, gitignore, stats) {
194
+ const fileName = path.basename(filePath);
195
+ const fileExt = path.extname(filePath) || 'no-extension';
196
+ const normalizedPath = filePath.replace(/\\/g, '/');
197
+
198
+ let skipReason = null;
199
+
200
+ if (config.dirsToIgnore.some(dir => normalizedPath.startsWith(dir))) {
201
+ skipReason = 'ignored-directory';
202
+ } else if (config.extensionsToIgnore.includes(path.extname(filePath))) {
203
+ skipReason = 'ignored-extension';
204
+ } else if (matchesPattern(filePath, config.filesToIgnore)) {
205
+ skipReason = 'ignored-pattern';
206
+ } else if (gitignore.ignores(normalizedPath)) {
207
+ skipReason = 'gitignore';
208
+ } else if (isBinaryPath(filePath)) {
209
+ skipReason = 'binary-file';
210
+ } else if (!config.includeHidden && fileName.startsWith('.')) {
211
+ skipReason = 'hidden-file';
212
+ }
213
+
214
+ if (skipReason) {
215
+ stats.skippedFiles++;
216
+ stats.skippedFileTypes.set(fileExt, (stats.skippedFileTypes.get(fileExt) || 0) + 1);
217
+ stats.skipReasons.set(skipReason, (stats.skipReasons.get(skipReason) || 0) + 1);
218
+
219
+ if (!stats.skippedFilesDetails.has(skipReason)) {
220
+ stats.skippedFilesDetails.set(skipReason, []);
221
+ }
222
+ stats.skippedFilesDetails.get(skipReason).push({ file: filePath, ext: fileExt });
223
+
224
+ if (skipReason === 'binary-file') stats.binaryFiles++;
225
+ return { skipped: true, reason: skipReason };
226
+ }
227
+
228
+ try {
229
+ const content = await readFileWithSizeCheck(filePath, parseSize(config.maxFileSize));
230
+ const fileContent = `--- File: /${normalizedPath} ---\n\n${content}\n\n`;
231
+
232
+ stats.includedFiles++;
233
+ stats.includedFileTypes.set(fileExt, (stats.includedFileTypes.get(fileExt) || 0) + 1);
234
+
235
+ return { content: fileContent, size: fileContent.length };
236
+ } catch (error) {
237
+ const errorReason = error.message.includes('too large') ? 'file-too-large' : 'read-error';
238
+
239
+ stats.errors.push({ file: filePath, error: error.message });
240
+ stats.skippedFiles++;
241
+ stats.skippedFileTypes.set(fileExt, (stats.skippedFileTypes.get(fileExt) || 0) + 1);
242
+ stats.skipReasons.set(errorReason, (stats.skipReasons.get(errorReason) || 0) + 1);
243
+
244
+ if (!stats.skippedFilesDetails.has(errorReason)) {
245
+ stats.skippedFilesDetails.set(errorReason, []);
246
+ }
247
+ stats.skippedFilesDetails.get(errorReason).push({ file: filePath, ext: fileExt });
248
+
249
+ if (error.message.includes('too large')) {
250
+ stats.largeFiles++;
251
+ }
252
+
253
+ return { skipped: true, reason: error.message };
254
+ }
255
+ }
256
+
257
+ async function createRepoSnapshot(repoPath, options) {
258
+ const absoluteRepoPath = path.resolve(repoPath);
259
+ const absoluteOutputPath = path.resolve(options.output);
260
+ const originalCwd = process.cwd();
261
+
262
+ console.log(`🚀 Starting snapshot for repository: ${absoluteRepoPath}`);
263
+ console.log(`📁 Snapshots will be saved to: ${absoluteOutputPath}`);
264
+
265
+ try {
266
+ const config = await loadConfig(options.config);
267
+ config.maxFileSize = options.maxFileSize || config.maxFileSize;
268
+ config.maxTotalSize = options.maxTotalSize || config.maxTotalSize;
269
+ config.maxDepth = options.maxDepth || config.maxDepth;
270
+ config.includeHidden = options.includeHidden || false;
271
+
272
+ await checkGitAvailability();
273
+ await checkGitRepository(absoluteRepoPath);
274
+
275
+ process.chdir(absoluteRepoPath);
276
+ console.log('✅ Successfully changed working directory');
277
+
278
+ const gitignore = await loadGitignore(absoluteRepoPath);
279
+
280
+ console.log('📋 Fetching file list from Git...');
281
+ const { stdout } = await execa('git', ['ls-files']);
282
+ const allFiles = stdout.split('\n').filter(Boolean);
283
+ console.log(`📊 Found ${allFiles.length} total files in the repository`);
284
+
285
+ const stats = {
286
+ totalFiles: allFiles.length,
287
+ includedFiles: 0,
288
+ skippedFiles: 0,
289
+ binaryFiles: 0,
290
+ largeFiles: 0,
291
+ errors: [],
292
+ includedFileTypes: new Map(),
293
+ skippedFileTypes: new Map(),
294
+ skipReasons: new Map(),
295
+ skippedFilesDetails: new Map()
296
+ };
297
+
298
+ let snapshotContent = '';
299
+
300
+ if (options.tree) {
301
+ console.log('🌳 Generating directory tree...');
302
+ const tree = await generateDirectoryTree(absoluteRepoPath, '', allFiles, 0, config.maxDepth, config);
303
+ snapshotContent += 'Directory Structure:\n\n';
304
+ snapshotContent += tree;
305
+ snapshotContent += '\n\n';
306
+ }
307
+
308
+ console.log('📝 Processing files...');
309
+ const limit = pLimit(config.concurrency);
310
+ const progressBar = options.verbose ? null : new SingleBar({
311
+ format: 'Progress |{bar}| {percentage}% | {value}/{total} files | ETA: {eta}s',
312
+ barCompleteChar: '\u2588',
313
+ barIncompleteChar: '\u2591',
314
+ hideCursor: true
315
+ }, Presets.shades_classic);
316
+
317
+ if (progressBar) progressBar.start(allFiles.length, 0);
318
+
319
+ const filePromises = allFiles.map((filePath, index) =>
320
+ limit(async () => {
321
+ const result = await processFile(filePath, config, gitignore, stats);
322
+
323
+ if (progressBar) {
324
+ progressBar.update(index + 1);
325
+ } else if (options.verbose) {
326
+ if (result.skipped) {
327
+ console.log(`⏭️ Skipping: ${filePath} (${result.reason})`);
328
+ } else {
329
+ console.log(`✅ Processed: ${filePath}`);
330
+ }
331
+ }
332
+
333
+ return result;
334
+ })
335
+ );
336
+
337
+ const results = await Promise.allSettled(filePromises);
338
+ if (progressBar) progressBar.stop();
339
+
340
+ const contentArray = [];
341
+ let totalSize = 0;
342
+ const maxTotalSize = parseSize(config.maxTotalSize);
343
+
344
+ for (const result of results) {
345
+ if (result.status === 'rejected') {
346
+ console.warn(`⚠️ Promise rejected: ${result.reason}`);
347
+ continue;
348
+ }
349
+ if (result.value && result.value.content) {
350
+ if (totalSize + result.value.size > maxTotalSize) {
351
+ console.warn(`⚠️ Warning: Approaching size limit. Some files may be excluded.`);
352
+ break;
353
+ }
354
+ contentArray.push(result.value.content);
355
+ totalSize += result.value.size;
356
+ }
357
+ }
358
+
359
+ snapshotContent += contentArray.join('');
360
+ const totalChars = snapshotContent.length;
361
+ const estimatedTokens = Math.round(totalChars / 4);
362
+
363
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
364
+ const repoName = path.basename(absoluteRepoPath);
365
+ const extension = options.format === 'json' ? 'json' : 'txt';
366
+ let outputFilename = `${repoName}_snapshot_${timestamp}.${extension}`;
367
+
368
+ if (options.compress) {
369
+ outputFilename += '.gz';
370
+ }
371
+
372
+ const fullOutputFilePath = path.join(absoluteOutputPath, outputFilename);
373
+
374
+ let finalContent = snapshotContent;
375
+ if (options.format === 'json') {
376
+ const jsonData = {
377
+ repository: repoName,
378
+ timestamp: new Date().toISOString(),
379
+ stats: {
380
+ ...stats,
381
+ includedFileTypes: Object.fromEntries(stats.includedFileTypes),
382
+ skippedFileTypes: Object.fromEntries(stats.skippedFileTypes),
383
+ skipReasons: Object.fromEntries(stats.skipReasons),
384
+ skippedFilesDetails: Object.fromEntries(
385
+ Array.from(stats.skippedFilesDetails.entries()).map(([reason, files]) => [
386
+ reason,
387
+ files.map(({file, ext}) => ({file, ext}))
388
+ ])
389
+ )
390
+ },
391
+ content: snapshotContent
392
+ };
393
+ finalContent = JSON.stringify(jsonData, null, 2);
394
+ }
395
+
396
+ await fs.mkdir(absoluteOutputPath, { recursive: true });
397
+
398
+ if (options.compress) {
399
+ const compressed = await gzip(finalContent);
400
+ await fs.writeFile(fullOutputFilePath, compressed);
401
+ } else {
402
+ await fs.writeFile(fullOutputFilePath, finalContent);
403
+ }
404
+
405
+ console.log('\n📊 Snapshot Summary');
406
+ console.log('='.repeat(50));
407
+ console.log(`🎉 Snapshot created successfully!`);
408
+ console.log(`📄 File saved to: ${fullOutputFilePath}`);
409
+ console.log(`📈 Included text files: ${stats.includedFiles} of ${stats.totalFiles}`);
410
+ console.log(`⏭️ Skipped files: ${stats.skippedFiles}`);
411
+ console.log(`🔢 Binary files skipped: ${stats.binaryFiles}`);
412
+ console.log(`📏 Large files skipped: ${stats.largeFiles}`);
413
+ if (options.tree) console.log('🌳 Directory tree included');
414
+ if (options.compress) console.log('🗜️ File compressed with gzip');
415
+ console.log(`📊 Total characters: ${totalChars.toLocaleString('en-US')}`);
416
+ console.log(`🎯 Estimated tokens: ~${estimatedTokens.toLocaleString('en-US')}`);
417
+ console.log(`💾 File size: ${formatSize(totalChars)}`);
418
+
419
+ if (stats.includedFileTypes.size > 0) {
420
+ console.log('\n📋 Included File Types Distribution:');
421
+ const sortedIncludedTypes = Array.from(stats.includedFileTypes.entries())
422
+ .sort(([,a], [,b]) => b - a)
423
+ .slice(0, 10);
424
+
425
+ for (const [ext, count] of sortedIncludedTypes) {
426
+ console.log(` ${ext}: ${count} files`);
427
+ }
428
+ }
429
+
430
+ if (stats.skippedFileTypes.size > 0) {
431
+ console.log('\n⏭️ Skipped File Types Distribution:');
432
+ const sortedSkippedTypes = Array.from(stats.skippedFileTypes.entries())
433
+ .sort(([,a], [,b]) => b - a)
434
+ .slice(0, 10);
435
+
436
+ for (const [ext, count] of sortedSkippedTypes) {
437
+ console.log(` ${ext}: ${count} files`);
438
+ }
439
+ }
440
+
441
+ if (stats.skipReasons.size > 0) {
442
+ console.log('\n📊 Skip Reasons:');
443
+ const sortedReasons = Array.from(stats.skipReasons.entries())
444
+ .sort(([,a], [,b]) => b - a);
445
+
446
+ const reasonLabels = {
447
+ 'ignored-directory': 'Ignored directories',
448
+ 'ignored-extension': 'Ignored extensions',
449
+ 'ignored-pattern': 'Ignored patterns',
450
+ 'gitignore': 'Gitignore rules',
451
+ 'binary-file': 'Binary files',
452
+ 'hidden-file': 'Hidden files',
453
+ 'file-too-large': 'Files too large',
454
+ 'read-error': 'Read errors'
455
+ };
456
+
457
+ for (const [reason, count] of sortedReasons) {
458
+ const label = reasonLabels[reason] || reason;
459
+ console.log(` ${label}: ${count} files`);
460
+
461
+ if (stats.skippedFilesDetails.has(reason)) {
462
+ const files = stats.skippedFilesDetails.get(reason);
463
+ const filesToShow = files.slice(0, 10);
464
+
465
+ for (const {file, ext} of filesToShow) {
466
+ console.log(` • ${file} (${ext})`);
467
+ }
468
+
469
+ if (files.length > 10) {
470
+ console.log(` ... and ${files.length - 10} more files`);
471
+ }
472
+ }
473
+ console.log();
474
+ }
475
+ }
476
+
477
+ if (stats.errors.length > 0) {
478
+ console.log('\n⚠️ Errors encountered:');
479
+ stats.errors.slice(0, 5).forEach(({ file, error }) => {
480
+ console.log(` ${file}: ${error}`);
481
+ });
482
+ if (stats.errors.length > 5) {
483
+ console.log(` ... and ${stats.errors.length - 5} more errors`);
484
+ }
485
+ }
486
+
487
+ console.log('='.repeat(50));
488
+
489
+ } catch (error) {
490
+ console.error('\n❌ An error occurred:');
491
+ if (error.code === 'ENOENT' && error.path && error.path.includes('.git')) {
492
+ console.error(`Error: The path "${absoluteRepoPath}" does not seem to be a Git repository.`);
493
+ } else if (error.message.includes('Git is not installed')) {
494
+ console.error('Error: Git is not installed or not available in PATH.');
495
+ console.error('Please install Git and ensure it\'s available in your system PATH.');
496
+ } else if (error.message.includes('Not a git repository')) {
497
+ console.error(error.message);
498
+ console.error('Please run this command from within a Git repository or provide a valid repository path.');
499
+ } else {
500
+ console.error(error.message);
501
+ if (options.verbose) {
502
+ console.error(error.stack);
503
+ }
504
+ }
505
+ process.exit(1);
506
+ } finally {
507
+ process.chdir(originalCwd);
508
+ }
509
+ }
510
+
511
+ // CLI setup
512
+ const program = new Command();
513
+
514
+ program
515
+ .name('eck-snapshot')
516
+ .description('A CLI tool to create a single text snapshot of a local Git repository.')
517
+ .version('2.0.0')
518
+ .argument('[repoPath]', 'Path to the git repository to snapshot.', process.cwd())
519
+ .option('-o, --output <dir>', 'Output directory for the snapshot file.', path.join(__dirname, 'snapshots'))
520
+ .option('--no-tree', 'Do not include the directory tree in the snapshot.')
521
+ .option('-v, --verbose', 'Show detailed processing information, including skipped files.')
522
+ .option('--max-file-size <size>', 'Maximum file size to include (e.g., 10MB)', '10MB')
523
+ .option('--max-total-size <size>', 'Maximum total snapshot size (e.g., 100MB)', '100MB')
524
+ .option('--max-depth <number>', 'Maximum directory depth for tree generation', parseInt, 10)
525
+ .option('--config <path>', 'Path to configuration file')
526
+ .option('--compress', 'Compress output file with gzip')
527
+ .option('--include-hidden', 'Include hidden files (starting with .)')
528
+ .option('--format <type>', 'Output format: txt, json', 'txt')
529
+ .action(createRepoSnapshot);
530
+
531
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@xelth/eck-snapshot",
3
+ "version": "1.5.0",
4
+ "description": "A CLI tool to create a single-file text snapshot of a Git repository, ideal for providing context to LLMs.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "eck-snapshot": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ ".ecksnapshot.config.js",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ },
19
+ "keywords": [
20
+ "snapshot",
21
+ "repository",
22
+ "cli",
23
+ "git",
24
+ "llm",
25
+ "context"
26
+ ],
27
+ "author": "xelth-com",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/xelth-com/eckSnapshot.git"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^12.1.0",
35
+ "execa": "^8.0.1",
36
+ "is-binary-path": "^2.1.0",
37
+ "ignore": "^5.3.1",
38
+ "cli-progress": "^3.12.0",
39
+ "p-limit": "^5.0.0"
40
+ }
41
+ }