@xelth/eck-snapshot 4.2.4 → 5.4.1

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.

Potentially problematic release.


This version of @xelth/eck-snapshot might be problematic. Click here for more details.

Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/index.js +14 -0
  4. package/package.json +64 -9
  5. package/scripts/mcp-eck-core.js +101 -0
  6. package/scripts/mcp-glm-zai-worker.mjs +243 -0
  7. package/scripts/verify_changes.js +68 -0
  8. package/setup.json +845 -0
  9. package/src/cli/cli.js +369 -0
  10. package/src/cli/commands/claudeSettings.js +93 -0
  11. package/src/cli/commands/consilium.js +86 -0
  12. package/src/cli/commands/createSnapshot.js +917 -0
  13. package/src/cli/commands/detectProfiles.js +98 -0
  14. package/src/cli/commands/detectProject.js +112 -0
  15. package/src/cli/commands/doctor.js +60 -0
  16. package/src/cli/commands/envSync.js +319 -0
  17. package/src/cli/commands/generateProfileGuide.js +144 -0
  18. package/src/cli/commands/pruneSnapshot.js +106 -0
  19. package/src/cli/commands/restoreSnapshot.js +173 -0
  20. package/src/cli/commands/setupGemini.js +149 -0
  21. package/src/cli/commands/setupGemini.test.js +115 -0
  22. package/src/cli/commands/setupMcp.js +269 -0
  23. package/src/cli/commands/showFile.js +39 -0
  24. package/src/cli/commands/trainTokens.js +38 -0
  25. package/src/cli/commands/updateSnapshot.js +247 -0
  26. package/src/config.js +115 -0
  27. package/src/core/skeletonizer.js +201 -0
  28. package/src/mcp-server/index.js +211 -0
  29. package/src/services/claudeCliService.js +626 -0
  30. package/src/services/claudeCliService.test.js +267 -0
  31. package/src/templates/agent-prompt.template.md +43 -0
  32. package/src/templates/architect-prompt.template.md +164 -0
  33. package/src/templates/claude-code/README.md +105 -0
  34. package/src/templates/claude-code/mcp-config-template.json +11 -0
  35. package/src/templates/claude-code/mcp-server-template.js +206 -0
  36. package/src/templates/claude-code/settings-claude.json +1 -0
  37. package/src/templates/envScanRequest.md +4 -0
  38. package/src/templates/gitWorkflow.md +32 -0
  39. package/src/templates/multiAgent.md +118 -0
  40. package/src/templates/opencode/coder.template.md +22 -0
  41. package/src/templates/opencode/junior-architect.template.md +85 -0
  42. package/src/templates/skeleton-instruction.md +16 -0
  43. package/src/templates/update-prompt.template.md +19 -0
  44. package/src/utils/aiHeader.js +678 -0
  45. package/src/utils/claudeMdGenerator.js +148 -0
  46. package/src/utils/eckProtocolParser.js +221 -0
  47. package/src/utils/fileUtils.js +1017 -0
  48. package/src/utils/gitUtils.js +51 -0
  49. package/src/utils/opencodeAgentsGenerator.js +271 -0
  50. package/src/utils/projectDetector.js +704 -0
  51. package/src/utils/tokenEstimator.js +201 -0
@@ -0,0 +1,1017 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { execa } from 'execa';
4
+ import ignore from 'ignore';
5
+ import { detectProjectType, getProjectSpecificFiltering } from './projectDetector.js';
6
+ import { executePrompt as askClaude } from '../services/claudeCliService.js';
7
+ import { getProfile, loadSetupConfig } from '../config.js';
8
+ import micromatch from 'micromatch';
9
+ import { minimatch } from 'minimatch';
10
+
11
+ /**
12
+ * Scanner for detecting and redacting secrets (API keys, tokens)
13
+ */
14
+ export const SecretScanner = {
15
+ patterns: [
16
+ // Service-specific patterns
17
+ { name: 'GitHub Token', regex: /gh[pous]_[a-zA-Z0-9]{36}/g },
18
+ { name: 'AWS Access Key', regex: /(?:AKIA|ASIA)[0-9A-Z]{16}/g },
19
+ { name: 'OpenAI API Key', regex: /sk-[a-zA-Z0-9]{32,}/g },
20
+ { name: 'Stripe Secret Key', regex: /sk_live_[0-9a-zA-Z]{24}/g },
21
+ { name: 'Google API Key', regex: /AIza[0-9A-Za-z\-_]{35}/g },
22
+ { name: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z\-]{10,}/g },
23
+ { name: 'NPM Token', regex: /npm_[a-zA-Z0-9]{36}/g },
24
+ { name: 'Private Key', regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
25
+ // Generic high-entropy patterns near sensitive keywords
26
+ {
27
+ name: 'Generic Secret',
28
+ regex: /(?:api[_-]?key|secret|password|token|auth|pwd|credential)\s*[:=]\s*["']([a-zA-Z0-9\-_.]{16,})["']/gi
29
+ }
30
+ ],
31
+
32
+ /**
33
+ * Scans content and replaces detected secrets with a placeholder
34
+ * @param {string} content - File content to scan
35
+ * @param {string} filePath - Path for logging context
36
+ * @returns {{content: string, found: string[]}} Redacted content and list of found secret types
37
+ */
38
+ redact(content, filePath) {
39
+ let redactedContent = content;
40
+ const foundSecrets = [];
41
+
42
+ for (const pattern of this.patterns) {
43
+ // Reset regex lastIndex for global patterns
44
+ pattern.regex.lastIndex = 0;
45
+
46
+ const matches = [...content.matchAll(pattern.regex)];
47
+ if (matches.length > 0) {
48
+ for (const match of matches) {
49
+ // For generic pattern, use captured group; for specific patterns, use full match
50
+ const secretValue = match[1] || match[0];
51
+ const placeholder = `[REDACTED_${pattern.name.replace(/\s+/g, '_').toUpperCase()}]`;
52
+ redactedContent = redactedContent.replace(secretValue, placeholder);
53
+ foundSecrets.push(pattern.name);
54
+ }
55
+ }
56
+ }
57
+
58
+ return {
59
+ content: redactedContent,
60
+ found: [...new Set(foundSecrets)]
61
+ };
62
+ }
63
+ };
64
+
65
+ export function parseSize(sizeStr) {
66
+ const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
67
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
68
+ if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
69
+ const [, size, unit = 'B'] = match;
70
+ return Math.floor(parseFloat(size) * units[unit.toUpperCase()]);
71
+ }
72
+
73
+ export function formatSize(bytes) {
74
+ const units = ['B', 'KB', 'MB', 'GB'];
75
+ let size = bytes;
76
+ let unitIndex = 0;
77
+ while (size >= 1024 && unitIndex < units.length - 1) {
78
+ size /= 1024;
79
+ unitIndex++;
80
+ }
81
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
82
+ }
83
+
84
+ export function matchesPattern(filePath, patterns) {
85
+ const fileName = path.basename(filePath);
86
+ return patterns.some(pattern => {
87
+ const regexPattern = '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$';
88
+ try {
89
+ const regex = new RegExp(regexPattern);
90
+ return regex.test(fileName);
91
+ } catch (e) {
92
+ console.warn(`⚠️ Invalid regex pattern in config: "${pattern}"`);
93
+ return false;
94
+ }
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Checks if a file matches confidential patterns using minimatch
100
+ * @param {string} fileName - The file name to check
101
+ * @param {array} patterns - Array of glob patterns to match against
102
+ * @returns {boolean} True if the file matches any pattern
103
+ */
104
+ function matchesConfidentialPattern(fileName, patterns) {
105
+ return patterns.some(pattern => minimatch(fileName, pattern, { nocase: true }));
106
+ }
107
+
108
+ /**
109
+ * Applies smart filtering for files within the .eck directory.
110
+ * Includes documentation files while excluding confidential files.
111
+ * @param {string} fileName - The file name to check
112
+ * @param {object} eckConfig - The eckDirectoryFiltering config object
113
+ * @returns {object} { include: boolean, isConfidential: boolean }
114
+ */
115
+ export function applyEckDirectoryFiltering(fileName, eckConfig) {
116
+ if (!eckConfig || !eckConfig.enabled) {
117
+ return { include: false, isConfidential: false }; // .eck filtering disabled, exclude all
118
+ }
119
+
120
+ const { confidentialPatterns = [], alwaysIncludePatterns = [] } = eckConfig;
121
+
122
+ // First check if file matches confidential patterns
123
+ const isConfidential = matchesConfidentialPattern(fileName, confidentialPatterns);
124
+ if (isConfidential) {
125
+ return { include: false, isConfidential: true };
126
+ }
127
+
128
+ // Check if file matches always-include patterns
129
+ if (matchesPattern(fileName, alwaysIncludePatterns)) {
130
+ return { include: true, isConfidential: false };
131
+ }
132
+
133
+ // Default: exclude files not in the include list
134
+ return { include: false, isConfidential: false };
135
+ }
136
+
137
+ export async function checkGitRepository(repoPath) {
138
+ try {
139
+ await execa('git', ['rev-parse', '--git-dir'], { cwd: repoPath });
140
+ return true;
141
+ } catch (error) {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ export async function scanDirectoryRecursively(dirPath, config, relativeTo = dirPath, projectType = null, trackConfidential = false) {
147
+ const files = [];
148
+ const confidentialFiles = [];
149
+
150
+ // Get project-specific filtering if not provided
151
+ if (!projectType) {
152
+ const detection = await detectProjectType(relativeTo);
153
+ projectType = detection.type;
154
+ }
155
+
156
+ const projectSpecific = await getProjectSpecificFiltering(projectType);
157
+
158
+ // Merge project-specific filters with global config
159
+ const effectiveConfig = {
160
+ ...config,
161
+ dirsToIgnore: [...(config.dirsToIgnore || []), ...(projectSpecific.dirsToIgnore || [])],
162
+ filesToIgnore: [...(config.filesToIgnore || []), ...(projectSpecific.filesToIgnore || [])],
163
+ extensionsToIgnore: [...(config.extensionsToIgnore || []), ...(projectSpecific.extensionsToIgnore || [])]
164
+ };
165
+
166
+ try {
167
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
168
+
169
+ for (const entry of entries) {
170
+ const fullPath = path.join(dirPath, entry.name);
171
+ const relativePath = path.relative(relativeTo, fullPath).replace(/\\/g, '/');
172
+
173
+ // --- GLOBAL HARD IGNORES (Zero-Config Safety) ---
174
+ // Explicitly skip heavy/system directories and lockfiles everywhere
175
+ if (entry.isDirectory()) {
176
+ if (entry.name === 'node_modules' ||
177
+ entry.name === '.git' ||
178
+ entry.name === '.idea' ||
179
+ entry.name === '.vscode') {
180
+ continue;
181
+ }
182
+ } else {
183
+ if (entry.name === 'package-lock.json' ||
184
+ entry.name === 'yarn.lock' ||
185
+ entry.name === 'pnpm-lock.yaml' ||
186
+ entry.name === 'go.sum') {
187
+ continue;
188
+ }
189
+ }
190
+ // -----------------------------------------------
191
+
192
+ // Special handling for .eck directory - never ignore it when tracking confidential files
193
+ const isEckDirectory = entry.name === '.eck' && entry.isDirectory();
194
+ const isInsideEck = relativePath.startsWith('.eck/');
195
+
196
+ if (effectiveConfig.dirsToIgnore.some(dir =>
197
+ entry.name === dir.replace('/', '') ||
198
+ relativePath.startsWith(dir)
199
+ ) && !isEckDirectory && !isInsideEck) {
200
+ continue;
201
+ }
202
+
203
+ if (!effectiveConfig.includeHidden && entry.name.startsWith('.') && !isEckDirectory && !isInsideEck) {
204
+ continue;
205
+ }
206
+
207
+ if (entry.isDirectory()) {
208
+ const subResult = await scanDirectoryRecursively(fullPath, effectiveConfig, relativeTo, projectType, trackConfidential);
209
+ if (trackConfidential) {
210
+ files.push(...subResult.files);
211
+ confidentialFiles.push(...subResult.confidentialFiles);
212
+ } else {
213
+ files.push(...subResult);
214
+ }
215
+ } else {
216
+ // Apply smart filtering for files inside .eck directory
217
+ if (isInsideEck) {
218
+ const eckConfig = effectiveConfig.eckDirectoryFiltering;
219
+ const filterResult = applyEckDirectoryFiltering(entry.name, eckConfig);
220
+
221
+ if (trackConfidential && filterResult.isConfidential) {
222
+ confidentialFiles.push(relativePath);
223
+ } else if (filterResult.include) {
224
+ files.push(relativePath);
225
+ }
226
+ } else {
227
+ // Normal filtering for non-.eck files
228
+ if (effectiveConfig.extensionsToIgnore.includes(path.extname(entry.name)) ||
229
+ matchesPattern(relativePath, effectiveConfig.filesToIgnore)) {
230
+ continue;
231
+ }
232
+ files.push(relativePath);
233
+ }
234
+ }
235
+ }
236
+ } catch (error) {
237
+ console.warn(`⚠️ Warning: Could not read directory: ${dirPath} - ${error.message}`);
238
+ }
239
+
240
+ return trackConfidential ? { files, confidentialFiles } : files;
241
+ }
242
+
243
+ export async function loadGitignore(repoPath) {
244
+ try {
245
+ const gitignoreContent = await fs.readFile(path.join(repoPath, '.gitignore'), 'utf-8');
246
+ const ig = ignore().add(gitignoreContent);
247
+ console.log('✅ .gitignore patterns loaded');
248
+ return ig;
249
+ } catch {
250
+ console.log('ℹ️ No .gitignore file found or could not be read');
251
+ return ignore();
252
+ }
253
+ }
254
+
255
+ export async function readFileWithSizeCheck(filePath, maxFileSize) {
256
+ try {
257
+ const stats = await fs.stat(filePath);
258
+ if (stats.size > maxFileSize) {
259
+ throw new Error(`File too large: ${formatSize(stats.size)}`);
260
+ }
261
+ return await fs.readFile(filePath, 'utf-8');
262
+ } catch (error) {
263
+ if (error.message.includes('too large')) throw error;
264
+ throw new Error(`Could not read file: ${error.message}`);
265
+ }
266
+ }
267
+
268
+ export async function generateDirectoryTree(dir, prefix = '', allFiles, depth = 0, maxDepth = 10, config) {
269
+ if (depth > maxDepth) return '';
270
+
271
+ try {
272
+ const entries = await fs.readdir(dir, { withFileTypes: true });
273
+ const sortedEntries = entries.sort((a, b) => {
274
+ if (a.isDirectory() && !b.isDirectory()) return -1;
275
+ if (!a.isDirectory() && b.isDirectory()) return 1;
276
+ return a.name.localeCompare(b.name);
277
+ });
278
+
279
+ let tree = '';
280
+ const validEntries = [];
281
+
282
+ for (const entry of sortedEntries) {
283
+ // Skip hidden directories and files (starting with '.')
284
+ // EXCEPT: Allow .eck to be visible
285
+ if (entry.name.startsWith('.') && entry.name !== '.eck') {
286
+ continue;
287
+ }
288
+ if (config.dirsToIgnore.some(d => entry.name.includes(d.replace('/', '')))) continue;
289
+ const fullPath = path.join(dir, entry.name);
290
+ const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
291
+
292
+ // FORCE VISIBILITY for .eck files in the tree
293
+ // Even if they are gitignored (not in allFiles), we want the Architect to see they exist
294
+ const isInsideEck = relativePath.startsWith('.eck/') || relativePath === '.eck';
295
+
296
+ if (entry.isDirectory() || allFiles.includes(relativePath) || isInsideEck) {
297
+ validEntries.push({ entry, fullPath, relativePath });
298
+ }
299
+ }
300
+
301
+ for (let i = 0; i < validEntries.length; i++) {
302
+ const { entry, fullPath, relativePath } = validEntries[i];
303
+ const isLast = i === validEntries.length - 1;
304
+
305
+ const connector = isLast ? '└── ' : '├── ';
306
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
307
+
308
+ if (entry.isDirectory()) {
309
+ tree += `${prefix}${connector}${entry.name}/\n`;
310
+
311
+ // RECURSION CONTROL:
312
+ // If we are currently inside .eck, do NOT recurse deeper into subdirectories (like snapshots, logs).
313
+ // We want to see that 'snapshots/' exists, but not list its contents.
314
+ const isInsideEckRoot = path.basename(dir) === '.eck';
315
+
316
+ if (!isInsideEckRoot) {
317
+ tree += await generateDirectoryTree(fullPath, nextPrefix, allFiles, depth + 1, maxDepth, config);
318
+ }
319
+ } else {
320
+ tree += `${prefix}${connector}${entry.name}\n`;
321
+ }
322
+ }
323
+
324
+ return tree;
325
+ } catch (error) {
326
+ console.warn(`⚠️ Warning: Could not read directory: ${dir}`);
327
+ return '';
328
+ }
329
+ }
330
+
331
+ export function parseSnapshotContent(content) {
332
+ const files = [];
333
+ const fileRegex = /--- File: \/(.+) ---/g;
334
+ const sections = content.split(fileRegex);
335
+
336
+ for (let i = 1; i < sections.length; i += 2) {
337
+ const filePath = sections[i].trim();
338
+ let fileContent = sections[i + 1] || '';
339
+
340
+ if (fileContent.startsWith('\n\n')) {
341
+ fileContent = fileContent.substring(2);
342
+ }
343
+ if (fileContent.endsWith('\n\n')) {
344
+ fileContent = fileContent.substring(0, fileContent.length - 2);
345
+ }
346
+
347
+ files.push({ path: filePath, content: fileContent });
348
+ }
349
+
350
+ return files;
351
+ }
352
+
353
+ export function filterFilesToRestore(files, options) {
354
+ let filtered = files;
355
+
356
+ if (options.include) {
357
+ const includePatterns = Array.isArray(options.include) ?
358
+ options.include : [options.include];
359
+ filtered = filtered.filter(file =>
360
+ includePatterns.some(pattern => {
361
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
362
+ return regex.test(file.path);
363
+ })
364
+ );
365
+ }
366
+
367
+ if (options.exclude) {
368
+ const excludePatterns = Array.isArray(options.exclude) ?
369
+ options.exclude : [options.exclude];
370
+ filtered = filtered.filter(file =>
371
+ !excludePatterns.some(pattern => {
372
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
373
+ return regex.test(file.path);
374
+ })
375
+ );
376
+ }
377
+
378
+ return filtered;
379
+ }
380
+
381
+ export function validateFilePaths(files, targetDir) {
382
+ const invalidFiles = [];
383
+
384
+ for (const file of files) {
385
+ const normalizedPath = path.normalize(file.path);
386
+ if (normalizedPath.includes('..') ||
387
+ normalizedPath.startsWith('/') ||
388
+ normalizedPath.includes('\0') ||
389
+ /[<>:"|?*]/.test(normalizedPath)) {
390
+ invalidFiles.push(file.path);
391
+ }
392
+ }
393
+
394
+ return invalidFiles;
395
+ }
396
+
397
+ export async function loadConfig(configPath) {
398
+ const { DEFAULT_CONFIG } = await import('../config.js');
399
+ let config = { ...DEFAULT_CONFIG };
400
+
401
+ if (configPath) {
402
+ try {
403
+ const configModule = await import(path.resolve(configPath));
404
+ config = { ...config, ...configModule.default };
405
+ console.log(`✅ Configuration loaded from: ${configPath}`);
406
+ } catch (error) {
407
+ console.warn(`⚠️ Warning: Could not load config file: ${configPath}`);
408
+ }
409
+ } else {
410
+ const possibleConfigs = [
411
+ '.ecksnapshot.config.js',
412
+ '.ecksnapshot.config.mjs',
413
+ 'ecksnapshot.config.js'
414
+ ];
415
+
416
+ for (const configFile of possibleConfigs) {
417
+ try {
418
+ await fs.access(configFile);
419
+ const configModule = await import(path.resolve(configFile));
420
+ config = { ...config, ...configModule.default };
421
+ console.log(`✅ Configuration loaded from: ${configFile}`);
422
+ break;
423
+ } catch {
424
+ // Config file doesn't exist, continue
425
+ }
426
+ }
427
+ }
428
+
429
+ return config;
430
+ }
431
+
432
+ export function generateTimestamp() {
433
+ const now = new Date();
434
+ const YY = String(now.getFullYear()).slice(-2);
435
+ const MM = String(now.getMonth() + 1).padStart(2, '0');
436
+ const DD = String(now.getDate()).padStart(2, '0');
437
+ const hh = String(now.getHours()).padStart(2, '0');
438
+ const mm = String(now.getMinutes()).padStart(2, '0');
439
+ // Compact format: YY-MM-DD_HH-mm (no seconds)
440
+ return `${YY}-${MM}-${DD}_${hh}-${mm}`;
441
+ }
442
+
443
+ /**
444
+ * Generates a short repo name with capitalized first 3 and last 2 characters
445
+ * Example: "Snapshot" -> "SnaOt", "MyProject" -> "MyPrjt"
446
+ * @param {string} repoName - The repository name
447
+ * @returns {string} Shortened repo name (Start3 + End2)
448
+ */
449
+ export function getShortRepoName(repoName) {
450
+ if (!repoName) return '';
451
+ if (repoName.length <= 5) {
452
+ return repoName.toUpperCase();
453
+ }
454
+ const start = repoName.substring(0, 3);
455
+ const end = repoName.substring(repoName.length - 2);
456
+ return (start + end).toUpperCase();
457
+ }
458
+
459
+ /**
460
+ * Displays project detection information in a user-friendly format
461
+ * @param {object} detection - Project detection result
462
+ */
463
+ export function displayProjectInfo(detection) {
464
+ console.log('\n🔍 Project Detection Results:');
465
+ console.log(` Type: ${detection.type} (confidence: ${(detection.confidence * 100).toFixed(0)}%)`);
466
+
467
+ if (detection.details) {
468
+ const details = detection.details;
469
+
470
+ switch (detection.type) {
471
+ case 'android':
472
+ console.log(` Language: ${details.language || 'unknown'}`);
473
+ if (details.packageName) {
474
+ console.log(` Package: ${details.packageName}`);
475
+ }
476
+ if (details.sourceDirs && details.sourceDirs.length > 0) {
477
+ console.log(` Source dirs: ${details.sourceDirs.join(', ')}`);
478
+ }
479
+ if (details.libFiles && details.libFiles.length > 0) {
480
+ console.log(` Libraries: ${details.libFiles.length} .aar/.jar files`);
481
+ }
482
+ break;
483
+
484
+ case 'nodejs':
485
+ if (details.name) {
486
+ console.log(` Package: ${details.name}@${details.version || '?'}`);
487
+ }
488
+ if (details.framework) {
489
+ console.log(` Framework: ${details.framework}`);
490
+ }
491
+ if (details.hasTypescript) {
492
+ console.log(` TypeScript: enabled`);
493
+ }
494
+ break;
495
+
496
+ case 'nodejs-monorepo':
497
+ if (details.name) {
498
+ console.log(` Project: ${details.name}@${details.version || '?'}`);
499
+ }
500
+ if (details.monorepoTool) {
501
+ console.log(` Monorepo tool: ${details.monorepoTool}`);
502
+ }
503
+ if (details.workspaceCount) {
504
+ console.log(` Workspaces: ${details.workspaceCount}`);
505
+ }
506
+ if (details.framework) {
507
+ console.log(` Framework: ${details.framework}`);
508
+ }
509
+ break;
510
+
511
+ case 'python-poetry':
512
+ case 'python-pip':
513
+ case 'python-conda':
514
+ if (details.name) {
515
+ console.log(` Project: ${details.name}@${details.version || '?'}`);
516
+ }
517
+ if (details.packageManager) {
518
+ console.log(` Package manager: ${details.packageManager}`);
519
+ }
520
+ if (details.dependencies) {
521
+ console.log(` Dependencies: ${details.dependencies}`);
522
+ }
523
+ if (details.hasVirtualEnv) {
524
+ console.log(` Virtual environment: detected`);
525
+ }
526
+ break;
527
+
528
+ case 'django':
529
+ if (details.name) {
530
+ console.log(` Project: ${details.name}`);
531
+ }
532
+ console.log(` Framework: Django`);
533
+ if (details.djangoApps && details.djangoApps.length > 0) {
534
+ console.log(` Django apps: ${details.djangoApps.join(', ')}`);
535
+ }
536
+ if (details.hasVirtualEnv) {
537
+ console.log(` Virtual environment: detected`);
538
+ }
539
+ break;
540
+
541
+ case 'flask':
542
+ if (details.name) {
543
+ console.log(` Project: ${details.name}`);
544
+ }
545
+ console.log(` Framework: Flask`);
546
+ if (details.hasVirtualEnv) {
547
+ console.log(` Virtual environment: detected`);
548
+ }
549
+ break;
550
+
551
+ case 'rust':
552
+ if (details.name) {
553
+ console.log(` Package: ${details.name}@${details.version || '?'}`);
554
+ }
555
+ if (details.edition) {
556
+ console.log(` Rust edition: ${details.edition}`);
557
+ }
558
+ if (details.isWorkspace) {
559
+ console.log(` Cargo workspace: detected`);
560
+ }
561
+ break;
562
+
563
+ case 'go':
564
+ if (details.module) {
565
+ console.log(` Module: ${details.module}`);
566
+ }
567
+ if (details.goVersion) {
568
+ console.log(` Go version: ${details.goVersion}`);
569
+ }
570
+ break;
571
+
572
+ case 'dotnet':
573
+ if (details.language) {
574
+ console.log(` Language: ${details.language}`);
575
+ }
576
+ if (details.projectFiles && details.projectFiles.length > 0) {
577
+ console.log(` Project files: ${details.projectFiles.join(', ')}`);
578
+ }
579
+ if (details.hasSolution) {
580
+ console.log(` Solution: detected`);
581
+ }
582
+ break;
583
+
584
+ case 'flutter':
585
+ if (details.name) {
586
+ console.log(` App: ${details.name}@${details.version || '?'}`);
587
+ }
588
+ break;
589
+
590
+ case 'react-native':
591
+ if (details.name) {
592
+ console.log(` App: ${details.name}@${details.version || '?'}`);
593
+ }
594
+ if (details.reactNativeVersion) {
595
+ console.log(` React Native: ${details.reactNativeVersion}`);
596
+ }
597
+ break;
598
+ }
599
+ }
600
+
601
+ if (detection.allDetections && detection.allDetections.length > 1) {
602
+ console.log(` Other possibilities: ${detection.allDetections.slice(1).map(d => d.type).join(', ')}`);
603
+ }
604
+
605
+ console.log('');
606
+ }
607
+
608
+ /**
609
+ * Parses YAML-like content from ENVIRONMENT.md
610
+ * @param {string} content - The raw content of ENVIRONMENT.md
611
+ * @returns {object} Parsed key-value pairs
612
+ */
613
+ function parseEnvironmentYaml(content) {
614
+ const result = {};
615
+ const lines = content.split('\n');
616
+
617
+ for (const line of lines) {
618
+ const trimmed = line.trim();
619
+ if (trimmed && !trimmed.startsWith('#') && trimmed.includes(':')) {
620
+ const [key, ...valueParts] = trimmed.split(':');
621
+ const value = valueParts.join(':').trim();
622
+
623
+ // Remove quotes if present
624
+ const cleanValue = value.replace(/^["']|["']$/g, '');
625
+ result[key.trim()] = cleanValue;
626
+ }
627
+ }
628
+
629
+ return result;
630
+ }
631
+
632
+ /**
633
+ * Loads and processes the .eck directory manifest
634
+ * @param {string} repoPath - Path to the repository
635
+ * @returns {Promise<object|null>} The eck manifest object or null if no .eck directory
636
+ */
637
+ export async function loadProjectEckManifest(repoPath) {
638
+ const eckDir = path.join(repoPath, '.eck');
639
+
640
+ try {
641
+ // Check if .eck directory exists
642
+ const eckStats = await fs.stat(eckDir);
643
+ if (!eckStats.isDirectory()) {
644
+ return null;
645
+ }
646
+
647
+ console.log('📋 Found .eck directory - loading project manifest...');
648
+
649
+ const manifest = {
650
+ environment: {},
651
+ context: '',
652
+ operations: '',
653
+ journal: '',
654
+ roadmap: '',
655
+ techDebt: ''
656
+ };
657
+
658
+ // Define the files to check
659
+ const files = [
660
+ { name: 'ENVIRONMENT.md', key: 'environment', parser: parseEnvironmentYaml },
661
+ { name: 'CONTEXT.md', key: 'context', parser: content => content },
662
+ { name: 'OPERATIONS.md', key: 'operations', parser: content => content },
663
+ { name: 'JOURNAL.md', key: 'journal', parser: content => content },
664
+ { name: 'ROADMAP.md', key: 'roadmap', parser: content => content },
665
+ { name: 'TECH_DEBT.md', key: 'techDebt', parser: content => content }
666
+ ];
667
+
668
+ // Process each file
669
+ for (const file of files) {
670
+ const filePath = path.join(eckDir, file.name);
671
+ try {
672
+ const content = await fs.readFile(filePath, 'utf-8');
673
+ manifest[file.key] = file.parser(content.trim());
674
+ console.log(` ✅ Loaded ${file.name}`);
675
+ } catch (error) {
676
+ // File doesn't exist or can't be read - that's okay, use default
677
+ console.log(` ⚠️ ${file.name} not found or unreadable`);
678
+ }
679
+ }
680
+
681
+ return manifest;
682
+ } catch (error) {
683
+ // .eck directory doesn't exist - that's normal
684
+ return null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Ensures that 'snapshots/' is added to the target project's .gitignore file
690
+ * @param {string} repoPath - Path to the repository
691
+ */
692
+ export async function ensureSnapshotsInGitignore(repoPath) {
693
+ const gitignorePath = path.join(repoPath, '.gitignore');
694
+ const entryToAdd = '.eck/';
695
+ const comment = '# Added by eck-snapshot to ignore metadata directory';
696
+
697
+ try {
698
+ // Check if the repo is a Git repository first
699
+ const isGitRepo = await checkGitRepository(repoPath);
700
+ if (!isGitRepo) {
701
+ // Not a Git repo, skip .gitignore modification
702
+ return;
703
+ }
704
+
705
+ let gitignoreContent = '';
706
+ let fileExists = true;
707
+
708
+ // Try to read existing .gitignore file
709
+ try {
710
+ gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
711
+ } catch (error) {
712
+ // File doesn't exist, we'll create it
713
+ fileExists = false;
714
+ gitignoreContent = '';
715
+ }
716
+
717
+ // Check if 'snapshots/' is already in the file
718
+ const lines = gitignoreContent.split('\n');
719
+ const hasSnapshotsEntry = lines.some(line => line.trim() === entryToAdd);
720
+
721
+ if (!hasSnapshotsEntry) {
722
+ // Add the entry
723
+ let newContent = gitignoreContent;
724
+
725
+ // If file exists and doesn't end with newline, add one
726
+ if (fileExists && gitignoreContent && !gitignoreContent.endsWith('\n')) {
727
+ newContent += '\n';
728
+ }
729
+
730
+ // Add comment and entry
731
+ if (fileExists && gitignoreContent) {
732
+ newContent += '\n';
733
+ }
734
+ newContent += comment + '\n' + entryToAdd + '\n';
735
+
736
+ await fs.writeFile(gitignorePath, newContent);
737
+ console.log(`✅ Added '${entryToAdd}' to .gitignore`);
738
+ }
739
+ } catch (error) {
740
+ // Silently fail - don't break the snapshot process if gitignore update fails
741
+ console.warn(`⚠️ Warning: Could not update .gitignore: ${error.message}`);
742
+ }
743
+ }
744
+
745
+ // Helper function to determine if a string is a glob pattern
746
+ function isGlob(str) {
747
+ return str.includes('*') || str.includes('?') || str.includes('{');
748
+ }
749
+
750
+ /**
751
+ * Applies advanced profile filtering (multi-profile, exclusion, and ad-hoc globs) to a file list.
752
+ */
753
+ export async function applyProfileFilter(allFiles, profileString, repoPath) {
754
+ const profileParts = profileString.split(',').map(p => p.trim()).filter(Boolean);
755
+
756
+ const includeGlobs = [];
757
+ const excludeGlobs = [];
758
+ const includeNames = [];
759
+ const excludeNames = [];
760
+
761
+ // Step 1: Differentiate between profile names and ad-hoc glob patterns
762
+ for (const part of profileParts) {
763
+ const isNegative = part.startsWith('-');
764
+ const pattern = isNegative ? part.substring(1) : part;
765
+
766
+ if (isGlob(pattern)) {
767
+ if (isNegative) {
768
+ excludeGlobs.push(pattern);
769
+ } else {
770
+ includeGlobs.push(pattern);
771
+ }
772
+ } else {
773
+ if (isNegative) {
774
+ excludeNames.push(pattern);
775
+ } else {
776
+ includeNames.push(pattern);
777
+ }
778
+ }
779
+ }
780
+
781
+ let workingFiles = [];
782
+ let finalIncludes = [...includeGlobs];
783
+ let finalExcludes = [...excludeGlobs];
784
+
785
+ // Step 2: Load patterns from specified profile names
786
+ const allProfileNames = [...new Set([...includeNames, ...excludeNames])];
787
+ const profiles = new Map();
788
+ const notFoundProfiles = [];
789
+
790
+ for (const name of allProfileNames) {
791
+ const profile = await getProfile(name, repoPath);
792
+ if (profile) {
793
+ profiles.set(name, profile);
794
+ } else {
795
+ // This is an ad-hoc glob, not a profile, so no warning is needed.
796
+ if (!isGlob(name)) {
797
+ notFoundProfiles.push(name);
798
+ console.warn(`⚠️ Warning: Profile '${name}' not found and will be skipped.`);
799
+ }
800
+ }
801
+ }
802
+
803
+ for (const name of includeNames) {
804
+ if (profiles.has(name)) {
805
+ finalIncludes.push(...(profiles.get(name).include || []));
806
+ finalExcludes.push(...(profiles.get(name).exclude || []));
807
+ }
808
+ }
809
+ for (const name of excludeNames) {
810
+ if (profiles.has(name)) {
811
+ finalExcludes.push(...(profiles.get(name).include || []));
812
+ }
813
+ }
814
+
815
+ // Step 3: Apply the filtering logic
816
+ if (finalIncludes.length > 0) {
817
+ workingFiles = micromatch(allFiles, finalIncludes);
818
+ } else if (includeNames.length > 0 && includeGlobs.length === 0) {
819
+ workingFiles = [];
820
+ } else {
821
+ workingFiles = allFiles;
822
+ }
823
+
824
+ if (finalExcludes.length > 0) {
825
+ workingFiles = micromatch.not(workingFiles, finalExcludes);
826
+ }
827
+
828
+ return {
829
+ files: workingFiles,
830
+ notFoundProfiles,
831
+ foundProfiles: Array.from(profiles.keys())
832
+ };
833
+ }
834
+
835
+ /**
836
+ * Automatically initializes the .eck manifest directory, attempting dynamic generation via Claude.
837
+ * @param {string} projectPath - Path to the project
838
+ */
839
+ export async function initializeEckManifest(projectPath) {
840
+ const eckDir = path.join(projectPath, '.eck');
841
+
842
+ // Load setup configuration to check AI generation settings
843
+ let aiGenerationEnabled = false;
844
+ try {
845
+ const setupConfig = await loadSetupConfig();
846
+ aiGenerationEnabled = setupConfig?.aiInstructions?.manifestInitialization?.aiGenerationEnabled ?? false;
847
+ } catch (error) {
848
+ // If setup config fails to load, default to disabled
849
+ console.warn(` ⚠️ Could not load setup config: ${error.message}. AI generation disabled.`);
850
+ }
851
+
852
+ try {
853
+ // Check if .eck directory already exists and has all required files
854
+ let needsInitialization = false;
855
+ try {
856
+ const eckStats = await fs.stat(eckDir);
857
+ if (eckStats.isDirectory()) {
858
+ // Directory exists, check if all required files are present
859
+ const requiredFiles = ['ENVIRONMENT.md', 'CONTEXT.md', 'OPERATIONS.md', 'JOURNAL.md'];
860
+ for (const fileName of requiredFiles) {
861
+ try {
862
+ await fs.stat(path.join(eckDir, fileName));
863
+ } catch (error) {
864
+ console.log(` ℹ️ Missing ${fileName}, initialization needed`);
865
+ needsInitialization = true;
866
+ break;
867
+ }
868
+ }
869
+ if (!needsInitialization) {
870
+ // All files exist, no need to initialize
871
+ return;
872
+ }
873
+ }
874
+ } catch (error) {
875
+ // Directory doesn't exist, we'll create it
876
+ needsInitialization = true;
877
+ }
878
+
879
+ // Create .eck directory
880
+ await fs.mkdir(eckDir, { recursive: true });
881
+ console.log('📋 Initializing .eck manifest directory...');
882
+
883
+ // --- NEW HYBRID LOGIC ---
884
+ // 1. Run static analysis first to gather facts.
885
+ let staticFacts = {};
886
+ try {
887
+ staticFacts = await detectProjectType(projectPath);
888
+ console.log(` 🔍 Static analysis complete. Detected type: ${staticFacts.type}`);
889
+ } catch (e) {
890
+ console.warn(` ⚠️ Static project detection failed: ${e.message}. Proceeding with generic prompts.`);
891
+ }
892
+
893
+ // Prevent AI hallucination by removing low-confidence "other possibilities"
894
+ if (staticFacts && staticFacts.allDetections) {
895
+ delete staticFacts.allDetections;
896
+ }
897
+
898
+ const staticFactsJson = JSON.stringify(staticFacts, null, 2);
899
+ // --- END NEW LOGIC ---
900
+
901
+ // 3. Define smarter templates and prompts with "STUB NOTICES"
902
+ const templateConfigs = {
903
+ 'ENVIRONMENT.md': {
904
+ prompt: `Generate raw YAML for .eck/ENVIRONMENT.md based on these project facts:\n${staticFactsJson}\nInclude project_type, runtime, and agent_id: local_dev. NO markdown fences.`,
905
+ fallback: `project_type: ${staticFacts.type || 'unknown'}
906
+ # [STUB: ENVIRONMENT.MD]
907
+ # ARCHITECT: Task Coder to verify environment variables.
908
+ # CODER: Scan project for .env files or config files and fill this. Remove this stub notice.
909
+ agent_id: local_dev
910
+ `
911
+ },
912
+ 'CONTEXT.md': {
913
+ prompt: `Analyze these project files and dependencies:\n${staticFactsJson}\nGenerate a professional # Project Overview in Markdown. Describe the actual architecture and purpose of this specific project. Be technical and concise. Start with '# Project Overview'.`,
914
+ fallback: `# [STUB: CONTEXT.MD]
915
+
916
+ ## 🚨 ATTENTION ARCHITECT & CODER
917
+ **ARCHITECT:** This file is EMPTY. You MUST prioritize a task for the Coder to analyze the source code and define the Project Overview, Architecture, and Key Technologies.
918
+ **CODER:** Use your tools to read the code, write the real content, and DELETE this entire stub header.
919
+
920
+ ## Description
921
+ (Placeholder: A ${staticFacts.type || 'project'} project)
922
+
923
+ ## Architecture
924
+ (Placeholder: TBD)`
925
+ },
926
+ 'OPERATIONS.md': {
927
+ prompt: `Look at the dependencies and files:\n${staticFactsJson}\nGenerate a Markdown guide for common operations (Setup, Run, Test, Build) using the correct commands for this tech stack. Start with '# Common Operations'.`,
928
+ fallback: `# [STUB: OPERATIONS.MD]
929
+
930
+ ## 🚨 ATTENTION
931
+ **CODER:** Run \`npm run\`, check Makefile, or build files to identify REAL commands for Setup, Running, and Testing. Replace this stub with actual commands. Remove this notice.
932
+
933
+ ## Setup
934
+ ${staticFacts.type === 'nodejs' ? 'npm install' : 'TBD'}`
935
+ },
936
+ 'ROADMAP.md': {
937
+ prompt: `Based on the project type (${staticFacts.type}), propose a 3-step roadmap. Start with '# Project Roadmap'.`,
938
+ fallback: `# [STUB: ROADMAP.MD]
939
+
940
+ **ARCHITECT:** Set a real roadmap based on user goals. **CODER:** Remove this stub marker once a real goal is added.`
941
+ },
942
+ 'TECH_DEBT.md': {
943
+ prompt: `Given this is a ${staticFacts.type} project, list 2-3 common technical debt items. Start with '# Technical Debt'.`,
944
+ fallback: `# [STUB: TECH_DEBT.MD]
945
+
946
+ **CODER:** Scan for TODOs/FIXMEs or structural issues and list them here. Remove this stub marker.`
947
+ },
948
+ 'JOURNAL.md': {
949
+ fallback: `# Development Journal
950
+
951
+ ## Recent Changes
952
+ ---
953
+ type: feat
954
+ scope: project
955
+ summary: Initial manifest generated (PENDING REVIEW)
956
+ date: ${new Date().toISOString().split('T')[0]}
957
+ ---
958
+ - NOTICE: Some .eck files are STUBS. They need manual or AI-assisted verification.`
959
+ }
960
+ };
961
+
962
+ // Create each template file (only if it doesn't exist)
963
+ for (const [fileName, config] of Object.entries(templateConfigs)) {
964
+ const filePath = path.join(eckDir, fileName);
965
+
966
+ // Skip if file already exists
967
+ try {
968
+ await fs.stat(filePath);
969
+ console.log(` ✅ ${fileName} already exists, skipping`);
970
+ continue;
971
+ } catch (error) {
972
+ // File doesn't exist, create it
973
+ }
974
+
975
+ let fileContent = config.fallback; // Start with stub fallback
976
+ let generatedByAI = false;
977
+
978
+ // For files with a prompt, try to dynamically generate (only if enabled)
979
+ if (config.prompt && aiGenerationEnabled) {
980
+ try {
981
+ console.log(` 🧠 Attempting to auto-generate ${fileName} via Claude...`);
982
+ const aiResponseObject = await askClaude(config.prompt);
983
+ const rawText = aiResponseObject.result;
984
+
985
+ if (!rawText || typeof rawText.replace !== 'function') {
986
+ throw new Error(`AI returned invalid content type: ${typeof rawText}`);
987
+ }
988
+
989
+ // Basic cleanup of potential markdown code blocks from Claude
990
+ const cleanedResponse = rawText.replace(/^```(markdown|yaml)?\n|```$/g, '').trim();
991
+
992
+ if (cleanedResponse) {
993
+ fileContent = cleanedResponse;
994
+ generatedByAI = true;
995
+ console.log(` ✨ AI successfully generated ${fileName}`);
996
+ } else {
997
+ throw new Error('AI returned empty content.');
998
+ }
999
+ } catch (error) {
1000
+ console.warn(` ⚠️ AI generation failed for ${fileName}: ${error.message}. Using stub template.`);
1001
+ // fileContent is already set to the stub fallback
1002
+ }
1003
+ }
1004
+
1005
+ await fs.writeFile(filePath, fileContent);
1006
+ if (!generatedByAI) {
1007
+ console.log(` ✅ Created ${fileName} (stub template)`);
1008
+ }
1009
+ }
1010
+
1011
+ console.log('📋 .eck manifest initialized! Edit the files to provide project-specific context.');
1012
+
1013
+ } catch (error) {
1014
+ // Silently fail - don't break the snapshot process if manifest initialization fails
1015
+ console.warn(`⚠️ Warning: Could not initialize .eck manifest: ${error.message}`);
1016
+ }
1017
+ }