ripperdoc 0.2.9__py3-none-any.whl → 0.3.0__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +379 -51
- ripperdoc/cli/commands/__init__.py +6 -0
- ripperdoc/cli/commands/agents_cmd.py +128 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +63 -7
- ripperdoc/cli/commands/resume_cmd.py +5 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +10 -0
- ripperdoc/cli/commands/tasks_cmd.py +6 -3
- ripperdoc/cli/commands/themes_cmd.py +139 -0
- ripperdoc/cli/ui/file_mention_completer.py +63 -13
- ripperdoc/cli/ui/helpers.py +6 -3
- ripperdoc/cli/ui/interrupt_handler.py +34 -0
- ripperdoc/cli/ui/panels.py +14 -8
- ripperdoc/cli/ui/rich_ui.py +737 -47
- ripperdoc/cli/ui/spinner.py +93 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +24 -19
- ripperdoc/core/agents.py +14 -3
- ripperdoc/core/config.py +238 -6
- ripperdoc/core/default_tools.py +91 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +58 -0
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +160 -9
- ripperdoc/core/providers/openai.py +84 -28
- ripperdoc/core/query.py +489 -87
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +15 -5
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +354 -139
- ripperdoc/tools/bash_tool.py +117 -22
- ripperdoc/tools/file_edit_tool.py +228 -50
- ripperdoc/tools/file_read_tool.py +154 -3
- ripperdoc/tools/file_write_tool.py +53 -11
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +609 -0
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +539 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +216 -7
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +812 -0
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +7 -4
- ripperdoc/utils/messages.py +198 -33
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +242 -0
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +294 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
- ripperdoc-0.3.0.dist-info/RECORD +136 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -333
- ripperdoc-0.2.9.dist-info/RECORD +0 -123
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -5,8 +5,9 @@ Allows the AI to read file contents.
|
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import AsyncGenerator, List, Optional
|
|
8
|
+
from typing import AsyncGenerator, List, Optional, Tuple
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
|
+
from charset_normalizer import from_bytes
|
|
10
11
|
|
|
11
12
|
from ripperdoc.core.tool import (
|
|
12
13
|
Tool,
|
|
@@ -23,6 +24,105 @@ from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
|
23
24
|
logger = get_logger()
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def detect_file_encoding(file_path: str) -> Tuple[Optional[str], float]:
|
|
28
|
+
"""Detect file encoding using charset-normalizer.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (encoding, confidence). encoding is None if detection failed.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
with open(file_path, "rb") as f:
|
|
35
|
+
raw_data = f.read()
|
|
36
|
+
results = from_bytes(raw_data)
|
|
37
|
+
|
|
38
|
+
if not results:
|
|
39
|
+
return None, 0.0
|
|
40
|
+
|
|
41
|
+
best = results.best()
|
|
42
|
+
if not best:
|
|
43
|
+
return None, 0.0
|
|
44
|
+
|
|
45
|
+
# For Chinese content, prefer GB encodings over Big5/others
|
|
46
|
+
# charset-normalizer sometimes picks Big5 for simplified Chinese
|
|
47
|
+
if best.language == "Chinese":
|
|
48
|
+
gb_encodings = {"gb18030", "gbk", "gb2312"}
|
|
49
|
+
for result in results:
|
|
50
|
+
if result.encoding.lower() in gb_encodings:
|
|
51
|
+
return result.encoding, 0.9
|
|
52
|
+
|
|
53
|
+
return best.encoding, 0.9
|
|
54
|
+
except (OSError, IOError) as e:
|
|
55
|
+
logger.warning("Failed to detect encoding for %s: %s", file_path, e)
|
|
56
|
+
return None, 0.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_file_with_encoding(file_path: str) -> Tuple[Optional[List[str]], str, Optional[str]]:
|
|
60
|
+
"""Read file with proper encoding detection.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Tuple of (lines, encoding_used, error_message).
|
|
64
|
+
If successful: (lines, encoding, None)
|
|
65
|
+
If failed: (None, "", error_message)
|
|
66
|
+
"""
|
|
67
|
+
# First, try UTF-8 (most common)
|
|
68
|
+
try:
|
|
69
|
+
with open(file_path, "r", encoding="utf-8", errors="strict") as f:
|
|
70
|
+
lines = f.readlines()
|
|
71
|
+
return lines, "utf-8", None
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# UTF-8 failed, use charset-normalizer to detect encoding
|
|
76
|
+
detected_encoding, confidence = detect_file_encoding(file_path)
|
|
77
|
+
|
|
78
|
+
if detected_encoding:
|
|
79
|
+
try:
|
|
80
|
+
with open(file_path, "r", encoding=detected_encoding, errors="strict") as f:
|
|
81
|
+
lines = f.readlines()
|
|
82
|
+
logger.info(
|
|
83
|
+
"File %s decoded using detected encoding %s",
|
|
84
|
+
file_path,
|
|
85
|
+
detected_encoding,
|
|
86
|
+
)
|
|
87
|
+
return lines, detected_encoding, None
|
|
88
|
+
except (UnicodeDecodeError, LookupError) as e:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"Failed to read %s with detected encoding %s: %s",
|
|
91
|
+
file_path,
|
|
92
|
+
detected_encoding,
|
|
93
|
+
e,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Detection failed - try latin-1 as last resort (can decode any byte sequence)
|
|
97
|
+
try:
|
|
98
|
+
with open(file_path, "r", encoding="latin-1", errors="strict") as f:
|
|
99
|
+
lines = f.readlines()
|
|
100
|
+
logger.warning(
|
|
101
|
+
"File %s: encoding detection failed, using latin-1 fallback",
|
|
102
|
+
file_path,
|
|
103
|
+
)
|
|
104
|
+
return lines, "latin-1", None
|
|
105
|
+
except (UnicodeDecodeError, LookupError):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# All attempts failed - return error
|
|
109
|
+
error_msg = (
|
|
110
|
+
f"Unable to determine file encoding. "
|
|
111
|
+
f"Detected: {detected_encoding or 'unknown'} (confidence: {confidence * 100:.0f}%). "
|
|
112
|
+
f"Tried fallback encodings: utf-8, latin-1. "
|
|
113
|
+
f"Please convert the file to UTF-8."
|
|
114
|
+
)
|
|
115
|
+
return None, "", error_msg
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Maximum file size to read (default 256KB)
|
|
119
|
+
# Can be overridden via env var in bytes
|
|
120
|
+
MAX_FILE_SIZE_BYTES = int(os.getenv("RIPPERDOC_MAX_READ_FILE_SIZE_BYTES", "262144")) # 256KB
|
|
121
|
+
|
|
122
|
+
# Maximum lines to read when no limit is specified (default 2000 lines)
|
|
123
|
+
MAX_READ_LINES = int(os.getenv("RIPPERDOC_MAX_READ_LINES", "2000"))
|
|
124
|
+
|
|
125
|
+
|
|
26
126
|
class FileReadToolInput(BaseModel):
|
|
27
127
|
"""Input schema for FileReadTool."""
|
|
28
128
|
|
|
@@ -75,6 +175,7 @@ and limit to read only a portion of the file."""
|
|
|
75
175
|
"Read a file from the local filesystem.\n\n"
|
|
76
176
|
"Usage:\n"
|
|
77
177
|
"- The file_path parameter must be an absolute path (not relative).\n"
|
|
178
|
+
"- Files larger than 256KB or with more than 2000 lines require using offset and limit parameters.\n"
|
|
78
179
|
"- By default, the entire file is read. You can optionally specify a line offset and limit (handy for long files); offset is zero-based and output line numbers start at 1.\n"
|
|
79
180
|
"- Lines longer than 2000 characters are truncated in the output.\n"
|
|
80
181
|
"- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
|
|
@@ -140,11 +241,60 @@ and limit to read only a portion of the file."""
|
|
|
140
241
|
"""Read the file."""
|
|
141
242
|
|
|
142
243
|
try:
|
|
143
|
-
|
|
144
|
-
|
|
244
|
+
# Check file size before reading to prevent memory exhaustion
|
|
245
|
+
file_size = os.path.getsize(input_data.file_path)
|
|
246
|
+
if file_size > MAX_FILE_SIZE_BYTES:
|
|
247
|
+
size_kb = file_size / 1024
|
|
248
|
+
limit_kb = MAX_FILE_SIZE_BYTES / 1024
|
|
249
|
+
error_output = FileReadToolOutput(
|
|
250
|
+
content=f"File too large to read: {size_kb:.1f}KB exceeds limit of {limit_kb:.0f}KB. Use offset and limit parameters to read portions.",
|
|
251
|
+
file_path=input_data.file_path,
|
|
252
|
+
line_count=0,
|
|
253
|
+
offset=0,
|
|
254
|
+
limit=None,
|
|
255
|
+
)
|
|
256
|
+
yield ToolResult(
|
|
257
|
+
data=error_output,
|
|
258
|
+
result_for_assistant=f"Error: File {input_data.file_path} is too large ({size_kb:.1f}KB). Maximum size is {limit_kb:.0f}KB. Use offset and limit to read portions, e.g., Read(file_path='{input_data.file_path}', offset=0, limit=500).",
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# Detect and read file with proper encoding
|
|
263
|
+
lines, used_encoding, encoding_error = read_file_with_encoding(input_data.file_path)
|
|
264
|
+
|
|
265
|
+
if lines is None:
|
|
266
|
+
# Encoding detection failed - return warning to LLM
|
|
267
|
+
error_output = FileReadToolOutput(
|
|
268
|
+
content=f"Encoding error: {encoding_error}",
|
|
269
|
+
file_path=input_data.file_path,
|
|
270
|
+
line_count=0,
|
|
271
|
+
offset=0,
|
|
272
|
+
limit=None,
|
|
273
|
+
)
|
|
274
|
+
yield ToolResult(
|
|
275
|
+
data=error_output,
|
|
276
|
+
result_for_assistant=f"Error: Cannot read file {input_data.file_path}. {encoding_error}",
|
|
277
|
+
)
|
|
278
|
+
return
|
|
145
279
|
|
|
146
280
|
offset = input_data.offset or 0
|
|
147
281
|
limit = input_data.limit
|
|
282
|
+
total_lines = len(lines)
|
|
283
|
+
|
|
284
|
+
# Check line count if no limit is specified (to prevent context overflow)
|
|
285
|
+
if limit is None and total_lines > MAX_READ_LINES:
|
|
286
|
+
error_output = FileReadToolOutput(
|
|
287
|
+
content=f"File too large: {total_lines} lines exceeds limit of {MAX_READ_LINES} lines. Use offset and limit parameters to read portions.",
|
|
288
|
+
file_path=input_data.file_path,
|
|
289
|
+
line_count=total_lines,
|
|
290
|
+
offset=0,
|
|
291
|
+
limit=None,
|
|
292
|
+
)
|
|
293
|
+
yield ToolResult(
|
|
294
|
+
data=error_output,
|
|
295
|
+
result_for_assistant=f"Error: File {input_data.file_path} has {total_lines} lines, exceeding the limit of {MAX_READ_LINES} lines when reading without limit parameter. Use offset and limit to read portions, e.g., Read(file_path='{input_data.file_path}', offset=0, limit=500).",
|
|
296
|
+
)
|
|
297
|
+
return
|
|
148
298
|
|
|
149
299
|
# Apply offset and limit
|
|
150
300
|
if limit is not None:
|
|
@@ -164,6 +314,7 @@ and limit to read only a portion of the file."""
|
|
|
164
314
|
getattr(context, "file_state_cache", {}),
|
|
165
315
|
offset=offset,
|
|
166
316
|
limit=limit,
|
|
317
|
+
encoding=used_encoding,
|
|
167
318
|
)
|
|
168
319
|
except (OSError, IOError, RuntimeError) as exc:
|
|
169
320
|
logger.warning(
|
|
@@ -19,10 +19,48 @@ from ripperdoc.core.tool import (
|
|
|
19
19
|
from ripperdoc.utils.log import get_logger
|
|
20
20
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
21
|
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
22
|
+
from ripperdoc.tools.file_read_tool import detect_file_encoding
|
|
22
23
|
|
|
23
24
|
logger = get_logger()
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
def determine_write_encoding(file_path: str, content: str) -> str:
|
|
28
|
+
"""Determine the best encoding to use for writing a file.
|
|
29
|
+
|
|
30
|
+
Strategy:
|
|
31
|
+
1. If file doesn't exist -> use UTF-8
|
|
32
|
+
2. If file exists -> detect its encoding using charset-normalizer
|
|
33
|
+
3. Verify content can be encoded with target encoding
|
|
34
|
+
4. If encoding fails (e.g., emoji in GBK) -> fall back to UTF-8
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The encoding to use for writing.
|
|
38
|
+
"""
|
|
39
|
+
# Default to UTF-8 for new files
|
|
40
|
+
if not os.path.exists(file_path):
|
|
41
|
+
return "utf-8"
|
|
42
|
+
|
|
43
|
+
# Detect existing file's encoding
|
|
44
|
+
detected_encoding, _ = detect_file_encoding(file_path)
|
|
45
|
+
|
|
46
|
+
# If detection failed, use UTF-8
|
|
47
|
+
if not detected_encoding:
|
|
48
|
+
return "utf-8"
|
|
49
|
+
|
|
50
|
+
# Verify content can be encoded with detected encoding
|
|
51
|
+
try:
|
|
52
|
+
content.encode(detected_encoding)
|
|
53
|
+
return detected_encoding
|
|
54
|
+
except (UnicodeEncodeError, LookupError):
|
|
55
|
+
# Content can't be encoded (e.g., emoji in GBK), fall back to UTF-8
|
|
56
|
+
logger.info(
|
|
57
|
+
"Content cannot be encoded with %s, falling back to UTF-8 for %s",
|
|
58
|
+
detected_encoding,
|
|
59
|
+
file_path,
|
|
60
|
+
)
|
|
61
|
+
return "utf-8"
|
|
62
|
+
|
|
63
|
+
|
|
26
64
|
class FileWriteToolInput(BaseModel):
|
|
27
65
|
"""Input schema for FileWriteTool."""
|
|
28
66
|
|
|
@@ -104,6 +142,13 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
104
142
|
|
|
105
143
|
file_path = os.path.abspath(input_data.file_path)
|
|
106
144
|
|
|
145
|
+
file_path_obj = Path(file_path)
|
|
146
|
+
should_proceed, warning_msg = check_path_for_tool(
|
|
147
|
+
file_path_obj, tool_name="Write", warn_only=True
|
|
148
|
+
)
|
|
149
|
+
if warning_msg:
|
|
150
|
+
logger.warning("[file_write_tool] %s", warning_msg)
|
|
151
|
+
|
|
107
152
|
# If file doesn't exist, it's a new file - allow without reading first
|
|
108
153
|
if not os.path.exists(file_path):
|
|
109
154
|
return ValidationResult(result=True)
|
|
@@ -132,14 +177,6 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
132
177
|
except OSError:
|
|
133
178
|
pass # File mtime check failed, proceed anyway
|
|
134
179
|
|
|
135
|
-
# Check if path is ignored (warning for write operations)
|
|
136
|
-
file_path_obj = Path(file_path)
|
|
137
|
-
should_proceed, warning_msg = check_path_for_tool(
|
|
138
|
-
file_path_obj, tool_name="Write", warn_only=True
|
|
139
|
-
)
|
|
140
|
-
if warning_msg:
|
|
141
|
-
logger.warning("[file_write_tool] %s", warning_msg)
|
|
142
|
-
|
|
143
180
|
return ValidationResult(result=True)
|
|
144
181
|
|
|
145
182
|
def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
|
|
@@ -156,11 +193,15 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
156
193
|
"""Write the file."""
|
|
157
194
|
|
|
158
195
|
try:
|
|
159
|
-
#
|
|
160
|
-
|
|
196
|
+
# Determine encoding based on target file and content
|
|
197
|
+
file_path = os.path.abspath(input_data.file_path)
|
|
198
|
+
encoding = determine_write_encoding(file_path, input_data.content)
|
|
199
|
+
|
|
200
|
+
# Write the file with the appropriate encoding
|
|
201
|
+
with open(input_data.file_path, "w", encoding=encoding) as f:
|
|
161
202
|
f.write(input_data.content)
|
|
162
203
|
|
|
163
|
-
bytes_written = len(input_data.content.encode(
|
|
204
|
+
bytes_written = len(input_data.content.encode(encoding))
|
|
164
205
|
|
|
165
206
|
# Use absolute path to ensure consistency with validation lookup
|
|
166
207
|
abs_file_path = os.path.abspath(input_data.file_path)
|
|
@@ -169,6 +210,7 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
169
210
|
abs_file_path,
|
|
170
211
|
input_data.content,
|
|
171
212
|
getattr(context, "file_state_cache", {}),
|
|
213
|
+
encoding=encoding,
|
|
172
214
|
)
|
|
173
215
|
except (OSError, IOError, RuntimeError) as exc:
|
|
174
216
|
logger.warning(
|
ripperdoc/tools/grep_tool.py
CHANGED
|
@@ -78,6 +78,35 @@ def _normalize_glob_for_grep(glob_pattern: str) -> str:
|
|
|
78
78
|
return glob_pattern.split("/")[-1] or glob_pattern
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
_GREP_SUPPORTS_PCRE: Optional[bool] = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _grep_supports_pcre() -> bool:
|
|
85
|
+
"""Detect if the system grep supports -P (Perl regex), caching the result."""
|
|
86
|
+
global _GREP_SUPPORTS_PCRE
|
|
87
|
+
if _GREP_SUPPORTS_PCRE is not None:
|
|
88
|
+
return _GREP_SUPPORTS_PCRE
|
|
89
|
+
|
|
90
|
+
if shutil.which("grep") is None:
|
|
91
|
+
_GREP_SUPPORTS_PCRE = False
|
|
92
|
+
return _GREP_SUPPORTS_PCRE
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
proc = subprocess.run(
|
|
96
|
+
["grep", "-P", ""],
|
|
97
|
+
stdin=subprocess.DEVNULL, # Fix: prevent waiting for stdin
|
|
98
|
+
stdout=subprocess.DEVNULL,
|
|
99
|
+
stderr=subprocess.PIPE,
|
|
100
|
+
check=False,
|
|
101
|
+
timeout=15, # Safety timeout
|
|
102
|
+
)
|
|
103
|
+
_GREP_SUPPORTS_PCRE = proc.returncode in (0, 1)
|
|
104
|
+
except (OSError, ValueError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
105
|
+
_GREP_SUPPORTS_PCRE = False
|
|
106
|
+
|
|
107
|
+
return _GREP_SUPPORTS_PCRE
|
|
108
|
+
|
|
109
|
+
|
|
81
110
|
class GrepToolInput(BaseModel):
|
|
82
111
|
"""Input schema for GrepTool."""
|
|
83
112
|
|
|
@@ -234,11 +263,36 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
|
|
|
234
263
|
self, input_data: GrepToolInput, _context: ToolUseContext
|
|
235
264
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
236
265
|
"""Search for the pattern."""
|
|
266
|
+
logger.debug(
|
|
267
|
+
"[grep_tool] call ENTER: pattern='%s' path='%s'", input_data.pattern, input_data.path
|
|
268
|
+
)
|
|
237
269
|
|
|
238
270
|
try:
|
|
239
271
|
search_path = input_data.path or "."
|
|
240
272
|
|
|
273
|
+
async def _run_search(command: List[str]) -> Tuple[int, str, str]:
|
|
274
|
+
"""Execute the search command and return decoded output."""
|
|
275
|
+
logger.debug(
|
|
276
|
+
"[grep_tool] _run_search: BEFORE create_subprocess_exec, cmd=%s", command[:5]
|
|
277
|
+
)
|
|
278
|
+
process = await asyncio.create_subprocess_exec(
|
|
279
|
+
*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
280
|
+
)
|
|
281
|
+
logger.debug(
|
|
282
|
+
"[grep_tool] _run_search: AFTER create_subprocess_exec, pid=%s", process.pid
|
|
283
|
+
)
|
|
284
|
+
logger.debug("[grep_tool] _run_search: BEFORE communicate()")
|
|
285
|
+
stdout, stderr = await process.communicate()
|
|
286
|
+
logger.debug(
|
|
287
|
+
"[grep_tool] _run_search: AFTER communicate(), returncode=%s",
|
|
288
|
+
process.returncode,
|
|
289
|
+
)
|
|
290
|
+
stdout_text = stdout.decode("utf-8", errors="ignore") if stdout else ""
|
|
291
|
+
stderr_text = stderr.decode("utf-8", errors="ignore") if stderr else ""
|
|
292
|
+
return process.returncode or 0, stdout_text, stderr_text
|
|
293
|
+
|
|
241
294
|
use_ripgrep = shutil.which("rg") is not None
|
|
295
|
+
logger.debug("[grep_tool] use_ripgrep=%s", use_ripgrep)
|
|
242
296
|
pattern = input_data.pattern
|
|
243
297
|
|
|
244
298
|
if use_ripgrep:
|
|
@@ -263,7 +317,11 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
|
|
|
263
317
|
cmd.append(search_path)
|
|
264
318
|
else:
|
|
265
319
|
# Fallback to grep (note: grep --include matches basenames only)
|
|
266
|
-
|
|
320
|
+
logger.debug("[grep_tool] Using grep fallback, checking PCRE support...")
|
|
321
|
+
use_pcre = _grep_supports_pcre()
|
|
322
|
+
logger.debug("[grep_tool] PCRE support check done: use_pcre=%s", use_pcre)
|
|
323
|
+
cmd = ["grep", "-r", "--color=never", "-P" if use_pcre else "-E"]
|
|
324
|
+
logger.debug("[grep_tool] Building grep command...")
|
|
267
325
|
|
|
268
326
|
if input_data.case_insensitive:
|
|
269
327
|
cmd.append("-i")
|
|
@@ -285,20 +343,52 @@ class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
|
|
|
285
343
|
|
|
286
344
|
cmd.append(search_path)
|
|
287
345
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
346
|
+
logger.debug("[grep_tool] BEFORE _run_search, cmd=%s", cmd)
|
|
347
|
+
returncode, stdout_text, stderr_text = await _run_search(cmd)
|
|
348
|
+
logger.debug(
|
|
349
|
+
"[grep_tool] AFTER _run_search, returncode=%s, stdout_len=%d",
|
|
350
|
+
returncode,
|
|
351
|
+
len(stdout_text),
|
|
291
352
|
)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
returncode
|
|
353
|
+
fallback_attempted = False
|
|
354
|
+
|
|
355
|
+
if returncode not in (0, 1):
|
|
356
|
+
if not use_ripgrep and "-P" in cmd:
|
|
357
|
+
# BSD grep lacks -P; retry with extended regex before surfacing the error.
|
|
358
|
+
fallback_attempted = True
|
|
359
|
+
cmd = [flag if flag != "-P" else "-E" for flag in cmd]
|
|
360
|
+
returncode, stdout_text, stderr_text = await _run_search(cmd)
|
|
361
|
+
|
|
362
|
+
if returncode not in (0, 1):
|
|
363
|
+
error_msg = stderr_text.strip() or f"grep exited with status {returncode}"
|
|
364
|
+
logger.warning(
|
|
365
|
+
"[grep_tool] Grep command failed",
|
|
366
|
+
extra={
|
|
367
|
+
"pattern": input_data.pattern,
|
|
368
|
+
"path": input_data.path,
|
|
369
|
+
"returncode": returncode,
|
|
370
|
+
"stderr": error_msg,
|
|
371
|
+
"fallback_to_E": fallback_attempted,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
error_output = GrepToolOutput(
|
|
375
|
+
matches=[],
|
|
376
|
+
pattern=input_data.pattern,
|
|
377
|
+
total_files=0,
|
|
378
|
+
total_matches=0,
|
|
379
|
+
output_mode=input_data.output_mode,
|
|
380
|
+
head_limit=input_data.head_limit,
|
|
381
|
+
)
|
|
382
|
+
yield ToolResult(
|
|
383
|
+
data=error_output, result_for_assistant=f"Grep error: {error_msg}"
|
|
384
|
+
)
|
|
385
|
+
return
|
|
295
386
|
|
|
296
387
|
# Parse output
|
|
297
388
|
matches: List[GrepMatch] = []
|
|
298
389
|
total_matches = 0
|
|
299
390
|
total_files = 0
|
|
300
391
|
omitted_results = 0
|
|
301
|
-
stdout_text = stdout.decode("utf-8", errors="ignore") if stdout else ""
|
|
302
392
|
lines = [line for line in stdout_text.split("\n") if line]
|
|
303
393
|
|
|
304
394
|
if returncode in (0, 1): # 0 = matches found, 1 = no matches (ripgrep/grep)
|