@hbarefoot/engram 1.1.0 → 1.3.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/bin/engram.js +278 -90
- package/dashboard/dist/assets/index-CIMIyJGP.css +1 -0
- package/dashboard/dist/assets/index-CK-bEXRL.js +45 -0
- package/dashboard/dist/engram-logo.png +0 -0
- package/dashboard/dist/favicon.png +0 -0
- package/dashboard/dist/index.html +3 -3
- package/package.json +9 -2
- package/src/embed/index.js +116 -16
- package/src/import/index.js +259 -0
- package/src/import/parsers/claude.js +260 -0
- package/src/import/parsers/cursorrules.js +192 -0
- package/src/import/parsers/env.js +170 -0
- package/src/import/parsers/git.js +237 -0
- package/src/import/parsers/obsidian.js +234 -0
- package/src/import/parsers/package.js +224 -0
- package/src/import/parsers/shell.js +286 -0
- package/src/import/parsers/ssh.js +179 -0
- package/src/import/wizard.js +289 -0
- package/src/server/mcp.js +5 -1
- package/src/server/rest.js +172 -10
- package/src/utils/format.js +224 -0
- package/dashboard/dist/assets/index-BHkLa5w_.css +0 -1
- package/dashboard/dist/assets/index-D9QR_Cnu.js +0 -45
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { validateContent } from '../../extract/secrets.js';
|
|
5
|
+
|
|
6
|
+
const CLAUDE_LOCATIONS = [
|
|
7
|
+
'.claude',
|
|
8
|
+
'CLAUDE.md',
|
|
9
|
+
'.claude/settings.json',
|
|
10
|
+
'.claude/commands'
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect if .claude project files exist
|
|
15
|
+
* @param {Object} [options] - Detection options
|
|
16
|
+
* @param {string} [options.cwd] - Working directory to scan
|
|
17
|
+
* @param {string[]} [options.paths] - Additional directories to scan
|
|
18
|
+
* @returns {{ found: boolean, path: string|null, paths: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
export function detect(options = {}) {
|
|
21
|
+
const cwd = options.cwd || process.cwd();
|
|
22
|
+
const foundPaths = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
|
|
25
|
+
// Check cwd
|
|
26
|
+
for (const loc of CLAUDE_LOCATIONS) {
|
|
27
|
+
const fullPath = path.resolve(cwd, loc);
|
|
28
|
+
if (fs.existsSync(fullPath)) {
|
|
29
|
+
const dir = path.resolve(cwd);
|
|
30
|
+
if (!seen.has(dir)) {
|
|
31
|
+
seen.add(dir);
|
|
32
|
+
foundPaths.push(dir);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Always check home directory for user-level .claude
|
|
39
|
+
const homeDir = os.homedir();
|
|
40
|
+
for (const loc of CLAUDE_LOCATIONS) {
|
|
41
|
+
const fullPath = path.resolve(homeDir, loc);
|
|
42
|
+
if (fs.existsSync(fullPath)) {
|
|
43
|
+
const dir = path.resolve(homeDir);
|
|
44
|
+
if (!seen.has(dir)) {
|
|
45
|
+
seen.add(dir);
|
|
46
|
+
foundPaths.push(dir);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check additional paths
|
|
53
|
+
if (options.paths && Array.isArray(options.paths)) {
|
|
54
|
+
for (const extraDir of options.paths) {
|
|
55
|
+
for (const loc of CLAUDE_LOCATIONS) {
|
|
56
|
+
const fullPath = path.resolve(extraDir, loc);
|
|
57
|
+
if (fs.existsSync(fullPath)) {
|
|
58
|
+
const dir = path.resolve(extraDir);
|
|
59
|
+
if (!seen.has(dir)) {
|
|
60
|
+
seen.add(dir);
|
|
61
|
+
foundPaths.push(dir);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
found: foundPaths.length > 0,
|
|
71
|
+
path: foundPaths[0] || null,
|
|
72
|
+
paths: foundPaths
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse .claude project files into memory candidates
|
|
78
|
+
* Scans cwd, home directory, and any additional paths
|
|
79
|
+
*/
|
|
80
|
+
export async function parse(options = {}) {
|
|
81
|
+
const result = { source: 'claude', memories: [], skipped: [], warnings: [] };
|
|
82
|
+
const detected = detect(options);
|
|
83
|
+
const dirsToScan = detected.paths.length > 0 ? detected.paths : [options.cwd || process.cwd()];
|
|
84
|
+
|
|
85
|
+
for (const dir of dirsToScan) {
|
|
86
|
+
// Parse CLAUDE.md
|
|
87
|
+
const claudeMdPath = path.resolve(dir, 'CLAUDE.md');
|
|
88
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
89
|
+
parseClaudeMd(claudeMdPath, result);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Parse .claude/settings.json
|
|
93
|
+
const settingsPath = path.resolve(dir, '.claude/settings.json');
|
|
94
|
+
if (fs.existsSync(settingsPath)) {
|
|
95
|
+
parseClaudeSettings(settingsPath, result);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parse .claude/commands directory
|
|
99
|
+
const commandsPath = path.resolve(dir, '.claude/commands');
|
|
100
|
+
if (fs.existsSync(commandsPath) && fs.statSync(commandsPath).isDirectory()) {
|
|
101
|
+
parseClaudeCommands(commandsPath, result);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (result.memories.length === 0) {
|
|
106
|
+
result.warnings.push('No .claude project files found or no extractable content');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse CLAUDE.md into structured memories
|
|
114
|
+
*/
|
|
115
|
+
function parseClaudeMd(filePath, result) {
|
|
116
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
117
|
+
const lines = content.split('\n');
|
|
118
|
+
|
|
119
|
+
let currentSection = null;
|
|
120
|
+
let currentBlock = [];
|
|
121
|
+
|
|
122
|
+
for (const rawLine of lines) {
|
|
123
|
+
const line = rawLine.trim();
|
|
124
|
+
|
|
125
|
+
// Section headers
|
|
126
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
127
|
+
if (currentBlock.length > 0) {
|
|
128
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
129
|
+
currentBlock = [];
|
|
130
|
+
}
|
|
131
|
+
currentSection = line.replace(/^#{1,3}\s+/, '').trim();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Skip empty lines
|
|
136
|
+
if (!line) {
|
|
137
|
+
if (currentBlock.length > 0) {
|
|
138
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
139
|
+
currentBlock = [];
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Skip code blocks (fences)
|
|
145
|
+
if (line.startsWith('```')) continue;
|
|
146
|
+
|
|
147
|
+
// Collect content
|
|
148
|
+
const cleaned = line.replace(/^[-*]\s+/, '').trim();
|
|
149
|
+
if (cleaned.length > 15) {
|
|
150
|
+
currentBlock.push(cleaned);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (currentBlock.length > 0) {
|
|
155
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function flushClaudeBlock(lines, section, result) {
|
|
160
|
+
// Group related lines into single memories when they're short
|
|
161
|
+
const grouped = [];
|
|
162
|
+
let current = [];
|
|
163
|
+
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
if (line.length > 100) {
|
|
166
|
+
// Long lines become individual memories
|
|
167
|
+
if (current.length > 0) {
|
|
168
|
+
grouped.push(current.join('. '));
|
|
169
|
+
current = [];
|
|
170
|
+
}
|
|
171
|
+
grouped.push(line);
|
|
172
|
+
} else {
|
|
173
|
+
current.push(line);
|
|
174
|
+
if (current.join('. ').length > 150) {
|
|
175
|
+
grouped.push(current.join('. '));
|
|
176
|
+
current = [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (current.length > 0) grouped.push(current.join('. '));
|
|
181
|
+
|
|
182
|
+
for (const text of grouped) {
|
|
183
|
+
const validation = validateContent(text, { autoRedact: false });
|
|
184
|
+
if (!validation.valid) {
|
|
185
|
+
result.skipped.push({ content: text, reason: 'Contains secrets' });
|
|
186
|
+
result.warnings.push('Skipped CLAUDE.md section containing sensitive data');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const content = section
|
|
191
|
+
? `Claude project context (${section}): ${text}`
|
|
192
|
+
: `Claude project context: ${text}`;
|
|
193
|
+
|
|
194
|
+
if (content.length < 25) continue;
|
|
195
|
+
|
|
196
|
+
result.memories.push({
|
|
197
|
+
content,
|
|
198
|
+
category: 'fact',
|
|
199
|
+
entity: null,
|
|
200
|
+
confidence: 0.9,
|
|
201
|
+
tags: ['claude-project', 'project-context'],
|
|
202
|
+
source: 'import:claude'
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse .claude/settings.json
|
|
209
|
+
*/
|
|
210
|
+
function parseClaudeSettings(filePath, result) {
|
|
211
|
+
try {
|
|
212
|
+
const settings = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
213
|
+
|
|
214
|
+
if (settings.permissions?.allow) {
|
|
215
|
+
const content = `Claude project allows these tool permissions: ${settings.permissions.allow.join(', ')}`;
|
|
216
|
+
result.memories.push({
|
|
217
|
+
content,
|
|
218
|
+
category: 'fact',
|
|
219
|
+
entity: 'claude-code',
|
|
220
|
+
confidence: 0.9,
|
|
221
|
+
tags: ['claude-project', 'permissions'],
|
|
222
|
+
source: 'import:claude'
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
result.warnings.push('Failed to parse .claude/settings.json');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Parse .claude/commands directory
|
|
232
|
+
*/
|
|
233
|
+
function parseClaudeCommands(dirPath, result) {
|
|
234
|
+
try {
|
|
235
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
|
|
236
|
+
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
const name = path.basename(file, '.md');
|
|
239
|
+
const content = `Claude project has custom command: /${name}`;
|
|
240
|
+
result.memories.push({
|
|
241
|
+
content,
|
|
242
|
+
category: 'fact',
|
|
243
|
+
entity: 'claude-code',
|
|
244
|
+
confidence: 0.9,
|
|
245
|
+
tags: ['claude-project', 'custom-commands'],
|
|
246
|
+
source: 'import:claude'
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
result.warnings.push('Failed to read .claude/commands directory');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const meta = {
|
|
255
|
+
name: 'claude',
|
|
256
|
+
label: '.claude files',
|
|
257
|
+
description: 'Project context and instructions from Claude Code',
|
|
258
|
+
category: 'fact',
|
|
259
|
+
locations: CLAUDE_LOCATIONS
|
|
260
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { validateContent } from '../../extract/secrets.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Known locations for .cursorrules files
|
|
8
|
+
*/
|
|
9
|
+
const CURSORRULES_LOCATIONS = [
|
|
10
|
+
'.cursorrules',
|
|
11
|
+
'.cursor/rules'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect if .cursorrules exists
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {string} [options.cwd] - Working directory to scan
|
|
18
|
+
* @param {string[]} [options.paths] - Additional directories to scan
|
|
19
|
+
* @returns {{ found: boolean, path: string|null, paths: string[] }}
|
|
20
|
+
*/
|
|
21
|
+
export function detect(options = {}) {
|
|
22
|
+
const cwd = options.cwd || process.cwd();
|
|
23
|
+
const foundPaths = [];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
|
|
26
|
+
// Check cwd
|
|
27
|
+
for (const loc of CURSORRULES_LOCATIONS) {
|
|
28
|
+
const fullPath = path.resolve(cwd, loc);
|
|
29
|
+
if (!seen.has(fullPath) && fs.existsSync(fullPath)) {
|
|
30
|
+
seen.add(fullPath);
|
|
31
|
+
foundPaths.push(fullPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check home directory
|
|
36
|
+
const homePath = path.resolve(os.homedir(), '.cursorrules');
|
|
37
|
+
if (!seen.has(homePath) && fs.existsSync(homePath)) {
|
|
38
|
+
seen.add(homePath);
|
|
39
|
+
foundPaths.push(homePath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check additional paths
|
|
43
|
+
if (options.paths && Array.isArray(options.paths)) {
|
|
44
|
+
for (const dir of options.paths) {
|
|
45
|
+
for (const loc of CURSORRULES_LOCATIONS) {
|
|
46
|
+
const fullPath = path.resolve(dir, loc);
|
|
47
|
+
if (!seen.has(fullPath) && fs.existsSync(fullPath)) {
|
|
48
|
+
seen.add(fullPath);
|
|
49
|
+
foundPaths.push(fullPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
found: foundPaths.length > 0,
|
|
57
|
+
path: foundPaths[0] || null,
|
|
58
|
+
paths: foundPaths
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse .cursorrules file into memory candidates
|
|
64
|
+
* @param {Object} options
|
|
65
|
+
* @param {string} [options.cwd] - Working directory
|
|
66
|
+
* @param {string} [options.filePath] - Explicit file path
|
|
67
|
+
* @returns {Object} Parse result with memories, skipped, warnings
|
|
68
|
+
*/
|
|
69
|
+
export async function parse(options = {}) {
|
|
70
|
+
const result = { source: 'cursorrules', memories: [], skipped: [], warnings: [] };
|
|
71
|
+
|
|
72
|
+
// If explicit filePath, parse just that one (backward compat)
|
|
73
|
+
if (options.filePath) {
|
|
74
|
+
parseOneCursorrules(options.filePath, result);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const detected = detect(options);
|
|
79
|
+
if (!detected.found) {
|
|
80
|
+
result.warnings.push('No .cursorrules file found');
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const filePath of detected.paths) {
|
|
85
|
+
parseOneCursorrules(filePath, result);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a single .cursorrules file and accumulate results
|
|
93
|
+
*/
|
|
94
|
+
function parseOneCursorrules(filePath, result) {
|
|
95
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
96
|
+
result.warnings.push('No .cursorrules file found');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
101
|
+
if (!content) {
|
|
102
|
+
result.warnings.push('.cursorrules file is empty');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lines = content.split('\n');
|
|
107
|
+
let currentSection = null;
|
|
108
|
+
let currentBlock = [];
|
|
109
|
+
|
|
110
|
+
for (const rawLine of lines) {
|
|
111
|
+
const line = rawLine.trim();
|
|
112
|
+
|
|
113
|
+
// Skip empty lines between blocks
|
|
114
|
+
if (!line) {
|
|
115
|
+
if (currentBlock.length > 0) {
|
|
116
|
+
flushBlock(currentBlock, currentSection, result);
|
|
117
|
+
currentBlock = [];
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Detect section headers (markdown-style or YAML-style)
|
|
123
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
124
|
+
if (currentBlock.length > 0) {
|
|
125
|
+
flushBlock(currentBlock, currentSection, result);
|
|
126
|
+
currentBlock = [];
|
|
127
|
+
}
|
|
128
|
+
currentSection = line.replace(/^#{1,3}\s+/, '').trim();
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// YAML-style top-level key
|
|
133
|
+
if (/^[a-zA-Z_-]+:\s*$/.test(line)) {
|
|
134
|
+
if (currentBlock.length > 0) {
|
|
135
|
+
flushBlock(currentBlock, currentSection, result);
|
|
136
|
+
currentBlock = [];
|
|
137
|
+
}
|
|
138
|
+
currentSection = line.replace(':', '').trim();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Collect content lines (strip list markers)
|
|
143
|
+
const cleaned = line.replace(/^[-*]\s+/, '').replace(/^-\s+/, '').trim();
|
|
144
|
+
if (cleaned.length > 10) {
|
|
145
|
+
currentBlock.push(cleaned);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Flush remaining block
|
|
150
|
+
if (currentBlock.length > 0) {
|
|
151
|
+
flushBlock(currentBlock, currentSection, result);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Convert a block of lines into memory candidates
|
|
157
|
+
*/
|
|
158
|
+
function flushBlock(lines, section, result) {
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
// Run secret detection
|
|
161
|
+
const validation = validateContent(line, { autoRedact: false });
|
|
162
|
+
if (!validation.valid) {
|
|
163
|
+
result.skipped.push({ content: line, reason: 'Contains secrets' });
|
|
164
|
+
result.warnings.push('Skipped rule containing sensitive data');
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Build memory content with section context
|
|
169
|
+
const content = section
|
|
170
|
+
? `Cursor rule (${section}): ${line}`
|
|
171
|
+
: `Cursor rule: ${line}`;
|
|
172
|
+
|
|
173
|
+
if (content.length < 20) continue;
|
|
174
|
+
|
|
175
|
+
result.memories.push({
|
|
176
|
+
content,
|
|
177
|
+
category: 'preference',
|
|
178
|
+
entity: null,
|
|
179
|
+
confidence: 0.85,
|
|
180
|
+
tags: ['cursorrules', 'coding-style'],
|
|
181
|
+
source: 'import:cursorrules'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const meta = {
|
|
187
|
+
name: 'cursorrules',
|
|
188
|
+
label: '.cursorrules',
|
|
189
|
+
description: 'Coding preferences and style rules from Cursor',
|
|
190
|
+
category: 'preference',
|
|
191
|
+
locations: CURSORRULES_LOCATIONS
|
|
192
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect if .env.example exists
|
|
6
|
+
* @param {Object} [options] - Detection options
|
|
7
|
+
* @param {string} [options.cwd] - Working directory to scan
|
|
8
|
+
* @param {string[]} [options.paths] - Additional directories to scan
|
|
9
|
+
* @returns {{ found: boolean, path: string|null, paths: string[] }}
|
|
10
|
+
*/
|
|
11
|
+
export function detect(options = {}) {
|
|
12
|
+
const candidates = ['.env.example', '.env.sample', '.env.template'];
|
|
13
|
+
const baseDir = options.cwd || process.cwd();
|
|
14
|
+
const foundPaths = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
|
|
17
|
+
// Check cwd
|
|
18
|
+
for (const name of candidates) {
|
|
19
|
+
const filePath = path.resolve(baseDir, name);
|
|
20
|
+
if (!seen.has(filePath) && fs.existsSync(filePath)) {
|
|
21
|
+
seen.add(filePath);
|
|
22
|
+
foundPaths.push(filePath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check additional paths
|
|
27
|
+
if (options.paths && Array.isArray(options.paths)) {
|
|
28
|
+
for (const dir of options.paths) {
|
|
29
|
+
for (const name of candidates) {
|
|
30
|
+
const filePath = path.resolve(dir, name);
|
|
31
|
+
if (!seen.has(filePath) && fs.existsSync(filePath)) {
|
|
32
|
+
seen.add(filePath);
|
|
33
|
+
foundPaths.push(filePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
found: foundPaths.length > 0,
|
|
41
|
+
path: foundPaths[0] || null,
|
|
42
|
+
paths: foundPaths
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse .env.example for environment variable NAMES only.
|
|
48
|
+
* NEVER extracts actual values — only variable names and comments.
|
|
49
|
+
*/
|
|
50
|
+
export async function parse(options = {}) {
|
|
51
|
+
const result = { source: 'env', memories: [], skipped: [], warnings: [] };
|
|
52
|
+
|
|
53
|
+
// If explicit filePath, parse just that one (backward compat)
|
|
54
|
+
if (options.filePath) {
|
|
55
|
+
parseOneEnv(options.filePath, result);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const detected = detect(options);
|
|
60
|
+
if (!detected.found) {
|
|
61
|
+
result.warnings.push('No .env.example file found');
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const filePath of detected.paths) {
|
|
66
|
+
parseOneEnv(filePath, result);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse a single .env.example file and accumulate results
|
|
74
|
+
*/
|
|
75
|
+
function parseOneEnv(filePath, result) {
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
result.warnings.push(`No .env.example file found at ${filePath}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
|
|
84
|
+
const variables = [];
|
|
85
|
+
let currentComment = null;
|
|
86
|
+
|
|
87
|
+
for (const rawLine of lines) {
|
|
88
|
+
const line = rawLine.trim();
|
|
89
|
+
|
|
90
|
+
// Track comments as context
|
|
91
|
+
if (line.startsWith('#')) {
|
|
92
|
+
currentComment = line.replace(/^#+\s*/, '').trim();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!line) {
|
|
97
|
+
currentComment = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Parse KEY=value (only extract KEY)
|
|
102
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
|
|
103
|
+
if (match) {
|
|
104
|
+
variables.push({
|
|
105
|
+
name: match[1],
|
|
106
|
+
comment: currentComment
|
|
107
|
+
});
|
|
108
|
+
currentComment = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (variables.length === 0) {
|
|
113
|
+
result.warnings.push('.env.example has no variables');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Group variables by prefix for cleaner memories
|
|
118
|
+
const groups = {};
|
|
119
|
+
for (const v of variables) {
|
|
120
|
+
const prefix = v.name.split('_')[0];
|
|
121
|
+
if (!groups[prefix]) groups[prefix] = [];
|
|
122
|
+
groups[prefix].push(v);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create summary memory with all variable names
|
|
126
|
+
const varNames = variables.map(v => v.name);
|
|
127
|
+
result.memories.push({
|
|
128
|
+
content: `Project environment variables (names only): ${varNames.join(', ')}`,
|
|
129
|
+
category: 'fact',
|
|
130
|
+
entity: 'environment',
|
|
131
|
+
confidence: 0.9,
|
|
132
|
+
tags: ['env-example', 'configuration'],
|
|
133
|
+
source: 'import:env'
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Create grouped memories for larger variable sets
|
|
137
|
+
for (const [prefix, vars] of Object.entries(groups)) {
|
|
138
|
+
if (vars.length >= 2) {
|
|
139
|
+
const names = vars.map(v => v.name).join(', ');
|
|
140
|
+
const comments = vars
|
|
141
|
+
.filter(v => v.comment)
|
|
142
|
+
.map(v => `${v.name}: ${v.comment}`)
|
|
143
|
+
.join('; ');
|
|
144
|
+
|
|
145
|
+
const content = comments
|
|
146
|
+
? `Environment config group "${prefix}": ${names}. ${comments}`
|
|
147
|
+
: `Environment config group "${prefix}": ${names}`;
|
|
148
|
+
|
|
149
|
+
result.memories.push({
|
|
150
|
+
content,
|
|
151
|
+
category: 'fact',
|
|
152
|
+
entity: 'environment',
|
|
153
|
+
confidence: 0.85,
|
|
154
|
+
tags: ['env-example', 'configuration'],
|
|
155
|
+
source: 'import:env'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Security warning
|
|
161
|
+
result.warnings.push('Only variable NAMES were extracted — no values or secrets');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const meta = {
|
|
165
|
+
name: 'env',
|
|
166
|
+
label: '.env.example',
|
|
167
|
+
description: 'Environment variable names only (NEVER values)',
|
|
168
|
+
category: 'fact',
|
|
169
|
+
locations: ['.env.example', '.env.sample', '.env.template']
|
|
170
|
+
};
|