ripperdoc 0.2.10__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 +164 -57
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +3 -7
- ripperdoc/cli/commands/doctor_cmd.py +29 -0
- ripperdoc/cli/commands/memory_cmd.py +2 -1
- ripperdoc/cli/commands/models_cmd.py +61 -5
- ripperdoc/cli/commands/resume_cmd.py +1 -0
- ripperdoc/cli/commands/skills_cmd.py +103 -0
- ripperdoc/cli/commands/stats_cmd.py +4 -4
- 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 +13 -8
- ripperdoc/cli/ui/rich_ui.py +451 -32
- ripperdoc/cli/ui/spinner.py +68 -5
- ripperdoc/cli/ui/tool_renderers.py +10 -9
- ripperdoc/cli/ui/wizard.py +18 -11
- ripperdoc/core/agents.py +4 -0
- ripperdoc/core/config.py +235 -0
- ripperdoc/core/default_tools.py +1 -0
- ripperdoc/core/hooks/llm_callback.py +0 -1
- ripperdoc/core/hooks/manager.py +6 -0
- ripperdoc/core/permissions.py +82 -5
- ripperdoc/core/providers/openai.py +55 -9
- ripperdoc/core/query.py +349 -108
- ripperdoc/core/query_utils.py +17 -14
- ripperdoc/core/skills.py +1 -0
- ripperdoc/core/theme.py +298 -0
- ripperdoc/core/tool.py +8 -3
- ripperdoc/protocol/__init__.py +14 -0
- ripperdoc/protocol/models.py +300 -0
- ripperdoc/protocol/stdio.py +1453 -0
- ripperdoc/tools/background_shell.py +49 -5
- ripperdoc/tools/bash_tool.py +75 -9
- ripperdoc/tools/file_edit_tool.py +98 -29
- ripperdoc/tools/file_read_tool.py +139 -8
- ripperdoc/tools/file_write_tool.py +46 -3
- ripperdoc/tools/grep_tool.py +98 -8
- ripperdoc/tools/lsp_tool.py +9 -15
- ripperdoc/tools/multi_edit_tool.py +26 -3
- ripperdoc/tools/skill_tool.py +52 -1
- ripperdoc/tools/task_tool.py +33 -8
- ripperdoc/utils/file_watch.py +12 -6
- ripperdoc/utils/image_utils.py +125 -0
- ripperdoc/utils/log.py +30 -3
- ripperdoc/utils/lsp.py +9 -3
- ripperdoc/utils/mcp.py +80 -18
- ripperdoc/utils/message_formatting.py +2 -2
- ripperdoc/utils/messages.py +177 -32
- ripperdoc/utils/pending_messages.py +50 -0
- ripperdoc/utils/permissions/shell_command_validation.py +3 -3
- ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
- ripperdoc/utils/platform.py +198 -0
- ripperdoc/utils/session_heatmap.py +1 -3
- ripperdoc/utils/session_history.py +2 -2
- ripperdoc/utils/session_stats.py +1 -0
- ripperdoc/utils/shell_utils.py +8 -5
- ripperdoc/utils/todo.py +0 -6
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
- ripperdoc/sdk/__init__.py +0 -9
- ripperdoc/sdk/client.py +0 -408
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.10.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,
|
|
@@ -22,9 +23,104 @@ from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
|
22
23
|
|
|
23
24
|
logger = get_logger()
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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"))
|
|
28
124
|
|
|
29
125
|
|
|
30
126
|
class FileReadToolInput(BaseModel):
|
|
@@ -79,6 +175,7 @@ and limit to read only a portion of the file."""
|
|
|
79
175
|
"Read a file from the local filesystem.\n\n"
|
|
80
176
|
"Usage:\n"
|
|
81
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"
|
|
82
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"
|
|
83
180
|
"- Lines longer than 2000 characters are truncated in the output.\n"
|
|
84
181
|
"- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
|
|
@@ -147,8 +244,10 @@ and limit to read only a portion of the file."""
|
|
|
147
244
|
# Check file size before reading to prevent memory exhaustion
|
|
148
245
|
file_size = os.path.getsize(input_data.file_path)
|
|
149
246
|
if file_size > MAX_FILE_SIZE_BYTES:
|
|
247
|
+
size_kb = file_size / 1024
|
|
248
|
+
limit_kb = MAX_FILE_SIZE_BYTES / 1024
|
|
150
249
|
error_output = FileReadToolOutput(
|
|
151
|
-
content=f"File too large to read: {
|
|
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.",
|
|
152
251
|
file_path=input_data.file_path,
|
|
153
252
|
line_count=0,
|
|
154
253
|
offset=0,
|
|
@@ -156,15 +255,46 @@ and limit to read only a portion of the file."""
|
|
|
156
255
|
)
|
|
157
256
|
yield ToolResult(
|
|
158
257
|
data=error_output,
|
|
159
|
-
result_for_assistant=f"Error: File {input_data.file_path} is too large ({
|
|
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).",
|
|
160
259
|
)
|
|
161
260
|
return
|
|
162
261
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
165
279
|
|
|
166
280
|
offset = input_data.offset or 0
|
|
167
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
|
|
168
298
|
|
|
169
299
|
# Apply offset and limit
|
|
170
300
|
if limit is not None:
|
|
@@ -184,6 +314,7 @@ and limit to read only a portion of the file."""
|
|
|
184
314
|
getattr(context, "file_state_cache", {}),
|
|
185
315
|
offset=offset,
|
|
186
316
|
limit=limit,
|
|
317
|
+
encoding=used_encoding,
|
|
187
318
|
)
|
|
188
319
|
except (OSError, IOError, RuntimeError) as exc:
|
|
189
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
|
|
|
@@ -155,11 +193,15 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
155
193
|
"""Write the file."""
|
|
156
194
|
|
|
157
195
|
try:
|
|
158
|
-
#
|
|
159
|
-
|
|
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:
|
|
160
202
|
f.write(input_data.content)
|
|
161
203
|
|
|
162
|
-
bytes_written = len(input_data.content.encode(
|
|
204
|
+
bytes_written = len(input_data.content.encode(encoding))
|
|
163
205
|
|
|
164
206
|
# Use absolute path to ensure consistency with validation lookup
|
|
165
207
|
abs_file_path = os.path.abspath(input_data.file_path)
|
|
@@ -168,6 +210,7 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
168
210
|
abs_file_path,
|
|
169
211
|
input_data.content,
|
|
170
212
|
getattr(context, "file_state_cache", {}),
|
|
213
|
+
encoding=encoding,
|
|
171
214
|
)
|
|
172
215
|
except (OSError, IOError, RuntimeError) as exc:
|
|
173
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)
|
ripperdoc/tools/lsp_tool.py
CHANGED
|
@@ -101,9 +101,7 @@ def _read_text(file_path: Path) -> str:
|
|
|
101
101
|
return file_path.read_text(encoding="utf-8", errors="replace")
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def _normalize_position(
|
|
105
|
-
lines: List[str], line: int, character: int
|
|
106
|
-
) -> Tuple[int, int, str]:
|
|
104
|
+
def _normalize_position(lines: List[str], line: int, character: int) -> Tuple[int, int, str]:
|
|
107
105
|
if not lines:
|
|
108
106
|
return 0, 0, ""
|
|
109
107
|
line_index = max(0, min(line - 1, len(lines) - 1))
|
|
@@ -121,7 +119,9 @@ def _extract_symbol_at_position(line_text: str, char_index: int) -> Optional[str
|
|
|
121
119
|
return None
|
|
122
120
|
|
|
123
121
|
if not line_text[char_index].isalnum() and line_text[char_index] != "_":
|
|
124
|
-
if char_index > 0 and (
|
|
122
|
+
if char_index > 0 and (
|
|
123
|
+
line_text[char_index - 1].isalnum() or line_text[char_index - 1] == "_"
|
|
124
|
+
):
|
|
125
125
|
char_index -= 1
|
|
126
126
|
else:
|
|
127
127
|
return None
|
|
@@ -148,8 +148,8 @@ def _location_to_path_line_char(location: Optional[Dict[str, Any]]) -> Tuple[str
|
|
|
148
148
|
if not location:
|
|
149
149
|
return "<unknown>", 0, 0
|
|
150
150
|
uri = location.get("uri") or location.get("targetUri")
|
|
151
|
-
range_info =
|
|
152
|
-
"targetSelectionRange"
|
|
151
|
+
range_info = (
|
|
152
|
+
location.get("range") or location.get("targetRange") or location.get("targetSelectionRange")
|
|
153
153
|
)
|
|
154
154
|
path = "<unknown>"
|
|
155
155
|
if isinstance(uri, str):
|
|
@@ -166,9 +166,7 @@ def _location_to_path_line_char(location: Optional[Dict[str, Any]]) -> Tuple[str
|
|
|
166
166
|
return path, line, character
|
|
167
167
|
|
|
168
168
|
|
|
169
|
-
def _format_locations(
|
|
170
|
-
label: str, locations: List[Dict[str, Any]]
|
|
171
|
-
) -> Tuple[str, int, int]:
|
|
169
|
+
def _format_locations(label: str, locations: List[Dict[str, Any]]) -> Tuple[str, int, int]:
|
|
172
170
|
if not locations:
|
|
173
171
|
return f"No {label} found.", 0, 0
|
|
174
172
|
|
|
@@ -583,13 +581,9 @@ class LspTool(Tool[LspToolInput, LspToolOutput]):
|
|
|
583
581
|
if operation == "goToDefinition":
|
|
584
582
|
if isinstance(result, dict):
|
|
585
583
|
result = [result]
|
|
586
|
-
formatted, result_count, file_count = _format_locations(
|
|
587
|
-
"definition(s)", result or []
|
|
588
|
-
)
|
|
584
|
+
formatted, result_count, file_count = _format_locations("definition(s)", result or [])
|
|
589
585
|
elif operation == "findReferences":
|
|
590
|
-
formatted, result_count, file_count = _format_locations(
|
|
591
|
-
"reference(s)", result or []
|
|
592
|
-
)
|
|
586
|
+
formatted, result_count, file_count = _format_locations("reference(s)", result or [])
|
|
593
587
|
elif operation == "hover":
|
|
594
588
|
formatted, result_count, file_count = _format_hover(result or {})
|
|
595
589
|
elif operation == "documentSymbol":
|
|
@@ -20,6 +20,7 @@ from ripperdoc.core.tool import (
|
|
|
20
20
|
)
|
|
21
21
|
from ripperdoc.utils.log import get_logger
|
|
22
22
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
23
|
+
from ripperdoc.tools.file_read_tool import detect_file_encoding
|
|
23
24
|
|
|
24
25
|
logger = get_logger()
|
|
25
26
|
|
|
@@ -341,10 +342,18 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
341
342
|
|
|
342
343
|
existing = file_path.exists()
|
|
343
344
|
original_content = ""
|
|
345
|
+
file_encoding = "utf-8"
|
|
346
|
+
|
|
347
|
+
# Detect file encoding if file exists
|
|
348
|
+
if existing:
|
|
349
|
+
detected_encoding, _ = detect_file_encoding(str(file_path))
|
|
350
|
+
if detected_encoding:
|
|
351
|
+
file_encoding = detected_encoding
|
|
352
|
+
|
|
344
353
|
try:
|
|
345
354
|
if existing:
|
|
346
|
-
original_content = file_path.read_text(encoding=
|
|
347
|
-
except (OSError, IOError, PermissionError) as exc:
|
|
355
|
+
original_content = file_path.read_text(encoding=file_encoding)
|
|
356
|
+
except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
|
|
348
357
|
# pragma: no cover - unlikely permission issue
|
|
349
358
|
logger.warning(
|
|
350
359
|
"[multi_edit_tool] Error reading file before edits: %s: %s",
|
|
@@ -396,13 +405,27 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
396
405
|
|
|
397
406
|
# Ensure parent exists (validated earlier) and write the file.
|
|
398
407
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
|
|
409
|
+
# Verify content can be encoded, fall back to UTF-8 if needed
|
|
410
|
+
write_encoding = file_encoding
|
|
411
|
+
try:
|
|
412
|
+
updated_content.encode(file_encoding)
|
|
413
|
+
except (UnicodeEncodeError, LookupError):
|
|
414
|
+
logger.info(
|
|
415
|
+
"New content cannot be encoded with %s, using UTF-8 for %s",
|
|
416
|
+
file_encoding,
|
|
417
|
+
str(file_path),
|
|
418
|
+
)
|
|
419
|
+
write_encoding = "utf-8"
|
|
420
|
+
|
|
399
421
|
try:
|
|
400
|
-
file_path.write_text(updated_content, encoding=
|
|
422
|
+
file_path.write_text(updated_content, encoding=write_encoding)
|
|
401
423
|
try:
|
|
402
424
|
record_snapshot(
|
|
403
425
|
str(file_path),
|
|
404
426
|
updated_content,
|
|
405
427
|
getattr(context, "file_state_cache", {}),
|
|
428
|
+
encoding=write_encoding,
|
|
406
429
|
)
|
|
407
430
|
except (OSError, IOError, RuntimeError) as exc:
|
|
408
431
|
logger.warning(
|
ripperdoc/tools/skill_tool.py
CHANGED
|
@@ -131,6 +131,33 @@ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
|
131
131
|
)
|
|
132
132
|
return ValidationResult(result=True)
|
|
133
133
|
|
|
134
|
+
def _list_skill_files(self, base_dir: Path, max_depth: int = 2) -> List[str]:
|
|
135
|
+
"""List documentation files in the skill directory (excluding SKILL.md)."""
|
|
136
|
+
files: List[str] = []
|
|
137
|
+
doc_extensions = {".md", ".txt", ".rst", ".json", ".yaml", ".yml"}
|
|
138
|
+
|
|
139
|
+
def scan_dir(dir_path: Path, depth: int, prefix: str = "") -> None:
|
|
140
|
+
if depth > max_depth or not dir_path.exists():
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
entries = sorted(dir_path.iterdir())
|
|
144
|
+
except PermissionError:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
for entry in entries:
|
|
148
|
+
# Skip hidden files/directories and SKILL.md
|
|
149
|
+
if entry.name.startswith(".") or entry.name == "SKILL.md":
|
|
150
|
+
continue
|
|
151
|
+
rel_path = f"{prefix}{entry.name}"
|
|
152
|
+
if entry.is_dir():
|
|
153
|
+
files.append(f"{rel_path}/")
|
|
154
|
+
scan_dir(entry, depth + 1, f"{rel_path}/")
|
|
155
|
+
elif entry.suffix.lower() in doc_extensions:
|
|
156
|
+
files.append(rel_path)
|
|
157
|
+
|
|
158
|
+
scan_dir(base_dir, 0)
|
|
159
|
+
return files
|
|
160
|
+
|
|
134
161
|
def _render_result(self, skill: SkillDefinition) -> str:
|
|
135
162
|
allowed = ", ".join(skill.allowed_tools) if skill.allowed_tools else "no specific limit"
|
|
136
163
|
model_hint = f"\nModel hint: {skill.model}" if skill.model else ""
|
|
@@ -139,6 +166,17 @@ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
|
139
166
|
if skill.max_thinking_tokens is not None
|
|
140
167
|
else ""
|
|
141
168
|
)
|
|
169
|
+
|
|
170
|
+
# List available documentation files in skill directory
|
|
171
|
+
skill_files = self._list_skill_files(skill.base_dir)
|
|
172
|
+
files_section = ""
|
|
173
|
+
if skill_files:
|
|
174
|
+
files_list = "\n".join(f" - {f}" for f in skill_files)
|
|
175
|
+
files_section = (
|
|
176
|
+
f"\n\nAvailable documentation files in skill directory (use Read tool to access when needed):\n"
|
|
177
|
+
f"{files_list}"
|
|
178
|
+
)
|
|
179
|
+
|
|
142
180
|
lines = [
|
|
143
181
|
f"Skill loaded: {skill.name} ({skill.location.value})",
|
|
144
182
|
f"Description: {skill.description}",
|
|
@@ -147,7 +185,8 @@ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
|
147
185
|
"SKILL.md content:",
|
|
148
186
|
skill.content,
|
|
149
187
|
]
|
|
150
|
-
|
|
188
|
+
result = "\n".join(lines)
|
|
189
|
+
return result + files_section
|
|
151
190
|
|
|
152
191
|
def _to_output(self, skill: SkillDefinition) -> SkillToolOutput:
|
|
153
192
|
return SkillToolOutput(
|
|
@@ -192,6 +231,17 @@ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
|
192
231
|
if output.max_thinking_tokens is not None
|
|
193
232
|
else ""
|
|
194
233
|
)
|
|
234
|
+
|
|
235
|
+
# List available documentation files in skill directory
|
|
236
|
+
skill_files = self._list_skill_files(Path(output.base_dir))
|
|
237
|
+
files_section = ""
|
|
238
|
+
if skill_files:
|
|
239
|
+
files_list = "\n".join(f" - {f}" for f in skill_files)
|
|
240
|
+
files_section = (
|
|
241
|
+
f"\n\nAvailable documentation files in skill directory (use Read tool to access when needed):\n"
|
|
242
|
+
f"{files_list}"
|
|
243
|
+
)
|
|
244
|
+
|
|
195
245
|
return (
|
|
196
246
|
f"Skill loaded: {output.skill} ({output.location})\n"
|
|
197
247
|
f"Description: {output.description}\n"
|
|
@@ -199,6 +249,7 @@ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
|
199
249
|
f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}\n"
|
|
200
250
|
"SKILL.md content:\n"
|
|
201
251
|
f"{output.content}"
|
|
252
|
+
f"{files_section}"
|
|
202
253
|
)
|
|
203
254
|
|
|
204
255
|
def render_tool_use_message(self, input_data: SkillToolInput, verbose: bool = False) -> str: # noqa: ARG002
|