ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,203 @@
1
+ """File reading tool.
2
+
3
+ Allows the AI to read file contents.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import AsyncGenerator, List, Optional
9
+ from pydantic import BaseModel, Field
10
+
11
+ from ripperdoc.core.tool import (
12
+ Tool,
13
+ ToolUseContext,
14
+ ToolResult,
15
+ ToolOutput,
16
+ ToolUseExample,
17
+ ValidationResult,
18
+ )
19
+ from ripperdoc.utils.log import get_logger
20
+ from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.path_ignore import check_path_for_tool, is_path_ignored
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class FileReadToolInput(BaseModel):
27
+ """Input schema for FileReadTool."""
28
+
29
+ file_path: str = Field(description="Absolute path to the file to read")
30
+ offset: Optional[int] = Field(
31
+ default=None, description="Line number to start reading from (optional)"
32
+ )
33
+ limit: Optional[int] = Field(default=None, description="Number of lines to read (optional)")
34
+
35
+
36
+ class FileReadToolOutput(BaseModel):
37
+ """Output from file reading."""
38
+
39
+ content: str
40
+ file_path: str
41
+ line_count: int
42
+ offset: int
43
+ limit: Optional[int]
44
+
45
+
46
+ class FileReadTool(Tool[FileReadToolInput, FileReadToolOutput]):
47
+ """Tool for reading file contents."""
48
+
49
+ @property
50
+ def name(self) -> str:
51
+ return "View"
52
+
53
+ async def description(self) -> str:
54
+ return """Read the contents of a file. You can optionally specify an offset
55
+ and limit to read only a portion of the file."""
56
+
57
+ @property
58
+ def input_schema(self) -> type[FileReadToolInput]:
59
+ return FileReadToolInput
60
+
61
+ def input_examples(self) -> List[ToolUseExample]:
62
+ return [
63
+ ToolUseExample(
64
+ description="Read the top of a file to understand structure",
65
+ example={"file_path": "/repo/src/main.py", "limit": 50},
66
+ ),
67
+ ToolUseExample(
68
+ description="Inspect a slice of a large log without loading everything",
69
+ example={"file_path": "/repo/logs/server.log", "offset": 200, "limit": 40},
70
+ ),
71
+ ]
72
+
73
+ async def prompt(self, safe_mode: bool = False) -> str:
74
+ return (
75
+ "Read a file from the local filesystem.\n\n"
76
+ "Usage:\n"
77
+ "- The file_path parameter must be an absolute path (not relative).\n"
78
+ "- 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
+ "- Lines longer than 2000 characters are truncated in the output.\n"
80
+ "- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
81
+ "- You can call multiple tools in a single response—speculatively read multiple potentially useful files together.\n"
82
+ "- It is okay to attempt reading a non-existent file; an error will be returned if the file is missing."
83
+ )
84
+
85
+ def is_read_only(self) -> bool:
86
+ return True
87
+
88
+ def is_concurrency_safe(self) -> bool:
89
+ return True
90
+
91
+ def needs_permissions(self, input_data: Optional[FileReadToolInput] = None) -> bool:
92
+ return False
93
+
94
+ async def validate_input(
95
+ self, input_data: FileReadToolInput, context: Optional[ToolUseContext] = None
96
+ ) -> ValidationResult:
97
+ # Check if file exists
98
+ if not os.path.exists(input_data.file_path):
99
+ return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
100
+
101
+ # Check if it's a file (not a directory)
102
+ if not os.path.isfile(input_data.file_path):
103
+ return ValidationResult(
104
+ result=False, message=f"Path is not a file: {input_data.file_path}"
105
+ )
106
+
107
+ # Check if path is ignored (warning only for read operations)
108
+ file_path = Path(input_data.file_path)
109
+ should_proceed, warning_msg = check_path_for_tool(file_path, tool_name="Read", warn_only=True)
110
+ if warning_msg:
111
+ logger.info("[file_read_tool] %s", warning_msg)
112
+
113
+ return ValidationResult(result=True)
114
+
115
+ def render_result_for_assistant(self, output: FileReadToolOutput) -> str:
116
+ """Format output for the AI."""
117
+ lines = output.content.split("\n")
118
+ numbered_lines = []
119
+
120
+ for i, line in enumerate(lines, start=output.offset + 1):
121
+ # Truncate very long lines
122
+ if len(line) > 2000:
123
+ line = line[:2000] + "... [truncated]"
124
+ numbered_lines.append(f"{i:6d}\t{line}")
125
+
126
+ return "\n".join(numbered_lines)
127
+
128
+ def render_tool_use_message(self, input_data: FileReadToolInput, verbose: bool = False) -> str:
129
+ """Format the tool use for display."""
130
+ msg = f"Reading: {input_data.file_path}"
131
+ if input_data.offset or input_data.limit:
132
+ msg += f" (offset: {input_data.offset}, limit: {input_data.limit})"
133
+ return msg
134
+
135
+ async def call(
136
+ self, input_data: FileReadToolInput, context: ToolUseContext
137
+ ) -> AsyncGenerator[ToolOutput, None]:
138
+ """Read the file."""
139
+
140
+ try:
141
+ with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
142
+ lines = f.readlines()
143
+
144
+ offset = input_data.offset or 0
145
+ limit = input_data.limit
146
+
147
+ # Apply offset and limit
148
+ if limit is not None:
149
+ selected_lines = lines[offset : offset + limit]
150
+ else:
151
+ selected_lines = lines[offset:]
152
+
153
+ content = "".join(selected_lines)
154
+
155
+ # Remember what we read so we can detect user edits later.
156
+ # Use absolute path to ensure consistency with Edit tool's lookup
157
+ abs_file_path = os.path.abspath(input_data.file_path)
158
+ try:
159
+ record_snapshot(
160
+ abs_file_path,
161
+ content,
162
+ getattr(context, "file_state_cache", {}),
163
+ offset=offset,
164
+ limit=limit,
165
+ )
166
+ except (OSError, IOError, RuntimeError) as exc:
167
+ logger.warning(
168
+ "[file_read_tool] Failed to record file snapshot: %s: %s",
169
+ type(exc).__name__, exc,
170
+ extra={"file_path": input_data.file_path},
171
+ )
172
+
173
+ output = FileReadToolOutput(
174
+ content=content,
175
+ file_path=input_data.file_path,
176
+ line_count=len(selected_lines),
177
+ offset=offset,
178
+ limit=limit,
179
+ )
180
+
181
+ yield ToolResult(
182
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
183
+ )
184
+
185
+ except (OSError, IOError, UnicodeDecodeError, ValueError) as e:
186
+ logger.warning(
187
+ "[file_read_tool] Error reading file: %s: %s",
188
+ type(e).__name__, e,
189
+ extra={"file_path": input_data.file_path},
190
+ )
191
+ # Create an error output
192
+ error_output = FileReadToolOutput(
193
+ content=f"Error reading file: {str(e)}",
194
+ file_path=input_data.file_path,
195
+ line_count=0,
196
+ offset=0,
197
+ limit=None,
198
+ )
199
+
200
+ yield ToolResult(
201
+ data=error_output,
202
+ result_for_assistant=f"Error reading file {input_data.file_path}: {str(e)}",
203
+ )
@@ -0,0 +1,205 @@
1
+ """File writing tool.
2
+
3
+ Allows the AI to create new files.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import AsyncGenerator, List, Optional
9
+ from pydantic import BaseModel, Field
10
+
11
+ from ripperdoc.core.tool import (
12
+ Tool,
13
+ ToolUseContext,
14
+ ToolResult,
15
+ ToolOutput,
16
+ ToolUseExample,
17
+ ValidationResult,
18
+ )
19
+ from ripperdoc.utils.log import get_logger
20
+ from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.path_ignore import check_path_for_tool
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class FileWriteToolInput(BaseModel):
27
+ """Input schema for FileWriteTool."""
28
+
29
+ file_path: str = Field(description="Absolute path to the file to create")
30
+ content: str = Field(description="Content to write to the file")
31
+
32
+
33
+ class FileWriteToolOutput(BaseModel):
34
+ """Output from file writing."""
35
+
36
+ file_path: str
37
+ bytes_written: int
38
+ success: bool
39
+ message: str
40
+
41
+
42
+ class FileWriteTool(Tool[FileWriteToolInput, FileWriteToolOutput]):
43
+ """Tool for creating new files."""
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ return "Write"
48
+
49
+ async def description(self) -> str:
50
+ return """Create a new file with the specified content. This will overwrite
51
+ the file if it already exists."""
52
+
53
+ @property
54
+ def input_schema(self) -> type[FileWriteToolInput]:
55
+ return FileWriteToolInput
56
+
57
+ def input_examples(self) -> List[ToolUseExample]:
58
+ return [
59
+ ToolUseExample(
60
+ description="Create a JSON fixture file",
61
+ example={
62
+ "file_path": "/repo/tests/fixtures/sample.json",
63
+ "content": '{\n "items": []\n}\n',
64
+ },
65
+ ),
66
+ ToolUseExample(
67
+ description="Write a short markdown note",
68
+ example={
69
+ "file_path": "/repo/docs/USAGE.md",
70
+ "content": "# Usage\n\nRun `make test`.\n",
71
+ },
72
+ ),
73
+ ]
74
+
75
+ async def prompt(self, safe_mode: bool = False) -> str:
76
+ prompt = """Use the Write tool to create new files. """
77
+
78
+ if safe_mode:
79
+ prompt += """IMPORTANT: You must ALWAYS prefer editing existing files.
80
+ NEVER write new files unless explicitly required by the user."""
81
+
82
+ return prompt
83
+
84
+ def is_read_only(self) -> bool:
85
+ return False
86
+
87
+ def is_concurrency_safe(self) -> bool:
88
+ return False
89
+
90
+ def needs_permissions(self, input_data: Optional[FileWriteToolInput] = None) -> bool:
91
+ return True
92
+
93
+ async def validate_input(
94
+ self, input_data: FileWriteToolInput, context: Optional[ToolUseContext] = None
95
+ ) -> ValidationResult:
96
+ # Check if parent directory exists
97
+ parent = Path(input_data.file_path).parent
98
+ if not parent.exists():
99
+ return ValidationResult(
100
+ result=False,
101
+ message=f"Parent directory does not exist: {parent}",
102
+ error_code=1,
103
+ )
104
+
105
+ file_path = os.path.abspath(input_data.file_path)
106
+
107
+ # If file doesn't exist, it's a new file - allow without reading first
108
+ if not os.path.exists(file_path):
109
+ return ValidationResult(result=True)
110
+
111
+ # File exists - check if it has been read before writing
112
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
113
+ file_snapshot = file_state_cache.get(file_path)
114
+
115
+ if not file_snapshot:
116
+ return ValidationResult(
117
+ result=False,
118
+ message="File has not been read yet. Read it first before writing to it.",
119
+ error_code=2,
120
+ )
121
+
122
+ # Check if file has been modified since it was read
123
+ try:
124
+ current_mtime = os.path.getmtime(file_path)
125
+ if current_mtime > file_snapshot.timestamp:
126
+ return ValidationResult(
127
+ result=False,
128
+ message="File has been modified since read, either by the user or by a linter. "
129
+ "Read it again before attempting to write it.",
130
+ error_code=3,
131
+ )
132
+ except OSError:
133
+ pass # File mtime check failed, proceed anyway
134
+
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(file_path_obj, tool_name="Write", warn_only=True)
138
+ if warning_msg:
139
+ logger.warning("[file_write_tool] %s", warning_msg)
140
+
141
+ return ValidationResult(result=True)
142
+
143
+ def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
144
+ """Format output for the AI."""
145
+ return output.message
146
+
147
+ def render_tool_use_message(self, input_data: FileWriteToolInput, verbose: bool = False) -> str:
148
+ """Format the tool use for display."""
149
+ return f"Writing: {input_data.file_path} ({len(input_data.content)} bytes)"
150
+
151
+ async def call(
152
+ self, input_data: FileWriteToolInput, context: ToolUseContext
153
+ ) -> AsyncGenerator[ToolOutput, None]:
154
+ """Write the file."""
155
+
156
+ try:
157
+ # Write the file
158
+ with open(input_data.file_path, "w", encoding="utf-8") as f:
159
+ f.write(input_data.content)
160
+
161
+ bytes_written = len(input_data.content.encode("utf-8"))
162
+
163
+ # Use absolute path to ensure consistency with validation lookup
164
+ abs_file_path = os.path.abspath(input_data.file_path)
165
+ try:
166
+ record_snapshot(
167
+ abs_file_path,
168
+ input_data.content,
169
+ getattr(context, "file_state_cache", {}),
170
+ )
171
+ except (OSError, IOError, RuntimeError) as exc:
172
+ logger.warning(
173
+ "[file_write_tool] Failed to record file snapshot: %s: %s",
174
+ type(exc).__name__, exc,
175
+ extra={"file_path": abs_file_path},
176
+ )
177
+
178
+ output = FileWriteToolOutput(
179
+ file_path=input_data.file_path,
180
+ bytes_written=bytes_written,
181
+ success=True,
182
+ message=f"Successfully wrote {bytes_written} bytes to {input_data.file_path}",
183
+ )
184
+
185
+ yield ToolResult(
186
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
187
+ )
188
+
189
+ except (OSError, IOError, PermissionError, UnicodeEncodeError) as e:
190
+ logger.warning(
191
+ "[file_write_tool] Error writing file: %s: %s",
192
+ type(e).__name__, e,
193
+ extra={"file_path": input_data.file_path},
194
+ )
195
+ error_output = FileWriteToolOutput(
196
+ file_path=input_data.file_path,
197
+ bytes_written=0,
198
+ success=False,
199
+ message=f"Error writing file: {str(e)}",
200
+ )
201
+
202
+ yield ToolResult(
203
+ data=error_output,
204
+ result_for_assistant=self.render_result_for_assistant(error_output),
205
+ )
@@ -0,0 +1,179 @@
1
+ """Glob pattern matching tool.
2
+
3
+ Allows the AI to find files using glob patterns.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import AsyncGenerator, Optional, List
8
+ from pydantic import BaseModel, Field
9
+
10
+ from ripperdoc.core.tool import (
11
+ Tool,
12
+ ToolUseContext,
13
+ ToolResult,
14
+ ToolOutput,
15
+ ToolUseExample,
16
+ ValidationResult,
17
+ )
18
+ from ripperdoc.utils.log import get_logger
19
+
20
+ logger = get_logger()
21
+
22
+
23
+ GLOB_USAGE = (
24
+ "- Fast file pattern matching tool that works with any codebase size\n"
25
+ '- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n'
26
+ "- Returns matching file paths sorted by modification time\n"
27
+ "- Use this tool when you need to find files by name patterns\n"
28
+ "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n"
29
+ "- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\n"
30
+ )
31
+
32
+ RESULT_LIMIT = 100
33
+
34
+
35
+ class GlobToolInput(BaseModel):
36
+ """Input schema for GlobTool."""
37
+
38
+ pattern: str = Field(description="Glob pattern to match files (e.g., '**/*.py')")
39
+ path: Optional[str] = Field(
40
+ default=None, description="Directory to search in (default: current working directory)"
41
+ )
42
+
43
+
44
+ class GlobToolOutput(BaseModel):
45
+ """Output from glob pattern matching."""
46
+
47
+ matches: List[str]
48
+ pattern: str
49
+ count: int
50
+ truncated: bool = False
51
+
52
+
53
+ class GlobTool(Tool[GlobToolInput, GlobToolOutput]):
54
+ """Tool for finding files using glob patterns."""
55
+
56
+ @property
57
+ def name(self) -> str:
58
+ return "Glob"
59
+
60
+ async def description(self) -> str:
61
+ return GLOB_USAGE
62
+
63
+ @property
64
+ def input_schema(self) -> type[GlobToolInput]:
65
+ return GlobToolInput
66
+
67
+ def input_examples(self) -> List[ToolUseExample]:
68
+ return [
69
+ ToolUseExample(
70
+ description="Find Python sources inside src",
71
+ example={"pattern": "src/**/*.py"},
72
+ ),
73
+ ToolUseExample(
74
+ description="Locate snapshot files within tests",
75
+ example={"pattern": "tests/**/__snapshots__/*.snap", "path": "/repo"},
76
+ ),
77
+ ]
78
+
79
+ async def prompt(self, _safe_mode: bool = False) -> str:
80
+ return GLOB_USAGE
81
+
82
+ def is_read_only(self) -> bool:
83
+ return True
84
+
85
+ def is_concurrency_safe(self) -> bool:
86
+ return True
87
+
88
+ def needs_permissions(self, _input_data: Optional[GlobToolInput] = None) -> bool:
89
+ return False
90
+
91
+ async def validate_input(
92
+ self, _input_data: GlobToolInput, _context: Optional[ToolUseContext] = None
93
+ ) -> ValidationResult:
94
+ return ValidationResult(result=True)
95
+
96
+ def render_result_for_assistant(self, output: GlobToolOutput) -> str:
97
+ """Format output for the AI."""
98
+ if not output.matches:
99
+ return f"No files found matching pattern: {output.pattern}"
100
+
101
+ lines = list(output.matches)
102
+ if output.truncated:
103
+ lines.append("(Results are truncated. Consider using a more specific path or pattern.)")
104
+ return "\n".join(lines)
105
+
106
+ def render_tool_use_message(self, input_data: GlobToolInput, _verbose: bool = False) -> str:
107
+ """Format the tool use for display."""
108
+ if not input_data.pattern:
109
+ return "Glob"
110
+
111
+ base_path = Path.cwd()
112
+ rendered_path = ""
113
+ if input_data.path:
114
+ candidate_path = Path(input_data.path)
115
+ absolute_path = (
116
+ candidate_path
117
+ if candidate_path.is_absolute()
118
+ else (base_path / candidate_path).resolve()
119
+ )
120
+
121
+ try:
122
+ relative_path = absolute_path.relative_to(base_path)
123
+ except ValueError:
124
+ relative_path = None
125
+
126
+ if _verbose or not relative_path or str(relative_path) == ".":
127
+ rendered_path = str(absolute_path)
128
+ else:
129
+ rendered_path = str(relative_path)
130
+
131
+ path_fragment = f', path: "{rendered_path}"' if rendered_path else ""
132
+ return f'pattern: "{input_data.pattern}"{path_fragment}'
133
+
134
+ async def call(
135
+ self, input_data: GlobToolInput, _context: ToolUseContext
136
+ ) -> AsyncGenerator[ToolOutput, None]:
137
+ """Find files matching the pattern."""
138
+
139
+ try:
140
+ search_path = Path(input_data.path) if input_data.path else Path.cwd()
141
+ if not search_path.is_absolute():
142
+ search_path = (Path.cwd() / search_path).resolve()
143
+
144
+ def _mtime(path: Path) -> float:
145
+ try:
146
+ return path.stat().st_mtime
147
+ except OSError:
148
+ return float("-inf")
149
+
150
+ # Find matching files, sorted by modification time
151
+ paths = sorted(
152
+ (p for p in search_path.glob(input_data.pattern) if p.is_file()),
153
+ key=_mtime,
154
+ )
155
+
156
+ truncated = len(paths) > RESULT_LIMIT
157
+ paths = paths[:RESULT_LIMIT]
158
+
159
+ matches = [str(p) for p in paths]
160
+
161
+ output = GlobToolOutput(
162
+ matches=matches, pattern=input_data.pattern, count=len(matches), truncated=truncated
163
+ )
164
+
165
+ yield ToolResult(
166
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
167
+ )
168
+
169
+ except (OSError, RuntimeError, ValueError) as e:
170
+ logger.warning(
171
+ "[glob_tool] Error executing glob: %s: %s",
172
+ type(e).__name__, e,
173
+ extra={"pattern": input_data.pattern, "path": input_data.path},
174
+ )
175
+ error_output = GlobToolOutput(matches=[], pattern=input_data.pattern, count=0)
176
+
177
+ yield ToolResult(
178
+ data=error_output, result_for_assistant=f"Error executing glob: {str(e)}"
179
+ )