toolplane-python-client 0.1.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 (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,775 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Description: Custom editing tool for viewing, creating and editing files
5
+ * State is persistent across command calls and discussions with the user
6
+ * If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
7
+ * The `create` command cannot be used if the specified `path` already exists as a file
8
+ * If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
9
+ * The `undo_edit` command will revert the last edit made to the file at `path`
10
+
11
+ Notes for using the `str_replace` command:
12
+ * The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
13
+ * If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
14
+ * The `new_str` parameter should contain the edited lines that should replace the `old_str`
15
+
16
+ Parameters:
17
+ (1) command (string, required): The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
18
+ Allowed values: [`view`, `create`, `str_replace`, `insert`, `undo_edit`]
19
+ (2) path (string, required): Absolute path to file or directory, e.g. `/testbed/file.py` or `/testbed`.
20
+ (3) file_text (string, optional): Required parameter of `create` command, with the content of the file to be created.
21
+ (4) old_str (string, optional): Required parameter of `str_replace` command containing the string in `path` to replace.
22
+ (5) new_str (string, optional): Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
23
+ (6) insert_line (integer, optional): Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.
24
+ (7) view_range (array, optional): Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
25
+ (8) enable_linting (boolean, optional): Optional parameter to enable Python linting checks before saving changes. Default is `false`.
26
+ (9) concise (boolean, optional): Optional parameter to enable a condensed view for Python files. Default is `false`. Super useful for understanding the structure of a Python file without getting bogged down in the details.
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import warnings
36
+ from collections import defaultdict
37
+ from pathlib import Path
38
+ from typing import Dict, List, Optional, Tuple
39
+
40
+ import chardet
41
+
42
+ # sys.stdout.reconfigure(encoding='utf-8')
43
+
44
+
45
+ def get_temp_dir():
46
+ """Get platform-appropriate temp directory"""
47
+ return Path(tempfile.gettempdir())
48
+
49
+
50
+ def get_state_file_path(filename="editor_state.json"):
51
+ """Get the editor state file path.
52
+
53
+ The default is a per-user cache directory, NOT the shared world-writable
54
+ temp dir: the state holds full pre-edit copies of every edited file, so a
55
+ predictable shared path both leaks file contents to other local users and
56
+ is poisonable (undo_edit writes history bytes back to disk). Override via
57
+ TOOLPLANE_EDITOR_STATE_FILE (or the legacy EDITOR_STATE_FILE).
58
+ """
59
+ override = os.environ.get("TOOLPLANE_EDITOR_STATE_FILE") or os.environ.get(
60
+ "EDITOR_STATE_FILE"
61
+ )
62
+ if override:
63
+ return Path(override)
64
+ return Path.home() / ".cache" / "toolplane" / filename
65
+
66
+
67
+ STATE_FILE = str(get_state_file_path())
68
+ SNIPPET_LINES = 4
69
+
70
+ # We ignore certain warnings from tree_sitter (optional).
71
+ warnings.simplefilter("ignore", category=FutureWarning)
72
+
73
+ _LINT_ERROR_TEMPLATE = """Your proposed edit has introduced new syntax error(s).
74
+ Please read this error message carefully and then retry editing the file.
75
+ ERRORS:
76
+ """
77
+
78
+ TRUNCATED_MESSAGE = (
79
+ "<response clipped><NOTE>To save on context only part of this file has been "
80
+ "shown to you. You should retry this tool after you have searched inside the file "
81
+ "with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
82
+ )
83
+ MAX_RESPONSE_LEN = 32000 # 4000 #12000 # 16000
84
+
85
+
86
+ import io
87
+ import sys
88
+
89
+ # NOTE: this module deliberately does NOT replace sys.stdout at import time.
90
+ # A module-level stdout swap wraps (and on garbage collection, closes) the
91
+ # caller's capture buffer — pytest capture, piped output, and the provider's
92
+ # own result capture all break. Printing stays UTF-8-safe via safe_print.
93
+
94
+
95
+ def safe_print(x):
96
+ try:
97
+ print(x)
98
+ except UnicodeEncodeError:
99
+ print(x.encode("utf-8", errors="replace").decode("utf-8", errors="replace"))
100
+
101
+
102
+ def maybe_truncate(content: str, truncate_after: Optional[int] = MAX_RESPONSE_LEN):
103
+ if not truncate_after or len(content) <= truncate_after:
104
+ return content
105
+ return content[:truncate_after] + TRUNCATED_MESSAGE
106
+
107
+
108
+ class EditorError(Exception):
109
+ """Raised for usage or file system errors within the editor tool."""
110
+
111
+
112
+ class EditorResult:
113
+ """
114
+ Simple container for output and optional error messages.
115
+ """
116
+
117
+ def __init__(self, output: str, error: str = ""):
118
+ self.output = output
119
+ self.error = error
120
+
121
+ def __str__(self):
122
+ if self.error:
123
+ return f"ERROR: {self.error}\n\n{self.output}"
124
+ return self.output
125
+
126
+
127
+ def load_history() -> Dict[str, List[str]]:
128
+ """
129
+ Load the file edit history from STATE_FILE if it exists.
130
+ """
131
+ try:
132
+ with open(STATE_FILE, "r", encoding="utf-8") as f:
133
+ data = json.load(f)
134
+ return {k: v for k, v in data.items()}
135
+ except FileNotFoundError:
136
+ return {}
137
+ except Exception as e:
138
+ safe_print(f"Warning: Could not load editor history from {STATE_FILE}: {e}")
139
+ return {}
140
+
141
+
142
+ def save_history(history: Dict[str, List[str]]):
143
+ """
144
+ Save the file edit history to STATE_FILE as JSON.
145
+ """
146
+ try:
147
+ Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
148
+ with open(STATE_FILE, "w", encoding="utf-8") as f:
149
+ json.dump(history, f)
150
+ except Exception as e:
151
+ safe_print(f"Warning: Could not write editor history to {STATE_FILE}: {e}")
152
+
153
+
154
+ class StrReplaceEditor:
155
+ """
156
+ A file editor that supports the following commands:
157
+ - view
158
+ - create
159
+ - str_replace
160
+ - insert
161
+ - undo_edit
162
+
163
+ The edit history is kept in memory (self.file_history) and also persisted to disk.
164
+
165
+ Additionally, a `--concise` option for `view` on Python files:
166
+ - Uses tree-sitter to skip large function bodies.
167
+ - Preserves original line numbering, printing placeholders for elided lines.
168
+ """
169
+
170
+ def __init__(
171
+ self, file_history: Dict[str, List[str]], enable_linting: bool = False
172
+ ):
173
+ self.file_history = defaultdict(list, file_history)
174
+ self.enable_linting = enable_linting
175
+
176
+ def run(
177
+ self,
178
+ command: str,
179
+ path_str: str,
180
+ file_text: str = None,
181
+ view_range: List[int] = None,
182
+ old_str: str = None,
183
+ new_str: str = None,
184
+ insert_line: int = None,
185
+ concise: bool = False,
186
+ python_only: bool = True,
187
+ ) -> EditorResult:
188
+ path = Path(path_str)
189
+ self.validate_path(command, path)
190
+
191
+ if command == "view":
192
+ return self.view(path, view_range, concise=concise, python_only=python_only)
193
+ elif command == "create":
194
+ return self.create(path, file_text)
195
+ elif command == "str_replace":
196
+ return self.str_replace(path, old_str, new_str)
197
+ elif command == "insert":
198
+ return self.insert(path, insert_line, new_str)
199
+ elif command == "undo_edit":
200
+ return self.undo_edit(path)
201
+ else:
202
+ raise EditorError(
203
+ f"Unrecognized command '{command}'. "
204
+ "Allowed commands: view, create, str_replace, insert, undo_edit."
205
+ )
206
+
207
+ def validate_path(self, command: str, path: Path):
208
+ # Optional workspace jail: when TOOLPLANE_WORKSPACE_ROOT is set, every
209
+ # editor operation must resolve inside it (symlinks resolved first).
210
+ root = os.environ.get("TOOLPLANE_WORKSPACE_ROOT", "").strip()
211
+ if root:
212
+ try:
213
+ Path(path).resolve().relative_to(Path(root).resolve())
214
+ except ValueError:
215
+ raise EditorError(
216
+ f"Path '{path}' is outside TOOLPLANE_WORKSPACE_ROOT ({root})."
217
+ )
218
+
219
+ if command == "create":
220
+ if path.exists():
221
+ raise EditorError(
222
+ f"File already exists at: {path}. Cannot overwrite with 'create'."
223
+ )
224
+ else:
225
+ if not path.exists():
226
+ raise EditorError(f"The path '{path}' does not exist.")
227
+
228
+ if path.is_dir() and command != "view":
229
+ raise EditorError(
230
+ f"The path '{path}' is a directory. Only 'view' can be used on directories."
231
+ )
232
+
233
+ @staticmethod
234
+ def read_path(path: Path) -> str:
235
+ encoding = chardet.detect(path.read_bytes())["encoding"]
236
+ if encoding is None:
237
+ encoding = "utf-8"
238
+ return path.read_text(encoding=encoding)
239
+
240
+ def view(
241
+ self,
242
+ path: Path,
243
+ view_range: Optional[List[int]] = None,
244
+ concise: bool = False,
245
+ python_only: bool = True,
246
+ ) -> EditorResult:
247
+ """
248
+ If path is a directory, list contents (2 levels deep, excluding hidden).
249
+ If path is a file, optionally use the 'concise' approach for Python.
250
+ Then apply [start_line, end_line] slicing if provided.
251
+ """
252
+ if path.is_dir():
253
+ if not python_only:
254
+ cmd = ["find", str(path), "-maxdepth", "2", "-not", "-path", "*/.*"]
255
+ else:
256
+ # Use `-type d -o -name '*.py'` to only list directories or *.py files
257
+ cmd = [
258
+ "find",
259
+ str(path),
260
+ "-maxdepth",
261
+ "2",
262
+ "-not",
263
+ "-path",
264
+ "*/.*",
265
+ "(",
266
+ "-type",
267
+ "d",
268
+ "-o",
269
+ "-name",
270
+ "*.py",
271
+ ")",
272
+ ]
273
+ try:
274
+ # Try using the newer parameters first (Python 3.7+)
275
+ try:
276
+ proc = subprocess.run(
277
+ cmd, capture_output=True, text=True, check=False
278
+ )
279
+ except TypeError:
280
+ # Fallback for Python 3.5 and 3.6 where capture_output and text are not supported
281
+ proc = subprocess.run(
282
+ cmd,
283
+ stdout=subprocess.PIPE,
284
+ stderr=subprocess.PIPE,
285
+ universal_newlines=True,
286
+ check=False,
287
+ )
288
+
289
+ stderr = proc.stderr.strip()
290
+ stdout = proc.stdout
291
+ if stderr:
292
+ return EditorResult(output="", error=stderr)
293
+
294
+ msg = (
295
+ f"Here's the files and directories up to 2 levels deep in {path}, "
296
+ "excluding hidden:\n" + stdout
297
+ )
298
+ msg = maybe_truncate(msg)
299
+ return EditorResult(output=msg)
300
+ except Exception as e:
301
+ return EditorResult(
302
+ output="",
303
+ error=f"Ran into {e} while trying to list directory contents of {path}.",
304
+ )
305
+
306
+ # ====================
307
+ # NEW RESTRICTION: only .py files are allowed for viewing
308
+ if path.suffix != ".py" and python_only:
309
+ error_msg = (
310
+ f"ERROR: Viewing non-Python files is disallowed for saving context. "
311
+ f"File '{path.name}' is not a .py file."
312
+ )
313
+ return EditorResult(output="", error=error_msg)
314
+ # ====================
315
+
316
+ # -----------------------------------------------
317
+ # NEW LOGIC for deciding whether to use 'concise' mode
318
+ # -----------------------------------------------
319
+ # If no view_range is given, user did NOT explicitly request 'concise',
320
+ # and we have a Python file with more than 50 lines => default to concise
321
+ if path.suffix == ".py" and not view_range and not concise:
322
+ file_text_tmp = self.read_path(path)
323
+ if len(file_text_tmp.splitlines()) > 110:
324
+ concise = True
325
+
326
+ # For a file
327
+ if path.suffix == ".py" and concise:
328
+ # Use the tree_sitter approach
329
+ lines_with_original_numbers = self._get_elided_lines(path)
330
+ else:
331
+ # Normal reading
332
+ file_text = self.read_path(path)
333
+ lines_with_original_numbers = [
334
+ (i, line) for i, line in enumerate(file_text.splitlines())
335
+ ]
336
+
337
+ # Optionally slice by [start_line, end_line]
338
+ total_lines = len(lines_with_original_numbers)
339
+ if view_range and len(view_range) == 2:
340
+ start, end = view_range
341
+ if not (1 <= start <= total_lines):
342
+ return EditorResult(
343
+ output="",
344
+ error=(
345
+ f"Invalid view_range {view_range}: start line must be in [1, {total_lines}]"
346
+ ),
347
+ )
348
+ if end != -1 and (end < start or end > total_lines):
349
+ return EditorResult(
350
+ output="",
351
+ error=(
352
+ f"Invalid view_range {view_range}: end must be >= start "
353
+ f"and <= {total_lines}, or -1 to view until end."
354
+ ),
355
+ )
356
+
357
+ # Filter lines by 1-based index
358
+ sliced_lines = []
359
+ for i, text in lines_with_original_numbers:
360
+ one_based = i + 1
361
+ if one_based < start:
362
+ continue
363
+ if end != -1 and one_based > end:
364
+ continue
365
+ sliced_lines.append((i, text))
366
+ else:
367
+ # No slicing
368
+ sliced_lines = lines_with_original_numbers
369
+
370
+ # Now produce a cat-like output (line numbering = i+1)
371
+ if concise:
372
+ final_output = f"Here is a condensed view for file: {path}; [Note: Useful for understanding file structure in a concise manner. Please use specific view_range without concise cmd if you want to explore further into the relevant parts.]\n"
373
+ else:
374
+ final_output = (
375
+ f"Here's the result of running `cat -n` on the file: {path}:\n"
376
+ )
377
+ # Then maybe truncate
378
+ output_str_list = []
379
+ for i, text in sliced_lines:
380
+ # i is 0-based
381
+ output_str_list.append(f"{i+1:6d} {text}")
382
+
383
+ final_output += "\n".join(output_str_list)
384
+ final_output = maybe_truncate(final_output)
385
+ return EditorResult(output=final_output)
386
+
387
+ def _get_elided_lines(self, path: Path) -> List[Tuple[int, str]]:
388
+ """
389
+ Parse the Python file with the built-in 'ast' module to skip
390
+ large function bodies (≥ 5 lines).
391
+ Return a list of (zero_based_line_idx, text).
392
+ """
393
+ import ast
394
+
395
+ file_text = self.read_path(path)
396
+ try:
397
+ tree = ast.parse(file_text, filename=str(path))
398
+ except SyntaxError as e:
399
+ # Raise EditorError to make sure we handle it gracefully upstream
400
+ raise EditorError(f"Syntax error for file {path}: {e}")
401
+
402
+ def max_lineno_in_subtree(n: ast.AST) -> int:
403
+ m = getattr(n, "lineno", 0)
404
+ for child in ast.iter_child_nodes(n):
405
+ m = max(m, max_lineno_in_subtree(child))
406
+ return m
407
+
408
+ # We'll gather the line ranges for all large function bodies
409
+ elide_line_ranges = []
410
+ for node in ast.walk(tree):
411
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.body:
412
+ # Attempt to use end_lineno (Python 3.8+), else fallback
413
+ last_stmt = node.body[-1]
414
+ if hasattr(last_stmt, "end_lineno") and last_stmt.end_lineno:
415
+ # 0-based start of the body (first statement in the function)
416
+ body_start = node.body[0].lineno - 1
417
+
418
+ body_end = last_stmt.end_lineno - 1
419
+ else:
420
+ body_start = node.body[0].lineno - 1
421
+
422
+ docstring = ast.get_docstring(node)
423
+
424
+ if docstring:
425
+ num_docstring_lines = docstring.count("\n") + 1
426
+ body_start -= num_docstring_lines
427
+
428
+ # Fallback: traverse the subtree to find max lineno
429
+ body_end = max_lineno_in_subtree(last_stmt) - 1
430
+
431
+ # Skip the body if spans ≥ 5 lines
432
+ if (body_end - body_start) >= 3:
433
+ elide_line_ranges.append((body_start, body_end))
434
+
435
+ if isinstance(node, ast.ClassDef) and node.body:
436
+ # elide large docstrings for classes
437
+ has_docstring1 = (
438
+ isinstance(node.body[0], ast.Expr)
439
+ and isinstance(node.body[0].value, ast.Constant)
440
+ and isinstance(node.body[0].value.value, str)
441
+ )
442
+ has_docstring2 = isinstance(node.body[0], ast.Expr) and isinstance(
443
+ node.body[0].value, ast.Str
444
+ )
445
+ has_docstring = has_docstring1 or has_docstring2
446
+ if has_docstring:
447
+ if hasattr(node.body[0], "end_lineno") and node.body[0].end_lineno:
448
+ docstring_start = node.body[0].lineno - 1
449
+ docstring_end = node.body[0].end_lineno - 1
450
+ else:
451
+ docstring_start = node.lineno
452
+ docstring_end = max_lineno_in_subtree(node.body[0]) - 1
453
+
454
+ if (docstring_end - docstring_start) >= 4:
455
+ elide_line_ranges.append(
456
+ (docstring_start + 1, docstring_end - 1)
457
+ )
458
+
459
+ # Build a set of lines to skip
460
+ elide_lines = {
461
+ line for (start, end) in elide_line_ranges for line in range(start, end + 1)
462
+ }
463
+
464
+ # Add an "elision notice" at the beginning of each range
465
+ elide_messages = [
466
+ (start, f"... eliding lines {start+1}-{end+1} ...")
467
+ for (start, end) in elide_line_ranges
468
+ ]
469
+
470
+ # Lines we do keep
471
+ all_lines = file_text.splitlines()
472
+ keep_lines = [
473
+ (i, line) for i, line in enumerate(all_lines) if i not in elide_lines
474
+ ]
475
+
476
+ # Combine and sort by line index
477
+ combined = elide_messages + keep_lines
478
+ combined.sort(key=lambda x: x[0])
479
+
480
+ return combined
481
+
482
+ def create(self, path: Path, file_text: str) -> EditorResult:
483
+ if file_text is None:
484
+ raise EditorError("Cannot create file without 'file_text' parameter.")
485
+
486
+ if self.enable_linting and path.suffix == ".py":
487
+ lint_error = self._lint_check(file_text, str(path))
488
+ if lint_error:
489
+ return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
490
+
491
+ try:
492
+ path.write_text(file_text, encoding="utf-8")
493
+ self.file_history[str(path)].append("")
494
+ except Exception as e:
495
+ raise EditorError(f"Error creating file at {path}: {e}")
496
+
497
+ success_msg = f"File created at {path}. "
498
+ success_msg += self._make_output(file_text, str(path))
499
+ success_msg += "Review the file and make sure that it is as expected. Edit the file if necessary."
500
+
501
+ return EditorResult(output=f"{success_msg}")
502
+
503
+ def str_replace(self, path: Path, old_str: str, new_str: str) -> EditorResult:
504
+ if old_str is None:
505
+ raise EditorError("Missing required parameter 'old_str' for 'str_replace'.")
506
+
507
+ file_content = self.read_file(path).expandtabs()
508
+ old_str = old_str.expandtabs()
509
+ new_str = new_str.expandtabs() if new_str else ""
510
+ occurrences = file_content.count(old_str)
511
+ if occurrences == 0:
512
+ raise EditorError(
513
+ f"No occurrences of '{old_str}' found in {path} for replacement."
514
+ )
515
+ if occurrences > 1:
516
+ raise EditorError(
517
+ f"Multiple occurrences of '{old_str}' found in {path}. "
518
+ "Please ensure it is unique before using str_replace."
519
+ )
520
+
521
+ old_text = file_content
522
+ updated_text = file_content.replace(old_str, new_str if new_str else "")
523
+
524
+ if self.enable_linting and path.suffix == ".py":
525
+ lint_error = self._lint_check(updated_text, str(path))
526
+ if lint_error:
527
+ return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
528
+
529
+ self.file_history[str(path)].append(old_text)
530
+ self.write_file(path, updated_text)
531
+
532
+ # Original snippet logic
533
+ replacement_line = file_content.split(old_str)[0].count("\n")
534
+ start_line = max(0, replacement_line - SNIPPET_LINES)
535
+ end_line = replacement_line + SNIPPET_LINES + (new_str or "").count("\n")
536
+ snippet = "\n".join(updated_text.split("\n")[start_line : end_line + 1])
537
+
538
+ success_msg = f"The file {path} has been edited. "
539
+ success_msg += self._make_output(
540
+ snippet, f"a snippet of {path}", start_line + 1
541
+ )
542
+ success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
543
+
544
+ return EditorResult(output=success_msg)
545
+
546
+ def insert(self, path: Path, insert_line: int, new_str: str) -> EditorResult:
547
+ if new_str is None:
548
+ raise EditorError("Missing required parameter 'new_str' for 'insert'.")
549
+
550
+ old_text = self.read_file(path).expandtabs()
551
+ new_str = new_str.expandtabs()
552
+ file_text_lines = old_text.split("\n")
553
+
554
+ if insert_line < 0 or insert_line > len(file_text_lines):
555
+ raise EditorError(
556
+ f"Invalid insert_line {insert_line}. Must be in [0, {len(file_text_lines)}]."
557
+ )
558
+
559
+ new_str_lines = new_str.split("\n")
560
+ new_file_text_lines = (
561
+ file_text_lines[:insert_line]
562
+ + new_str_lines
563
+ + file_text_lines[insert_line:]
564
+ )
565
+ updated_text = "\n".join(new_file_text_lines)
566
+
567
+ if self.enable_linting and path.suffix == ".py":
568
+ lint_error = self._lint_check(updated_text, str(path))
569
+ if lint_error:
570
+ return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
571
+
572
+ self.file_history[str(path)].append(old_text)
573
+ self.write_file(path, updated_text)
574
+
575
+ # Original snippet logic
576
+ snippet_lines = (
577
+ file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
578
+ + new_str_lines
579
+ + file_text_lines[insert_line : insert_line + SNIPPET_LINES]
580
+ )
581
+ snippet = "\n".join(snippet_lines)
582
+
583
+ success_msg = f"The file {path} has been edited. "
584
+ success_msg += self._make_output(
585
+ snippet,
586
+ "a snippet of the edited file",
587
+ max(1, insert_line - SNIPPET_LINES + 1),
588
+ )
589
+ success_msg += (
590
+ "Review the changes and make sure they are as expected "
591
+ "(correct indentation, no duplicate lines, etc). Edit the file again if necessary."
592
+ )
593
+
594
+ return EditorResult(output=success_msg)
595
+
596
+ def undo_edit(self, path: Path) -> EditorResult:
597
+ path_str = str(path)
598
+ if not self.file_history[path_str]:
599
+ raise EditorError(f"No previous edits found for {path} to undo.")
600
+
601
+ old_text = self.file_history[path_str].pop()
602
+ self.write_file(path, old_text)
603
+
604
+ return EditorResult(
605
+ output=(
606
+ f"Last edit to {path} undone successfully. "
607
+ + self._make_output(old_text, str(path))
608
+ )
609
+ )
610
+
611
+ def read_file(self, path: Path) -> str:
612
+ try:
613
+ return self.read_path(path)
614
+ except Exception as e:
615
+ raise EditorError(f"Failed to read file {path}: {e}")
616
+
617
+ def write_file(self, path: Path, content: str):
618
+ try:
619
+ path.write_text(content, encoding="utf-8")
620
+ except Exception as e:
621
+ raise EditorError(f"Failed to write file {path}: {e}")
622
+
623
+ def _make_output(
624
+ self,
625
+ file_content: str,
626
+ file_descriptor: str,
627
+ init_line: int = 1,
628
+ expand_tabs: bool = True,
629
+ ) -> str:
630
+ """
631
+ Mimics cat -n style numbering, plus maybe_truncate to avoid huge outputs.
632
+ """
633
+ file_content = maybe_truncate(file_content)
634
+ if expand_tabs:
635
+ file_content = file_content.expandtabs()
636
+
637
+ lines = file_content.split("\n")
638
+ numbered = "\n".join(
639
+ f"{i + init_line:6}\t{line}" for i, line in enumerate(lines)
640
+ )
641
+ return (
642
+ f"Here's the result of running `cat -n` on {file_descriptor}:\n"
643
+ + numbered
644
+ + "\n"
645
+ )
646
+
647
+ def _lint_check(self, new_content: str, file_path: str) -> str:
648
+ import ast
649
+
650
+ try:
651
+ ast.parse(new_content, filename=file_path)
652
+ return ""
653
+ except SyntaxError as e:
654
+ return str(e)
655
+
656
+
657
+ def main():
658
+ def parse_view_range(range_str: str):
659
+ # Remove surrounding brackets if present
660
+ range_str = range_str.strip().strip("[]()")
661
+
662
+ # Split on commas or whitespace
663
+ parts = range_str.replace(",", " ").split()
664
+
665
+ if len(parts) != 2:
666
+ raise argparse.ArgumentTypeError(f"Expected two numbers, got: {range_str}")
667
+ try:
668
+ start_line = int(parts[0])
669
+ end_line = int(parts[1])
670
+ except ValueError:
671
+ raise argparse.ArgumentTypeError(f"Could not convert {parts} to integers.")
672
+ return [start_line, end_line]
673
+
674
+ parser = argparse.ArgumentParser(
675
+ description=(
676
+ "A disk-backed file editing tool (view, create, str_replace, insert, undo_edit) "
677
+ "with optional linting. Also supports a 'concise' view for Python files."
678
+ )
679
+ )
680
+ parser.add_argument(
681
+ "command",
682
+ type=str,
683
+ help="One of: view, create, str_replace, insert, undo_edit",
684
+ )
685
+ parser.add_argument(
686
+ "--path",
687
+ type=str,
688
+ help="Path to the target file or directory (absolute path recommended)",
689
+ )
690
+ parser.add_argument(
691
+ "--file_text",
692
+ type=str,
693
+ default=None,
694
+ help="File content (for 'create')",
695
+ )
696
+ parser.add_argument(
697
+ "--view_range",
698
+ type=parse_view_range,
699
+ default=None,
700
+ help="Line range to view [start_line, end_line], use -1 for end.",
701
+ )
702
+ parser.add_argument(
703
+ "--old_str",
704
+ type=str,
705
+ default=None,
706
+ help="Old string (for 'str_replace')",
707
+ )
708
+ parser.add_argument(
709
+ "--new_str",
710
+ type=str,
711
+ default=None,
712
+ help="New string (for 'str_replace' or 'insert')",
713
+ )
714
+ parser.add_argument(
715
+ "--insert_line",
716
+ type=int,
717
+ default=None,
718
+ help="Line number to insert text at (for 'insert')",
719
+ )
720
+ parser.add_argument(
721
+ "--enable_linting",
722
+ type=bool,
723
+ default=False,
724
+ help="Enable linting checks for Python files before saving changes.",
725
+ )
726
+ parser.add_argument(
727
+ "--concise",
728
+ type=str,
729
+ default="False",
730
+ help="If True, attempts to produce a condensed view for Python files, eliding large function bodies.",
731
+ )
732
+ parser.add_argument(
733
+ "--python_only",
734
+ type=bool,
735
+ default=True,
736
+ help="If True, attempts to limit view (for both dir and file level) to Python files only.",
737
+ )
738
+
739
+ args = parser.parse_args()
740
+
741
+ file_history = load_history()
742
+ editor = StrReplaceEditor(file_history, enable_linting=args.enable_linting)
743
+ if args.concise.lower() == "true":
744
+ args.concise = True
745
+ else:
746
+ args.concise = False
747
+
748
+ try:
749
+ result = editor.run(
750
+ command=args.command,
751
+ path_str=args.path,
752
+ file_text=args.file_text,
753
+ view_range=args.view_range,
754
+ old_str=args.old_str,
755
+ new_str=args.new_str,
756
+ insert_line=args.insert_line,
757
+ concise=args.concise,
758
+ )
759
+ safe_print(result.output)
760
+ if result.error:
761
+ safe_print(f"ERROR: {result.error}")
762
+
763
+ except EditorError as e:
764
+ safe_print(f"ERROR: {e}")
765
+ except Exception as e:
766
+ safe_print(f"ERROR: Unhandled exception: {e}")
767
+ import traceback
768
+
769
+ traceback.print_exc()
770
+
771
+ save_history(dict(editor.file_history))
772
+
773
+
774
+ if __name__ == "__main__":
775
+ main()