fast-context-mcp 1.0.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.
@@ -0,0 +1,553 @@
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 } 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
+ export class ToolExecutor {
40
+ /**
41
+ * @param {string} projectRoot
42
+ */
43
+ constructor(projectRoot) {
44
+ this.root = resolve(projectRoot);
45
+ /** @type {string[]} */
46
+ this.collectedRgPatterns = [];
47
+ }
48
+
49
+ /**
50
+ * Map virtual /codebase path to real filesystem path.
51
+ * @param {string} virtual
52
+ * @returns {string}
53
+ */
54
+ _real(virtual) {
55
+ if (virtual.startsWith("/codebase")) {
56
+ const rel = virtual.slice("/codebase".length).replace(/^\/+/, "");
57
+ return join(this.root, rel);
58
+ }
59
+ return virtual;
60
+ }
61
+
62
+ /**
63
+ * Truncate output to match Windsurf behavior:
64
+ * 50 line limit, 250 char per-line silent truncation.
65
+ * @param {string} text
66
+ * @returns {string}
67
+ */
68
+ static _truncate(text) {
69
+ const lines = text.split("\n");
70
+ const truncatedLines = [];
71
+ const limit = Math.min(lines.length, RESULT_MAX_LINES);
72
+ for (let i = 0; i < limit; i++) {
73
+ const line = lines[i];
74
+ truncatedLines.push(line.length > LINE_MAX_CHARS ? line.slice(0, LINE_MAX_CHARS) : line);
75
+ }
76
+ let result = truncatedLines.join("\n");
77
+ if (lines.length > RESULT_MAX_LINES) {
78
+ result += "\n... (lines truncated) ...";
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Replace real project root with /codebase in output.
85
+ * @param {string} text
86
+ * @returns {string}
87
+ */
88
+ _remap(text) {
89
+ // Replace both forward-slash and native-sep versions
90
+ return text.replaceAll(this.root, "/codebase");
91
+ }
92
+
93
+ /**
94
+ * Check if a file matches any glob pattern (simplified fnmatch).
95
+ * @param {string} relPath
96
+ * @param {string} filename
97
+ * @param {string[]} patterns
98
+ * @returns {boolean}
99
+ */
100
+ static _globMatch(relPath, filename, patterns) {
101
+ for (const pat of patterns) {
102
+ const normalized = pat.replace(/\\/g, "/");
103
+ if (normalized.startsWith("**/")) {
104
+ const sub = normalized.slice(3);
105
+ if (sub.includes("/**")) continue; // directory pattern, handled by skipDirs
106
+ if (_fnmatch(filename, sub)) return true;
107
+ } else if (_fnmatch(relPath, normalized)) {
108
+ return true;
109
+ } else if (_fnmatch(filename, normalized)) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Search for pattern using @vscode/ripgrep (async version).
118
+ * @param {string} pattern
119
+ * @param {string} path
120
+ * @param {string[]|null} [include]
121
+ * @param {string[]|null} [exclude]
122
+ * @returns {Promise<string>}
123
+ */
124
+ async rgAsync(pattern, path, include = null, exclude = null) {
125
+ this.collectedRgPatterns.push(pattern);
126
+ const rp = this._real(path);
127
+ if (!existsSync(rp)) {
128
+ return `Error: path does not exist: ${path}`;
129
+ }
130
+
131
+ const args = ["--no-heading", "-n", "--max-count", "50", pattern, rp];
132
+ if (include) {
133
+ for (const g of include) {
134
+ args.push("--glob", g);
135
+ }
136
+ }
137
+ if (exclude) {
138
+ for (const g of exclude) {
139
+ args.push("--glob", `!${g}`);
140
+ }
141
+ }
142
+
143
+ try {
144
+ const { stdout } = await execFileAsync(rgPath, args, {
145
+ timeout: 30000,
146
+ maxBuffer: 10 * 1024 * 1024,
147
+ env: { ...process.env, RIPGREP_CONFIG_PATH: "" },
148
+ encoding: "utf-8",
149
+ });
150
+ return ToolExecutor._truncate(this._remap(stdout || "(no matches)"));
151
+ } catch (err) {
152
+ if (err.code === 1 || err.status === 1) {
153
+ return "(no matches)";
154
+ }
155
+ if (err.stderr) {
156
+ return ToolExecutor._truncate(this._remap(err.stderr));
157
+ }
158
+ return `Error: ${err.message}`;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Search for pattern using @vscode/ripgrep.
164
+ * @param {string} pattern
165
+ * @param {string} path
166
+ * @param {string[]|null} [include]
167
+ * @param {string[]|null} [exclude]
168
+ * @returns {string}
169
+ */
170
+ rg(pattern, path, include = null, exclude = null) {
171
+ this.collectedRgPatterns.push(pattern);
172
+ const rp = this._real(path);
173
+ if (!existsSync(rp)) {
174
+ return `Error: path does not exist: ${path}`;
175
+ }
176
+
177
+ const args = ["--no-heading", "-n", "--max-count", "50", pattern, rp];
178
+ if (include) {
179
+ for (const g of include) {
180
+ args.push("--glob", g);
181
+ }
182
+ }
183
+ if (exclude) {
184
+ for (const g of exclude) {
185
+ args.push("--glob", `!${g}`);
186
+ }
187
+ }
188
+
189
+ try {
190
+ const stdout = execFileSync(rgPath, args, {
191
+ timeout: 30000,
192
+ maxBuffer: 10 * 1024 * 1024,
193
+ env: { ...process.env, RIPGREP_CONFIG_PATH: "" },
194
+ encoding: "utf-8",
195
+ });
196
+ return ToolExecutor._truncate(this._remap(stdout || "(no matches)"));
197
+ } catch (err) {
198
+ // rg exits with code 1 when no matches found — that's normal
199
+ if (err.status === 1) {
200
+ return "(no matches)";
201
+ }
202
+ // rg exits with code 2 on errors
203
+ if (err.stderr) {
204
+ return ToolExecutor._truncate(this._remap(err.stderr));
205
+ }
206
+ return `Error: ${err.message}`;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Read file contents with optional line range (1-indexed, inclusive).
212
+ * @param {string} file
213
+ * @param {number|null} [startLine]
214
+ * @param {number|null} [endLine]
215
+ * @returns {string}
216
+ */
217
+ readfile(file, startLine = null, endLine = null) {
218
+ const rp = this._real(file);
219
+ try {
220
+ const stat = statSync(rp);
221
+ if (!stat.isFile()) {
222
+ return `Error: file not found: ${file}`;
223
+ }
224
+ } catch {
225
+ return `Error: file not found: ${file}`;
226
+ }
227
+
228
+ let content;
229
+ try {
230
+ content = readFileSync(rp, "utf-8");
231
+ } catch (e) {
232
+ return `Error: ${e.message}`;
233
+ }
234
+
235
+ const allLines = content.split("\n");
236
+ // If the file ends with a newline, there'll be an empty string at the end
237
+ // Keep behavior consistent with Python readlines()
238
+ const s = (startLine || 1) - 1;
239
+ const e = endLine || allLines.length;
240
+ const selected = allLines.slice(s, e);
241
+ const out = selected.map((line, idx) => `${s + idx + 1}:${line}`).join("\n");
242
+ return ToolExecutor._truncate(out);
243
+ }
244
+
245
+ /**
246
+ * Display directory structure as a tree.
247
+ * @param {string} path
248
+ * @param {number|null} [levels]
249
+ * @returns {string}
250
+ */
251
+ tree(path, levels = null) {
252
+ const rp = this._real(path);
253
+ try {
254
+ const stat = statSync(rp);
255
+ if (!stat.isDirectory()) {
256
+ return `Error: dir not found: ${path}`;
257
+ }
258
+ } catch {
259
+ return `Error: dir not found: ${path}`;
260
+ }
261
+
262
+ try {
263
+ const opts = {};
264
+ if (levels) opts.maxDepth = levels;
265
+ let stdout = treeNodeCli(rp, opts);
266
+ // tree-node-cli outputs basename as root line; replace with virtual path
267
+ const dirName = rp.split("/").pop() || rp.split("\\").pop() || rp;
268
+ const lines = stdout.split("\n");
269
+ if (lines[0] === dirName) {
270
+ lines[0] = path;
271
+ stdout = lines.join("\n");
272
+ }
273
+ return ToolExecutor._truncate(this._remap(stdout));
274
+ } catch {
275
+ return `Error: failed to generate tree for ${path}`;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * List files in a directory.
281
+ * @param {string} path
282
+ * @param {boolean} [longFormat=false]
283
+ * @param {boolean} [allFiles=false]
284
+ * @returns {string}
285
+ */
286
+ ls(path, longFormat = false, allFiles = false) {
287
+ const rp = this._real(path);
288
+ try {
289
+ const stat = statSync(rp);
290
+ if (!stat.isDirectory()) {
291
+ return `Error: not a directory: ${path}`;
292
+ }
293
+ } catch {
294
+ return `Error: dir not found: ${path}`;
295
+ }
296
+
297
+ let entries;
298
+ try {
299
+ entries = readdirSync(rp).sort();
300
+ } catch (e) {
301
+ return `Error: ${e.message}`;
302
+ }
303
+
304
+ if (!allFiles) {
305
+ entries = entries.filter((e) => !e.startsWith("."));
306
+ }
307
+
308
+ if (!longFormat) {
309
+ return ToolExecutor._truncate(entries.join("\n"));
310
+ }
311
+
312
+ // Long format: emulate ls -l output
313
+ const lines = [`total ${entries.length}`];
314
+ for (const name of entries) {
315
+ const fp = join(rp, name);
316
+ try {
317
+ const st = statSync(fp);
318
+ const isDir = st.isDirectory();
319
+ const type = isDir ? "d" : "-";
320
+ const perm = "rwxr-xr-x";
321
+ const size = String(st.size).padStart(8);
322
+ const mtime = st.mtime;
323
+ const month = mtime.toLocaleString("en", { month: "short" });
324
+ const day = String(mtime.getDate()).padStart(2);
325
+ const hh = String(mtime.getHours()).padStart(2, "0");
326
+ const mm = String(mtime.getMinutes()).padStart(2, "0");
327
+ const dateStr = `${month} ${day} ${hh}:${mm}`;
328
+ lines.push(`${type}${perm} 1 user staff ${size} ${dateStr} ${name}`);
329
+ } catch {
330
+ lines.push(`?--------- ? ? ? ? ? ? ? ${name}`);
331
+ }
332
+ }
333
+ return ToolExecutor._truncate(this._remap(lines.join("\n")));
334
+ }
335
+
336
+ /**
337
+ * Glob pattern matching.
338
+ * @param {string} pattern
339
+ * @param {string} path
340
+ * @param {string} [typeFilter="all"]
341
+ * @returns {string}
342
+ */
343
+ glob(pattern, path, typeFilter = "all") {
344
+ const rp = this._real(path);
345
+
346
+ // Use recursive readdir + fnmatch since Node 22 globSync may not be available
347
+ const matches = [];
348
+ const fullPattern = join(rp, pattern).replace(/\\/g, "/");
349
+
350
+ try {
351
+ _globWalk(rp, pattern, matches, typeFilter);
352
+ } catch {
353
+ // fallback: try simple readdir
354
+ try {
355
+ const entries = readdirSync(rp);
356
+ for (const entry of entries) {
357
+ const fp = join(rp, entry);
358
+ if (_fnmatch(entry, pattern)) {
359
+ try {
360
+ const st = statSync(fp);
361
+ if (typeFilter === "file" && !st.isFile()) continue;
362
+ if (typeFilter === "directory" && !st.isDirectory()) continue;
363
+ matches.push(fp);
364
+ } catch { /* skip */ }
365
+ }
366
+ }
367
+ } catch { /* skip */ }
368
+ }
369
+
370
+ const sorted = matches.sort().slice(0, 100);
371
+ const out = sorted.map((m) => this._remap(m)).join("\n");
372
+ return out || "(no matches)";
373
+ }
374
+
375
+ /**
376
+ * Dispatch a command dict to the appropriate method (async).
377
+ * Uses async rg for parallelism, sync for others (they are fast enough).
378
+ * @param {Object} cmd
379
+ * @returns {Promise<string>}
380
+ */
381
+ async execCommandAsync(cmd) {
382
+ const t = cmd.type || "";
383
+ switch (t) {
384
+ case "rg":
385
+ return this.rgAsync(cmd.pattern, cmd.path, cmd.include || null, cmd.exclude || null);
386
+ case "readfile":
387
+ return this.readfile(cmd.file, cmd.start_line || null, cmd.end_line || null);
388
+ case "tree":
389
+ return this.tree(cmd.path, cmd.levels || null);
390
+ case "ls":
391
+ return this.ls(cmd.path, cmd.long_format || false, cmd.all || false);
392
+ case "glob":
393
+ return this.glob(cmd.pattern, cmd.path, cmd.type_filter || "all");
394
+ default:
395
+ return `Error: unknown command type '${t}'`;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Dispatch a command dict to the appropriate method.
401
+ * @param {Object} cmd
402
+ * @returns {string}
403
+ */
404
+ execCommand(cmd) {
405
+ const t = cmd.type || "";
406
+ switch (t) {
407
+ case "rg":
408
+ return this.rg(cmd.pattern, cmd.path, cmd.include || null, cmd.exclude || null);
409
+ case "readfile":
410
+ return this.readfile(cmd.file, cmd.start_line || null, cmd.end_line || null);
411
+ case "tree":
412
+ return this.tree(cmd.path, cmd.levels || null);
413
+ case "ls":
414
+ return this.ls(cmd.path, cmd.long_format || false, cmd.all || false);
415
+ case "glob":
416
+ return this.glob(cmd.pattern, cmd.path, cmd.type_filter || "all");
417
+ default:
418
+ return `Error: unknown command type '${t}'`;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Execute all commandN keys from a tool call args dict (parallel).
424
+ * @param {Object} args
425
+ * @returns {Promise<string>}
426
+ */
427
+ async execToolCallAsync(args) {
428
+ const keys = Object.keys(args).filter((k) => k.startsWith("command")).sort();
429
+ const tasks = keys
430
+ .filter((key) => typeof args[key] === "object")
431
+ .map(async (key) => {
432
+ const output = await this.execCommandAsync(args[key]);
433
+ return `<${key}_result>\n${output}\n</${key}_result>`;
434
+ });
435
+ const results = await Promise.all(tasks);
436
+ return results.join("");
437
+ }
438
+
439
+ /**
440
+ * Execute all commandN keys from a tool call args dict.
441
+ * @param {Object} args
442
+ * @returns {string}
443
+ */
444
+ execToolCall(args) {
445
+ const parts = [];
446
+ const keys = Object.keys(args).filter((k) => k.startsWith("command")).sort();
447
+ for (const key of keys) {
448
+ if (typeof args[key] === "object") {
449
+ const output = this.execCommand(args[key]);
450
+ parts.push(`<${key}_result>\n${output}\n</${key}_result>`);
451
+ }
452
+ }
453
+ return parts.join("");
454
+ }
455
+ }
456
+
457
+ // ─── Helpers ───────────────────────────────────────────────
458
+
459
+ /**
460
+ * Simple fnmatch-like glob matching.
461
+ * Supports *, ?, and ** patterns.
462
+ * @param {string} str
463
+ * @param {string} pattern
464
+ * @returns {boolean}
465
+ */
466
+ function _fnmatch(str, pattern) {
467
+ // Convert glob pattern to regex
468
+ let regex = "^";
469
+ let i = 0;
470
+ while (i < pattern.length) {
471
+ const c = pattern[i];
472
+ if (c === "*") {
473
+ if (pattern[i + 1] === "*") {
474
+ // ** matches everything including /
475
+ regex += ".*";
476
+ i += 2;
477
+ if (pattern[i] === "/") i++; // skip trailing /
478
+ continue;
479
+ }
480
+ regex += "[^/]*";
481
+ } else if (c === "?") {
482
+ regex += "[^/]";
483
+ } else if (c === "[") {
484
+ // Pass through character classes
485
+ const end = pattern.indexOf("]", i);
486
+ if (end === -1) {
487
+ regex += "\\[";
488
+ } else {
489
+ regex += pattern.slice(i, end + 1);
490
+ i = end;
491
+ }
492
+ } else if (".+^${}()|\\".includes(c)) {
493
+ regex += "\\" + c;
494
+ } else {
495
+ regex += c;
496
+ }
497
+ i++;
498
+ }
499
+ regex += "$";
500
+ try {
501
+ return new RegExp(regex).test(str);
502
+ } catch {
503
+ return false;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Recursive glob walk.
509
+ * @param {string} base
510
+ * @param {string} pattern
511
+ * @param {string[]} matches
512
+ * @param {string} typeFilter
513
+ */
514
+ function _globWalk(base, pattern, matches, typeFilter) {
515
+ const isRecursive = pattern.includes("**");
516
+
517
+ const walk = (dir, depth) => {
518
+ if (matches.length >= 100) return;
519
+ if (!isRecursive && depth > 0) return;
520
+
521
+ let entries;
522
+ try {
523
+ entries = readdirSync(dir);
524
+ } catch {
525
+ return;
526
+ }
527
+
528
+ for (const entry of entries) {
529
+ if (matches.length >= 100) return;
530
+ const fp = join(dir, entry);
531
+ const relFromBase = relative(base, fp).replace(/\\/g, "/");
532
+
533
+ let st;
534
+ try {
535
+ st = statSync(fp);
536
+ } catch {
537
+ continue;
538
+ }
539
+
540
+ if (_fnmatch(relFromBase, pattern) || _fnmatch(entry, pattern)) {
541
+ if (typeFilter === "file" && !st.isFile()) continue;
542
+ if (typeFilter === "directory" && !st.isDirectory()) continue;
543
+ matches.push(fp);
544
+ }
545
+
546
+ if (st.isDirectory() && !entry.startsWith(".") && isRecursive) {
547
+ walk(fp, depth + 1);
548
+ }
549
+ }
550
+ };
551
+
552
+ walk(base, 0);
553
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Windsurf API Key extraction from local installation.
3
+ *
4
+ * Cross-platform: macOS / Windows / Linux.
5
+ * Uses better-sqlite3 to read state.vscdb.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir, platform } from "node:os";
11
+ import Database from "better-sqlite3";
12
+
13
+ /**
14
+ * Get the platform-specific path to Windsurf's state.vscdb.
15
+ * @returns {string}
16
+ */
17
+ export function getDbPath() {
18
+ const plat = platform();
19
+ const home = homedir();
20
+
21
+ if (plat === "darwin") {
22
+ return join(home, "Library", "Application Support", "Windsurf", "User", "globalStorage", "state.vscdb");
23
+ } else if (plat === "win32") {
24
+ const appdata = process.env.APPDATA || "";
25
+ if (!appdata) throw new Error("Cannot determine APPDATA path");
26
+ return join(appdata, "Windsurf", "User", "globalStorage", "state.vscdb");
27
+ } else {
28
+ // Linux
29
+ const config = process.env.XDG_CONFIG_HOME || join(home, ".config");
30
+ return join(config, "Windsurf", "User", "globalStorage", "state.vscdb");
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extract API Key from Windsurf state.vscdb.
36
+ * @param {string} [dbPath]
37
+ * @returns {{ api_key?: string, db_path: string, error?: string, hint?: string }}
38
+ */
39
+ export function extractKey(dbPath) {
40
+ if (!dbPath) {
41
+ dbPath = getDbPath();
42
+ }
43
+
44
+ if (!existsSync(dbPath)) {
45
+ return {
46
+ error: `Windsurf database not found: ${dbPath}`,
47
+ hint: "Ensure Windsurf is installed and logged in.",
48
+ db_path: dbPath,
49
+ };
50
+ }
51
+
52
+ let row;
53
+ try {
54
+ const db = new Database(dbPath, { readonly: true });
55
+ row = db.prepare("SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus'").get();
56
+ db.close();
57
+ } catch (e) {
58
+ return { error: `Failed to read database: ${e.message}`, db_path: dbPath };
59
+ }
60
+
61
+ if (!row) {
62
+ return {
63
+ error: "windsurfAuthStatus record not found",
64
+ hint: "Ensure Windsurf is logged in.",
65
+ db_path: dbPath,
66
+ };
67
+ }
68
+
69
+ let data;
70
+ try {
71
+ data = JSON.parse(row.value);
72
+ } catch {
73
+ return { error: "windsurfAuthStatus data parse failed", db_path: dbPath };
74
+ }
75
+
76
+ const apiKey = data.apiKey || "";
77
+ if (!apiKey) {
78
+ return { error: "apiKey field is empty", db_path: dbPath };
79
+ }
80
+
81
+ return { api_key: apiKey, db_path: dbPath };
82
+ }