devorch 0.1.2__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.
tools/edit.py ADDED
@@ -0,0 +1,236 @@
1
+ """
2
+ Edit Tool - Make targeted edits to files.
3
+ Supports find/replace, line-based edits, and smart code modifications.
4
+ """
5
+
6
+ import difflib
7
+ import os
8
+ import re
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from tools.base import Tool
14
+
15
+
16
+ class EditToolSchema(BaseModel):
17
+ action: Literal["replace", "replace_lines", "insert", "delete", "patch"] = Field(
18
+ ..., description="Edit action: replace (find/replace), replace_lines, insert, delete, patch"
19
+ )
20
+ path: str = Field(..., description="Path to the file to edit.")
21
+ find: str = Field(default="", description="Text or pattern to find (for replace action).")
22
+ replace_with: str = Field(default="", description="Replacement text.")
23
+ line_start: int = Field(
24
+ default=0, description="Start line number (1-indexed, for line-based actions)."
25
+ )
26
+ line_end: int = Field(default=0, description="End line number (for replace_lines/delete).")
27
+ content: str = Field(default="", description="Content for insert or patch action.")
28
+ regex: bool = Field(default=False, description="Treat 'find' as regex pattern.")
29
+ count: int = Field(default=1, description="Number of replacements (0 = all).")
30
+ dry_run: bool = Field(default=False, description="Preview changes without applying.")
31
+
32
+
33
+ class EditTool(Tool):
34
+ """
35
+ Make targeted edits to files with precise control.
36
+ Returns a diff showing the changes made.
37
+ """
38
+
39
+ name = "edit"
40
+ description = """Make targeted edits to files. More precise than full file writes.
41
+
42
+ Actions:
43
+ - replace: Find and replace text (supports regex)
44
+ - replace_lines: Replace specific line range with new content
45
+ - insert: Insert content at a specific line
46
+ - delete: Delete lines in a range
47
+ - patch: Apply a unified diff patch
48
+
49
+ Examples:
50
+ - Replace text: action="replace", path="file.py", find="old_func", replace_with="new_func"
51
+ - Replace all: action="replace", path="file.py", find="TODO", replace_with="DONE", count=0
52
+ - Regex replace: action="replace", find="def (\\w+)\\(", replace_with="async def \\1(", regex=true
53
+ - Replace lines 10-15: action="replace_lines", line_start=10, line_end=15, content="new code"
54
+ - Insert at line 5: action="insert", line_start=5, content="# New comment"
55
+ - Delete lines 20-25: action="delete", line_start=20, line_end=25
56
+ - Preview changes: dry_run=true
57
+
58
+ Always use dry_run=true first to preview significant changes!"""
59
+ args_schema = EditToolSchema
60
+
61
+ def _read_file(self, path: str) -> list[str]:
62
+ """Read file and return lines."""
63
+ with open(path, encoding="utf-8") as f:
64
+ return f.readlines()
65
+
66
+ def _write_file(self, path: str, lines: list[str]):
67
+ """Write lines to file."""
68
+ with open(path, "w", encoding="utf-8") as f:
69
+ f.writelines(lines)
70
+
71
+ def _generate_diff(self, original: list[str], modified: list[str], path: str) -> str:
72
+ """Generate a unified diff between original and modified content."""
73
+ diff = difflib.unified_diff(
74
+ original, modified, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
75
+ )
76
+ return "\n".join(diff)
77
+
78
+ def _replace_text(
79
+ self, lines: list[str], find: str, replace_with: str, regex: bool = False, count: int = 1
80
+ ) -> tuple[list[str], int]:
81
+ """Replace text in content. Returns (new_lines, replacement_count)."""
82
+ content = "".join(lines)
83
+ replacements = 0
84
+
85
+ if regex:
86
+ try:
87
+ pattern = re.compile(find)
88
+ if count == 0:
89
+ new_content, replacements = pattern.subn(replace_with, content)
90
+ else:
91
+ new_content, replacements = pattern.subn(replace_with, content, count=count)
92
+ except re.error as e:
93
+ raise ValueError(f"Invalid regex: {e}") from e
94
+ else:
95
+ if count == 0:
96
+ replacements = content.count(find)
97
+ new_content = content.replace(find, replace_with)
98
+ else:
99
+ new_content = content.replace(find, replace_with, count)
100
+ replacements = min(content.count(find), count)
101
+
102
+ # Preserve line structure
103
+ if new_content and not new_content.endswith("\n") and lines and lines[-1].endswith("\n"):
104
+ new_content += "\n"
105
+
106
+ new_lines = new_content.splitlines(keepends=True)
107
+ return new_lines, replacements
108
+
109
+ def _replace_lines(self, lines: list[str], start: int, end: int, new_content: str) -> list[str]:
110
+ """Replace lines in range with new content."""
111
+ # Convert to 0-indexed
112
+ start_idx = max(0, start - 1)
113
+ end_idx = min(len(lines), end)
114
+
115
+ # Ensure new content ends with newline
116
+ if new_content and not new_content.endswith("\n"):
117
+ new_content += "\n"
118
+
119
+ new_lines = new_content.splitlines(keepends=True) if new_content else []
120
+
121
+ return lines[:start_idx] + new_lines + lines[end_idx:]
122
+
123
+ def _insert_lines(self, lines: list[str], at_line: int, content: str) -> list[str]:
124
+ """Insert content at a specific line."""
125
+ # Convert to 0-indexed
126
+ insert_idx = max(0, min(len(lines), at_line - 1))
127
+
128
+ # Ensure content ends with newline
129
+ if content and not content.endswith("\n"):
130
+ content += "\n"
131
+
132
+ new_lines = content.splitlines(keepends=True) if content else []
133
+
134
+ return lines[:insert_idx] + new_lines + lines[insert_idx:]
135
+
136
+ def _delete_lines(self, lines: list[str], start: int, end: int) -> list[str]:
137
+ """Delete lines in range."""
138
+ start_idx = max(0, start - 1)
139
+ end_idx = min(len(lines), end)
140
+
141
+ return lines[:start_idx] + lines[end_idx:]
142
+
143
+ def run(self, arguments: dict[str, Any]) -> Any:
144
+ action = arguments.get("action")
145
+ path = arguments.get("path")
146
+ find = arguments.get("find", "")
147
+ replace_with = arguments.get("replace_with", "")
148
+ line_start = arguments.get("line_start", 0)
149
+ line_end = arguments.get("line_end", 0)
150
+ content = arguments.get("content", "")
151
+ regex = arguments.get("regex", False)
152
+ count = arguments.get("count", 1)
153
+ dry_run = arguments.get("dry_run", False)
154
+
155
+ if not path:
156
+ return "Error: Path not provided."
157
+
158
+ if not os.path.isfile(path):
159
+ return f"Error: File '{path}' not found."
160
+
161
+ try:
162
+ original_lines = self._read_file(path)
163
+ modified_lines = original_lines.copy()
164
+ change_summary = ""
165
+
166
+ if action == "replace":
167
+ if not find:
168
+ return "Error: 'find' parameter required for replace action."
169
+
170
+ modified_lines, num_replacements = self._replace_text(
171
+ modified_lines, find, replace_with, regex, count
172
+ )
173
+
174
+ if num_replacements == 0:
175
+ return f"No matches found for: {find}"
176
+
177
+ change_summary = f"Replaced {num_replacements} occurrence(s)"
178
+
179
+ elif action == "replace_lines":
180
+ if line_start <= 0:
181
+ return "Error: line_start must be >= 1"
182
+ if line_end <= 0:
183
+ line_end = line_start # Replace single line
184
+
185
+ if line_start > len(original_lines):
186
+ return f"Error: line_start ({line_start}) exceeds file length ({len(original_lines)})"
187
+
188
+ modified_lines = self._replace_lines(modified_lines, line_start, line_end, content)
189
+ change_summary = f"Replaced lines {line_start}-{line_end}"
190
+
191
+ elif action == "insert":
192
+ if line_start <= 0:
193
+ return "Error: line_start must be >= 1"
194
+ if not content:
195
+ return "Error: 'content' parameter required for insert action."
196
+
197
+ modified_lines = self._insert_lines(modified_lines, line_start, content)
198
+ inserted_count = len(content.splitlines())
199
+ change_summary = f"Inserted {inserted_count} line(s) at line {line_start}"
200
+
201
+ elif action == "delete":
202
+ if line_start <= 0:
203
+ return "Error: line_start must be >= 1"
204
+ if line_end <= 0:
205
+ line_end = line_start # Delete single line
206
+
207
+ if line_start > len(original_lines):
208
+ return f"Error: line_start ({line_start}) exceeds file length ({len(original_lines)})"
209
+
210
+ modified_lines = self._delete_lines(modified_lines, line_start, line_end)
211
+ deleted_count = min(line_end, len(original_lines)) - line_start + 1
212
+ change_summary = f"Deleted {deleted_count} line(s) ({line_start}-{line_end})"
213
+
214
+ elif action == "patch":
215
+ # Apply a unified diff patch (more complex, basic implementation)
216
+ return "Error: Patch action not yet implemented. Use replace_lines instead."
217
+
218
+ else:
219
+ return f"Error: Unknown action '{action}'"
220
+
221
+ # Generate diff
222
+ diff = self._generate_diff(original_lines, modified_lines, path)
223
+
224
+ if not diff:
225
+ return "No changes needed."
226
+
227
+ if dry_run:
228
+ return f"[DRY RUN] {change_summary}\n\nPreview:\n{diff}"
229
+
230
+ # Apply changes
231
+ self._write_file(path, modified_lines)
232
+
233
+ return f"{change_summary}\n\nDiff:\n{diff}"
234
+
235
+ except Exception as e:
236
+ return f"Edit error: {str(e)}"
tools/filesystem.py ADDED
@@ -0,0 +1,224 @@
1
+ import os
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from tools.base import Tool
7
+
8
+
9
+ class FilesystemToolSchema(BaseModel):
10
+ action: Literal["read", "write", "list", "read_lines", "info"] = Field(
11
+ ..., description="Action: read (full file), read_lines (specific lines), write, list, info"
12
+ )
13
+ path: str = Field(..., description="Path to the file or directory.")
14
+ content: str = Field(default="", description="Content to write (only for 'write' action).")
15
+ start_line: int = Field(default=1, description="Start line number for read_lines (1-indexed).")
16
+ end_line: int = Field(
17
+ default=0, description="End line number for read_lines (0 = to end of file)."
18
+ )
19
+ max_lines: int = Field(default=200, description="Maximum lines to return for read action.")
20
+
21
+
22
+ class FilesystemTool(Tool):
23
+ name = "filesystem"
24
+ description = """Read, write, or list files and directories with line-specific control.
25
+
26
+ Actions:
27
+ - read: Read file content (with line numbers, truncated at max_lines)
28
+ - read_lines: Read specific line range (start_line to end_line)
29
+ - write: Write content to file
30
+ - list: List directory contents with file info
31
+ - info: Get file metadata (size, modified date, line count)
32
+
33
+ Examples:
34
+ - Read first 50 lines: action="read", path="file.py", max_lines=50
35
+ - Read lines 100-150: action="read_lines", path="file.py", start_line=100, end_line=150
36
+ - Get file info: action="info", path="file.py"
37
+ """
38
+ args_schema = FilesystemToolSchema
39
+
40
+ def _format_size(self, size: int) -> str:
41
+ """Format file size in human-readable format."""
42
+ for unit in ["B", "KB", "MB", "GB"]:
43
+ if size < 1024:
44
+ return f"{size:.1f}{unit}"
45
+ size /= 1024
46
+ return f"{size:.1f}TB"
47
+
48
+ def _count_lines(self, path: str) -> int:
49
+ """Count lines in a file efficiently."""
50
+ try:
51
+ with open(path, encoding="utf-8", errors="ignore") as f:
52
+ return sum(1 for _ in f)
53
+ except Exception:
54
+ return -1
55
+
56
+ def _read_with_line_numbers(
57
+ self, path: str, start_line: int = 1, end_line: int = 0, max_lines: int = 200
58
+ ) -> str:
59
+ """Read file with line numbers, supporting line ranges."""
60
+ try:
61
+ with open(path, encoding="utf-8", errors="ignore") as f:
62
+ lines = f.readlines()
63
+
64
+ total_lines = len(lines)
65
+
66
+ # Adjust line numbers (1-indexed)
67
+ start_idx = max(0, start_line - 1)
68
+ if end_line > 0:
69
+ end_idx = min(total_lines, end_line)
70
+ else:
71
+ end_idx = min(start_idx + max_lines, total_lines)
72
+
73
+ # Build output with line numbers
74
+ output_lines = []
75
+ line_num_width = len(str(end_idx))
76
+
77
+ for i in range(start_idx, end_idx):
78
+ line_num = str(i + 1).rjust(line_num_width)
79
+ output_lines.append(f"{line_num} | {lines[i].rstrip()}")
80
+
81
+ # Add truncation notice if needed
82
+ header = f"[{path}] Lines {start_idx + 1}-{end_idx} of {total_lines}\n"
83
+
84
+ if end_idx < total_lines and end_line == 0:
85
+ footer = f"\n... ({total_lines - end_idx} more lines)"
86
+ else:
87
+ footer = ""
88
+
89
+ return header + "\n".join(output_lines) + footer
90
+
91
+ except Exception as e:
92
+ return f"Error reading file: {str(e)}"
93
+
94
+ def _list_directory(self, path: str) -> str:
95
+ """List directory with file info."""
96
+ try:
97
+ items = os.listdir(path)
98
+ if not items:
99
+ return "Directory is empty."
100
+
101
+ output = []
102
+ dirs = []
103
+ files = []
104
+
105
+ for item in sorted(items):
106
+ item_path = os.path.join(path, item)
107
+ if os.path.isdir(item_path):
108
+ dirs.append(f" [DIR] {item}/")
109
+ else:
110
+ try:
111
+ size = os.path.getsize(item_path)
112
+ size_str = self._format_size(size)
113
+ files.append(f" {size_str:>8} {item}")
114
+ except Exception:
115
+ files.append(f" {'?':>8} {item}")
116
+
117
+ if dirs:
118
+ output.append("Directories:")
119
+ output.extend(dirs)
120
+ if files:
121
+ if dirs:
122
+ output.append("")
123
+ output.append("Files:")
124
+ output.extend(files)
125
+
126
+ return "\n".join(output)
127
+
128
+ except Exception as e:
129
+ return f"Error listing directory: {str(e)}"
130
+
131
+ def _get_file_info(self, path: str) -> str:
132
+ """Get file metadata."""
133
+ try:
134
+ stat = os.stat(path)
135
+ size = self._format_size(stat.st_size)
136
+ modified = os.path.getmtime(path)
137
+
138
+ from datetime import datetime
139
+
140
+ mod_date = datetime.fromtimestamp(modified).strftime("%Y-%m-%d %H:%M:%S")
141
+
142
+ info = [
143
+ f"Path: {os.path.abspath(path)}",
144
+ f"Size: {size} ({stat.st_size} bytes)",
145
+ f"Modified: {mod_date}",
146
+ ]
147
+
148
+ # Add line count for text files
149
+ ext = os.path.splitext(path)[1].lower()
150
+ text_exts = {
151
+ ".py",
152
+ ".js",
153
+ ".ts",
154
+ ".json",
155
+ ".yaml",
156
+ ".yml",
157
+ ".md",
158
+ ".txt",
159
+ ".html",
160
+ ".css",
161
+ }
162
+ if ext in text_exts or not ext:
163
+ line_count = self._count_lines(path)
164
+ if line_count >= 0:
165
+ info.append(f"Lines: {line_count}")
166
+
167
+ return "\n".join(info)
168
+
169
+ except Exception as e:
170
+ return f"Error getting file info: {str(e)}"
171
+
172
+ def run(self, arguments: dict[str, Any]) -> Any:
173
+ action = arguments.get("action")
174
+ path = arguments.get("path")
175
+ content = arguments.get("content", "")
176
+ start_line = arguments.get("start_line", 1)
177
+ end_line = arguments.get("end_line", 0)
178
+ max_lines = min(arguments.get("max_lines", 200), 500) # Cap at 500
179
+
180
+ if not path:
181
+ return "Error: Path not provided."
182
+
183
+ try:
184
+ if action == "read":
185
+ if not os.path.isfile(path):
186
+ return f"Error: File '{path}' not found."
187
+ return self._read_with_line_numbers(path, max_lines=max_lines)
188
+
189
+ elif action == "read_lines":
190
+ if not os.path.isfile(path):
191
+ return f"Error: File '{path}' not found."
192
+ return self._read_with_line_numbers(path, start_line, end_line, max_lines=500)
193
+
194
+ elif action == "write":
195
+ # Create directory if needed
196
+ dir_path = os.path.dirname(path)
197
+ if dir_path and not os.path.exists(dir_path):
198
+ os.makedirs(dir_path)
199
+
200
+ with open(path, "w", encoding="utf-8") as f:
201
+ f.write(content)
202
+
203
+ line_count = content.count("\n") + (
204
+ 1 if content and not content.endswith("\n") else 0
205
+ )
206
+ return f"Successfully wrote {line_count} lines to {path}"
207
+
208
+ elif action == "list":
209
+ if not os.path.exists(path):
210
+ return f"Error: Path '{path}' does not exist."
211
+ if os.path.isfile(path):
212
+ return self._get_file_info(path)
213
+ return self._list_directory(path)
214
+
215
+ elif action == "info":
216
+ if not os.path.exists(path):
217
+ return f"Error: Path '{path}' does not exist."
218
+ return self._get_file_info(path)
219
+
220
+ else:
221
+ return f"Error: Unknown action '{action}'. Use: read, read_lines, write, list, info"
222
+
223
+ except Exception as e:
224
+ return f"Filesystem error: {str(e)}"
tools/git.py ADDED
File without changes