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.
- sanityops_cli/__init__.py +16 -0
- sanityops_cli/agents/__init__.py +14 -0
- sanityops_cli/agents/repair_agent/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/agent.py +230 -0
- sanityops_cli/agents/repair_agent/prompts.py +100 -0
- sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
- sanityops_cli/agents/scanner_agent/agent.py +332 -0
- sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
- sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
- sanityops_cli/agents/scanner_agent/prompts.py +316 -0
- sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
- sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
- sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
- sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
- sanityops_cli/api/__init__.py +16 -0
- sanityops_cli/api/client.py +403 -0
- sanityops_cli/commands/__init__.py +14 -0
- sanityops_cli/commands/config.py +312 -0
- sanityops_cli/commands/init.py +132 -0
- sanityops_cli/commands/inspect.py +646 -0
- sanityops_cli/constants/__init__.py +14 -0
- sanityops_cli/constants/config_defaults.py +22 -0
- sanityops_cli/constants/exit_codes.py +19 -0
- sanityops_cli/defect_checker/__init__.py +16 -0
- sanityops_cli/defect_checker/checker.py +100 -0
- sanityops_cli/defect_checker/llm_config.py +112 -0
- sanityops_cli/defect_checker/markdown_reporter.py +249 -0
- sanityops_cli/defect_checker/renderer.py +203 -0
- sanityops_cli/exceptions/__init__.py +14 -0
- sanityops_cli/exceptions/api_exceptions.py +60 -0
- sanityops_cli/exceptions/base_exceptions.py +25 -0
- sanityops_cli/help_panel.py +49 -0
- sanityops_cli/logging/__init__.py +18 -0
- sanityops_cli/logging/logger.py +108 -0
- sanityops_cli/main.py +123 -0
- sanityops_cli/progress/__init__.py +18 -0
- sanityops_cli/progress/tracker.py +159 -0
- sanityops_cli/renderers/__init__.py +14 -0
- sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
- sanityops_cli/templates/__init__.py +14 -0
- sanityops_cli/templates/inspect_config.yaml +55 -0
- sanityops_cli/utils/__init__.py +14 -0
- sanityops_cli/utils/artifact_packer.py +407 -0
- sanityops_cli/utils/config_loader.py +296 -0
- sanityops_cli/utils/config_resolver.py +358 -0
- sanityops_cli/utils/validators.py +117 -0
- sanityops_cli-0.1.3.dist-info/METADATA +213 -0
- sanityops_cli-0.1.3.dist-info/RECORD +53 -0
- sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
- sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
- sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|