kollabor 0.4.9__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.
- core/__init__.py +18 -0
- core/application.py +578 -0
- core/cli.py +193 -0
- core/commands/__init__.py +43 -0
- core/commands/executor.py +277 -0
- core/commands/menu_renderer.py +319 -0
- core/commands/parser.py +186 -0
- core/commands/registry.py +331 -0
- core/commands/system_commands.py +479 -0
- core/config/__init__.py +7 -0
- core/config/llm_task_config.py +110 -0
- core/config/loader.py +501 -0
- core/config/manager.py +112 -0
- core/config/plugin_config_manager.py +346 -0
- core/config/plugin_schema.py +424 -0
- core/config/service.py +399 -0
- core/effects/__init__.py +1 -0
- core/events/__init__.py +12 -0
- core/events/bus.py +129 -0
- core/events/executor.py +154 -0
- core/events/models.py +258 -0
- core/events/processor.py +176 -0
- core/events/registry.py +289 -0
- core/fullscreen/__init__.py +19 -0
- core/fullscreen/command_integration.py +290 -0
- core/fullscreen/components/__init__.py +12 -0
- core/fullscreen/components/animation.py +258 -0
- core/fullscreen/components/drawing.py +160 -0
- core/fullscreen/components/matrix_components.py +177 -0
- core/fullscreen/manager.py +302 -0
- core/fullscreen/plugin.py +204 -0
- core/fullscreen/renderer.py +282 -0
- core/fullscreen/session.py +324 -0
- core/io/__init__.py +52 -0
- core/io/buffer_manager.py +362 -0
- core/io/config_status_view.py +272 -0
- core/io/core_status_views.py +410 -0
- core/io/input_errors.py +313 -0
- core/io/input_handler.py +2655 -0
- core/io/input_mode_manager.py +402 -0
- core/io/key_parser.py +344 -0
- core/io/layout.py +587 -0
- core/io/message_coordinator.py +204 -0
- core/io/message_renderer.py +601 -0
- core/io/modal_interaction_handler.py +315 -0
- core/io/raw_input_processor.py +946 -0
- core/io/status_renderer.py +845 -0
- core/io/terminal_renderer.py +586 -0
- core/io/terminal_state.py +551 -0
- core/io/visual_effects.py +734 -0
- core/llm/__init__.py +26 -0
- core/llm/api_communication_service.py +863 -0
- core/llm/conversation_logger.py +473 -0
- core/llm/conversation_manager.py +414 -0
- core/llm/file_operations_executor.py +1401 -0
- core/llm/hook_system.py +402 -0
- core/llm/llm_service.py +1629 -0
- core/llm/mcp_integration.py +386 -0
- core/llm/message_display_service.py +450 -0
- core/llm/model_router.py +214 -0
- core/llm/plugin_sdk.py +396 -0
- core/llm/response_parser.py +848 -0
- core/llm/response_processor.py +364 -0
- core/llm/tool_executor.py +520 -0
- core/logging/__init__.py +19 -0
- core/logging/setup.py +208 -0
- core/models/__init__.py +5 -0
- core/models/base.py +23 -0
- core/plugins/__init__.py +13 -0
- core/plugins/collector.py +212 -0
- core/plugins/discovery.py +386 -0
- core/plugins/factory.py +263 -0
- core/plugins/registry.py +152 -0
- core/storage/__init__.py +5 -0
- core/storage/state_manager.py +84 -0
- core/ui/__init__.py +6 -0
- core/ui/config_merger.py +176 -0
- core/ui/config_widgets.py +369 -0
- core/ui/live_modal_renderer.py +276 -0
- core/ui/modal_actions.py +162 -0
- core/ui/modal_overlay_renderer.py +373 -0
- core/ui/modal_renderer.py +591 -0
- core/ui/modal_state_manager.py +443 -0
- core/ui/widget_integration.py +222 -0
- core/ui/widgets/__init__.py +27 -0
- core/ui/widgets/base_widget.py +136 -0
- core/ui/widgets/checkbox.py +85 -0
- core/ui/widgets/dropdown.py +140 -0
- core/ui/widgets/label.py +78 -0
- core/ui/widgets/slider.py +185 -0
- core/ui/widgets/text_input.py +224 -0
- core/utils/__init__.py +11 -0
- core/utils/config_utils.py +656 -0
- core/utils/dict_utils.py +212 -0
- core/utils/error_utils.py +275 -0
- core/utils/key_reader.py +171 -0
- core/utils/plugin_utils.py +267 -0
- core/utils/prompt_renderer.py +151 -0
- kollabor-0.4.9.dist-info/METADATA +298 -0
- kollabor-0.4.9.dist-info/RECORD +128 -0
- kollabor-0.4.9.dist-info/WHEEL +5 -0
- kollabor-0.4.9.dist-info/entry_points.txt +2 -0
- kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
- kollabor-0.4.9.dist-info/top_level.txt +4 -0
- kollabor_cli_main.py +20 -0
- plugins/__init__.py +1 -0
- plugins/enhanced_input/__init__.py +18 -0
- plugins/enhanced_input/box_renderer.py +103 -0
- plugins/enhanced_input/box_styles.py +142 -0
- plugins/enhanced_input/color_engine.py +165 -0
- plugins/enhanced_input/config.py +150 -0
- plugins/enhanced_input/cursor_manager.py +72 -0
- plugins/enhanced_input/geometry.py +81 -0
- plugins/enhanced_input/state.py +130 -0
- plugins/enhanced_input/text_processor.py +115 -0
- plugins/enhanced_input_plugin.py +385 -0
- plugins/fullscreen/__init__.py +9 -0
- plugins/fullscreen/example_plugin.py +327 -0
- plugins/fullscreen/matrix_plugin.py +132 -0
- plugins/hook_monitoring_plugin.py +1299 -0
- plugins/query_enhancer_plugin.py +350 -0
- plugins/save_conversation_plugin.py +502 -0
- plugins/system_commands_plugin.py +93 -0
- plugins/tmux_plugin.py +795 -0
- plugins/workflow_enforcement_plugin.py +629 -0
- system_prompt/default.md +1286 -0
- system_prompt/default_win.md +265 -0
- system_prompt/example_with_trender.md +47 -0
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
"""File operations executor for LLM-driven file manipulation.
|
|
2
|
+
|
|
3
|
+
Provides safe file operations with automatic backups, validation, and
|
|
4
|
+
comprehensive error handling. Implements 11 file operation types:
|
|
5
|
+
- edit: Find/replace in files (replaces ALL occurrences)
|
|
6
|
+
- create: Create new files
|
|
7
|
+
- create_overwrite: Create/overwrite files
|
|
8
|
+
- delete: Delete files with safety checks
|
|
9
|
+
- move: Move/rename files
|
|
10
|
+
- copy: Copy files
|
|
11
|
+
- copy_overwrite: Copy with overwrite
|
|
12
|
+
- append: Append to files
|
|
13
|
+
- insert_after: Insert content after pattern (exact match required)
|
|
14
|
+
- insert_before: Insert content before pattern (exact match required)
|
|
15
|
+
- mkdir: Create directories
|
|
16
|
+
- rmdir: Remove empty directories
|
|
17
|
+
- read: Read file content
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileOperationsExecutor:
|
|
31
|
+
"""Execute file operations with comprehensive safety features.
|
|
32
|
+
|
|
33
|
+
Features:
|
|
34
|
+
- Automatic backups before destructive operations
|
|
35
|
+
- Protected path checking
|
|
36
|
+
- Path traversal prevention
|
|
37
|
+
- Binary file detection
|
|
38
|
+
- Optional Python syntax validation
|
|
39
|
+
- File size limits
|
|
40
|
+
- Multi-occurrence handling (edit vs insert operations)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config=None):
|
|
44
|
+
"""Initialize file operations executor.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: Configuration manager (optional)
|
|
48
|
+
"""
|
|
49
|
+
self.config = config
|
|
50
|
+
|
|
51
|
+
# Default configuration values
|
|
52
|
+
self.enabled = self._get_config("file_operations.enabled", True)
|
|
53
|
+
self.automatic_backups = self._get_config("file_operations.automatic_backups", True)
|
|
54
|
+
self.validate_python_syntax = self._get_config("file_operations.validate_python_syntax", True)
|
|
55
|
+
self.rollback_on_syntax_error = self._get_config("file_operations.rollback_on_syntax_error", True)
|
|
56
|
+
self.max_edit_size_mb = self._get_config("file_operations.max_edit_size_mb", 10)
|
|
57
|
+
self.max_create_size_mb = self._get_config("file_operations.max_create_size_mb", 5)
|
|
58
|
+
self.max_read_size_mb = self._get_config("file_operations.max_read_size_mb", 10)
|
|
59
|
+
self.create_parent_directories = self._get_config("file_operations.create_parent_directories", True)
|
|
60
|
+
self.allow_binary_operations = self._get_config("file_operations.allow_binary_operations", False)
|
|
61
|
+
|
|
62
|
+
# Protected paths (cannot delete/modify)
|
|
63
|
+
self.protected_files = self._get_config("file_operations.protected_files", [
|
|
64
|
+
"core/application.py",
|
|
65
|
+
"main.py"
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
self.protected_patterns = self._get_config("file_operations.protected_patterns", [
|
|
69
|
+
".git/**",
|
|
70
|
+
"venv/**",
|
|
71
|
+
".venv/**",
|
|
72
|
+
"node_modules/**"
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
logger.info(f"File operations executor initialized (enabled={self.enabled})")
|
|
76
|
+
|
|
77
|
+
def _get_config(self, key: str, default: Any) -> Any:
|
|
78
|
+
"""Get configuration value with fallback.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key: Config key in dot notation
|
|
82
|
+
default: Default value if not found
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Configuration value or default
|
|
86
|
+
"""
|
|
87
|
+
if self.config:
|
|
88
|
+
return self.config.get(key, default)
|
|
89
|
+
return default
|
|
90
|
+
|
|
91
|
+
def validate_file_path(self, filepath: str) -> Tuple[bool, str]:
|
|
92
|
+
"""Validate file path for security and correctness.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
filepath: File path to validate
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
(is_valid, error_message)
|
|
99
|
+
"""
|
|
100
|
+
if not filepath:
|
|
101
|
+
return False, "Empty file path"
|
|
102
|
+
|
|
103
|
+
# Security: No path traversal
|
|
104
|
+
if ".." in filepath:
|
|
105
|
+
return False, f"Path traversal detected: {filepath}"
|
|
106
|
+
|
|
107
|
+
# Security: No absolute paths (relative paths only)
|
|
108
|
+
if filepath.startswith("/") or (len(filepath) > 1 and filepath[1] == ":"):
|
|
109
|
+
return False, f"Absolute paths not allowed: {filepath}"
|
|
110
|
+
|
|
111
|
+
# Practical: Path length limit
|
|
112
|
+
if len(filepath) > 255:
|
|
113
|
+
return False, f"Path too long: {len(filepath)} chars (max 255)"
|
|
114
|
+
|
|
115
|
+
# Security: No null bytes
|
|
116
|
+
if "\x00" in filepath:
|
|
117
|
+
return False, "Null byte in file path"
|
|
118
|
+
|
|
119
|
+
return True, ""
|
|
120
|
+
|
|
121
|
+
def is_protected_path(self, filepath: str) -> bool:
|
|
122
|
+
"""Check if file path is protected from deletion/modification.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
filepath: File path to check
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if path is protected
|
|
129
|
+
"""
|
|
130
|
+
# Check exact matches
|
|
131
|
+
if filepath in self.protected_files:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# Check pattern matches
|
|
135
|
+
path_obj = Path(filepath)
|
|
136
|
+
for pattern in self.protected_patterns:
|
|
137
|
+
# Simple wildcard matching
|
|
138
|
+
if pattern.endswith("/**"):
|
|
139
|
+
prefix = pattern[:-3]
|
|
140
|
+
if str(path_obj).startswith(prefix):
|
|
141
|
+
return True
|
|
142
|
+
elif pattern.endswith("/*"):
|
|
143
|
+
prefix = pattern[:-2]
|
|
144
|
+
if str(path_obj.parent).startswith(prefix):
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
def is_text_file(self, filepath: str) -> bool:
|
|
150
|
+
"""Check if file is text (not binary).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
filepath: File path to check
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if file is text
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
160
|
+
f.read(1024) # Try reading first 1KB as text
|
|
161
|
+
return True
|
|
162
|
+
except (UnicodeDecodeError, FileNotFoundError):
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def check_file_size(self, filepath: str, max_size_mb: float) -> Tuple[bool, str]:
|
|
166
|
+
"""Check if file size is within limits.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
filepath: File path to check
|
|
170
|
+
max_size_mb: Maximum size in MB
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
(is_valid, error_message)
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
size_bytes = os.path.getsize(filepath)
|
|
177
|
+
size_mb = size_bytes / (1024 * 1024)
|
|
178
|
+
|
|
179
|
+
if size_mb > max_size_mb:
|
|
180
|
+
return False, f"File too large: {size_mb:.1f}MB (max {max_size_mb}MB)"
|
|
181
|
+
|
|
182
|
+
return True, ""
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
return True, "" # File doesn't exist yet, allow operation
|
|
185
|
+
|
|
186
|
+
def create_backup(self, filepath: str, suffix: str = ".bak") -> Optional[str]:
|
|
187
|
+
"""Create backup of file before modification.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
filepath: File to backup
|
|
191
|
+
suffix: Backup file suffix
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Backup file path or None if failed
|
|
195
|
+
"""
|
|
196
|
+
if not self.automatic_backups:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
if not os.path.exists(filepath):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
backup_path = f"{filepath}{suffix}"
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
shutil.copy2(filepath, backup_path)
|
|
206
|
+
logger.debug(f"Created backup: {backup_path}")
|
|
207
|
+
return backup_path
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to create backup: {e}")
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def validate_python_syntax_file(self, filepath: str) -> Tuple[bool, str]:
|
|
213
|
+
"""Validate Python file syntax.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
filepath: Python file to validate
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
(is_valid, error_message)
|
|
220
|
+
"""
|
|
221
|
+
if not self.validate_python_syntax:
|
|
222
|
+
return True, ""
|
|
223
|
+
|
|
224
|
+
if not filepath.endswith('.py'):
|
|
225
|
+
return True, ""
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
with open(filepath, 'r') as f:
|
|
229
|
+
content = f.read()
|
|
230
|
+
ast.parse(content)
|
|
231
|
+
return True, ""
|
|
232
|
+
except SyntaxError as e:
|
|
233
|
+
return False, f"Syntax error at line {e.lineno}: {e.msg}"
|
|
234
|
+
except Exception as e:
|
|
235
|
+
return False, f"Validation error: {str(e)}"
|
|
236
|
+
|
|
237
|
+
def find_pattern_occurrences(self, content: str, pattern: str) -> List[int]:
|
|
238
|
+
"""Find all line numbers where pattern occurs.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
content: File content
|
|
242
|
+
pattern: Pattern to search for
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of line numbers (1-indexed) where pattern appears
|
|
246
|
+
"""
|
|
247
|
+
lines = content.split('\n')
|
|
248
|
+
occurrences = []
|
|
249
|
+
|
|
250
|
+
# For multi-line patterns
|
|
251
|
+
pattern_lines = pattern.split('\n')
|
|
252
|
+
pattern_len = len(pattern_lines)
|
|
253
|
+
|
|
254
|
+
for i in range(len(lines) - pattern_len + 1):
|
|
255
|
+
# Check if pattern matches starting at line i
|
|
256
|
+
matches = True
|
|
257
|
+
for j in range(pattern_len):
|
|
258
|
+
if pattern_lines[j] != lines[i + j]:
|
|
259
|
+
matches = False
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
if matches:
|
|
263
|
+
occurrences.append(i + 1) # 1-indexed
|
|
264
|
+
|
|
265
|
+
# Also try simple substring search if no multi-line matches
|
|
266
|
+
if not occurrences and pattern in content:
|
|
267
|
+
for i, line in enumerate(lines, 1):
|
|
268
|
+
if pattern in line:
|
|
269
|
+
occurrences.append(i)
|
|
270
|
+
|
|
271
|
+
return occurrences
|
|
272
|
+
|
|
273
|
+
def execute_operation(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
274
|
+
"""Execute a file operation.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
operation: Operation dictionary from parser
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Result dictionary with success, output, error
|
|
281
|
+
"""
|
|
282
|
+
if not self.enabled:
|
|
283
|
+
return {
|
|
284
|
+
"success": False,
|
|
285
|
+
"error": "File operations are disabled in configuration"
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
op_type = operation.get("type", "unknown")
|
|
289
|
+
op_id = operation.get("id", "unknown")
|
|
290
|
+
|
|
291
|
+
logger.info(f"Executing file operation: {op_type} ({op_id})")
|
|
292
|
+
|
|
293
|
+
# Malformed operations - provide helpful error message
|
|
294
|
+
if op_type == "malformed_file_op":
|
|
295
|
+
op_name = operation.get('operation', 'unknown')
|
|
296
|
+
error_msg = operation.get('error', 'Unknown error')
|
|
297
|
+
expected = operation.get('expected_format', '')
|
|
298
|
+
preview = operation.get('content_preview', '')
|
|
299
|
+
|
|
300
|
+
error_lines = [
|
|
301
|
+
f"Malformed <{op_name}> operation: {error_msg}",
|
|
302
|
+
"",
|
|
303
|
+
"Expected format:",
|
|
304
|
+
expected,
|
|
305
|
+
]
|
|
306
|
+
if preview:
|
|
307
|
+
error_lines.extend([
|
|
308
|
+
"",
|
|
309
|
+
"Received:",
|
|
310
|
+
preview[:200] + "..." if len(preview) > 200 else preview
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
"success": False,
|
|
315
|
+
"error": "\n".join(error_lines)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Route to specific operation handler
|
|
319
|
+
handlers = {
|
|
320
|
+
"file_edit": self._execute_edit,
|
|
321
|
+
"file_create": self._execute_create,
|
|
322
|
+
"file_create_overwrite": self._execute_create_overwrite,
|
|
323
|
+
"file_delete": self._execute_delete,
|
|
324
|
+
"file_move": self._execute_move,
|
|
325
|
+
"file_copy": self._execute_copy,
|
|
326
|
+
"file_copy_overwrite": self._execute_copy_overwrite,
|
|
327
|
+
"file_append": self._execute_append,
|
|
328
|
+
"file_insert_after": self._execute_insert_after,
|
|
329
|
+
"file_insert_before": self._execute_insert_before,
|
|
330
|
+
"file_mkdir": self._execute_mkdir,
|
|
331
|
+
"file_rmdir": self._execute_rmdir,
|
|
332
|
+
"file_read": self._execute_read,
|
|
333
|
+
"file_grep": self._execute_grep
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
handler = handlers.get(op_type)
|
|
337
|
+
|
|
338
|
+
if not handler:
|
|
339
|
+
return {
|
|
340
|
+
"success": False,
|
|
341
|
+
"error": f"Unknown operation type: {op_type}"
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
return handler(operation)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
import traceback
|
|
348
|
+
error_trace = traceback.format_exc()
|
|
349
|
+
logger.error(f"Operation {op_type} failed: {error_trace}")
|
|
350
|
+
return {
|
|
351
|
+
"success": False,
|
|
352
|
+
"error": f"Operation failed: {str(e)}"
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def _execute_edit(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
356
|
+
"""Execute file edit operation (find/replace).
|
|
357
|
+
|
|
358
|
+
Behavior: Replaces ALL occurrences, reports count.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
operation: Operation data
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Result dictionary
|
|
365
|
+
"""
|
|
366
|
+
filepath = operation.get("file")
|
|
367
|
+
find_content = operation.get("find")
|
|
368
|
+
replace_content = operation.get("replace")
|
|
369
|
+
|
|
370
|
+
# Validation
|
|
371
|
+
if not filepath or not find_content or replace_content is None:
|
|
372
|
+
return {
|
|
373
|
+
"success": False,
|
|
374
|
+
"error": "Missing required fields: file, find, replace"
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
378
|
+
if not is_valid:
|
|
379
|
+
return {"success": False, "error": error}
|
|
380
|
+
|
|
381
|
+
if not os.path.exists(filepath):
|
|
382
|
+
return {
|
|
383
|
+
"success": False,
|
|
384
|
+
"error": f"File not found: {filepath}"
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# Check file size
|
|
388
|
+
is_valid, error = self.check_file_size(filepath, self.max_edit_size_mb)
|
|
389
|
+
if not is_valid:
|
|
390
|
+
return {"success": False, "error": error}
|
|
391
|
+
|
|
392
|
+
# Check if text file
|
|
393
|
+
if not self.is_text_file(filepath):
|
|
394
|
+
return {
|
|
395
|
+
"success": False,
|
|
396
|
+
"error": f"Cannot edit binary file: {filepath}"
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# Read file
|
|
400
|
+
try:
|
|
401
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
402
|
+
content = f.read()
|
|
403
|
+
except Exception as e:
|
|
404
|
+
return {
|
|
405
|
+
"success": False,
|
|
406
|
+
"error": f"Failed to read file: {str(e)}"
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
# Check pattern exists
|
|
410
|
+
count = content.count(find_content)
|
|
411
|
+
if count == 0:
|
|
412
|
+
return {
|
|
413
|
+
"success": False,
|
|
414
|
+
"error": f"Pattern not found in {filepath}"
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Find line numbers for reporting
|
|
418
|
+
line_numbers = self.find_pattern_occurrences(content, find_content)
|
|
419
|
+
|
|
420
|
+
# Create backup
|
|
421
|
+
backup_path = self.create_backup(filepath)
|
|
422
|
+
|
|
423
|
+
# Perform replacement (REPLACE ALL)
|
|
424
|
+
new_content = content.replace(find_content, replace_content)
|
|
425
|
+
|
|
426
|
+
# Write back
|
|
427
|
+
try:
|
|
428
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
429
|
+
f.write(new_content)
|
|
430
|
+
except Exception as e:
|
|
431
|
+
# Restore from backup if write fails
|
|
432
|
+
if backup_path and os.path.exists(backup_path):
|
|
433
|
+
shutil.copy2(backup_path, filepath)
|
|
434
|
+
return {
|
|
435
|
+
"success": False,
|
|
436
|
+
"error": f"Failed to write file: {str(e)}"
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
# Optional: Validate Python syntax
|
|
440
|
+
if filepath.endswith('.py') and self.validate_python_syntax:
|
|
441
|
+
is_valid, error = self.validate_python_syntax_file(filepath)
|
|
442
|
+
if not is_valid and self.rollback_on_syntax_error:
|
|
443
|
+
# Rollback
|
|
444
|
+
if backup_path and os.path.exists(backup_path):
|
|
445
|
+
shutil.copy2(backup_path, filepath)
|
|
446
|
+
return {
|
|
447
|
+
"success": False,
|
|
448
|
+
"error": f"Syntax validation failed: {error}. Edit rolled back."
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# Build success message with diff info
|
|
452
|
+
if count == 1:
|
|
453
|
+
output = f"✓ Replaced 1 occurrence in {filepath}"
|
|
454
|
+
else:
|
|
455
|
+
lines_str = ", ".join(str(ln) for ln in line_numbers[:10])
|
|
456
|
+
if len(line_numbers) > 10:
|
|
457
|
+
lines_str += f" (+{len(line_numbers) - 10} more)"
|
|
458
|
+
output = f"✓ Replaced {count} occurrences in {filepath}\nLocations: lines {lines_str}"
|
|
459
|
+
|
|
460
|
+
if backup_path:
|
|
461
|
+
output += f"\nBackup: {backup_path}"
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
"success": True,
|
|
465
|
+
"output": output,
|
|
466
|
+
"diff_info": {
|
|
467
|
+
"find": find_content,
|
|
468
|
+
"replace": replace_content,
|
|
469
|
+
"count": count,
|
|
470
|
+
"lines": line_numbers[:5] # First 5 line numbers for context
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
def _execute_create(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
475
|
+
"""Execute file create operation.
|
|
476
|
+
|
|
477
|
+
Fails if file already exists.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
operation: Operation data
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Result dictionary
|
|
484
|
+
"""
|
|
485
|
+
filepath = operation.get("file")
|
|
486
|
+
content = operation.get("content", "")
|
|
487
|
+
|
|
488
|
+
# Validation
|
|
489
|
+
if not filepath:
|
|
490
|
+
return {"success": False, "error": "Missing file path"}
|
|
491
|
+
|
|
492
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
493
|
+
if not is_valid:
|
|
494
|
+
return {"success": False, "error": error}
|
|
495
|
+
|
|
496
|
+
if os.path.exists(filepath):
|
|
497
|
+
return {
|
|
498
|
+
"success": False,
|
|
499
|
+
"error": f"File already exists: {filepath}. Use <edit> to modify or <create_overwrite> to replace."
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# Check content size
|
|
503
|
+
size_mb = len(content.encode('utf-8')) / (1024 * 1024)
|
|
504
|
+
if size_mb > self.max_create_size_mb:
|
|
505
|
+
return {
|
|
506
|
+
"success": False,
|
|
507
|
+
"error": f"Content too large: {size_mb:.1f}MB (max {self.max_create_size_mb}MB)"
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# Create parent directories if needed
|
|
511
|
+
parent_dir = os.path.dirname(filepath)
|
|
512
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
513
|
+
if self.create_parent_directories:
|
|
514
|
+
try:
|
|
515
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
516
|
+
logger.debug(f"Created parent directories: {parent_dir}")
|
|
517
|
+
except Exception as e:
|
|
518
|
+
return {
|
|
519
|
+
"success": False,
|
|
520
|
+
"error": f"Failed to create parent directories: {str(e)}"
|
|
521
|
+
}
|
|
522
|
+
else:
|
|
523
|
+
return {
|
|
524
|
+
"success": False,
|
|
525
|
+
"error": f"Parent directory does not exist: {parent_dir}"
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
# Write file
|
|
529
|
+
try:
|
|
530
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
531
|
+
f.write(content)
|
|
532
|
+
|
|
533
|
+
# Set permissions (644 = rw-r--r--)
|
|
534
|
+
os.chmod(filepath, 0o644)
|
|
535
|
+
|
|
536
|
+
size_bytes = len(content.encode('utf-8'))
|
|
537
|
+
return {
|
|
538
|
+
"success": True,
|
|
539
|
+
"output": f"✓ Created {filepath} ({size_bytes} bytes)"
|
|
540
|
+
}
|
|
541
|
+
except Exception as e:
|
|
542
|
+
return {
|
|
543
|
+
"success": False,
|
|
544
|
+
"error": f"Failed to create file: {str(e)}"
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def _execute_create_overwrite(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
548
|
+
"""Execute file create with overwrite.
|
|
549
|
+
|
|
550
|
+
Creates backup if file exists.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
operation: Operation data
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
Result dictionary
|
|
557
|
+
"""
|
|
558
|
+
filepath = operation.get("file")
|
|
559
|
+
content = operation.get("content", "")
|
|
560
|
+
|
|
561
|
+
# Validation
|
|
562
|
+
if not filepath:
|
|
563
|
+
return {"success": False, "error": "Missing file path"}
|
|
564
|
+
|
|
565
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
566
|
+
if not is_valid:
|
|
567
|
+
return {"success": False, "error": error}
|
|
568
|
+
|
|
569
|
+
# Create backup if file exists
|
|
570
|
+
backup_path = None
|
|
571
|
+
if os.path.exists(filepath):
|
|
572
|
+
backup_path = self.create_backup(filepath)
|
|
573
|
+
|
|
574
|
+
# Check content size
|
|
575
|
+
size_mb = len(content.encode('utf-8')) / (1024 * 1024)
|
|
576
|
+
if size_mb > self.max_create_size_mb:
|
|
577
|
+
return {
|
|
578
|
+
"success": False,
|
|
579
|
+
"error": f"Content too large: {size_mb:.1f}MB (max {self.max_create_size_mb}MB)"
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
# Create parent directories if needed
|
|
583
|
+
parent_dir = os.path.dirname(filepath)
|
|
584
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
585
|
+
if self.create_parent_directories:
|
|
586
|
+
try:
|
|
587
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
588
|
+
except Exception as e:
|
|
589
|
+
return {
|
|
590
|
+
"success": False,
|
|
591
|
+
"error": f"Failed to create parent directories: {str(e)}"
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
# Write file
|
|
595
|
+
try:
|
|
596
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
597
|
+
f.write(content)
|
|
598
|
+
|
|
599
|
+
os.chmod(filepath, 0o644)
|
|
600
|
+
|
|
601
|
+
size_bytes = len(content.encode('utf-8'))
|
|
602
|
+
output = f"✓ Created/overwrote {filepath} ({size_bytes} bytes)"
|
|
603
|
+
if backup_path:
|
|
604
|
+
output += f"\nBackup: {backup_path}"
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
"success": True,
|
|
608
|
+
"output": output
|
|
609
|
+
}
|
|
610
|
+
except Exception as e:
|
|
611
|
+
# Restore from backup if failed
|
|
612
|
+
if backup_path and os.path.exists(backup_path):
|
|
613
|
+
shutil.copy2(backup_path, filepath)
|
|
614
|
+
return {
|
|
615
|
+
"success": False,
|
|
616
|
+
"error": f"Failed to write file: {str(e)}"
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
def _execute_delete(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
620
|
+
"""Execute file delete operation.
|
|
621
|
+
|
|
622
|
+
Creates backup before deletion.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
operation: Operation data
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
Result dictionary
|
|
629
|
+
"""
|
|
630
|
+
filepath = operation.get("file")
|
|
631
|
+
|
|
632
|
+
# Validation
|
|
633
|
+
if not filepath:
|
|
634
|
+
return {"success": False, "error": "Missing file path"}
|
|
635
|
+
|
|
636
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
637
|
+
if not is_valid:
|
|
638
|
+
return {"success": False, "error": error}
|
|
639
|
+
|
|
640
|
+
if not os.path.exists(filepath):
|
|
641
|
+
return {
|
|
642
|
+
"success": False,
|
|
643
|
+
"error": f"File not found: {filepath}"
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# Check protected paths
|
|
647
|
+
if self.is_protected_path(filepath):
|
|
648
|
+
return {
|
|
649
|
+
"success": False,
|
|
650
|
+
"error": f"Cannot delete protected file: {filepath}"
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
# Create backup with .deleted suffix
|
|
654
|
+
backup_path = self.create_backup(filepath, suffix=".deleted")
|
|
655
|
+
|
|
656
|
+
# Delete file
|
|
657
|
+
try:
|
|
658
|
+
os.remove(filepath)
|
|
659
|
+
|
|
660
|
+
output = f"✓ Deleted {filepath}"
|
|
661
|
+
if backup_path:
|
|
662
|
+
output += f"\nBackup: {backup_path}"
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
"success": True,
|
|
666
|
+
"output": output
|
|
667
|
+
}
|
|
668
|
+
except Exception as e:
|
|
669
|
+
return {
|
|
670
|
+
"success": False,
|
|
671
|
+
"error": f"Failed to delete file: {str(e)}"
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
def _execute_move(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
675
|
+
"""Execute file move operation.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
operation: Operation data
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
Result dictionary
|
|
682
|
+
"""
|
|
683
|
+
from_path = operation.get("from")
|
|
684
|
+
to_path = operation.get("to")
|
|
685
|
+
|
|
686
|
+
# Validation
|
|
687
|
+
if not from_path or not to_path:
|
|
688
|
+
return {"success": False, "error": "Missing from/to paths"}
|
|
689
|
+
|
|
690
|
+
is_valid, error = self.validate_file_path(from_path)
|
|
691
|
+
if not is_valid:
|
|
692
|
+
return {"success": False, "error": f"Source: {error}"}
|
|
693
|
+
|
|
694
|
+
is_valid, error = self.validate_file_path(to_path)
|
|
695
|
+
if not is_valid:
|
|
696
|
+
return {"success": False, "error": f"Destination: {error}"}
|
|
697
|
+
|
|
698
|
+
if not os.path.exists(from_path):
|
|
699
|
+
return {
|
|
700
|
+
"success": False,
|
|
701
|
+
"error": f"Source file not found: {from_path}"
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if os.path.exists(to_path):
|
|
705
|
+
return {
|
|
706
|
+
"success": False,
|
|
707
|
+
"error": f"Destination already exists: {to_path}. Delete it first or choose different destination."
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if from_path == to_path:
|
|
711
|
+
return {
|
|
712
|
+
"success": False,
|
|
713
|
+
"error": f"Source and destination are the same: {from_path}"
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
# Create parent directories for destination if needed
|
|
717
|
+
parent_dir = os.path.dirname(to_path)
|
|
718
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
719
|
+
if self.create_parent_directories:
|
|
720
|
+
try:
|
|
721
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
722
|
+
except Exception as e:
|
|
723
|
+
return {
|
|
724
|
+
"success": False,
|
|
725
|
+
"error": f"Failed to create destination directory: {str(e)}"
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# Move file
|
|
729
|
+
try:
|
|
730
|
+
shutil.move(from_path, to_path)
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
"success": True,
|
|
734
|
+
"output": f"✓ Moved {from_path} → {to_path}"
|
|
735
|
+
}
|
|
736
|
+
except Exception as e:
|
|
737
|
+
return {
|
|
738
|
+
"success": False,
|
|
739
|
+
"error": f"Failed to move file: {str(e)}"
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
def _execute_copy(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
743
|
+
"""Execute file copy operation.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
operation: Operation data
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
Result dictionary
|
|
750
|
+
"""
|
|
751
|
+
from_path = operation.get("from")
|
|
752
|
+
to_path = operation.get("to")
|
|
753
|
+
|
|
754
|
+
# Validation
|
|
755
|
+
if not from_path or not to_path:
|
|
756
|
+
return {"success": False, "error": "Missing from/to paths"}
|
|
757
|
+
|
|
758
|
+
is_valid, error = self.validate_file_path(from_path)
|
|
759
|
+
if not is_valid:
|
|
760
|
+
return {"success": False, "error": f"Source: {error}"}
|
|
761
|
+
|
|
762
|
+
is_valid, error = self.validate_file_path(to_path)
|
|
763
|
+
if not is_valid:
|
|
764
|
+
return {"success": False, "error": f"Destination: {error}"}
|
|
765
|
+
|
|
766
|
+
if not os.path.exists(from_path):
|
|
767
|
+
return {
|
|
768
|
+
"success": False,
|
|
769
|
+
"error": f"Source file not found: {from_path}"
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if os.path.exists(to_path):
|
|
773
|
+
return {
|
|
774
|
+
"success": False,
|
|
775
|
+
"error": f"Destination already exists: {to_path}. Use <copy_overwrite> to replace."
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
# Create parent directories for destination if needed
|
|
779
|
+
parent_dir = os.path.dirname(to_path)
|
|
780
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
781
|
+
if self.create_parent_directories:
|
|
782
|
+
try:
|
|
783
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
return {
|
|
786
|
+
"success": False,
|
|
787
|
+
"error": f"Failed to create destination directory: {str(e)}"
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
# Copy file with metadata
|
|
791
|
+
try:
|
|
792
|
+
shutil.copy2(from_path, to_path)
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
"success": True,
|
|
796
|
+
"output": f"✓ Copied {from_path} → {to_path}"
|
|
797
|
+
}
|
|
798
|
+
except Exception as e:
|
|
799
|
+
return {
|
|
800
|
+
"success": False,
|
|
801
|
+
"error": f"Failed to copy file: {str(e)}"
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
def _execute_copy_overwrite(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
805
|
+
"""Execute file copy with overwrite.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
operation: Operation data
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
Result dictionary
|
|
812
|
+
"""
|
|
813
|
+
from_path = operation.get("from")
|
|
814
|
+
to_path = operation.get("to")
|
|
815
|
+
|
|
816
|
+
# Validation
|
|
817
|
+
if not from_path or not to_path:
|
|
818
|
+
return {"success": False, "error": "Missing from/to paths"}
|
|
819
|
+
|
|
820
|
+
is_valid, error = self.validate_file_path(from_path)
|
|
821
|
+
if not is_valid:
|
|
822
|
+
return {"success": False, "error": f"Source: {error}"}
|
|
823
|
+
|
|
824
|
+
is_valid, error = self.validate_file_path(to_path)
|
|
825
|
+
if not is_valid:
|
|
826
|
+
return {"success": False, "error": f"Destination: {error}"}
|
|
827
|
+
|
|
828
|
+
if not os.path.exists(from_path):
|
|
829
|
+
return {
|
|
830
|
+
"success": False,
|
|
831
|
+
"error": f"Source file not found: {from_path}"
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Create backup if destination exists
|
|
835
|
+
backup_path = None
|
|
836
|
+
if os.path.exists(to_path):
|
|
837
|
+
backup_path = self.create_backup(to_path)
|
|
838
|
+
|
|
839
|
+
# Create parent directories for destination if needed
|
|
840
|
+
parent_dir = os.path.dirname(to_path)
|
|
841
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
842
|
+
if self.create_parent_directories:
|
|
843
|
+
try:
|
|
844
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
845
|
+
except Exception as e:
|
|
846
|
+
return {
|
|
847
|
+
"success": False,
|
|
848
|
+
"error": f"Failed to create destination directory: {str(e)}"
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
# Copy file with metadata
|
|
852
|
+
try:
|
|
853
|
+
shutil.copy2(from_path, to_path)
|
|
854
|
+
|
|
855
|
+
output = f"✓ Copied {from_path} → {to_path}"
|
|
856
|
+
if backup_path:
|
|
857
|
+
output += f"\nBackup: {backup_path}"
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
"success": True,
|
|
861
|
+
"output": output
|
|
862
|
+
}
|
|
863
|
+
except Exception as e:
|
|
864
|
+
# Restore backup if failed
|
|
865
|
+
if backup_path and os.path.exists(backup_path):
|
|
866
|
+
shutil.copy2(backup_path, to_path)
|
|
867
|
+
return {
|
|
868
|
+
"success": False,
|
|
869
|
+
"error": f"Failed to copy file: {str(e)}"
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
def _execute_append(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
873
|
+
"""Execute file append operation.
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
operation: Operation data
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
Result dictionary
|
|
880
|
+
"""
|
|
881
|
+
filepath = operation.get("file")
|
|
882
|
+
content = operation.get("content", "")
|
|
883
|
+
|
|
884
|
+
# Validation
|
|
885
|
+
if not filepath:
|
|
886
|
+
return {"success": False, "error": "Missing file path"}
|
|
887
|
+
|
|
888
|
+
if not content:
|
|
889
|
+
return {"success": False, "error": "Empty content"}
|
|
890
|
+
|
|
891
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
892
|
+
if not is_valid:
|
|
893
|
+
return {"success": False, "error": error}
|
|
894
|
+
|
|
895
|
+
if not os.path.exists(filepath):
|
|
896
|
+
return {
|
|
897
|
+
"success": False,
|
|
898
|
+
"error": f"File not found: {filepath}"
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
# Check if text file
|
|
902
|
+
if not self.is_text_file(filepath):
|
|
903
|
+
return {
|
|
904
|
+
"success": False,
|
|
905
|
+
"error": f"Cannot append to binary file: {filepath}"
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
# Create backup
|
|
909
|
+
backup_path = self.create_backup(filepath)
|
|
910
|
+
|
|
911
|
+
# Append content
|
|
912
|
+
try:
|
|
913
|
+
with open(filepath, 'a', encoding='utf-8') as f:
|
|
914
|
+
f.write(content)
|
|
915
|
+
|
|
916
|
+
output = f"✓ Appended content to {filepath}"
|
|
917
|
+
if backup_path:
|
|
918
|
+
output += f"\nBackup: {backup_path}"
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
"success": True,
|
|
922
|
+
"output": output
|
|
923
|
+
}
|
|
924
|
+
except Exception as e:
|
|
925
|
+
# Restore backup if failed
|
|
926
|
+
if backup_path and os.path.exists(backup_path):
|
|
927
|
+
shutil.copy2(backup_path, filepath)
|
|
928
|
+
return {
|
|
929
|
+
"success": False,
|
|
930
|
+
"error": f"Failed to append to file: {str(e)}"
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
def _execute_insert_after(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
934
|
+
"""Execute insert after pattern operation.
|
|
935
|
+
|
|
936
|
+
Pattern must appear exactly once (fails on 0 or 2+ matches).
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
operation: Operation data
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
Result dictionary
|
|
943
|
+
"""
|
|
944
|
+
filepath = operation.get("file")
|
|
945
|
+
pattern = operation.get("pattern")
|
|
946
|
+
content = operation.get("content", "")
|
|
947
|
+
|
|
948
|
+
# Validation
|
|
949
|
+
if not filepath or not pattern:
|
|
950
|
+
return {"success": False, "error": "Missing file or pattern"}
|
|
951
|
+
|
|
952
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
953
|
+
if not is_valid:
|
|
954
|
+
return {"success": False, "error": error}
|
|
955
|
+
|
|
956
|
+
if not os.path.exists(filepath):
|
|
957
|
+
return {
|
|
958
|
+
"success": False,
|
|
959
|
+
"error": f"File not found: {filepath}"
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
# Check if text file
|
|
963
|
+
if not self.is_text_file(filepath):
|
|
964
|
+
return {
|
|
965
|
+
"success": False,
|
|
966
|
+
"error": f"Cannot insert into binary file: {filepath}"
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
# Read file
|
|
970
|
+
try:
|
|
971
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
972
|
+
file_content = f.read()
|
|
973
|
+
except Exception as e:
|
|
974
|
+
return {
|
|
975
|
+
"success": False,
|
|
976
|
+
"error": f"Failed to read file: {str(e)}"
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
# Find pattern occurrences
|
|
980
|
+
count = file_content.count(pattern)
|
|
981
|
+
line_numbers = self.find_pattern_occurrences(file_content, pattern)
|
|
982
|
+
|
|
983
|
+
# Validate exact match
|
|
984
|
+
if count == 0:
|
|
985
|
+
return {
|
|
986
|
+
"success": False,
|
|
987
|
+
"error": f"Pattern not found: '{pattern}'"
|
|
988
|
+
}
|
|
989
|
+
elif count > 1:
|
|
990
|
+
lines_str = ", ".join(str(ln) for ln in line_numbers[:10])
|
|
991
|
+
return {
|
|
992
|
+
"success": False,
|
|
993
|
+
"error": f"Ambiguous pattern: '{pattern}' appears {count} times at lines {lines_str}. "
|
|
994
|
+
f"Pattern must be unique for insert operations. Use <edit> with full context instead."
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
# Create backup
|
|
998
|
+
backup_path = self.create_backup(filepath)
|
|
999
|
+
|
|
1000
|
+
# Insert content after pattern
|
|
1001
|
+
new_content = file_content.replace(pattern, f"{pattern}\n{content}", 1)
|
|
1002
|
+
|
|
1003
|
+
# Write back
|
|
1004
|
+
try:
|
|
1005
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
1006
|
+
f.write(new_content)
|
|
1007
|
+
|
|
1008
|
+
output = f"✓ Inserted content after pattern in {filepath} (line {line_numbers[0]})"
|
|
1009
|
+
if backup_path:
|
|
1010
|
+
output += f"\nBackup: {backup_path}"
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
"success": True,
|
|
1014
|
+
"output": output
|
|
1015
|
+
}
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
# Restore backup if failed
|
|
1018
|
+
if backup_path and os.path.exists(backup_path):
|
|
1019
|
+
shutil.copy2(backup_path, filepath)
|
|
1020
|
+
return {
|
|
1021
|
+
"success": False,
|
|
1022
|
+
"error": f"Failed to write file: {str(e)}"
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
def _execute_insert_before(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1026
|
+
"""Execute insert before pattern operation.
|
|
1027
|
+
|
|
1028
|
+
Pattern must appear exactly once (fails on 0 or 2+ matches).
|
|
1029
|
+
|
|
1030
|
+
Args:
|
|
1031
|
+
operation: Operation data
|
|
1032
|
+
|
|
1033
|
+
Returns:
|
|
1034
|
+
Result dictionary
|
|
1035
|
+
"""
|
|
1036
|
+
filepath = operation.get("file")
|
|
1037
|
+
pattern = operation.get("pattern")
|
|
1038
|
+
content = operation.get("content", "")
|
|
1039
|
+
|
|
1040
|
+
# Validation
|
|
1041
|
+
if not filepath or not pattern:
|
|
1042
|
+
return {"success": False, "error": "Missing file or pattern"}
|
|
1043
|
+
|
|
1044
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
1045
|
+
if not is_valid:
|
|
1046
|
+
return {"success": False, "error": error}
|
|
1047
|
+
|
|
1048
|
+
if not os.path.exists(filepath):
|
|
1049
|
+
return {
|
|
1050
|
+
"success": False,
|
|
1051
|
+
"error": f"File not found: {filepath}"
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
# Check if text file
|
|
1055
|
+
if not self.is_text_file(filepath):
|
|
1056
|
+
return {
|
|
1057
|
+
"success": False,
|
|
1058
|
+
"error": f"Cannot insert into binary file: {filepath}"
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
# Read file
|
|
1062
|
+
try:
|
|
1063
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
1064
|
+
file_content = f.read()
|
|
1065
|
+
except Exception as e:
|
|
1066
|
+
return {
|
|
1067
|
+
"success": False,
|
|
1068
|
+
"error": f"Failed to read file: {str(e)}"
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
# Find pattern occurrences
|
|
1072
|
+
count = file_content.count(pattern)
|
|
1073
|
+
line_numbers = self.find_pattern_occurrences(file_content, pattern)
|
|
1074
|
+
|
|
1075
|
+
# Validate exact match
|
|
1076
|
+
if count == 0:
|
|
1077
|
+
return {
|
|
1078
|
+
"success": False,
|
|
1079
|
+
"error": f"Pattern not found: '{pattern}'"
|
|
1080
|
+
}
|
|
1081
|
+
elif count > 1:
|
|
1082
|
+
lines_str = ", ".join(str(ln) for ln in line_numbers[:10])
|
|
1083
|
+
return {
|
|
1084
|
+
"success": False,
|
|
1085
|
+
"error": f"Ambiguous pattern: '{pattern}' appears {count} times at lines {lines_str}. "
|
|
1086
|
+
f"Pattern must be unique for insert operations. Use <edit> with full context instead."
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
# Create backup
|
|
1090
|
+
backup_path = self.create_backup(filepath)
|
|
1091
|
+
|
|
1092
|
+
# Insert content before pattern
|
|
1093
|
+
new_content = file_content.replace(pattern, f"{content}\n{pattern}", 1)
|
|
1094
|
+
|
|
1095
|
+
# Write back
|
|
1096
|
+
try:
|
|
1097
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
1098
|
+
f.write(new_content)
|
|
1099
|
+
|
|
1100
|
+
output = f"✓ Inserted content before pattern in {filepath} (line {line_numbers[0]})"
|
|
1101
|
+
if backup_path:
|
|
1102
|
+
output += f"\nBackup: {backup_path}"
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
"success": True,
|
|
1106
|
+
"output": output
|
|
1107
|
+
}
|
|
1108
|
+
except Exception as e:
|
|
1109
|
+
# Restore backup if failed
|
|
1110
|
+
if backup_path and os.path.exists(backup_path):
|
|
1111
|
+
shutil.copy2(backup_path, filepath)
|
|
1112
|
+
return {
|
|
1113
|
+
"success": False,
|
|
1114
|
+
"error": f"Failed to write file: {str(e)}"
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
def _execute_mkdir(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1118
|
+
"""Execute create directory operation.
|
|
1119
|
+
|
|
1120
|
+
Args:
|
|
1121
|
+
operation: Operation data
|
|
1122
|
+
|
|
1123
|
+
Returns:
|
|
1124
|
+
Result dictionary
|
|
1125
|
+
"""
|
|
1126
|
+
dir_path = operation.get("path")
|
|
1127
|
+
|
|
1128
|
+
# Validation
|
|
1129
|
+
if not dir_path:
|
|
1130
|
+
return {"success": False, "error": "Missing directory path"}
|
|
1131
|
+
|
|
1132
|
+
is_valid, error = self.validate_file_path(dir_path)
|
|
1133
|
+
if not is_valid:
|
|
1134
|
+
return {"success": False, "error": error}
|
|
1135
|
+
|
|
1136
|
+
if os.path.exists(dir_path):
|
|
1137
|
+
if os.path.isdir(dir_path):
|
|
1138
|
+
return {
|
|
1139
|
+
"success": False,
|
|
1140
|
+
"error": f"Directory already exists: {dir_path}"
|
|
1141
|
+
}
|
|
1142
|
+
else:
|
|
1143
|
+
return {
|
|
1144
|
+
"success": False,
|
|
1145
|
+
"error": f"Path exists as a file: {dir_path}"
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
# Create directory with parents
|
|
1149
|
+
try:
|
|
1150
|
+
os.makedirs(dir_path, mode=0o755, exist_ok=False)
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
"success": True,
|
|
1154
|
+
"output": f"✓ Created directory: {dir_path}"
|
|
1155
|
+
}
|
|
1156
|
+
except Exception as e:
|
|
1157
|
+
return {
|
|
1158
|
+
"success": False,
|
|
1159
|
+
"error": f"Failed to create directory: {str(e)}"
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
def _execute_rmdir(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1163
|
+
"""Execute remove directory operation.
|
|
1164
|
+
|
|
1165
|
+
Only removes empty directories.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
operation: Operation data
|
|
1169
|
+
|
|
1170
|
+
Returns:
|
|
1171
|
+
Result dictionary
|
|
1172
|
+
"""
|
|
1173
|
+
dir_path = operation.get("path")
|
|
1174
|
+
|
|
1175
|
+
# Validation
|
|
1176
|
+
if not dir_path:
|
|
1177
|
+
return {"success": False, "error": "Missing directory path"}
|
|
1178
|
+
|
|
1179
|
+
is_valid, error = self.validate_file_path(dir_path)
|
|
1180
|
+
if not is_valid:
|
|
1181
|
+
return {"success": False, "error": error}
|
|
1182
|
+
|
|
1183
|
+
if not os.path.exists(dir_path):
|
|
1184
|
+
return {
|
|
1185
|
+
"success": False,
|
|
1186
|
+
"error": f"Directory not found: {dir_path}"
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if not os.path.isdir(dir_path):
|
|
1190
|
+
return {
|
|
1191
|
+
"success": False,
|
|
1192
|
+
"error": f"Path is not a directory: {dir_path}"
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
# Check protected paths
|
|
1196
|
+
if self.is_protected_path(dir_path):
|
|
1197
|
+
return {
|
|
1198
|
+
"success": False,
|
|
1199
|
+
"error": f"Cannot delete protected directory: {dir_path}"
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
# Check if directory is empty
|
|
1203
|
+
try:
|
|
1204
|
+
if os.listdir(dir_path):
|
|
1205
|
+
return {
|
|
1206
|
+
"success": False,
|
|
1207
|
+
"error": f"Directory not empty: {dir_path}. Only empty directories can be removed."
|
|
1208
|
+
}
|
|
1209
|
+
except Exception as e:
|
|
1210
|
+
return {
|
|
1211
|
+
"success": False,
|
|
1212
|
+
"error": f"Failed to check directory: {str(e)}"
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
# Remove directory
|
|
1216
|
+
try:
|
|
1217
|
+
os.rmdir(dir_path)
|
|
1218
|
+
|
|
1219
|
+
return {
|
|
1220
|
+
"success": True,
|
|
1221
|
+
"output": f"✓ Removed directory: {dir_path}"
|
|
1222
|
+
}
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
return {
|
|
1225
|
+
"success": False,
|
|
1226
|
+
"error": f"Failed to remove directory: {str(e)}"
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
def _execute_read(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1230
|
+
"""Execute read file operation.
|
|
1231
|
+
|
|
1232
|
+
Args:
|
|
1233
|
+
operation: Operation data
|
|
1234
|
+
|
|
1235
|
+
Returns:
|
|
1236
|
+
Result dictionary with file content
|
|
1237
|
+
"""
|
|
1238
|
+
filepath = operation.get("file")
|
|
1239
|
+
lines_spec = operation.get("lines") # Optional: "10-20"
|
|
1240
|
+
|
|
1241
|
+
# Validation
|
|
1242
|
+
if not filepath:
|
|
1243
|
+
return {"success": False, "error": "Missing file path"}
|
|
1244
|
+
|
|
1245
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
1246
|
+
if not is_valid:
|
|
1247
|
+
return {"success": False, "error": error}
|
|
1248
|
+
|
|
1249
|
+
if not os.path.exists(filepath):
|
|
1250
|
+
return {
|
|
1251
|
+
"success": False,
|
|
1252
|
+
"error": f"File not found: {filepath}"
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
# Check file size
|
|
1256
|
+
is_valid, error = self.check_file_size(filepath, self.max_read_size_mb)
|
|
1257
|
+
if not is_valid:
|
|
1258
|
+
return {"success": False, "error": error}
|
|
1259
|
+
|
|
1260
|
+
# Check if text file
|
|
1261
|
+
if not self.is_text_file(filepath):
|
|
1262
|
+
return {
|
|
1263
|
+
"success": False,
|
|
1264
|
+
"error": f"Cannot read binary file: {filepath}"
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
# Read file
|
|
1268
|
+
try:
|
|
1269
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
1270
|
+
content = f.read()
|
|
1271
|
+
except Exception as e:
|
|
1272
|
+
return {
|
|
1273
|
+
"success": False,
|
|
1274
|
+
"error": f"Failed to read file: {str(e)}"
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
# Count total lines for display
|
|
1278
|
+
total_lines = content.count('\n') + 1 if content else 0
|
|
1279
|
+
|
|
1280
|
+
# Handle line range if specified
|
|
1281
|
+
if lines_spec:
|
|
1282
|
+
try:
|
|
1283
|
+
if '-' in lines_spec:
|
|
1284
|
+
start_str, end_str = lines_spec.split('-')
|
|
1285
|
+
start_line = int(start_str.strip()) - 1 # Convert to 0-indexed
|
|
1286
|
+
end_line = int(end_str.strip())
|
|
1287
|
+
else:
|
|
1288
|
+
start_line = int(lines_spec.strip()) - 1
|
|
1289
|
+
end_line = start_line + 1
|
|
1290
|
+
|
|
1291
|
+
lines = content.split('\n')
|
|
1292
|
+
selected_lines = lines[start_line:end_line]
|
|
1293
|
+
content = '\n'.join(selected_lines)
|
|
1294
|
+
line_count = len(selected_lines)
|
|
1295
|
+
|
|
1296
|
+
return {
|
|
1297
|
+
"success": True,
|
|
1298
|
+
"output": f"✓ Read {line_count} lines from {filepath} (lines {lines_spec}):\n\n{content}"
|
|
1299
|
+
}
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
return {
|
|
1302
|
+
"success": False,
|
|
1303
|
+
"error": f"Invalid line specification '{lines_spec}': {str(e)}"
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return {
|
|
1307
|
+
"success": True,
|
|
1308
|
+
"output": f"✓ Read {total_lines} lines from {filepath}:\n\n{content}"
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
def _execute_grep(self, operation: Dict[str, Any]) -> Dict[str, Any]:
|
|
1312
|
+
"""Execute grep file operation (search for pattern in file).
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
operation: Operation data
|
|
1316
|
+
|
|
1317
|
+
Returns:
|
|
1318
|
+
Result dictionary with matching lines
|
|
1319
|
+
"""
|
|
1320
|
+
filepath = operation.get("file")
|
|
1321
|
+
pattern = operation.get("pattern")
|
|
1322
|
+
case_insensitive = operation.get("case_insensitive", False)
|
|
1323
|
+
|
|
1324
|
+
# Validation
|
|
1325
|
+
if not filepath:
|
|
1326
|
+
return {"success": False, "error": "Missing file path"}
|
|
1327
|
+
|
|
1328
|
+
if not pattern:
|
|
1329
|
+
return {"success": False, "error": "Missing search pattern"}
|
|
1330
|
+
|
|
1331
|
+
is_valid, error = self.validate_file_path(filepath)
|
|
1332
|
+
if not is_valid:
|
|
1333
|
+
return {"success": False, "error": error}
|
|
1334
|
+
|
|
1335
|
+
if not os.path.exists(filepath):
|
|
1336
|
+
return {
|
|
1337
|
+
"success": False,
|
|
1338
|
+
"error": f"File not found: {filepath}"
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
# Check file size
|
|
1342
|
+
is_valid, error = self.check_file_size(filepath, self.max_read_size_mb)
|
|
1343
|
+
if not is_valid:
|
|
1344
|
+
return {"success": False, "error": error}
|
|
1345
|
+
|
|
1346
|
+
# Check if text file
|
|
1347
|
+
if not self.is_text_file(filepath):
|
|
1348
|
+
return {
|
|
1349
|
+
"success": False,
|
|
1350
|
+
"error": f"Cannot grep binary file: {filepath}"
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
# Read file and search for pattern
|
|
1354
|
+
try:
|
|
1355
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
1356
|
+
lines = f.readlines()
|
|
1357
|
+
except Exception as e:
|
|
1358
|
+
return {
|
|
1359
|
+
"success": False,
|
|
1360
|
+
"error": f"Failed to read file: {str(e)}"
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
# Search for pattern in each line
|
|
1364
|
+
import re
|
|
1365
|
+
matches = []
|
|
1366
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
1367
|
+
|
|
1368
|
+
try:
|
|
1369
|
+
regex = re.compile(pattern, flags)
|
|
1370
|
+
except re.error as e:
|
|
1371
|
+
return {
|
|
1372
|
+
"success": False,
|
|
1373
|
+
"error": f"Invalid regex pattern: {str(e)}"
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for line_num, line in enumerate(lines, start=1):
|
|
1377
|
+
if regex.search(line):
|
|
1378
|
+
# Remove trailing newline for cleaner display
|
|
1379
|
+
matches.append((line_num, line.rstrip('\n')))
|
|
1380
|
+
|
|
1381
|
+
# Build result output
|
|
1382
|
+
if not matches:
|
|
1383
|
+
return {
|
|
1384
|
+
"success": True,
|
|
1385
|
+
"output": f"✓ No matches found for '{pattern}' in {filepath}"
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
match_count = len(matches)
|
|
1389
|
+
result_lines = [f"✓ Found {match_count} match{'es' if match_count != 1 else ''} for '{pattern}' in {filepath}:\n"]
|
|
1390
|
+
|
|
1391
|
+
# Show up to 50 matches
|
|
1392
|
+
for line_num, line_content in matches[:50]:
|
|
1393
|
+
result_lines.append(f"{line_num}: {line_content}")
|
|
1394
|
+
|
|
1395
|
+
if len(matches) > 50:
|
|
1396
|
+
result_lines.append(f"\n... ({len(matches) - 50} more matches)")
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
"success": True,
|
|
1400
|
+
"output": "\n".join(result_lines)
|
|
1401
|
+
}
|