Source code for agenix.tools.grep
"""Grep/search tool."""
import os
import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from .base import Tool, ToolResult
[docs]
class GrepTool(Tool):
"""Search for patterns in files."""
[docs]
def __init__(self, working_dir: str = "."):
self.working_dir = working_dir
super().__init__(
name="grep",
description=(
"Search file contents for patterns using regular expressions. "
"Respects .gitignore. Supports filtering by file patterns (*.py, *.js). "
"Shows matching lines with context. Fast for finding code across projects."
),
parameters={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for"
},
"path": {
"type": "string",
"description": "File or directory to search in (default: current directory)",
"default": "."
},
"file_pattern": {
"type": "string",
"description": "Glob pattern to filter files (e.g., '*.py', '*.{js,ts}')",
},
"ignore_case": {
"type": "boolean",
"description": "Case insensitive search (default: false)",
"default": False
},
"context_lines": {
"type": "integer",
"description": "Number of lines to show before and after each match (default: 0)",
"default": 0
},
"max_results": {
"type": "integer",
"description": "Maximum number of matches to return (default: 100)",
"default": 100
}
},
"required": ["pattern"]
}
)
[docs]
async def execute(
self,
tool_call_id: str,
arguments: Dict[str, Any],
on_update: Optional[Callable[[str], None]] = None,
) -> ToolResult:
"""Execute grep operation."""
pattern = arguments["pattern"]
path = arguments.get("path", ".")
file_pattern = arguments.get("file_pattern")
ignore_case = arguments.get("ignore_case", False)
context_lines = arguments.get("context_lines", 0)
max_results = arguments.get("max_results", 100)
# Resolve path
if not os.path.isabs(path):
path = os.path.join(self.working_dir, path)
try:
# Compile regex
flags = re.IGNORECASE if ignore_case else 0
regex = re.compile(pattern, flags)
# Find files to search
files_to_search = []
if os.path.isfile(path):
files_to_search = [path]
elif os.path.isdir(path):
files_to_search = self._find_files(path, file_pattern)
else:
return ToolResult(
content=f"Error: Path not found: {path}",
is_error=True
)
# Search files
matches = []
files_searched = 0
for file_path in files_to_search:
if len(matches) >= max_results:
break
if on_update:
on_update(f"Searching: {file_path}")
file_matches = self._search_file(
file_path, regex, context_lines, max_results - len(matches)
)
if file_matches:
matches.extend(file_matches)
files_searched += 1
# Format results
if not matches:
return ToolResult(
content=f"No matches found for pattern '{pattern}' in {files_searched} files"
)
result_lines = [f"Found {len(matches)} matches in {files_searched} files:\n"]
current_file = None
for match in matches:
if match['file'] != current_file:
current_file = match['file']
result_lines.append(f"\n{current_file}:")
line_num = match['line_num']
line = match['line'].rstrip()
result_lines.append(f" {line_num:6d}: {line}")
# Add context lines
for ctx_line_num, ctx_line in match.get('context', []):
result_lines.append(f" {ctx_line_num:6d}: {ctx_line.rstrip()}")
return ToolResult(
content="\n".join(result_lines),
details={
"matches": len(matches),
"files_searched": files_searched
}
)
except re.error as e:
return ToolResult(
content=f"Error: Invalid regex pattern: {str(e)}",
is_error=True
)
except Exception as e:
return ToolResult(
content=f"Error during search: {str(e)}",
is_error=True
)
def _find_files(self, root: str, pattern: Optional[str] = None) -> List[str]:
"""Find files matching pattern."""
files = []
# Convert glob pattern to regex if provided
if pattern:
# Simple glob to regex conversion
regex_pattern = pattern.replace('.', r'\.')
regex_pattern = regex_pattern.replace('*', '.*')
regex_pattern = regex_pattern.replace('?', '.')
file_regex = re.compile(f"^{regex_pattern}$")
else:
file_regex = None
for dirpath, dirnames, filenames in os.walk(root):
# Skip hidden directories and common ignore patterns
dirnames[:] = [d for d in dirnames if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv', '.git']]
for filename in filenames:
# Skip hidden files
if filename.startswith('.'):
continue
# Check pattern
if file_regex and not file_regex.match(filename):
continue
files.append(os.path.join(dirpath, filename))
# Limit total files
if len(files) >= 1000:
return files
return files
def _search_file(
self,
file_path: str,
regex: re.Pattern,
context_lines: int,
max_matches: int
) -> List[Dict[str, Any]]:
"""Search a single file."""
matches = []
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
for i, line in enumerate(lines, start=1):
if len(matches) >= max_matches:
break
if regex.search(line):
match = {
'file': file_path,
'line_num': i,
'line': line,
}
# Add context lines
if context_lines > 0:
context = []
# Before
for j in range(max(0, i - context_lines - 1), i - 1):
context.append((j + 1, lines[j]))
# After
for j in range(i, min(len(lines), i + context_lines)):
context.append((j + 1, lines[j]))
match['context'] = context
matches.append(match)
except Exception:
# Skip files that can't be read
pass
return matches