brains-cli 0.1.0 → 1.0.2
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/.env.example +2 -0
- package/README.md +64 -81
- package/bin/brains.js +23 -1
- package/package.json +3 -1
- package/src/api.js +115 -0
- package/src/commands/config.js +236 -0
- package/src/commands/install.js +3 -1
- package/src/commands/run.js +477 -317
- package/src/config.js +237 -0
- package/src/providers/anthropic.js +83 -0
- package/src/providers/openai-compat.js +96 -0
- package/src/providers/registry.js +125 -0
- package/src/utils/files.js +239 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Extensions to include when scanning for review
|
|
7
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
8
|
+
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
|
9
|
+
'.py', '.rb', '.go', '.rs', '.java', '.kt',
|
|
10
|
+
'.c', '.cpp', '.h', '.hpp', '.cs',
|
|
11
|
+
'.php', '.swift', '.dart',
|
|
12
|
+
'.vue', '.svelte', '.astro',
|
|
13
|
+
'.css', '.scss', '.less',
|
|
14
|
+
'.html', '.htm',
|
|
15
|
+
'.json', '.yaml', '.yml', '.toml',
|
|
16
|
+
'.sql', '.graphql', '.gql',
|
|
17
|
+
'.sh', '.bash', '.zsh',
|
|
18
|
+
'.md', '.mdx',
|
|
19
|
+
'.prisma', '.proto',
|
|
20
|
+
'.env.example', '.env.local.example',
|
|
21
|
+
'Dockerfile', 'docker-compose.yml',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Directories to always skip
|
|
25
|
+
const IGNORE_DIRS = new Set([
|
|
26
|
+
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
|
|
27
|
+
'dist', 'build', 'out', '.output',
|
|
28
|
+
'coverage', '.nyc_output',
|
|
29
|
+
'__pycache__', '.pytest_cache', 'venv', '.venv',
|
|
30
|
+
'vendor', 'target',
|
|
31
|
+
'.cache', '.parcel-cache', '.turbo',
|
|
32
|
+
'.brains',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Files to always skip
|
|
36
|
+
const IGNORE_FILES = new Set([
|
|
37
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
38
|
+
'.DS_Store', 'Thumbs.db',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const MAX_FILE_SIZE = 100 * 1024; // 100KB per file
|
|
42
|
+
const MAX_TOTAL_SIZE = 500 * 1024; // 500KB total to send to Claude
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively scan a directory for source files.
|
|
46
|
+
* Respects .gitignore patterns (basic) and built-in ignore lists.
|
|
47
|
+
*/
|
|
48
|
+
function scanSourceFiles(dir, maxFiles = 50) {
|
|
49
|
+
const files = [];
|
|
50
|
+
|
|
51
|
+
function walk(currentDir, depth) {
|
|
52
|
+
if (depth > 8 || files.length >= maxFiles) return;
|
|
53
|
+
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return; // permission denied, etc.
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (files.length >= maxFiles) break;
|
|
63
|
+
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
66
|
+
walk(path.join(currentDir, entry.name), depth + 1);
|
|
67
|
+
}
|
|
68
|
+
} else if (entry.isFile()) {
|
|
69
|
+
if (IGNORE_FILES.has(entry.name)) continue;
|
|
70
|
+
|
|
71
|
+
const ext = path.extname(entry.name);
|
|
72
|
+
const isSourceFile = SOURCE_EXTENSIONS.has(ext) ||
|
|
73
|
+
SOURCE_EXTENSIONS.has(entry.name) ||
|
|
74
|
+
(!ext && entry.name === 'Makefile') ||
|
|
75
|
+
(!ext && entry.name === 'Dockerfile');
|
|
76
|
+
|
|
77
|
+
if (isSourceFile) {
|
|
78
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
79
|
+
try {
|
|
80
|
+
const stat = fs.statSync(fullPath);
|
|
81
|
+
if (stat.size <= MAX_FILE_SIZE && stat.size > 0) {
|
|
82
|
+
files.push({
|
|
83
|
+
path: path.relative(dir, fullPath).replace(/\\/g, '/'),
|
|
84
|
+
fullPath,
|
|
85
|
+
size: stat.size,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// skip unreadable files
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
walk(dir, 0);
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read scanned files and format them for Claude review.
|
|
102
|
+
* Returns { content: string, fileCount: number, totalSize: number }
|
|
103
|
+
*/
|
|
104
|
+
function readFilesForReview(dir, maxFiles = 50) {
|
|
105
|
+
const files = scanSourceFiles(dir, maxFiles);
|
|
106
|
+
|
|
107
|
+
let totalSize = 0;
|
|
108
|
+
const parts = [];
|
|
109
|
+
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
if (totalSize >= MAX_TOTAL_SIZE) break;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const content = fs.readFileSync(file.fullPath, 'utf-8');
|
|
115
|
+
totalSize += content.length;
|
|
116
|
+
parts.push(`### ${file.path}\n\`\`\`\n${content}\n\`\`\``);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// skip unreadable
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content: parts.join('\n\n'),
|
|
124
|
+
fileCount: parts.length,
|
|
125
|
+
totalSize,
|
|
126
|
+
files: files.map((f) => f.path),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse Claude's response for FILE: blocks and extract file contents.
|
|
132
|
+
* Supports formats:
|
|
133
|
+
* FILE: path/to/file.ext
|
|
134
|
+
* ```lang
|
|
135
|
+
* content
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* Returns array of { filePath, content, language }
|
|
139
|
+
*/
|
|
140
|
+
function parseFileBlocks(response) {
|
|
141
|
+
const files = [];
|
|
142
|
+
const regex = /FILE:\s*(.+?)\s*\n```(\w*)\n([\s\S]*?)```/g;
|
|
143
|
+
|
|
144
|
+
let match;
|
|
145
|
+
while ((match = regex.exec(response)) !== null) {
|
|
146
|
+
files.push({
|
|
147
|
+
filePath: match[1].trim(),
|
|
148
|
+
language: match[2] || '',
|
|
149
|
+
content: match[3],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fallback: try to find ```lang:filepath patterns
|
|
154
|
+
if (files.length === 0) {
|
|
155
|
+
const altRegex = /```(\w+):(.+?)\n([\s\S]*?)```/g;
|
|
156
|
+
while ((match = altRegex.exec(response)) !== null) {
|
|
157
|
+
files.push({
|
|
158
|
+
filePath: match[2].trim(),
|
|
159
|
+
language: match[1] || '',
|
|
160
|
+
content: match[3],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return files;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write parsed file blocks to disk.
|
|
170
|
+
* Creates directories as needed.
|
|
171
|
+
* Returns { written: string[], errors: string[] }
|
|
172
|
+
*/
|
|
173
|
+
function writeFileBlocks(baseDir, fileBlocks) {
|
|
174
|
+
const written = [];
|
|
175
|
+
const errors = [];
|
|
176
|
+
|
|
177
|
+
for (const block of fileBlocks) {
|
|
178
|
+
const fullPath = path.join(baseDir, block.filePath);
|
|
179
|
+
const dir = path.dirname(fullPath);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (!fs.existsSync(dir)) {
|
|
183
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
184
|
+
}
|
|
185
|
+
fs.writeFileSync(fullPath, block.content);
|
|
186
|
+
written.push(block.filePath);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
errors.push(`${block.filePath}: ${e.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { written, errors };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate a tree view of written files.
|
|
197
|
+
*/
|
|
198
|
+
function generateTreeView(files, rootName) {
|
|
199
|
+
if (files.length === 0) return '';
|
|
200
|
+
|
|
201
|
+
const lines = [`${rootName}/`];
|
|
202
|
+
|
|
203
|
+
// Build a tree structure
|
|
204
|
+
const tree = {};
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
const parts = file.split('/');
|
|
207
|
+
let current = tree;
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
if (!current[part]) current[part] = {};
|
|
210
|
+
current = current[part];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function render(node, prefix, isLast) {
|
|
215
|
+
const entries = Object.entries(node);
|
|
216
|
+
entries.forEach(([name, children], i) => {
|
|
217
|
+
const last = i === entries.length - 1;
|
|
218
|
+
const connector = last ? '└── ' : '├── ';
|
|
219
|
+
const childPrefix = last ? ' ' : '│ ';
|
|
220
|
+
const isDir = Object.keys(children).length > 0;
|
|
221
|
+
|
|
222
|
+
lines.push(`${prefix}${connector}${name}${isDir ? '/' : ''}`);
|
|
223
|
+
if (isDir) {
|
|
224
|
+
render(children, prefix + childPrefix, last);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
render(tree, '', false);
|
|
230
|
+
return lines.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
scanSourceFiles,
|
|
235
|
+
readFilesForReview,
|
|
236
|
+
parseFileBlocks,
|
|
237
|
+
writeFileBlocks,
|
|
238
|
+
generateTreeView,
|
|
239
|
+
};
|