| 17 | |
| 18 | |
| 19 | class GrepTool(Tool): |
| 20 | @property |
| 21 | def name(self) -> str: |
| 22 | return "grep" |
| 23 | |
| 24 | @property |
| 25 | def description(self) -> str: |
| 26 | return "Search file contents using regex. Uses ripgrep (rg) if available, otherwise falls back to Python re." |
| 27 | |
| 28 | @property |
| 29 | def input_schema(self) -> dict[str, Any]: |
| 30 | return { |
| 31 | "type": "object", |
| 32 | "properties": { |
| 33 | "pattern": {"type": "string", "description": "Regex pattern to search for."}, |
| 34 | "path": {"type": "string", "description": "File or directory to search (default: '.')."}, |
| 35 | "include": {"type": "string", "description": "Glob to filter files (e.g. '*.py')."}, |
| 36 | }, |
| 37 | "required": ["pattern"], |
| 38 | } |
| 39 | |
| 40 | def execute(self, params: dict[str, Any]) -> ToolResult: |
| 41 | pattern = params["pattern"] |
| 42 | search_path = Path(params.get("path", ".")).expanduser().resolve() |
| 43 | include = params.get("include") |
| 44 | |
| 45 | if shutil.which("rg"): |
| 46 | return self._rg_search(pattern, search_path, include) |
| 47 | return self._python_search(pattern, search_path, include) |
| 48 | |
| 49 | def _rg_search(self, pattern: str, path: Path, include: str | None) -> ToolResult: |
| 50 | cmd = ["rg", "--no-heading", "--line-number", "--max-count", str(MAX_MATCHES), pattern, str(path)] |
| 51 | if include: |
| 52 | cmd.extend(["--glob", include]) |
| 53 | try: |
| 54 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) |
| 55 | output = result.stdout.strip() |
| 56 | if not output: |
| 57 | return ToolResult(output="No matches found.") |
| 58 | lines = output.split("\n") |
| 59 | if len(lines) > MAX_MATCHES: |
| 60 | lines = lines[:MAX_MATCHES] |
| 61 | lines.append(f"... (truncated at {MAX_MATCHES} matches)") |
| 62 | return ToolResult(output="\n".join(lines)) |
| 63 | except Exception as exc: |
| 64 | return ToolResult(output=f"Error running rg: {exc}", is_error=True) |
| 65 | |
| 66 | def _python_search(self, pattern: str, path: Path, include: str | None) -> ToolResult: |
| 67 | try: |
| 68 | regex = re.compile(pattern) |
| 69 | except re.error as exc: |
| 70 | return ToolResult(output=f"Invalid regex: {exc}", is_error=True) |
| 71 | |
| 72 | matches: list[str] = [] |
| 73 | files = [path] if path.is_file() else sorted(path.rglob(include or "*")) |
| 74 | |
| 75 | for fp in files: |
| 76 | if not fp.is_file(): |