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.
- package/.contextforge/config.ai.example.json +28 -0
- package/.contextforge/config.example.json +29 -0
- package/.contextforge/prompts/README.md +50 -0
- package/.env.example +73 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/contextforge.js +14 -0
- package/bin/postinstall-worker.js +14 -0
- package/bin/postinstall.js +26 -0
- package/package.json +65 -0
- package/prompts/README.md +50 -0
- package/prompts/response-schema.md +22 -0
- package/prompts/retry-addon.md +1 -0
- package/prompts/system.md +10 -0
- package/prompts/user-template.md +22 -0
- package/src/analyzers/ast-parser.js +139 -0
- package/src/analyzers/bugs-lite.js +118 -0
- package/src/analyzers/changelog.js +190 -0
- package/src/analyzers/code-insights.js +225 -0
- package/src/analyzers/dependencies.js +110 -0
- package/src/analyzers/detection-quality.js +106 -0
- package/src/analyzers/eslint-runner.js +56 -0
- package/src/analyzers/folder-tree.js +60 -0
- package/src/analyzers/git-insights.js +94 -0
- package/src/analyzers/project-meta.js +98 -0
- package/src/cli/commands/changes.js +36 -0
- package/src/cli/commands/doctor.js +152 -0
- package/src/cli/commands/generate.js +7 -0
- package/src/cli/commands/init.js +98 -0
- package/src/cli/commands/prompt.js +53 -0
- package/src/cli/commands/stop.js +10 -0
- package/src/cli/commands/summary.js +22 -0
- package/src/cli/commands/watch.js +9 -0
- package/src/cli/index.js +120 -0
- package/src/core/confidence.js +51 -0
- package/src/core/config.js +183 -0
- package/src/core/cursor-rules.js +293 -0
- package/src/core/ensure-setup.js +45 -0
- package/src/core/env.js +113 -0
- package/src/core/package-meta.js +35 -0
- package/src/core/paths.js +39 -0
- package/src/core/pipeline.js +256 -0
- package/src/core/postinstall.js +168 -0
- package/src/core/setup-env.js +86 -0
- package/src/core/setup-prompts.js +31 -0
- package/src/core/snapshot-hash.js +70 -0
- package/src/detectors/architecture.js +117 -0
- package/src/detectors/auth-deploy.js +51 -0
- package/src/detectors/database.js +127 -0
- package/src/detectors/tech-stack.js +180 -0
- package/src/generators/artifacts.js +44 -0
- package/src/generators/changes-markdown.js +72 -0
- package/src/generators/context-json.js +91 -0
- package/src/generators/context-markdown.js +571 -0
- package/src/generators/mermaid.js +59 -0
- package/src/index.js +13 -0
- package/src/scanner/change-detector.js +24 -0
- package/src/scanner/config-files.js +52 -0
- package/src/scanner/file-index.js +55 -0
- package/src/scanner/package-deps.js +32 -0
- package/src/scanner/repo-scanner.js +143 -0
- package/src/services/ai/background.js +87 -0
- package/src/services/ai/config.js +48 -0
- package/src/services/ai/enrich.js +87 -0
- package/src/services/ai/index.js +3 -0
- package/src/services/ai/prompt-loader.js +104 -0
- package/src/services/ai/prompt.js +42 -0
- package/src/services/ai/providers/groq.js +36 -0
- package/src/services/ai/providers/openai.js +32 -0
- package/src/services/ai/redact.js +33 -0
- package/src/services/ai/validate.js +70 -0
- package/src/watcher/watcher.js +128 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigPath, getContextForgeRoot } from './paths.js';
|
|
4
|
+
import { DEFAULT_AI_CONFIG } from '../services/ai/config.js';
|
|
5
|
+
import {
|
|
6
|
+
loadProjectEnv,
|
|
7
|
+
getRefreshIntervalMinutesFromEnv,
|
|
8
|
+
getDebounceMsFromEnv,
|
|
9
|
+
isAiEnabledViaEnv,
|
|
10
|
+
hasAnyAiKey,
|
|
11
|
+
} from './env.js';
|
|
12
|
+
import { resolveAiConfig } from '../services/ai/config.js';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CONFIG = {
|
|
15
|
+
ignore: [
|
|
16
|
+
'node_modules',
|
|
17
|
+
'dist',
|
|
18
|
+
'build',
|
|
19
|
+
'.next',
|
|
20
|
+
'coverage',
|
|
21
|
+
'.git',
|
|
22
|
+
'test/fixtures',
|
|
23
|
+
'test/tmp-mini-express',
|
|
24
|
+
'.contextforge/cache',
|
|
25
|
+
'.contextforge/logs',
|
|
26
|
+
'.contextforge/embeddings',
|
|
27
|
+
],
|
|
28
|
+
watch: true,
|
|
29
|
+
autoGenerate: true,
|
|
30
|
+
includeGitHistory: true,
|
|
31
|
+
detectBugs: true,
|
|
32
|
+
debounceMs: 2000,
|
|
33
|
+
/** Auto-refresh context.md every N minutes during `watch` (0 = file changes only) */
|
|
34
|
+
refreshIntervalMinutes: 0,
|
|
35
|
+
/** Track and log file/stack changes between runs */
|
|
36
|
+
trackChanges: true,
|
|
37
|
+
maxTreeDepth: 4,
|
|
38
|
+
gitCommitLimit: 20,
|
|
39
|
+
maxFileSizeBytes: 524288,
|
|
40
|
+
installCursorRules: true,
|
|
41
|
+
/** Run generate after npm install (postinstall hook) */
|
|
42
|
+
postinstallGenerate: true,
|
|
43
|
+
/** Start `watch --daemon` in background after npm install */
|
|
44
|
+
postinstallWatch: true,
|
|
45
|
+
ai: { ...DEFAULT_AI_CONFIG },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Paths never read for content analysis */
|
|
49
|
+
export const SENSITIVE_PATTERNS = [
|
|
50
|
+
/^\.env(\.|$)/,
|
|
51
|
+
/\.pem$/i,
|
|
52
|
+
/credentials/i,
|
|
53
|
+
/id_rsa/i,
|
|
54
|
+
/\.key$/i,
|
|
55
|
+
/secrets?\./i,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function isSensitivePath(relativePath) {
|
|
59
|
+
const base = path.basename(relativePath);
|
|
60
|
+
return SENSITIVE_PATTERNS.some((re) => re.test(base) || re.test(relativePath));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Merge saved config with package defaults (adds new fields on upgrade). */
|
|
64
|
+
export function mergeConfigWithDefaults(userConfig = {}) {
|
|
65
|
+
const merged = { ...DEFAULT_CONFIG, ...userConfig };
|
|
66
|
+
if (userConfig.ignore) {
|
|
67
|
+
merged.ignore = [...new Set([...DEFAULT_CONFIG.ignore, ...userConfig.ignore])];
|
|
68
|
+
}
|
|
69
|
+
merged.ai = {
|
|
70
|
+
...DEFAULT_AI_CONFIG,
|
|
71
|
+
...(userConfig.ai || {}),
|
|
72
|
+
openai: { ...DEFAULT_AI_CONFIG.openai, ...(userConfig.ai?.openai || {}) },
|
|
73
|
+
groq: { ...DEFAULT_AI_CONFIG.groq, ...(userConfig.ai?.groq || {}) },
|
|
74
|
+
};
|
|
75
|
+
return merged;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply .env overrides (timer, debounce, AI provider) on top of config. */
|
|
79
|
+
export function applyEnvOverrides(config) {
|
|
80
|
+
const envInterval = getRefreshIntervalMinutesFromEnv();
|
|
81
|
+
if (envInterval !== null) {
|
|
82
|
+
config.refreshIntervalMinutes = envInterval;
|
|
83
|
+
}
|
|
84
|
+
const envDebounce = getDebounceMsFromEnv();
|
|
85
|
+
if (envDebounce !== null) {
|
|
86
|
+
config.debounceMs = envDebounce;
|
|
87
|
+
}
|
|
88
|
+
const resolvedAi = resolveAiConfig(config);
|
|
89
|
+
config.ai = { ...config.ai, ...resolvedAi };
|
|
90
|
+
return config;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write/update config.json from defaults + .env (proper init/upgrade path).
|
|
95
|
+
* @param {string} projectRoot
|
|
96
|
+
* @param {{ enableAi?: boolean, upgrade?: boolean }} options
|
|
97
|
+
*/
|
|
98
|
+
export async function syncConfigFile(projectRoot, options = {}) {
|
|
99
|
+
await loadProjectEnv(projectRoot);
|
|
100
|
+
const configPath = getConfigPath(projectRoot);
|
|
101
|
+
let userConfig = {};
|
|
102
|
+
if (await fs.pathExists(configPath)) {
|
|
103
|
+
userConfig = await fs.readJson(configPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const merged = mergeConfigWithDefaults(userConfig);
|
|
107
|
+
|
|
108
|
+
if (options.enableAi) {
|
|
109
|
+
merged.ai.enabled = true;
|
|
110
|
+
merged.ai.enrichContext = true;
|
|
111
|
+
merged.ai.background = true;
|
|
112
|
+
} else {
|
|
113
|
+
const envAi = isAiEnabledViaEnv();
|
|
114
|
+
if (envAi === true) {
|
|
115
|
+
merged.ai.enabled = true;
|
|
116
|
+
} else if (envAi === false) {
|
|
117
|
+
merged.ai.enabled = false;
|
|
118
|
+
} else if (options.upgrade && hasAnyAiKey() && userConfig.ai?.enabled === undefined) {
|
|
119
|
+
merged.ai.enabled = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
applyEnvOverrides(merged);
|
|
124
|
+
|
|
125
|
+
await ensureContextForgeDirs(projectRoot);
|
|
126
|
+
await fs.writeJson(configPath, merged, { spaces: 2 });
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadConfig(projectRoot) {
|
|
131
|
+
await loadProjectEnv(projectRoot);
|
|
132
|
+
|
|
133
|
+
const configPath = getConfigPath(projectRoot);
|
|
134
|
+
let userConfig = {};
|
|
135
|
+
|
|
136
|
+
if (await fs.pathExists(configPath)) {
|
|
137
|
+
userConfig = await fs.readJson(configPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const merged = mergeConfigWithDefaults(userConfig);
|
|
141
|
+
applyEnvOverrides(merged);
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function ensureContextForgeDirs(projectRoot) {
|
|
146
|
+
const root = getContextForgeRoot(projectRoot);
|
|
147
|
+
await fs.ensureDir(root);
|
|
148
|
+
await fs.ensureDir(path.join(root, 'cache'));
|
|
149
|
+
await fs.ensureDir(path.join(root, 'logs'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function writeDefaultConfig(projectRoot, options = {}) {
|
|
153
|
+
await ensureContextForgeDirs(projectRoot);
|
|
154
|
+
const configPath = getConfigPath(projectRoot);
|
|
155
|
+
|
|
156
|
+
if (!(await fs.pathExists(configPath))) {
|
|
157
|
+
const config = mergeConfigWithDefaults(options);
|
|
158
|
+
if (options.ai) {
|
|
159
|
+
config.ai = { ...config.ai, ...options.ai };
|
|
160
|
+
}
|
|
161
|
+
applyEnvOverrides(config);
|
|
162
|
+
if (options.enableAi) {
|
|
163
|
+
config.ai.enabled = true;
|
|
164
|
+
}
|
|
165
|
+
await fs.writeJson(configPath, config, { spaces: 2 });
|
|
166
|
+
return config;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (options.ai) {
|
|
170
|
+
const existing = await fs.readJson(configPath);
|
|
171
|
+
await fs.writeJson(
|
|
172
|
+
configPath,
|
|
173
|
+
mergeConfigWithDefaults({ ...existing, ai: { ...existing.ai, ...options.ai } }),
|
|
174
|
+
{ spaces: 2 }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return mergeConfigWithDefaults(await fs.readJson(configPath));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function isInitialized(projectRoot) {
|
|
182
|
+
return fs.pathExists(getConfigPath(projectRoot));
|
|
183
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getOutputPath, OUTPUT_FILES } from './paths.js';
|
|
4
|
+
|
|
5
|
+
const RULE_FILENAME = 'contextforge.mdc';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ description?: string, alwaysApply?: boolean }} meta
|
|
9
|
+
* @param {string} body
|
|
10
|
+
*/
|
|
11
|
+
function formatMdc(meta, body) {
|
|
12
|
+
const lines = ['---'];
|
|
13
|
+
if (meta.description) lines.push(`description: ${meta.description}`);
|
|
14
|
+
lines.push(`alwaysApply: ${meta.alwaysApply === true}`);
|
|
15
|
+
lines.push('---', '', body.trim(), '');
|
|
16
|
+
return lines.join('\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bullets(items, empty = '_None_') {
|
|
20
|
+
if (!items?.length) return empty;
|
|
21
|
+
return items.map((i) => `- ${i}`).join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderAtAGlanceTable(snapshot) {
|
|
25
|
+
const ts = snapshot.techStack || {};
|
|
26
|
+
const arch = snapshot.architecture || {};
|
|
27
|
+
const auth = snapshot.authDeploy || {};
|
|
28
|
+
|
|
29
|
+
const rows = [
|
|
30
|
+
['Frontend', [...(ts.frontend || []), ...(ts.styling || [])].join(', ') || '—'],
|
|
31
|
+
['Backend', (ts.backend || []).join(', ') || '—'],
|
|
32
|
+
['Database / ORM', [...(ts.database || []), ...(ts.orm || [])].join(', ') || '—'],
|
|
33
|
+
['State', (ts.state || []).join(', ') || '—'],
|
|
34
|
+
[
|
|
35
|
+
'Auth',
|
|
36
|
+
[...new Set([...(auth.authentication || []), ...(ts.auth || [])])].join(', ') || '—',
|
|
37
|
+
],
|
|
38
|
+
['Deploy', [...(auth.deployment || []), ...(ts.deploy || [])].join(', ') || '—'],
|
|
39
|
+
['Architecture', arch?.primary?.label || '—'],
|
|
40
|
+
['API style', arch?.apiFlow || snapshot.codeInsights?.pipelineFlow || '—'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
'| Area | Detected |',
|
|
45
|
+
'|------|----------|',
|
|
46
|
+
...rows.map(([a, b]) => `| **${a}** | ${b} |`),
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function collectImportantRules(snapshot) {
|
|
51
|
+
const code = snapshot.codeInsights || {};
|
|
52
|
+
const ai = snapshot.aiEnrichment?.structured || snapshot.aiEnrichment || {};
|
|
53
|
+
const fromCode = code.importantRules || [];
|
|
54
|
+
const fromAi = Array.isArray(ai.importantRules)
|
|
55
|
+
? ai.importantRules
|
|
56
|
+
: ai.importantRules
|
|
57
|
+
? [String(ai.importantRules)]
|
|
58
|
+
: [];
|
|
59
|
+
return [...new Set([...fromCode, ...fromAi])].slice(0, 12);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectConventions(snapshot) {
|
|
63
|
+
const ai = snapshot.aiEnrichment?.structured || snapshot.aiEnrichment || {};
|
|
64
|
+
const fromAi = Array.isArray(ai.codingConventions)
|
|
65
|
+
? ai.codingConventions
|
|
66
|
+
: ai.codingConventions
|
|
67
|
+
? [String(ai.codingConventions)]
|
|
68
|
+
: [];
|
|
69
|
+
const patterns = snapshot.codeInsights?.patterns || [];
|
|
70
|
+
return [...new Set([...fromAi, ...patterns])].slice(0, 10);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderRoutesBrief(endpoints = []) {
|
|
74
|
+
if (!endpoints.length) return null;
|
|
75
|
+
const lines = [
|
|
76
|
+
'| Method | Path | File |',
|
|
77
|
+
'|--------|------|------|',
|
|
78
|
+
...endpoints.slice(0, 20).map((e) => `| ${e.method} | ${e.path} | \`${e.file}\` |`),
|
|
79
|
+
];
|
|
80
|
+
if (endpoints.length > 20) {
|
|
81
|
+
lines.push('', `_+ ${endpoints.length - 20} more in \`.contextforge/context.md\`_`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderServicesBrief(businessLogic = []) {
|
|
87
|
+
if (!businessLogic.length) return null;
|
|
88
|
+
return businessLogic
|
|
89
|
+
.slice(0, 15)
|
|
90
|
+
.map((b) => `- \`${b.name}\` (${b.type}) — \`${b.file}\``)
|
|
91
|
+
.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a single Cursor rule file from the pipeline snapshot (same source as context.md).
|
|
96
|
+
* @param {object} snapshot
|
|
97
|
+
* @returns {{ filename: string, content: string }[]}
|
|
98
|
+
*/
|
|
99
|
+
export function buildCursorRuleFiles(snapshot) {
|
|
100
|
+
const projectName = snapshot.projectName || 'project';
|
|
101
|
+
const generatedAt = snapshot.generatedAt || new Date().toISOString();
|
|
102
|
+
const ts = snapshot.techStack || {};
|
|
103
|
+
const sections = [];
|
|
104
|
+
|
|
105
|
+
sections.push(
|
|
106
|
+
`# ContextForge — ${projectName}`,
|
|
107
|
+
'',
|
|
108
|
+
`_Synced from \`.contextforge/context.md\` (${generatedAt})_`,
|
|
109
|
+
'',
|
|
110
|
+
'Before answering questions about this codebase, **read [`.contextforge/context.md`](.contextforge/context.md)** for full detail.',
|
|
111
|
+
'',
|
|
112
|
+
'## At a Glance',
|
|
113
|
+
'',
|
|
114
|
+
renderAtAGlanceTable(snapshot),
|
|
115
|
+
'',
|
|
116
|
+
'## Commands',
|
|
117
|
+
'',
|
|
118
|
+
'- Regenerate: `npx contextforge generate`',
|
|
119
|
+
'- Watch: `npx contextforge watch`',
|
|
120
|
+
'- Changes: `npx contextforge changes` or `.contextforge/CHANGES.md`',
|
|
121
|
+
'- Timer: `CONTEXTFORGE_REFRESH_INTERVAL_MINUTES` in `.env` (e.g. 15)'
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const importantRules = collectImportantRules(snapshot);
|
|
125
|
+
if (importantRules.length) {
|
|
126
|
+
sections.push('', '## Important rules', '', bullets(importantRules));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const conventions = collectConventions(snapshot);
|
|
130
|
+
if (conventions.length) {
|
|
131
|
+
sections.push('', '## Patterns & conventions', '', bullets(conventions));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const topIssues = (snapshot.bugs?.issues || []).slice(0, 5);
|
|
135
|
+
if (topIssues.length) {
|
|
136
|
+
sections.push(
|
|
137
|
+
'',
|
|
138
|
+
'## Watch for (static analysis)',
|
|
139
|
+
'',
|
|
140
|
+
...topIssues.map(
|
|
141
|
+
(i) => `- **[${i.severity}]** ${i.message}${i.file ? ` — \`${i.file}\`` : ''}`
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const endpoints = snapshot.codeInsights?.apiEndpoints || [];
|
|
147
|
+
const routeTable = renderRoutesBrief(endpoints);
|
|
148
|
+
if (routeTable) {
|
|
149
|
+
sections.push(
|
|
150
|
+
'',
|
|
151
|
+
'## API routes',
|
|
152
|
+
'',
|
|
153
|
+
snapshot.architecture?.apiFlow ? `**Flow:** ${snapshot.architecture.apiFlow}` : '',
|
|
154
|
+
'',
|
|
155
|
+
routeTable
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const businessLogic = snapshot.codeInsights?.businessLogic || [];
|
|
160
|
+
const serviceList = renderServicesBrief(businessLogic);
|
|
161
|
+
if (serviceList || snapshot.codeInsights?.summary) {
|
|
162
|
+
sections.push('', '## Business logic');
|
|
163
|
+
if (snapshot.codeInsights?.summary) {
|
|
164
|
+
sections.push('', snapshot.codeInsights.summary);
|
|
165
|
+
}
|
|
166
|
+
if (serviceList) {
|
|
167
|
+
sections.push('', serviceList);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (ts.frontend?.length || ts.styling?.length) {
|
|
172
|
+
sections.push(
|
|
173
|
+
'',
|
|
174
|
+
'## Frontend',
|
|
175
|
+
'',
|
|
176
|
+
`**Stack:** ${[...(ts.frontend || []), ...(ts.styling || []), ...(ts.state || [])].join(', ') || '—'}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
sections.push(
|
|
181
|
+
'_More detail in `.contextforge/context.md` (architecture, git, database, full issue list)._'
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return [
|
|
185
|
+
{
|
|
186
|
+
filename: RULE_FILENAME,
|
|
187
|
+
content: formatMdc(
|
|
188
|
+
{
|
|
189
|
+
description: 'ContextForge project memory — read before coding',
|
|
190
|
+
alwaysApply: true,
|
|
191
|
+
},
|
|
192
|
+
sections.join('\n\n')
|
|
193
|
+
),
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} projectRoot
|
|
200
|
+
* @param {object} [snapshot]
|
|
201
|
+
*/
|
|
202
|
+
export async function installCursorRules(projectRoot, snapshot = null) {
|
|
203
|
+
const cursorDir = path.join(projectRoot, '.cursor', 'rules');
|
|
204
|
+
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
|
205
|
+
|
|
206
|
+
if (!snapshot) {
|
|
207
|
+
const jsonPath = getOutputPath(projectRoot, OUTPUT_FILES.contextJson);
|
|
208
|
+
if (await fs.pathExists(jsonPath)) {
|
|
209
|
+
snapshot = await fs.readJson(jsonPath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await fs.ensureDir(cursorDir);
|
|
214
|
+
|
|
215
|
+
const ruleFiles = snapshot ? buildCursorRuleFiles(snapshot) : [buildFallbackRule()];
|
|
216
|
+
|
|
217
|
+
const written = [];
|
|
218
|
+
for (const { filename, content } of ruleFiles) {
|
|
219
|
+
const rulePath = path.join(cursorDir, filename);
|
|
220
|
+
await fs.writeFile(rulePath, content, 'utf8');
|
|
221
|
+
written.push(rulePath);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await pruneStaleRules(cursorDir, ruleFiles.map((r) => r.filename));
|
|
225
|
+
|
|
226
|
+
await syncAgentsMd(agentsPath);
|
|
227
|
+
|
|
228
|
+
return { rulePaths: written, agentsPath };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildFallbackRule() {
|
|
232
|
+
return {
|
|
233
|
+
filename: RULE_FILENAME,
|
|
234
|
+
content: formatMdc(
|
|
235
|
+
{
|
|
236
|
+
description: 'ContextForge project context — read before coding',
|
|
237
|
+
alwaysApply: true,
|
|
238
|
+
},
|
|
239
|
+
`# ContextForge — Project Memory
|
|
240
|
+
|
|
241
|
+
Before answering questions about this codebase, **read \`.contextforge/context.md\`** in full.
|
|
242
|
+
|
|
243
|
+
Regenerate context: \`npx contextforge generate\`
|
|
244
|
+
Watch mode: \`npx contextforge watch\`
|
|
245
|
+
Change history: \`npx contextforge changes\` or \`.contextforge/CHANGES.md\``
|
|
246
|
+
),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Remove old split contextforge-*.mdc files from previous versions. */
|
|
251
|
+
async function pruneStaleRules(cursorDir, keepFilenames) {
|
|
252
|
+
const keep = new Set(keepFilenames);
|
|
253
|
+
let entries = [];
|
|
254
|
+
try {
|
|
255
|
+
entries = await fs.readdir(cursorDir);
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
for (const name of entries) {
|
|
260
|
+
if (!name.startsWith('contextforge') || !name.endsWith('.mdc')) continue;
|
|
261
|
+
if (!keep.has(name)) {
|
|
262
|
+
await fs.remove(path.join(cursorDir, name));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function syncAgentsMd(agentsPath) {
|
|
268
|
+
const agentsSnippet = `## ContextForge
|
|
269
|
+
|
|
270
|
+
Read [\`.contextforge/context.md\`](.contextforge/context.md) before working on this repository.
|
|
271
|
+
Cursor rule [\`.cursor/rules/contextforge.mdc\`](.cursor/rules/contextforge.mdc) is synced from the latest generate run.
|
|
272
|
+
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
if (await fs.pathExists(agentsPath)) {
|
|
276
|
+
const content = await fs.readFile(agentsPath, 'utf8');
|
|
277
|
+
if (!content.includes('contextforge/context.md')) {
|
|
278
|
+
await fs.writeFile(agentsPath, agentsSnippet + content, 'utf8');
|
|
279
|
+
} else if (!content.includes('contextforge.mdc')) {
|
|
280
|
+
const updated = content.replace(
|
|
281
|
+
/Cursor rules in \[`\.cursor\/rules\/`\]\(\.cursor\/rules\/\) are synced from the latest generate run\./,
|
|
282
|
+
'Cursor rule [`.cursor/rules/contextforge.mdc`](.cursor/rules/contextforge.mdc) is synced from the latest generate run.'
|
|
283
|
+
);
|
|
284
|
+
if (updated !== content) await fs.writeFile(agentsPath, updated, 'utf8');
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
await fs.writeFile(
|
|
288
|
+
agentsPath,
|
|
289
|
+
`# Agent Instructions\n\n${agentsSnippet}`,
|
|
290
|
+
'utf8'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
writeDefaultConfig,
|
|
3
|
+
isInitialized,
|
|
4
|
+
syncConfigFile,
|
|
5
|
+
ensureContextForgeDirs,
|
|
6
|
+
} from './config.js';
|
|
7
|
+
import { ensureEnvExample, ensureEnvFromExample } from './setup-env.js';
|
|
8
|
+
import { ensureProjectPrompts } from './setup-prompts.js';
|
|
9
|
+
import { getContextForgeRoot } from './paths.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create `.contextforge/` and config when missing (used by generate/watch).
|
|
13
|
+
* @param {string} projectRoot
|
|
14
|
+
* @param {{ enableAi?: boolean, quiet?: boolean }} [options]
|
|
15
|
+
* @returns {Promise<boolean>} true if setup was created now
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureInitialized(projectRoot, options = {}) {
|
|
18
|
+
if (await isInitialized(projectRoot)) {
|
|
19
|
+
await ensureContextForgeDirs(projectRoot);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!options.quiet) {
|
|
24
|
+
console.log('ContextForge not set up — creating `.contextforge/` automatically...');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const initOptions = {};
|
|
28
|
+
if (options.enableAi) {
|
|
29
|
+
initOptions.ai = { enabled: true, enrichContext: true, background: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await writeDefaultConfig(projectRoot, initOptions);
|
|
33
|
+
await syncConfigFile(projectRoot, { enableAi: options.enableAi, upgrade: true });
|
|
34
|
+
await ensureProjectPrompts(projectRoot);
|
|
35
|
+
await ensureEnvExample(projectRoot);
|
|
36
|
+
await ensureEnvFromExample(projectRoot);
|
|
37
|
+
await ensureContextForgeDirs(projectRoot);
|
|
38
|
+
|
|
39
|
+
if (!options.quiet) {
|
|
40
|
+
console.log(` → ${getContextForgeRoot(projectRoot)}/`);
|
|
41
|
+
console.log(' Tip: run `npx contextforge watch` to keep context.md updated while you code');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return true;
|
|
45
|
+
}
|
package/src/core/env.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load environment variables from project .env (never committed).
|
|
7
|
+
* @param {string} projectRoot
|
|
8
|
+
*/
|
|
9
|
+
export async function loadProjectEnv(projectRoot) {
|
|
10
|
+
const root = path.resolve(projectRoot);
|
|
11
|
+
const envPath = path.join(root, '.env');
|
|
12
|
+
const examplePath = path.join(root, '.env.example');
|
|
13
|
+
|
|
14
|
+
if (await fs.pathExists(envPath)) {
|
|
15
|
+
const result = dotenv.config({ path: envPath, override: true });
|
|
16
|
+
if (result.error) {
|
|
17
|
+
console.warn(`[contextforge] Warning: could not parse .env — ${result.error.message}`);
|
|
18
|
+
}
|
|
19
|
+
} else if (await fs.pathExists(examplePath)) {
|
|
20
|
+
dotenv.config({ path: examplePath, override: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getApiKey(provider) {
|
|
25
|
+
if (provider === 'groq') {
|
|
26
|
+
return (
|
|
27
|
+
process.env.GROQ_API_KEY ||
|
|
28
|
+
process.env.CONTEXTFORGE_GROQ_API_KEY ||
|
|
29
|
+
''
|
|
30
|
+
).trim();
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
process.env.OPENAI_API_KEY ||
|
|
34
|
+
process.env.CONTEXTFORGE_OPENAI_API_KEY ||
|
|
35
|
+
''
|
|
36
|
+
).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getEnvModel(provider, defaultModel) {
|
|
40
|
+
if (provider === 'groq') {
|
|
41
|
+
return (process.env.GROQ_MODEL || defaultModel).trim();
|
|
42
|
+
}
|
|
43
|
+
return (process.env.OPENAI_MODEL || defaultModel).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseEnvBool(value) {
|
|
47
|
+
if (value === 'true' || value === '1') return true;
|
|
48
|
+
if (value === 'false' || value === '0') return false;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isAiEnabledViaEnv() {
|
|
53
|
+
return parseEnvBool(process.env.CONTEXTFORGE_AI_ENABLED);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Master switch: auto setup + background watch on `npm install` (default: on). */
|
|
57
|
+
export function isAutoStartEnabledViaEnv() {
|
|
58
|
+
if (process.env.CONTEXTFORGE_SKIP_POSTINSTALL === '1') return false;
|
|
59
|
+
const v = parseEnvBool(process.env.CONTEXTFORGE_AUTO_START);
|
|
60
|
+
if (v !== null) return v;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isPostinstallGenerateEnabledViaEnv() {
|
|
65
|
+
const v = parseEnvBool(process.env.CONTEXTFORGE_POSTINSTALL_GENERATE);
|
|
66
|
+
if (v !== null) return v;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isPostinstallWatchEnabledViaEnv() {
|
|
71
|
+
const v = parseEnvBool(process.env.CONTEXTFORGE_POSTINSTALL_WATCH);
|
|
72
|
+
if (v !== null) return v;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hasAnyAiKey() {
|
|
77
|
+
return !!(getApiKey('openai') || getApiKey('groq'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseNonNegativeNumber(value) {
|
|
81
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const n = parseFloat(String(value).trim());
|
|
85
|
+
if (Number.isNaN(n) || n < 0) return null;
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Auto-refresh interval for `watch` (minutes). 0 = only on file changes.
|
|
91
|
+
* Env: CONTEXTFORGE_REFRESH_INTERVAL_MINUTES, CONTEXTFORGE_WATCH_INTERVAL_MINUTES
|
|
92
|
+
* Or seconds: CONTEXTFORGE_REFRESH_INTERVAL_SECONDS
|
|
93
|
+
*/
|
|
94
|
+
export function getRefreshIntervalMinutesFromEnv() {
|
|
95
|
+
const minutes = parseNonNegativeNumber(
|
|
96
|
+
process.env.CONTEXTFORGE_REFRESH_INTERVAL_MINUTES ??
|
|
97
|
+
process.env.CONTEXTFORGE_WATCH_INTERVAL_MINUTES
|
|
98
|
+
);
|
|
99
|
+
if (minutes !== null) return minutes;
|
|
100
|
+
|
|
101
|
+
const seconds = parseNonNegativeNumber(
|
|
102
|
+
process.env.CONTEXTFORGE_REFRESH_INTERVAL_SECONDS
|
|
103
|
+
);
|
|
104
|
+
if (seconds !== null) return seconds / 60;
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** File-change debounce for watch (milliseconds). Env: CONTEXTFORGE_DEBOUNCE_MS */
|
|
110
|
+
export function getDebounceMsFromEnv() {
|
|
111
|
+
const ms = parseNonNegativeNumber(process.env.CONTEXTFORGE_DEBOUNCE_MS);
|
|
112
|
+
return ms !== null ? Math.round(ms) : null;
|
|
113
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
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
|
+
/** Published npm package name (must match package.json "name"). */
|
|
9
|
+
export const NPM_PACKAGE_NAME = 'contextforge-cli-ai-prompt-pirates';
|
|
10
|
+
|
|
11
|
+
/** Older / alternate names still recognized in consumer package.json. */
|
|
12
|
+
export const LEGACY_NPM_NAMES = ['contextforge', '@contextforge/core'];
|
|
13
|
+
|
|
14
|
+
let cachedOwnName = null;
|
|
15
|
+
|
|
16
|
+
export async function getOwnPackageName() {
|
|
17
|
+
if (cachedOwnName) return cachedOwnName;
|
|
18
|
+
try {
|
|
19
|
+
const pkg = await fs.readJson(path.join(PACKAGE_ROOT, 'package.json'));
|
|
20
|
+
cachedOwnName = pkg.name || NPM_PACKAGE_NAME;
|
|
21
|
+
} catch {
|
|
22
|
+
cachedOwnName = NPM_PACKAGE_NAME;
|
|
23
|
+
}
|
|
24
|
+
return cachedOwnName;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Record<string, string>} deps merged dependencies from consumer package.json
|
|
29
|
+
* @param {string} consumerPackageName
|
|
30
|
+
*/
|
|
31
|
+
export function hasContextForgeDependency(deps, consumerPackageName, ownName) {
|
|
32
|
+
const keys = [ownName, NPM_PACKAGE_NAME, ...LEGACY_NPM_NAMES];
|
|
33
|
+
if (keys.some((k) => deps[k])) return true;
|
|
34
|
+
return keys.includes(consumerPackageName);
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const CONTEXTFORGE_DIR = '.contextforge';
|
|
4
|
+
|
|
5
|
+
export function getContextForgeRoot(projectRoot) {
|
|
6
|
+
return path.join(projectRoot, CONTEXTFORGE_DIR);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getConfigPath(projectRoot) {
|
|
10
|
+
return path.join(getContextForgeRoot(projectRoot), 'config.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getCacheDir(projectRoot) {
|
|
14
|
+
return path.join(getContextForgeRoot(projectRoot), 'cache');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getLogsDir(projectRoot) {
|
|
18
|
+
return path.join(getContextForgeRoot(projectRoot), 'logs');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getFileIndexPath(projectRoot) {
|
|
22
|
+
return path.join(getCacheDir(projectRoot), 'file-index.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getPidPath(projectRoot) {
|
|
26
|
+
return path.join(getContextForgeRoot(projectRoot), '.watch.pid');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const OUTPUT_FILES = {
|
|
30
|
+
contextMd: 'context.md',
|
|
31
|
+
contextJson: 'context.json',
|
|
32
|
+
architectureJson: 'architecture.json',
|
|
33
|
+
gitInsightsJson: 'git-insights.json',
|
|
34
|
+
bugsReportJson: 'bugs-report.json',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function getOutputPath(projectRoot, filename) {
|
|
38
|
+
return path.join(getContextForgeRoot(projectRoot), filename);
|
|
39
|
+
}
|