| import { z } from 'zod/v4' |
| import type { ValidationResult } from '../../Tool.js' |
| import { buildTool, type ToolDef } from '../../Tool.js' |
| import { getCwd } from '../../utils/cwd.js' |
| import { isENOENT } from '../../utils/errors.js' |
| import { |
| FILE_NOT_FOUND_CWD_NOTE, |
| suggestPathUnderCwd, |
| } from '../../utils/file.js' |
| import { getFsImplementation } from '../../utils/fsOperations.js' |
| import { lazySchema } from '../../utils/lazySchema.js' |
| import { expandPath, toRelativePath } from '../../utils/path.js' |
| import { |
| checkReadPermissionForTool, |
| getFileReadIgnorePatterns, |
| normalizePatternsToPath, |
| } from '../../utils/permissions/filesystem.js' |
| import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' |
| import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js' |
| import { getGlobExclusionsForPluginCache } from '../../utils/plugins/orphanedPluginFilter.js' |
| import { ripGrep } from '../../utils/ripgrep.js' |
| import { semanticBoolean } from '../../utils/semanticBoolean.js' |
| import { semanticNumber } from '../../utils/semanticNumber.js' |
| import { plural } from '../../utils/stringUtils.js' |
| import { GREP_TOOL_NAME, getDescription } from './prompt.js' |
| import { |
| getToolUseSummary, |
| renderToolResultMessage, |
| renderToolUseErrorMessage, |
| renderToolUseMessage, |
| } from './UI.js' |
|
|
| const inputSchema = lazySchema(() => |
| z.strictObject({ |
| pattern: z |
| .string() |
| .describe( |
| 'The regular expression pattern to search for in file contents', |
| ), |
| path: z |
| .string() |
| .optional() |
| .describe( |
| 'File or directory to search in (rg PATH). Defaults to current working directory.', |
| ), |
| glob: z |
| .string() |
| .optional() |
| .describe( |
| 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob', |
| ), |
| output_mode: z |
| .enum(['content', 'files_with_matches', 'count']) |
| .optional() |
| .describe( |
| 'Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".', |
| ), |
| '-B': semanticNumber(z.number().optional()).describe( |
| 'Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.', |
| ), |
| '-A': semanticNumber(z.number().optional()).describe( |
| 'Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.', |
| ), |
| '-C': semanticNumber(z.number().optional()).describe('Alias for context.'), |
| context: semanticNumber(z.number().optional()).describe( |
| 'Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.', |
| ), |
| '-n': semanticBoolean(z.boolean().optional()).describe( |
| 'Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.', |
| ), |
| '-i': semanticBoolean(z.boolean().optional()).describe( |
| 'Case insensitive search (rg -i)', |
| ), |
| type: z |
| .string() |
| .optional() |
| .describe( |
| 'File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.', |
| ), |
| head_limit: semanticNumber(z.number().optional()).describe( |
| 'Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context).', |
| ), |
| offset: semanticNumber(z.number().optional()).describe( |
| 'Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.', |
| ), |
| multiline: semanticBoolean(z.boolean().optional()).describe( |
| 'Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.', |
| ), |
| }), |
| ) |
| type InputSchema = ReturnType<typeof inputSchema> |
|
|
| |
| |
| const VCS_DIRECTORIES_TO_EXCLUDE = [ |
| '.git', |
| '.svn', |
| '.hg', |
| '.bzr', |
| '.jj', |
| '.sl', |
| ] as const |
|
|
| |
| |
| |
| |
| const DEFAULT_HEAD_LIMIT = 250 |
|
|
| function applyHeadLimit<T>( |
| items: T[], |
| limit: number | undefined, |
| offset: number = 0, |
| ): { items: T[]; appliedLimit: number | undefined } { |
| |
| if (limit === 0) { |
| return { items: items.slice(offset), appliedLimit: undefined } |
| } |
| const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT |
| const sliced = items.slice(offset, offset + effectiveLimit) |
| |
| |
| const wasTruncated = items.length - offset > effectiveLimit |
| return { |
| items: sliced, |
| appliedLimit: wasTruncated ? effectiveLimit : undefined, |
| } |
| } |
|
|
| |
| |
| |
| |
| function formatLimitInfo( |
| appliedLimit: number | undefined, |
| appliedOffset: number | undefined, |
| ): string { |
| const parts: string[] = [] |
| if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`) |
| if (appliedOffset) parts.push(`offset: ${appliedOffset}`) |
| return parts.join(', ') |
| } |
|
|
| const outputSchema = lazySchema(() => |
| z.object({ |
| mode: z.enum(['content', 'files_with_matches', 'count']).optional(), |
| numFiles: z.number(), |
| filenames: z.array(z.string()), |
| content: z.string().optional(), |
| numLines: z.number().optional(), |
| numMatches: z.number().optional(), |
| appliedLimit: z.number().optional(), |
| appliedOffset: z.number().optional(), |
| }), |
| ) |
| type OutputSchema = ReturnType<typeof outputSchema> |
|
|
| type Output = z.infer<OutputSchema> |
|
|
| export const GrepTool = buildTool({ |
| name: GREP_TOOL_NAME, |
| searchHint: 'search file contents with regex (ripgrep)', |
| |
| maxResultSizeChars: 20_000, |
| strict: true, |
| async description() { |
| return getDescription() |
| }, |
| userFacingName() { |
| return 'Search' |
| }, |
| getToolUseSummary, |
| getActivityDescription(input) { |
| const summary = getToolUseSummary(input) |
| return summary ? `Searching for ${summary}` : 'Searching' |
| }, |
| get inputSchema(): InputSchema { |
| return inputSchema() |
| }, |
| get outputSchema(): OutputSchema { |
| return outputSchema() |
| }, |
| isConcurrencySafe() { |
| return true |
| }, |
| isReadOnly() { |
| return true |
| }, |
| toAutoClassifierInput(input) { |
| return input.path ? `${input.pattern} in ${input.path}` : input.pattern |
| }, |
| isSearchOrReadCommand() { |
| return { isSearch: true, isRead: false } |
| }, |
| getPath({ path }): string { |
| return path || getCwd() |
| }, |
| async preparePermissionMatcher({ pattern }) { |
| return rulePattern => matchWildcardPattern(rulePattern, pattern) |
| }, |
| async validateInput({ path }): Promise<ValidationResult> { |
| |
| if (path) { |
| const fs = getFsImplementation() |
| const absolutePath = expandPath(path) |
|
|
| |
| if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) { |
| return { result: true } |
| } |
|
|
| try { |
| await fs.stat(absolutePath) |
| } catch (e: unknown) { |
| if (isENOENT(e)) { |
| const cwdSuggestion = await suggestPathUnderCwd(absolutePath) |
| let message = `Path does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.` |
| if (cwdSuggestion) { |
| message += ` Did you mean ${cwdSuggestion}?` |
| } |
| return { |
| result: false, |
| message, |
| errorCode: 1, |
| } |
| } |
| throw e |
| } |
| } |
|
|
| return { result: true } |
| }, |
| async checkPermissions(input, context): Promise<PermissionDecision> { |
| const appState = context.getAppState() |
| return checkReadPermissionForTool( |
| GrepTool, |
| input, |
| appState.toolPermissionContext, |
| ) |
| }, |
| async prompt() { |
| return getDescription() |
| }, |
| renderToolUseMessage, |
| renderToolUseErrorMessage, |
| renderToolResultMessage, |
| |
| |
| |
| extractSearchText({ mode, content, filenames }) { |
| if (mode === 'content' && content) return content |
| return filenames.join('\n') |
| }, |
| mapToolResultToToolResultBlockParam( |
| { |
| mode = 'files_with_matches', |
| numFiles, |
| filenames, |
| content, |
| numLines: _numLines, |
| numMatches, |
| appliedLimit, |
| appliedOffset, |
| }, |
| toolUseID, |
| ) { |
| if (mode === 'content') { |
| const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) |
| const resultContent = content || 'No matches found' |
| const finalContent = limitInfo |
| ? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]` |
| : resultContent |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: finalContent, |
| } |
| } |
|
|
| if (mode === 'count') { |
| const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) |
| const rawContent = content || 'No matches found' |
| const matches = numMatches ?? 0 |
| const files = numFiles ?? 0 |
| const summary = `\n\nFound ${matches} total ${matches === 1 ? 'occurrence' : 'occurrences'} across ${files} ${files === 1 ? 'file' : 'files'}.${limitInfo ? ` with pagination = ${limitInfo}` : ''}` |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: rawContent + summary, |
| } |
| } |
|
|
| |
| const limitInfo = formatLimitInfo(appliedLimit, appliedOffset) |
| if (numFiles === 0) { |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: 'No files found', |
| } |
| } |
| |
| const result = `Found ${numFiles} ${plural(numFiles, 'file')}${limitInfo ? ` ${limitInfo}` : ''}\n${filenames.join('\n')}` |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: result, |
| } |
| }, |
| async call( |
| { |
| pattern, |
| path, |
| glob, |
| type, |
| output_mode = 'files_with_matches', |
| '-B': context_before, |
| '-A': context_after, |
| '-C': context_c, |
| context, |
| '-n': show_line_numbers = true, |
| '-i': case_insensitive = false, |
| head_limit, |
| offset = 0, |
| multiline = false, |
| }, |
| { abortController, getAppState }, |
| ) { |
| const absolutePath = path ? expandPath(path) : getCwd() |
| const args = ['--hidden'] |
|
|
| |
| for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { |
| args.push('--glob', `!${dir}`) |
| } |
|
|
| |
| args.push('--max-columns', '500') |
|
|
| |
| if (multiline) { |
| args.push('-U', '--multiline-dotall') |
| } |
|
|
| |
| if (case_insensitive) { |
| args.push('-i') |
| } |
|
|
| |
| if (output_mode === 'files_with_matches') { |
| args.push('-l') |
| } else if (output_mode === 'count') { |
| args.push('-c') |
| } |
|
|
| |
| if (show_line_numbers && output_mode === 'content') { |
| args.push('-n') |
| } |
|
|
| |
| if (output_mode === 'content') { |
| if (context !== undefined) { |
| args.push('-C', context.toString()) |
| } else if (context_c !== undefined) { |
| args.push('-C', context_c.toString()) |
| } else { |
| if (context_before !== undefined) { |
| args.push('-B', context_before.toString()) |
| } |
| if (context_after !== undefined) { |
| args.push('-A', context_after.toString()) |
| } |
| } |
| } |
|
|
| |
| |
| if (pattern.startsWith('-')) { |
| args.push('-e', pattern) |
| } else { |
| args.push(pattern) |
| } |
|
|
| |
| if (type) { |
| args.push('--type', type) |
| } |
|
|
| if (glob) { |
| |
| const globPatterns: string[] = [] |
| const rawPatterns = glob.split(/\s+/) |
|
|
| for (const rawPattern of rawPatterns) { |
| |
| if (rawPattern.includes('{') && rawPattern.includes('}')) { |
| globPatterns.push(rawPattern) |
| } else { |
| |
| globPatterns.push(...rawPattern.split(',').filter(Boolean)) |
| } |
| } |
|
|
| for (const globPattern of globPatterns.filter(Boolean)) { |
| args.push('--glob', globPattern) |
| } |
| } |
|
|
| |
| const appState = getAppState() |
| const ignorePatterns = normalizePatternsToPath( |
| getFileReadIgnorePatterns(appState.toolPermissionContext), |
| getCwd(), |
| ) |
| for (const ignorePattern of ignorePatterns) { |
| |
| |
| |
| |
| |
| const rgIgnorePattern = ignorePattern.startsWith('/') |
| ? `!${ignorePattern}` |
| : `!**/${ignorePattern}` |
| args.push('--glob', rgIgnorePattern) |
| } |
|
|
| |
| for (const exclusion of await getGlobExclusionsForPluginCache( |
| absolutePath, |
| )) { |
| args.push('--glob', exclusion) |
| } |
|
|
| |
| |
| |
| |
| |
| const results = await ripGrep(args, absolutePath, abortController.signal) |
|
|
| if (output_mode === 'content') { |
| |
| |
|
|
| |
| |
| |
| const { items: limitedResults, appliedLimit } = applyHeadLimit( |
| results, |
| head_limit, |
| offset, |
| ) |
|
|
| const finalLines = limitedResults.map(line => { |
| |
| const colonIndex = line.indexOf(':') |
| if (colonIndex > 0) { |
| const filePath = line.substring(0, colonIndex) |
| const rest = line.substring(colonIndex) |
| return toRelativePath(filePath) + rest |
| } |
| return line |
| }) |
| const output = { |
| mode: 'content' as const, |
| numFiles: 0, |
| filenames: [], |
| content: finalLines.join('\n'), |
| numLines: finalLines.length, |
| ...(appliedLimit !== undefined && { appliedLimit }), |
| ...(offset > 0 && { appliedOffset: offset }), |
| } |
| return { data: output } |
| } |
|
|
| if (output_mode === 'count') { |
| |
| |
| const { items: limitedResults, appliedLimit } = applyHeadLimit( |
| results, |
| head_limit, |
| offset, |
| ) |
|
|
| |
| const finalCountLines = limitedResults.map(line => { |
| |
| const colonIndex = line.lastIndexOf(':') |
| if (colonIndex > 0) { |
| const filePath = line.substring(0, colonIndex) |
| const count = line.substring(colonIndex) |
| return toRelativePath(filePath) + count |
| } |
| return line |
| }) |
|
|
| |
| let totalMatches = 0 |
| let fileCount = 0 |
| for (const line of finalCountLines) { |
| const colonIndex = line.lastIndexOf(':') |
| if (colonIndex > 0) { |
| const countStr = line.substring(colonIndex + 1) |
| const count = parseInt(countStr, 10) |
| if (!isNaN(count)) { |
| totalMatches += count |
| fileCount += 1 |
| } |
| } |
| } |
|
|
| const output = { |
| mode: 'count' as const, |
| numFiles: fileCount, |
| filenames: [], |
| content: finalCountLines.join('\n'), |
| numMatches: totalMatches, |
| ...(appliedLimit !== undefined && { appliedLimit }), |
| ...(offset > 0 && { appliedOffset: offset }), |
| } |
| return { data: output } |
| } |
|
|
| |
| |
| |
| const stats = await Promise.allSettled( |
| results.map(_ => getFsImplementation().stat(_)), |
| ) |
| const sortedMatches = results |
| |
| .map((_, i) => { |
| const r = stats[i]! |
| return [ |
| _, |
| r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0, |
| ] as const |
| }) |
| .sort((a, b) => { |
| if (process.env.NODE_ENV === 'test') { |
| |
| return a[0].localeCompare(b[0]) |
| } |
| const timeComparison = b[1] - a[1] |
| if (timeComparison === 0) { |
| |
| return a[0].localeCompare(b[0]) |
| } |
| return timeComparison |
| }) |
| .map(_ => _[0]) |
|
|
| |
| const { items: finalMatches, appliedLimit } = applyHeadLimit( |
| sortedMatches, |
| head_limit, |
| offset, |
| ) |
|
|
| |
| const relativeMatches = finalMatches.map(toRelativePath) |
|
|
| const output = { |
| mode: 'files_with_matches' as const, |
| filenames: relativeMatches, |
| numFiles: relativeMatches.length, |
| ...(appliedLimit !== undefined && { appliedLimit }), |
| ...(offset > 0 && { appliedOffset: offset }), |
| } |
|
|
| return { |
| data: output, |
| } |
| }, |
| } satisfies ToolDef<InputSchema, Output>) |
|
|