centaurus-cli 2.9.3 → 2.9.4
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-adapter.d.ts +72 -8
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +607 -141
- package/dist/cli-adapter.js.map +1 -1
- package/dist/commands/CommandParser.d.ts +1 -1
- package/dist/commands/CommandParser.d.ts.map +1 -1
- package/dist/commands/CommandParser.js +113 -0
- package/dist/commands/CommandParser.js.map +1 -1
- package/dist/config/slash-commands.d.ts +2 -0
- package/dist/config/slash-commands.d.ts.map +1 -1
- package/dist/config/slash-commands.js +28 -0
- package/dist/config/slash-commands.js.map +1 -1
- package/dist/context/context-manager.d.ts +1 -1
- package/dist/context/context-manager.d.ts.map +1 -1
- package/dist/context/context-manager.js +3 -1
- package/dist/context/context-manager.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts +9 -0
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +99 -10
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts +20 -0
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +129 -1
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/subshell-handler.d.ts +15 -0
- package/dist/context/subshell-handler.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/services/ai-service-client.d.ts.map +1 -1
- package/dist/services/ai-service-client.js +33 -11
- package/dist/services/ai-service-client.js.map +1 -1
- package/dist/services/api-client.js +1 -1
- package/dist/services/api-client.js.map +1 -1
- package/dist/services/warpify-detector.d.ts +43 -0
- package/dist/services/warpify-detector.d.ts.map +1 -0
- package/dist/services/warpify-detector.js +203 -0
- package/dist/services/warpify-detector.js.map +1 -0
- package/dist/services/workflow-storage.d.ts +72 -0
- package/dist/services/workflow-storage.d.ts.map +1 -0
- package/dist/services/workflow-storage.js +239 -0
- package/dist/services/workflow-storage.js.map +1 -0
- package/dist/tools/command.d.ts.map +1 -1
- package/dist/tools/command.js +14 -0
- package/dist/tools/command.js.map +1 -1
- package/dist/tools/enter-remote-session.d.ts +13 -0
- package/dist/tools/enter-remote-session.d.ts.map +1 -0
- package/dist/tools/enter-remote-session.js +226 -0
- package/dist/tools/enter-remote-session.js.map +1 -0
- package/dist/tools/find-files.d.ts.map +1 -1
- package/dist/tools/find-files.js +9 -2
- package/dist/tools/find-files.js.map +1 -1
- package/dist/tools/grep-search.d.ts +104 -31
- package/dist/tools/grep-search.d.ts.map +1 -1
- package/dist/tools/grep-search.js +699 -430
- package/dist/tools/grep-search.js.map +1 -1
- package/dist/tools/workflow-tool.d.ts +11 -0
- package/dist/tools/workflow-tool.d.ts.map +1 -0
- package/dist/tools/workflow-tool.js +87 -0
- package/dist/tools/workflow-tool.js.map +1 -0
- package/dist/types/workflow.d.ts +110 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/dist/types/workflow.js +8 -0
- package/dist/types/workflow.js.map +1 -0
- package/dist/ui/components/App.d.ts +10 -1
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +117 -4
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/Breadcrumbs.d.ts +4 -3
- package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/ui/components/Breadcrumbs.js +60 -54
- package/dist/ui/components/Breadcrumbs.js.map +1 -1
- package/dist/ui/components/ConnectionStatusMessage.js +2 -2
- package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +1 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +168 -2
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/InteractiveShell.d.ts +2 -0
- package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
- package/dist/ui/components/InteractiveShell.js +13 -3
- package/dist/ui/components/InteractiveShell.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +164 -25
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/ui/components/WorkflowCreatorScreen.d.ts +25 -0
- package/dist/ui/components/WorkflowCreatorScreen.d.ts.map +1 -0
- package/dist/ui/components/WorkflowCreatorScreen.js +164 -0
- package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -0
- package/dist/utils/input-classifier.d.ts.map +1 -1
- package/dist/utils/input-classifier.js +2 -1
- package/dist/utils/input-classifier.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,87 +1,164 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
1
2
|
import { execFile } from 'child_process';
|
|
2
3
|
import { promisify } from 'util';
|
|
3
4
|
import * as path from 'path';
|
|
4
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as readline from 'readline';
|
|
5
7
|
const execFileAsync = promisify(execFile);
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// GREP SEARCH TOOL CLASS - CROSS-PLATFORM ROBUST IMPLEMENTATION
|
|
10
|
+
// ============================================================================
|
|
6
11
|
/**
|
|
7
|
-
* GrepSearchTool -
|
|
12
|
+
* GrepSearchTool - Cross-platform text search with multiple backends
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
* 1.
|
|
11
|
-
* 2.
|
|
12
|
-
* 3.
|
|
13
|
-
* 4.
|
|
14
|
+
* Backend priority:
|
|
15
|
+
* 1. ripgrep (fastest, cross-platform)
|
|
16
|
+
* 2. grep (Linux/macOS native)
|
|
17
|
+
* 3. PowerShell Select-String (Windows, good Unicode support)
|
|
18
|
+
* 4. Pure Node.js (universal fallback, no dependencies)
|
|
19
|
+
*
|
|
20
|
+
* Features:
|
|
21
|
+
* - Streaming output to handle large repos
|
|
22
|
+
* - Early abort when MAX_MATCHES reached
|
|
23
|
+
* - Proper encoding handling (UTF-8 with fallback)
|
|
24
|
+
* - CRLF normalization
|
|
25
|
+
* - Windows path support (drive letters, UNC)
|
|
14
26
|
*/
|
|
15
27
|
export class GrepSearchTool {
|
|
16
|
-
static
|
|
28
|
+
static DEFAULT_MAX_MATCHES = 50;
|
|
17
29
|
static MAX_LINE_LENGTH = 300;
|
|
18
30
|
static CONTEXT_LINES = 2;
|
|
31
|
+
static SEARCH_TIMEOUT = 30000;
|
|
32
|
+
// Cache tool availability to avoid repeated checks
|
|
33
|
+
static toolCache = new Map();
|
|
19
34
|
/**
|
|
20
|
-
* Check if
|
|
35
|
+
* Check if a command is available
|
|
21
36
|
*/
|
|
22
|
-
async
|
|
37
|
+
async hasCommand(cmd, args = ['--version']) {
|
|
38
|
+
const cacheKey = `${cmd}:${args.join(',')}`;
|
|
39
|
+
if (GrepSearchTool.toolCache.has(cacheKey)) {
|
|
40
|
+
return GrepSearchTool.toolCache.get(cacheKey);
|
|
41
|
+
}
|
|
23
42
|
try {
|
|
24
|
-
// Add timeout to prevent hanging
|
|
25
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000));
|
|
26
43
|
await Promise.race([
|
|
27
|
-
execFileAsync(
|
|
28
|
-
|
|
44
|
+
execFileAsync(cmd, args, { timeout: 2000 }),
|
|
45
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
|
|
29
46
|
]);
|
|
47
|
+
GrepSearchTool.toolCache.set(cacheKey, true);
|
|
30
48
|
return true;
|
|
31
49
|
}
|
|
32
50
|
catch {
|
|
51
|
+
GrepSearchTool.toolCache.set(cacheKey, false);
|
|
33
52
|
return false;
|
|
34
53
|
}
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
async hasRipgrep() {
|
|
56
|
+
return this.hasCommand('rg', ['--version']);
|
|
57
|
+
}
|
|
39
58
|
async hasGrep() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
timeoutPromise
|
|
46
|
-
]);
|
|
59
|
+
return this.hasCommand('grep', ['--version']);
|
|
60
|
+
}
|
|
61
|
+
async hasPowerShell() {
|
|
62
|
+
// Try pwsh first (PowerShell Core), then powershell (Windows PowerShell)
|
|
63
|
+
if (await this.hasCommand('pwsh', ['-Version']))
|
|
47
64
|
return true;
|
|
65
|
+
if (process.platform === 'win32') {
|
|
66
|
+
return this.hasCommand('powershell', ['-Version']);
|
|
48
67
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get the PowerShell executable name
|
|
72
|
+
*/
|
|
73
|
+
async getPowerShellExe() {
|
|
74
|
+
if (await this.hasCommand('pwsh', ['-Version']))
|
|
75
|
+
return 'pwsh';
|
|
76
|
+
return 'powershell';
|
|
52
77
|
}
|
|
53
78
|
/**
|
|
54
|
-
* Normalize file path to
|
|
79
|
+
* Normalize file path to forward slashes
|
|
55
80
|
*/
|
|
56
81
|
normalizePath(filePath) {
|
|
57
82
|
return filePath.replace(/\\/g, '/');
|
|
58
83
|
}
|
|
59
84
|
/**
|
|
60
|
-
*
|
|
61
|
-
|
|
85
|
+
* Truncate long lines
|
|
86
|
+
*/
|
|
87
|
+
truncateLine(line) {
|
|
88
|
+
if (line.length <= GrepSearchTool.MAX_LINE_LENGTH) {
|
|
89
|
+
return line;
|
|
90
|
+
}
|
|
91
|
+
return line.substring(0, GrepSearchTool.MAX_LINE_LENGTH) + ' [truncated]';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Normalize line endings (CRLF -> LF)
|
|
95
|
+
*/
|
|
96
|
+
normalizeLineEndings(text) {
|
|
97
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse file:line:content format robustly, handling Windows paths
|
|
101
|
+
* E.g., "C:\foo\bar.ts:42:content" or "/foo/bar.ts:42:content"
|
|
102
|
+
*/
|
|
103
|
+
parseGrepLine(line) {
|
|
104
|
+
// Windows absolute path: C:\path\to\file:line:content
|
|
105
|
+
const m1 = line.match(/^([A-Za-z]:\\.+?):(\d+):(.*)$/);
|
|
106
|
+
if (m1)
|
|
107
|
+
return { file: m1[1], lineNum: +m1[2], content: m1[3] };
|
|
108
|
+
// UNC path: \\server\share\path:line:content
|
|
109
|
+
const m2 = line.match(/^(\\\\.+?):(\d+):(.*)$/);
|
|
110
|
+
if (m2)
|
|
111
|
+
return { file: m2[1], lineNum: +m2[2], content: m2[3] };
|
|
112
|
+
// Unix / relative path: /path/to/file:line:content or path/to/file:line:content
|
|
113
|
+
const m3 = line.match(/^(.+?):(\d+):(.*)$/);
|
|
114
|
+
if (m3)
|
|
115
|
+
return { file: m3[1], lineNum: +m3[2], content: m3[3] };
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Create regex from pattern
|
|
120
|
+
*/
|
|
121
|
+
createSearchRegex(pattern, isRegex, caseInsensitive) {
|
|
122
|
+
const flags = caseInsensitive ? 'gi' : 'g';
|
|
123
|
+
if (isRegex) {
|
|
124
|
+
try {
|
|
125
|
+
return new RegExp(pattern, flags);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Invalid regex, escape and use as literal
|
|
129
|
+
return new RegExp(this.escapeRegex(pattern), flags);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return new RegExp(this.escapeRegex(pattern), flags);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Escape regex special characters
|
|
136
|
+
*/
|
|
137
|
+
escapeRegex(str) {
|
|
138
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if file matches include patterns
|
|
62
142
|
*/
|
|
63
143
|
matchesIncludesFilter(filePath, includes) {
|
|
64
144
|
if (!includes || includes.length === 0) {
|
|
65
|
-
return true;
|
|
145
|
+
return true;
|
|
66
146
|
}
|
|
67
147
|
const fileName = path.basename(filePath);
|
|
68
148
|
const normalizedPath = this.normalizePath(filePath);
|
|
69
149
|
for (const pattern of includes) {
|
|
70
|
-
// Handle simple extension patterns like *.ts
|
|
71
150
|
if (pattern.startsWith('*.')) {
|
|
72
|
-
const ext = pattern.substring(1);
|
|
151
|
+
const ext = pattern.substring(1);
|
|
73
152
|
if (fileName.endsWith(ext)) {
|
|
74
153
|
return true;
|
|
75
154
|
}
|
|
76
155
|
}
|
|
77
|
-
// Handle patterns like **/*.ts
|
|
78
156
|
else if (pattern.includes('**')) {
|
|
79
157
|
const ext = pattern.replace('**/', '').replace('*', '');
|
|
80
158
|
if (fileName.endsWith(ext) || normalizedPath.includes(ext)) {
|
|
81
159
|
return true;
|
|
82
160
|
}
|
|
83
161
|
}
|
|
84
|
-
// Handle direct matches
|
|
85
162
|
else if (fileName === pattern || normalizedPath.includes(pattern)) {
|
|
86
163
|
return true;
|
|
87
164
|
}
|
|
@@ -89,418 +166,591 @@ export class GrepSearchTool {
|
|
|
89
166
|
return false;
|
|
90
167
|
}
|
|
91
168
|
/**
|
|
92
|
-
*
|
|
93
|
-
*/
|
|
94
|
-
truncateLine(line) {
|
|
95
|
-
if (line.length <= GrepSearchTool.MAX_LINE_LENGTH) {
|
|
96
|
-
return line;
|
|
97
|
-
}
|
|
98
|
-
return line.substring(0, GrepSearchTool.MAX_LINE_LENGTH) + ' [truncated]';
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Execute search using ripgrep
|
|
169
|
+
* Check if file is likely binary
|
|
102
170
|
*/
|
|
103
|
-
async
|
|
104
|
-
const args = [
|
|
105
|
-
'--json',
|
|
106
|
-
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
107
|
-
`--max-count=${GrepSearchTool.MAX_MATCHES}`,
|
|
108
|
-
];
|
|
109
|
-
// Case sensitivity
|
|
110
|
-
if (!params.CaseInsensitive) {
|
|
111
|
-
// rg is case-sensitive by default, smart-case is default in CLI but maybe not here
|
|
112
|
-
// We want explicit case sensitivity unless CaseInsensitive is true
|
|
113
|
-
// Actually, rg is case-sensitive by default.
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
args.push('--ignore-case');
|
|
117
|
-
}
|
|
118
|
-
// Fixed string vs Regex
|
|
119
|
-
if (!params.IsRegex) {
|
|
120
|
-
args.push('--fixed-strings');
|
|
121
|
-
}
|
|
122
|
-
// File pattern (Includes)
|
|
123
|
-
if (params.Includes && params.Includes.length > 0) {
|
|
124
|
-
params.Includes.forEach(pattern => {
|
|
125
|
-
args.push('--glob', pattern);
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
// Search Path (file or directory)
|
|
129
|
-
// rg takes the path as the last argument usually, or we can use it as root
|
|
130
|
-
// We'll pass it as an argument.
|
|
131
|
-
// Pattern
|
|
132
|
-
// We pass pattern after flags
|
|
133
|
-
args.push('--', params.Query);
|
|
134
|
-
// Path
|
|
135
|
-
args.push(params.SearchPath);
|
|
171
|
+
async isBinaryFile(filePath) {
|
|
136
172
|
try {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
totalMatches: 0,
|
|
146
|
-
truncated: false,
|
|
147
|
-
searchPattern: params.Query,
|
|
148
|
-
};
|
|
173
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
174
|
+
const buffer = Buffer.alloc(8192);
|
|
175
|
+
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
|
|
176
|
+
await fd.close();
|
|
177
|
+
// Check for null bytes (common in binary files)
|
|
178
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
179
|
+
if (buffer[i] === 0)
|
|
180
|
+
return true;
|
|
149
181
|
}
|
|
150
|
-
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return true; // Assume binary on error
|
|
151
186
|
}
|
|
152
187
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
contextAfter: contextAfter.map(l => this.truncateLine(l)),
|
|
188
|
+
// ========================================================================
|
|
189
|
+
// RIPGREP BACKEND (Streaming)
|
|
190
|
+
// ========================================================================
|
|
191
|
+
async searchWithRipgrepStreaming(params, cwd) {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const args = [
|
|
194
|
+
'--json',
|
|
195
|
+
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
196
|
+
];
|
|
197
|
+
if (params.CaseInsensitive) {
|
|
198
|
+
args.push('--ignore-case');
|
|
199
|
+
}
|
|
200
|
+
if (!params.IsRegex) {
|
|
201
|
+
args.push('--fixed-strings');
|
|
202
|
+
}
|
|
203
|
+
if (params.Includes && params.Includes.length > 0) {
|
|
204
|
+
params.Includes.forEach(pattern => {
|
|
205
|
+
args.push('--glob', pattern);
|
|
172
206
|
});
|
|
173
|
-
currentMatch = null;
|
|
174
|
-
contextBefore = [];
|
|
175
|
-
contextAfter = [];
|
|
176
207
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
args.push('--', params.Query, params.SearchPath);
|
|
209
|
+
const matches = [];
|
|
210
|
+
let truncated = false;
|
|
211
|
+
let currentFile = null;
|
|
212
|
+
let contextQueue = [];
|
|
213
|
+
let pendingMatch = null;
|
|
214
|
+
let contextAfterCount = 0;
|
|
215
|
+
// RACECONDITION FIX: Guard against double-resolution
|
|
216
|
+
let finished = false;
|
|
217
|
+
const child = spawn('rg', args, {
|
|
218
|
+
cwd,
|
|
219
|
+
env: { ...process.env, LANG: 'en_US.UTF-8' }
|
|
220
|
+
});
|
|
221
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
222
|
+
// Robust cleanup function
|
|
223
|
+
const done = (result) => {
|
|
224
|
+
if (finished)
|
|
225
|
+
return;
|
|
226
|
+
finished = true;
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
// TERMINATION FIX: Use simple kill() for Windows compatibility
|
|
229
|
+
child.kill();
|
|
230
|
+
rl.close();
|
|
231
|
+
resolve(result);
|
|
232
|
+
};
|
|
233
|
+
const fail = (err) => {
|
|
234
|
+
if (finished)
|
|
235
|
+
return;
|
|
236
|
+
finished = true;
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
child.kill();
|
|
239
|
+
rl.close();
|
|
240
|
+
reject(err);
|
|
241
|
+
};
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
done({
|
|
244
|
+
matches,
|
|
245
|
+
totalMatches: matches.length,
|
|
246
|
+
truncated: true,
|
|
247
|
+
truncationReason: 'timeout',
|
|
248
|
+
searchPattern: params.Query
|
|
249
|
+
});
|
|
250
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
251
|
+
rl.on('line', (line) => {
|
|
252
|
+
if (finished)
|
|
253
|
+
return;
|
|
254
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
255
|
+
if (matches.length >= maxMatches) {
|
|
256
|
+
truncated = true;
|
|
257
|
+
// ABORT FIX: Immediately stop and resolve
|
|
258
|
+
done({
|
|
259
|
+
matches,
|
|
260
|
+
totalMatches: matches.length,
|
|
261
|
+
truncated,
|
|
262
|
+
truncationReason: 'max_matches',
|
|
263
|
+
searchPattern: params.Query
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
192
266
|
}
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
267
|
+
try {
|
|
268
|
+
const event = JSON.parse(line);
|
|
269
|
+
if (event.type === 'begin' && event.data?.path?.text) {
|
|
270
|
+
currentFile = this.normalizePath(event.data.path.text);
|
|
271
|
+
contextQueue = [];
|
|
272
|
+
pendingMatch = null;
|
|
273
|
+
contextAfterCount = 0;
|
|
274
|
+
}
|
|
275
|
+
else if (event.type === 'context' && event.data?.lines?.text) {
|
|
276
|
+
const ctxLine = {
|
|
277
|
+
lineNumber: event.data.line_number,
|
|
278
|
+
text: this.truncateLine(this.normalizeLineEndings(event.data.lines.text).trimEnd())
|
|
279
|
+
};
|
|
280
|
+
if (pendingMatch && contextAfterCount < GrepSearchTool.CONTEXT_LINES) {
|
|
281
|
+
pendingMatch.contextAfter.push(ctxLine);
|
|
282
|
+
contextAfterCount++;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
contextQueue.push(ctxLine);
|
|
286
|
+
if (contextQueue.length > GrepSearchTool.CONTEXT_LINES) {
|
|
287
|
+
contextQueue.shift();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
199
290
|
}
|
|
200
|
-
else {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// Actually, rg JSON output structure:
|
|
205
|
-
// context (before) -> match -> context (after)
|
|
206
|
-
// But if matches are close, context after match 1 might be context before match 2.
|
|
207
|
-
// For simplicity in this parser:
|
|
208
|
-
// If we have a currentMatch, this is context AFTER.
|
|
209
|
-
// If we don't, this is context BEFORE.
|
|
210
|
-
contextBefore.push(contextLine);
|
|
211
|
-
// Keep only last N lines for 'before' context
|
|
212
|
-
if (contextBefore.length > GrepSearchTool.CONTEXT_LINES) {
|
|
213
|
-
contextBefore.shift();
|
|
291
|
+
else if (event.type === 'match' && event.data) {
|
|
292
|
+
// Finalize previous match
|
|
293
|
+
if (pendingMatch) {
|
|
294
|
+
matches.push(pendingMatch);
|
|
214
295
|
}
|
|
296
|
+
const lineText = event.data.lines?.text || '';
|
|
297
|
+
const submatches = event.data.submatches || [];
|
|
298
|
+
pendingMatch = {
|
|
299
|
+
file: currentFile || this.normalizePath(event.data.path?.text || ''),
|
|
300
|
+
line: event.data.line_number,
|
|
301
|
+
column: submatches.length > 0 ? submatches[0].start + 1 : undefined,
|
|
302
|
+
byteOffset: event.data.absolute_offset,
|
|
303
|
+
match: this.truncateLine(this.normalizeLineEndings(lineText).trimEnd()),
|
|
304
|
+
matchIndices: submatches.map((s) => [s.start, s.end]),
|
|
305
|
+
contextBefore: [...contextQueue],
|
|
306
|
+
contextAfter: []
|
|
307
|
+
};
|
|
308
|
+
contextQueue = [];
|
|
309
|
+
contextAfterCount = 0;
|
|
215
310
|
}
|
|
216
311
|
}
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
312
|
+
catch {
|
|
313
|
+
// Ignore parse errors
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
child.on('close', (code) => {
|
|
317
|
+
if (finished)
|
|
318
|
+
return;
|
|
319
|
+
// Finalize last match
|
|
320
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
321
|
+
if (pendingMatch && matches.length < maxMatches) {
|
|
322
|
+
matches.push(pendingMatch);
|
|
222
323
|
}
|
|
324
|
+
done({
|
|
325
|
+
matches,
|
|
326
|
+
totalMatches: matches.length,
|
|
327
|
+
truncated,
|
|
328
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
329
|
+
searchPattern: params.Query
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
child.on('error', (err) => {
|
|
333
|
+
fail(err);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
// ========================================================================
|
|
338
|
+
// GREP BACKEND (Streaming)
|
|
339
|
+
// ========================================================================
|
|
340
|
+
async searchWithGrepStreaming(params, cwd) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const args = [
|
|
343
|
+
'-n', // line numbers
|
|
344
|
+
'-r', // recursive
|
|
345
|
+
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
346
|
+
];
|
|
347
|
+
if (params.CaseInsensitive) {
|
|
348
|
+
args.push('-i');
|
|
223
349
|
}
|
|
224
|
-
|
|
225
|
-
|
|
350
|
+
if (!params.IsRegex) {
|
|
351
|
+
args.push('-F'); // fixed strings
|
|
226
352
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
353
|
+
else {
|
|
354
|
+
args.push('-E'); // extended regex (supports |)
|
|
355
|
+
}
|
|
356
|
+
if (params.Includes && params.Includes.length > 0) {
|
|
357
|
+
params.Includes.forEach(pattern => {
|
|
358
|
+
args.push('--include', pattern);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
args.push(params.Query, params.SearchPath);
|
|
362
|
+
const matches = [];
|
|
363
|
+
let truncated = false;
|
|
364
|
+
let finished = false;
|
|
365
|
+
const child = spawn('grep', args, {
|
|
366
|
+
cwd,
|
|
367
|
+
env: { ...process.env, LANG: 'en_US.UTF-8' }
|
|
368
|
+
});
|
|
369
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
370
|
+
const done = (result) => {
|
|
371
|
+
if (finished)
|
|
372
|
+
return;
|
|
373
|
+
finished = true;
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
child.kill();
|
|
376
|
+
rl.close();
|
|
377
|
+
resolve(result);
|
|
378
|
+
};
|
|
379
|
+
const fail = (err) => {
|
|
380
|
+
if (finished)
|
|
381
|
+
return;
|
|
382
|
+
finished = true;
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
child.kill();
|
|
385
|
+
rl.close();
|
|
386
|
+
reject(err);
|
|
387
|
+
};
|
|
388
|
+
const timer = setTimeout(() => {
|
|
389
|
+
done({
|
|
390
|
+
matches,
|
|
391
|
+
totalMatches: matches.length,
|
|
392
|
+
truncated: true,
|
|
393
|
+
truncationReason: 'timeout',
|
|
394
|
+
searchPattern: params.Query
|
|
395
|
+
});
|
|
396
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
397
|
+
rl.on('line', (line) => {
|
|
398
|
+
if (finished)
|
|
399
|
+
return;
|
|
400
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
401
|
+
if (matches.length >= maxMatches) {
|
|
402
|
+
truncated = true;
|
|
403
|
+
done({
|
|
404
|
+
matches,
|
|
405
|
+
totalMatches: matches.length,
|
|
406
|
+
truncated,
|
|
407
|
+
truncationReason: 'max_matches',
|
|
408
|
+
searchPattern: params.Query
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const normalizedLine = this.normalizeLineEndings(line);
|
|
413
|
+
// Skip separator lines
|
|
414
|
+
if (normalizedLine === '--')
|
|
415
|
+
return;
|
|
416
|
+
const parsed = this.parseGrepLine(normalizedLine);
|
|
417
|
+
if (parsed) {
|
|
418
|
+
// For grep, we treat each line as a match (context handling is simpler)
|
|
419
|
+
matches.push({
|
|
420
|
+
file: this.normalizePath(parsed.file),
|
|
421
|
+
line: parsed.lineNum,
|
|
422
|
+
match: this.truncateLine(parsed.content),
|
|
423
|
+
contextBefore: [],
|
|
424
|
+
contextAfter: []
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
child.on('close', () => {
|
|
429
|
+
if (finished)
|
|
430
|
+
return;
|
|
431
|
+
done({
|
|
432
|
+
matches,
|
|
433
|
+
totalMatches: matches.length,
|
|
434
|
+
truncated,
|
|
435
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
436
|
+
searchPattern: params.Query
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
child.on('error', (err) => fail(err));
|
|
440
|
+
});
|
|
235
441
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async
|
|
240
|
-
const
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
442
|
+
// ========================================================================
|
|
443
|
+
// POWERSHELL BACKEND (Windows)
|
|
444
|
+
// ========================================================================
|
|
445
|
+
async searchWithPowerShell(params, cwd) {
|
|
446
|
+
const psExe = await this.getPowerShellExe();
|
|
447
|
+
// Build the PowerShell command
|
|
448
|
+
// Select-String supports regex by default, -SimpleMatch for literal
|
|
449
|
+
const patternArg = params.IsRegex ? params.Query : params.Query.replace(/'/g, "''");
|
|
450
|
+
const searchPath = path.resolve(cwd, params.SearchPath);
|
|
451
|
+
let psCommand;
|
|
452
|
+
// Check if searching a file or directory
|
|
453
|
+
const stats = await fs.promises.stat(searchPath).catch(() => null);
|
|
454
|
+
const isDirectory = stats?.isDirectory() ?? false;
|
|
455
|
+
// PATH FIX: Use $_.Path instead of $_.Filename for absolute paths
|
|
456
|
+
if (isDirectory) {
|
|
457
|
+
// Search directory recursively
|
|
458
|
+
const includePattern = params.Includes && params.Includes.length > 0
|
|
459
|
+
? params.Includes.map(p => `'${p}'`).join(',')
|
|
460
|
+
: "'*'";
|
|
461
|
+
const max = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
462
|
+
psCommand = params.IsRegex
|
|
463
|
+
? `Get-ChildItem -Path '${searchPath}' -Recurse -File -Include ${includePattern} -ErrorAction SilentlyContinue | Select-String -Pattern '${patternArg}'${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`
|
|
464
|
+
: `Get-ChildItem -Path '${searchPath}' -Recurse -File -Include ${includePattern} -ErrorAction SilentlyContinue | Select-String -Pattern '${patternArg}' -SimpleMatch${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`;
|
|
244
465
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
466
|
+
else {
|
|
467
|
+
// Search single file
|
|
468
|
+
const max = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
469
|
+
psCommand = params.IsRegex
|
|
470
|
+
? `Select-String -Path '${searchPath}' -Pattern '${patternArg}'${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`
|
|
471
|
+
: `Select-String -Path '${searchPath}' -Pattern '${patternArg}' -SimpleMatch${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`;
|
|
472
|
+
}
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const matches = [];
|
|
475
|
+
let truncated = false;
|
|
476
|
+
let finished = false;
|
|
477
|
+
const child = spawn(psExe, ['-NoProfile', '-Command', psCommand], {
|
|
478
|
+
cwd,
|
|
479
|
+
env: { ...process.env }
|
|
251
480
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
481
|
+
// Robust cleanup function
|
|
482
|
+
const done = (result) => {
|
|
483
|
+
if (finished)
|
|
484
|
+
return;
|
|
485
|
+
finished = true;
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
child.kill();
|
|
488
|
+
resolve(result);
|
|
489
|
+
};
|
|
490
|
+
const fail = (err) => {
|
|
491
|
+
if (finished)
|
|
492
|
+
return;
|
|
493
|
+
finished = true;
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
child.kill();
|
|
496
|
+
reject(err);
|
|
497
|
+
};
|
|
498
|
+
const timer = setTimeout(() => {
|
|
499
|
+
done({
|
|
500
|
+
matches,
|
|
501
|
+
totalMatches: matches.length,
|
|
502
|
+
truncated: true,
|
|
503
|
+
truncationReason: 'timeout',
|
|
504
|
+
searchPattern: params.Query
|
|
505
|
+
});
|
|
506
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
507
|
+
let stdout = '';
|
|
508
|
+
let stderr = '';
|
|
509
|
+
child.stdout.on('data', (data) => {
|
|
510
|
+
stdout += data.toString();
|
|
511
|
+
});
|
|
512
|
+
child.stderr.on('data', (data) => {
|
|
513
|
+
stderr += data.toString();
|
|
514
|
+
});
|
|
515
|
+
child.on('close', (code) => {
|
|
516
|
+
if (finished)
|
|
517
|
+
return;
|
|
518
|
+
const lines = this.normalizeLineEndings(stdout).split('\n').filter(l => l.trim());
|
|
519
|
+
for (const line of lines) {
|
|
520
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
521
|
+
if (matches.length >= maxMatches) {
|
|
522
|
+
truncated = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
const parsed = this.parseGrepLine(line);
|
|
526
|
+
if (parsed) {
|
|
527
|
+
matches.push({
|
|
528
|
+
file: this.normalizePath(parsed.file),
|
|
529
|
+
line: parsed.lineNum,
|
|
530
|
+
match: this.truncateLine(parsed.content),
|
|
531
|
+
contextBefore: [],
|
|
532
|
+
contextAfter: []
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
done({
|
|
537
|
+
matches,
|
|
538
|
+
totalMatches: matches.length,
|
|
539
|
+
truncated,
|
|
540
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
541
|
+
searchPattern: params.Query
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
child.on('error', (err) => fail(err));
|
|
545
|
+
});
|
|
276
546
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// -- (separator)
|
|
282
|
-
const lines = output.trim().split('\n');
|
|
547
|
+
// ========================================================================
|
|
548
|
+
// PURE NODE.JS BACKEND (Universal Fallback)
|
|
549
|
+
// ========================================================================
|
|
550
|
+
async searchWithNodeJS(params, cwd) {
|
|
283
551
|
const matches = [];
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
552
|
+
let truncated = false;
|
|
553
|
+
let filesSearched = 0;
|
|
554
|
+
let encodingFallback = false;
|
|
555
|
+
const searchPath = path.resolve(cwd, params.SearchPath);
|
|
556
|
+
const regex = this.createSearchRegex(params.Query, params.IsRegex ?? false, params.CaseInsensitive ?? false);
|
|
557
|
+
// Collect files to search
|
|
558
|
+
const filesToSearch = [];
|
|
559
|
+
const stats = await fs.promises.stat(searchPath).catch(() => null);
|
|
560
|
+
if (!stats) {
|
|
561
|
+
return { matches: [], totalMatches: 0, truncated: false, searchPattern: params.Query };
|
|
562
|
+
}
|
|
563
|
+
if (stats.isFile()) {
|
|
564
|
+
filesToSearch.push(searchPath);
|
|
565
|
+
}
|
|
566
|
+
else if (stats.isDirectory()) {
|
|
567
|
+
await this.collectFiles(searchPath, filesToSearch, params.Includes);
|
|
568
|
+
}
|
|
569
|
+
// Search each file
|
|
570
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
571
|
+
for (const filePath of filesToSearch) {
|
|
572
|
+
if (matches.length >= maxMatches) {
|
|
573
|
+
truncated = true;
|
|
574
|
+
break;
|
|
302
575
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (line === '--') {
|
|
306
|
-
pushMatch();
|
|
576
|
+
// Skip binary files
|
|
577
|
+
if (await this.isBinaryFile(filePath)) {
|
|
307
578
|
continue;
|
|
308
579
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
contextAfter.push(contextM[3]);
|
|
580
|
+
filesSearched++;
|
|
581
|
+
try {
|
|
582
|
+
let content;
|
|
583
|
+
const buffer = await fs.promises.readFile(filePath);
|
|
584
|
+
// Try UTF-8 first
|
|
585
|
+
try {
|
|
586
|
+
content = buffer.toString('utf8');
|
|
587
|
+
// Check for replacement characters indicating decode failure
|
|
588
|
+
if (content.includes('\uFFFD')) {
|
|
589
|
+
content = buffer.toString('latin1');
|
|
590
|
+
encodingFallback = true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
content = buffer.toString('latin1');
|
|
595
|
+
encodingFallback = true;
|
|
326
596
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
597
|
+
const lines = this.normalizeLineEndings(content).split('\n');
|
|
598
|
+
for (let i = 0; i < lines.length; i++) {
|
|
599
|
+
if (matches.length >= maxMatches) {
|
|
600
|
+
truncated = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
const line = lines[i];
|
|
604
|
+
regex.lastIndex = 0; // Reset regex state
|
|
605
|
+
// LOOP FIX: Capture all matches in the line, not just the first
|
|
606
|
+
let matchResult;
|
|
607
|
+
while ((matchResult = regex.exec(line)) !== null) {
|
|
608
|
+
// Prevent infinite loops with zero-width matches
|
|
609
|
+
if (matchResult.index === regex.lastIndex) {
|
|
610
|
+
regex.lastIndex++;
|
|
611
|
+
}
|
|
612
|
+
const relativePath = path.relative(cwd, filePath);
|
|
613
|
+
// Collect context
|
|
614
|
+
const contextBefore = [];
|
|
615
|
+
const contextAfter = [];
|
|
616
|
+
for (let j = Math.max(0, i - GrepSearchTool.CONTEXT_LINES); j < i; j++) {
|
|
617
|
+
contextBefore.push({
|
|
618
|
+
lineNumber: j + 1,
|
|
619
|
+
text: this.truncateLine(lines[j])
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
for (let j = i + 1; j <= Math.min(lines.length - 1, i + GrepSearchTool.CONTEXT_LINES); j++) {
|
|
623
|
+
contextAfter.push({
|
|
624
|
+
lineNumber: j + 1,
|
|
625
|
+
text: this.truncateLine(lines[j])
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
matches.push({
|
|
629
|
+
file: this.normalizePath(relativePath),
|
|
630
|
+
line: i + 1,
|
|
631
|
+
column: matchResult.index + 1,
|
|
632
|
+
match: this.truncateLine(line),
|
|
633
|
+
contextBefore,
|
|
634
|
+
contextAfter
|
|
635
|
+
});
|
|
636
|
+
if (matches.length >= maxMatches) {
|
|
637
|
+
truncated = true;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (truncated)
|
|
642
|
+
break;
|
|
331
643
|
}
|
|
332
644
|
}
|
|
645
|
+
catch {
|
|
646
|
+
// Skip files that can't be read
|
|
647
|
+
}
|
|
333
648
|
}
|
|
334
|
-
pushMatch();
|
|
335
649
|
return {
|
|
336
|
-
matches
|
|
337
|
-
totalMatches: matches.length,
|
|
338
|
-
truncated
|
|
339
|
-
|
|
650
|
+
matches,
|
|
651
|
+
totalMatches: matches.length,
|
|
652
|
+
truncated,
|
|
653
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
654
|
+
searchPattern: params.Query,
|
|
655
|
+
filesSearched,
|
|
656
|
+
encodingFallback
|
|
340
657
|
};
|
|
341
658
|
}
|
|
342
659
|
/**
|
|
343
|
-
*
|
|
344
|
-
* NOTE: Context is DISABLED for findstr to avoid performance/encoding issues.
|
|
660
|
+
* Recursively collect files from directory
|
|
345
661
|
*/
|
|
346
|
-
async
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
'/S', // Recursive
|
|
350
|
-
];
|
|
351
|
-
if (params.CaseInsensitive) {
|
|
352
|
-
args.push('/I');
|
|
353
|
-
}
|
|
354
|
-
// findstr doesn't support full regex, only limited.
|
|
355
|
-
// If IsRegex is true, we might be limited.
|
|
356
|
-
if (!params.IsRegex) {
|
|
357
|
-
args.push('/L'); // Literal search
|
|
358
|
-
}
|
|
359
|
-
else {
|
|
360
|
-
args.push('/R'); // Regex search
|
|
361
|
-
}
|
|
362
|
-
// Pattern
|
|
363
|
-
args.push('/C:' + params.Query);
|
|
364
|
-
// File mask
|
|
365
|
-
// findstr [options] strings [drive:][path]filename[...]
|
|
366
|
-
// We can pass the path/mask at the end.
|
|
367
|
-
// If Includes is set, we can try to use it, but findstr is limited.
|
|
368
|
-
// We'll just use SearchPath and optional Includes if it's a simple extension.
|
|
369
|
-
let searchMask = '*.*';
|
|
370
|
-
if (params.Includes && params.Includes.length === 1 && params.Includes[0].startsWith('*.')) {
|
|
371
|
-
searchMask = params.Includes[0];
|
|
372
|
-
}
|
|
373
|
-
// If SearchPath is a directory, append mask. If file, use it.
|
|
374
|
-
let target = params.SearchPath;
|
|
662
|
+
async collectFiles(dir, files, includes, maxFiles = 10000) {
|
|
663
|
+
if (files.length >= maxFiles)
|
|
664
|
+
return;
|
|
375
665
|
try {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
666
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
667
|
+
for (const entry of entries) {
|
|
668
|
+
if (files.length >= maxFiles)
|
|
669
|
+
break;
|
|
670
|
+
const fullPath = path.join(dir, entry.name);
|
|
671
|
+
// Skip common non-code directories
|
|
672
|
+
if (entry.isDirectory()) {
|
|
673
|
+
if (['node_modules', '.git', 'dist', 'build', '__pycache__', '.vscode', '.idea'].includes(entry.name)) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
await this.collectFiles(fullPath, files, includes, maxFiles);
|
|
677
|
+
}
|
|
678
|
+
else if (entry.isFile()) {
|
|
679
|
+
if (this.matchesIncludesFilter(fullPath, includes)) {
|
|
680
|
+
files.push(fullPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
379
683
|
}
|
|
380
684
|
}
|
|
381
685
|
catch {
|
|
382
|
-
//
|
|
383
|
-
// Just pass it as is.
|
|
384
|
-
}
|
|
385
|
-
args.push(target);
|
|
386
|
-
try {
|
|
387
|
-
// Use execFile with 'findstr'
|
|
388
|
-
const { stdout } = await execFileAsync('findstr', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
389
|
-
return this.parseFindstrOutput(stdout, params.Query);
|
|
390
|
-
}
|
|
391
|
-
catch (error) {
|
|
392
|
-
if (error.code === 1) {
|
|
393
|
-
return { matches: [], totalMatches: 0, truncated: false, searchPattern: params.Query };
|
|
394
|
-
}
|
|
395
|
-
throw error;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
parseFindstrOutput(output, searchPattern) {
|
|
399
|
-
const lines = output.trim().split('\r\n'); // Windows line endings
|
|
400
|
-
const matches = [];
|
|
401
|
-
for (const line of lines) {
|
|
402
|
-
if (!line)
|
|
403
|
-
continue;
|
|
404
|
-
// findstr output format with /N /S flags:
|
|
405
|
-
// C:\path\to\file.ts:1:content
|
|
406
|
-
//
|
|
407
|
-
// Windows paths start with drive letter like "C:" so we need to be careful
|
|
408
|
-
// about finding the line number delimiter
|
|
409
|
-
// Strategy: Find the pattern ":digits:" after the path
|
|
410
|
-
// On Windows, paths have \ not : except for drive letter
|
|
411
|
-
// So we look for the last occurrence of ":\d+:" pattern
|
|
412
|
-
const lineNumMatch = line.match(/:(\d+):/);
|
|
413
|
-
if (lineNumMatch && lineNumMatch.index !== undefined) {
|
|
414
|
-
// Everything before the match is the file path
|
|
415
|
-
const file = line.substring(0, lineNumMatch.index);
|
|
416
|
-
const lineNum = parseInt(lineNumMatch[1], 10);
|
|
417
|
-
// Everything after the ":linenum:" is the content
|
|
418
|
-
const colonAfterLineNum = lineNumMatch.index + lineNumMatch[0].length;
|
|
419
|
-
const content = line.substring(colonAfterLineNum);
|
|
420
|
-
matches.push({
|
|
421
|
-
file: this.normalizePath(file),
|
|
422
|
-
line: lineNum,
|
|
423
|
-
match: this.truncateLine(content),
|
|
424
|
-
contextBefore: [], // No context for findstr
|
|
425
|
-
contextAfter: []
|
|
426
|
-
});
|
|
427
|
-
}
|
|
686
|
+
// Ignore directories that can't be read
|
|
428
687
|
}
|
|
429
|
-
return {
|
|
430
|
-
matches: matches.slice(0, GrepSearchTool.MAX_MATCHES),
|
|
431
|
-
totalMatches: matches.length,
|
|
432
|
-
truncated: matches.length >= GrepSearchTool.MAX_MATCHES,
|
|
433
|
-
searchPattern
|
|
434
|
-
};
|
|
435
688
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
689
|
+
// ========================================================================
|
|
690
|
+
// MAIN EXECUTE METHOD
|
|
691
|
+
// ========================================================================
|
|
439
692
|
async execute(params, cwd) {
|
|
440
693
|
let result;
|
|
441
|
-
|
|
442
|
-
const searchTimeout = 30000;
|
|
443
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Search operation timed out after 30 seconds')), searchTimeout));
|
|
694
|
+
let backend;
|
|
444
695
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
timeoutPromise
|
|
464
|
-
]);
|
|
696
|
+
// Try backends in order of preference
|
|
697
|
+
if (await this.hasRipgrep()) {
|
|
698
|
+
backend = 'ripgrep';
|
|
699
|
+
result = await this.searchWithRipgrepStreaming(params, cwd);
|
|
700
|
+
}
|
|
701
|
+
else if (await this.hasGrep()) {
|
|
702
|
+
backend = 'grep';
|
|
703
|
+
result = await this.searchWithGrepStreaming(params, cwd);
|
|
704
|
+
}
|
|
705
|
+
else if (process.platform === 'win32' && await this.hasPowerShell()) {
|
|
706
|
+
backend = 'powershell';
|
|
707
|
+
result = await this.searchWithPowerShell(params, cwd);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Universal Node.js fallback
|
|
711
|
+
backend = 'nodejs';
|
|
712
|
+
result = await this.searchWithNodeJS(params, cwd);
|
|
713
|
+
}
|
|
465
714
|
}
|
|
466
715
|
catch (error) {
|
|
467
|
-
|
|
468
|
-
|
|
716
|
+
// If primary backend fails, try fallback
|
|
717
|
+
try {
|
|
718
|
+
backend = 'nodejs';
|
|
719
|
+
result = await this.searchWithNodeJS(params, cwd);
|
|
720
|
+
}
|
|
721
|
+
catch (fallbackError) {
|
|
722
|
+
throw new Error(`Search failed: ${error.message}. Fallback also failed: ${fallbackError.message}`);
|
|
469
723
|
}
|
|
470
|
-
throw error;
|
|
471
724
|
}
|
|
472
|
-
// Apply post-filtering for Includes
|
|
473
|
-
if (params.Includes && params.Includes.length > 0) {
|
|
725
|
+
// Apply post-filtering for Includes if backend didn't support it
|
|
726
|
+
if (params.Includes && params.Includes.length > 0 && backend === 'nodejs') {
|
|
474
727
|
const filteredMatches = result.matches.filter(match => this.matchesIncludesFilter(match.file, params.Includes));
|
|
475
728
|
return {
|
|
729
|
+
...result,
|
|
476
730
|
matches: filteredMatches,
|
|
477
731
|
totalMatches: filteredMatches.length,
|
|
478
|
-
|
|
479
|
-
searchPattern: result.searchPattern,
|
|
732
|
+
backend
|
|
480
733
|
};
|
|
481
734
|
}
|
|
482
|
-
return result;
|
|
735
|
+
return { ...result, backend };
|
|
483
736
|
}
|
|
484
737
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
* Format:
|
|
489
|
-
* filename
|
|
490
|
-
* line: context
|
|
491
|
-
* line:> match
|
|
492
|
-
* line: context
|
|
493
|
-
*/
|
|
738
|
+
// ============================================================================
|
|
739
|
+
// FORMATTING FUNCTION
|
|
740
|
+
// ============================================================================
|
|
494
741
|
function formatGrepResults(result) {
|
|
495
742
|
if (result.matches.length === 0) {
|
|
496
743
|
return `No matches found for pattern "${result.searchPattern}"`;
|
|
497
744
|
}
|
|
745
|
+
const uniqueFiles = new Set();
|
|
746
|
+
result.matches.forEach(match => uniqueFiles.add(match.file));
|
|
747
|
+
const fileCount = uniqueFiles.size;
|
|
498
748
|
const output = [];
|
|
499
|
-
output.push(`Found ${result.
|
|
749
|
+
output.push(`Found ${result.uniqueMatchesCount} match${result.uniqueMatchesCount === 1 ? '' : 'es'} in ${fileCount} file${fileCount === 1 ? '' : 's'} for pattern "${result.searchPattern}"`);
|
|
500
750
|
if (result.truncated) {
|
|
501
|
-
|
|
751
|
+
const reason = result.truncationReason === 'timeout' ? '(timed out)' : '';
|
|
752
|
+
output.push(`(showing first ${result.matches.length} matches${reason ? ' ' + reason : ''})`);
|
|
502
753
|
}
|
|
503
|
-
// Group by file
|
|
504
754
|
const matchesByFile = new Map();
|
|
505
755
|
result.matches.forEach(match => {
|
|
506
756
|
if (!matchesByFile.has(match.file)) {
|
|
@@ -511,35 +761,21 @@ function formatGrepResults(result) {
|
|
|
511
761
|
matchesByFile.forEach((matches, file) => {
|
|
512
762
|
output.push(`\n${file}`);
|
|
513
763
|
matches.forEach(match => {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// Calculate line number for context if possible?
|
|
517
|
-
// rg json gives line numbers for context, but our interface simplified it.
|
|
518
|
-
// We'll just print it without line number or with a placeholder if we don't have it.
|
|
519
|
-
// Actually, for AI parsing, it's better to have line numbers.
|
|
520
|
-
// But if we don't have them (grep/findstr), we shouldn't fake them incorrectly.
|
|
521
|
-
// However, rg gives them.
|
|
522
|
-
// Let's just indent context.
|
|
523
|
-
// Wait, user requested: "48: function..."
|
|
524
|
-
// If we don't have line numbers for context, maybe we should skip context or just indent?
|
|
525
|
-
// "Use line numbers explicitly for every line."
|
|
526
|
-
// Since we might not have them for context in all cases, let's try to infer or just use indentation.
|
|
527
|
-
// For the match, we definitely have the line number.
|
|
528
|
-
// Let's assume context lines are immediately preceding.
|
|
529
|
-
const startLine = match.line - match.contextBefore.length;
|
|
530
|
-
output.push(`${startLine + idx}: ${ctx}`);
|
|
764
|
+
match.contextBefore.forEach(ctx => {
|
|
765
|
+
output.push(`${ctx.lineNumber}: ${ctx.text}`);
|
|
531
766
|
});
|
|
532
|
-
|
|
533
|
-
output.push(`${match.line}:> ${match.match}`);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
output.push(`${match.line + 1 + idx}: ${ctx}`);
|
|
767
|
+
const columnInfo = match.column ? ` (col ${match.column})` : '';
|
|
768
|
+
output.push(`${match.line}:> ${match.match}${columnInfo}`);
|
|
769
|
+
match.contextAfter.forEach(ctx => {
|
|
770
|
+
output.push(`${ctx.lineNumber}: ${ctx.text}`);
|
|
537
771
|
});
|
|
538
|
-
// Separator if needed? AI usually handles blocks well.
|
|
539
772
|
});
|
|
540
773
|
});
|
|
541
774
|
return output.join('\n');
|
|
542
775
|
}
|
|
776
|
+
// ============================================================================
|
|
777
|
+
// TOOL EXPORT
|
|
778
|
+
// ============================================================================
|
|
543
779
|
export const grepSearchTool = {
|
|
544
780
|
schema: {
|
|
545
781
|
name: 'grep_search',
|
|
@@ -558,7 +794,7 @@ MULTI-WORD SEARCH: When you search for "control system", this tool will automati
|
|
|
558
794
|
Results are returned with file paths and line numbers:
|
|
559
795
|
filename
|
|
560
796
|
line: context
|
|
561
|
-
line:> match
|
|
797
|
+
line:> match (col X)
|
|
562
798
|
line: context
|
|
563
799
|
|
|
564
800
|
NOT for: Finding files/folders by NAME. Use find_files for that instead.
|
|
@@ -583,9 +819,13 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
|
|
|
583
819
|
description: 'Glob patterns to filter files (e.g. ["*.ts"]).',
|
|
584
820
|
items: { type: 'string' }
|
|
585
821
|
},
|
|
822
|
+
MaxResults: {
|
|
823
|
+
type: 'number',
|
|
824
|
+
description: 'Optional: Maximum number of results to return. Default is 50. Increase this if you need to see more matches.',
|
|
825
|
+
},
|
|
586
826
|
CaseInsensitive: {
|
|
587
827
|
type: 'boolean',
|
|
588
|
-
description: 'If true, performs a case-insensitive search. Default: false',
|
|
828
|
+
description: 'If true, performs a case-insensitive search. Default: false (case-sensitive)',
|
|
589
829
|
},
|
|
590
830
|
IsRegex: {
|
|
591
831
|
type: 'boolean',
|
|
@@ -597,17 +837,20 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
|
|
|
597
837
|
},
|
|
598
838
|
async execute(args, context) {
|
|
599
839
|
const tool = new GrepSearchTool();
|
|
600
|
-
|
|
840
|
+
const startTime = Date.now();
|
|
601
841
|
const query = args.Query;
|
|
602
842
|
const words = query.trim().split(/\s+/).filter(w => w.length > 2);
|
|
603
|
-
// If query has multiple words, search for combined AND individual terms
|
|
604
843
|
const allMatches = [];
|
|
605
|
-
const seenMatches = new Set();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
844
|
+
const seenMatches = new Set();
|
|
845
|
+
const matchesPerQuery = {};
|
|
846
|
+
const queriesExecuted = [];
|
|
847
|
+
let rawTotalMatches = 0;
|
|
848
|
+
let backend = 'ripgrep';
|
|
849
|
+
let truncated = false;
|
|
850
|
+
let truncationReason;
|
|
851
|
+
let encodingFallback = false;
|
|
852
|
+
const queries = [query];
|
|
609
853
|
if (words.length > 1 && !args.IsRegex) {
|
|
610
|
-
// Add individual words as additional searches
|
|
611
854
|
for (const word of words) {
|
|
612
855
|
if (word !== query) {
|
|
613
856
|
queries.push(word);
|
|
@@ -616,38 +859,64 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
|
|
|
616
859
|
}
|
|
617
860
|
try {
|
|
618
861
|
for (const searchQuery of queries) {
|
|
862
|
+
queriesExecuted.push(searchQuery);
|
|
619
863
|
const params = {
|
|
620
864
|
Query: searchQuery,
|
|
621
865
|
SearchPath: args.SearchPath,
|
|
622
866
|
Includes: args.Includes,
|
|
623
|
-
|
|
867
|
+
MaxResults: args.MaxResults,
|
|
868
|
+
CaseInsensitive: args.CaseInsensitive ?? false,
|
|
624
869
|
IsRegex: args.IsRegex,
|
|
625
870
|
};
|
|
626
871
|
try {
|
|
627
872
|
const result = await tool.execute(params, context.cwd);
|
|
628
|
-
|
|
629
|
-
|
|
873
|
+
backend = result.backend;
|
|
874
|
+
matchesPerQuery[searchQuery] = result.totalMatches;
|
|
875
|
+
rawTotalMatches += result.totalMatches;
|
|
876
|
+
if (result.truncated) {
|
|
877
|
+
truncated = true;
|
|
878
|
+
truncationReason = result.truncationReason;
|
|
879
|
+
}
|
|
880
|
+
if (result.encodingFallback) {
|
|
881
|
+
encodingFallback = true;
|
|
882
|
+
}
|
|
883
|
+
// Add unique matches with improved dedupe key
|
|
630
884
|
for (const match of result.matches) {
|
|
631
|
-
|
|
632
|
-
|
|
885
|
+
// Use file:line:column:matchText for more accurate deduplication
|
|
886
|
+
const key = `${match.file}:${match.line}:${match.column ?? 0}:${match.match.substring(0, 50)}`;
|
|
887
|
+
const maxMatches = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
888
|
+
if (!seenMatches.has(key) && allMatches.length < maxMatches) {
|
|
633
889
|
seenMatches.add(key);
|
|
634
890
|
allMatches.push(match);
|
|
635
891
|
}
|
|
636
892
|
}
|
|
637
893
|
}
|
|
638
|
-
catch
|
|
639
|
-
|
|
894
|
+
catch {
|
|
895
|
+
matchesPerQuery[searchQuery] = 0;
|
|
640
896
|
continue;
|
|
641
897
|
}
|
|
642
898
|
}
|
|
643
|
-
|
|
644
|
-
const
|
|
899
|
+
const endTime = Date.now();
|
|
900
|
+
const uniqueMatchesCount = allMatches.length;
|
|
901
|
+
const max = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
902
|
+
const finalResult = {
|
|
645
903
|
matches: allMatches,
|
|
646
|
-
totalMatches:
|
|
647
|
-
|
|
904
|
+
totalMatches: rawTotalMatches,
|
|
905
|
+
uniqueMatchesCount,
|
|
906
|
+
truncated: truncated || allMatches.length >= max,
|
|
907
|
+
truncationReason: allMatches.length >= max ? 'max_matches' : truncationReason,
|
|
648
908
|
searchPattern: queries.length > 1 ? `"${query}" (+ individual words)` : query,
|
|
909
|
+
metadata: {
|
|
910
|
+
backend,
|
|
911
|
+
searchDurationMs: endTime - startTime,
|
|
912
|
+
queriesExecuted,
|
|
913
|
+
matchesPerQuery,
|
|
914
|
+
encodingFallback
|
|
915
|
+
},
|
|
916
|
+
formattedOutput: '',
|
|
649
917
|
};
|
|
650
|
-
|
|
918
|
+
finalResult.formattedOutput = formatGrepResults(finalResult);
|
|
919
|
+
return finalResult.formattedOutput;
|
|
651
920
|
}
|
|
652
921
|
catch (error) {
|
|
653
922
|
throw new Error(`Grep search failed: ${error.message}`);
|