fast-context-skill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +12 -0
- package/README.md +172 -0
- package/SKILL.md +116 -0
- package/package.json +34 -0
- package/references/script-contract.md +70 -0
- package/src/cli.mjs +348 -0
- package/src/config.mjs +40 -0
- package/src/core.mjs +2246 -0
- package/src/directory-scorer.mjs +1086 -0
- package/src/executor.mjs +659 -0
- package/src/extract-key.mjs +93 -0
- package/src/project-path.mjs +47 -0
- package/src/protobuf.mjs +235 -0
package/src/executor.mjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool executor for Windsurf's restricted commands.
|
|
3
|
+
*
|
|
4
|
+
* Uses @vscode/ripgrep for built-in rg binary — no system install needed.
|
|
5
|
+
* Matches Python ToolExecutor behavior exactly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync, execFile as execFileCb } from "node:child_process";
|
|
9
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
10
|
+
import { join, resolve, relative, sep, basename, isAbsolute } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { rgPath } from "@vscode/ripgrep";
|
|
13
|
+
import treeNodeCli from "tree-node-cli";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFileCb);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an integer env var with optional clamping.
|
|
19
|
+
* @param {string} name
|
|
20
|
+
* @param {number} defaultValue
|
|
21
|
+
* @param {{ min?: number, max?: number }} [opts]
|
|
22
|
+
* @returns {number}
|
|
23
|
+
*/
|
|
24
|
+
function readIntEnv(name, defaultValue, opts = {}) {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
27
|
+
if (!Number.isFinite(parsed)) return defaultValue;
|
|
28
|
+
const min = typeof opts.min === "number" ? opts.min : null;
|
|
29
|
+
const max = typeof opts.max === "number" ? opts.max : null;
|
|
30
|
+
let value = parsed;
|
|
31
|
+
if (min !== null) value = Math.max(min, value);
|
|
32
|
+
if (max !== null) value = Math.min(max, value);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESULT_MAX_LINES = readIntEnv("FC_RESULT_MAX_LINES", 50, { min: 1, max: 500 });
|
|
37
|
+
const LINE_MAX_CHARS = readIntEnv("FC_LINE_MAX_CHARS", 250, { min: 20, max: 10000 });
|
|
38
|
+
|
|
39
|
+
function expandExcludeGlobs(pattern) {
|
|
40
|
+
if (typeof pattern !== "string") return [];
|
|
41
|
+
const normalized = pattern.trim().replace(/\\/g, "/");
|
|
42
|
+
if (!normalized) return [];
|
|
43
|
+
const expanded = [normalized];
|
|
44
|
+
if (!normalized.startsWith("**/") && !normalized.startsWith("/")) {
|
|
45
|
+
expanded.push(`**/${normalized}`);
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(expanded)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ToolExecutor {
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} projectRoot
|
|
53
|
+
*/
|
|
54
|
+
constructor(projectRoot) {
|
|
55
|
+
this.root = resolve(projectRoot);
|
|
56
|
+
/** @type {string[]} */
|
|
57
|
+
this.collectedRgPatterns = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map virtual /codebase path to real filesystem path.
|
|
62
|
+
* @param {string} virtual
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
_real(virtual) {
|
|
66
|
+
if (virtual == null || typeof virtual !== "string") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const normalized = virtual.trim().replace(/\\/g, "/");
|
|
70
|
+
if (!normalized.startsWith("/codebase")) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (normalized !== "/codebase" && !normalized.startsWith("/codebase/")) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const rel = normalized.slice("/codebase".length).replace(/^[\/\\]+/, "");
|
|
77
|
+
const abs = resolve(this.root, rel);
|
|
78
|
+
const relToRoot = relative(this.root, abs);
|
|
79
|
+
if (relToRoot === ".." || relToRoot.startsWith(`..${sep}`) || isAbsolute(relToRoot)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return abs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a root-boundary error.
|
|
87
|
+
* @param {string} kind
|
|
88
|
+
* @param {string} value
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
static _pathError(kind, value) {
|
|
92
|
+
return `Error: ${kind} must stay within /codebase: ${value}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Truncate output to match Windsurf behavior:
|
|
97
|
+
* 50 line limit, 250 char per-line silent truncation.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
static _truncate(text) {
|
|
102
|
+
const lines = text.split("\n");
|
|
103
|
+
const truncatedLines = [];
|
|
104
|
+
const limit = Math.min(lines.length, RESULT_MAX_LINES);
|
|
105
|
+
for (let i = 0; i < limit; i++) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
truncatedLines.push(line.length > LINE_MAX_CHARS ? line.slice(0, LINE_MAX_CHARS) : line);
|
|
108
|
+
}
|
|
109
|
+
let result = truncatedLines.join("\n");
|
|
110
|
+
if (lines.length > RESULT_MAX_LINES) {
|
|
111
|
+
result += "\n... (lines truncated) ...";
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Replace real project root with /codebase in output.
|
|
118
|
+
* @param {string} text
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
_remap(text) {
|
|
122
|
+
// Replace both forward-slash and native-sep (Windows \) versions of root path
|
|
123
|
+
let result = text.replaceAll(this.root, "/codebase");
|
|
124
|
+
if (sep === "\\") {
|
|
125
|
+
// Windows: resolve() returns backslash paths, but rg output may use either
|
|
126
|
+
result = result.replaceAll(this.root.replaceAll("\\", "/"), "/codebase");
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if a file matches any glob pattern (simplified fnmatch).
|
|
133
|
+
* @param {string} relPath
|
|
134
|
+
* @param {string} filename
|
|
135
|
+
* @param {string[]} patterns
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
static _globMatch(relPath, filename, patterns) {
|
|
139
|
+
for (const pat of patterns) {
|
|
140
|
+
const normalized = pat.replace(/\\/g, "/");
|
|
141
|
+
if (normalized.startsWith("**/")) {
|
|
142
|
+
const sub = normalized.slice(3);
|
|
143
|
+
if (sub.includes("/**")) continue; // directory pattern, handled by skipDirs
|
|
144
|
+
if (_fnmatch(filename, sub)) return true;
|
|
145
|
+
} else if (_fnmatch(relPath, normalized)) {
|
|
146
|
+
return true;
|
|
147
|
+
} else if (_fnmatch(filename, normalized)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Search for pattern using @vscode/ripgrep (async version).
|
|
156
|
+
* @param {string} pattern
|
|
157
|
+
* @param {string} path
|
|
158
|
+
* @param {string[]|null} [include]
|
|
159
|
+
* @param {string[]|null} [exclude]
|
|
160
|
+
* @returns {Promise<string>}
|
|
161
|
+
*/
|
|
162
|
+
async rgAsync(pattern, path, include = null, exclude = null) {
|
|
163
|
+
if (!pattern || typeof pattern !== "string") {
|
|
164
|
+
return "Error: missing or invalid pattern";
|
|
165
|
+
}
|
|
166
|
+
if (!path || typeof path !== "string") {
|
|
167
|
+
return "Error: missing or invalid path";
|
|
168
|
+
}
|
|
169
|
+
this.collectedRgPatterns.push(pattern);
|
|
170
|
+
const rp = this._real(path);
|
|
171
|
+
if (!rp) {
|
|
172
|
+
return ToolExecutor._pathError("path", path);
|
|
173
|
+
}
|
|
174
|
+
if (!existsSync(rp)) {
|
|
175
|
+
return `Error: path does not exist: ${path}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const args = ["--no-heading", "-n", "--max-count", "50"];
|
|
179
|
+
if (include) {
|
|
180
|
+
for (const g of include) {
|
|
181
|
+
args.push("--glob", g);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (exclude) {
|
|
185
|
+
for (const g of exclude) {
|
|
186
|
+
for (const ex of expandExcludeGlobs(g)) {
|
|
187
|
+
args.push("--glob", `!${ex}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
args.push("--", pattern, rp);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const { stdout } = await execFileAsync(rgPath, args, {
|
|
195
|
+
timeout: 30000,
|
|
196
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
197
|
+
env: { ...process.env, RIPGREP_CONFIG_PATH: "" },
|
|
198
|
+
encoding: "utf-8",
|
|
199
|
+
});
|
|
200
|
+
return ToolExecutor._truncate(this._remap(stdout || "(no matches)"));
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code === 1 || err.status === 1) {
|
|
203
|
+
return "(no matches)";
|
|
204
|
+
}
|
|
205
|
+
if (err.stderr) {
|
|
206
|
+
return ToolExecutor._truncate(this._remap(err.stderr));
|
|
207
|
+
}
|
|
208
|
+
return `Error: ${err.message}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Search for pattern using @vscode/ripgrep.
|
|
214
|
+
* @param {string} pattern
|
|
215
|
+
* @param {string} path
|
|
216
|
+
* @param {string[]|null} [include]
|
|
217
|
+
* @param {string[]|null} [exclude]
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
rg(pattern, path, include = null, exclude = null) {
|
|
221
|
+
if (!pattern || typeof pattern !== "string") {
|
|
222
|
+
return "Error: missing or invalid pattern";
|
|
223
|
+
}
|
|
224
|
+
if (!path || typeof path !== "string") {
|
|
225
|
+
return "Error: missing or invalid path";
|
|
226
|
+
}
|
|
227
|
+
this.collectedRgPatterns.push(pattern);
|
|
228
|
+
const rp = this._real(path);
|
|
229
|
+
if (!rp) {
|
|
230
|
+
return ToolExecutor._pathError("path", path);
|
|
231
|
+
}
|
|
232
|
+
if (!existsSync(rp)) {
|
|
233
|
+
return `Error: path does not exist: ${path}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const args = ["--no-heading", "-n", "--max-count", "50"];
|
|
237
|
+
if (include) {
|
|
238
|
+
for (const g of include) {
|
|
239
|
+
args.push("--glob", g);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (exclude) {
|
|
243
|
+
for (const g of exclude) {
|
|
244
|
+
for (const ex of expandExcludeGlobs(g)) {
|
|
245
|
+
args.push("--glob", `!${ex}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
args.push("--", pattern, rp);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const stdout = execFileSync(rgPath, args, {
|
|
253
|
+
timeout: 30000,
|
|
254
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
255
|
+
env: { ...process.env, RIPGREP_CONFIG_PATH: "" },
|
|
256
|
+
encoding: "utf-8",
|
|
257
|
+
});
|
|
258
|
+
return ToolExecutor._truncate(this._remap(stdout || "(no matches)"));
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// rg exits with code 1 when no matches found — that's normal
|
|
261
|
+
if (err.status === 1) {
|
|
262
|
+
return "(no matches)";
|
|
263
|
+
}
|
|
264
|
+
// rg exits with code 2 on errors
|
|
265
|
+
if (err.stderr) {
|
|
266
|
+
return ToolExecutor._truncate(this._remap(err.stderr));
|
|
267
|
+
}
|
|
268
|
+
return `Error: ${err.message}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Read file contents with optional line range (1-indexed, inclusive).
|
|
274
|
+
* @param {string} file
|
|
275
|
+
* @param {number|null} [startLine]
|
|
276
|
+
* @param {number|null} [endLine]
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
readfile(file, startLine = null, endLine = null) {
|
|
280
|
+
if (!file || typeof file !== "string") {
|
|
281
|
+
return "Error: missing or invalid file path";
|
|
282
|
+
}
|
|
283
|
+
const rp = this._real(file);
|
|
284
|
+
if (!rp) {
|
|
285
|
+
return ToolExecutor._pathError("file path", file);
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const stat = statSync(rp);
|
|
289
|
+
if (!stat.isFile()) {
|
|
290
|
+
return `Error: file not found: ${file}`;
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
return `Error: file not found: ${file}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let content;
|
|
297
|
+
try {
|
|
298
|
+
content = readFileSync(rp, "utf-8");
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return `Error: ${e.message}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const allLines = content.split("\n");
|
|
304
|
+
// If the file ends with a newline, there'll be an empty string at the end
|
|
305
|
+
// Keep behavior consistent with Python readlines()
|
|
306
|
+
const s = (startLine || 1) - 1;
|
|
307
|
+
const e = endLine || allLines.length;
|
|
308
|
+
const selected = allLines.slice(s, e);
|
|
309
|
+
const out = selected.map((line, idx) => `${s + idx + 1}:${line}`).join("\n");
|
|
310
|
+
return ToolExecutor._truncate(out);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Display directory structure as a tree.
|
|
315
|
+
* @param {string} path
|
|
316
|
+
* @param {number|null} [levels]
|
|
317
|
+
* @returns {string}
|
|
318
|
+
*/
|
|
319
|
+
tree(path, levels = null) {
|
|
320
|
+
if (!path || typeof path !== "string") {
|
|
321
|
+
return "Error: missing or invalid path";
|
|
322
|
+
}
|
|
323
|
+
const rp = this._real(path);
|
|
324
|
+
if (!rp) {
|
|
325
|
+
return ToolExecutor._pathError("path", path);
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const stat = statSync(rp);
|
|
329
|
+
if (!stat.isDirectory()) {
|
|
330
|
+
return `Error: dir not found: ${path}`;
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
return `Error: dir not found: ${path}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const opts = {};
|
|
338
|
+
if (levels) opts.maxDepth = levels;
|
|
339
|
+
let stdout = treeNodeCli(rp, opts);
|
|
340
|
+
// Two-step normalization:
|
|
341
|
+
// 1. _remap: replace absolute project root with /codebase globally
|
|
342
|
+
stdout = this._remap(stdout);
|
|
343
|
+
// 2. Handle basename root line: tree-node-cli outputs the directory
|
|
344
|
+
// basename as the first line (e.g. "supabase"), which _remap won't
|
|
345
|
+
// catch since it's not the full absolute path. Replace with the
|
|
346
|
+
// virtual path the AI requested (already /codebase/...).
|
|
347
|
+
const dirName = basename(rp);
|
|
348
|
+
const lines = stdout.split("\n");
|
|
349
|
+
if (lines[0] === dirName) {
|
|
350
|
+
lines[0] = path;
|
|
351
|
+
stdout = lines.join("\n");
|
|
352
|
+
}
|
|
353
|
+
return ToolExecutor._truncate(stdout);
|
|
354
|
+
} catch {
|
|
355
|
+
return `Error: failed to generate tree for ${path}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* List files in a directory.
|
|
361
|
+
* @param {string} path
|
|
362
|
+
* @param {boolean} [longFormat=false]
|
|
363
|
+
* @param {boolean} [allFiles=false]
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
ls(path, longFormat = false, allFiles = false) {
|
|
367
|
+
if (!path || typeof path !== "string") {
|
|
368
|
+
return "Error: missing or invalid path";
|
|
369
|
+
}
|
|
370
|
+
const rp = this._real(path);
|
|
371
|
+
if (!rp) {
|
|
372
|
+
return ToolExecutor._pathError("path", path);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const stat = statSync(rp);
|
|
376
|
+
if (!stat.isDirectory()) {
|
|
377
|
+
return `Error: not a directory: ${path}`;
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
return `Error: dir not found: ${path}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let entries;
|
|
384
|
+
try {
|
|
385
|
+
entries = readdirSync(rp).sort();
|
|
386
|
+
} catch (e) {
|
|
387
|
+
return `Error: ${e.message}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!allFiles) {
|
|
391
|
+
entries = entries.filter((e) => !e.startsWith("."));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!longFormat) {
|
|
395
|
+
return ToolExecutor._truncate(entries.join("\n"));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Long format: emulate ls -l output
|
|
399
|
+
const lines = [`total ${entries.length}`];
|
|
400
|
+
for (const name of entries) {
|
|
401
|
+
const fp = join(rp, name);
|
|
402
|
+
try {
|
|
403
|
+
const st = statSync(fp);
|
|
404
|
+
const isDir = st.isDirectory();
|
|
405
|
+
const type = isDir ? "d" : "-";
|
|
406
|
+
const perm = "rwxr-xr-x";
|
|
407
|
+
const size = String(st.size).padStart(8);
|
|
408
|
+
const mtime = st.mtime;
|
|
409
|
+
const month = mtime.toLocaleString("en", { month: "short" });
|
|
410
|
+
const day = String(mtime.getDate()).padStart(2);
|
|
411
|
+
const hh = String(mtime.getHours()).padStart(2, "0");
|
|
412
|
+
const mm = String(mtime.getMinutes()).padStart(2, "0");
|
|
413
|
+
const dateStr = `${month} ${day} ${hh}:${mm}`;
|
|
414
|
+
lines.push(`${type}${perm} 1 user staff ${size} ${dateStr} ${name}`);
|
|
415
|
+
} catch {
|
|
416
|
+
lines.push(`?--------- ? ? ? ? ? ? ? ${name}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return ToolExecutor._truncate(this._remap(lines.join("\n")));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Glob pattern matching.
|
|
424
|
+
* @param {string} pattern
|
|
425
|
+
* @param {string} path
|
|
426
|
+
* @param {string} [typeFilter="all"]
|
|
427
|
+
* @returns {string}
|
|
428
|
+
*/
|
|
429
|
+
glob(pattern, path, typeFilter = "all") {
|
|
430
|
+
if (!pattern || typeof pattern !== "string") {
|
|
431
|
+
return "Error: missing or invalid pattern";
|
|
432
|
+
}
|
|
433
|
+
if (!path || typeof path !== "string") {
|
|
434
|
+
return "Error: missing or invalid path";
|
|
435
|
+
}
|
|
436
|
+
const rp = this._real(path);
|
|
437
|
+
if (!rp) {
|
|
438
|
+
return ToolExecutor._pathError("path", path);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Use recursive readdir + fnmatch since Node 22 globSync may not be available
|
|
442
|
+
const matches = [];
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
_globWalk(rp, pattern, matches, typeFilter);
|
|
446
|
+
} catch {
|
|
447
|
+
// fallback: try simple readdir
|
|
448
|
+
try {
|
|
449
|
+
const entries = readdirSync(rp);
|
|
450
|
+
for (const entry of entries) {
|
|
451
|
+
const fp = join(rp, entry);
|
|
452
|
+
if (_fnmatch(entry, pattern)) {
|
|
453
|
+
try {
|
|
454
|
+
const st = statSync(fp);
|
|
455
|
+
if (typeFilter === "file" && !st.isFile()) continue;
|
|
456
|
+
if (typeFilter === "directory" && !st.isDirectory()) continue;
|
|
457
|
+
matches.push(fp);
|
|
458
|
+
} catch { /* skip */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch { /* skip */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const sorted = matches.sort().slice(0, 100);
|
|
465
|
+
const out = sorted.map((m) => this._remap(m)).join("\n");
|
|
466
|
+
return out || "(no matches)";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Dispatch a command dict to the appropriate method (async).
|
|
471
|
+
* Uses async rg for parallelism, sync for others (they are fast enough).
|
|
472
|
+
* @param {Object} cmd
|
|
473
|
+
* @returns {Promise<string>}
|
|
474
|
+
*/
|
|
475
|
+
async execCommandAsync(cmd) {
|
|
476
|
+
if (!cmd || typeof cmd !== "object") {
|
|
477
|
+
return "Error: missing or invalid command";
|
|
478
|
+
}
|
|
479
|
+
const t = cmd.type || "";
|
|
480
|
+
switch (t) {
|
|
481
|
+
case "rg":
|
|
482
|
+
return this.rgAsync(cmd.pattern, cmd.path, cmd.include || null, cmd.exclude || null);
|
|
483
|
+
case "readfile":
|
|
484
|
+
return this.readfile(cmd.file, cmd.start_line || null, cmd.end_line || null);
|
|
485
|
+
case "tree":
|
|
486
|
+
return this.tree(cmd.path, cmd.levels || null);
|
|
487
|
+
case "ls":
|
|
488
|
+
return this.ls(cmd.path, cmd.long_format || false, cmd.all || false);
|
|
489
|
+
case "glob":
|
|
490
|
+
return this.glob(cmd.pattern, cmd.path, cmd.type_filter || "all");
|
|
491
|
+
default:
|
|
492
|
+
return `Error: unknown command type '${t}'`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Dispatch a command dict to the appropriate method.
|
|
498
|
+
* @param {Object} cmd
|
|
499
|
+
* @returns {string}
|
|
500
|
+
*/
|
|
501
|
+
execCommand(cmd) {
|
|
502
|
+
if (!cmd || typeof cmd !== "object") {
|
|
503
|
+
return "Error: missing or invalid command";
|
|
504
|
+
}
|
|
505
|
+
const t = cmd.type || "";
|
|
506
|
+
switch (t) {
|
|
507
|
+
case "rg":
|
|
508
|
+
return this.rg(cmd.pattern, cmd.path, cmd.include || null, cmd.exclude || null);
|
|
509
|
+
case "readfile":
|
|
510
|
+
return this.readfile(cmd.file, cmd.start_line || null, cmd.end_line || null);
|
|
511
|
+
case "tree":
|
|
512
|
+
return this.tree(cmd.path, cmd.levels || null);
|
|
513
|
+
case "ls":
|
|
514
|
+
return this.ls(cmd.path, cmd.long_format || false, cmd.all || false);
|
|
515
|
+
case "glob":
|
|
516
|
+
return this.glob(cmd.pattern, cmd.path, cmd.type_filter || "all");
|
|
517
|
+
default:
|
|
518
|
+
return `Error: unknown command type '${t}'`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Execute all commandN keys from a tool call args dict (parallel).
|
|
524
|
+
* @param {Object} args
|
|
525
|
+
* @returns {Promise<string>}
|
|
526
|
+
*/
|
|
527
|
+
async execToolCallAsync(args) {
|
|
528
|
+
if (!args || typeof args !== "object") {
|
|
529
|
+
return "Error: missing or invalid tool args";
|
|
530
|
+
}
|
|
531
|
+
const keys = Object.keys(args)
|
|
532
|
+
.filter((k) => /^command\d+$/.test(k))
|
|
533
|
+
.sort((a, b) => parseInt(a.slice(7), 10) - parseInt(b.slice(7), 10));
|
|
534
|
+
const tasks = keys.map(async (key) => {
|
|
535
|
+
const output = await this.execCommandAsync(args[key]);
|
|
536
|
+
return `<${key}_result>\n${output}\n</${key}_result>`;
|
|
537
|
+
});
|
|
538
|
+
const results = await Promise.all(tasks);
|
|
539
|
+
return results.join("");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Execute all commandN keys from a tool call args dict.
|
|
544
|
+
* @param {Object} args
|
|
545
|
+
* @returns {string}
|
|
546
|
+
*/
|
|
547
|
+
execToolCall(args) {
|
|
548
|
+
const parts = [];
|
|
549
|
+
if (!args || typeof args !== "object") {
|
|
550
|
+
return "Error: missing or invalid tool args";
|
|
551
|
+
}
|
|
552
|
+
const keys = Object.keys(args)
|
|
553
|
+
.filter((k) => /^command\d+$/.test(k))
|
|
554
|
+
.sort((a, b) => parseInt(a.slice(7), 10) - parseInt(b.slice(7), 10));
|
|
555
|
+
for (const key of keys) {
|
|
556
|
+
const output = this.execCommand(args[key]);
|
|
557
|
+
parts.push(`<${key}_result>\n${output}\n</${key}_result>`);
|
|
558
|
+
}
|
|
559
|
+
return parts.join("");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Simple fnmatch-like glob matching.
|
|
567
|
+
* Supports *, ?, and ** patterns.
|
|
568
|
+
* @param {string} str
|
|
569
|
+
* @param {string} pattern
|
|
570
|
+
* @returns {boolean}
|
|
571
|
+
*/
|
|
572
|
+
function _fnmatch(str, pattern) {
|
|
573
|
+
// Convert glob pattern to regex
|
|
574
|
+
let regex = "^";
|
|
575
|
+
let i = 0;
|
|
576
|
+
while (i < pattern.length) {
|
|
577
|
+
const c = pattern[i];
|
|
578
|
+
if (c === "*") {
|
|
579
|
+
if (pattern[i + 1] === "*") {
|
|
580
|
+
// ** matches everything including /
|
|
581
|
+
regex += ".*";
|
|
582
|
+
i += 2;
|
|
583
|
+
if (pattern[i] === "/") i++; // skip trailing /
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
regex += "[^/]*";
|
|
587
|
+
} else if (c === "?") {
|
|
588
|
+
regex += "[^/]";
|
|
589
|
+
} else if (c === "[") {
|
|
590
|
+
// Pass through character classes
|
|
591
|
+
const end = pattern.indexOf("]", i);
|
|
592
|
+
if (end === -1) {
|
|
593
|
+
regex += "\\[";
|
|
594
|
+
} else {
|
|
595
|
+
regex += pattern.slice(i, end + 1);
|
|
596
|
+
i = end;
|
|
597
|
+
}
|
|
598
|
+
} else if (".+^${}()|\\".includes(c)) {
|
|
599
|
+
regex += "\\" + c;
|
|
600
|
+
} else {
|
|
601
|
+
regex += c;
|
|
602
|
+
}
|
|
603
|
+
i++;
|
|
604
|
+
}
|
|
605
|
+
regex += "$";
|
|
606
|
+
try {
|
|
607
|
+
return new RegExp(regex).test(str);
|
|
608
|
+
} catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Recursive glob walk.
|
|
615
|
+
* @param {string} base
|
|
616
|
+
* @param {string} pattern
|
|
617
|
+
* @param {string[]} matches
|
|
618
|
+
* @param {string} typeFilter
|
|
619
|
+
*/
|
|
620
|
+
function _globWalk(base, pattern, matches, typeFilter) {
|
|
621
|
+
const isRecursive = pattern.includes("**");
|
|
622
|
+
|
|
623
|
+
const walk = (dir, depth) => {
|
|
624
|
+
if (matches.length >= 100) return;
|
|
625
|
+
if (!isRecursive && depth > 0) return;
|
|
626
|
+
|
|
627
|
+
let entries;
|
|
628
|
+
try {
|
|
629
|
+
entries = readdirSync(dir);
|
|
630
|
+
} catch {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
if (matches.length >= 100) return;
|
|
636
|
+
const fp = join(dir, entry);
|
|
637
|
+
const relFromBase = relative(base, fp).replace(/\\/g, "/");
|
|
638
|
+
|
|
639
|
+
let st;
|
|
640
|
+
try {
|
|
641
|
+
st = statSync(fp);
|
|
642
|
+
} catch {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (_fnmatch(relFromBase, pattern) || _fnmatch(entry, pattern)) {
|
|
647
|
+
if (typeFilter === "file" && !st.isFile()) continue;
|
|
648
|
+
if (typeFilter === "directory" && !st.isDirectory()) continue;
|
|
649
|
+
matches.push(fp);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (st.isDirectory() && !entry.startsWith(".") && isRecursive) {
|
|
653
|
+
walk(fp, depth + 1);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
walk(base, 0);
|
|
659
|
+
}
|