tunacode-cli 0.0.9__py3-none-any.whl → 0.0.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands.py +34 -165
- tunacode/cli/main.py +15 -38
- tunacode/cli/repl.py +24 -18
- tunacode/configuration/defaults.py +1 -1
- tunacode/configuration/models.py +4 -11
- tunacode/configuration/settings.py +10 -3
- tunacode/constants.py +6 -4
- tunacode/context.py +3 -1
- tunacode/core/agents/main.py +94 -52
- tunacode/core/setup/agent_setup.py +1 -1
- tunacode/core/setup/config_setup.py +161 -81
- tunacode/core/setup/coordinator.py +4 -2
- tunacode/core/setup/environment_setup.py +1 -1
- tunacode/core/setup/git_safety_setup.py +51 -39
- tunacode/exceptions.py +2 -0
- tunacode/prompts/system.txt +1 -1
- tunacode/services/undo_service.py +16 -13
- tunacode/setup.py +6 -2
- tunacode/tools/base.py +20 -11
- tunacode/tools/update_file.py +14 -24
- tunacode/tools/write_file.py +7 -9
- tunacode/ui/completers.py +33 -98
- tunacode/ui/input.py +9 -13
- tunacode/ui/keybindings.py +3 -1
- tunacode/ui/lexers.py +17 -16
- tunacode/ui/output.py +8 -14
- tunacode/ui/panels.py +7 -5
- tunacode/ui/prompt_manager.py +4 -8
- tunacode/ui/tool_ui.py +3 -3
- tunacode/utils/system.py +0 -40
- tunacode_cli-0.0.11.dist-info/METADATA +387 -0
- tunacode_cli-0.0.11.dist-info/RECORD +65 -0
- {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.11.dist-info}/licenses/LICENSE +1 -1
- tunacode/cli/model_selector.py +0 -178
- tunacode/core/agents/tinyagent_main.py +0 -194
- tunacode/core/setup/optimized_coordinator.py +0 -73
- tunacode/services/enhanced_undo_service.py +0 -322
- tunacode/services/project_undo_service.py +0 -311
- tunacode/tools/tinyagent_tools.py +0 -103
- tunacode/utils/lazy_imports.py +0 -59
- tunacode/utils/regex_cache.py +0 -33
- tunacode_cli-0.0.9.dist-info/METADATA +0 -321
- tunacode_cli-0.0.9.dist-info/RECORD +0 -73
- {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.11.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.11.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.11.dist-info}/top_level.txt +0 -0
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
"""TinyAgent-based agent implementation."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from contextlib import asynccontextmanager
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict, Optional
|
|
8
|
-
|
|
9
|
-
from tinyagent import ReactAgent
|
|
10
|
-
|
|
11
|
-
from tunacode.core.state import StateManager
|
|
12
|
-
from tunacode.tools.tinyagent_tools import read_file, run_command, update_file, write_file
|
|
13
|
-
from tunacode.types import ModelName, ToolCallback
|
|
14
|
-
|
|
15
|
-
# Set up tinyagent configuration
|
|
16
|
-
# First check if config exists in the package directory
|
|
17
|
-
_package_dir = Path(__file__).parent.parent.parent.parent # Navigate to tunacode root
|
|
18
|
-
_config_candidates = [
|
|
19
|
-
_package_dir / "tunacode_config.yml", # In package root
|
|
20
|
-
Path.home() / ".config" / "tunacode_config.yml", # In user config dir
|
|
21
|
-
Path.cwd() / "tunacode_config.yml", # In current directory
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
# Find the first existing config file
|
|
25
|
-
for config_path in _config_candidates:
|
|
26
|
-
if config_path.exists():
|
|
27
|
-
os.environ["TINYAGENT_CONFIG"] = str(config_path)
|
|
28
|
-
break
|
|
29
|
-
else:
|
|
30
|
-
# If no config found, we'll rely on tinyagent to handle it
|
|
31
|
-
# This prevents the immediate error on import
|
|
32
|
-
pass
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def get_or_create_react_agent(model: ModelName, state_manager: StateManager) -> ReactAgent:
|
|
36
|
-
"""
|
|
37
|
-
Get or create a ReactAgent for the specified model.
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
model: The model name (e.g., "openai:gpt-4o", "openrouter:openai/gpt-4.1")
|
|
41
|
-
state_manager: The state manager instance
|
|
42
|
-
|
|
43
|
-
Returns:
|
|
44
|
-
ReactAgent instance configured for the model
|
|
45
|
-
"""
|
|
46
|
-
agents = state_manager.session.agents
|
|
47
|
-
|
|
48
|
-
if model not in agents:
|
|
49
|
-
# Parse model string to determine provider and actual model name
|
|
50
|
-
# Format: "provider:model" or "openrouter:provider/model"
|
|
51
|
-
if model.startswith("openrouter:"):
|
|
52
|
-
# OpenRouter model - extract the actual model name
|
|
53
|
-
actual_model = model.replace("openrouter:", "")
|
|
54
|
-
# Set environment to use OpenRouter base URL
|
|
55
|
-
import os
|
|
56
|
-
|
|
57
|
-
os.environ["OPENAI_BASE_URL"] = "https://openrouter.ai/api/v1"
|
|
58
|
-
# Use OpenRouter API key if available
|
|
59
|
-
if state_manager.session.user_config["env"].get("OPENROUTER_API_KEY"):
|
|
60
|
-
os.environ["OPENAI_API_KEY"] = state_manager.session.user_config["env"][
|
|
61
|
-
"OPENROUTER_API_KEY"
|
|
62
|
-
]
|
|
63
|
-
else:
|
|
64
|
-
# Direct provider (openai, anthropic, google-gla)
|
|
65
|
-
provider, actual_model = model.split(":", 1)
|
|
66
|
-
# Reset to default base URL for direct providers
|
|
67
|
-
import os
|
|
68
|
-
|
|
69
|
-
if provider == "openai":
|
|
70
|
-
os.environ["OPENAI_BASE_URL"] = "https://api.openai.com/v1"
|
|
71
|
-
# Set appropriate API key
|
|
72
|
-
provider_key_map = {
|
|
73
|
-
"openai": "OPENAI_API_KEY",
|
|
74
|
-
"anthropic": "ANTHROPIC_API_KEY",
|
|
75
|
-
"google-gla": "GEMINI_API_KEY",
|
|
76
|
-
}
|
|
77
|
-
if provider in provider_key_map:
|
|
78
|
-
key_name = provider_key_map[provider]
|
|
79
|
-
if state_manager.session.user_config["env"].get(key_name):
|
|
80
|
-
os.environ[key_name] = state_manager.session.user_config["env"][key_name]
|
|
81
|
-
|
|
82
|
-
# Create new ReactAgent with tools
|
|
83
|
-
# Note: tinyAgent gets model from environment variables, not constructor
|
|
84
|
-
agent = ReactAgent(tools=[read_file, write_file, update_file, run_command])
|
|
85
|
-
|
|
86
|
-
# Add MCP compatibility method
|
|
87
|
-
@asynccontextmanager
|
|
88
|
-
async def run_mcp_servers():
|
|
89
|
-
# TinyAgent doesn't have built-in MCP support yet
|
|
90
|
-
# This is a placeholder for compatibility
|
|
91
|
-
yield
|
|
92
|
-
|
|
93
|
-
agent.run_mcp_servers = run_mcp_servers
|
|
94
|
-
|
|
95
|
-
# Cache the agent
|
|
96
|
-
agents[model] = agent
|
|
97
|
-
|
|
98
|
-
return agents[model]
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
async def process_request_with_tinyagent(
|
|
102
|
-
model: ModelName,
|
|
103
|
-
message: str,
|
|
104
|
-
state_manager: StateManager,
|
|
105
|
-
tool_callback: Optional[ToolCallback] = None,
|
|
106
|
-
) -> Dict[str, Any]:
|
|
107
|
-
"""
|
|
108
|
-
Process a request using TinyAgent's ReactAgent.
|
|
109
|
-
|
|
110
|
-
Args:
|
|
111
|
-
model: The model to use
|
|
112
|
-
message: The user message
|
|
113
|
-
state_manager: State manager instance
|
|
114
|
-
tool_callback: Optional callback for tool execution (for UI updates)
|
|
115
|
-
|
|
116
|
-
Returns:
|
|
117
|
-
Dict containing the result and any metadata
|
|
118
|
-
"""
|
|
119
|
-
agent = get_or_create_react_agent(model, state_manager)
|
|
120
|
-
|
|
121
|
-
# Convert message history to format expected by tinyAgent
|
|
122
|
-
# Note: tinyAgent handles message history differently than pydantic-ai
|
|
123
|
-
# We'll need to adapt based on tinyAgent's actual API
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
# Run the agent with the message
|
|
127
|
-
# The new API's run() method might be synchronous based on the examples
|
|
128
|
-
import asyncio
|
|
129
|
-
if asyncio.iscoroutinefunction(agent.run):
|
|
130
|
-
result = await agent.run(message)
|
|
131
|
-
else:
|
|
132
|
-
result = agent.run(message)
|
|
133
|
-
|
|
134
|
-
# Update message history in state_manager
|
|
135
|
-
# This will need to be adapted based on how tinyAgent returns messages
|
|
136
|
-
state_manager.session.messages.append(
|
|
137
|
-
{
|
|
138
|
-
"role": "user",
|
|
139
|
-
"content": message,
|
|
140
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
141
|
-
}
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
state_manager.session.messages.append(
|
|
145
|
-
{
|
|
146
|
-
"role": "assistant",
|
|
147
|
-
"content": result,
|
|
148
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
149
|
-
}
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
return {"result": result, "success": True, "model": model}
|
|
153
|
-
|
|
154
|
-
except Exception as e:
|
|
155
|
-
# Handle errors
|
|
156
|
-
error_result = {
|
|
157
|
-
"result": f"Error: {str(e)}",
|
|
158
|
-
"success": False,
|
|
159
|
-
"model": model,
|
|
160
|
-
"error": str(e),
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
# Still update message history with the error
|
|
164
|
-
state_manager.session.messages.append(
|
|
165
|
-
{
|
|
166
|
-
"role": "user",
|
|
167
|
-
"content": message,
|
|
168
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
169
|
-
}
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
state_manager.session.messages.append(
|
|
173
|
-
{
|
|
174
|
-
"role": "assistant",
|
|
175
|
-
"content": f"Error occurred: {str(e)}",
|
|
176
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
177
|
-
"error": True,
|
|
178
|
-
}
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
return error_result
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def patch_tool_messages(
|
|
185
|
-
error_message: str = "Tool operation failed",
|
|
186
|
-
state_manager: StateManager = None,
|
|
187
|
-
):
|
|
188
|
-
"""
|
|
189
|
-
Compatibility function for patching tool messages.
|
|
190
|
-
With tinyAgent, this may not be needed as it handles tool errors differently.
|
|
191
|
-
"""
|
|
192
|
-
# TinyAgent handles tool retries and errors internally
|
|
193
|
-
# This function is kept for compatibility but may be simplified
|
|
194
|
-
pass
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""Optimized setup coordinator with deferred loading."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from typing import List, Set
|
|
5
|
-
|
|
6
|
-
from tunacode.core.setup.base import BaseSetup
|
|
7
|
-
from tunacode.core.state import StateManager
|
|
8
|
-
from tunacode.ui import console as ui
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class OptimizedSetupCoordinator:
|
|
12
|
-
"""Optimized coordinator that defers non-critical setup steps."""
|
|
13
|
-
|
|
14
|
-
def __init__(self, state_manager: StateManager):
|
|
15
|
-
self.state_manager = state_manager
|
|
16
|
-
self.critical_steps: List[BaseSetup] = []
|
|
17
|
-
self.deferred_steps: List[BaseSetup] = []
|
|
18
|
-
self._deferred_task = None
|
|
19
|
-
|
|
20
|
-
# Define critical steps that must run at startup
|
|
21
|
-
self.critical_step_names: Set[str] = {
|
|
22
|
-
"Configuration", # Need config to know which model to use
|
|
23
|
-
"Environment Variables", # Need API keys
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
def register_step(self, step: BaseSetup) -> None:
|
|
27
|
-
"""Register a setup step, separating critical from deferred."""
|
|
28
|
-
if step.name in self.critical_step_names:
|
|
29
|
-
self.critical_steps.append(step)
|
|
30
|
-
else:
|
|
31
|
-
self.deferred_steps.append(step)
|
|
32
|
-
|
|
33
|
-
async def run_setup(self, force_setup: bool = False) -> None:
|
|
34
|
-
"""Run critical setup immediately, defer the rest."""
|
|
35
|
-
# Run critical steps synchronously
|
|
36
|
-
for step in self.critical_steps:
|
|
37
|
-
try:
|
|
38
|
-
if await step.should_run(force_setup):
|
|
39
|
-
await step.execute(force_setup)
|
|
40
|
-
if not await step.validate():
|
|
41
|
-
await ui.error(f"Setup validation failed: {step.name}")
|
|
42
|
-
raise RuntimeError(f"Setup step '{step.name}' failed validation")
|
|
43
|
-
except Exception as e:
|
|
44
|
-
await ui.error(f"Setup failed at step '{step.name}': {str(e)}")
|
|
45
|
-
raise
|
|
46
|
-
|
|
47
|
-
# Schedule deferred steps to run in background
|
|
48
|
-
if self.deferred_steps and not self._deferred_task:
|
|
49
|
-
self._deferred_task = asyncio.create_task(self._run_deferred_steps(force_setup))
|
|
50
|
-
|
|
51
|
-
async def _run_deferred_steps(self, force_setup: bool) -> None:
|
|
52
|
-
"""Run deferred steps in the background."""
|
|
53
|
-
# Wait a moment to let the main UI start
|
|
54
|
-
await asyncio.sleep(0.1)
|
|
55
|
-
|
|
56
|
-
for step in self.deferred_steps:
|
|
57
|
-
try:
|
|
58
|
-
if await step.should_run(force_setup):
|
|
59
|
-
await step.execute(force_setup)
|
|
60
|
-
# Don't validate deferred steps - they're non-critical
|
|
61
|
-
except Exception:
|
|
62
|
-
# Log but don't fail on deferred steps
|
|
63
|
-
pass
|
|
64
|
-
|
|
65
|
-
async def ensure_deferred_complete(self) -> None:
|
|
66
|
-
"""Ensure deferred steps are complete before certain operations."""
|
|
67
|
-
if self._deferred_task and not self._deferred_task.done():
|
|
68
|
-
await self._deferred_task
|
|
69
|
-
|
|
70
|
-
def clear_steps(self) -> None:
|
|
71
|
-
"""Clear all registered setup steps."""
|
|
72
|
-
self.critical_steps.clear()
|
|
73
|
-
self.deferred_steps.clear()
|
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Enhanced undo service with multiple failsafe mechanisms.
|
|
3
|
-
Provides three layers of protection for file operations.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import shutil
|
|
8
|
-
import subprocess
|
|
9
|
-
from datetime import datetime
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Dict, List, Optional, Tuple
|
|
12
|
-
|
|
13
|
-
from tunacode.core.state import StateManager
|
|
14
|
-
from tunacode.utils.system import get_session_dir
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class EnhancedUndoService:
|
|
18
|
-
"""Multi-layer undo system with failsafes."""
|
|
19
|
-
|
|
20
|
-
def __init__(self, state_manager: StateManager):
|
|
21
|
-
self.state_manager = state_manager
|
|
22
|
-
self.session_dir = get_session_dir(state_manager)
|
|
23
|
-
|
|
24
|
-
# Layer 1: File backups directory
|
|
25
|
-
self.backup_dir = self.session_dir / "backups"
|
|
26
|
-
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
-
|
|
28
|
-
# Layer 2: Operation log
|
|
29
|
-
self.op_log_file = self.session_dir / "operations.jsonl"
|
|
30
|
-
|
|
31
|
-
# Layer 3: Git (existing system)
|
|
32
|
-
self.git_dir = self.session_dir / ".git"
|
|
33
|
-
|
|
34
|
-
self._init_systems()
|
|
35
|
-
|
|
36
|
-
def _init_systems(self):
|
|
37
|
-
"""Initialize all undo systems."""
|
|
38
|
-
# Ensure operation log exists
|
|
39
|
-
if not self.op_log_file.exists():
|
|
40
|
-
self.op_log_file.touch()
|
|
41
|
-
|
|
42
|
-
# ===== LAYER 1: File Backups =====
|
|
43
|
-
|
|
44
|
-
def backup_file(self, filepath: Path) -> Optional[Path]:
|
|
45
|
-
"""
|
|
46
|
-
Create a timestamped backup of a file before modification.
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Path to backup file, or None if backup failed
|
|
50
|
-
"""
|
|
51
|
-
# Note: We'll create empty backup even if file doesn't exist yet
|
|
52
|
-
# This helps track file creation operations
|
|
53
|
-
|
|
54
|
-
try:
|
|
55
|
-
# Create unique backup name with timestamp
|
|
56
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
57
|
-
backup_name = f"{filepath.name}.{timestamp}.bak"
|
|
58
|
-
backup_path = self.backup_dir / backup_name
|
|
59
|
-
|
|
60
|
-
# Copy the file
|
|
61
|
-
shutil.copy2(filepath, backup_path)
|
|
62
|
-
|
|
63
|
-
# Log the backup
|
|
64
|
-
self._log_operation(
|
|
65
|
-
{
|
|
66
|
-
"type": "backup",
|
|
67
|
-
"original": str(filepath),
|
|
68
|
-
"backup": str(backup_path),
|
|
69
|
-
"timestamp": timestamp,
|
|
70
|
-
}
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
return backup_path
|
|
74
|
-
except Exception as e:
|
|
75
|
-
print(f"⚠️ Failed to create backup: {e}")
|
|
76
|
-
return None
|
|
77
|
-
|
|
78
|
-
def restore_from_backup(self, original_path: Path) -> Tuple[bool, str]:
|
|
79
|
-
"""
|
|
80
|
-
Restore a file from its most recent backup.
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
(success, message) tuple
|
|
84
|
-
"""
|
|
85
|
-
try:
|
|
86
|
-
# Find most recent backup for this file
|
|
87
|
-
backups = sorted(
|
|
88
|
-
[f for f in self.backup_dir.glob(f"{original_path.name}.*.bak")], reverse=True
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
if not backups:
|
|
92
|
-
return False, f"No backups found for {original_path.name}"
|
|
93
|
-
|
|
94
|
-
latest_backup = backups[0]
|
|
95
|
-
|
|
96
|
-
# Restore the file
|
|
97
|
-
shutil.copy2(latest_backup, original_path)
|
|
98
|
-
|
|
99
|
-
# Log the restore
|
|
100
|
-
self._log_operation(
|
|
101
|
-
{
|
|
102
|
-
"type": "restore",
|
|
103
|
-
"file": str(original_path),
|
|
104
|
-
"from_backup": str(latest_backup),
|
|
105
|
-
"timestamp": datetime.now().isoformat(),
|
|
106
|
-
}
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
return True, f"Restored {original_path.name} from backup"
|
|
110
|
-
|
|
111
|
-
except Exception as e:
|
|
112
|
-
return False, f"Failed to restore from backup: {e}"
|
|
113
|
-
|
|
114
|
-
# ===== LAYER 2: Operation Log =====
|
|
115
|
-
|
|
116
|
-
def _log_operation(self, operation: Dict):
|
|
117
|
-
"""Log an operation to the operations file."""
|
|
118
|
-
try:
|
|
119
|
-
with open(self.op_log_file, "a") as f:
|
|
120
|
-
json.dump(operation, f)
|
|
121
|
-
f.write("\n")
|
|
122
|
-
except Exception:
|
|
123
|
-
pass # Silent fail for logging
|
|
124
|
-
|
|
125
|
-
def log_file_operation(
|
|
126
|
-
self,
|
|
127
|
-
op_type: str,
|
|
128
|
-
filepath: Path,
|
|
129
|
-
old_content: Optional[str] = None,
|
|
130
|
-
new_content: Optional[str] = None,
|
|
131
|
-
):
|
|
132
|
-
"""
|
|
133
|
-
Log a file operation with content for potential recovery.
|
|
134
|
-
"""
|
|
135
|
-
operation = {
|
|
136
|
-
"type": "file_operation",
|
|
137
|
-
"operation": op_type,
|
|
138
|
-
"file": str(filepath),
|
|
139
|
-
"timestamp": datetime.now().isoformat(),
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
# For safety, only log content for smaller files
|
|
143
|
-
if old_content and len(old_content) < 100_000: # 100KB limit
|
|
144
|
-
operation["old_content"] = old_content
|
|
145
|
-
if new_content and len(new_content) < 100_000:
|
|
146
|
-
operation["new_content"] = new_content
|
|
147
|
-
|
|
148
|
-
self._log_operation(operation)
|
|
149
|
-
|
|
150
|
-
def get_recent_operations(self, limit: int = 10) -> List[Dict]:
|
|
151
|
-
"""Get recent operations from the log."""
|
|
152
|
-
operations = []
|
|
153
|
-
try:
|
|
154
|
-
with open(self.op_log_file, "r") as f:
|
|
155
|
-
for line in f:
|
|
156
|
-
if line.strip():
|
|
157
|
-
operations.append(json.loads(line))
|
|
158
|
-
except Exception:
|
|
159
|
-
pass
|
|
160
|
-
|
|
161
|
-
return operations[-limit:]
|
|
162
|
-
|
|
163
|
-
def undo_from_log(self) -> Tuple[bool, str]:
|
|
164
|
-
"""
|
|
165
|
-
Attempt to undo the last operation using the operation log.
|
|
166
|
-
"""
|
|
167
|
-
operations = self.get_recent_operations(20)
|
|
168
|
-
|
|
169
|
-
# Find the last file operation
|
|
170
|
-
for op in reversed(operations):
|
|
171
|
-
if op.get("type") == "file_operation" and "old_content" in op:
|
|
172
|
-
filepath = Path(op["file"])
|
|
173
|
-
|
|
174
|
-
try:
|
|
175
|
-
# Restore old content
|
|
176
|
-
with open(filepath, "w") as f:
|
|
177
|
-
f.write(op["old_content"])
|
|
178
|
-
|
|
179
|
-
self._log_operation(
|
|
180
|
-
{
|
|
181
|
-
"type": "undo_from_log",
|
|
182
|
-
"restored_file": str(filepath),
|
|
183
|
-
"timestamp": datetime.now().isoformat(),
|
|
184
|
-
}
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
return True, f"Restored {filepath.name} from operation log"
|
|
188
|
-
except Exception as e:
|
|
189
|
-
return False, f"Failed to restore from log: {e}"
|
|
190
|
-
|
|
191
|
-
return False, "No recoverable operations found in log"
|
|
192
|
-
|
|
193
|
-
# ===== LAYER 3: Git Integration =====
|
|
194
|
-
|
|
195
|
-
def git_commit(self, message: str = "Auto-save") -> bool:
|
|
196
|
-
"""Create a git commit if available."""
|
|
197
|
-
if not self.git_dir.exists():
|
|
198
|
-
return False
|
|
199
|
-
|
|
200
|
-
try:
|
|
201
|
-
git_dir_arg = f"--git-dir={self.git_dir}"
|
|
202
|
-
subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, timeout=5)
|
|
203
|
-
|
|
204
|
-
subprocess.run(
|
|
205
|
-
["git", git_dir_arg, "commit", "-m", message], capture_output=True, timeout=5
|
|
206
|
-
)
|
|
207
|
-
return True
|
|
208
|
-
except Exception:
|
|
209
|
-
return False
|
|
210
|
-
|
|
211
|
-
# ===== Unified Undo Interface =====
|
|
212
|
-
|
|
213
|
-
def perform_undo(self) -> Tuple[bool, str]:
|
|
214
|
-
"""
|
|
215
|
-
Perform undo with automatic failover:
|
|
216
|
-
1. Try Git first (if available)
|
|
217
|
-
2. Fall back to operation log
|
|
218
|
-
3. Fall back to file backups
|
|
219
|
-
"""
|
|
220
|
-
# Layer 3: Try Git first
|
|
221
|
-
if self.git_dir.exists():
|
|
222
|
-
try:
|
|
223
|
-
from tunacode.services.undo_service import perform_undo
|
|
224
|
-
|
|
225
|
-
success, message = perform_undo(self.state_manager)
|
|
226
|
-
if success:
|
|
227
|
-
return success, f"[Git] {message}"
|
|
228
|
-
except Exception:
|
|
229
|
-
pass
|
|
230
|
-
|
|
231
|
-
# Layer 2: Try operation log
|
|
232
|
-
success, message = self.undo_from_log()
|
|
233
|
-
if success:
|
|
234
|
-
return success, f"[OpLog] {message}"
|
|
235
|
-
|
|
236
|
-
# Layer 1: Show available backups
|
|
237
|
-
backups = list(self.backup_dir.glob("*.bak"))
|
|
238
|
-
if backups:
|
|
239
|
-
recent_backups = sorted(backups, reverse=True)[:5]
|
|
240
|
-
backup_list = "\n".join([f" - {b.name}" for b in recent_backups])
|
|
241
|
-
return False, f"Manual restore available from backups:\n{backup_list}"
|
|
242
|
-
|
|
243
|
-
return False, "No undo information available"
|
|
244
|
-
|
|
245
|
-
def cleanup_old_backups(self, keep_recent: int = 50):
|
|
246
|
-
"""Clean up old backup files, keeping the most recent ones."""
|
|
247
|
-
backups = sorted(self.backup_dir.glob("*.bak"))
|
|
248
|
-
|
|
249
|
-
if len(backups) > keep_recent:
|
|
250
|
-
for backup in backups[:-keep_recent]:
|
|
251
|
-
try:
|
|
252
|
-
backup.unlink()
|
|
253
|
-
except Exception:
|
|
254
|
-
pass
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# ===== Safe File Operations Wrapper =====
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
class SafeFileOperations:
|
|
261
|
-
"""Wrapper for file operations with automatic backup."""
|
|
262
|
-
|
|
263
|
-
def __init__(self, undo_service: EnhancedUndoService):
|
|
264
|
-
self.undo = undo_service
|
|
265
|
-
|
|
266
|
-
async def safe_write(self, filepath: Path, content: str) -> Tuple[bool, str]:
|
|
267
|
-
"""Write file with automatic backup."""
|
|
268
|
-
filepath = Path(filepath)
|
|
269
|
-
|
|
270
|
-
# Get old content if file exists
|
|
271
|
-
old_content = None
|
|
272
|
-
if filepath.exists():
|
|
273
|
-
try:
|
|
274
|
-
old_content = filepath.read_text()
|
|
275
|
-
except Exception:
|
|
276
|
-
pass
|
|
277
|
-
|
|
278
|
-
# Always create backup before write (even for new files)
|
|
279
|
-
self.undo.backup_file(filepath)
|
|
280
|
-
|
|
281
|
-
# Write new content
|
|
282
|
-
try:
|
|
283
|
-
filepath.write_text(content)
|
|
284
|
-
|
|
285
|
-
# Log the operation
|
|
286
|
-
self.undo.log_file_operation("write", filepath, old_content, content)
|
|
287
|
-
|
|
288
|
-
# Commit to git if available
|
|
289
|
-
self.undo.git_commit(f"Modified {filepath.name}")
|
|
290
|
-
|
|
291
|
-
return True, "File written successfully"
|
|
292
|
-
|
|
293
|
-
except Exception as e:
|
|
294
|
-
return False, f"Failed to write file: {e}"
|
|
295
|
-
|
|
296
|
-
async def safe_delete(self, filepath: Path) -> Tuple[bool, str]:
|
|
297
|
-
"""Delete file with automatic backup."""
|
|
298
|
-
filepath = Path(filepath)
|
|
299
|
-
|
|
300
|
-
if not filepath.exists():
|
|
301
|
-
return False, "File does not exist"
|
|
302
|
-
|
|
303
|
-
try:
|
|
304
|
-
# Create backup first
|
|
305
|
-
backup_path = self.undo.backup_file(filepath)
|
|
306
|
-
|
|
307
|
-
# Get content for operation log
|
|
308
|
-
content = filepath.read_text()
|
|
309
|
-
|
|
310
|
-
# Delete the file
|
|
311
|
-
filepath.unlink()
|
|
312
|
-
|
|
313
|
-
# Log the operation
|
|
314
|
-
self.undo.log_file_operation("delete", filepath, old_content=content)
|
|
315
|
-
|
|
316
|
-
# Commit to git
|
|
317
|
-
self.undo.git_commit(f"Deleted {filepath.name}")
|
|
318
|
-
|
|
319
|
-
return True, f"File deleted (backup at {backup_path.name})"
|
|
320
|
-
|
|
321
|
-
except Exception as e:
|
|
322
|
-
return False, f"Failed to delete file: {e}"
|