omnimancer-cli 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 (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1790 @@
1
+ """File system operations manager with security, backup, and atomic operations."""
2
+
3
+ import glob as glob_module
4
+ import hashlib
5
+ import logging
6
+ import mimetypes
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import (
12
+ Any,
13
+ Callable,
14
+ Dict,
15
+ List,
16
+ Optional,
17
+ Union,
18
+ )
19
+
20
+ import aiofiles
21
+ import aiofiles.os
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ from ..security import SecurityManager
26
+ from ..security.permission_controller import PermissionOperation
27
+ from .approval_manager import EnhancedApprovalManager
28
+ from .read_before_write_errors import (
29
+ CallbackError,
30
+ ContentValidationError,
31
+ DiffGenerationError,
32
+ FileReadError,
33
+ FileWriteError,
34
+ ReadBeforeWriteErrorHandler,
35
+ )
36
+ from .types import Operation, OperationResult, OperationType
37
+
38
+
39
+ class FileOperationError(Exception):
40
+ """Custom exception for file operation errors."""
41
+
42
+ pass
43
+
44
+
45
+ class FileSystemManager:
46
+ """Comprehensive file system operations with security, backup, and atomic operations."""
47
+
48
+ def __init__(
49
+ self,
50
+ security_manager: Optional[SecurityManager] = None,
51
+ approval_manager: Optional[EnhancedApprovalManager] = None,
52
+ backup_dir: Optional[str] = None,
53
+ max_file_size_mb: int = 100,
54
+ chunk_size: int = 8192,
55
+ require_approval: bool = True,
56
+ error_handler: Optional[ReadBeforeWriteErrorHandler] = None,
57
+ base_path: Optional[str] = None,
58
+ ):
59
+
60
+ self.security = security_manager or SecurityManager()
61
+ self.approval_manager = approval_manager
62
+ self.max_file_size_mb = max_file_size_mb
63
+ self.chunk_size = chunk_size
64
+ self.require_approval = require_approval
65
+
66
+ # Set up base path for operations (for test compatibility)
67
+ self.base_path = Path(base_path) if base_path else Path.cwd()
68
+
69
+ # Set up read-before-write error handler
70
+ self.error_handler = error_handler or ReadBeforeWriteErrorHandler()
71
+
72
+ # Set up backup directory
73
+ if backup_dir:
74
+ self.backup_dir = Path(backup_dir)
75
+ else:
76
+ self.backup_dir = Path("/tmp/omnimancer_backups")
77
+
78
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
79
+
80
+ # Operation tracking
81
+ self.active_operations: Dict[str, Dict[str, Any]] = {}
82
+
83
+ async def _request_approval_if_needed(self, operation: Operation) -> bool:
84
+ """Request approval for operation if approval manager is configured."""
85
+ if not self.require_approval or not self.approval_manager:
86
+ return True
87
+
88
+ # Request approval through the approval manager
89
+ return await self.approval_manager.request_single_approval(operation)
90
+
91
+ def _create_operation(
92
+ self,
93
+ operation_type: OperationType,
94
+ description: str,
95
+ data: Dict[str, Any],
96
+ reversible: bool = False,
97
+ ) -> Operation:
98
+ """Create an Operation object for approval workflow."""
99
+ return Operation(
100
+ type=operation_type,
101
+ description=description,
102
+ data=data,
103
+ requires_approval=self.require_approval,
104
+ reversible=reversible,
105
+ )
106
+
107
+ async def check_file_exists(
108
+ self, path: Union[str, Path], follow_symlinks: bool = True
109
+ ) -> Dict[str, Any]:
110
+ """
111
+ Check if a file exists with comprehensive error handling and metadata.
112
+
113
+ Args:
114
+ path: Path to check for existence
115
+ follow_symlinks: Whether to follow symbolic links (default: True)
116
+
117
+ Returns:
118
+ Dict containing:
119
+ - exists: Boolean indicating if file exists
120
+ - path: Resolved path string
121
+ - is_file: Boolean indicating if path points to a file
122
+ - is_directory: Boolean indicating if path points to a directory
123
+ - is_symlink: Boolean indicating if path is a symbolic link
124
+ - size: File size in bytes (if exists and is file)
125
+ - modified_time: Last modified timestamp (if exists)
126
+ - error: Error message if check failed
127
+ """
128
+ try:
129
+ original_path = Path(path)
130
+ # Use resolved path for file operations, but preserve original path for return value
131
+ resolved_path = (
132
+ original_path.resolve() if follow_symlinks else original_path
133
+ )
134
+
135
+ # Check if original path is a symlink before resolving
136
+ is_symlink = await aiofiles.os.path.islink(original_path)
137
+
138
+ # First check existence using aiofiles for async operation
139
+ exists = await aiofiles.os.path.exists(resolved_path)
140
+
141
+ if not exists:
142
+ return {
143
+ "exists": False,
144
+ "path": str(original_path), # Return original path, not resolved
145
+ "is_file": False,
146
+ "is_directory": False,
147
+ "is_symlink": is_symlink, # May be True for broken symlinks
148
+ "size": None,
149
+ "modified_time": None,
150
+ "error": None,
151
+ }
152
+
153
+ # Since path exists, do security validation for access
154
+ # Note: We only do security check for actual access control,
155
+ # not for existence determination
156
+ try:
157
+ security_result = await self.security.secure_file_access(
158
+ str(resolved_path), "read"
159
+ )
160
+ if not security_result["success"]:
161
+ # Check if it's a real security denial vs. SecurityManager limitations
162
+ error_msg = security_result.get("error", "")
163
+ # If SecurityManager says "does not exist" but we know it exists,
164
+ # it might be a directory or other filesystem object the SecurityManager doesn't handle
165
+ if (
166
+ "does not exist" in error_msg.lower()
167
+ or "not found" in error_msg.lower()
168
+ ):
169
+ # Continue with existence check - SecurityManager may not handle directories properly
170
+ logger.debug(
171
+ f"SecurityManager doesn't recognize {resolved_path}, continuing with existence check"
172
+ )
173
+ else:
174
+ # Actual security denial for existing path
175
+ return {
176
+ "exists": True,
177
+ "path": str(
178
+ original_path
179
+ ), # Return original path, not resolved
180
+ "is_file": False,
181
+ "is_directory": False,
182
+ "is_symlink": is_symlink,
183
+ "size": None,
184
+ "modified_time": None,
185
+ "error": f"Access denied: {error_msg}",
186
+ }
187
+ except Exception as security_error:
188
+ logger.warning(
189
+ f"Security check failed for {resolved_path}: {security_error}"
190
+ )
191
+ # Continue with existence check despite security error
192
+ logger.debug(
193
+ f"Continuing existence check despite security error: {security_error}"
194
+ )
195
+
196
+ # Gather additional metadata if file exists
197
+ try:
198
+ is_file = await aiofiles.os.path.isfile(resolved_path)
199
+ is_directory = await aiofiles.os.path.isdir(resolved_path)
200
+ # is_symlink was already determined above
201
+
202
+ # Get file size and modification time
203
+ stat_result = await aiofiles.os.stat(resolved_path)
204
+ file_size = stat_result.st_size if is_file else None
205
+ modified_time = datetime.fromtimestamp(stat_result.st_mtime)
206
+
207
+ return {
208
+ "exists": True,
209
+ "path": str(
210
+ original_path
211
+ ), # Return original path to preserve symlink paths
212
+ "is_file": is_file,
213
+ "is_directory": is_directory,
214
+ "is_symlink": is_symlink,
215
+ "size": file_size,
216
+ "modified_time": modified_time,
217
+ "error": None,
218
+ }
219
+
220
+ except OSError as os_error:
221
+ # File exists but we can't get metadata (permission issues, etc.)
222
+ logger.warning(
223
+ f"Could not get metadata for {resolved_path}: {os_error}"
224
+ )
225
+ return {
226
+ "exists": True,
227
+ "path": str(
228
+ original_path
229
+ ), # Return original path to preserve symlink paths
230
+ "is_file": False,
231
+ "is_directory": False,
232
+ "is_symlink": is_symlink,
233
+ "size": None,
234
+ "modified_time": None,
235
+ "error": f"Metadata access failed: {str(os_error)}",
236
+ }
237
+
238
+ except Exception as e:
239
+ logger.error(f"Error checking file existence for {original_path}: {e}")
240
+ return {
241
+ "exists": False,
242
+ "path": str(original_path if "original_path" in locals() else path),
243
+ "is_file": False,
244
+ "is_directory": False,
245
+ "is_symlink": False,
246
+ "size": None,
247
+ "modified_time": None,
248
+ "error": f"Existence check failed: {str(e)}",
249
+ }
250
+
251
+ async def file_exists(self, path: Union[str, Path]) -> bool:
252
+ """
253
+ Simple boolean check for file existence.
254
+
255
+ Args:
256
+ path: Path to check for existence
257
+
258
+ Returns:
259
+ True if file exists and is accessible, False otherwise
260
+ """
261
+ result = await self.check_file_exists(path)
262
+ return result["exists"] and result["error"] is None
263
+
264
+ async def write_file_with_confirmation(
265
+ self,
266
+ path: Union[str, Path],
267
+ content: Union[str, bytes],
268
+ encoding: str = "utf-8",
269
+ confirmation_callback: Optional[Callable] = None,
270
+ backup: bool = True,
271
+ atomic: bool = True,
272
+ read_before_write: bool = False,
273
+ user_review_callback: Optional[Callable] = None,
274
+ ) -> Dict[str, Any]:
275
+ """
276
+ Write file content with existence check and user confirmation if file exists.
277
+
278
+ Args:
279
+ path: Path to write to
280
+ content: Content to write (string or bytes)
281
+ encoding: File encoding (default: 'utf-8')
282
+ confirmation_callback: Optional callback for user confirmation on file overwrite
283
+ backup: Whether to create backup of existing file (default: True)
284
+ atomic: Whether to use atomic write operation (default: True)
285
+ read_before_write: Whether to use read-before-write workflow (default: False)
286
+ user_review_callback: Optional callback for user review when read_before_write=True
287
+
288
+ Returns:
289
+ Dictionary with operation result
290
+ """
291
+ original_path = Path(path)
292
+ operation_id = (
293
+ f"write_confirm_{hashlib.md5(str(original_path).encode()).hexdigest()[:8]}"
294
+ )
295
+
296
+ try:
297
+ # Check if file exists with detailed information (preserving symlink info)
298
+ file_info = await self.check_file_exists(original_path)
299
+
300
+ # If file exists and we have a confirmation callback, ask user
301
+ if file_info["exists"] and confirmation_callback:
302
+ try:
303
+ confirmation_result = await confirmation_callback(file_info)
304
+
305
+ if not confirmation_result.get("confirmed", False):
306
+ return {
307
+ "success": False,
308
+ "operation_id": operation_id,
309
+ "path": str(original_path),
310
+ "reason": "User cancelled operation",
311
+ "user_decision": confirmation_result,
312
+ "file_exists": True,
313
+ }
314
+
315
+ # Handle different user actions
316
+ user_action = confirmation_result.get("action", "overwrite")
317
+ if user_action == "backup":
318
+ backup = True # Force backup if user requested it
319
+ elif user_action == "cancel":
320
+ return {
321
+ "success": False,
322
+ "operation_id": operation_id,
323
+ "path": str(original_path),
324
+ "reason": confirmation_result.get(
325
+ "reason", "User cancelled"
326
+ ),
327
+ "user_decision": confirmation_result,
328
+ "file_exists": True,
329
+ }
330
+
331
+ except Exception as e:
332
+ logger.error(f"Error in confirmation callback: {e}")
333
+ return {
334
+ "success": False,
335
+ "operation_id": operation_id,
336
+ "path": str(original_path),
337
+ "reason": f"Confirmation callback error: {str(e)}",
338
+ "file_exists": file_info["exists"],
339
+ }
340
+
341
+ # Handle symlink replacement if needed
342
+ if file_info["exists"] and file_info.get("is_symlink", False):
343
+ # If overwriting a symlink, remove it first to create a regular file
344
+ logger.debug(
345
+ f"Removing symlink at {original_path} before writing new file"
346
+ )
347
+ await aiofiles.os.unlink(original_path)
348
+
349
+ # Proceed with regular file write
350
+ write_result = await self.write_file(
351
+ path=original_path,
352
+ content=content,
353
+ encoding=encoding,
354
+ backup=backup,
355
+ atomic=atomic,
356
+ read_before_write=read_before_write,
357
+ user_review_callback=user_review_callback,
358
+ )
359
+
360
+ # Add confirmation information to result
361
+ write_result.update(
362
+ {
363
+ "file_existed_before": file_info["exists"],
364
+ "confirmation_requested": file_info["exists"]
365
+ and confirmation_callback is not None,
366
+ "user_confirmed": file_info["exists"]
367
+ and confirmation_callback is not None,
368
+ }
369
+ )
370
+
371
+ return write_result
372
+
373
+ except Exception as e:
374
+ logger.error(f"Error in write_file_with_confirmation: {e}")
375
+ return {
376
+ "success": False,
377
+ "operation_id": operation_id,
378
+ "path": str(path),
379
+ "reason": f"Write operation failed: {str(e)}",
380
+ "error": str(e),
381
+ }
382
+
383
+ async def read_file(
384
+ self,
385
+ path: Union[str, Path],
386
+ encoding: str = "utf-8",
387
+ binary: bool = False,
388
+ ) -> Union[str, bytes]:
389
+ """Read file content with security validation and error handling."""
390
+
391
+ path = Path(path).resolve()
392
+
393
+ # Security validation
394
+ result = await self.security.secure_file_access(str(path), "read")
395
+ if not result["success"]:
396
+ raise FileOperationError(f"Security check failed: {result['error']}")
397
+
398
+ try:
399
+ # Check if file exists
400
+ if not await aiofiles.os.path.exists(path):
401
+ raise FileOperationError(f"File not found: {path}")
402
+
403
+ # Check file size
404
+ stat_result = await aiofiles.os.stat(path)
405
+ file_size_mb = stat_result.st_size / (1024 * 1024)
406
+
407
+ if file_size_mb > self.max_file_size_mb:
408
+ # Use streaming for large files
409
+ return await self._read_large_file(path, binary, encoding)
410
+
411
+ # Regular file read
412
+ if binary or self._is_binary_file(path):
413
+ async with aiofiles.open(path, "rb") as f:
414
+ return await f.read()
415
+ else:
416
+ async with aiofiles.open(path, "r", encoding=encoding) as f:
417
+ return await f.read()
418
+
419
+ except Exception as e:
420
+ raise FileOperationError(f"Failed to read file {path}: {str(e)}")
421
+
422
+ async def write_file(
423
+ self,
424
+ path: Union[str, Path],
425
+ content: Union[str, bytes],
426
+ encoding: str = "utf-8",
427
+ backup: bool = True,
428
+ atomic: bool = True,
429
+ read_before_write: bool = False,
430
+ user_review_callback: Optional[Callable] = None,
431
+ ) -> Dict[str, Any]:
432
+ """
433
+ Write file content with atomic operations, backup, security validation, and approval.
434
+
435
+ Args:
436
+ path: Path to write to
437
+ content: Content to write (string or bytes)
438
+ encoding: File encoding (default: 'utf-8')
439
+ backup: Whether to create backup of existing file (default: True)
440
+ atomic: Whether to use atomic write operation (default: True)
441
+ read_before_write: Whether to use read-before-write workflow (default: False)
442
+ user_review_callback: Optional callback for user review when read_before_write=True
443
+
444
+ Returns:
445
+ Dictionary with operation result
446
+ """
447
+
448
+ path = Path(path).resolve()
449
+ operation_id = f"write_{hashlib.md5(str(path).encode()).hexdigest()[:8]}"
450
+
451
+ # If read-before-write is enabled, delegate to that method
452
+ if read_before_write:
453
+ return await self.read_before_write(
454
+ path=path,
455
+ new_content=content,
456
+ encoding=encoding,
457
+ user_review_callback=user_review_callback,
458
+ )
459
+
460
+ # Validate content is not None
461
+ if content is None:
462
+ raise FileOperationError(
463
+ f"Content cannot be None for write operation to {path}"
464
+ )
465
+
466
+ # Create operation for approval workflow
467
+ file_exists = await aiofiles.os.path.exists(path)
468
+ operation_type = OperationType.FILE_WRITE
469
+ operation_description = (
470
+ f"{'Modify' if file_exists else 'Create'} file: {path.name}"
471
+ )
472
+
473
+ # Safely handle content for operation data
474
+ if content is None:
475
+ content_str = ""
476
+ content_length = 0
477
+ elif isinstance(content, str):
478
+ content_str = content[:1000] + "..." if len(content) > 1000 else content
479
+ content_length = len(content)
480
+ else:
481
+ # bytes content
482
+ decoded_content = content.decode(encoding, errors="ignore")
483
+ content_str = (
484
+ decoded_content[:1000] + "..."
485
+ if len(decoded_content) > 1000
486
+ else decoded_content
487
+ )
488
+ content_length = len(content)
489
+
490
+ operation = self._create_operation(
491
+ operation_type=operation_type,
492
+ description=operation_description,
493
+ data={
494
+ "path": str(path),
495
+ "content": content_str,
496
+ "content_length": content_length,
497
+ "encoding": encoding,
498
+ "backup": backup,
499
+ "atomic": atomic,
500
+ },
501
+ reversible=file_exists
502
+ and backup, # Reversible if we have backup capability
503
+ )
504
+
505
+ # Request approval if needed
506
+ approved = await self._request_approval_if_needed(operation)
507
+ if not approved:
508
+ raise FileOperationError(
509
+ f"File write operation denied by approval workflow: {path}"
510
+ )
511
+
512
+ # Track operation
513
+ self.active_operations[operation_id] = {
514
+ "type": "write",
515
+ "path": path,
516
+ "start_time": datetime.now(),
517
+ "backup_path": None,
518
+ "temp_path": None,
519
+ "approved": approved,
520
+ }
521
+
522
+ try:
523
+ # Use shared implementation
524
+ result = await self._execute_write_operation(
525
+ path=path,
526
+ content=content,
527
+ encoding=encoding,
528
+ backup=backup,
529
+ atomic=atomic,
530
+ operation_id=operation_id,
531
+ approved=approved,
532
+ )
533
+ return result
534
+
535
+ finally:
536
+ # Clean up temporary files safely
537
+ try:
538
+ if (
539
+ operation_id in self.active_operations
540
+ and "temp_path" in self.active_operations[operation_id]
541
+ ):
542
+ temp_path = self.active_operations[operation_id]["temp_path"]
543
+ if temp_path and await aiofiles.os.path.exists(temp_path):
544
+ await aiofiles.os.remove(temp_path)
545
+ except Exception:
546
+ # Ignore cleanup errors - temporary files will be cleaned up eventually
547
+ pass
548
+
549
+ async def _execute_write_operation(
550
+ self,
551
+ path: Union[str, Path],
552
+ content: Union[str, bytes],
553
+ encoding: str = "utf-8",
554
+ backup: bool = True,
555
+ atomic: bool = True,
556
+ operation_id: str = None,
557
+ approved: bool = True,
558
+ ) -> Dict[str, Any]:
559
+ """
560
+ Execute the core file write operation with security, backup, and atomic operations.
561
+ Used by both write_file() and _perform_direct_write() to avoid code duplication.
562
+ """
563
+ try:
564
+ # Security validation
565
+ security_result = await self.security.secure_file_access(
566
+ str(path),
567
+ "write",
568
+ content if isinstance(content, str) else None,
569
+ )
570
+ if not security_result["success"]:
571
+ raise FileOperationError(
572
+ f"Security check failed: {security_result['error']}"
573
+ )
574
+
575
+ # Create backup if file exists and backup is requested
576
+ backup_path = None
577
+ if backup and await aiofiles.os.path.exists(path):
578
+ try:
579
+ backup_path = await self.create_backup(path)
580
+ if operation_id:
581
+ self.active_operations[operation_id][
582
+ "backup_path"
583
+ ] = backup_path
584
+ except Exception as e:
585
+ # Log backup failure but continue with write operation
586
+ logger.warning(f"Backup creation failed for {path}: {e}")
587
+ if operation_id:
588
+ self.active_operations[operation_id]["backup_warning"] = str(e)
589
+
590
+ # Atomic write
591
+ if atomic:
592
+ result = await self._atomic_write(path, content, encoding)
593
+ else:
594
+ result = await self._direct_write(path, content, encoding)
595
+
596
+ # Update operation tracking if operation_id provided
597
+ if operation_id:
598
+ self.active_operations[operation_id].update(result)
599
+ self.active_operations[operation_id]["success"] = True
600
+
601
+ return {
602
+ "success": True,
603
+ "operation_id": operation_id,
604
+ "path": str(path),
605
+ "backup_path": str(backup_path) if backup_path else None,
606
+ "bytes_written": len(
607
+ content.encode(encoding) if isinstance(content, str) else content
608
+ ),
609
+ "atomic": atomic,
610
+ "approved": approved,
611
+ }
612
+
613
+ except Exception as e:
614
+ # Rollback on failure if we have backup info
615
+ if (
616
+ backup
617
+ and operation_id
618
+ and "backup_path" in self.active_operations[operation_id]
619
+ ):
620
+ await self._rollback_operation(operation_id)
621
+
622
+ if operation_id:
623
+ self.active_operations[operation_id]["success"] = False
624
+ self.active_operations[operation_id]["error"] = str(e)
625
+
626
+ raise FileOperationError(f"Failed to write file {path}: {str(e)}")
627
+
628
+ async def _perform_direct_write(
629
+ self,
630
+ path: Union[str, Path],
631
+ content: Union[str, bytes],
632
+ encoding: str = "utf-8",
633
+ backup: bool = True,
634
+ atomic: bool = True,
635
+ ) -> Dict[str, Any]:
636
+ """
637
+ Perform direct file write bypassing the main write_file method.
638
+ Used internally by read_before_write to avoid infinite loops.
639
+ """
640
+ path = Path(path).resolve()
641
+ operation_id = f"direct_write_{hashlib.md5(str(path).encode()).hexdigest()[:8]}"
642
+
643
+ # Validate content is not None
644
+ if content is None:
645
+ raise FileOperationError(
646
+ f"Content cannot be None for write operation to {path}"
647
+ )
648
+
649
+ # Track operation
650
+ self.active_operations[operation_id] = {
651
+ "type": "direct_write",
652
+ "path": path,
653
+ "start_time": datetime.now(),
654
+ "backup_path": None,
655
+ "temp_path": None,
656
+ }
657
+
658
+ try:
659
+ # Use shared implementation
660
+ result = await self._execute_write_operation(
661
+ path=path,
662
+ content=content,
663
+ encoding=encoding,
664
+ backup=backup,
665
+ atomic=atomic,
666
+ operation_id=operation_id,
667
+ approved=True, # Direct writes bypass approval
668
+ )
669
+ return result
670
+
671
+ finally:
672
+ # Clean up temporary files safely
673
+ try:
674
+ if (
675
+ operation_id in self.active_operations
676
+ and "temp_path" in self.active_operations[operation_id]
677
+ ):
678
+ temp_path = self.active_operations[operation_id]["temp_path"]
679
+ if temp_path and await aiofiles.os.path.exists(temp_path):
680
+ await aiofiles.os.remove(temp_path)
681
+ except Exception:
682
+ # Ignore cleanup errors - temporary files will be cleaned up eventually
683
+ pass
684
+
685
+ async def create_backup(self, path: Union[str, Path]) -> Path:
686
+ """Create a timestamped backup of a file."""
687
+
688
+ path = Path(path).resolve()
689
+
690
+ if not await aiofiles.os.path.exists(path):
691
+ raise FileOperationError(f"Cannot backup non-existent file: {path}")
692
+
693
+ # Generate backup filename with timestamp
694
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
695
+ backup_filename = f"{path.name}.{timestamp}.backup"
696
+ backup_path = self.backup_dir / backup_filename
697
+
698
+ # Ensure backup directory exists
699
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
700
+
701
+ # Copy file to backup location
702
+ await self._copy_file(path, backup_path)
703
+
704
+ return backup_path
705
+
706
+ async def restore_backup(
707
+ self, backup_path: Union[str, Path], target_path: Union[str, Path]
708
+ ) -> bool:
709
+ """Restore a file from backup."""
710
+
711
+ backup_path = Path(backup_path)
712
+ target_path = Path(target_path)
713
+
714
+ if not await aiofiles.os.path.exists(backup_path):
715
+ raise FileOperationError(f"Backup file not found: {backup_path}")
716
+
717
+ # Security validation for target path
718
+ result = await self.security.secure_file_access(str(target_path), "write")
719
+ if not result["success"]:
720
+ raise FileOperationError(f"Security check failed: {result['error']}")
721
+
722
+ try:
723
+ await self._copy_file(backup_path, target_path)
724
+ return True
725
+ except Exception as e:
726
+ raise FileOperationError(f"Failed to restore backup: {str(e)}")
727
+
728
+ async def create_directory(
729
+ self, path: Union[str, Path], parents: bool = True
730
+ ) -> bool:
731
+ """Create directory with security validation and approval."""
732
+
733
+ path = Path(path).resolve()
734
+
735
+ # Create operation for approval workflow
736
+ operation = self._create_operation(
737
+ operation_type=OperationType.DIRECTORY_CREATE,
738
+ description=f"Create directory: {path.name}",
739
+ data={"path": str(path), "parents": parents},
740
+ reversible=True, # Directory creation is reversible (can be deleted)
741
+ )
742
+
743
+ # Request approval if needed
744
+ approved = await self._request_approval_if_needed(operation)
745
+ if not approved:
746
+ raise FileOperationError(
747
+ f"Directory creation denied by approval workflow: {path}"
748
+ )
749
+
750
+ # Security validation
751
+ perm_operation = PermissionOperation(
752
+ operation_type="file_create", path=str(path)
753
+ )
754
+ validation = await self.security.validate_operation(perm_operation)
755
+
756
+ if not validation["allowed"]:
757
+ raise FileOperationError(
758
+ f"Directory creation blocked: {', '.join(validation['reasons'])}"
759
+ )
760
+
761
+ try:
762
+ if parents:
763
+ path.mkdir(parents=True, exist_ok=True)
764
+ else:
765
+ path.mkdir(exist_ok=True)
766
+ return True
767
+ except Exception as e:
768
+ raise FileOperationError(f"Failed to create directory {path}: {str(e)}")
769
+
770
+ async def delete_file(
771
+ self, path: Union[str, Path], backup: bool = True
772
+ ) -> Dict[str, Any]:
773
+ """Delete file with optional backup and approval."""
774
+
775
+ path = Path(path).resolve()
776
+
777
+ # Create operation for approval workflow
778
+ operation = self._create_operation(
779
+ operation_type=OperationType.FILE_DELETE,
780
+ description=f"Delete file: {path.name}",
781
+ data={"path": str(path), "backup": backup},
782
+ reversible=backup, # Reversible if we create backup
783
+ )
784
+
785
+ # Request approval if needed
786
+ approved = await self._request_approval_if_needed(operation)
787
+ if not approved:
788
+ raise FileOperationError(
789
+ f"File delete operation denied by approval workflow: {path}"
790
+ )
791
+
792
+ # Security validation
793
+ result = await self.security.secure_file_access(str(path), "delete")
794
+ if not result["success"]:
795
+ raise FileOperationError(f"Security check failed: {result['error']}")
796
+
797
+ backup_path = None
798
+ try:
799
+ # Create backup if requested
800
+ if backup and await aiofiles.os.path.exists(path):
801
+ backup_path = await self.create_backup(path)
802
+
803
+ # Delete the file
804
+ await aiofiles.os.remove(path)
805
+
806
+ return {
807
+ "success": True,
808
+ "path": str(path),
809
+ "backup_path": str(backup_path) if backup_path else None,
810
+ "approved": approved,
811
+ }
812
+
813
+ except Exception as e:
814
+ raise FileOperationError(f"Failed to delete file {path}: {str(e)}")
815
+
816
+ async def glob_files(self, pattern: str, recursive: bool = True) -> List[Path]:
817
+ """Find files matching glob pattern with security validation."""
818
+
819
+ try:
820
+ # Use glob to find matching files
821
+ if recursive:
822
+ matches = glob_module.glob(pattern, recursive=True)
823
+ else:
824
+ matches = glob_module.glob(pattern)
825
+
826
+ # Convert to Path objects and validate each
827
+ validated_paths = []
828
+ for match in matches:
829
+ path = Path(match).resolve()
830
+
831
+ # Basic security check for each path
832
+ try:
833
+ result = await self.security.secure_file_access(str(path), "read")
834
+ if result["success"]:
835
+ validated_paths.append(path)
836
+ except:
837
+ # Skip files that fail security validation
838
+ continue
839
+
840
+ return validated_paths
841
+
842
+ except Exception as e:
843
+ raise FileOperationError(f"Glob pattern matching failed: {str(e)}")
844
+
845
+ async def copy_file(self, src: Union[str, Path], dst: Union[str, Path]) -> bool:
846
+ """Copy file with security validation."""
847
+
848
+ src = Path(src).resolve()
849
+ dst = Path(dst).resolve()
850
+
851
+ # Validate source read access
852
+ read_result = await self.security.secure_file_access(str(src), "read")
853
+ if not read_result["success"]:
854
+ raise FileOperationError(
855
+ f"Source read access denied: {read_result['error']}"
856
+ )
857
+
858
+ # Validate destination write access
859
+ write_result = await self.security.secure_file_access(str(dst), "write")
860
+ if not write_result["success"]:
861
+ raise FileOperationError(
862
+ f"Destination write access denied: {write_result['error']}"
863
+ )
864
+
865
+ try:
866
+ await self._copy_file(src, dst)
867
+ return True
868
+ except Exception as e:
869
+ raise FileOperationError(f"Failed to copy file: {str(e)}")
870
+
871
+ async def move_file(
872
+ self, src: Union[str, Path], dst: Union[str, Path], backup: bool = True
873
+ ) -> Dict[str, Any]:
874
+ """Move file with security validation, approval, and optional backup."""
875
+
876
+ src = Path(src).resolve()
877
+ dst = Path(dst).resolve()
878
+
879
+ # Create operation for approval workflow
880
+ operation = self._create_operation(
881
+ operation_type=OperationType.FILE_WRITE, # Move involves writing to new location
882
+ description=f"Move file: {src.name} → {dst.name}",
883
+ data={
884
+ "source_path": str(src),
885
+ "destination_path": str(dst),
886
+ "backup": backup,
887
+ },
888
+ reversible=backup, # Reversible if we create backup
889
+ )
890
+
891
+ # Request approval if needed
892
+ approved = await self._request_approval_if_needed(operation)
893
+ if not approved:
894
+ raise FileOperationError(
895
+ f"File move operation denied by approval workflow: {src} → {dst}"
896
+ )
897
+
898
+ # Copy first, then delete source
899
+ await self.copy_file(src, dst)
900
+ delete_result = await self.delete_file(src, backup=backup)
901
+
902
+ return {
903
+ "success": True,
904
+ "source": str(src),
905
+ "destination": str(dst),
906
+ "backup_path": delete_result.get("backup_path"),
907
+ "approved": approved,
908
+ }
909
+
910
+ async def get_file_info(self, path: Union[str, Path]) -> Dict[str, Any]:
911
+ """Get comprehensive file information."""
912
+
913
+ path = Path(path).resolve()
914
+
915
+ # Security validation
916
+ result = await self.security.secure_file_access(str(path), "read")
917
+ if not result["success"]:
918
+ raise FileOperationError(f"Security check failed: {result['error']}")
919
+
920
+ try:
921
+ if not await aiofiles.os.path.exists(path):
922
+ raise FileOperationError(f"File not found: {path}")
923
+
924
+ stat_result = await aiofiles.os.stat(path)
925
+
926
+ info = {
927
+ "path": str(path),
928
+ "name": path.name,
929
+ "size": stat_result.st_size,
930
+ "size_mb": stat_result.st_size / (1024 * 1024),
931
+ "modified": datetime.fromtimestamp(stat_result.st_mtime),
932
+ "created": datetime.fromtimestamp(stat_result.st_ctime),
933
+ "is_file": path.is_file(),
934
+ "is_dir": path.is_dir(),
935
+ "is_binary": (self._is_binary_file(path) if path.is_file() else False),
936
+ "mime_type": mimetypes.guess_type(str(path))[0],
937
+ "permissions": oct(stat_result.st_mode)[-3:],
938
+ }
939
+
940
+ # Add file hash for files
941
+ if (
942
+ path.is_file() and stat_result.st_size < 10 * 1024 * 1024
943
+ ): # Only for files < 10MB
944
+ content = await self.read_file(path, binary=True)
945
+ info["md5_hash"] = hashlib.md5(content).hexdigest()
946
+ info["sha256_hash"] = hashlib.sha256(content).hexdigest()
947
+
948
+ return info
949
+
950
+ except Exception as e:
951
+ raise FileOperationError(f"Failed to get file info: {str(e)}")
952
+
953
+ async def list_directory(
954
+ self, path: Union[str, Path], recursive: bool = False
955
+ ) -> List[Dict[str, Any]]:
956
+ """List directory contents with file information."""
957
+
958
+ path = Path(path).resolve()
959
+
960
+ # Security validation
961
+ result = await self.security.secure_file_access(str(path), "read")
962
+ if not result["success"]:
963
+ raise FileOperationError(f"Security check failed: {result['error']}")
964
+
965
+ try:
966
+ if not path.is_dir():
967
+ raise FileOperationError(f"Path is not a directory: {path}")
968
+
969
+ items = []
970
+
971
+ if recursive:
972
+ for item in path.rglob("*"):
973
+ try:
974
+ info = await self.get_file_info(item)
975
+ items.append(info)
976
+ except:
977
+ # Skip items that can't be accessed
978
+ continue
979
+ else:
980
+ for item in path.iterdir():
981
+ try:
982
+ info = await self.get_file_info(item)
983
+ items.append(info)
984
+ except:
985
+ # Skip items that can't be accessed
986
+ continue
987
+
988
+ return sorted(items, key=lambda x: (not x["is_dir"], x["name"].lower()))
989
+
990
+ except Exception as e:
991
+ raise FileOperationError(f"Failed to list directory: {str(e)}")
992
+
993
+ # Private helper methods
994
+
995
+ def _is_binary_file(self, path: Path) -> bool:
996
+ """Detect if file is binary."""
997
+ try:
998
+ with open(path, "rb") as f:
999
+ chunk = f.read(8192)
1000
+ return b"\x00" in chunk
1001
+ except:
1002
+ return False
1003
+
1004
+ async def _atomic_write(
1005
+ self, path: Path, content: Union[str, bytes], encoding: str
1006
+ ) -> Dict[str, Any]:
1007
+ """Perform atomic write using temporary file."""
1008
+
1009
+ # Create temporary file in same directory
1010
+ temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}"
1011
+
1012
+ try:
1013
+ # Write to temporary file
1014
+ if isinstance(content, bytes):
1015
+ async with aiofiles.open(temp_path, "wb") as f:
1016
+ await f.write(content)
1017
+ else:
1018
+ async with aiofiles.open(temp_path, "w", encoding=encoding) as f:
1019
+ await f.write(content)
1020
+
1021
+ # Atomic move
1022
+ await aiofiles.os.rename(temp_path, path)
1023
+
1024
+ return {
1025
+ "method": "atomic",
1026
+ "temp_path": temp_path,
1027
+ }
1028
+
1029
+ except Exception as e:
1030
+ # Clean up temp file on failure
1031
+ if await aiofiles.os.path.exists(temp_path):
1032
+ await aiofiles.os.remove(temp_path)
1033
+ raise e
1034
+
1035
+ async def _direct_write(
1036
+ self, path: Path, content: Union[str, bytes], encoding: str
1037
+ ) -> Dict[str, Any]:
1038
+ """Perform direct write to file."""
1039
+
1040
+ # Ensure parent directory exists
1041
+ path.parent.mkdir(parents=True, exist_ok=True)
1042
+
1043
+ if isinstance(content, bytes):
1044
+ async with aiofiles.open(path, "wb") as f:
1045
+ await f.write(content)
1046
+ else:
1047
+ async with aiofiles.open(path, "w", encoding=encoding) as f:
1048
+ await f.write(content)
1049
+
1050
+ return {
1051
+ "method": "direct",
1052
+ }
1053
+
1054
+ async def _copy_file(self, src: Path, dst: Path) -> None:
1055
+ """Copy file using async operations."""
1056
+
1057
+ # Ensure destination directory exists
1058
+ dst.parent.mkdir(parents=True, exist_ok=True)
1059
+
1060
+ async with aiofiles.open(src, "rb") as src_file:
1061
+ async with aiofiles.open(dst, "wb") as dst_file:
1062
+ while True:
1063
+ chunk = await src_file.read(self.chunk_size)
1064
+ if not chunk:
1065
+ break
1066
+ await dst_file.write(chunk)
1067
+
1068
+ async def _read_large_file(
1069
+ self, path: Path, binary: bool, encoding: str
1070
+ ) -> Union[str, bytes]:
1071
+ """Read large file in chunks."""
1072
+
1073
+ chunks = []
1074
+
1075
+ if binary:
1076
+ async with aiofiles.open(path, "rb") as f:
1077
+ while True:
1078
+ chunk = await f.read(self.chunk_size)
1079
+ if not chunk:
1080
+ break
1081
+ chunks.append(chunk)
1082
+ return b"".join(chunks)
1083
+ else:
1084
+ async with aiofiles.open(path, "r", encoding=encoding) as f:
1085
+ while True:
1086
+ chunk = await f.read(self.chunk_size)
1087
+ if not chunk:
1088
+ break
1089
+ chunks.append(chunk)
1090
+ return "".join(chunks)
1091
+
1092
+ async def _rollback_operation(self, operation_id: str) -> bool:
1093
+ """Rollback a failed operation using backup."""
1094
+
1095
+ if operation_id not in self.active_operations:
1096
+ return False
1097
+
1098
+ operation = self.active_operations[operation_id]
1099
+ backup_path = operation.get("backup_path")
1100
+ original_path = operation.get("path")
1101
+
1102
+ if backup_path and original_path and await aiofiles.os.path.exists(backup_path):
1103
+ try:
1104
+ await self._copy_file(Path(backup_path), Path(original_path))
1105
+ return True
1106
+ except:
1107
+ return False
1108
+
1109
+ return False
1110
+
1111
+ # Git integration methods
1112
+
1113
+ async def git_add(self, path: Union[str, Path]) -> bool:
1114
+ """Add file to git staging area."""
1115
+
1116
+ path = Path(path).resolve()
1117
+
1118
+ # Check if we're in a git repository
1119
+ try:
1120
+ result = await self.security.execute_secure_command(
1121
+ ["git", "rev-parse", "--git-dir"], working_dir=str(path.parent)
1122
+ )
1123
+ if not result["success"]:
1124
+ raise FileOperationError("Not in a git repository")
1125
+ except:
1126
+ raise FileOperationError("Git not available or not in a git repository")
1127
+
1128
+ # Add file to git
1129
+ try:
1130
+ result = await self.security.execute_secure_command(
1131
+ ["git", "add", str(path)], working_dir=str(path.parent)
1132
+ )
1133
+ return result["success"]
1134
+ except Exception as e:
1135
+ raise FileOperationError(f"Git add failed: {str(e)}")
1136
+
1137
+ async def git_status(
1138
+ self, path: Optional[Union[str, Path]] = None
1139
+ ) -> Dict[str, Any]:
1140
+ """Get git status for path or current directory."""
1141
+
1142
+ working_dir = str(Path(path).parent if path else Path.cwd())
1143
+
1144
+ try:
1145
+ result = await self.security.execute_secure_command(
1146
+ ["git", "status", "--porcelain"], working_dir=working_dir
1147
+ )
1148
+
1149
+ if result["success"]:
1150
+ return {
1151
+ "success": True,
1152
+ "status_output": result["stdout"],
1153
+ "changes": (
1154
+ result["stdout"].strip().split("\n")
1155
+ if result["stdout"].strip()
1156
+ else []
1157
+ ),
1158
+ }
1159
+ else:
1160
+ return {
1161
+ "success": False,
1162
+ "error": result["stderr"],
1163
+ }
1164
+ except Exception as e:
1165
+ raise FileOperationError(f"Git status failed: {str(e)}")
1166
+
1167
+ # Context managers and streaming
1168
+
1169
+ @asynccontextmanager
1170
+ async def streaming_read(
1171
+ self, path: Union[str, Path], chunk_size: Optional[int] = None
1172
+ ):
1173
+ """Context manager for streaming file reads."""
1174
+
1175
+ path = Path(path).resolve()
1176
+ chunk_size = chunk_size or self.chunk_size
1177
+
1178
+ # Security validation
1179
+ result = await self.security.secure_file_access(str(path), "read")
1180
+ if not result["success"]:
1181
+ raise FileOperationError(f"Security check failed: {result['error']}")
1182
+
1183
+ async with aiofiles.open(path, "rb") as f:
1184
+ yield f
1185
+
1186
+ async def get_operation_status(self, operation_id: str) -> Optional[Dict[str, Any]]:
1187
+ """Get status of a tracked operation."""
1188
+ return self.active_operations.get(operation_id)
1189
+
1190
+ def clear_completed_operations(self) -> int:
1191
+ """Clear completed operations from tracking."""
1192
+ completed = [
1193
+ op_id
1194
+ for op_id, op in self.active_operations.items()
1195
+ if op.get("success") is not None
1196
+ ]
1197
+
1198
+ for op_id in completed:
1199
+ del self.active_operations[op_id]
1200
+
1201
+ return len(completed)
1202
+
1203
+ # Read-before-write functionality
1204
+
1205
+ async def read_before_write(
1206
+ self,
1207
+ path: Union[str, Path],
1208
+ new_content: Union[str, bytes],
1209
+ encoding: str = "utf-8",
1210
+ user_review_callback: Optional[Callable] = None,
1211
+ max_retries: int = 2,
1212
+ ) -> Dict[str, Any]:
1213
+ """
1214
+ Read existing file content before write and present to user for review.
1215
+
1216
+ This method implements read-before-write logic by:
1217
+ 1. Reading current file content if it exists
1218
+ 2. Presenting both current and new content to user for review
1219
+ 3. Allowing user to approve, modify, or reject the change
1220
+
1221
+ Args:
1222
+ path: Path to the file to be written
1223
+ new_content: New content to write to the file
1224
+ encoding: File encoding for text files
1225
+ user_review_callback: Callback function for user review interface
1226
+ max_retries: Maximum number of retry attempts for recoverable errors
1227
+
1228
+ Returns:
1229
+ Dict with operation result and user decision
1230
+ """
1231
+ return await self._read_before_write_with_recovery(
1232
+ path=path,
1233
+ new_content=new_content,
1234
+ encoding=encoding,
1235
+ user_review_callback=user_review_callback,
1236
+ max_retries=max_retries,
1237
+ retry_count=0,
1238
+ )
1239
+
1240
+ async def _read_before_write_with_recovery(
1241
+ self,
1242
+ path: Union[str, Path],
1243
+ new_content: Union[str, bytes],
1244
+ encoding: str = "utf-8",
1245
+ user_review_callback: Optional[Callable] = None,
1246
+ max_retries: int = 2,
1247
+ retry_count: int = 0,
1248
+ ) -> Dict[str, Any]:
1249
+ """
1250
+ Internal method with error recovery for read-before-write operations.
1251
+ """
1252
+ path = Path(path).resolve()
1253
+ operation_type = (
1254
+ "create" if not await aiofiles.os.path.exists(path) else "modify"
1255
+ )
1256
+
1257
+ try:
1258
+ # Read current content if file exists
1259
+ current_content = None
1260
+ file_exists = await aiofiles.os.path.exists(path)
1261
+
1262
+ if file_exists:
1263
+ try:
1264
+ current_content = await self.read_file(path, encoding=encoding)
1265
+ except Exception as e:
1266
+ # Handle file read error
1267
+ read_error = FileReadError(str(path), e)
1268
+ error_result = self.error_handler.handle_error(
1269
+ read_error, retry_count, max_retries
1270
+ )
1271
+
1272
+ if error_result.get("should_retry"):
1273
+ return await self._read_before_write_with_recovery(
1274
+ path,
1275
+ new_content,
1276
+ encoding,
1277
+ user_review_callback,
1278
+ max_retries,
1279
+ retry_count + 1,
1280
+ )
1281
+ elif error_result.get("fallback_action") == "regular_write":
1282
+ logger.warning(
1283
+ f"Falling back to regular write for {path} due to read error"
1284
+ )
1285
+ return await self._perform_direct_write(
1286
+ path=path, content=new_content, encoding=encoding
1287
+ )
1288
+ elif error_result.get("should_abort"):
1289
+ return {
1290
+ "success": False,
1291
+ "error": f"Failed to read existing content: {str(e)}",
1292
+ "operation": "read_before_write",
1293
+ "error_details": error_result,
1294
+ }
1295
+ else:
1296
+ # Continue with warning
1297
+ current_content = f"<Error reading file: {str(e)}>"
1298
+ logger.warning(f"Continuing with read error for {path}: {e}")
1299
+
1300
+ # Prepare review data
1301
+ review_data = {
1302
+ "file_path": str(path),
1303
+ "file_exists": file_exists,
1304
+ "current_content": current_content,
1305
+ "new_content": (
1306
+ new_content
1307
+ if isinstance(new_content, str)
1308
+ else new_content.decode(encoding, errors="ignore")
1309
+ ),
1310
+ "encoding": encoding,
1311
+ "operation": operation_type,
1312
+ }
1313
+
1314
+ # Generate diff if both contents exist
1315
+ try:
1316
+ if current_content and not current_content.startswith(
1317
+ "<Error reading file:"
1318
+ ):
1319
+ if isinstance(new_content, str):
1320
+ review_data["diff"] = self._generate_content_diff(
1321
+ current_content, new_content, str(path)
1322
+ )
1323
+ elif isinstance(new_content, bytes):
1324
+ review_data["diff"] = self._generate_content_diff(
1325
+ current_content,
1326
+ new_content.decode(encoding, errors="ignore"),
1327
+ str(path),
1328
+ )
1329
+ except Exception as e:
1330
+ # Handle diff generation error
1331
+ diff_error = DiffGenerationError(str(path), e)
1332
+ error_result = self.error_handler.handle_error(
1333
+ diff_error, retry_count, max_retries
1334
+ )
1335
+
1336
+ if error_result.get("fallback_action") == "continue_with_warning":
1337
+ logger.warning(f"Continuing without diff for {path}: {e}")
1338
+ review_data["diff"] = f"<Error generating diff: {str(e)}>"
1339
+ else:
1340
+ review_data["diff"] = None
1341
+
1342
+ # Present to user for review if callback provided
1343
+ user_decision = {"approved": True, "modified_content": None}
1344
+ if user_review_callback:
1345
+ try:
1346
+ user_decision = await user_review_callback(review_data)
1347
+ except Exception as e:
1348
+ # Handle callback error
1349
+ callback_error = CallbackError(str(path), e)
1350
+ error_result = self.error_handler.handle_error(
1351
+ callback_error, retry_count, max_retries
1352
+ )
1353
+
1354
+ if error_result.get("should_retry"):
1355
+ return await self._read_before_write_with_recovery(
1356
+ path,
1357
+ new_content,
1358
+ encoding,
1359
+ user_review_callback,
1360
+ max_retries,
1361
+ retry_count + 1,
1362
+ )
1363
+ elif error_result.get("fallback_action") == "regular_write":
1364
+ logger.warning(
1365
+ f"Falling back to regular write for {path} due to callback error"
1366
+ )
1367
+ return await self._perform_direct_write(
1368
+ path=path, content=new_content, encoding=encoding
1369
+ )
1370
+ else:
1371
+ return {
1372
+ "success": False,
1373
+ "error": f"User review failed: {str(e)}",
1374
+ "operation": "read_before_write",
1375
+ "error_details": error_result,
1376
+ }
1377
+
1378
+ # Handle user decision - CRITICAL FIX for cancellation
1379
+ if user_decision.get("cancelled"):
1380
+ # User pressed 'q' to quit - exit immediately without retry
1381
+ logger.info(f"🛑 User cancelled file operation for {path}")
1382
+ return {
1383
+ "success": False,
1384
+ "error": "User cancelled operation",
1385
+ "operation": "read_before_write",
1386
+ "user_decision": user_decision,
1387
+ "cancelled": True, # Mark as cancelled for upstream handling
1388
+ "final": True, # No retries should be attempted
1389
+ }
1390
+ elif not user_decision.get("approved", False):
1391
+ from .read_before_write_errors import UserRejectionError
1392
+
1393
+ rejection_reason = user_decision.get("reason", "User rejected changes")
1394
+ rejection_error = UserRejectionError(str(path), rejection_reason)
1395
+ error_result = self.error_handler.handle_error(
1396
+ rejection_error, retry_count, max_retries
1397
+ )
1398
+
1399
+ return {
1400
+ "success": False,
1401
+ "error": "User rejected the file modification",
1402
+ "operation": "read_before_write",
1403
+ "user_decision": user_decision,
1404
+ "error_details": error_result,
1405
+ }
1406
+
1407
+ # Validate content if modified by user
1408
+ final_content = user_decision.get("modified_content", new_content)
1409
+
1410
+ # Ensure final_content is not None
1411
+ if final_content is None:
1412
+ final_content = new_content
1413
+
1414
+ if user_decision.get("modified_content") is not None:
1415
+ try:
1416
+ self._validate_content(final_content, str(path))
1417
+ except Exception as validation_error:
1418
+ content_error = ContentValidationError(
1419
+ str(path), str(validation_error)
1420
+ )
1421
+ error_result = self.error_handler.handle_error(
1422
+ content_error, retry_count, max_retries
1423
+ )
1424
+
1425
+ if error_result.get("fallback_action") == "prompt_user":
1426
+ return {
1427
+ "success": False,
1428
+ "error": f"Content validation failed: {str(validation_error)}",
1429
+ "operation": "read_before_write",
1430
+ "error_details": error_result,
1431
+ "requires_user_action": True,
1432
+ }
1433
+
1434
+ # Proceed with write operation - CRITICAL FIX for infinite loop
1435
+ try:
1436
+ # Skip read_before_write to avoid calling the replaced write_file method again
1437
+ # This directly performs the file write without triggering approval again
1438
+ write_result = await self._perform_direct_write(
1439
+ path=path,
1440
+ content=final_content,
1441
+ encoding=encoding,
1442
+ backup=True, # Always create backup in read-before-write mode
1443
+ atomic=True,
1444
+ )
1445
+
1446
+ # Add read-before-write specific metadata
1447
+ write_result["operation"] = "read_before_write"
1448
+ write_result["had_existing_content"] = file_exists
1449
+ write_result["user_reviewed"] = bool(user_review_callback)
1450
+ write_result["content_modified_by_user"] = (
1451
+ user_decision.get("modified_content") is not None
1452
+ )
1453
+ write_result["retry_count"] = retry_count
1454
+
1455
+ return write_result
1456
+
1457
+ except Exception as e:
1458
+ # Handle write error
1459
+ write_error = FileWriteError(str(path), e)
1460
+ error_result = self.error_handler.handle_error(
1461
+ write_error, retry_count, max_retries
1462
+ )
1463
+
1464
+ if error_result.get("should_retry"):
1465
+ return await self._read_before_write_with_recovery(
1466
+ path,
1467
+ new_content,
1468
+ encoding,
1469
+ user_review_callback,
1470
+ max_retries,
1471
+ retry_count + 1,
1472
+ )
1473
+ else:
1474
+ return {
1475
+ "success": False,
1476
+ "error": f"Write operation failed: {str(e)}",
1477
+ "operation": "read_before_write",
1478
+ "error_details": error_result,
1479
+ }
1480
+
1481
+ except Exception as e:
1482
+ # Handle any unexpected errors
1483
+ logger.error(
1484
+ f"Unexpected error in read_before_write for {path}: {e}",
1485
+ exc_info=True,
1486
+ )
1487
+ return {
1488
+ "success": False,
1489
+ "error": f"Unexpected error: {str(e)}",
1490
+ "operation": "read_before_write",
1491
+ "retry_count": retry_count,
1492
+ }
1493
+
1494
+ def _validate_content(self, content: Union[str, bytes], file_path: str):
1495
+ """
1496
+ Validate file content before writing.
1497
+
1498
+ Args:
1499
+ content: Content to validate
1500
+ file_path: Path to the file for context
1501
+
1502
+ Raises:
1503
+ ValueError: If content validation fails
1504
+ """
1505
+ if content is None:
1506
+ raise ValueError("Content cannot be None")
1507
+
1508
+ if isinstance(content, str):
1509
+ # Basic text content validation
1510
+ if len(content) > 50 * 1024 * 1024: # 50MB limit
1511
+ raise ValueError("Content exceeds maximum size limit (50MB)")
1512
+
1513
+ # Check for suspicious patterns (basic security)
1514
+ suspicious_patterns = [
1515
+ "#!/usr/bin/env", # Executable scripts
1516
+ "eval(", # Code evaluation
1517
+ "exec(", # Code execution
1518
+ "__import__", # Dynamic imports
1519
+ ]
1520
+
1521
+ content_lower = content.lower()
1522
+ for pattern in suspicious_patterns:
1523
+ if pattern in content_lower:
1524
+ logger.warning(
1525
+ f"Potentially suspicious content pattern detected: {pattern}"
1526
+ )
1527
+
1528
+ elif isinstance(content, bytes):
1529
+ # Basic binary content validation
1530
+ if len(content) > 100 * 1024 * 1024: # 100MB limit for binary
1531
+ raise ValueError("Binary content exceeds maximum size limit (100MB)")
1532
+
1533
+ # File-specific validation based on extension
1534
+ path = Path(file_path)
1535
+ if path.suffix.lower() in [".py", ".js", ".ts", ".sh"]:
1536
+ # Basic syntax check for code files could be added here
1537
+ pass
1538
+
1539
+ def _generate_content_diff(
1540
+ self, current_content: str, new_content: str, file_path: str
1541
+ ) -> str:
1542
+ """Generate unified diff between current and new content."""
1543
+ import difflib
1544
+
1545
+ current_lines = current_content.splitlines(keepends=True)
1546
+ new_lines = new_content.splitlines(keepends=True)
1547
+
1548
+ diff_lines = list(
1549
+ difflib.unified_diff(
1550
+ current_lines,
1551
+ new_lines,
1552
+ fromfile=f"{file_path} (current)",
1553
+ tofile=f"{file_path} (new)",
1554
+ lineterm="",
1555
+ )
1556
+ )
1557
+
1558
+ return "".join(diff_lines)
1559
+
1560
+ async def preview_file_modification(
1561
+ self,
1562
+ path: Union[str, Path],
1563
+ new_content: Union[str, bytes],
1564
+ encoding: str = "utf-8",
1565
+ ) -> Dict[str, Any]:
1566
+ """
1567
+ Preview file modification without making changes.
1568
+
1569
+ Args:
1570
+ path: Path to the file
1571
+ new_content: Proposed new content
1572
+ encoding: File encoding for text files
1573
+
1574
+ Returns:
1575
+ Dict with preview information including diff
1576
+ """
1577
+ path = Path(path).resolve()
1578
+
1579
+ # Read current content if file exists
1580
+ current_content = None
1581
+ file_exists = await aiofiles.os.path.exists(path)
1582
+
1583
+ if file_exists:
1584
+ try:
1585
+ current_content = await self.read_file(path, encoding=encoding)
1586
+ except Exception as e:
1587
+ current_content = f"<Error reading file: {str(e)}>"
1588
+
1589
+ # Prepare preview data
1590
+ preview = {
1591
+ "file_path": str(path),
1592
+ "file_exists": file_exists,
1593
+ "operation_type": "create" if not file_exists else "modify",
1594
+ "current_content": current_content,
1595
+ "new_content": (
1596
+ new_content
1597
+ if isinstance(new_content, str)
1598
+ else new_content.decode(encoding, errors="ignore")
1599
+ ),
1600
+ "current_size": len(current_content) if current_content else 0,
1601
+ "new_size": (
1602
+ len(new_content)
1603
+ if isinstance(new_content, str)
1604
+ else len(new_content.decode(encoding, errors="ignore"))
1605
+ ),
1606
+ "encoding": encoding,
1607
+ }
1608
+
1609
+ # Generate diff if both contents exist
1610
+ if current_content:
1611
+ new_content_str = (
1612
+ new_content
1613
+ if isinstance(new_content, str)
1614
+ else new_content.decode(encoding, errors="ignore")
1615
+ )
1616
+ preview["diff"] = self._generate_content_diff(
1617
+ current_content, new_content_str, str(path)
1618
+ )
1619
+ preview["has_changes"] = current_content != new_content_str
1620
+ else:
1621
+ preview["diff"] = None
1622
+ preview["has_changes"] = True # New file is always a change
1623
+
1624
+ return preview
1625
+
1626
+ # Directory awareness methods
1627
+
1628
+ def get_current_working_directory(self) -> Path:
1629
+ """Get the current working directory."""
1630
+ return Path.cwd()
1631
+
1632
+ async def is_git_repository(self, path: Optional[Union[str, Path]] = None) -> bool:
1633
+ """Check if the given path (or current directory) is a Git repository."""
1634
+
1635
+ check_path = (
1636
+ Path(path).resolve() if path else self.get_current_working_directory()
1637
+ )
1638
+
1639
+ # Check for .git directory
1640
+ git_dir = check_path / ".git"
1641
+ if git_dir.exists() and git_dir.is_dir():
1642
+ return True
1643
+
1644
+ # Check if we're inside a git repository by looking up the directory tree
1645
+ current = check_path
1646
+ while current != current.parent: # Stop at filesystem root
1647
+ git_dir = current / ".git"
1648
+ if git_dir.exists() and git_dir.is_dir():
1649
+ return True
1650
+ current = current.parent
1651
+
1652
+ return False
1653
+
1654
+ async def get_git_repository_root(
1655
+ self, path: Optional[Union[str, Path]] = None
1656
+ ) -> Optional[Path]:
1657
+ """Get the root directory of the Git repository, if any."""
1658
+
1659
+ check_path = (
1660
+ Path(path).resolve() if path else self.get_current_working_directory()
1661
+ )
1662
+
1663
+ # Look up the directory tree for .git directory
1664
+ current = check_path
1665
+ while current != current.parent: # Stop at filesystem root
1666
+ git_dir = current / ".git"
1667
+ if git_dir.exists() and git_dir.is_dir():
1668
+ return current
1669
+ current = current.parent
1670
+
1671
+ return None
1672
+
1673
+ async def get_directory_context(
1674
+ self, path: Optional[Union[str, Path]] = None
1675
+ ) -> Dict[str, Any]:
1676
+ """Get comprehensive directory context including working directory and repository status."""
1677
+
1678
+ target_path = (
1679
+ Path(path).resolve() if path else self.get_current_working_directory()
1680
+ )
1681
+
1682
+ context = {
1683
+ "current_working_directory": str(target_path),
1684
+ "is_git_repository": await self.is_git_repository(target_path),
1685
+ "git_repository_root": None,
1686
+ "relative_to_repo_root": None,
1687
+ }
1688
+
1689
+ # If it's a git repository, get additional git context
1690
+ if context["is_git_repository"]:
1691
+ repo_root = await self.get_git_repository_root(target_path)
1692
+ if repo_root:
1693
+ context["git_repository_root"] = str(repo_root)
1694
+ try:
1695
+ context["relative_to_repo_root"] = str(
1696
+ target_path.relative_to(repo_root)
1697
+ )
1698
+ except ValueError:
1699
+ # Path is not relative to repo root (shouldn't happen, but just in case)
1700
+ context["relative_to_repo_root"] = str(target_path)
1701
+
1702
+ return context
1703
+
1704
+ async def cleanup(self) -> None:
1705
+ """Cleanup resources and temporary files."""
1706
+
1707
+ # Clean up any remaining temporary files
1708
+ for operation in self.active_operations.values():
1709
+ temp_path = operation.get("temp_path")
1710
+ if temp_path and await aiofiles.os.path.exists(temp_path):
1711
+ try:
1712
+ await aiofiles.os.remove(temp_path)
1713
+ except:
1714
+ pass
1715
+
1716
+ self.active_operations.clear()
1717
+
1718
+ # Additional methods for test compatibility
1719
+
1720
+ async def create_file(
1721
+ self,
1722
+ path: Union[str, Path],
1723
+ content: Union[str, bytes] = "",
1724
+ encoding: str = "utf-8",
1725
+ **kwargs,
1726
+ ) -> Dict[str, Any]:
1727
+ """Create a new file with the given content (test compatibility wrapper)."""
1728
+ return await self.write_file(
1729
+ path=path, content=content, encoding=encoding, **kwargs
1730
+ )
1731
+
1732
+ async def modify_file(
1733
+ self,
1734
+ path: Union[str, Path],
1735
+ content: Union[str, bytes],
1736
+ encoding: str = "utf-8",
1737
+ **kwargs,
1738
+ ) -> Dict[str, Any]:
1739
+ """Modify an existing file with new content (test compatibility wrapper)."""
1740
+ return await self.write_file(
1741
+ path=path, content=content, encoding=encoding, **kwargs
1742
+ )
1743
+
1744
+ async def execute_operation(self, operation: Operation) -> OperationResult:
1745
+ """Execute a file system operation (interface compatibility)."""
1746
+
1747
+ try:
1748
+ if operation.type == OperationType.FILE_READ:
1749
+ result = await self.read_file(operation.data["path"])
1750
+ return OperationResult(success=True, data=result)
1751
+ elif operation.type == OperationType.FILE_WRITE:
1752
+ result = await self.write_file(
1753
+ path=operation.data["path"],
1754
+ content=operation.data["content"],
1755
+ encoding=operation.data.get("encoding", "utf-8"),
1756
+ )
1757
+ return OperationResult(success=result["success"], data=result)
1758
+ elif operation.type == OperationType.FILE_DELETE:
1759
+ result = await self.delete_file(
1760
+ path=operation.data["path"],
1761
+ backup=operation.data.get("backup", True),
1762
+ )
1763
+ return OperationResult(success=result["success"], data=result)
1764
+ else:
1765
+ return OperationResult(
1766
+ success=False,
1767
+ error=f"Unsupported operation type: {operation.type}",
1768
+ )
1769
+ except Exception as e:
1770
+ return OperationResult(success=False, error=str(e))
1771
+
1772
+ async def preview_operation(self, operation: Operation) -> str:
1773
+ """Generate a preview of what the operation will do (interface compatibility)."""
1774
+ try:
1775
+ if operation.type == OperationType.FILE_READ:
1776
+ return f"Read file: {operation.data['path']}"
1777
+ elif operation.type == OperationType.FILE_WRITE:
1778
+ path = operation.data["path"]
1779
+ exists = await aiofiles.os.path.exists(path)
1780
+ action = "Update" if exists else "Create"
1781
+ content_preview = str(operation.data["content"])[:100]
1782
+ if len(content_preview) < len(str(operation.data["content"])):
1783
+ content_preview += "..."
1784
+ return f"{action} file: {path}\nContent preview: {content_preview}"
1785
+ elif operation.type == OperationType.FILE_DELETE:
1786
+ return f"Delete file: {operation.data['path']}"
1787
+ else:
1788
+ return f"Unknown operation: {operation.type}"
1789
+ except Exception as e:
1790
+ return f"Preview error: {str(e)}"