contextforge-cli-ai-prompt-pirates 0.4.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.
Files changed (72) hide show
  1. package/.contextforge/config.ai.example.json +28 -0
  2. package/.contextforge/config.example.json +29 -0
  3. package/.contextforge/prompts/README.md +50 -0
  4. package/.env.example +73 -0
  5. package/LICENSE +21 -0
  6. package/README.md +223 -0
  7. package/bin/contextforge.js +14 -0
  8. package/bin/postinstall-worker.js +14 -0
  9. package/bin/postinstall.js +26 -0
  10. package/package.json +65 -0
  11. package/prompts/README.md +50 -0
  12. package/prompts/response-schema.md +22 -0
  13. package/prompts/retry-addon.md +1 -0
  14. package/prompts/system.md +10 -0
  15. package/prompts/user-template.md +22 -0
  16. package/src/analyzers/ast-parser.js +139 -0
  17. package/src/analyzers/bugs-lite.js +118 -0
  18. package/src/analyzers/changelog.js +190 -0
  19. package/src/analyzers/code-insights.js +225 -0
  20. package/src/analyzers/dependencies.js +110 -0
  21. package/src/analyzers/detection-quality.js +106 -0
  22. package/src/analyzers/eslint-runner.js +56 -0
  23. package/src/analyzers/folder-tree.js +60 -0
  24. package/src/analyzers/git-insights.js +94 -0
  25. package/src/analyzers/project-meta.js +98 -0
  26. package/src/cli/commands/changes.js +36 -0
  27. package/src/cli/commands/doctor.js +152 -0
  28. package/src/cli/commands/generate.js +7 -0
  29. package/src/cli/commands/init.js +98 -0
  30. package/src/cli/commands/prompt.js +53 -0
  31. package/src/cli/commands/stop.js +10 -0
  32. package/src/cli/commands/summary.js +22 -0
  33. package/src/cli/commands/watch.js +9 -0
  34. package/src/cli/index.js +120 -0
  35. package/src/core/confidence.js +51 -0
  36. package/src/core/config.js +183 -0
  37. package/src/core/cursor-rules.js +293 -0
  38. package/src/core/ensure-setup.js +45 -0
  39. package/src/core/env.js +113 -0
  40. package/src/core/package-meta.js +35 -0
  41. package/src/core/paths.js +39 -0
  42. package/src/core/pipeline.js +256 -0
  43. package/src/core/postinstall.js +168 -0
  44. package/src/core/setup-env.js +86 -0
  45. package/src/core/setup-prompts.js +31 -0
  46. package/src/core/snapshot-hash.js +70 -0
  47. package/src/detectors/architecture.js +117 -0
  48. package/src/detectors/auth-deploy.js +51 -0
  49. package/src/detectors/database.js +127 -0
  50. package/src/detectors/tech-stack.js +180 -0
  51. package/src/generators/artifacts.js +44 -0
  52. package/src/generators/changes-markdown.js +72 -0
  53. package/src/generators/context-json.js +91 -0
  54. package/src/generators/context-markdown.js +571 -0
  55. package/src/generators/mermaid.js +59 -0
  56. package/src/index.js +13 -0
  57. package/src/scanner/change-detector.js +24 -0
  58. package/src/scanner/config-files.js +52 -0
  59. package/src/scanner/file-index.js +55 -0
  60. package/src/scanner/package-deps.js +32 -0
  61. package/src/scanner/repo-scanner.js +143 -0
  62. package/src/services/ai/background.js +87 -0
  63. package/src/services/ai/config.js +48 -0
  64. package/src/services/ai/enrich.js +87 -0
  65. package/src/services/ai/index.js +3 -0
  66. package/src/services/ai/prompt-loader.js +104 -0
  67. package/src/services/ai/prompt.js +42 -0
  68. package/src/services/ai/providers/groq.js +36 -0
  69. package/src/services/ai/providers/openai.js +32 -0
  70. package/src/services/ai/redact.js +33 -0
  71. package/src/services/ai/validate.js +70 -0
  72. package/src/watcher/watcher.js +128 -0
@@ -0,0 +1,256 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { loadConfig, ensureContextForgeDirs } from './config.js';
4
+ import { getLogsDir } from './paths.js';
5
+ import { scanRepository } from '../scanner/repo-scanner.js';
6
+ import { loadFileIndex, saveFileIndex, updateFileIndex } from '../scanner/file-index.js';
7
+ import { findConfigFiles } from '../scanner/config-files.js';
8
+ import { mergePackageDependencies } from '../scanner/package-deps.js';
9
+ import { detectTechStack } from '../detectors/tech-stack.js';
10
+ import { analyzeArchitecture } from '../detectors/architecture.js';
11
+ import { extractDatabaseInfo } from '../detectors/database.js';
12
+ import { detectAuthAndDeploy } from '../detectors/auth-deploy.js';
13
+ import { buildFolderTree } from '../analyzers/folder-tree.js';
14
+ import { collectGitInsights } from '../analyzers/git-insights.js';
15
+ import { analyzeDependencies } from '../analyzers/dependencies.js';
16
+ import { analyzeCodeInsights } from '../analyzers/code-insights.js';
17
+ import { runBugsLite } from '../analyzers/bugs-lite.js';
18
+ import { parseSourceFilesAst } from '../analyzers/ast-parser.js';
19
+ import { runEslintIfAvailable } from '../analyzers/eslint-runner.js';
20
+ import { computeChangelog, computeFileDiff, loadChangesHistory } from '../analyzers/changelog.js';
21
+ import { writeChangesMarkdown } from '../generators/changes-markdown.js';
22
+ import { buildContextSnapshot } from '../generators/context-json.js';
23
+ import { writeArtifacts } from '../generators/artifacts.js';
24
+ import { renderContextMarkdown } from '../generators/context-markdown.js';
25
+ import { loadProjectEnv } from './env.js';
26
+ import {
27
+ enrichWithAI,
28
+ isAiAvailable,
29
+ scheduleBackgroundEnrichment,
30
+ loadCachedEnrichment,
31
+ } from '../services/ai/index.js';
32
+ import { computeConfidenceScores } from './confidence.js';
33
+ import { analyzeProjectMeta } from '../analyzers/project-meta.js';
34
+ import { assessDetectionQuality } from '../analyzers/detection-quality.js';
35
+ import {
36
+ computeScanHash,
37
+ computeFilesSignature,
38
+ saveScanHash,
39
+ shouldSkipAi,
40
+ } from './snapshot-hash.js';
41
+ import { installCursorRules } from './cursor-rules.js';
42
+
43
+ function log(projectRoot, message, verbose) {
44
+ if (verbose) {
45
+ console.log(`[contextforge] ${message}`);
46
+ }
47
+ const logFile = path.join(getLogsDir(projectRoot), 'contextforge.log');
48
+ const line = `${new Date().toISOString()} ${message}\n`;
49
+ fs.appendFile(logFile, line).catch(() => {});
50
+ }
51
+
52
+ /**
53
+ * @param {string} projectRoot
54
+ * @param {{ verbose?: boolean, skipAi?: boolean, forceAi?: boolean }} options
55
+ */
56
+ export async function runPipeline(projectRoot, options = {}) {
57
+ const { verbose = false, skipAi = false, forceAi = false } = options;
58
+ const projectRootResolved = path.resolve(projectRoot);
59
+
60
+ await loadProjectEnv(projectRootResolved);
61
+ await ensureContextForgeDirs(projectRootResolved);
62
+ const config = await loadConfig(projectRootResolved);
63
+
64
+ if (forceAi) config.ai = { ...config.ai, enabled: true };
65
+ if (skipAi) config.ai = { ...config.ai, enabled: false };
66
+
67
+ log(projectRootResolved, 'Scanning repository...', verbose);
68
+
69
+ const scanResult = await scanRepository(projectRootResolved, config);
70
+ const configFiles = await findConfigFiles(projectRootResolved, scanResult.relativePaths);
71
+ configFiles.mergedDependencies = await mergePackageDependencies(
72
+ projectRootResolved,
73
+ scanResult.relativePaths,
74
+ configFiles.packageJson
75
+ );
76
+
77
+ const previousIndex = await loadFileIndex(projectRootResolved);
78
+ const fileDiff = config.trackChanges !== false
79
+ ? computeFileDiff(previousIndex, scanResult.files)
80
+ : null;
81
+ const fileIndex = updateFileIndex(previousIndex, scanResult.files);
82
+ await saveFileIndex(projectRootResolved, fileIndex);
83
+
84
+ const techStack = detectTechStack(configFiles, scanResult.relativePaths);
85
+ const architecture = analyzeArchitecture(scanResult.relativePaths);
86
+ const database = await extractDatabaseInfo(projectRootResolved, scanResult.relativePaths);
87
+ const authDeploy = detectAuthAndDeploy(techStack, configFiles, scanResult.relativePaths);
88
+ const folderTree = buildFolderTree(scanResult.relativePaths, config.maxTreeDepth);
89
+
90
+ const ast = await parseSourceFilesAst(projectRootResolved, scanResult.relativePaths);
91
+
92
+ let git = { available: false, commits: [], insights: [] };
93
+ if (config.includeGitHistory) {
94
+ git = await collectGitInsights(projectRootResolved, config.gitCommitLimit);
95
+ }
96
+
97
+ const codeInsights = await analyzeCodeInsights(
98
+ projectRootResolved,
99
+ scanResult,
100
+ configFiles,
101
+ ast
102
+ );
103
+ const projectMeta = await analyzeProjectMeta(
104
+ projectRootResolved,
105
+ configFiles,
106
+ scanResult.relativePaths
107
+ );
108
+ const dependencies = analyzeDependencies(
109
+ projectRootResolved,
110
+ scanResult,
111
+ config,
112
+ codeInsights
113
+ );
114
+ if (!architecture.dependencyFlow && dependencies.chains?.length) {
115
+ architecture.dependencyFlow = dependencies.chains[0];
116
+ }
117
+ if (!architecture.apiFlow && codeInsights.pipelineFlow) {
118
+ architecture.apiFlow = codeInsights.pipelineFlow;
119
+ }
120
+
121
+ let bugs = { issues: [], summary: 'Bug detection disabled' };
122
+ if (config.detectBugs) {
123
+ bugs = await runBugsLite(projectRootResolved, scanResult, config);
124
+ const eslint = await runEslintIfAvailable(projectRootResolved, scanResult.relativePaths);
125
+ if (eslint.ran && eslint.issues.length) {
126
+ bugs.issues = [...eslint.issues, ...bugs.issues].slice(0, 35);
127
+ bugs.summary = `${bugs.summary}; ${eslint.summary}`;
128
+ } else if (eslint.ran) {
129
+ bugs.summary = eslint.summary;
130
+ }
131
+ }
132
+
133
+ let aiEnrichment = null;
134
+
135
+ const filesSignature = computeFilesSignature(scanResult.files);
136
+
137
+ const snapshot = buildContextSnapshot({
138
+ projectRoot: projectRootResolved,
139
+ config,
140
+ scanResult,
141
+ filesSignature,
142
+ configFiles,
143
+ techStack,
144
+ architecture,
145
+ database,
146
+ authDeploy,
147
+ folderTree,
148
+ git,
149
+ dependencies,
150
+ codeInsights,
151
+ bugs,
152
+ aiEnrichment,
153
+ ast,
154
+ changelog: null,
155
+ confidence: null,
156
+ detectionQuality: null,
157
+ projectMeta,
158
+ astFull: ast,
159
+ });
160
+
161
+ snapshot.changelog = await computeChangelog(
162
+ projectRootResolved,
163
+ snapshot,
164
+ fileDiff
165
+ );
166
+ snapshot.confidence = computeConfidenceScores(snapshot);
167
+ snapshot.detectionQuality = assessDetectionQuality(snapshot);
168
+
169
+ snapshot.filesSignature = filesSignature;
170
+
171
+ const scanHash = computeScanHash(snapshot, fileDiff);
172
+ const skipAiRun = await shouldSkipAi(
173
+ projectRootResolved,
174
+ snapshot,
175
+ forceAi,
176
+ fileDiff
177
+ );
178
+
179
+ const cachedAi = await loadCachedEnrichment(projectRootResolved);
180
+ if (cachedAi && !forceAi && skipAiRun) {
181
+ aiEnrichment = cachedAi;
182
+ snapshot.aiEnrichment = cachedAi;
183
+ log(projectRootResolved, 'Using cached AI enrichment (scan unchanged)', verbose);
184
+ }
185
+
186
+ const aiReady = config.ai?.enabled && isAiAvailable(config);
187
+
188
+ if (aiReady && config.ai.enrichContext && !config.ai.background && !skipAi && !skipAiRun) {
189
+ log(projectRootResolved, `AI enrichment (${config.ai.provider})...`, verbose);
190
+ try {
191
+ aiEnrichment = await enrichWithAI(snapshot, config);
192
+ snapshot.aiEnrichment = aiEnrichment;
193
+ await saveScanHash(projectRootResolved, scanHash);
194
+ console.log(` AI enriched via ${aiEnrichment.provider} (${aiEnrichment.model})`);
195
+ } catch (err) {
196
+ console.warn(` AI enrichment skipped: ${err.message}`);
197
+ }
198
+ } else if (skipAiRun && aiReady) {
199
+ console.log(' AI skipped — repository scan unchanged since last run');
200
+ }
201
+
202
+ await writeArtifacts(projectRootResolved, snapshot);
203
+ await renderContextMarkdown(projectRootResolved, snapshot);
204
+
205
+ if (config.installCursorRules !== false) {
206
+ const { rulePaths } = await installCursorRules(projectRootResolved, snapshot);
207
+ log(
208
+ projectRootResolved,
209
+ `Synced ${rulePaths.length} Cursor rule(s) in .cursor/rules/`,
210
+ verbose
211
+ );
212
+ }
213
+
214
+ if (config.trackChanges !== false) {
215
+ const history = await loadChangesHistory(projectRootResolved, 15);
216
+ await writeChangesMarkdown(projectRootResolved, snapshot.changelog, history);
217
+ }
218
+
219
+ await saveScanHash(projectRootResolved, scanHash);
220
+
221
+ log(projectRootResolved, `Generated ${path.join('.contextforge', 'context.md')}`, true);
222
+ console.log('Context generated successfully.');
223
+ console.log(` → .contextforge/context.md`);
224
+ if (fileDiff?.hasFileChanges) {
225
+ const parts = [];
226
+ if (fileDiff.added.length) parts.push(`+${fileDiff.added.length} new`);
227
+ if (fileDiff.modified.length) parts.push(`~${fileDiff.modified.length} modified`);
228
+ if (fileDiff.removed.length) parts.push(`-${fileDiff.removed.length} removed`);
229
+ console.log(` File/code changes detected — context updated (${parts.join(', ')})`);
230
+ if (fileDiff.modified.length && verbose) {
231
+ console.log(` Modified: ${fileDiff.modified.slice(0, 5).join(', ')}${fileDiff.modified.length > 5 ? '...' : ''}`);
232
+ }
233
+ } else if (config.trackChanges !== false && snapshot.changelog?.hasPrevious) {
234
+ console.log(' No file/code changes — context refreshed (metadata only).');
235
+ }
236
+ if (config.trackChanges !== false) {
237
+ console.log(` → .contextforge/CHANGES.md`);
238
+ if (snapshot.changelog?.hasChanges) {
239
+ console.log(` Changes: ${snapshot.changelog.summary}`);
240
+ } else if (snapshot.changelog?.hasPrevious) {
241
+ console.log(' Summary: no structural changes since last run.');
242
+ }
243
+ }
244
+ console.log(` Overall confidence: ${snapshot.confidence?.overall || 'medium'}`);
245
+
246
+ if (aiReady && config.ai.enrichContext && config.ai.background && !skipAi && !skipAiRun) {
247
+ scheduleBackgroundEnrichment(projectRootResolved, { ...snapshot }, config, scanHash);
248
+ console.log(' AI enrichment queued in background (context.md will update when ready)');
249
+ } else if (aiReady && skipAiRun && config.ai.background && !fileDiff?.hasFileChanges) {
250
+ console.log(' Background AI skipped — no file/code changes since last run');
251
+ } else if (config.ai?.enabled && !isAiAvailable(config)) {
252
+ console.log(' Tip: Set OPENAI_API_KEY or GROQ_API_KEY in .env to enable AI enrichment');
253
+ }
254
+
255
+ return snapshot;
256
+ }
@@ -0,0 +1,168 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import fs from 'fs-extra';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ensureInitialized } from './ensure-setup.js';
6
+ import { loadConfig, syncConfigFile } from './config.js';
7
+ import { runPipeline } from './pipeline.js';
8
+ import { getPidPath, getLogsDir } from './paths.js';
9
+ import {
10
+ loadProjectEnv,
11
+ isAutoStartEnabledViaEnv,
12
+ isPostinstallGenerateEnabledViaEnv,
13
+ isPostinstallWatchEnabledViaEnv,
14
+ isAiEnabledViaEnv,
15
+ hasAnyAiKey,
16
+ } from './env.js';
17
+ import { ensureEnvFromExample } from './setup-env.js';
18
+ import { isAiAvailable } from '../services/ai/index.js';
19
+ import { startWatcher } from '../watcher/watcher.js';
20
+ import { getOwnPackageName, hasContextForgeDependency } from './package-meta.js';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const WORKER_SCRIPT = path.join(__dirname, '../../bin/postinstall-worker.js');
24
+
25
+ /**
26
+ * Project root where the user ran `npm install` (not node_modules/contextforge).
27
+ */
28
+ export function resolveInstallProjectRoot() {
29
+ return path.resolve(process.env.INIT_CWD || process.cwd());
30
+ }
31
+
32
+ /**
33
+ * @param {string} projectRoot
34
+ */
35
+ export async function shouldRunPostinstall(projectRoot) {
36
+ await loadProjectEnv(projectRoot);
37
+
38
+ if (!isAutoStartEnabledViaEnv()) {
39
+ return false;
40
+ }
41
+ if (process.env.CONTEXTFORGE_POSTINSTALL_WORKER === '1') {
42
+ return false;
43
+ }
44
+ if (process.env.npm_command === 'publish' || process.env.npm_command === 'pack') {
45
+ return false;
46
+ }
47
+ if (isCi() && process.env.CONTEXTFORGE_RUN_IN_CI !== '1') {
48
+ return false;
49
+ }
50
+
51
+ const pkgPath = path.join(projectRoot, 'package.json');
52
+ if (!(await fs.pathExists(pkgPath))) {
53
+ return false;
54
+ }
55
+
56
+ const pkg = await fs.readJson(pkgPath);
57
+ if (pkg.contextforge?.postinstall === false) {
58
+ return false;
59
+ }
60
+
61
+ const ownName = await getOwnPackageName();
62
+
63
+ // npm lifecycle: npm_package_name matches this package when postinstall runs
64
+ if (process.env.npm_package_name === ownName) {
65
+ return true;
66
+ }
67
+
68
+ const deps = {
69
+ ...pkg.dependencies,
70
+ ...pkg.devDependencies,
71
+ ...pkg.optionalDependencies,
72
+ };
73
+
74
+ return hasContextForgeDependency(deps, pkg.name, ownName);
75
+ }
76
+
77
+ function isCi() {
78
+ return (
79
+ process.env.CI === 'true' ||
80
+ process.env.CI === '1' ||
81
+ process.env.GITHUB_ACTIONS === 'true'
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Fire-and-forget worker so `npm install` is not blocked.
87
+ * @param {string} projectRoot
88
+ */
89
+ export function spawnPostinstallWorker(projectRoot) {
90
+ const child = spawn(process.execPath, [WORKER_SCRIPT, projectRoot], {
91
+ detached: true,
92
+ stdio: 'ignore',
93
+ cwd: projectRoot,
94
+ env: {
95
+ ...process.env,
96
+ CONTEXTFORGE_POSTINSTALL_WORKER: '1',
97
+ },
98
+ windowsHide: true,
99
+ });
100
+ child.unref();
101
+ }
102
+
103
+ async function logPostinstall(projectRoot, message) {
104
+ const logDir = getLogsDir(projectRoot);
105
+ await fs.ensureDir(logDir);
106
+ const line = `${new Date().toISOString()} ${message}\n`;
107
+ await fs.appendFile(path.join(logDir, 'postinstall.log'), line).catch(() => {});
108
+ }
109
+
110
+ /**
111
+ * Setup + generate + optional background watch (runs in detached worker).
112
+ * @param {string} projectRoot
113
+ */
114
+ export async function runPostinstall(projectRoot) {
115
+ const root = path.resolve(projectRoot);
116
+ await loadProjectEnv(root);
117
+ await logPostinstall(root, 'postinstall worker started');
118
+
119
+ try {
120
+ const createdEnv = await ensureEnvFromExample(root);
121
+ if (createdEnv) {
122
+ await logPostinstall(root, 'created .env from .env.example — add API keys for AI');
123
+ await loadProjectEnv(root);
124
+ }
125
+
126
+ await ensureInitialized(root, { quiet: true });
127
+ await syncConfigFile(root, { upgrade: true });
128
+ const config = await loadConfig(root);
129
+
130
+ const runGenerate =
131
+ config.postinstallGenerate !== false &&
132
+ config.autoGenerate !== false &&
133
+ isPostinstallGenerateEnabledViaEnv();
134
+
135
+ if (runGenerate) {
136
+ const envAi = isAiEnabledViaEnv();
137
+ const useAi =
138
+ envAi !== false &&
139
+ config.ai?.enabled !== false &&
140
+ config.ai?.enrichContext !== false &&
141
+ hasAnyAiKey() &&
142
+ isAiAvailable(config);
143
+ await logPostinstall(root, `running generate (ai=${useAi})`);
144
+ await runPipeline(root, { verbose: false, skipAi: !useAi });
145
+ await logPostinstall(root, 'generate complete');
146
+ }
147
+
148
+ const startWatch =
149
+ config.postinstallWatch !== false &&
150
+ config.watch !== false &&
151
+ config.autoGenerate !== false &&
152
+ isPostinstallWatchEnabledViaEnv();
153
+
154
+ if (startWatch) {
155
+ const pidPath = getPidPath(root);
156
+ if (await fs.pathExists(pidPath)) {
157
+ await logPostinstall(root, 'watch already running (pid file exists)');
158
+ return;
159
+ }
160
+ await logPostinstall(root, 'starting watch --daemon');
161
+ await startWatcher(root, { daemon: true, verbose: false });
162
+ }
163
+ } catch (err) {
164
+ await logPostinstall(root, `failed: ${err.message}`);
165
+ console.error('[contextforge] postinstall:', err.message);
166
+ process.exitCode = 1;
167
+ }
168
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const PACKAGE_ROOT = path.join(__dirname, '../..');
7
+
8
+ const ENV_EXAMPLE_CONTENT = `# ContextForge — auto-created on npm install
9
+
10
+ OPENAI_API_KEY=
11
+ OPENAI_MODEL=gpt-4o-mini
12
+
13
+ GROQ_API_KEY=
14
+ GROQ_MODEL=llama-3.3-70b-versatile
15
+
16
+ CONTEXTFORGE_AI_PROVIDER=openai
17
+ CONTEXTFORGE_AI_ENABLED=true
18
+
19
+ CONTEXTFORGE_AUTO_START=true
20
+ CONTEXTFORGE_POSTINSTALL_GENERATE=true
21
+ CONTEXTFORGE_POSTINSTALL_WATCH=true
22
+
23
+ CONTEXTFORGE_REFRESH_INTERVAL_MINUTES=15
24
+ `;
25
+
26
+ /**
27
+ * Ensure .env.example exists in the target project.
28
+ * @param {string} projectRoot
29
+ */
30
+ export async function ensureEnvExample(projectRoot) {
31
+ const target = path.join(projectRoot, '.env.example');
32
+ const packageExample = path.join(PACKAGE_ROOT, '.env.example');
33
+
34
+ if (await fs.pathExists(target)) return;
35
+
36
+ if (await fs.pathExists(packageExample)) {
37
+ await fs.copy(packageExample, target);
38
+ } else {
39
+ await fs.writeFile(target, ENV_EXAMPLE_CONTENT, 'utf8');
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Create .env from .env.example if .env does not exist.
45
+ * @param {string} projectRoot
46
+ * @returns {Promise<boolean>} true if .env was created
47
+ */
48
+ export async function ensureEnvFromExample(projectRoot) {
49
+ const envPath = path.join(projectRoot, '.env');
50
+ const examplePath = path.join(projectRoot, '.env.example');
51
+
52
+ if (await fs.pathExists(envPath)) return false;
53
+
54
+ await ensureEnvExample(projectRoot);
55
+
56
+ if (await fs.pathExists(examplePath)) {
57
+ await fs.copy(examplePath, envPath);
58
+ return true;
59
+ }
60
+
61
+ await fs.writeFile(envPath, ENV_EXAMPLE_CONTENT, 'utf8');
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Validate .env has at least placeholder structure (warn if keys empty when AI on).
67
+ * @param {string} projectRoot
68
+ */
69
+ export async function validateEnvSetup(projectRoot) {
70
+ const envPath = path.join(projectRoot, '.env');
71
+ const issues = [];
72
+
73
+ if (!(await fs.pathExists(envPath))) {
74
+ return { ok: false, issues: ['.env file missing — run: copy .env.example .env'] };
75
+ }
76
+
77
+ const content = await fs.readFile(envPath, 'utf8');
78
+ const hasOpenAI = /^OPENAI_API_KEY=.+/m.test(content) && !/your-|sk-your/i.test(content);
79
+ const hasGroq = /^GROQ_API_KEY=.+/m.test(content) && !/your-|gsk_your/i.test(content);
80
+
81
+ if (!hasOpenAI && !hasGroq) {
82
+ issues.push('Add OPENAI_API_KEY and/or GROQ_API_KEY to .env for AI enrichment');
83
+ }
84
+
85
+ return { ok: issues.length === 0, issues, hasOpenAI, hasGroq };
86
+ }
@@ -0,0 +1,31 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { getContextForgeRoot } from './paths.js';
4
+ import { PACKAGE_PROMPTS, PROMPT_FILES } from '../services/ai/prompt-loader.js';
5
+
6
+ /**
7
+ * Copy default prompts to .contextforge/prompts/ (skip existing files).
8
+ * @param {string} projectRoot
9
+ */
10
+ export async function ensureProjectPrompts(projectRoot) {
11
+ const targetDir = path.join(getContextForgeRoot(projectRoot), 'prompts');
12
+ await fs.ensureDir(targetDir);
13
+
14
+ let copied = 0;
15
+ for (const file of Object.values(PROMPT_FILES)) {
16
+ const dest = path.join(targetDir, file);
17
+ const src = path.join(PACKAGE_PROMPTS, file);
18
+ if (!(await fs.pathExists(dest)) && (await fs.pathExists(src))) {
19
+ await fs.copy(src, dest);
20
+ copied++;
21
+ }
22
+ }
23
+
24
+ const readmeSrc = path.join(PACKAGE_PROMPTS, 'README.md');
25
+ const readmeDest = path.join(targetDir, 'README.md');
26
+ if (!(await fs.pathExists(readmeDest)) && (await fs.pathExists(readmeSrc))) {
27
+ await fs.copy(readmeSrc, readmeDest);
28
+ }
29
+
30
+ return { targetDir, copied };
31
+ }
@@ -0,0 +1,70 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'fs-extra';
3
+ import path from 'node:path';
4
+ import { getCacheDir } from './paths.js';
5
+
6
+ function hashPayload(obj) {
7
+ return crypto.createHash('sha256').update(JSON.stringify(obj)).digest('hex');
8
+ }
9
+
10
+ /**
11
+ * Fingerprint of all scanned file contents — detects code edits.
12
+ */
13
+ export function computeFilesSignature(files = []) {
14
+ const parts = files
15
+ .filter((f) => f.hash && f.hash !== 'skipped-large' && f.hash !== 'unreadable')
16
+ .map((f) => `${f.relativePath}:${f.hash}`)
17
+ .sort();
18
+ return hashPayload(parts);
19
+ }
20
+
21
+ /**
22
+ * Stable hash of scan data — skip AI if unchanged.
23
+ * @param {object} snapshot
24
+ * @param {{ added?: string[], modified?: string[], removed?: string[] } | null} fileDiff
25
+ */
26
+ export function computeScanHash(snapshot, fileDiff = null) {
27
+ const payload = {
28
+ projectName: snapshot.projectName,
29
+ techStack: snapshot.techStack,
30
+ architecture: {
31
+ primary: snapshot.architecture?.primary?.pattern,
32
+ apiFlow: snapshot.architecture?.apiFlow,
33
+ },
34
+ database: snapshot.database?.models?.map((m) => m.name),
35
+ filesScanned: snapshot.filesScanned,
36
+ filesSignature: snapshot.filesSignature,
37
+ codeSummary: snapshot.codeInsights?.summary,
38
+ astRouteCount: snapshot.ast?.routes?.length,
39
+ bugCount: snapshot.bugs?.issues?.length,
40
+ gitHead: snapshot.git?.commits?.[0]?.hash,
41
+ fileDiff: fileDiff
42
+ ? {
43
+ added: fileDiff.added?.length || 0,
44
+ modified: fileDiff.modified?.length || 0,
45
+ removed: fileDiff.removed?.length || 0,
46
+ }
47
+ : null,
48
+ };
49
+ return hashPayload(payload);
50
+ }
51
+
52
+ export async function loadScanHash(projectRoot) {
53
+ const p = path.join(getCacheDir(projectRoot), 'scan-hash.txt');
54
+ if (await fs.pathExists(p)) {
55
+ return (await fs.readFile(p, 'utf8')).trim();
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export async function saveScanHash(projectRoot, hash) {
61
+ await fs.writeFile(path.join(getCacheDir(projectRoot), 'scan-hash.txt'), hash, 'utf8');
62
+ }
63
+
64
+ export async function shouldSkipAi(projectRoot, snapshot, forceAi, fileDiff = null) {
65
+ if (forceAi) return false;
66
+ if (fileDiff?.hasFileChanges) return false;
67
+ const current = computeScanHash(snapshot, fileDiff);
68
+ const previous = await loadScanHash(projectRoot);
69
+ return previous === current;
70
+ }