capsule-ai-cli
Version:
The AI Model Orchestrator - Intelligent multi-model workflows with device-locked licensing
408 lines ⢠16.1 kB
JavaScript
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