tunacode-cli 0.0.4__py3-none-any.whl → 0.0.6__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 (36) hide show
  1. tunacode/cli/commands.py +91 -33
  2. tunacode/cli/model_selector.py +178 -0
  3. tunacode/cli/repl.py +11 -10
  4. tunacode/configuration/models.py +11 -1
  5. tunacode/constants.py +11 -11
  6. tunacode/context.py +1 -3
  7. tunacode/core/agents/main.py +52 -94
  8. tunacode/core/agents/tinyagent_main.py +171 -0
  9. tunacode/core/setup/git_safety_setup.py +39 -51
  10. tunacode/core/setup/optimized_coordinator.py +73 -0
  11. tunacode/exceptions.py +13 -15
  12. tunacode/services/enhanced_undo_service.py +322 -0
  13. tunacode/services/project_undo_service.py +311 -0
  14. tunacode/services/undo_service.py +18 -21
  15. tunacode/tools/base.py +11 -20
  16. tunacode/tools/tinyagent_tools.py +103 -0
  17. tunacode/tools/update_file.py +24 -14
  18. tunacode/tools/write_file.py +9 -7
  19. tunacode/types.py +2 -2
  20. tunacode/ui/completers.py +98 -33
  21. tunacode/ui/input.py +8 -7
  22. tunacode/ui/keybindings.py +1 -3
  23. tunacode/ui/lexers.py +16 -17
  24. tunacode/ui/output.py +9 -3
  25. tunacode/ui/panels.py +4 -4
  26. tunacode/ui/prompt_manager.py +6 -4
  27. tunacode/utils/lazy_imports.py +59 -0
  28. tunacode/utils/regex_cache.py +33 -0
  29. tunacode/utils/system.py +13 -13
  30. tunacode_cli-0.0.6.dist-info/METADATA +235 -0
  31. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/RECORD +35 -27
  32. tunacode_cli-0.0.4.dist-info/METADATA +0 -247
  33. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/WHEEL +0 -0
  34. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/entry_points.txt +0 -0
  35. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/licenses/LICENSE +0 -0
  36. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,322 @@
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}"
@@ -0,0 +1,311 @@
1
+ """
2
+ Project-local undo service that stores backups in the working directory.
3
+ Designed for pip-installed CLI tool usage.
4
+ """
5
+
6
+ import json
7
+ import shutil
8
+ import time
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, Optional, Tuple
12
+
13
+ from tunacode.core.state import StateManager
14
+
15
+
16
+ class ProjectUndoService:
17
+ """Undo system that stores backups in .tunacode/ within the project."""
18
+
19
+ # Directory name for local undo data
20
+ UNDO_DIR_NAME = ".tunacode"
21
+ BACKUP_SUBDIR = "backups"
22
+ GITIGNORE_CONTENT = """# TunaCode undo system files
23
+ *
24
+ !.gitignore
25
+ """
26
+
27
+ def __init__(self, state_manager: StateManager, project_dir: Optional[Path] = None):
28
+ self.state_manager = state_manager
29
+ self.project_dir = Path(project_dir or Path.cwd())
30
+
31
+ # Local undo directory in the project
32
+ self.undo_dir = self.project_dir / self.UNDO_DIR_NAME
33
+ self.backup_dir = self.undo_dir / self.BACKUP_SUBDIR
34
+ self.op_log_file = self.undo_dir / "operations.jsonl"
35
+
36
+ # Git directory (if project uses git)
37
+ self.git_dir = self.project_dir / ".git"
38
+
39
+ self._init_undo_directory()
40
+
41
+ def _init_undo_directory(self):
42
+ """Initialize the .tunacode directory in the project."""
43
+ try:
44
+ # Create directories
45
+ self.undo_dir.mkdir(exist_ok=True)
46
+ self.backup_dir.mkdir(exist_ok=True)
47
+
48
+ # Create .gitignore to exclude undo files from version control
49
+ gitignore_path = self.undo_dir / ".gitignore"
50
+ if not gitignore_path.exists():
51
+ gitignore_path.write_text(self.GITIGNORE_CONTENT)
52
+
53
+ # Create operation log
54
+ if not self.op_log_file.exists():
55
+ self.op_log_file.touch()
56
+
57
+ # Add informational README
58
+ readme_path = self.undo_dir / "README.md"
59
+ if not readme_path.exists():
60
+ readme_path.write_text(
61
+ """# TunaCode Undo System
62
+
63
+ This directory contains local backup files created by TunaCode to enable undo functionality.
64
+
65
+ ## Contents:
66
+ - `backups/` - Timestamped file backups
67
+ - `operations.jsonl` - Operation history log
68
+ - `.gitignore` - Excludes these files from git
69
+
70
+ ## Notes:
71
+ - These files are local to your machine
72
+ - Safe to delete if you don't need undo history
73
+ - Automatically cleaned up (keeps last 50 backups)
74
+ - Not committed to version control
75
+
76
+ Created by TunaCode (https://github.com/larock22/tunacode)
77
+ """
78
+ )
79
+
80
+ except PermissionError:
81
+ print(f"⚠️ Cannot create {self.UNDO_DIR_NAME}/ directory - undo will be limited")
82
+ except Exception as e:
83
+ print(f"⚠️ Error initializing undo directory: {e}")
84
+
85
+ def should_track_file(self, filepath: Path) -> bool:
86
+ """Check if a file should be tracked by undo system."""
87
+ # Don't track files in our own undo directory
88
+ try:
89
+ if self.undo_dir in filepath.parents:
90
+ return False
91
+ except ValueError:
92
+ pass
93
+
94
+ # Don't track hidden files/directories (except .gitignore, .env, etc)
95
+ parts = filepath.parts
96
+ for part in parts:
97
+ if part.startswith(".") and part not in {".gitignore", ".env", ".envrc"}:
98
+ return False
99
+
100
+ # Don't track common build/cache directories
101
+ exclude_dirs = {
102
+ "node_modules",
103
+ "__pycache__",
104
+ ".pytest_cache",
105
+ "dist",
106
+ "build",
107
+ ".next",
108
+ ".nuxt",
109
+ "target",
110
+ }
111
+ if any(excluded in parts for excluded in exclude_dirs):
112
+ return False
113
+
114
+ return True
115
+
116
+ def backup_file(self, filepath: Path) -> Optional[Path]:
117
+ """Create a timestamped backup of a file."""
118
+ # Check if we should track this file
119
+ if not self.should_track_file(filepath):
120
+ return None
121
+
122
+ try:
123
+ # Use relative path for organizing backups
124
+ rel_path = filepath.relative_to(self.project_dir)
125
+
126
+ # Create subdirectories in backup dir to mirror project structure
127
+ backup_subdir = self.backup_dir / rel_path.parent
128
+ backup_subdir.mkdir(parents=True, exist_ok=True)
129
+
130
+ # Create backup filename with timestamp
131
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
132
+ backup_name = f"{filepath.name}.{timestamp}.bak"
133
+ backup_path = backup_subdir / backup_name
134
+
135
+ # Copy the file if it exists
136
+ if filepath.exists():
137
+ shutil.copy2(filepath, backup_path)
138
+ else:
139
+ # Create empty backup for new files
140
+ backup_path.touch()
141
+
142
+ # Log the backup
143
+ self._log_operation(
144
+ {
145
+ "type": "backup",
146
+ "file": str(rel_path),
147
+ "backup": str(backup_path.relative_to(self.undo_dir)),
148
+ "timestamp": datetime.now().isoformat(),
149
+ }
150
+ )
151
+
152
+ # Clean up old backups for this file
153
+ self._cleanup_file_backups(backup_subdir, filepath.name, keep=10)
154
+
155
+ return backup_path
156
+
157
+ except Exception:
158
+ # Silent fail - don't interrupt user's work
159
+ return None
160
+
161
+ def _cleanup_file_backups(self, backup_dir: Path, filename: str, keep: int = 10):
162
+ """Keep only the most recent backups for a specific file."""
163
+ try:
164
+ # Find all backups for this file
165
+ pattern = f"{filename}.*.bak"
166
+ backups = sorted(backup_dir.glob(pattern), key=lambda p: p.stat().st_mtime)
167
+
168
+ # Remove old backups
169
+ if len(backups) > keep:
170
+ for old_backup in backups[:-keep]:
171
+ old_backup.unlink()
172
+
173
+ except Exception:
174
+ pass # Silent cleanup failure
175
+
176
+ def _log_operation(self, operation: Dict):
177
+ """Log an operation to the operations file."""
178
+ try:
179
+ with open(self.op_log_file, "a") as f:
180
+ json.dump(operation, f)
181
+ f.write("\n")
182
+
183
+ # Keep log file size reasonable (last 1000 operations)
184
+ self._trim_log_file(1000)
185
+
186
+ except Exception:
187
+ pass
188
+
189
+ def _trim_log_file(self, max_lines: int):
190
+ """Keep only the last N operations in the log."""
191
+ try:
192
+ with open(self.op_log_file, "r") as f:
193
+ lines = f.readlines()
194
+
195
+ if len(lines) > max_lines:
196
+ with open(self.op_log_file, "w") as f:
197
+ f.writelines(lines[-max_lines:])
198
+
199
+ except Exception:
200
+ pass
201
+
202
+ def get_undo_status(self) -> Dict[str, any]:
203
+ """Get current status of undo system."""
204
+ try:
205
+ backup_count = sum(1 for _ in self.backup_dir.rglob("*.bak"))
206
+ backup_size = sum(f.stat().st_size for f in self.backup_dir.rglob("*.bak"))
207
+ log_size = self.op_log_file.stat().st_size if self.op_log_file.exists() else 0
208
+
209
+ return {
210
+ "enabled": True,
211
+ "location": str(self.undo_dir),
212
+ "backup_count": backup_count,
213
+ "backup_size_mb": backup_size / (1024 * 1024),
214
+ "log_size_kb": log_size / 1024,
215
+ "git_available": self.git_dir.exists(),
216
+ }
217
+ except Exception:
218
+ return {"enabled": False, "error": "Cannot access undo directory"}
219
+
220
+ def cleanup_old_backups(self, days: int = 7):
221
+ """Remove backups older than specified days."""
222
+ try:
223
+ cutoff_time = time.time() - (days * 24 * 60 * 60)
224
+
225
+ for backup in self.backup_dir.rglob("*.bak"):
226
+ if backup.stat().st_mtime < cutoff_time:
227
+ backup.unlink()
228
+
229
+ except Exception:
230
+ pass
231
+
232
+
233
+ class ProjectSafeFileOperations:
234
+ """File operations with project-local backup."""
235
+
236
+ def __init__(self, undo_service: ProjectUndoService):
237
+ self.undo = undo_service
238
+
239
+ async def safe_write(self, filepath: Path, content: str) -> Tuple[bool, str]:
240
+ """Write file with automatic local backup."""
241
+ filepath = Path(filepath).resolve()
242
+
243
+ # Create backup before write
244
+ backup_path = self.undo.backup_file(filepath)
245
+
246
+ # Check if file exists for logging
247
+ if filepath.exists() and filepath.stat().st_size < 100_000:
248
+ try:
249
+ filepath.read_text() # Just check it's readable
250
+ except Exception:
251
+ pass
252
+
253
+ # Write the file
254
+ try:
255
+ filepath.parent.mkdir(parents=True, exist_ok=True)
256
+ filepath.write_text(content)
257
+
258
+ # Log the operation
259
+ self.undo._log_operation(
260
+ {
261
+ "type": "write",
262
+ "file": str(filepath.relative_to(self.undo.project_dir)),
263
+ "size": len(content),
264
+ "backup": (
265
+ str(backup_path.relative_to(self.undo.undo_dir)) if backup_path else None
266
+ ),
267
+ "timestamp": datetime.now().isoformat(),
268
+ }
269
+ )
270
+
271
+ return True, "File written successfully"
272
+
273
+ except Exception as e:
274
+ return False, f"Failed to write file: {e}"
275
+
276
+ async def safe_delete(self, filepath: Path) -> Tuple[bool, str]:
277
+ """Delete file with automatic local backup."""
278
+ filepath = Path(filepath).resolve()
279
+
280
+ if not filepath.exists():
281
+ return False, "File does not exist"
282
+
283
+ # Create backup before delete
284
+ backup_path = self.undo.backup_file(filepath)
285
+
286
+ try:
287
+ filepath.unlink()
288
+
289
+ # Log the operation
290
+ self.undo._log_operation(
291
+ {
292
+ "type": "delete",
293
+ "file": str(filepath.relative_to(self.undo.project_dir)),
294
+ "backup": (
295
+ str(backup_path.relative_to(self.undo.undo_dir)) if backup_path else None
296
+ ),
297
+ "timestamp": datetime.now().isoformat(),
298
+ }
299
+ )
300
+
301
+ return True, f"File deleted (backup available in {self.undo.UNDO_DIR_NAME}/)"
302
+
303
+ except Exception as e:
304
+ return False, f"Failed to delete file: {e}"
305
+
306
+
307
+ def get_project_undo_service(state_manager: StateManager) -> ProjectUndoService:
308
+ """Get or create project-local undo service."""
309
+ if not hasattr(state_manager.session, "project_undo"):
310
+ state_manager.session.project_undo = ProjectUndoService(state_manager)
311
+ return state_manager.session.project_undo