hud-python 0.3.5__py3-none-any.whl → 0.4.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.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +17 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +379 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +354 -0
  45. hud/clients/fastmcp.py +202 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.0.dist-info/METADATA +474 -0
  126. hud_python-0.4.0.dist-info/RECORD +132 -0
  127. hud_python-0.4.0.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/WHEEL +0 -0
hud/tools/edit.py CHANGED
@@ -1,290 +1,314 @@
1
- from __future__ import annotations
2
-
3
- from collections import defaultdict
4
- from pathlib import Path
5
- from typing import Any, Literal, get_args
6
-
7
- from .base import CLIResult, ToolError, ToolResult
8
- from .utils import maybe_truncate, run
9
-
10
- Command = Literal[
11
- "view",
12
- "create",
13
- "str_replace",
14
- "insert",
15
- "undo_edit",
16
- ]
17
- SNIPPET_LINES: int = 4
18
-
19
-
20
- class EditTool:
21
- """
22
- An filesystem editor tool that allows the agent to view, create, and edit files.
23
- The tool parameters are defined by Anthropic and are not editable.
24
- """
25
-
26
- _file_history: dict[Path, list[str]]
27
-
28
- def __init__(self) -> None:
29
- self._file_history = defaultdict(list)
30
-
31
- async def __call__(
32
- self,
33
- *,
34
- command: Command,
35
- path: str,
36
- file_text: str | None = None,
37
- view_range: list[int] | None = None,
38
- old_str: str | None = None,
39
- new_str: str | None = None,
40
- insert_line: int | None = None,
41
- **kwargs: Any,
42
- ) -> CLIResult:
43
- _path = Path(path)
44
- self.validate_path(command, _path)
45
- if command == "view":
46
- return await self.view(_path, view_range)
47
- elif command == "create":
48
- if file_text is None:
49
- raise ToolError("Parameter `file_text` is required for command: create")
50
- await self.write_file(_path, file_text)
51
- self._file_history[_path].append(file_text)
52
- return ToolResult(output=f"File created successfully at: {_path}")
53
- elif command == "str_replace":
54
- if old_str is None:
55
- raise ToolError("Parameter `old_str` is required for command: str_replace")
56
- return await self.str_replace(_path, old_str, new_str)
57
- elif command == "insert":
58
- if insert_line is None:
59
- raise ToolError("Parameter `insert_line` is required for command: insert")
60
- if new_str is None:
61
- raise ToolError("Parameter `new_str` is required for command: insert")
62
- return await self.insert(_path, insert_line, new_str)
63
- elif command == "undo_edit":
64
- return await self.undo_edit(_path)
65
- raise ToolError(
66
- f"Unrecognized command {command}. The allowed commands for the {self.name} tool are: "
67
- f"{', '.join(get_args(Command))}"
68
- )
69
-
70
- def validate_path(self, command: str, path: Path) -> None:
71
- """
72
- Check that the path/command combination is valid.
73
- """
74
- # Check if its an absolute path
75
- if not path.is_absolute():
76
- suggested_path = Path("") / path
77
- raise ToolError(
78
- f"The path {path} is not an absolute path, it should start with `/`. "
79
- f"Maybe you meant {suggested_path}?"
80
- )
81
- # Check if path exists
82
- if not path.exists() and command != "create":
83
- raise ToolError(f"The path {path} does not exist. Please provide a valid path.")
84
- if path.exists() and command == "create":
85
- raise ToolError(
86
- f"File already exists at: {path}. Cannot overwrite files using command `create`."
87
- )
88
- # Check if the path points to a directory
89
- if path.is_dir() and command != "view":
90
- raise ToolError(
91
- f"The path {path} is a dir and only the `view` command can be used on dirs."
92
- )
93
-
94
- async def view(self, path: Path, view_range: list[int] | None = None) -> CLIResult:
95
- """Implement the view command"""
96
- if path.is_dir():
97
- if view_range:
98
- raise ToolError(
99
- "The `view_range` parameter is not allowed when `path` points to a directory."
100
- )
101
-
102
- import shlex
103
-
104
- safe_path = shlex.quote(str(path))
105
- _, stdout, stderr = await run(rf"find {safe_path} -maxdepth 2 -not -path '*/\.*'")
106
- if not stderr:
107
- stdout = (
108
- f"Here's the files and directories up to 2 levels deep in {path}, "
109
- f"excluding hidden items:\n{stdout}\n"
110
- )
111
- return CLIResult(output=stdout, error=stderr)
112
-
113
- file_content = await self.read_file(path)
114
- init_line = 1
115
- if view_range:
116
- if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
117
- raise ToolError("Invalid `view_range`. It should be a list of two integers.")
118
- file_lines = file_content.split("\n")
119
- n_lines_file = len(file_lines)
120
- init_line, final_line = view_range
121
- if init_line < 1 or init_line > n_lines_file:
122
- raise ToolError(
123
- f"Invalid `view_range`: {view_range}. Its first element `{init_line}` "
124
- f"should be within the range of lines of the file: {[1, n_lines_file]}"
125
- )
126
- if final_line > n_lines_file:
127
- raise ToolError(
128
- f"Invalid `view_range`: {view_range}. Its second element `{final_line}` "
129
- f"should be smaller than the number of lines in the file: `{n_lines_file}`"
130
- )
131
- if final_line != -1 and final_line < init_line:
132
- raise ToolError(
133
- f"Invalid `view_range`: {view_range}. Its second element `{final_line}` "
134
- f"should be larger or equal than its first `{init_line}`"
135
- )
136
-
137
- if final_line == -1:
138
- file_content = "\n".join(file_lines[init_line - 1 :])
139
- else:
140
- file_content = "\n".join(file_lines[init_line - 1 : final_line])
141
-
142
- return CLIResult(output=self._make_output(file_content, str(path), init_line=init_line))
143
-
144
- async def str_replace(self, path: Path, old_str: str, new_str: str | None) -> CLIResult:
145
- """
146
- Implement the str_replace command, which replaces old_str with new_str in the file content.
147
- """
148
- # Read the file content
149
- file_content = (await self.read_file(path)).expandtabs()
150
- old_str = old_str.expandtabs()
151
- new_str = new_str.expandtabs() if new_str is not None else ""
152
-
153
- # Check if old_str is unique in the file
154
- occurrences = file_content.count(old_str)
155
- if occurrences == 0:
156
- raise ToolError(
157
- f"No replacement was performed, old_str `{old_str}` did not appear verbatim in "
158
- f"{path}."
159
- )
160
- elif occurrences > 1:
161
- file_content_lines = file_content.split("\n")
162
- lines = [idx + 1 for idx, line in enumerate(file_content_lines) if old_str in line]
163
- raise ToolError(
164
- f"No replacement was performed. Multiple occurrences of old_str `{old_str}` "
165
- f"in lines {lines}. Please ensure it is unique"
166
- )
167
-
168
- # Replace old_str with new_str
169
- new_file_content = file_content.replace(old_str, new_str)
170
-
171
- # Write the new content to the file
172
- await self.write_file(path, new_file_content)
173
-
174
- # Save the content to history
175
- self._file_history[path].append(file_content)
176
-
177
- # Create a snippet of the edited section
178
- replacement_line = file_content.split(old_str)[0].count("\n")
179
- start_line = max(0, replacement_line - SNIPPET_LINES)
180
- end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")
181
- snippet = "\n".join(new_file_content.split("\n")[start_line : end_line + 1])
182
-
183
- # Prepare the success message
184
- success_msg = f"The file {path} has been edited. "
185
- success_msg += self._make_output(snippet, f"a snippet of {path}", start_line + 1)
186
- success_msg += (
187
- "Review the changes and make sure they are as expected. "
188
- "Edit the file again if necessary."
189
- )
190
-
191
- return CLIResult(output=success_msg)
192
-
193
- async def insert(self, path: Path, insert_line: int, new_str: str) -> CLIResult:
194
- """
195
- Implement the insert command, which inserts new_str at the specified line in the file.
196
- """
197
- file_text = (await self.read_file(path)).expandtabs()
198
- new_str = new_str.expandtabs()
199
- file_text_lines = file_text.split("\n")
200
- n_lines_file = len(file_text_lines)
201
-
202
- if insert_line < 0 or insert_line > n_lines_file:
203
- raise ToolError(
204
- f"Invalid `insert_line` parameter: {insert_line}. It should be within the range "
205
- f"of lines of the file: {[0, n_lines_file]}"
206
- )
207
-
208
- new_str_lines = new_str.split("\n")
209
- new_file_text_lines = (
210
- file_text_lines[:insert_line] + new_str_lines + file_text_lines[insert_line:]
211
- )
212
- snippet_lines = (
213
- file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
214
- + new_str_lines
215
- + file_text_lines[insert_line : insert_line + SNIPPET_LINES]
216
- )
217
-
218
- new_file_text = "\n".join(new_file_text_lines)
219
- snippet = "\n".join(snippet_lines)
220
-
221
- await self.write_file(path, new_file_text)
222
- self._file_history[path].append(file_text)
223
-
224
- success_msg = f"The file {path} has been edited. "
225
- success_msg += self._make_output(
226
- snippet,
227
- "a snippet of the edited file",
228
- max(1, insert_line - SNIPPET_LINES + 1),
229
- )
230
- success_msg += (
231
- "Review the changes and make sure they are as expected (correct indentation, "
232
- "no duplicate lines, etc). Edit the file again if necessary."
233
- )
234
- return CLIResult(output=success_msg)
235
-
236
- async def undo_edit(self, path: Path) -> CLIResult:
237
- """Implement the undo_edit command."""
238
- if not self._file_history[path]:
239
- raise ToolError(f"No edit history found for {path}.")
240
-
241
- old_text = self._file_history[path].pop()
242
- await self.write_file(path, old_text)
243
-
244
- return CLIResult(
245
- output=f"Last edit to {path} undone successfully. "
246
- f"{self._make_output(old_text, str(path))}"
247
- )
248
-
249
- async def read_file(self, path: Path) -> str:
250
- """Read the content of a file from a given path; raise a ToolError if an error occurs."""
251
- try:
252
- import shlex
253
-
254
- safe_path = shlex.quote(str(path))
255
- code, out, err = await run(f"cat {safe_path}")
256
- if code != 0:
257
- raise ToolError(f"Ran into {err} while trying to read {path}")
258
- return out
259
- except Exception as e:
260
- raise ToolError(f"Ran into {e} while trying to read {path}") from None
261
-
262
- async def write_file(self, path: Path, file: str) -> None:
263
- """Write the content of a file to a given path; raise a ToolError if an error occurs."""
264
- try:
265
- import shlex
266
-
267
- safe_path = shlex.quote(str(path))
268
- code, _, err = await run(f"cat > {safe_path} << 'EOF'\n{file}\nEOF")
269
- if code != 0:
270
- raise ToolError(f"Ran into {err} while trying to write to {path}")
271
- except Exception as e:
272
- raise ToolError(f"Ran into {e} while trying to write to {path}") from None
273
-
274
- def _make_output(
275
- self,
276
- file_content: str,
277
- file_descriptor: str,
278
- init_line: int = 1,
279
- expand_tabs: bool = True,
280
- ) -> str:
281
- """Generate output for the CLI based on the content of a file."""
282
- file_content = maybe_truncate(file_content)
283
- if expand_tabs:
284
- file_content = file_content.expandtabs()
285
- file_content = "\n".join(
286
- [f"{i + init_line:6}\t{line}" for i, line in enumerate(file_content.split("\n"))]
287
- )
288
- return (
289
- f"Here's the result of running `cat -n` on {file_descriptor}:\n" + file_content + "\n"
290
- )
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any, Literal, get_args
6
+
7
+ from .base import BaseTool
8
+ from .types import ContentResult, ToolError
9
+ from .utils import maybe_truncate, run
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp.types import ContentBlock
13
+
14
+ Command = Literal[
15
+ "view",
16
+ "create",
17
+ "str_replace",
18
+ "insert",
19
+ "undo_edit",
20
+ ]
21
+ SNIPPET_LINES: int = 4
22
+
23
+
24
+ class EditTool(BaseTool):
25
+ """
26
+ A filesystem editor tool that allows the agent to view, create, and edit files.
27
+ Maintains a history of file edits for undo functionality.
28
+ """
29
+
30
+ def __init__(self, file_history: dict[Path, list[str]] | None = None) -> None:
31
+ """Initialize EditTool with optional file history.
32
+
33
+ Args:
34
+ file_history: Optional dictionary tracking edit history per file.
35
+ If not provided, a new history will be created.
36
+ """
37
+ super().__init__(
38
+ env=file_history or defaultdict(list),
39
+ name="edit",
40
+ title="File Editor",
41
+ description="View, create, and edit files with undo support",
42
+ )
43
+
44
+ @property
45
+ def file_history(self) -> dict[Path, list[str]]:
46
+ """Get the file edit history (alias for context)."""
47
+ return self.env
48
+
49
+ async def __call__(
50
+ self,
51
+ *,
52
+ command: Command,
53
+ path: str,
54
+ file_text: str | None = None,
55
+ view_range: list[int] | None = None,
56
+ old_str: str | None = None,
57
+ new_str: str | None = None,
58
+ insert_line: int | None = None,
59
+ **kwargs: Any,
60
+ ) -> list[ContentBlock]:
61
+ _path = Path(path)
62
+ self.validate_path(command, _path)
63
+ if command == "view":
64
+ result = await self.view(_path, view_range)
65
+ return result.to_content_blocks()
66
+ elif command == "create":
67
+ if file_text is None:
68
+ raise ToolError("Parameter `file_text` is required for command: create")
69
+ await self.write_file(_path, file_text)
70
+ self.file_history[_path].append(file_text)
71
+ return ContentResult(
72
+ output=f"File created successfully at: {_path}"
73
+ ).to_content_blocks()
74
+ elif command == "str_replace":
75
+ if old_str is None:
76
+ raise ToolError("Parameter `old_str` is required for command: str_replace")
77
+ result = await self.str_replace(_path, old_str, new_str)
78
+ return result.to_content_blocks()
79
+ elif command == "insert":
80
+ if insert_line is None:
81
+ raise ToolError("Parameter `insert_line` is required for command: insert")
82
+ if new_str is None:
83
+ raise ToolError("Parameter `new_str` is required for command: insert")
84
+ result = await self.insert(_path, insert_line, new_str)
85
+ return result.to_content_blocks()
86
+ elif command == "undo_edit":
87
+ result = await self.undo_edit(_path)
88
+ return result.to_content_blocks()
89
+ raise ToolError(
90
+ f"Unrecognized command {command}. The allowed commands for the {self.name} tool are: "
91
+ f"{', '.join(get_args(Command))}"
92
+ )
93
+
94
+ def validate_path(self, command: str, path: Path) -> None:
95
+ """
96
+ Check that the path/command combination is valid.
97
+ """
98
+ # Check if its an absolute path
99
+ if not path.is_absolute():
100
+ suggested_path = Path("") / path
101
+ raise ToolError(
102
+ f"The path {path} is not an absolute path, it should start with `/`. "
103
+ f"Maybe you meant {suggested_path}?"
104
+ )
105
+ # Check if path exists
106
+ if not path.exists() and command != "create":
107
+ raise ToolError(f"The path {path} does not exist. Please provide a valid path.")
108
+ if path.exists() and command == "create":
109
+ raise ToolError(
110
+ f"File already exists at: {path}. Cannot overwrite files using command `create`."
111
+ )
112
+ # Check if the path points to a directory
113
+ if path.is_dir() and command != "view":
114
+ raise ToolError(
115
+ f"The path {path} is a dir and only the `view` command can be used on dirs."
116
+ )
117
+
118
+ async def view(self, path: Path, view_range: list[int] | None = None) -> ContentResult:
119
+ """Implement the view command"""
120
+ if path.is_dir():
121
+ if view_range:
122
+ raise ToolError(
123
+ "The `view_range` parameter is not allowed when `path` points to a directory."
124
+ )
125
+
126
+ import shlex
127
+
128
+ safe_path = shlex.quote(str(path))
129
+ _, stdout, stderr = await run(rf"find {safe_path} -maxdepth 2 -not -path '*/\.*'")
130
+ if not stderr:
131
+ stdout = (
132
+ f"Here's the files and directories up to 2 levels deep in {path}, "
133
+ f"excluding hidden items:\n{stdout}\n"
134
+ )
135
+ return ContentResult(output=stdout, error=stderr)
136
+
137
+ file_content = await self.read_file(path)
138
+ init_line = 1
139
+ if view_range:
140
+ if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
141
+ raise ToolError("Invalid `view_range`. It should be a list of two integers.")
142
+ file_lines = file_content.split("\n")
143
+ n_lines_file = len(file_lines)
144
+ init_line, final_line = view_range
145
+ if init_line < 1 or init_line > n_lines_file:
146
+ raise ToolError(
147
+ f"Invalid `view_range`: {view_range}. Its first element `{init_line}` "
148
+ f"should be within the range of lines of the file: {[1, n_lines_file]}"
149
+ )
150
+ if final_line > n_lines_file:
151
+ raise ToolError(
152
+ f"Invalid `view_range`: {view_range}. Its second element `{final_line}` "
153
+ f"should be smaller than the number of lines in the file: `{n_lines_file}`"
154
+ )
155
+ if final_line != -1 and final_line < init_line:
156
+ raise ToolError(
157
+ f"Invalid `view_range`: {view_range}. Its second element `{final_line}` "
158
+ f"should be larger or equal than its first `{init_line}`"
159
+ )
160
+
161
+ if final_line == -1:
162
+ file_content = "\n".join(file_lines[init_line - 1 :])
163
+ else:
164
+ file_content = "\n".join(file_lines[init_line - 1 : final_line])
165
+
166
+ return ContentResult(output=self._make_output(file_content, str(path), init_line=init_line))
167
+
168
+ async def str_replace(self, path: Path, old_str: str, new_str: str | None) -> ContentResult:
169
+ """
170
+ Implement the str_replace command, which replaces old_str with new_str in the file content.
171
+ """
172
+ # Read the file content
173
+ file_content = (await self.read_file(path)).expandtabs()
174
+ old_str = old_str.expandtabs()
175
+ new_str = new_str.expandtabs() if new_str is not None else ""
176
+
177
+ # Check if old_str is unique in the file
178
+ occurrences = file_content.count(old_str)
179
+ if occurrences == 0:
180
+ raise ToolError(
181
+ f"No replacement was performed, old_str `{old_str}` did not appear verbatim in "
182
+ f"{path}."
183
+ )
184
+ elif occurrences > 1:
185
+ file_content_lines = file_content.split("\n")
186
+ lines = [idx + 1 for idx, line in enumerate(file_content_lines) if old_str in line]
187
+ raise ToolError(
188
+ f"No replacement was performed. Multiple occurrences of old_str `{old_str}` "
189
+ f"in lines {lines}. Please ensure it is unique"
190
+ )
191
+
192
+ # Replace old_str with new_str
193
+ new_file_content = file_content.replace(old_str, new_str)
194
+
195
+ # Write the new content to the file
196
+ await self.write_file(path, new_file_content)
197
+
198
+ # Save the content to history
199
+ self.file_history[path].append(file_content)
200
+
201
+ # Create a snippet of the edited section
202
+ replacement_line = file_content.split(old_str)[0].count("\n")
203
+ start_line = max(0, replacement_line - SNIPPET_LINES)
204
+ end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")
205
+ snippet = "\n".join(new_file_content.split("\n")[start_line : end_line + 1])
206
+
207
+ # Prepare the success message
208
+ success_msg = f"The file {path} has been edited. "
209
+ success_msg += self._make_output(snippet, f"a snippet of {path}", start_line + 1)
210
+ success_msg += (
211
+ "Review the changes and make sure they are as expected. "
212
+ "Edit the file again if necessary."
213
+ )
214
+
215
+ return ContentResult(output=success_msg)
216
+
217
+ async def insert(self, path: Path, insert_line: int, new_str: str) -> ContentResult:
218
+ """
219
+ Implement the insert command, which inserts new_str at the specified line in the file.
220
+ """
221
+ file_text = (await self.read_file(path)).expandtabs()
222
+ new_str = new_str.expandtabs()
223
+ file_text_lines = file_text.split("\n")
224
+ n_lines_file = len(file_text_lines)
225
+
226
+ if insert_line < 0 or insert_line > n_lines_file:
227
+ raise ToolError(
228
+ f"Invalid `insert_line` parameter: {insert_line}. It should be within the range "
229
+ f"of lines of the file: {[0, n_lines_file]}"
230
+ )
231
+
232
+ new_str_lines = new_str.split("\n")
233
+ new_file_text_lines = (
234
+ file_text_lines[:insert_line] + new_str_lines + file_text_lines[insert_line:]
235
+ )
236
+ snippet_lines = (
237
+ file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
238
+ + new_str_lines
239
+ + file_text_lines[insert_line : insert_line + SNIPPET_LINES]
240
+ )
241
+
242
+ new_file_text = "\n".join(new_file_text_lines)
243
+ snippet = "\n".join(snippet_lines)
244
+
245
+ await self.write_file(path, new_file_text)
246
+ self.file_history[path].append(file_text)
247
+
248
+ success_msg = f"The file {path} has been edited. "
249
+ success_msg += self._make_output(
250
+ snippet,
251
+ "a snippet of the edited file",
252
+ max(1, insert_line - SNIPPET_LINES + 1),
253
+ )
254
+ success_msg += (
255
+ "Review the changes and make sure they are as expected (correct indentation, "
256
+ "no duplicate lines, etc). Edit the file again if necessary."
257
+ )
258
+ return ContentResult(output=success_msg)
259
+
260
+ async def undo_edit(self, path: Path) -> ContentResult:
261
+ """Implement the undo_edit command."""
262
+ if not self.file_history[path]:
263
+ raise ToolError(f"No edit history found for {path}.")
264
+
265
+ old_text = self.file_history[path].pop()
266
+ await self.write_file(path, old_text)
267
+
268
+ return ContentResult(
269
+ output=f"Last edit to {path} undone successfully. "
270
+ f"{self._make_output(old_text, str(path))}"
271
+ )
272
+
273
+ async def read_file(self, path: Path) -> str:
274
+ """Read the content of a file from a given path; raise a ToolError if an error occurs."""
275
+ try:
276
+ import shlex
277
+
278
+ safe_path = shlex.quote(str(path))
279
+ code, out, err = await run(f"cat {safe_path}")
280
+ if code != 0:
281
+ raise ToolError(f"Ran into {err} while trying to read {path}")
282
+ return out
283
+ except Exception as e:
284
+ raise ToolError(f"Ran into {e} while trying to read {path}") from None
285
+
286
+ async def write_file(self, path: Path, file: str) -> None:
287
+ """Write the content of a file to a given path; raise a ToolError if an error occurs."""
288
+ try:
289
+ import shlex
290
+
291
+ safe_path = shlex.quote(str(path))
292
+ code, _, err = await run(f"cat > {safe_path} << 'EOF'\n{file}\nEOF")
293
+ if code != 0:
294
+ raise ToolError(f"Ran into {err} while trying to write to {path}")
295
+ except Exception as e:
296
+ raise ToolError(f"Ran into {e} while trying to write to {path}") from None
297
+
298
+ def _make_output(
299
+ self,
300
+ file_content: str,
301
+ file_descriptor: str,
302
+ init_line: int = 1,
303
+ expand_tabs: bool = True,
304
+ ) -> str:
305
+ """Generate output for the CLI based on the content of a file."""
306
+ file_content = maybe_truncate(file_content)
307
+ if expand_tabs:
308
+ file_content = file_content.expandtabs()
309
+ file_content = "\n".join(
310
+ [f"{i + init_line:6}\t{line}" for i, line in enumerate(file_content.split("\n"))]
311
+ )
312
+ return (
313
+ f"Here's the result of running `cat -n` on {file_descriptor}:\n" + file_content + "\n"
314
+ )