sanityops-cli 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,667 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """
17
+ GrepTool - A powerful search tool built on ripgrep.
18
+
19
+ Adapted for the deeplogic-cli agent framework.
20
+ """
21
+
22
+ import asyncio
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from pathlib import Path
29
+
30
+ from sanityops_agent.tools.base import Tool, ToolResult
31
+
32
+
33
+ class OutputMode(Enum):
34
+ """Output mode for grep results."""
35
+ CONTENT = "content"
36
+ FILES_WITH_MATCHES = "files_with_matches"
37
+ COUNT = "count"
38
+
39
+
40
+ # Version control system directories to exclude from searches
41
+ VCS_DIRECTORIES_TO_EXCLUDE = [
42
+ '.git',
43
+ '.svn',
44
+ '.hg',
45
+ '.bzr',
46
+ '.jj',
47
+ '.sl',
48
+ ]
49
+
50
+ # Default cap on grep results when head_limit is unspecified
51
+ DEFAULT_HEAD_LIMIT = 250
52
+
53
+
54
+ @dataclass
55
+ class GrepOutput:
56
+ """Output schema for GrepTool."""
57
+ mode: OutputMode
58
+ num_files: int
59
+ filenames: list[str] = field(default_factory=list)
60
+ content: str | None = None
61
+ num_lines: int | None = None
62
+ num_matches: int | None = None
63
+ applied_limit: int | None = None
64
+ applied_offset: int | None = None
65
+
66
+
67
+ def get_cwd() -> str:
68
+ """Get current working directory."""
69
+ return os.getcwd()
70
+
71
+
72
+ def expand_path(path: str, base_dir: str | None = None) -> str:
73
+ """
74
+ Expand a path that may contain tilde notation (~) to an absolute path.
75
+
76
+ Args:
77
+ path: The path to expand
78
+ base_dir: Base directory for relative paths (defaults to cwd)
79
+
80
+ Returns:
81
+ The expanded absolute path
82
+ """
83
+ actual_base_dir = base_dir or get_cwd()
84
+
85
+ # Handle empty path
86
+ if not path or not path.strip():
87
+ return os.path.normpath(actual_base_dir)
88
+
89
+ # expanduser automatically handles '~' and '~/...'
90
+ expanded = os.path.expanduser(path.strip())
91
+
92
+ # Handle absolute paths
93
+ if os.path.isabs(expanded):
94
+ return os.path.normpath(expanded)
95
+
96
+ # Handle relative paths
97
+ return str(Path(actual_base_dir) / expanded)
98
+
99
+
100
+ def to_relative_path(absolute_path: str) -> str:
101
+ """
102
+ Convert an absolute path to a relative path from cwd.
103
+
104
+ If the path is outside cwd (relative path would start with ..),
105
+ returns the absolute path unchanged.
106
+ """
107
+ try:
108
+ relative_path = os.path.relpath(absolute_path, get_cwd())
109
+ if relative_path.startswith('..'):
110
+ return absolute_path
111
+ return relative_path
112
+ except ValueError:
113
+ # On Windows, different drives can't be relativized
114
+ return absolute_path
115
+
116
+
117
+ def apply_head_limit(
118
+ items: list,
119
+ limit: int | None,
120
+ offset: int = 0
121
+ ) -> tuple[list, int | None]:
122
+ """
123
+ Apply head_limit and offset to a list of items.
124
+
125
+ Args:
126
+ items: List of items to limit
127
+ limit: Maximum number of items (0 = unlimited)
128
+ offset: Number of items to skip from the start
129
+
130
+ Returns:
131
+ Tuple of (limited items, applied_limit if truncation occurred)
132
+ """
133
+ if limit == 0:
134
+ return items[offset:], None
135
+
136
+ effective_limit = limit or DEFAULT_HEAD_LIMIT
137
+ sliced = items[offset:offset + effective_limit]
138
+ was_truncated = len(items) - offset > effective_limit
139
+
140
+ return sliced, effective_limit if was_truncated else None
141
+
142
+
143
+ def format_limit_info(
144
+ applied_limit: int | None,
145
+ applied_offset: int | None
146
+ ) -> str:
147
+ """Format limit/offset information for display in tool results."""
148
+ parts = []
149
+ if applied_limit is not None:
150
+ parts.append(f"limit: {applied_limit}")
151
+ if applied_offset:
152
+ parts.append(f"offset: {applied_offset}")
153
+ return ", ".join(parts)
154
+
155
+
156
+ def find_ripgrep() -> str:
157
+ """Find the ripgrep executable."""
158
+ # Try to find rg in PATH
159
+ rg_path = shutil.which('rg')
160
+ if rg_path:
161
+ return rg_path
162
+
163
+ # Fallback to common locations
164
+ common_paths = [
165
+ '/usr/local/bin/rg',
166
+ '/usr/bin/rg',
167
+ os.path.expanduser('~/.local/bin/rg'),
168
+ ]
169
+
170
+ for path in common_paths:
171
+ if os.path.isfile(path) and os.access(path, os.X_OK):
172
+ return path
173
+
174
+ raise FileNotFoundError(
175
+ "ripgrep (rg) not found. Please install ripgrep: "
176
+ "https://github.com/BurntSushi/ripgrep#installation"
177
+ )
178
+
179
+
180
+ def run_ripgrep(
181
+ args: list[str],
182
+ target: str,
183
+ timeout: int = 20
184
+ ) -> list[str]:
185
+ """
186
+ Run ripgrep with the given arguments.
187
+
188
+ Args:
189
+ args: Arguments to pass to ripgrep
190
+ target: Target path to search
191
+ timeout: Timeout in seconds (default 20)
192
+
193
+ Returns:
194
+ List of output lines from ripgrep
195
+
196
+ Raises:
197
+ FileNotFoundError: If ripgrep is not found
198
+ subprocess.TimeoutExpired: If ripgrep times out
199
+ subprocess.CalledProcessError: If ripgrep fails
200
+ """
201
+ rg_path = find_ripgrep()
202
+
203
+ full_args = [rg_path] + args + [target]
204
+
205
+ result = subprocess.run(
206
+ full_args,
207
+ capture_output=True,
208
+ text=True,
209
+ timeout=timeout
210
+ )
211
+
212
+ # Exit code 0 = matches found, 1 = no matches (both are success)
213
+ # Exit code 2 = errors occurred (e.g., permission denied), but may have partial results
214
+ if result.returncode not in (0, 1):
215
+ # If we have stdout content despite returncode 2, treat as partial success
216
+ # This handles cases where some files had permission errors but results were found
217
+ if result.returncode == 2 and result.stdout.strip():
218
+ pass # Continue with partial results
219
+ else:
220
+ raise subprocess.CalledProcessError(
221
+ result.returncode,
222
+ full_args,
223
+ result.stdout,
224
+ result.stderr
225
+ )
226
+
227
+ # Split output into lines, filtering empty lines
228
+ lines = [
229
+ line.rstrip('\r')
230
+ for line in result.stdout.strip().split('\n')
231
+ if line
232
+ ]
233
+
234
+ return lines
235
+
236
+
237
+ def execute_grep(
238
+ pattern: str,
239
+ path: str | None = None,
240
+ glob: str | None = None,
241
+ output_mode: str | OutputMode = OutputMode.FILES_WITH_MATCHES,
242
+ context_before: int | None = None,
243
+ context_after: int | None = None,
244
+ context: int | None = None,
245
+ show_line_numbers: bool = True,
246
+ case_insensitive: bool = False,
247
+ type: str | None = None,
248
+ head_limit: int | None = None,
249
+ offset: int = 0,
250
+ multiline: bool = False,
251
+ ) -> GrepOutput:
252
+ """
253
+ Execute a grep search using ripgrep.
254
+
255
+ Args:
256
+ pattern: The regular expression pattern to search for
257
+ path: File or directory to search in (defaults to cwd)
258
+ glob: Glob pattern to filter files (e.g., "*.js", "*.{ts,tsx}")
259
+ output_mode: Output mode - "content", "files_with_matches", or "count"
260
+ context_before: Number of lines to show before each match (-B)
261
+ context_after: Number of lines to show after each match (-A)
262
+ context: Number of lines to show before and after each match (-C)
263
+ show_line_numbers: Show line numbers in output (-n)
264
+ case_insensitive: Case insensitive search (-i)
265
+ type: File type to search (e.g., "js", "py", "rust")
266
+ head_limit: Limit output to first N lines/entries
267
+ offset: Skip first N lines/entries before applying head_limit
268
+ multiline: Enable multiline mode where . matches newlines
269
+
270
+ Returns:
271
+ GrepOutput containing the search results
272
+ """
273
+ # Convert string output_mode to enum
274
+ if isinstance(output_mode, str):
275
+ output_mode = OutputMode(output_mode)
276
+
277
+ # Resolve the target path
278
+ absolute_path = expand_path(path) if path else get_cwd()
279
+
280
+ # Build ripgrep arguments
281
+ args = ['--hidden', '-H'] # -H: always show filename (fixes single-file search)
282
+
283
+ # Exclude VCS directories
284
+ for dir_name in VCS_DIRECTORIES_TO_EXCLUDE:
285
+ args.extend(['--glob', f'!{dir_name}'])
286
+
287
+ # Limit line length
288
+ args.extend(['--max-columns', '500'])
289
+
290
+ # Multiline mode
291
+ if multiline:
292
+ args.extend(['-U', '--multiline-dotall'])
293
+
294
+ # Case insensitive
295
+ if case_insensitive:
296
+ args.append('-i')
297
+
298
+ # Output mode
299
+ if output_mode == OutputMode.FILES_WITH_MATCHES:
300
+ args.append('-l')
301
+ elif output_mode == OutputMode.COUNT:
302
+ args.append('-c')
303
+
304
+ # Line numbers (only for content mode)
305
+ if show_line_numbers and output_mode == OutputMode.CONTENT:
306
+ args.append('-n')
307
+
308
+ # Context flags
309
+ if output_mode == OutputMode.CONTENT:
310
+ if context is not None:
311
+ args.extend(['-C', str(context)])
312
+ elif context_before is not None or context_after is not None:
313
+ if context_before is not None:
314
+ args.extend(['-B', str(context_before)])
315
+ if context_after is not None:
316
+ args.extend(['-A', str(context_after)])
317
+
318
+ # Pattern (use -e if pattern starts with dash)
319
+ if pattern.startswith('-'):
320
+ args.extend(['-e', pattern])
321
+ else:
322
+ args.append(pattern)
323
+
324
+ # Type filter
325
+ if type:
326
+ args.extend(['--type', type])
327
+
328
+ # Glob patterns - pass each space-separated pattern directly to ripgrep
329
+ # ripgrep natively supports comma-separated patterns and brace expansion
330
+ if glob:
331
+ for pattern_item in glob.split():
332
+ if pattern_item:
333
+ args.extend(['--glob', pattern_item])
334
+
335
+ # Execute ripgrep
336
+ results = run_ripgrep(args, absolute_path)
337
+
338
+ # Process results based on output mode
339
+ if output_mode == OutputMode.CONTENT:
340
+ limited_results, applied_limit = apply_head_limit(
341
+ results,
342
+ head_limit,
343
+ offset
344
+ )
345
+
346
+ # Convert absolute paths to relative paths
347
+ # Handle Windows drive letters (e.g., C:\path) where colon appears at index 1
348
+ final_lines = []
349
+ for line in limited_results:
350
+ colon_index = line.find(':')
351
+ # Check for Windows drive letter pattern (e.g., "C:\" or "C:/")
352
+ if colon_index == 1 and len(line) > 2 and line[0].isalpha() and line[2] in ('\\', '/'):
353
+ colon_index = line.find(':', colon_index + 1)
354
+
355
+ if colon_index > 0:
356
+ file_path = line[:colon_index]
357
+ rest = line[colon_index:]
358
+ final_lines.append(to_relative_path(file_path) + rest)
359
+ else:
360
+ final_lines.append(line)
361
+
362
+ return GrepOutput(
363
+ mode=OutputMode.CONTENT,
364
+ num_files=0,
365
+ content='\n'.join(final_lines),
366
+ num_lines=len(final_lines),
367
+ applied_limit=applied_limit,
368
+ applied_offset=offset if offset > 0 else None
369
+ )
370
+
371
+ if output_mode == OutputMode.COUNT:
372
+ limited_results, applied_limit = apply_head_limit(
373
+ results,
374
+ head_limit,
375
+ offset
376
+ )
377
+
378
+ # Convert absolute paths to relative paths
379
+ final_count_lines = []
380
+ total_matches = 0
381
+ file_count = 0
382
+
383
+ for line in limited_results:
384
+ colon_index = line.rfind(':')
385
+ if colon_index > 0:
386
+ file_path = line[:colon_index]
387
+ count_str = line[colon_index + 1:]
388
+ final_count_lines.append(to_relative_path(file_path) + ':' + count_str)
389
+
390
+ count = int(count_str) if count_str.isdigit() else 0
391
+ total_matches += count
392
+ if count > 0:
393
+ file_count += 1
394
+ else:
395
+ final_count_lines.append(line)
396
+
397
+ return GrepOutput(
398
+ mode=OutputMode.COUNT,
399
+ num_files=file_count,
400
+ content='\n'.join(final_count_lines),
401
+ num_matches=total_matches,
402
+ applied_limit=applied_limit,
403
+ applied_offset=offset if offset > 0 else None
404
+ )
405
+
406
+ # files_with_matches mode (default)
407
+ # Apply head_limit to sorted file list
408
+ final_matches, applied_limit = apply_head_limit(
409
+ results,
410
+ head_limit,
411
+ offset
412
+ )
413
+
414
+ # Convert absolute paths to relative paths
415
+ relative_matches = [to_relative_path(p) for p in final_matches]
416
+
417
+ return GrepOutput(
418
+ mode=OutputMode.FILES_WITH_MATCHES,
419
+ filenames=relative_matches,
420
+ num_files=len(relative_matches),
421
+ applied_limit=applied_limit,
422
+ applied_offset=offset if offset > 0 else None
423
+ )
424
+
425
+
426
+ def format_result(output: GrepOutput) -> str:
427
+ """
428
+ Format the grep output for display.
429
+
430
+ Args:
431
+ output: The grep output to format
432
+
433
+ Returns:
434
+ Formatted string representation of the results
435
+ """
436
+ if output.mode == OutputMode.CONTENT:
437
+ content = output.content or 'No matches found'
438
+ limit_info = format_limit_info(output.applied_limit, output.applied_offset)
439
+ if limit_info:
440
+ return f"{content}\n\n[Showing results with pagination = {limit_info}]"
441
+ return content
442
+
443
+ if output.mode == OutputMode.COUNT:
444
+ content = output.content or 'No matches found'
445
+ matches = output.num_matches or 0
446
+ files = output.num_files or 0
447
+ limit_info = format_limit_info(output.applied_limit, output.applied_offset)
448
+
449
+ matches_str = 'occurrence' if matches == 1 else 'occurrences'
450
+ files_str = 'file' if files == 1 else 'files'
451
+
452
+ summary = f"\n\nFound {matches} total {matches_str} across {files} {files_str}."
453
+ if limit_info:
454
+ summary += f" with pagination = {limit_info}"
455
+
456
+ return content + summary
457
+
458
+ # files_with_matches mode
459
+ if output.num_files == 0:
460
+ return 'No files found'
461
+
462
+ files_str = 'file' if output.num_files == 1 else 'files'
463
+ limit_info = format_limit_info(output.applied_limit, output.applied_offset)
464
+
465
+ result = f"Found {output.num_files} {files_str}"
466
+ if limit_info:
467
+ result += f" {limit_info}"
468
+
469
+ return result + '\n' + '\n'.join(output.filenames)
470
+
471
+
472
+ class GrepTool(Tool):
473
+ """
474
+ A powerful search tool built on ripgrep.
475
+
476
+ This tool provides regex-based file content searching with support for:
477
+ - Full regex syntax
478
+ - Glob pattern filtering
479
+ - Multiple output modes (content, files_with_matches, count)
480
+ - Context lines before/after matches
481
+ - Case-insensitive search
482
+ - Multiline matching
483
+ """
484
+
485
+ name = "grep"
486
+ description = """A powerful search tool built on ripgrep
487
+
488
+ Usage:
489
+ - ALWAYS use grep for search tasks. NEVER invoke `grep` or `rg` as a shell command.
490
+ - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
491
+ - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
492
+ - Output modes: "content" shows matching lines, "files_with_matches" shows file paths (default), "count" shows match counts
493
+ - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\\{\\}` to find `interface{}` in Go code)
494
+ - Multiline matching: By default patterns match within single lines only. For cross-line patterns, use multiline: true
495
+ """
496
+ parameters = {
497
+ "type": "object",
498
+ "properties": {
499
+ "pattern": {
500
+ "type": "string",
501
+ "description": "The regular expression pattern to search for"
502
+ },
503
+ "path": {
504
+ "type": "string",
505
+ "description": "File or directory to search in (defaults to cwd)"
506
+ },
507
+ "glob": {
508
+ "type": "string",
509
+ "description": "Glob pattern to filter files (e.g., '*.js', '*.{ts,tsx}')"
510
+ },
511
+ "output_mode": {
512
+ "type": "string",
513
+ "enum": ["content", "files_with_matches", "count"],
514
+ "default": "files_with_matches",
515
+ "description": "Output mode - 'content' shows matching lines, 'files_with_matches' shows file paths, 'count' shows match counts"
516
+ },
517
+ "context_before": {
518
+ "type": "integer",
519
+ "description": "Number of lines to show before each match (-B)"
520
+ },
521
+ "context_after": {
522
+ "type": "integer",
523
+ "description": "Number of lines to show after each match (-A)"
524
+ },
525
+ "context": {
526
+ "type": "integer",
527
+ "description": "Number of lines to show before and after each match (-C)"
528
+ },
529
+ "show_line_numbers": {
530
+ "type": "boolean",
531
+ "default": True,
532
+ "description": "Show line numbers in output"
533
+ },
534
+ "case_insensitive": {
535
+ "type": "boolean",
536
+ "default": False,
537
+ "description": "Case insensitive search"
538
+ },
539
+ "type": {
540
+ "type": "string",
541
+ "description": "File type to search (e.g., 'js', 'py', 'rust')"
542
+ },
543
+ "head_limit": {
544
+ "type": "integer",
545
+ "description": "Limit output to first N lines/entries"
546
+ },
547
+ "offset": {
548
+ "type": "integer",
549
+ "default": 0,
550
+ "description": "Skip first N lines/entries before applying head_limit"
551
+ },
552
+ "multiline": {
553
+ "type": "boolean",
554
+ "default": False,
555
+ "description": "Enable multiline mode where . matches newlines"
556
+ }
557
+ },
558
+ "required": ["pattern"]
559
+ }
560
+ tags = ["scanner", "search"]
561
+
562
+ async def execute(
563
+ self,
564
+ pattern: str,
565
+ path: str | None = None,
566
+ glob: str | None = None,
567
+ output_mode: str = "files_with_matches",
568
+ context_before: int | None = None,
569
+ context_after: int | None = None,
570
+ context: int | None = None,
571
+ show_line_numbers: bool = True,
572
+ case_insensitive: bool = False,
573
+ type: str | None = None,
574
+ head_limit: int | None = None,
575
+ offset: int = 0,
576
+ multiline: bool = False,
577
+ **kwargs
578
+ ) -> ToolResult:
579
+ """
580
+ Search for a pattern in files using ripgrep.
581
+
582
+ Args:
583
+ pattern: The regular expression pattern to search for
584
+ path: File or directory to search in (defaults to cwd)
585
+ glob: Glob pattern to filter files (e.g., "*.js", "*.{ts,tsx}")
586
+ output_mode: Output mode - "content", "files_with_matches", or "count"
587
+ context_before: Number of lines to show before each match (-B)
588
+ context_after: Number of lines to show after each match (-A)
589
+ context: Number of lines to show before and after each match (-C)
590
+ show_line_numbers: Show line numbers in output (-n)
591
+ case_insensitive: Case insensitive search (-i)
592
+ type: File type to search (e.g., "js", "py", "rust")
593
+ head_limit: Limit output to first N lines/entries
594
+ offset: Skip first N lines/entries before applying head_limit
595
+ multiline: Enable multiline mode where . matches newlines
596
+
597
+ Returns:
598
+ ToolResult containing the search results
599
+ """
600
+ try:
601
+ # Use asyncio.to_thread to avoid blocking the event loop
602
+ # since execute_grep uses subprocess.run which is synchronous
603
+ output = await asyncio.to_thread(
604
+ execute_grep,
605
+ pattern=pattern,
606
+ path=path,
607
+ glob=glob,
608
+ output_mode=output_mode,
609
+ context_before=context_before,
610
+ context_after=context_after,
611
+ context=context,
612
+ show_line_numbers=show_line_numbers,
613
+ case_insensitive=case_insensitive,
614
+ type=type,
615
+ head_limit=head_limit,
616
+ offset=offset,
617
+ multiline=multiline,
618
+ )
619
+
620
+ content = format_result(output)
621
+
622
+ return ToolResult(
623
+ content=content,
624
+ success=True,
625
+ metadata={
626
+ "mode": output.mode.value,
627
+ "num_files": output.num_files,
628
+ "num_lines": output.num_lines,
629
+ "num_matches": output.num_matches,
630
+ "applied_limit": output.applied_limit,
631
+ }
632
+ )
633
+
634
+ except FileNotFoundError as e:
635
+ return ToolResult(
636
+ content=f"Error: {e}",
637
+ success=False,
638
+ error=str(e)
639
+ )
640
+ except subprocess.TimeoutExpired:
641
+ return ToolResult(
642
+ content="Error: grep search timed out",
643
+ success=False,
644
+ error="Timeout"
645
+ )
646
+ except subprocess.CalledProcessError as e:
647
+ return ToolResult(
648
+ content=f"Error: grep failed with code {e.returncode}",
649
+ success=False,
650
+ error=str(e.stderr) if e.stderr else str(e)
651
+ )
652
+ except Exception as e:
653
+ return ToolResult(
654
+ content=f"Error: {e}",
655
+ success=False,
656
+ error=str(e)
657
+ )
658
+
659
+
660
+ # Export public API
661
+ __all__ = [
662
+ 'GrepTool',
663
+ 'GrepOutput',
664
+ 'OutputMode',
665
+ 'execute_grep',
666
+ 'format_result',
667
+ ]