UNPKG

capsule-ai-cli

Version:

The AI Model Orchestrator - Intelligent multi-model workflows with device-locked licensing

408 lines • 16.1 kB
import { BaseTool } from '../base.js'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import fs from 'fs/promises'; import { glob } from 'glob'; const execAsync = promisify(exec); export class SearchTool extends BaseTool { name = 'search'; displayName = 'šŸ” Search'; description = 'FAST code/file search. Use this instead of bash find/grep. Searches content AND filenames intelligently.'; category = 'file'; icon = 'šŸ”'; parameters = [ { name: 'query', type: 'string', description: 'Search query (regex for content search, glob for filename search)', required: true }, { name: 'path', type: 'string', description: 'Starting path for search (default: current directory)', required: false, default: '.' }, { name: 'type', type: 'string', description: 'Search type: content (search inside files), filename (search file names), both', required: false, default: 'content', enum: ['content', 'filename', 'both'] }, { name: 'filePattern', type: 'string', description: 'File pattern to include (e.g., "*.ts", "*.{js,jsx}")', required: false }, { name: 'excludePattern', type: 'string', description: 'Pattern to exclude (e.g., "node_modules", "*.test.js")', required: false }, { name: 'caseSensitive', type: 'boolean', description: 'Case sensitive search', required: false, default: false }, { name: 'maxResults', type: 'number', description: 'Maximum number of results to return', required: false, default: 10 }, { name: 'includeHidden', type: 'boolean', description: 'Include hidden files and directories', required: false, default: false }, { name: 'searchOutsideRepo', type: 'boolean', description: 'Allow searching outside the current repository', required: false, default: false }, { name: 'maxDepth', type: 'number', description: 'Maximum directory depth to search', required: false }, { name: 'fileType', type: 'string', description: 'File type filter (text, binary, image, etc.)', required: false } ]; permissions = { fileSystem: 'read' }; ui = { showProgress: true, collapsible: true, dangerous: false }; async run(params, context) { const { query, path: searchPath = '.', type = 'content', filePattern, excludePattern, caseSensitive = false, maxResults = 100, includeHidden = false, searchOutsideRepo = false, maxDepth, fileType } = params; const resolvedPath = path.isAbsolute(searchPath) ? searchPath : path.join(context.workingDirectory || process.cwd(), searchPath); if (!searchOutsideRepo && !resolvedPath.startsWith(context.workingDirectory || process.cwd())) { throw new Error('Searching outside repository requires searchOutsideRepo: true'); } this.reportProgress(context, `Searching for "${query}" in ${resolvedPath}...`); const results = []; try { if (type === 'content' || type === 'both') { const contentResults = await this.searchContent(query, resolvedPath, { filePattern, excludePattern, caseSensitive, maxResults, includeHidden, maxDepth }, context); results.push(...contentResults); } if (type === 'filename' || type === 'both') { const filenameResults = await this.searchFilenames(query, resolvedPath, { excludePattern, caseSensitive, maxResults: maxResults - results.length, includeHidden, maxDepth, fileType }, context); results.push(...filenameResults); } const sortedResults = results .sort((a, b) => { const aExact = a.path.toLowerCase().includes(query.toLowerCase()); const bExact = b.path.toLowerCase().includes(query.toLowerCase()); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; const aMatches = a.matches?.length || 0; const bMatches = b.matches?.length || 0; return bMatches - aMatches; }) .slice(0, maxResults); const summary = this.formatResults(sortedResults, query, type); return { query, type, path: resolvedPath, resultCount: sortedResults.length, results: sortedResults, summary, display: summary }; } catch (error) { throw new Error(`Search failed: ${error.message}`); } } async searchContent(query, searchPath, options, context) { const results = []; try { const rgCommand = this.buildRipgrepCommand(query, searchPath, options); this.reportProgress(context, 'Using ripgrep for fast content search...'); const { stdout } = await execAsync(rgCommand, { maxBuffer: 50 * 1024 * 1024, cwd: searchPath }); const lines = stdout.trim().split('\n').filter(line => line); const fileMatches = new Map(); for (const line of lines) { try { const match = JSON.parse(line); if (match.type === 'match') { const filePath = path.join(searchPath, match.data.path.text); if (!fileMatches.has(filePath)) { fileMatches.set(filePath, { path: filePath, type: 'file', matches: [] }); } const result = fileMatches.get(filePath); result.matches.push({ line: match.data.line_number, content: match.data.lines.text, preview: this.createPreview(match.data.lines.text, match.data.submatches) }); } } catch (e) { } } results.push(...fileMatches.values()); } catch (error) { if (error.message.includes('maxBuffer')) { this.reportProgress(context, 'Too many results, limiting search scope...'); const limitedOptions = { ...options, maxResults: 25 }; const rgCommand = this.buildRipgrepCommand(query, searchPath, limitedOptions); const fullCommand = rgCommand.replace(/^rg /, 'rg --max-filesize 1M '); const { stdout } = await execAsync(fullCommand, { maxBuffer: 10 * 1024 * 1024, cwd: searchPath }); const lines = stdout.trim().split('\n').filter(line => line); const fileMatches = new Map(); for (const line of lines.slice(0, 100)) { try { const match = JSON.parse(line); if (match.type === 'match') { const filePath = path.join(searchPath, match.data.path.text); if (!fileMatches.has(filePath)) { fileMatches.set(filePath, { path: filePath, type: 'file', matches: [] }); } const result = fileMatches.get(filePath); if (result.matches.length < 2) { result.matches.push({ line: match.data.line_number, content: match.data.lines.text, preview: this.createPreview(match.data.lines.text, match.data.submatches) }); } } } catch (e) { } } results.push(...fileMatches.values()); return results; } if (error.message.includes('command not found')) { this.reportProgress(context, 'Falling back to grep...'); return this.searchContentWithGrep(query, searchPath, options, context); } throw error; } return results; } buildRipgrepCommand(query, searchPath, options) { const args = ['rg', '--json']; if (!options.caseSensitive) { args.push('-i'); } if (options.filePattern) { args.push('-g', this.escapeShellArg(options.filePattern)); } if (options.excludePattern) { const excludes = options.excludePattern.split(','); for (const exclude of excludes) { args.push('-g', this.escapeShellArg(`!${exclude.trim()}`)); } } if (options.includeHidden) { args.push('--hidden'); } if (options.maxDepth) { args.push('--max-depth', options.maxDepth.toString()); } args.push('-m', '3'); args.push('--max-count', '50'); args.push(this.escapeShellArg(query)); args.push(this.escapeShellArg(searchPath)); return args.join(' '); } escapeShellArg(arg) { return "'" + arg.replace(/'/g, "'\\''") + "'"; } async searchContentWithGrep(query, searchPath, options, _context) { const args = ['grep', '-r', '-n']; if (!options.caseSensitive) { args.push('-i'); } args.push(this.escapeShellArg(query), this.escapeShellArg(searchPath)); try { const { stdout } = await execAsync(args.join(' '), { maxBuffer: 10 * 1024 * 1024 }); const lines = stdout.trim().split('\n').filter(line => line); const fileMatches = new Map(); for (const line of lines) { const match = line.match(/^(.+?):(\d+):(.*)$/); if (match) { const [, filePath, lineNum, content] = match; if (!fileMatches.has(filePath)) { fileMatches.set(filePath, { path: filePath, type: 'file', matches: [] }); } const result = fileMatches.get(filePath); result.matches.push({ line: parseInt(lineNum), content: content, preview: content.trim() }); } } return Array.from(fileMatches.values()); } catch (error) { if (error.code === 1) { return []; } throw error; } } async searchFilenames(query, searchPath, options, context) { this.reportProgress(context, 'Searching filenames...'); let pattern = query; if (!pattern.includes('*') && !pattern.includes('?')) { pattern = `*${pattern}*`; } const globOptions = { cwd: searchPath, nocase: !options.caseSensitive, dot: options.includeHidden, absolute: true }; if (options.maxDepth) { globOptions.maxDepth = options.maxDepth; } const files = await glob(pattern, globOptions); const results = []; for (const file of files) { if (options.excludePattern) { const excludes = options.excludePattern.split(','); if (excludes.some((exclude) => file.includes(exclude.trim()))) { continue; } } try { const stats = await fs.stat(file); results.push({ path: file, type: stats.isDirectory() ? 'directory' : 'file', size: stats.size, modified: stats.mtime }); } catch (e) { } if (results.length >= options.maxResults) { break; } } return results; } createPreview(content, submatches) { if (!submatches || submatches.length === 0) { return content.trim(); } let highlighted = content; const highlights = []; for (const submatch of submatches) { highlights.push([submatch.start, submatch.end]); } highlights.sort((a, b) => b[0] - a[0]); for (const [start, end] of highlights) { highlighted = highlighted.slice(0, start) + '**' + highlighted.slice(start, end) + '**' + highlighted.slice(end); } return highlighted.trim(); } formatResults(results, query, _type) { if (results.length === 0) { return `No results found for "${query}"`; } let output = `šŸ” Search Results for "${query}"\n`; output += `Found ${results.length} results\n\n`; const contentResults = results.filter(r => r.matches && r.matches.length > 0); const filenameResults = results.filter(r => !r.matches || r.matches.length === 0); if (contentResults.length > 0) { output += `šŸ“„ Content Matches (${contentResults.length} files):\n`; for (const result of contentResults.slice(0, 10)) { const relPath = path.relative(process.cwd(), result.path); output += `\n ${relPath}:\n`; for (const match of result.matches.slice(0, 3)) { output += ` ${match.line}: ${match.preview}\n`; } if (result.matches.length > 3) { output += ` ... and ${result.matches.length - 3} more matches\n`; } } if (contentResults.length > 10) { output += `\n ... and ${contentResults.length - 10} more files\n`; } } if (filenameResults.length > 0) { output += `\nšŸ“ Filename Matches (${filenameResults.length} items):\n`; for (const result of filenameResults.slice(0, 20)) { const relPath = path.relative(process.cwd(), result.path); const icon = result.type === 'directory' ? 'šŸ“' : 'šŸ“„'; output += ` ${icon} ${relPath}\n`; } if (filenameResults.length > 20) { output += ` ... and ${filenameResults.length - 20} more items\n`; } } return output; } } //# sourceMappingURL=search.js.map