@hbarefoot/engram 1.0.0 → 1.2.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/README.md +174 -425
- package/bin/engram.js +275 -90
- package/dashboard/dist/assets/index-D0xT6oKC.css +1 -0
- package/dashboard/dist/assets/index-D3bysGhj.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 +19 -10
- package/src/embed/index.js +116 -16
- package/src/extract/secrets.js +1 -1
- package/src/import/index.js +259 -0
- package/src/import/parsers/claude.js +208 -0
- package/src/import/parsers/cursorrules.js +153 -0
- package/src/import/parsers/env.js +129 -0
- package/src/import/parsers/git.js +194 -0
- package/src/import/parsers/obsidian.js +219 -0
- package/src/import/parsers/package.js +177 -0
- package/src/import/parsers/shell.js +256 -0
- package/src/import/parsers/ssh.js +132 -0
- package/src/import/wizard.js +280 -0
- package/src/server/mcp.js +5 -1
- package/src/server/rest.js +152 -1
- 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,208 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { validateContent } from '../../extract/secrets.js';
|
|
4
|
+
|
|
5
|
+
const CLAUDE_LOCATIONS = [
|
|
6
|
+
'.claude',
|
|
7
|
+
'CLAUDE.md',
|
|
8
|
+
'.claude/settings.json',
|
|
9
|
+
'.claude/commands'
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect if .claude project files exist
|
|
14
|
+
*/
|
|
15
|
+
export function detect(options = {}) {
|
|
16
|
+
const cwd = options.cwd || process.cwd();
|
|
17
|
+
|
|
18
|
+
for (const loc of CLAUDE_LOCATIONS) {
|
|
19
|
+
const fullPath = path.resolve(cwd, loc);
|
|
20
|
+
if (fs.existsSync(fullPath)) {
|
|
21
|
+
return { found: true, path: path.resolve(cwd) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { found: false, path: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse .claude project files into memory candidates
|
|
30
|
+
*/
|
|
31
|
+
export async function parse(options = {}) {
|
|
32
|
+
const result = { source: 'claude', memories: [], skipped: [], warnings: [] };
|
|
33
|
+
const cwd = options.cwd || process.cwd();
|
|
34
|
+
|
|
35
|
+
// Parse CLAUDE.md
|
|
36
|
+
const claudeMdPath = path.resolve(cwd, 'CLAUDE.md');
|
|
37
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
38
|
+
parseClaudeMd(claudeMdPath, result);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parse .claude/settings.json
|
|
42
|
+
const settingsPath = path.resolve(cwd, '.claude/settings.json');
|
|
43
|
+
if (fs.existsSync(settingsPath)) {
|
|
44
|
+
parseClaudeSettings(settingsPath, result);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse .claude/commands directory
|
|
48
|
+
const commandsPath = path.resolve(cwd, '.claude/commands');
|
|
49
|
+
if (fs.existsSync(commandsPath) && fs.statSync(commandsPath).isDirectory()) {
|
|
50
|
+
parseClaudeCommands(commandsPath, result);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (result.memories.length === 0) {
|
|
54
|
+
result.warnings.push('No .claude project files found or no extractable content');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse CLAUDE.md into structured memories
|
|
62
|
+
*/
|
|
63
|
+
function parseClaudeMd(filePath, result) {
|
|
64
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
|
|
67
|
+
let currentSection = null;
|
|
68
|
+
let currentBlock = [];
|
|
69
|
+
|
|
70
|
+
for (const rawLine of lines) {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
|
|
73
|
+
// Section headers
|
|
74
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
75
|
+
if (currentBlock.length > 0) {
|
|
76
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
77
|
+
currentBlock = [];
|
|
78
|
+
}
|
|
79
|
+
currentSection = line.replace(/^#{1,3}\s+/, '').trim();
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Skip empty lines
|
|
84
|
+
if (!line) {
|
|
85
|
+
if (currentBlock.length > 0) {
|
|
86
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
87
|
+
currentBlock = [];
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Skip code blocks (fences)
|
|
93
|
+
if (line.startsWith('```')) continue;
|
|
94
|
+
|
|
95
|
+
// Collect content
|
|
96
|
+
const cleaned = line.replace(/^[-*]\s+/, '').trim();
|
|
97
|
+
if (cleaned.length > 15) {
|
|
98
|
+
currentBlock.push(cleaned);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (currentBlock.length > 0) {
|
|
103
|
+
flushClaudeBlock(currentBlock, currentSection, result);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function flushClaudeBlock(lines, section, result) {
|
|
108
|
+
// Group related lines into single memories when they're short
|
|
109
|
+
const grouped = [];
|
|
110
|
+
let current = [];
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.length > 100) {
|
|
114
|
+
// Long lines become individual memories
|
|
115
|
+
if (current.length > 0) {
|
|
116
|
+
grouped.push(current.join('. '));
|
|
117
|
+
current = [];
|
|
118
|
+
}
|
|
119
|
+
grouped.push(line);
|
|
120
|
+
} else {
|
|
121
|
+
current.push(line);
|
|
122
|
+
if (current.join('. ').length > 150) {
|
|
123
|
+
grouped.push(current.join('. '));
|
|
124
|
+
current = [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (current.length > 0) grouped.push(current.join('. '));
|
|
129
|
+
|
|
130
|
+
for (const text of grouped) {
|
|
131
|
+
const validation = validateContent(text, { autoRedact: false });
|
|
132
|
+
if (!validation.valid) {
|
|
133
|
+
result.skipped.push({ content: text, reason: 'Contains secrets' });
|
|
134
|
+
result.warnings.push('Skipped CLAUDE.md section containing sensitive data');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const content = section
|
|
139
|
+
? `Claude project context (${section}): ${text}`
|
|
140
|
+
: `Claude project context: ${text}`;
|
|
141
|
+
|
|
142
|
+
if (content.length < 25) continue;
|
|
143
|
+
|
|
144
|
+
result.memories.push({
|
|
145
|
+
content,
|
|
146
|
+
category: 'fact',
|
|
147
|
+
entity: null,
|
|
148
|
+
confidence: 0.9,
|
|
149
|
+
tags: ['claude-project', 'project-context'],
|
|
150
|
+
source: 'import:claude'
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse .claude/settings.json
|
|
157
|
+
*/
|
|
158
|
+
function parseClaudeSettings(filePath, result) {
|
|
159
|
+
try {
|
|
160
|
+
const settings = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
161
|
+
|
|
162
|
+
if (settings.permissions?.allow) {
|
|
163
|
+
const content = `Claude project allows these tool permissions: ${settings.permissions.allow.join(', ')}`;
|
|
164
|
+
result.memories.push({
|
|
165
|
+
content,
|
|
166
|
+
category: 'fact',
|
|
167
|
+
entity: 'claude-code',
|
|
168
|
+
confidence: 0.9,
|
|
169
|
+
tags: ['claude-project', 'permissions'],
|
|
170
|
+
source: 'import:claude'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
result.warnings.push('Failed to parse .claude/settings.json');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Parse .claude/commands directory
|
|
180
|
+
*/
|
|
181
|
+
function parseClaudeCommands(dirPath, result) {
|
|
182
|
+
try {
|
|
183
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
|
|
184
|
+
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const name = path.basename(file, '.md');
|
|
187
|
+
const content = `Claude project has custom command: /${name}`;
|
|
188
|
+
result.memories.push({
|
|
189
|
+
content,
|
|
190
|
+
category: 'fact',
|
|
191
|
+
entity: 'claude-code',
|
|
192
|
+
confidence: 0.9,
|
|
193
|
+
tags: ['claude-project', 'custom-commands'],
|
|
194
|
+
source: 'import:claude'
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
result.warnings.push('Failed to read .claude/commands directory');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const meta = {
|
|
203
|
+
name: 'claude',
|
|
204
|
+
label: '.claude files',
|
|
205
|
+
description: 'Project context and instructions from Claude Code',
|
|
206
|
+
category: 'fact',
|
|
207
|
+
locations: CLAUDE_LOCATIONS
|
|
208
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
* @returns {Object} Detection result
|
|
19
|
+
*/
|
|
20
|
+
export function detect(options = {}) {
|
|
21
|
+
const cwd = options.cwd || process.cwd();
|
|
22
|
+
|
|
23
|
+
for (const loc of CURSORRULES_LOCATIONS) {
|
|
24
|
+
const fullPath = path.resolve(cwd, loc);
|
|
25
|
+
if (fs.existsSync(fullPath)) {
|
|
26
|
+
return { found: true, path: fullPath };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Also check home directory
|
|
31
|
+
const homePath = path.join(os.homedir(), '.cursorrules');
|
|
32
|
+
if (fs.existsSync(homePath)) {
|
|
33
|
+
return { found: true, path: homePath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { found: false, path: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse .cursorrules file into memory candidates
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} [options.cwd] - Working directory
|
|
43
|
+
* @param {string} [options.filePath] - Explicit file path
|
|
44
|
+
* @returns {Object} Parse result with memories, skipped, warnings
|
|
45
|
+
*/
|
|
46
|
+
export async function parse(options = {}) {
|
|
47
|
+
const result = { source: 'cursorrules', memories: [], skipped: [], warnings: [] };
|
|
48
|
+
|
|
49
|
+
const filePath = options.filePath || (() => {
|
|
50
|
+
const detected = detect(options);
|
|
51
|
+
return detected.path;
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
55
|
+
result.warnings.push('No .cursorrules file found');
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
60
|
+
if (!content) {
|
|
61
|
+
result.warnings.push('.cursorrules file is empty');
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
let currentSection = null;
|
|
67
|
+
let currentBlock = [];
|
|
68
|
+
|
|
69
|
+
for (const rawLine of lines) {
|
|
70
|
+
const line = rawLine.trim();
|
|
71
|
+
|
|
72
|
+
// Skip empty lines between blocks
|
|
73
|
+
if (!line) {
|
|
74
|
+
if (currentBlock.length > 0) {
|
|
75
|
+
flushBlock(currentBlock, currentSection, result);
|
|
76
|
+
currentBlock = [];
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Detect section headers (markdown-style or YAML-style)
|
|
82
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
83
|
+
if (currentBlock.length > 0) {
|
|
84
|
+
flushBlock(currentBlock, currentSection, result);
|
|
85
|
+
currentBlock = [];
|
|
86
|
+
}
|
|
87
|
+
currentSection = line.replace(/^#{1,3}\s+/, '').trim();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// YAML-style top-level key
|
|
92
|
+
if (/^[a-zA-Z_-]+:\s*$/.test(line)) {
|
|
93
|
+
if (currentBlock.length > 0) {
|
|
94
|
+
flushBlock(currentBlock, currentSection, result);
|
|
95
|
+
currentBlock = [];
|
|
96
|
+
}
|
|
97
|
+
currentSection = line.replace(':', '').trim();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Collect content lines (strip list markers)
|
|
102
|
+
const cleaned = line.replace(/^[-*]\s+/, '').replace(/^-\s+/, '').trim();
|
|
103
|
+
if (cleaned.length > 10) {
|
|
104
|
+
currentBlock.push(cleaned);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Flush remaining block
|
|
109
|
+
if (currentBlock.length > 0) {
|
|
110
|
+
flushBlock(currentBlock, currentSection, result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convert a block of lines into memory candidates
|
|
118
|
+
*/
|
|
119
|
+
function flushBlock(lines, section, result) {
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
// Run secret detection
|
|
122
|
+
const validation = validateContent(line, { autoRedact: false });
|
|
123
|
+
if (!validation.valid) {
|
|
124
|
+
result.skipped.push({ content: line, reason: 'Contains secrets' });
|
|
125
|
+
result.warnings.push('Skipped rule containing sensitive data');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Build memory content with section context
|
|
130
|
+
const content = section
|
|
131
|
+
? `Cursor rule (${section}): ${line}`
|
|
132
|
+
: `Cursor rule: ${line}`;
|
|
133
|
+
|
|
134
|
+
if (content.length < 20) continue;
|
|
135
|
+
|
|
136
|
+
result.memories.push({
|
|
137
|
+
content,
|
|
138
|
+
category: 'preference',
|
|
139
|
+
entity: null,
|
|
140
|
+
confidence: 0.85,
|
|
141
|
+
tags: ['cursorrules', 'coding-style'],
|
|
142
|
+
source: 'import:cursorrules'
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const meta = {
|
|
148
|
+
name: 'cursorrules',
|
|
149
|
+
label: '.cursorrules',
|
|
150
|
+
description: 'Coding preferences and style rules from Cursor',
|
|
151
|
+
category: 'preference',
|
|
152
|
+
locations: CURSORRULES_LOCATIONS
|
|
153
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect if .env.example exists
|
|
6
|
+
*/
|
|
7
|
+
export function detect(options = {}) {
|
|
8
|
+
const candidates = ['.env.example', '.env.sample', '.env.template'];
|
|
9
|
+
const baseDir = options.cwd || process.cwd();
|
|
10
|
+
|
|
11
|
+
for (const name of candidates) {
|
|
12
|
+
const filePath = path.resolve(baseDir, name);
|
|
13
|
+
if (fs.existsSync(filePath)) {
|
|
14
|
+
return { found: true, path: filePath };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { found: false, path: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse .env.example for environment variable NAMES only.
|
|
23
|
+
* NEVER extracts actual values — only variable names and comments.
|
|
24
|
+
*/
|
|
25
|
+
export async function parse(options = {}) {
|
|
26
|
+
const result = { source: 'env', memories: [], skipped: [], warnings: [] };
|
|
27
|
+
|
|
28
|
+
const filePath = options.filePath || (() => {
|
|
29
|
+
const detected = detect(options);
|
|
30
|
+
return detected.path;
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
34
|
+
result.warnings.push('No .env.example file found');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
|
|
41
|
+
const variables = [];
|
|
42
|
+
let currentComment = null;
|
|
43
|
+
|
|
44
|
+
for (const rawLine of lines) {
|
|
45
|
+
const line = rawLine.trim();
|
|
46
|
+
|
|
47
|
+
// Track comments as context
|
|
48
|
+
if (line.startsWith('#')) {
|
|
49
|
+
currentComment = line.replace(/^#+\s*/, '').trim();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!line) {
|
|
54
|
+
currentComment = null;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse KEY=value (only extract KEY)
|
|
59
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
|
|
60
|
+
if (match) {
|
|
61
|
+
variables.push({
|
|
62
|
+
name: match[1],
|
|
63
|
+
comment: currentComment
|
|
64
|
+
});
|
|
65
|
+
currentComment = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (variables.length === 0) {
|
|
70
|
+
result.warnings.push('.env.example has no variables');
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Group variables by prefix for cleaner memories
|
|
75
|
+
const groups = {};
|
|
76
|
+
for (const v of variables) {
|
|
77
|
+
const prefix = v.name.split('_')[0];
|
|
78
|
+
if (!groups[prefix]) groups[prefix] = [];
|
|
79
|
+
groups[prefix].push(v);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create summary memory with all variable names
|
|
83
|
+
const varNames = variables.map(v => v.name);
|
|
84
|
+
result.memories.push({
|
|
85
|
+
content: `Project environment variables (names only): ${varNames.join(', ')}`,
|
|
86
|
+
category: 'fact',
|
|
87
|
+
entity: 'environment',
|
|
88
|
+
confidence: 0.9,
|
|
89
|
+
tags: ['env-example', 'configuration'],
|
|
90
|
+
source: 'import:env'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Create grouped memories for larger variable sets
|
|
94
|
+
for (const [prefix, vars] of Object.entries(groups)) {
|
|
95
|
+
if (vars.length >= 2) {
|
|
96
|
+
const names = vars.map(v => v.name).join(', ');
|
|
97
|
+
const comments = vars
|
|
98
|
+
.filter(v => v.comment)
|
|
99
|
+
.map(v => `${v.name}: ${v.comment}`)
|
|
100
|
+
.join('; ');
|
|
101
|
+
|
|
102
|
+
const content = comments
|
|
103
|
+
? `Environment config group "${prefix}": ${names}. ${comments}`
|
|
104
|
+
: `Environment config group "${prefix}": ${names}`;
|
|
105
|
+
|
|
106
|
+
result.memories.push({
|
|
107
|
+
content,
|
|
108
|
+
category: 'fact',
|
|
109
|
+
entity: 'environment',
|
|
110
|
+
confidence: 0.85,
|
|
111
|
+
tags: ['env-example', 'configuration'],
|
|
112
|
+
source: 'import:env'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Security warning
|
|
118
|
+
result.warnings.push('Only variable NAMES were extracted — no values or secrets');
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const meta = {
|
|
124
|
+
name: 'env',
|
|
125
|
+
label: '.env.example',
|
|
126
|
+
description: 'Environment variable names only (NEVER values)',
|
|
127
|
+
category: 'fact',
|
|
128
|
+
locations: ['.env.example', '.env.sample', '.env.template']
|
|
129
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
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 GITCONFIG_LOCATIONS = [
|
|
7
|
+
path.join(os.homedir(), '.gitconfig'),
|
|
8
|
+
path.join(os.homedir(), '.config/git/config')
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect if git config exists
|
|
13
|
+
*/
|
|
14
|
+
export function detect() {
|
|
15
|
+
for (const loc of GITCONFIG_LOCATIONS) {
|
|
16
|
+
if (fs.existsSync(loc)) {
|
|
17
|
+
return { found: true, path: loc };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { found: false, path: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse ~/.gitconfig into memory candidates
|
|
25
|
+
*/
|
|
26
|
+
export async function parse(options = {}) {
|
|
27
|
+
const result = { source: 'git', memories: [], skipped: [], warnings: [] };
|
|
28
|
+
|
|
29
|
+
const filePath = options.filePath || (() => {
|
|
30
|
+
const detected = detect();
|
|
31
|
+
return detected.path;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
35
|
+
result.warnings.push('No .gitconfig found');
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
40
|
+
const sections = parseIniFile(content);
|
|
41
|
+
|
|
42
|
+
// Extract user info
|
|
43
|
+
if (sections.user) {
|
|
44
|
+
if (sections.user.name) {
|
|
45
|
+
result.memories.push({
|
|
46
|
+
content: `Git user name: ${sections.user.name}`,
|
|
47
|
+
category: 'fact',
|
|
48
|
+
entity: 'git',
|
|
49
|
+
confidence: 1.0,
|
|
50
|
+
tags: ['gitconfig', 'user-info'],
|
|
51
|
+
source: 'import:git'
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (sections.user.email) {
|
|
56
|
+
// Validate — email itself isn't a secret, but check anyway
|
|
57
|
+
const validation = validateContent(sections.user.email, { autoRedact: false });
|
|
58
|
+
if (validation.valid) {
|
|
59
|
+
result.memories.push({
|
|
60
|
+
content: `Git user email: ${sections.user.email}`,
|
|
61
|
+
category: 'fact',
|
|
62
|
+
entity: 'git',
|
|
63
|
+
confidence: 1.0,
|
|
64
|
+
tags: ['gitconfig', 'user-info'],
|
|
65
|
+
source: 'import:git'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (sections.user.signingkey) {
|
|
71
|
+
// Don't extract the actual key, just note that signing is configured
|
|
72
|
+
result.memories.push({
|
|
73
|
+
content: 'Git commit signing is configured',
|
|
74
|
+
category: 'fact',
|
|
75
|
+
entity: 'git',
|
|
76
|
+
confidence: 1.0,
|
|
77
|
+
tags: ['gitconfig', 'security'],
|
|
78
|
+
source: 'import:git'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extract core settings
|
|
84
|
+
if (sections.core) {
|
|
85
|
+
const coreSettings = [];
|
|
86
|
+
if (sections.core.editor) coreSettings.push(`editor: ${sections.core.editor}`);
|
|
87
|
+
if (sections.core.autocrlf) coreSettings.push(`autocrlf: ${sections.core.autocrlf}`);
|
|
88
|
+
if (sections.core.pager) coreSettings.push(`pager: ${sections.core.pager}`);
|
|
89
|
+
|
|
90
|
+
if (coreSettings.length > 0) {
|
|
91
|
+
result.memories.push({
|
|
92
|
+
content: `Git core settings: ${coreSettings.join(', ')}`,
|
|
93
|
+
category: 'preference',
|
|
94
|
+
entity: 'git',
|
|
95
|
+
confidence: 0.9,
|
|
96
|
+
tags: ['gitconfig', 'preferences'],
|
|
97
|
+
source: 'import:git'
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract aliases
|
|
103
|
+
if (sections.alias) {
|
|
104
|
+
const aliases = Object.entries(sections.alias);
|
|
105
|
+
if (aliases.length > 0) {
|
|
106
|
+
const aliasStr = aliases
|
|
107
|
+
.slice(0, 10) // Limit to 10 most relevant
|
|
108
|
+
.map(([name, cmd]) => `${name}="${cmd}"`)
|
|
109
|
+
.join(', ');
|
|
110
|
+
|
|
111
|
+
result.memories.push({
|
|
112
|
+
content: `Git aliases: ${aliasStr}`,
|
|
113
|
+
category: 'pattern',
|
|
114
|
+
entity: 'git',
|
|
115
|
+
confidence: 0.85,
|
|
116
|
+
tags: ['gitconfig', 'aliases'],
|
|
117
|
+
source: 'import:git'
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extract default branch
|
|
123
|
+
if (sections.init?.defaultBranch) {
|
|
124
|
+
result.memories.push({
|
|
125
|
+
content: `Git default branch: ${sections.init.defaultBranch}`,
|
|
126
|
+
category: 'preference',
|
|
127
|
+
entity: 'git',
|
|
128
|
+
confidence: 1.0,
|
|
129
|
+
tags: ['gitconfig', 'preferences'],
|
|
130
|
+
source: 'import:git'
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Extract merge/diff tool preferences
|
|
135
|
+
if (sections.merge?.tool || sections.diff?.tool) {
|
|
136
|
+
const tools = [];
|
|
137
|
+
if (sections.merge?.tool) tools.push(`merge tool: ${sections.merge.tool}`);
|
|
138
|
+
if (sections.diff?.tool) tools.push(`diff tool: ${sections.diff.tool}`);
|
|
139
|
+
|
|
140
|
+
result.memories.push({
|
|
141
|
+
content: `Git ${tools.join(', ')}`,
|
|
142
|
+
category: 'preference',
|
|
143
|
+
entity: 'git',
|
|
144
|
+
confidence: 0.9,
|
|
145
|
+
tags: ['gitconfig', 'tools'],
|
|
146
|
+
source: 'import:git'
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Simple INI file parser for git config format
|
|
155
|
+
*/
|
|
156
|
+
function parseIniFile(content) {
|
|
157
|
+
const sections = {};
|
|
158
|
+
let currentSection = null;
|
|
159
|
+
|
|
160
|
+
for (const rawLine of content.split('\n')) {
|
|
161
|
+
const line = rawLine.trim();
|
|
162
|
+
|
|
163
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
164
|
+
|
|
165
|
+
// Section header [section] or [section "subsection"]
|
|
166
|
+
const sectionMatch = line.match(/^\[([^\s\]]+)(?:\s+"([^"]+)")?\]$/);
|
|
167
|
+
if (sectionMatch) {
|
|
168
|
+
const sectionName = sectionMatch[2]
|
|
169
|
+
? `${sectionMatch[1]}.${sectionMatch[2]}`
|
|
170
|
+
: sectionMatch[1];
|
|
171
|
+
currentSection = sectionName;
|
|
172
|
+
if (!sections[currentSection]) sections[currentSection] = {};
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Key = value
|
|
177
|
+
if (currentSection) {
|
|
178
|
+
const kvMatch = line.match(/^(\w+)\s*=\s*(.*)$/);
|
|
179
|
+
if (kvMatch) {
|
|
180
|
+
sections[currentSection][kvMatch[1]] = kvMatch[2].trim();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return sections;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const meta = {
|
|
189
|
+
name: 'git',
|
|
190
|
+
label: 'git config',
|
|
191
|
+
description: 'Name, email, aliases, and preferences from git configuration',
|
|
192
|
+
category: 'fact',
|
|
193
|
+
locations: GITCONFIG_LOCATIONS.map(p => p.replace(os.homedir(), '~'))
|
|
194
|
+
};
|