Skip to main content
Glama
cli.ts43.5 kB
import { z } from 'zod'; import { exec } from 'child_process'; import fs from 'fs'; import path from 'path'; import { loadConfig } from '../config.js'; import { logAudit } from '../audit.js'; const config = loadConfig(); // Basic blocklist for safety even in YOLO mode const BLOCKLIST = [ 'rm -rf /', 'mkfs', ':(){:|:&};:', // fork bomb ...config.cliPolicy.extraBlockedPatterns ]; function isBlocked(command: string): boolean { return BLOCKLIST.some(pattern => command.includes(pattern)); } /** * Truncate output to prevent context stuffing. * Returns truncated output with metadata about what was cut. */ function truncateOutput(output: string, maxChars: number, mode: 'head' | 'tail' | 'both'): { text: string; truncated: boolean; originalLength: number } { if (output.length <= maxChars) { return { text: output, truncated: false, originalLength: output.length }; } const originalLength = output.length; let text: string; if (mode === 'head') { text = output.slice(0, maxChars); text += `\n\n⚠️ OUTPUT TRUNCATED: Showing first ${maxChars.toLocaleString()} of ${originalLength.toLocaleString()} characters.`; } else if (mode === 'tail') { text = output.slice(-maxChars); text = `⚠️ OUTPUT TRUNCATED: Showing last ${maxChars.toLocaleString()} of ${originalLength.toLocaleString()} characters.\n\n` + text; } else { // 'both' - keep head and tail for maximum context const headSize = Math.floor(maxChars * 0.6); // 60% from start const tailSize = maxChars - headSize; // 40% from end const head = output.slice(0, headSize); const tail = output.slice(-tailSize); const omitted = originalLength - headSize - tailSize; text = head + `\n\n⚠️ OUTPUT TRUNCATED: Omitted ${omitted.toLocaleString()} characters (${Math.round(omitted/originalLength*100)}% of output).\n` + `📊 Total: ${originalLength.toLocaleString()} chars | Showing: first ${headSize.toLocaleString()} + last ${tailSize.toLocaleString()}\n\n` + tail; } return { text, truncated: true, originalLength }; } export const ExecCliSchema = { command: z.string().describe('The command to execute'), cwd: z.string().optional().describe('Current working directory for the command'), }; export async function handleExecCli(args: { command: string; cwd?: string }) { const { command, cwd } = args; const outputConfig = config.cliOutput ?? { maxOutputChars: 50000, warnAtChars: 10000, truncateMode: 'both' as const }; if (isBlocked(command)) { const error = 'Command blocked by safety policy'; await logAudit('exec_cli', args, null, error); throw new Error(error); } return new Promise((resolve, reject) => { exec(command, { cwd: cwd || process.cwd(), timeout: config.cliPolicy.timeoutMs, maxBuffer: 10 * 1024 * 1024, // 10MB buffer to capture output before truncating }, async (error, stdout, stderr) => { // Apply output truncation to prevent context stuffing const stdoutResult = truncateOutput(stdout || '', outputConfig.maxOutputChars, outputConfig.truncateMode); const stderrResult = truncateOutput(stderr || '', Math.floor(outputConfig.maxOutputChars / 2), outputConfig.truncateMode); const result = { stdout: stdoutResult.text, stderr: stderrResult.text, exitCode: error ? error.code : 0, truncated: { stdout: stdoutResult.truncated, stderr: stderrResult.truncated, originalStdoutLength: stdoutResult.originalLength, originalStderrLength: stderrResult.originalLength, } }; await logAudit('exec_cli', args, result, error ? error.message : null); if (error && !stdout && !stderr) { // If there was an error executing (e.g. command not found) and no output resolve({ content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }); return; } // Add warning for large outputs that were within limits but still substantial let warning = ''; if (!stdoutResult.truncated && stdoutResult.originalLength > outputConfig.warnAtChars) { warning = `\n💡 Large output (${stdoutResult.originalLength.toLocaleString()} chars). Consider using --quiet or filtering output to preserve context.`; } resolve({ content: [ { type: 'text', text: stdoutResult.text + warning }, { type: 'text', text: stderrResult.text ? `STDERR:\n${stderrResult.text}` : '' } ], isError: !!error }); }); }); } // File system tools for convenience export const ReadFileSchema = { path: z.string(), }; export const WriteFileSchema = { path: z.string(), content: z.string(), }; export const ListDirectorySchema = { path: z.string(), }; // String replace schema - renamed parameters to avoid potential filtering export const StrReplaceSchema = { path: z.string().describe('Path to the file to edit'), oldText: z.string().describe('Text to replace (must be unique in file)'), newText: z.string().optional().describe('Replacement text (empty to delete)'), }; // Batch operation schemas for parallel execution export const BatchExecCliSchema = { commands: z.array(z.object({ command: z.string().describe('The command to execute'), cwd: z.string().optional().describe('Current working directory for the command'), })).describe('Array of commands to execute in parallel'), }; export const BatchReadFilesSchema = { paths: z.array(z.string()).describe('Array of file paths to read in parallel'), }; export const BatchWriteFilesSchema = { files: z.array(z.object({ path: z.string(), content: z.string(), })).describe('Array of files to write in parallel'), }; export const BatchListDirectoriesSchema = { paths: z.array(z.string()).describe('Array of directory paths to list in parallel'), }; // Read specific lines from a file (token-efficient alternative to reading entire file) export const ReadFileLinesSchema = { path: z.string().describe('Path to the file to read'), startLine: z.number().optional().describe('Starting line number (1-indexed, default: 1). Ignored if offset is specified.'), endLine: z.number().optional().describe('Ending line number (inclusive, default: end of file). Ignored if offset is specified.'), offset: z.number().optional().describe('Negative value reads last N lines from end of file (like Unix tail). Positive value reads first N lines. Takes precedence over startLine/endLine.'), includeLineNumbers: z.boolean().optional().describe('Include line numbers in output (default: true)'), }; // Search for patterns within a file and return matching lines export const SearchInFileSchema = { path: z.string().describe('Path to the file to search'), pattern: z.string().describe('Text or regex pattern to search for'), isRegex: z.boolean().optional().describe('Treat pattern as regex (default: false)'), caseSensitive: z.boolean().optional().describe('Case sensitive search (default: true)'), contextLines: z.number().optional().describe('Number of lines of context before and after matches (default: 0)'), maxMatches: z.number().optional().describe('Maximum number of matches to return (default: 100)'), }; // Batch str_replace schema - supports multiple replacements across multiple files export const BatchStrReplaceSchema = { replacements: z.array(z.object({ path: z.string().describe('Path to the file to edit'), oldText: z.string().describe('Text to replace'), newText: z.string().optional().describe('Replacement text (empty to delete)'), replaceAll: z.boolean().optional().describe('Replace all occurrences, not just first (default: false)'), })).describe('Array of replacement operations to execute'), stopOnError: z.boolean().optional().describe('Stop execution if any replacement fails (default: false)'), }; // Batch search in files schema - supports fuzzy/approximate matching export const BatchSearchInFilesSchema = { searches: z.array(z.object({ path: z.string().describe('Path to the file to search'), pattern: z.string().describe('Text, regex, or fuzzy pattern to search for'), })).describe('Array of file paths and patterns to search'), isRegex: z.boolean().optional().describe('Treat patterns as regex (default: false)'), isFuzzy: z.boolean().optional().describe('Use fuzzy/approximate matching (default: false)'), fuzzyThreshold: z.number().optional().describe('Similarity threshold for fuzzy matching 0-1 (default: 0.7)'), caseSensitive: z.boolean().optional().describe('Case sensitive search (default: true)'), contextLines: z.number().optional().describe('Number of lines of context before and after matches (default: 0)'), maxMatchesPerFile: z.number().optional().describe('Maximum matches per file (default: 50)'), }; export async function handleReadFile(args: { path: string }) { try { const config = loadConfig(); const maxLines = config.fileReading?.maxLines ?? 500; const warnAtLines = config.fileReading?.warnAtLines ?? 100; const content = fs.readFileSync(args.path, 'utf-8'); const lines = content.split('\n'); const totalLines = lines.length; let output = content; let warning = ''; // Truncate if over maxLines if (totalLines > maxLines) { output = lines.slice(0, maxLines).join('\n'); warning = `\n\n⚠️ FILE TRUNCATED: Showing ${maxLines} of ${totalLines} lines.\n` + `📝 BETTER TOOLS AVAILABLE:\n` + ` • read_file_lines - Read specific line ranges (e.g., offset: -50 for last 50 lines)\n` + ` • search_in_file - Find specific patterns with context\n` + ` • edit_block - Search/replace without reading entire file\n` + `Use surgical approaches to preserve context window.`; } else if (totalLines > warnAtLines) { warning = `\n\n💡 TIP: This file has ${totalLines} lines. Consider using:\n` + ` • read_file_lines - For specific sections\n` + ` • search_in_file - To find specific content\n` + `Surgical tools preserve your context window.`; } await logAudit('read_file', { path: args.path, totalLines, truncated: totalLines > maxLines }, 'success'); return { content: [{ type: 'text', text: output + warning }], }; } catch (error: any) { await logAudit('read_file', args, null, error.message); return { content: [{ type: 'text', text: `Error reading file: ${error.message}` }], isError: true, }; } } // Read specific lines from a file (token-efficient) export async function handleReadFileLines(args: { path: string; startLine?: number; endLine?: number; offset?: number; includeLineNumbers?: boolean; }) { try { const content = fs.readFileSync(args.path, 'utf-8'); const allLines = content.split('\n'); const totalLines = allLines.length; const includeLineNumbers = args.includeLineNumbers !== false; // default true let startLine: number; let endLine: number; // If offset is specified, it takes precedence if (args.offset !== undefined) { if (args.offset < 0) { // Negative offset: read last N lines (like tail) const linesToRead = Math.abs(args.offset); startLine = Math.max(1, totalLines - linesToRead + 1); endLine = totalLines; } else if (args.offset > 0) { // Positive offset: read first N lines (like head) startLine = 1; endLine = Math.min(totalLines, args.offset); } else { // offset = 0, read nothing return { content: [{ type: 'text', text: JSON.stringify({ path: args.path, totalLines, startLine: 0, endLine: 0, linesReturned: 0, content: '' }, null, 2) }], }; } } else { // Use startLine/endLine parameters startLine = Math.max(1, args.startLine ?? 1); endLine = Math.min(totalLines, args.endLine ?? totalLines); } if (startLine > totalLines) { return { content: [{ type: 'text', text: `Error: startLine ${startLine} exceeds total lines ${totalLines}` }], isError: true, }; } // Extract the requested lines (convert to 0-indexed) const selectedLines = allLines.slice(startLine - 1, endLine); let output: string; if (includeLineNumbers) { const lineNumWidth = String(endLine).length; output = selectedLines.map((line, idx) => { const lineNum = String(startLine + idx).padStart(lineNumWidth, ' '); return `${lineNum}: ${line}`; }).join('\n'); } else { output = selectedLines.join('\n'); } await logAudit('read_file_lines', { path: args.path, startLine, endLine, offset: args.offset }, `read ${selectedLines.length} lines`); return { content: [{ type: 'text', text: JSON.stringify({ path: args.path, totalLines, startLine, endLine, linesReturned: selectedLines.length, content: output }, null, 2) }], }; } catch (error: any) { await logAudit('read_file_lines', args, null, error.message); return { content: [{ type: 'text', text: `Error reading file: ${error.message}` }], isError: true, }; } } // Search for patterns within a file export async function handleSearchInFile(args: { path: string; pattern: string; isRegex?: boolean; caseSensitive?: boolean; contextLines?: number; maxMatches?: number; }) { try { const content = fs.readFileSync(args.path, 'utf-8'); const lines = content.split('\n'); const totalLines = lines.length; const isRegex = args.isRegex ?? false; const caseSensitive = args.caseSensitive !== false; // default true const contextLines = args.contextLines ?? 0; const maxMatches = args.maxMatches ?? 100; // Build the search pattern let regex: RegExp; try { if (isRegex) { regex = new RegExp(args.pattern, caseSensitive ? 'g' : 'gi'); } else { // Escape special regex characters for literal search const escaped = args.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); regex = new RegExp(escaped, caseSensitive ? 'g' : 'gi'); } } catch (regexError: any) { return { content: [{ type: 'text', text: `Error: Invalid regex pattern: ${regexError.message}` }], isError: true, }; } interface Match { lineNumber: number; line: string; context?: { before: Array<{ lineNumber: number; line: string }>; after: Array<{ lineNumber: number; line: string }>; }; } const matches: Match[] = []; const matchedLineNumbers = new Set<number>(); // Find all matching lines for (let i = 0; i < lines.length && matches.length < maxMatches; i++) { if (regex.test(lines[i])) { matchedLineNumbers.add(i); const match: Match = { lineNumber: i + 1, line: lines[i], }; if (contextLines > 0) { const beforeLines: Array<{ lineNumber: number; line: string }> = []; const afterLines: Array<{ lineNumber: number; line: string }> = []; // Get context before for (let b = Math.max(0, i - contextLines); b < i; b++) { beforeLines.push({ lineNumber: b + 1, line: lines[b] }); } // Get context after for (let a = i + 1; a <= Math.min(lines.length - 1, i + contextLines); a++) { afterLines.push({ lineNumber: a + 1, line: lines[a] }); } match.context = { before: beforeLines, after: afterLines }; } matches.push(match); } // Reset regex lastIndex for next test regex.lastIndex = 0; } await logAudit('search_in_file', { path: args.path, pattern: args.pattern }, `found ${matches.length} matches`); return { content: [{ type: 'text', text: JSON.stringify({ path: args.path, pattern: args.pattern, totalLines, matchCount: matches.length, truncated: matches.length >= maxMatches, matches }, null, 2) }], }; } catch (error: any) { await logAudit('search_in_file', args, null, error.message); return { content: [{ type: 'text', text: `Error searching file: ${error.message}` }], isError: true, }; } } export async function handleWriteFile(args: { path: string; content: string }) { try { const dir = path.dirname(args.path); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(args.path, args.content, 'utf-8'); await logAudit('write_file', args, 'success'); return { content: [{ type: 'text', text: `Successfully wrote to ${args.path}` }], }; } catch (error: any) { await logAudit('write_file', args, null, error.message); return { content: [{ type: 'text', text: `Error writing file: ${error.message}` }], isError: true, }; } } export async function handleListDirectory(args: { path: string }) { try { const entries = fs.readdirSync(args.path, { withFileTypes: true }); const formatted = entries.map(entry => { return `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`; }).join('\n'); await logAudit('list_directory', args, 'success'); return { content: [{ type: 'text', text: formatted }], }; } catch (error: any) { await logAudit('list_directory', args, null, error.message); return { content: [{ type: 'text', text: `Error listing directory: ${error.message}` }], isError: true, }; } } // String replace handler - replaces a unique string in a file // Accepts both old parameter names (old_str/new_str) and new ones (oldText/newText) for compatibility export async function handleStrReplace(args: { path: string; oldText?: string; newText?: string; old_str?: string; new_str?: string }) { try { // Support both parameter naming conventions const oldStr = args.oldText ?? args.old_str; const newStr = args.newText ?? args.new_str ?? ''; if (!oldStr) { return { content: [{ type: 'text', text: 'Error: oldText parameter is required' }], isError: true, }; } // Read the file if (!fs.existsSync(args.path)) { const error = `File not found: ${args.path}`; await logAudit('str_replace', args, null, error); return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true, }; } const content = fs.readFileSync(args.path, 'utf-8'); // Count occurrences const occurrences = content.split(oldStr).length - 1; if (occurrences === 0) { const error = `String not found in file: "${oldStr.substring(0, 50)}${oldStr.length > 50 ? '...' : ''}"`; await logAudit('str_replace', args, null, error); return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true, }; } if (occurrences > 1) { const error = `String appears ${occurrences} times in file. The string to replace must be unique. Add more context to make it unique.`; await logAudit('str_replace', args, null, error); return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true, }; } // Replace the string const newContent = content.replace(oldStr, newStr); fs.writeFileSync(args.path, newContent, 'utf-8'); await logAudit('str_replace', { path: args.path, oldText_length: oldStr.length, newText_length: newStr.length }, 'success'); return { content: [{ type: 'text', text: `Successfully replaced string in ${args.path}` }], }; } catch (error: any) { await logAudit('str_replace', args, null, error.message); return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true, }; } } // Batch handlers for parallel execution interface BatchResult { index: number; success: boolean; result?: any; error?: string; } export async function handleBatchExecCli(args: { commands: Array<{ command: string; cwd?: string }> }) { const startTime = Date.now(); const outputConfig = config.cliOutput ?? { maxOutputChars: 50000, warnAtChars: 10000, truncateMode: 'both' as const }; // For batch operations, use smaller limits per command to prevent aggregate overflow const perCommandLimit = Math.floor(outputConfig.maxOutputChars / Math.max(args.commands.length, 1)); const effectiveLimit = Math.max(perCommandLimit, 5000); // At least 5KB per command const results = await Promise.all( args.commands.map(async (cmd, index): Promise<BatchResult> => { if (isBlocked(cmd.command)) { await logAudit('batch_exec_cli_item', cmd, null, 'Command blocked by safety policy'); return { index, success: false, error: 'Command blocked by safety policy' }; } return new Promise((resolve) => { exec(cmd.command, { cwd: cmd.cwd || process.cwd(), timeout: config.cliPolicy.timeoutMs, maxBuffer: 10 * 1024 * 1024, }, async (error, stdout, stderr) => { if (error && !stdout && !stderr) { resolve({ index, success: false, error: error.message }); } else { // Apply truncation to batch results const stdoutResult = truncateOutput(stdout || '', effectiveLimit, outputConfig.truncateMode); const stderrResult = truncateOutput(stderr || '', Math.floor(effectiveLimit / 2), outputConfig.truncateMode); resolve({ index, success: !error, result: { stdout: stdoutResult.text, stderr: stderrResult.text, exitCode: error ? error.code : 0, truncated: stdoutResult.truncated || stderrResult.truncated } }); } }); }); }) ); const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const elapsed = Date.now() - startTime; await logAudit('batch_exec_cli', { count: args.commands.length }, { successful, failed, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.commands.length, successful, failed, elapsed_ms: elapsed }, results: results.sort((a, b) => a.index - b.index) }, null, 2) }], isError: failed > 0 && successful === 0, }; } export async function handleBatchReadFiles(args: { paths: string[] }) { const startTime = Date.now(); const results = await Promise.all( args.paths.map(async (filePath, index): Promise<BatchResult> => { try { const content = fs.readFileSync(filePath, 'utf-8'); return { index, success: true, result: { path: filePath, content } }; } catch (error: any) { return { index, success: false, error: `${filePath}: ${error.message}` }; } }) ); const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const elapsed = Date.now() - startTime; await logAudit('batch_read_files', { count: args.paths.length }, { successful, failed, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.paths.length, successful, failed, elapsed_ms: elapsed }, results: results.sort((a, b) => a.index - b.index) }, null, 2) }], isError: failed > 0 && successful === 0, }; } export async function handleBatchWriteFiles(args: { files: Array<{ path: string; content: string }> }) { const startTime = Date.now(); const results = await Promise.all( args.files.map(async (file, index): Promise<BatchResult> => { try { const dir = path.dirname(file.path); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(file.path, file.content, 'utf-8'); return { index, success: true, result: { path: file.path, written: true } }; } catch (error: any) { return { index, success: false, error: `${file.path}: ${error.message}` }; } }) ); const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const elapsed = Date.now() - startTime; await logAudit('batch_write_files', { count: args.files.length }, { successful, failed, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.files.length, successful, failed, elapsed_ms: elapsed }, results: results.sort((a, b) => a.index - b.index) }, null, 2) }], isError: failed > 0, }; } export async function handleBatchListDirectories(args: { paths: string[] }) { const startTime = Date.now(); const results = await Promise.all( args.paths.map(async (dirPath, index): Promise<BatchResult> => { try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const formatted = entries.map(entry => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' })); return { index, success: true, result: { path: dirPath, entries: formatted } }; } catch (error: any) { return { index, success: false, error: `${dirPath}: ${error.message}` }; } }) ); const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const elapsed = Date.now() - startTime; await logAudit('batch_list_directories', { count: args.paths.length }, { successful, failed, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.paths.length, successful, failed, elapsed_ms: elapsed }, results: results.sort((a, b) => a.index - b.index) }, null, 2) }], isError: failed > 0 && successful === 0, }; } // Batch str_replace handler - supports multiple replacements with replaceAll option export async function handleBatchStrReplace(args: { replacements: Array<{ path: string; oldText: string; newText?: string; replaceAll?: boolean; }>; stopOnError?: boolean; }) { const startTime = Date.now(); const stopOnError = args.stopOnError ?? false; const results: BatchResult[] = []; for (let index = 0; index < args.replacements.length; index++) { const op = args.replacements[index]; const newStr = op.newText ?? ''; const replaceAll = op.replaceAll ?? false; try { // Check file exists if (!fs.existsSync(op.path)) { const result: BatchResult = { index, success: false, error: `File not found: ${op.path}` }; results.push(result); if (stopOnError) break; continue; } const content = fs.readFileSync(op.path, 'utf-8'); // Count occurrences const occurrences = content.split(op.oldText).length - 1; if (occurrences === 0) { const result: BatchResult = { index, success: false, error: `String not found in ${op.path}: "${op.oldText.substring(0, 50)}${op.oldText.length > 50 ? '...' : ''}"` }; results.push(result); if (stopOnError) break; continue; } // If not replaceAll and multiple occurrences, that's an error if (!replaceAll && occurrences > 1) { const result: BatchResult = { index, success: false, error: `String appears ${occurrences} times in ${op.path}. Use replaceAll: true to replace all, or add more context.` }; results.push(result); if (stopOnError) break; continue; } // Perform replacement let newContent: string; if (replaceAll) { newContent = content.split(op.oldText).join(newStr); } else { newContent = content.replace(op.oldText, newStr); } fs.writeFileSync(op.path, newContent, 'utf-8'); results.push({ index, success: true, result: { path: op.path, replacements: replaceAll ? occurrences : 1, oldTextLength: op.oldText.length, newTextLength: newStr.length } }); } catch (error: any) { const result: BatchResult = { index, success: false, error: `${op.path}: ${error.message}` }; results.push(result); if (stopOnError) break; } } const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const totalReplacements = results .filter(r => r.success && r.result) .reduce((sum, r) => sum + (r.result.replacements || 0), 0); const elapsed = Date.now() - startTime; await logAudit('batch_str_replace', { count: args.replacements.length }, { successful, failed, totalReplacements, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.replacements.length, successful, failed, totalReplacements, elapsed_ms: elapsed }, results: results.sort((a, b) => a.index - b.index) }, null, 2) }], isError: failed > 0 && successful === 0, }; } // Levenshtein distance for fuzzy matching function levenshteinDistance(s1: string, s2: string): number { const m = s1.length; const n = s2.length; if (m === 0) return n; if (n === 0) return m; const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; dp[i][j] = Math.min( dp[i - 1][j] + 1, // deletion dp[i][j - 1] + 1, // insertion dp[i - 1][j - 1] + cost // substitution ); } } return dp[m][n]; } // Calculate similarity ratio (0-1) based on Levenshtein distance function similarityRatio(s1: string, s2: string): number { const maxLen = Math.max(s1.length, s2.length); if (maxLen === 0) return 1; const distance = levenshteinDistance(s1, s2); return 1 - (distance / maxLen); } // Find fuzzy matches in a line function findFuzzyMatches(line: string, pattern: string, threshold: number, caseSensitive: boolean): Array<{ start: number; end: number; matched: string; similarity: number }> { const matches: Array<{ start: number; end: number; matched: string; similarity: number }> = []; const searchLine = caseSensitive ? line : line.toLowerCase(); const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); const patternLen = pattern.length; // Slide window across the line for (let i = 0; i <= searchLine.length - patternLen; i++) { const window = searchLine.substring(i, i + patternLen); const similarity = similarityRatio(window, searchPattern); if (similarity >= threshold) { matches.push({ start: i, end: i + patternLen, matched: line.substring(i, i + patternLen), similarity }); // Skip ahead to avoid overlapping matches i += Math.floor(patternLen / 2); } } // Also check slightly longer/shorter windows for fuzzy matching for (const lenDelta of [-2, -1, 1, 2]) { const windowLen = patternLen + lenDelta; if (windowLen < 2) continue; for (let i = 0; i <= searchLine.length - windowLen; i++) { const window = searchLine.substring(i, i + windowLen); const similarity = similarityRatio(window, searchPattern); if (similarity >= threshold) { // Check if we already have a match at this position const hasOverlap = matches.some(m => (i >= m.start && i < m.end) || (i + windowLen > m.start && i + windowLen <= m.end) ); if (!hasOverlap) { matches.push({ start: i, end: i + windowLen, matched: line.substring(i, i + windowLen), similarity }); } } } } return matches.sort((a, b) => a.start - b.start); } // Batch search in files handler with fuzzy matching support export async function handleBatchSearchInFiles(args: { searches: Array<{ path: string; pattern: string }>; isRegex?: boolean; isFuzzy?: boolean; fuzzyThreshold?: number; caseSensitive?: boolean; contextLines?: number; maxMatchesPerFile?: number; }) { const startTime = Date.now(); const isRegex = args.isRegex ?? false; const isFuzzy = args.isFuzzy ?? false; const fuzzyThreshold = args.fuzzyThreshold ?? 0.7; const caseSensitive = args.caseSensitive !== false; const contextLines = args.contextLines ?? 0; const maxMatchesPerFile = args.maxMatchesPerFile ?? 50; interface FileSearchResult { path: string; pattern: string; success: boolean; error?: string; totalLines?: number; matchCount?: number; matches?: Array<{ lineNumber: number; line: string; similarity?: number; matchedText?: string; context?: { before: Array<{ lineNumber: number; line: string }>; after: Array<{ lineNumber: number; line: string }>; }; }>; } const results = await Promise.all( args.searches.map(async (search): Promise<FileSearchResult> => { try { const content = fs.readFileSync(search.path, 'utf-8'); const lines = content.split('\n'); const totalLines = lines.length; const matches: FileSearchResult['matches'] = []; if (isFuzzy) { // Fuzzy matching mode for (let i = 0; i < lines.length && matches.length < maxMatchesPerFile; i++) { const fuzzyMatches = findFuzzyMatches(lines[i], search.pattern, fuzzyThreshold, caseSensitive); for (const fm of fuzzyMatches) { if (matches.length >= maxMatchesPerFile) break; const match: any = { lineNumber: i + 1, line: lines[i], similarity: Math.round(fm.similarity * 100) / 100, matchedText: fm.matched }; if (contextLines > 0) { const before: Array<{ lineNumber: number; line: string }> = []; const after: Array<{ lineNumber: number; line: string }> = []; for (let b = Math.max(0, i - contextLines); b < i; b++) { before.push({ lineNumber: b + 1, line: lines[b] }); } for (let a = i + 1; a <= Math.min(lines.length - 1, i + contextLines); a++) { after.push({ lineNumber: a + 1, line: lines[a] }); } match.context = { before, after }; } matches.push(match); } } } else { // Regex or literal matching let regex: RegExp; try { if (isRegex) { regex = new RegExp(search.pattern, caseSensitive ? 'g' : 'gi'); } else { const escaped = search.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); regex = new RegExp(escaped, caseSensitive ? 'g' : 'gi'); } } catch (regexError: any) { return { path: search.path, pattern: search.pattern, success: false, error: `Invalid regex: ${regexError.message}` }; } for (let i = 0; i < lines.length && matches.length < maxMatchesPerFile; i++) { if (regex.test(lines[i])) { const match: any = { lineNumber: i + 1, line: lines[i] }; if (contextLines > 0) { const before: Array<{ lineNumber: number; line: string }> = []; const after: Array<{ lineNumber: number; line: string }> = []; for (let b = Math.max(0, i - contextLines); b < i; b++) { before.push({ lineNumber: b + 1, line: lines[b] }); } for (let a = i + 1; a <= Math.min(lines.length - 1, i + contextLines); a++) { after.push({ lineNumber: a + 1, line: lines[a] }); } match.context = { before, after }; } matches.push(match); } regex.lastIndex = 0; } } return { path: search.path, pattern: search.pattern, success: true, totalLines, matchCount: matches.length, matches }; } catch (error: any) { return { path: search.path, pattern: search.pattern, success: false, error: error.message }; } }) ); const successful = results.filter(r => r.success).length; const failed = results.filter(r => !r.success).length; const totalMatches = results.reduce((sum, r) => sum + (r.matchCount || 0), 0); const elapsed = Date.now() - startTime; await logAudit('batch_search_in_files', { count: args.searches.length, isRegex, isFuzzy, fuzzyThreshold: isFuzzy ? fuzzyThreshold : undefined }, { successful, failed, totalMatches, elapsed }); return { content: [{ type: 'text', text: JSON.stringify({ summary: { total: args.searches.length, successful, failed, totalMatches, searchMode: isFuzzy ? 'fuzzy' : (isRegex ? 'regex' : 'literal'), elapsed_ms: elapsed }, results }, null, 2) }], isError: failed > 0 && successful === 0, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/mnehmos.ooda.mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server