@taj-special/dravix-code 1.1.1
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/dist/cli/commands.js +100 -0
- package/dist/cli/index.js +280 -0
- package/dist/cli/repl.js +2833 -0
- package/dist/services/ai.js +206 -0
- package/dist/services/auth.js +67 -0
- package/dist/services/context.js +128 -0
- package/dist/services/conversations.js +62 -0
- package/dist/services/executor.js +628 -0
- package/dist/services/usage.js +60 -0
- package/dist/utils/display.js +258 -0
- package/dist/utils/fileops.js +17 -0
- package/package.json +35 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
6
|
+
'.cache', 'coverage', '__pycache__', '.venv', 'venv', 'env',
|
|
7
|
+
'vendor', '.tox', '.eggs', '.mypy_cache', '.pytest_cache',
|
|
8
|
+
]);
|
|
9
|
+
function safeResolvePath(cwd, userPath) {
|
|
10
|
+
const base = path.resolve(cwd);
|
|
11
|
+
const full = path.resolve(cwd, userPath);
|
|
12
|
+
if (full !== base && !full.startsWith(base + path.sep))
|
|
13
|
+
return null;
|
|
14
|
+
return full;
|
|
15
|
+
}
|
|
16
|
+
function findFile(name, cwd) {
|
|
17
|
+
const target = path.basename(name);
|
|
18
|
+
function search(dir, depth) {
|
|
19
|
+
if (depth > 10)
|
|
20
|
+
return null;
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Check files first (breadth-first)
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (!SKIP_DIRS.has(entry.name) && entry.isFile() && entry.name === target) {
|
|
31
|
+
return path.relative(cwd, path.join(dir, entry.name)).replace(/\\/g, '/');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!SKIP_DIRS.has(entry.name) && entry.isDirectory()) {
|
|
36
|
+
const found = search(path.join(dir, entry.name), depth + 1);
|
|
37
|
+
if (found)
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return search(cwd, 0);
|
|
44
|
+
}
|
|
45
|
+
export function computeDiff(oldText, newText) {
|
|
46
|
+
const o = oldText.split('\n');
|
|
47
|
+
const n = newText.split('\n');
|
|
48
|
+
if (o[o.length - 1] === '')
|
|
49
|
+
o.pop();
|
|
50
|
+
if (n[n.length - 1] === '')
|
|
51
|
+
n.pop();
|
|
52
|
+
const m = o.length, N = n.length;
|
|
53
|
+
// For very large files skip full LCS diff — caller should use snippet diff instead
|
|
54
|
+
if (m > 2500 || N > 2500)
|
|
55
|
+
return [];
|
|
56
|
+
// LCS-based diff (prefix dp)
|
|
57
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(N + 1).fill(0));
|
|
58
|
+
for (let i = 1; i <= m; i++) {
|
|
59
|
+
for (let j = 1; j <= N; j++) {
|
|
60
|
+
if (o[i - 1].trimEnd() === n[j - 1].trimEnd()) {
|
|
61
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Traceback from (m, N)
|
|
69
|
+
const result = [];
|
|
70
|
+
let i = m, j = N;
|
|
71
|
+
while (i > 0 || j > 0) {
|
|
72
|
+
if (i > 0 && j > 0 && o[i - 1].trimEnd() === n[j - 1].trimEnd()) {
|
|
73
|
+
result.push({ kind: 'context', text: n[j - 1].trimEnd(), lineNo: j });
|
|
74
|
+
i--;
|
|
75
|
+
j--;
|
|
76
|
+
}
|
|
77
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
78
|
+
result.push({ kind: 'add', text: n[j - 1].trimEnd(), lineNo: j });
|
|
79
|
+
j--;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
result.push({ kind: 'remove', text: o[i - 1].trimEnd(), lineNo: i });
|
|
83
|
+
i--;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result.reverse();
|
|
87
|
+
// Re-order each non-context block so removes always precede adds
|
|
88
|
+
const sorted = [];
|
|
89
|
+
let idx = 0;
|
|
90
|
+
while (idx < result.length) {
|
|
91
|
+
if (result[idx].kind === 'context') {
|
|
92
|
+
sorted.push(result[idx++]);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const block = [];
|
|
96
|
+
while (idx < result.length && result[idx].kind !== 'context')
|
|
97
|
+
block.push(result[idx++]);
|
|
98
|
+
for (const l of block)
|
|
99
|
+
if (l.kind === 'remove')
|
|
100
|
+
sorted.push(l);
|
|
101
|
+
for (const l of block)
|
|
102
|
+
if (l.kind === 'add')
|
|
103
|
+
sorted.push(l);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return sorted;
|
|
107
|
+
}
|
|
108
|
+
export function parseOps(text) {
|
|
109
|
+
const ops = [];
|
|
110
|
+
const writeRe = /<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g;
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = writeRe.exec(text)) !== null) {
|
|
113
|
+
ops.push({ type: 'write', path: m[1], content: m[2].replace(/^\n/, '') });
|
|
114
|
+
}
|
|
115
|
+
const editRe = /<edit_file\s+path="([^"]+)">[\s\S]*?<find>([\s\S]*?)<\/find>[\s\S]*?<replace>([\s\S]*?)<\/replace>[\s\S]*?<\/edit_file>/g;
|
|
116
|
+
while ((m = editRe.exec(text)) !== null) {
|
|
117
|
+
ops.push({ type: 'edit', path: m[1], find: m[2], replace: m[3] });
|
|
118
|
+
}
|
|
119
|
+
const mkdirRe = /<create_folder\s+path="([^"]+)"\s*(?:\/>|><\/create_folder>)/g;
|
|
120
|
+
while ((m = mkdirRe.exec(text)) !== null) {
|
|
121
|
+
ops.push({ type: 'mkdir', path: m[1] });
|
|
122
|
+
}
|
|
123
|
+
const deleteRe = /<delete_file\s+path="([^"]+)"\s*(?:\/>|><\/delete_file>)/g;
|
|
124
|
+
while ((m = deleteRe.exec(text)) !== null) {
|
|
125
|
+
ops.push({ type: 'delete', path: m[1] });
|
|
126
|
+
}
|
|
127
|
+
const runRe = /<run_command(?:\s+cwd="([^"]*)")?>([\s\S]*?)<\/run_command>/g;
|
|
128
|
+
while ((m = runRe.exec(text)) !== null) {
|
|
129
|
+
ops.push({ type: 'run', command: m[2].trim(), workdir: m[1] || undefined });
|
|
130
|
+
}
|
|
131
|
+
const readRe = /<read_file\s+path="([^"]+)"(?:\s+lines="([^"]+)")?\s*(?:\/>|><\/read_file>)/g;
|
|
132
|
+
while ((m = readRe.exec(text)) !== null) {
|
|
133
|
+
ops.push({ type: 'read_file', path: m[1], lines: m[2] || undefined });
|
|
134
|
+
}
|
|
135
|
+
const readFolderRe = /<read_folder\s+path="([^"]+)"\s*(?:\/>|><\/read_folder>)/g;
|
|
136
|
+
while ((m = readFolderRe.exec(text)) !== null) {
|
|
137
|
+
ops.push({ type: 'read_folder', path: m[1] });
|
|
138
|
+
}
|
|
139
|
+
const searchRe = /<search_code\s+pattern="([^"]+)"(?:\s+path="([^"]*)")?\s*(?:\/>|><\/search_code>)/g;
|
|
140
|
+
while ((m = searchRe.exec(text)) !== null) {
|
|
141
|
+
ops.push({ type: 'search_code', pattern: m[1], path: m[2] || undefined });
|
|
142
|
+
}
|
|
143
|
+
return ops;
|
|
144
|
+
}
|
|
145
|
+
export async function executeSingleOp(op, cwd, onStage) {
|
|
146
|
+
const stage = (msg) => onStage?.(msg);
|
|
147
|
+
// ── write ────────────────────────────────────────────────────────────────
|
|
148
|
+
if (op.type === 'write' && op.path && op.content !== undefined) {
|
|
149
|
+
try {
|
|
150
|
+
const fullPath = safeResolvePath(cwd, op.path);
|
|
151
|
+
if (!fullPath)
|
|
152
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
153
|
+
const exists = fs.existsSync(fullPath);
|
|
154
|
+
stage(exists ? `Writing ${op.path}` : `Creating ${op.path}`);
|
|
155
|
+
const oldContent = exists ? fs.readFileSync(fullPath, 'utf-8') : '';
|
|
156
|
+
if (exists && oldContent === op.content)
|
|
157
|
+
return { type: 'skipped', path: op.path };
|
|
158
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
159
|
+
fs.writeFileSync(fullPath, op.content, 'utf-8');
|
|
160
|
+
if (exists) {
|
|
161
|
+
const diff = computeDiff(oldContent, op.content);
|
|
162
|
+
const tooLarge = diff.length === 0 && oldContent !== op.content;
|
|
163
|
+
return {
|
|
164
|
+
type: 'modified', path: op.path, diff,
|
|
165
|
+
...(tooLarge ? {
|
|
166
|
+
linesBefore: oldContent.split('\n').length,
|
|
167
|
+
linesAfter: op.content.split('\n').length,
|
|
168
|
+
} : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { type: 'created', path: op.path };
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
return { type: 'error', message: `Failed to write ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
175
|
+
}
|
|
176
|
+
// ── edit ─────────────────────────────────────────────────────────────────
|
|
177
|
+
}
|
|
178
|
+
else if (op.type === 'edit' && op.path && op.find !== undefined && op.replace !== undefined) {
|
|
179
|
+
try {
|
|
180
|
+
stage(`Searching ${path.basename(op.path)}`);
|
|
181
|
+
let fullPath = safeResolvePath(cwd, op.path);
|
|
182
|
+
if (!fullPath)
|
|
183
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
184
|
+
let resolvedPath = op.path;
|
|
185
|
+
if (!fs.existsSync(fullPath)) {
|
|
186
|
+
const found = findFile(op.path, cwd);
|
|
187
|
+
if (!found)
|
|
188
|
+
return { type: 'error', message: `File not found: ${op.path}` };
|
|
189
|
+
const foundFull = safeResolvePath(cwd, found);
|
|
190
|
+
if (!foundFull)
|
|
191
|
+
return { type: 'error', message: `Access denied: ${found} is outside the project directory` };
|
|
192
|
+
resolvedPath = found;
|
|
193
|
+
fullPath = foundFull;
|
|
194
|
+
}
|
|
195
|
+
stage(`Reading ${resolvedPath}`);
|
|
196
|
+
const oldContent = fs.readFileSync(fullPath, 'utf-8');
|
|
197
|
+
// ── Robust match: try exact first, then trimEnd-normalized ──────────────
|
|
198
|
+
let actualFind = op.find;
|
|
199
|
+
if (!oldContent.includes(op.find)) {
|
|
200
|
+
// Try matching with trailing-whitespace normalization (most common AI copy error)
|
|
201
|
+
const oldLines = oldContent.split('\n');
|
|
202
|
+
const findLines = op.find.split('\n').map(l => l.trimEnd());
|
|
203
|
+
let matchStart = -1;
|
|
204
|
+
outer: for (let i = 0; i <= oldLines.length - findLines.length; i++) {
|
|
205
|
+
for (let j = 0; j < findLines.length; j++) {
|
|
206
|
+
if (oldLines[i + j].trimEnd() !== findLines[j])
|
|
207
|
+
continue outer;
|
|
208
|
+
}
|
|
209
|
+
matchStart = i;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (matchStart >= 0) {
|
|
213
|
+
// Rebuild actualFind from the REAL file lines so replaceAll works
|
|
214
|
+
actualFind = oldLines.slice(matchStart, matchStart + findLines.length).join('\n');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// Truly not found — give AI a useful hint
|
|
218
|
+
const firstLine = op.find.trim().split('\n')[0].trim().slice(0, 60);
|
|
219
|
+
let hintLine = -1;
|
|
220
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
221
|
+
if (firstLine.length > 4 && oldLines[i].includes(firstLine.slice(0, Math.min(30, firstLine.length)))) {
|
|
222
|
+
hintLine = i + 1;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const hint = hintLine > 0
|
|
227
|
+
? ` First line found near line ${hintLine} but block didn't match — use <read_file path="${resolvedPath}" lines="${Math.max(1, hintLine - 2)}-${hintLine + 20}"/> to get EXACT text, then retry.`
|
|
228
|
+
: ` Use <search_code pattern="${firstLine.slice(0, 40)}"/> to find the text, then <read_file lines="N-M"/> to read that section.`;
|
|
229
|
+
return { type: 'error', message: `Text not found in ${resolvedPath}.${hint}` };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
stage(`Applying ${resolvedPath}`);
|
|
233
|
+
const newContent = oldContent.replaceAll(actualFind, op.replace);
|
|
234
|
+
// No actual change — skip write and return skipped
|
|
235
|
+
if (newContent === oldContent)
|
|
236
|
+
return { type: 'skipped', path: resolvedPath };
|
|
237
|
+
fs.writeFileSync(fullPath, newContent, 'utf-8');
|
|
238
|
+
// Try full diff first (fast for files <= 2500 lines)
|
|
239
|
+
const diff = computeDiff(oldContent, newContent);
|
|
240
|
+
if (diff.length > 0) {
|
|
241
|
+
const adds = diff.filter(d => d.kind === 'add').length;
|
|
242
|
+
const removes = diff.filter(d => d.kind === 'remove').length;
|
|
243
|
+
if (adds > 0 || removes > 0) {
|
|
244
|
+
return { type: 'modified', path: resolvedPath, diff };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Full diff empty or no changes detected — use snippet diff for accurate display
|
|
248
|
+
const snippetDiff = computeDiff(op.find, op.replace);
|
|
249
|
+
if (snippetDiff.length > 0) {
|
|
250
|
+
const adds = snippetDiff.filter(d => d.kind === 'add').length;
|
|
251
|
+
const removes = snippetDiff.filter(d => d.kind === 'remove').length;
|
|
252
|
+
if (adds > 0 || removes > 0) {
|
|
253
|
+
const findIdx = oldContent.indexOf(op.find);
|
|
254
|
+
const lineOffset = findIdx >= 0 ? oldContent.slice(0, findIdx).split('\n').length - 1 : 0;
|
|
255
|
+
const adjusted = snippetDiff.map(d => ({ ...d, lineNo: d.lineNo + lineOffset }));
|
|
256
|
+
return { type: 'modified', path: resolvedPath, diff: adjusted };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
type: 'modified', path: resolvedPath, diff: [],
|
|
261
|
+
linesBefore: oldContent.split('\n').length,
|
|
262
|
+
linesAfter: newContent.split('\n').length,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
return { type: 'error', message: `Failed to edit ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
267
|
+
}
|
|
268
|
+
// ── mkdir ─────────────────────────────────────────────────────────────────
|
|
269
|
+
}
|
|
270
|
+
else if (op.type === 'mkdir' && op.path) {
|
|
271
|
+
try {
|
|
272
|
+
stage(`Creating ${op.path}`);
|
|
273
|
+
const mkdirFull = safeResolvePath(cwd, op.path);
|
|
274
|
+
if (!mkdirFull)
|
|
275
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
276
|
+
fs.mkdirSync(mkdirFull, { recursive: true });
|
|
277
|
+
return { type: 'folder_created', path: op.path };
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
return { type: 'error', message: `Failed to create folder ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
281
|
+
}
|
|
282
|
+
// ── delete ────────────────────────────────────────────────────────────────
|
|
283
|
+
}
|
|
284
|
+
else if (op.type === 'delete' && op.path) {
|
|
285
|
+
try {
|
|
286
|
+
stage(`Searching ${path.basename(op.path)}`);
|
|
287
|
+
let fullPath = safeResolvePath(cwd, op.path);
|
|
288
|
+
if (!fullPath)
|
|
289
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
290
|
+
let resolvedPath = op.path;
|
|
291
|
+
if (!fs.existsSync(fullPath)) {
|
|
292
|
+
const found = findFile(op.path, cwd);
|
|
293
|
+
if (!found)
|
|
294
|
+
return { type: 'error', message: `Not found: ${op.path}` };
|
|
295
|
+
const foundFull = safeResolvePath(cwd, found);
|
|
296
|
+
if (!foundFull)
|
|
297
|
+
return { type: 'error', message: `Access denied: ${found} is outside the project directory` };
|
|
298
|
+
resolvedPath = found;
|
|
299
|
+
fullPath = foundFull;
|
|
300
|
+
}
|
|
301
|
+
stage(`Deleting ${resolvedPath}`);
|
|
302
|
+
const stat = fs.statSync(fullPath);
|
|
303
|
+
if (stat.isDirectory()) {
|
|
304
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
fs.unlinkSync(fullPath);
|
|
308
|
+
}
|
|
309
|
+
return { type: 'deleted', path: resolvedPath };
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
return { type: 'error', message: `Failed to delete ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
313
|
+
}
|
|
314
|
+
// ── run ───────────────────────────────────────────────────────────────────
|
|
315
|
+
}
|
|
316
|
+
else if (op.type === 'run' && op.command) {
|
|
317
|
+
try {
|
|
318
|
+
const runCwd = op.workdir ? path.resolve(cwd, op.workdir) : cwd;
|
|
319
|
+
stage(`Running ${op.command.slice(0, 55)}`);
|
|
320
|
+
// Detect long-running server commands — capture initial output then detach
|
|
321
|
+
const isServerCmd = /(?:^|\s)(?:npm|yarn|pnpm|bun)\s+(?:run\s+(?:dev|start|serve|watch|preview)|start)(?:\s|$)/i.test(op.command)
|
|
322
|
+
|| /(?:^|\s)(?:node|ts-node|tsx|nodemon)\s+/i.test(op.command)
|
|
323
|
+
|| /(?:^|\s)(?:python|python3|uvicorn|gunicorn|flask|django-admin)\s+.*(?:runserver|run|serve|start)/i.test(op.command)
|
|
324
|
+
|| /(?:^|\s)python3?\s+-m\s+(?:http\.server|flask|uvicorn)/i.test(op.command)
|
|
325
|
+
|| /(?:^|\s)npx\s+(?:--yes\s+)?(?:serve|live-server|http-server|@web\/dev-server|vite|parcel)(?:\s|$)/i.test(op.command)
|
|
326
|
+
|| /(?:^|\s)(?:live-server|http-server|serve)\s*/i.test(op.command)
|
|
327
|
+
|| /(?:^|\s)php\s+-S\s+/i.test(op.command)
|
|
328
|
+
|| /(?:^|\s)ruby\s+.*(?:-p\s+\d+|WEBrick|rails\s+s)/i.test(op.command);
|
|
329
|
+
// Auto-add --yes to npx commands so they never block on "Ok to proceed? (y)"
|
|
330
|
+
const safeCmd = /(?:^|\s)npx\s+(?!--yes\b)/i.test(op.command)
|
|
331
|
+
? op.command.replace(/(?<=(?:^|\s))npx(\s+)/i, 'npx --yes$1')
|
|
332
|
+
: op.command;
|
|
333
|
+
// In PowerShell 5.1, 'curl' is an alias for Invoke-WebRequest — use curl.exe for real FTP/HTTP
|
|
334
|
+
const psCmd = safeCmd.replace(/\bcurl\b(?!\.\w)/g, 'curl.exe');
|
|
335
|
+
const out = await new Promise((res, rej) => {
|
|
336
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psCmd], { cwd: runCwd, windowsHide: true });
|
|
337
|
+
child.stdout?.setEncoding('utf-8');
|
|
338
|
+
child.stderr?.setEncoding('utf-8');
|
|
339
|
+
let stdout = '';
|
|
340
|
+
let stderr = '';
|
|
341
|
+
let settled = false;
|
|
342
|
+
const settle = (output, isErr = false) => {
|
|
343
|
+
if (settled)
|
|
344
|
+
return;
|
|
345
|
+
settled = true;
|
|
346
|
+
clearTimeout(killTimer);
|
|
347
|
+
try {
|
|
348
|
+
child.stdout?.destroy();
|
|
349
|
+
child.stderr?.destroy();
|
|
350
|
+
}
|
|
351
|
+
catch { /* ignore */ }
|
|
352
|
+
if (isErr)
|
|
353
|
+
rej(new Error(output));
|
|
354
|
+
else
|
|
355
|
+
res(output);
|
|
356
|
+
};
|
|
357
|
+
// Match Vite/Next/CRA/Express server ready signals (check both stdout+stderr)
|
|
358
|
+
const SUCCESS_PAT = /localhost:\d{2,5}|Local:\s*http|ready in \d|server running|listening on|started on port|\blistening\b.*\d{4}|Serving\s+HTTP|Available\s+on:|http:\/\/127\.0\.0\.1:\d|http:\/\/0\.0\.0\.0:\d|Serving!|Accepting connections|─+\n.*\n.*localhost/i;
|
|
359
|
+
const FATAL_PAT = /EADDRINUSE|EACCES|cannot find module|MODULE_NOT_FOUND/i;
|
|
360
|
+
const checkSuccess = () => {
|
|
361
|
+
const combined = stdout + stderr;
|
|
362
|
+
if (SUCCESS_PAT.test(combined)) {
|
|
363
|
+
child.unref();
|
|
364
|
+
settle(combined.trim());
|
|
365
|
+
}
|
|
366
|
+
else if (FATAL_PAT.test(combined)) {
|
|
367
|
+
settle(combined.trim(), true);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const timeoutMs = isServerCmd ? 25_000 : 600_000;
|
|
371
|
+
const killTimer = setTimeout(() => {
|
|
372
|
+
if (isServerCmd) {
|
|
373
|
+
child.unref();
|
|
374
|
+
const combined = (stdout + stderr).trim();
|
|
375
|
+
settle(combined || 'Server started (running in background)');
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
try {
|
|
379
|
+
child.kill();
|
|
380
|
+
}
|
|
381
|
+
catch { /* ignore */ }
|
|
382
|
+
settle(`Command timed out`, true);
|
|
383
|
+
}
|
|
384
|
+
}, timeoutMs);
|
|
385
|
+
child.stdout?.on('data', (d) => { stdout += d; if (isServerCmd)
|
|
386
|
+
checkSuccess(); });
|
|
387
|
+
child.stderr?.on('data', (d) => { stderr += d; if (isServerCmd)
|
|
388
|
+
checkSuccess(); });
|
|
389
|
+
child.on('close', (code) => {
|
|
390
|
+
if (code !== 0 && !settled) {
|
|
391
|
+
settle([stderr.trim(), stdout.trim()].filter(Boolean).join('\n') || `Exit code ${code}`, true);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
settle((stdout + stderr).trim());
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
child.on('error', (e) => settle(e.message, true));
|
|
398
|
+
});
|
|
399
|
+
return { type: 'run', message: op.command, output: out };
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
403
|
+
return { type: 'error', message: msg.slice(0, 600) };
|
|
404
|
+
}
|
|
405
|
+
// ── read_file ─────────────────────────────────────────────────
|
|
406
|
+
}
|
|
407
|
+
else if (op.type === 'read_file' && op.path) {
|
|
408
|
+
try {
|
|
409
|
+
stage(`Searching ${path.basename(op.path)}`);
|
|
410
|
+
let fullPath = safeResolvePath(cwd, op.path);
|
|
411
|
+
if (!fullPath)
|
|
412
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
413
|
+
let resolvedPath = op.path;
|
|
414
|
+
if (!fs.existsSync(fullPath)) {
|
|
415
|
+
const found = findFile(op.path, cwd);
|
|
416
|
+
if (!found)
|
|
417
|
+
return { type: 'error', message: `File not found: ${op.path}` };
|
|
418
|
+
const foundFull = safeResolvePath(cwd, found);
|
|
419
|
+
if (!foundFull)
|
|
420
|
+
return { type: 'error', message: `Access denied: ${found} is outside the project directory` };
|
|
421
|
+
resolvedPath = found;
|
|
422
|
+
fullPath = foundFull;
|
|
423
|
+
}
|
|
424
|
+
stage(`Reading ${resolvedPath}`);
|
|
425
|
+
const raw = fs.readFileSync(fullPath, 'utf-8');
|
|
426
|
+
const allLines = raw.split('\n');
|
|
427
|
+
const total = allLines.length;
|
|
428
|
+
// Files under 6000 lines → always return full content so AI has complete context
|
|
429
|
+
const LARGE_FILE_THRESHOLD = 6000;
|
|
430
|
+
// Only truly huge files (>6000 lines) get paginated — return first 600 lines + hint
|
|
431
|
+
if (!op.lines && total > LARGE_FILE_THRESHOLD) {
|
|
432
|
+
const PAGE = 600;
|
|
433
|
+
const preview = allLines.slice(0, PAGE).map((l, i) => `${String(i + 1).padStart(4)} │ ${l}`).join('\n');
|
|
434
|
+
const pages = Math.ceil(total / PAGE);
|
|
435
|
+
const output = `[File: ${resolvedPath} — ${total} lines — page 1/${pages}]\n` +
|
|
436
|
+
`⚠ In <find>, copy ONLY the content AFTER " │ " — never include line number or "│"\n` +
|
|
437
|
+
`⚠ To read next page: <read_file path="${resolvedPath}" lines="${PAGE + 1}-${PAGE * 2}"/>\n\n` +
|
|
438
|
+
preview +
|
|
439
|
+
`\n\n... (${total - PAGE} more lines — next page: lines ${PAGE + 1}-${PAGE * 2})`;
|
|
440
|
+
return { type: 'run', message: resolvedPath, output };
|
|
441
|
+
}
|
|
442
|
+
let startLine = 1;
|
|
443
|
+
let endLine = total;
|
|
444
|
+
if (op.lines) {
|
|
445
|
+
const parts = op.lines.split('-').map(s => parseInt(s.trim(), 10));
|
|
446
|
+
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
447
|
+
startLine = Math.max(1, parts[0]);
|
|
448
|
+
endLine = Math.min(total, parts[1]);
|
|
449
|
+
}
|
|
450
|
+
else if (parts.length === 1 && !isNaN(parts[0])) {
|
|
451
|
+
startLine = Math.max(1, parts[0]);
|
|
452
|
+
endLine = Math.min(total, parts[0] + 199);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const slice = allLines.slice(startLine - 1, endLine);
|
|
456
|
+
// Format: " 42 │ code" — the " │ " separator makes it clear numbers are NOT file content
|
|
457
|
+
const numbered = slice.map((line, i) => `${String(startLine + i).padStart(4)} │ ${line}`).join('\n');
|
|
458
|
+
const header = op.lines
|
|
459
|
+
? `[File: ${resolvedPath} — lines ${startLine}-${endLine} of ${total}]\n` +
|
|
460
|
+
`⚠ In <find>, copy ONLY the content AFTER " │ " — never include the line number or "│"\n`
|
|
461
|
+
: `[File: ${resolvedPath} — ${total} lines]\n` +
|
|
462
|
+
`⚠ In <find>, copy ONLY the content AFTER " │ " — never include the line number or "│"\n`;
|
|
463
|
+
return { type: 'run', message: resolvedPath, output: header + numbered };
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
return { type: 'error', message: `Failed to read ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
467
|
+
}
|
|
468
|
+
// ── read_folder ──────────────────────────────────────────────
|
|
469
|
+
}
|
|
470
|
+
else if (op.type === 'read_folder' && op.path) {
|
|
471
|
+
try {
|
|
472
|
+
const fullDir = safeResolvePath(cwd, op.path);
|
|
473
|
+
if (!fullDir)
|
|
474
|
+
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
475
|
+
if (!fs.existsSync(fullDir) || !fs.statSync(fullDir).isDirectory()) {
|
|
476
|
+
return { type: 'error', message: `Folder not found: ${op.path}` };
|
|
477
|
+
}
|
|
478
|
+
stage(`Reading ${op.path}`);
|
|
479
|
+
const entries = [];
|
|
480
|
+
function walkDir(dir, rel, depth) {
|
|
481
|
+
if (depth > 6)
|
|
482
|
+
return;
|
|
483
|
+
let de;
|
|
484
|
+
try {
|
|
485
|
+
de = fs.readdirSync(dir, { withFileTypes: true });
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const sorted = [...de].sort((a, b) => {
|
|
491
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
492
|
+
return -1;
|
|
493
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
494
|
+
return 1;
|
|
495
|
+
return a.name.localeCompare(b.name);
|
|
496
|
+
});
|
|
497
|
+
for (const e of sorted) {
|
|
498
|
+
if (SKIP_DIRS.has(e.name))
|
|
499
|
+
continue;
|
|
500
|
+
if (e.name.startsWith('.') && e.name !== '.env' && e.name !== '.gitignore')
|
|
501
|
+
continue;
|
|
502
|
+
const eRel = rel ? `${rel}/${e.name}` : e.name;
|
|
503
|
+
const eFull = path.join(dir, e.name);
|
|
504
|
+
if (e.isDirectory()) {
|
|
505
|
+
entries.push({ rel: eRel + '/', isDir: true, depth });
|
|
506
|
+
walkDir(eFull, eRel, depth + 1);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
let size;
|
|
510
|
+
try {
|
|
511
|
+
size = fs.statSync(eFull).size;
|
|
512
|
+
}
|
|
513
|
+
catch { /* ignore */ }
|
|
514
|
+
entries.push({ rel: eRel, size, isDir: false, depth });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
walkDir(fullDir, '', 0);
|
|
519
|
+
const fileCount = entries.filter(e => !e.isDir).length;
|
|
520
|
+
const dirCount = entries.filter(e => e.isDir).length;
|
|
521
|
+
const lines = [`Folder: ${op.path} (${fileCount} files, ${dirCount} dirs)`, ''];
|
|
522
|
+
for (const e of entries) {
|
|
523
|
+
const indent = ' '.repeat(e.depth);
|
|
524
|
+
const name = e.rel.split('/').filter(Boolean).pop() ?? e.rel;
|
|
525
|
+
const sizeStr = e.size !== undefined
|
|
526
|
+
? ` (${e.size < 1024 ? e.size + 'B' : e.size < 1_048_576 ? (e.size / 1024).toFixed(1) + 'KB' : (e.size / 1_048_576).toFixed(1) + 'MB'})`
|
|
527
|
+
: '';
|
|
528
|
+
lines.push(`${indent}${name}${e.isDir ? '/' : sizeStr}`);
|
|
529
|
+
}
|
|
530
|
+
return { type: 'run', message: `folder:${op.path}`, output: lines.join('\n') };
|
|
531
|
+
}
|
|
532
|
+
catch (e) {
|
|
533
|
+
return { type: 'error', message: `Failed to read folder ${op.path}: ${e instanceof Error ? e.message : String(e)}` };
|
|
534
|
+
}
|
|
535
|
+
// ── search_code ───────────────────────────────────────────────
|
|
536
|
+
}
|
|
537
|
+
else if (op.type === 'search_code' && op.pattern) {
|
|
538
|
+
try {
|
|
539
|
+
const searchRoot = safeResolvePath(cwd, op.path ?? '.');
|
|
540
|
+
if (!searchRoot)
|
|
541
|
+
return { type: 'error', message: `Access denied: path is outside the project directory` };
|
|
542
|
+
stage(`Searching ${op.pattern.slice(0, 45)}`);
|
|
543
|
+
const TEXT_EXTS = new Set([
|
|
544
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
545
|
+
'.py', '.go', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.rs', '.rb', '.php',
|
|
546
|
+
'.vue', '.svelte', '.html', '.css', '.scss', '.sass', '.less',
|
|
547
|
+
'.json', '.yaml', '.yml', '.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh',
|
|
548
|
+
'.env', '.sql', '.graphql', '.prisma', '.proto', '.swift', '.kt', '.dart',
|
|
549
|
+
]);
|
|
550
|
+
const matches = [];
|
|
551
|
+
const MAX_MATCHES = 200;
|
|
552
|
+
const patternLower = op.pattern.toLowerCase();
|
|
553
|
+
function walkSearch(dir) {
|
|
554
|
+
if (matches.length >= MAX_MATCHES)
|
|
555
|
+
return;
|
|
556
|
+
let de;
|
|
557
|
+
try {
|
|
558
|
+
de = fs.readdirSync(dir, { withFileTypes: true });
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
for (const entry of de) {
|
|
564
|
+
if (matches.length >= MAX_MATCHES)
|
|
565
|
+
return;
|
|
566
|
+
if (SKIP_DIRS.has(entry.name))
|
|
567
|
+
continue;
|
|
568
|
+
if (entry.name.startsWith('.') && entry.name !== '.env' && entry.name !== '.gitignore')
|
|
569
|
+
continue;
|
|
570
|
+
const eFull = path.join(dir, entry.name);
|
|
571
|
+
if (entry.isDirectory()) {
|
|
572
|
+
walkSearch(eFull);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
if (!TEXT_EXTS.has(path.extname(entry.name).toLowerCase()))
|
|
576
|
+
continue;
|
|
577
|
+
let content;
|
|
578
|
+
try {
|
|
579
|
+
content = fs.readFileSync(eFull, 'utf-8');
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const fileLines = content.split('\n');
|
|
585
|
+
const relFile = path.relative(cwd, eFull).replace(/\\/g, '/');
|
|
586
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
587
|
+
if (matches.length >= MAX_MATCHES)
|
|
588
|
+
break;
|
|
589
|
+
if (fileLines[i].toLowerCase().includes(patternLower)) {
|
|
590
|
+
matches.push({ file: relFile, line: i + 1, content: fileLines[i].trim().slice(0, 200) });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
walkSearch(searchRoot);
|
|
597
|
+
if (matches.length === 0) {
|
|
598
|
+
return { type: 'run', message: `search:${op.pattern}`, output: `No matches found for "${op.pattern}"` };
|
|
599
|
+
}
|
|
600
|
+
const byFile = new Map();
|
|
601
|
+
for (const fm of matches) {
|
|
602
|
+
if (!byFile.has(fm.file))
|
|
603
|
+
byFile.set(fm.file, []);
|
|
604
|
+
byFile.get(fm.file).push(fm);
|
|
605
|
+
}
|
|
606
|
+
const outLines = [];
|
|
607
|
+
for (const [file, fileMatches] of byFile) {
|
|
608
|
+
for (const fm of fileMatches) {
|
|
609
|
+
outLines.push(`${file}:${fm.line}: ${fm.content}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (matches.length >= MAX_MATCHES)
|
|
613
|
+
outLines.push(`... truncated at ${MAX_MATCHES} matches`);
|
|
614
|
+
outLines.push(`\nTotal: ${matches.length} match${matches.length !== 1 ? 'es' : ''} in ${byFile.size} file${byFile.size !== 1 ? 's' : ''}`);
|
|
615
|
+
return { type: 'run', message: `search:${op.pattern}`, output: outLines.join('\n') };
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
return { type: 'error', message: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { type: 'error', message: 'Unknown operation' };
|
|
622
|
+
}
|
|
623
|
+
export async function executeOps(ops, cwd) {
|
|
624
|
+
const results = [];
|
|
625
|
+
for (const op of ops)
|
|
626
|
+
results.push(await executeSingleOp(op, cwd));
|
|
627
|
+
return results;
|
|
628
|
+
}
|