tunacode-cli 0.0.8__py3-none-any.whl → 0.0.10__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.

Files changed (46) hide show
  1. tunacode/cli/commands.py +34 -165
  2. tunacode/cli/main.py +15 -38
  3. tunacode/cli/repl.py +24 -18
  4. tunacode/configuration/defaults.py +1 -1
  5. tunacode/configuration/models.py +4 -11
  6. tunacode/configuration/settings.py +10 -3
  7. tunacode/constants.py +6 -4
  8. tunacode/context.py +3 -1
  9. tunacode/core/agents/main.py +94 -52
  10. tunacode/core/setup/agent_setup.py +1 -1
  11. tunacode/core/setup/config_setup.py +148 -78
  12. tunacode/core/setup/coordinator.py +4 -2
  13. tunacode/core/setup/environment_setup.py +1 -1
  14. tunacode/core/setup/git_safety_setup.py +51 -39
  15. tunacode/exceptions.py +2 -0
  16. tunacode/prompts/system.txt +1 -1
  17. tunacode/services/undo_service.py +16 -13
  18. tunacode/setup.py +6 -2
  19. tunacode/tools/base.py +20 -11
  20. tunacode/tools/update_file.py +14 -24
  21. tunacode/tools/write_file.py +7 -9
  22. tunacode/ui/completers.py +33 -98
  23. tunacode/ui/input.py +9 -13
  24. tunacode/ui/keybindings.py +3 -1
  25. tunacode/ui/lexers.py +17 -16
  26. tunacode/ui/output.py +8 -14
  27. tunacode/ui/panels.py +7 -5
  28. tunacode/ui/prompt_manager.py +4 -8
  29. tunacode/ui/tool_ui.py +3 -3
  30. tunacode/utils/system.py +0 -40
  31. tunacode_cli-0.0.10.dist-info/METADATA +366 -0
  32. tunacode_cli-0.0.10.dist-info/RECORD +65 -0
  33. {tunacode_cli-0.0.8.dist-info → tunacode_cli-0.0.10.dist-info}/licenses/LICENSE +1 -1
  34. tunacode/cli/model_selector.py +0 -178
  35. tunacode/core/agents/tinyagent_main.py +0 -194
  36. tunacode/core/setup/optimized_coordinator.py +0 -73
  37. tunacode/services/enhanced_undo_service.py +0 -322
  38. tunacode/services/project_undo_service.py +0 -311
  39. tunacode/tools/tinyagent_tools.py +0 -103
  40. tunacode/utils/lazy_imports.py +0 -59
  41. tunacode/utils/regex_cache.py +0 -33
  42. tunacode_cli-0.0.8.dist-info/METADATA +0 -321
  43. tunacode_cli-0.0.8.dist-info/RECORD +0 -73
  44. {tunacode_cli-0.0.8.dist-info → tunacode_cli-0.0.10.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.8.dist-info → tunacode_cli-0.0.10.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.8.dist-info → tunacode_cli-0.0.10.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}"