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.
Files changed (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {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
- # Maximum file size to read (default 50MB, configurable via env)
26
- MAX_FILE_SIZE_MB = float(os.getenv("RIPPERDOC_MAX_READ_FILE_SIZE_MB", "50"))
27
- MAX_FILE_SIZE_BYTES = int(MAX_FILE_SIZE_MB * 1024 * 1024)
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: {file_size / (1024*1024):.1f}MB exceeds limit of {MAX_FILE_SIZE_MB}MB. Use offset and limit parameters to read portions.",
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 ({file_size / (1024*1024):.1f}MB). Maximum size is {MAX_FILE_SIZE_MB}MB. Use offset and limit to read portions.",
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
- with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
164
- lines = f.readlines()
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
- # Write the file
159
- with open(input_data.file_path, "w", encoding="utf-8") as f:
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("utf-8"))
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(
@@ -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
- cmd = ["grep", "-r", "--color=never", "-P"]
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
- # Run grep asynchronously
289
- process = await asyncio.create_subprocess_exec(
290
- *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
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
- stdout, stderr = await process.communicate()
294
- returncode = process.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)
@@ -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 (line_text[char_index - 1].isalnum() or line_text[char_index - 1] == "_"):
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 = location.get("range") or location.get("targetRange") or location.get(
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="utf-8")
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="utf-8")
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(
@@ -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
- return "\n".join(lines)
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