tunacode-cli 0.0.1__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/__init__.py +0 -0
- tunacode/cli/__init__.py +4 -0
- tunacode/cli/commands.py +632 -0
- tunacode/cli/main.py +47 -0
- tunacode/cli/repl.py +251 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +26 -0
- tunacode/configuration/models.py +69 -0
- tunacode/configuration/settings.py +32 -0
- tunacode/constants.py +129 -0
- tunacode/context.py +83 -0
- tunacode/core/__init__.py +0 -0
- tunacode/core/agents/__init__.py +0 -0
- tunacode/core/agents/main.py +119 -0
- tunacode/core/setup/__init__.py +17 -0
- tunacode/core/setup/agent_setup.py +41 -0
- tunacode/core/setup/base.py +37 -0
- tunacode/core/setup/config_setup.py +179 -0
- tunacode/core/setup/coordinator.py +45 -0
- tunacode/core/setup/environment_setup.py +62 -0
- tunacode/core/setup/git_safety_setup.py +188 -0
- tunacode/core/setup/undo_setup.py +32 -0
- tunacode/core/state.py +43 -0
- tunacode/core/tool_handler.py +57 -0
- tunacode/exceptions.py +105 -0
- tunacode/prompts/system.txt +71 -0
- tunacode/py.typed +0 -0
- tunacode/services/__init__.py +1 -0
- tunacode/services/mcp.py +86 -0
- tunacode/services/undo_service.py +244 -0
- tunacode/setup.py +50 -0
- tunacode/tools/__init__.py +0 -0
- tunacode/tools/base.py +244 -0
- tunacode/tools/read_file.py +89 -0
- tunacode/tools/run_command.py +107 -0
- tunacode/tools/update_file.py +117 -0
- tunacode/tools/write_file.py +82 -0
- tunacode/types.py +259 -0
- tunacode/ui/__init__.py +1 -0
- tunacode/ui/completers.py +129 -0
- tunacode/ui/console.py +74 -0
- tunacode/ui/constants.py +16 -0
- tunacode/ui/decorators.py +59 -0
- tunacode/ui/input.py +95 -0
- tunacode/ui/keybindings.py +27 -0
- tunacode/ui/lexers.py +46 -0
- tunacode/ui/output.py +109 -0
- tunacode/ui/panels.py +156 -0
- tunacode/ui/prompt_manager.py +117 -0
- tunacode/ui/tool_ui.py +187 -0
- tunacode/ui/validators.py +23 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/bm25.py +55 -0
- tunacode/utils/diff_utils.py +69 -0
- tunacode/utils/file_utils.py +41 -0
- tunacode/utils/ripgrep.py +17 -0
- tunacode/utils/system.py +336 -0
- tunacode/utils/text_utils.py +87 -0
- tunacode/utils/user_configuration.py +54 -0
- tunacode_cli-0.0.1.dist-info/METADATA +242 -0
- tunacode_cli-0.0.1.dist-info/RECORD +65 -0
- tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
- tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.services.undo_service
|
|
3
|
+
|
|
4
|
+
Provides Git-based undo functionality for TunaCode operations.
|
|
5
|
+
Manages automatic commits and rollback operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from pydantic_ai.messages import ModelResponse, TextPart
|
|
14
|
+
|
|
15
|
+
from tunacode.constants import (ERROR_UNDO_INIT, UNDO_DISABLED_HOME, UNDO_DISABLED_NO_GIT,
|
|
16
|
+
UNDO_GIT_TIMEOUT, UNDO_INITIAL_COMMIT)
|
|
17
|
+
from tunacode.core.state import StateManager
|
|
18
|
+
from tunacode.exceptions import GitOperationError
|
|
19
|
+
from tunacode.ui import console as ui
|
|
20
|
+
from tunacode.utils.system import get_session_dir
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_in_git_project(directory: Optional[Path] = None) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Recursively check if the given directory is inside a git project.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
directory (Path, optional): Directory to check. Defaults to current working directory.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
bool: True if in a git project, False otherwise
|
|
32
|
+
"""
|
|
33
|
+
if directory is None:
|
|
34
|
+
directory = Path.cwd()
|
|
35
|
+
|
|
36
|
+
if (directory / ".git").exists():
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
if directory == directory.parent:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
return is_in_git_project(directory.parent)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_undo_system(state_manager: StateManager) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Initialize the undo system by creating a Git repository
|
|
48
|
+
in the ~/.tunacode/sessions/<session-id> directory.
|
|
49
|
+
|
|
50
|
+
Skip initialization if running from home directory or not in a git project.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
state_manager: The StateManager instance.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
bool: True if the undo system was initialized, False otherwise.
|
|
57
|
+
"""
|
|
58
|
+
cwd = Path.cwd()
|
|
59
|
+
home_dir = Path.home()
|
|
60
|
+
|
|
61
|
+
if cwd == home_dir:
|
|
62
|
+
ui.warning(UNDO_DISABLED_HOME)
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
if not is_in_git_project():
|
|
66
|
+
# Temporarily use sync print for warnings during init
|
|
67
|
+
print("⚠️ Not in a git repository - undo functionality will be limited")
|
|
68
|
+
print("💡 To enable undo functionality, run: git init")
|
|
69
|
+
print(" File operations will still work, but can't be undone")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# Get the session directory path
|
|
73
|
+
session_dir = get_session_dir(state_manager)
|
|
74
|
+
tunacode_git_dir = session_dir / ".git"
|
|
75
|
+
|
|
76
|
+
# Check if already initialized
|
|
77
|
+
if tunacode_git_dir.exists():
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
# Initialize Git repository
|
|
81
|
+
try:
|
|
82
|
+
subprocess.run(
|
|
83
|
+
["git", "init", str(session_dir)], capture_output=True, check=True, timeout=5
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Make an initial commit
|
|
87
|
+
git_dir_arg = f"--git-dir={tunacode_git_dir}"
|
|
88
|
+
|
|
89
|
+
# Add all files
|
|
90
|
+
subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, check=True, timeout=5)
|
|
91
|
+
|
|
92
|
+
# Create initial commit
|
|
93
|
+
subprocess.run(
|
|
94
|
+
["git", git_dir_arg, "commit", "-m", UNDO_INITIAL_COMMIT],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
check=True,
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
except subprocess.TimeoutExpired as e:
|
|
102
|
+
error = GitOperationError(operation="init", message=UNDO_GIT_TIMEOUT, original_error=e)
|
|
103
|
+
ui.warning(str(error))
|
|
104
|
+
return False
|
|
105
|
+
except Exception as e:
|
|
106
|
+
error = GitOperationError(operation="init", message=str(e), original_error=e)
|
|
107
|
+
ui.warning(ERROR_UNDO_INIT.format(e=e))
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def commit_for_undo(
|
|
112
|
+
message_prefix: str = "tunacode", state_manager: Optional[StateManager] = None
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Commit the current state to the undo repository.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
message_prefix (str): Prefix for the commit message.
|
|
119
|
+
state_manager: The StateManager instance.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
bool: True if the commit was successful, False otherwise.
|
|
123
|
+
"""
|
|
124
|
+
# Get the session directory and git dir
|
|
125
|
+
if state_manager is None:
|
|
126
|
+
raise ValueError("state_manager is required for commit_for_undo")
|
|
127
|
+
session_dir = get_session_dir(state_manager)
|
|
128
|
+
tunacode_git_dir = session_dir / ".git"
|
|
129
|
+
|
|
130
|
+
if not tunacode_git_dir.exists():
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
git_dir_arg = f"--git-dir={tunacode_git_dir}"
|
|
135
|
+
|
|
136
|
+
# Add all files
|
|
137
|
+
subprocess.run(["git", git_dir_arg, "add", "."], capture_output=True, timeout=5)
|
|
138
|
+
|
|
139
|
+
# Create commit with timestamp
|
|
140
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
141
|
+
commit_message = f"{message_prefix} - {timestamp}"
|
|
142
|
+
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
["git", git_dir_arg, "commit", "-m", commit_message],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
timeout=5,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Handle case where there are no changes to commit
|
|
151
|
+
if "nothing to commit" in result.stdout or "nothing to commit" in result.stderr:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
return True
|
|
155
|
+
except subprocess.TimeoutExpired as e:
|
|
156
|
+
error = GitOperationError(
|
|
157
|
+
operation="commit", message="Git commit timed out", original_error=e
|
|
158
|
+
)
|
|
159
|
+
ui.warning(str(error))
|
|
160
|
+
return False
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error = GitOperationError(operation="commit", message=str(e), original_error=e)
|
|
163
|
+
ui.warning(f"Error creating undo commit: {e}")
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def perform_undo(state_manager: StateManager) -> Tuple[bool, str]:
|
|
168
|
+
"""
|
|
169
|
+
Undo the most recent change by resetting to the previous commit.
|
|
170
|
+
Also adds a system message to the chat history to inform the AI
|
|
171
|
+
that the last changes were undone.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
state_manager: The StateManager instance.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
tuple: (bool, str) - Success status and message
|
|
178
|
+
"""
|
|
179
|
+
# Get the session directory and git dir
|
|
180
|
+
session_dir = get_session_dir(state_manager)
|
|
181
|
+
tunacode_git_dir = session_dir / ".git"
|
|
182
|
+
|
|
183
|
+
if not tunacode_git_dir.exists():
|
|
184
|
+
return False, "Undo system not initialized"
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
git_dir_arg = f"--git-dir={tunacode_git_dir}"
|
|
188
|
+
|
|
189
|
+
# Get commit log to check if we have commits to undo
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
["git", git_dir_arg, "log", "--format=%H", "-n", "2"],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
check=True,
|
|
195
|
+
timeout=5,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
commits = result.stdout.strip().split("\n")
|
|
199
|
+
if len(commits) < 2:
|
|
200
|
+
return False, "Nothing to undo"
|
|
201
|
+
|
|
202
|
+
# Get the commit message of the commit we're undoing for context
|
|
203
|
+
commit_msg_result = subprocess.run(
|
|
204
|
+
["git", git_dir_arg, "log", "--format=%B", "-n", "1"],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
check=True,
|
|
208
|
+
timeout=5,
|
|
209
|
+
)
|
|
210
|
+
commit_msg = commit_msg_result.stdout.strip()
|
|
211
|
+
|
|
212
|
+
# Perform reset to previous commit
|
|
213
|
+
subprocess.run(
|
|
214
|
+
["git", git_dir_arg, "reset", "--hard", "HEAD~1"],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
check=True,
|
|
217
|
+
timeout=5,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Add a system message to the chat history to inform the AI
|
|
221
|
+
# about the undo operation
|
|
222
|
+
state_manager.session.messages.append(
|
|
223
|
+
ModelResponse(
|
|
224
|
+
parts=[
|
|
225
|
+
TextPart(
|
|
226
|
+
content=(
|
|
227
|
+
"The last changes were undone. "
|
|
228
|
+
f"Commit message of undone changes: {commit_msg}"
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
],
|
|
232
|
+
kind="response",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return True, "Successfully undid last change"
|
|
237
|
+
except subprocess.TimeoutExpired as e:
|
|
238
|
+
error = GitOperationError(
|
|
239
|
+
operation="reset", message="Undo operation timed out", original_error=e
|
|
240
|
+
)
|
|
241
|
+
return False, str(error)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
error = GitOperationError(operation="reset", message=str(e), original_error=e)
|
|
244
|
+
return False, f"Error performing undo: {e}"
|
tunacode/setup.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.setup
|
|
3
|
+
|
|
4
|
+
Package setup and metadata configuration for the TunaCode CLI.
|
|
5
|
+
Provides high-level setup functions for initializing the application and its agents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from tunacode.core.setup import (AgentSetup, ConfigSetup, EnvironmentSetup, GitSafetySetup,
|
|
11
|
+
SetupCoordinator, UndoSetup)
|
|
12
|
+
from tunacode.core.state import StateManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def setup(run_setup: bool, state_manager: StateManager) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Setup TunaCode on startup using the new setup coordinator.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
run_setup (bool): If True, force run the setup process, resetting current config.
|
|
21
|
+
state_manager (StateManager): The state manager instance.
|
|
22
|
+
"""
|
|
23
|
+
coordinator = SetupCoordinator(state_manager)
|
|
24
|
+
|
|
25
|
+
# Register setup steps in order
|
|
26
|
+
coordinator.register_step(ConfigSetup(state_manager))
|
|
27
|
+
coordinator.register_step(EnvironmentSetup(state_manager))
|
|
28
|
+
coordinator.register_step(GitSafetySetup(state_manager)) # Run after config/env but before undo
|
|
29
|
+
coordinator.register_step(UndoSetup(state_manager))
|
|
30
|
+
|
|
31
|
+
# Run all setup steps
|
|
32
|
+
await coordinator.run_setup(force_setup=run_setup)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def setup_agent(agent: Optional[Any], state_manager: StateManager) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Setup the agent separately.
|
|
38
|
+
|
|
39
|
+
This is called from other parts of the codebase when an agent needs to be initialized.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
agent: The agent instance to initialize.
|
|
43
|
+
state_manager (StateManager): The state manager instance.
|
|
44
|
+
"""
|
|
45
|
+
if agent is not None:
|
|
46
|
+
agent_setup = AgentSetup(state_manager, agent)
|
|
47
|
+
if await agent_setup.should_run():
|
|
48
|
+
await agent_setup.execute()
|
|
49
|
+
if not await agent_setup.validate():
|
|
50
|
+
raise RuntimeError("Agent setup failed validation")
|
|
File without changes
|
tunacode/tools/base.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Base tool class for all Sidekick tools.
|
|
2
|
+
|
|
3
|
+
This module provides a base class that implements common patterns
|
|
4
|
+
for all tools including error handling, UI logging, and ModelRetry support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
10
|
+
|
|
11
|
+
from tunacode.exceptions import FileOperationError, ToolExecutionError
|
|
12
|
+
from tunacode.types import FilePath, ToolName, ToolResult, UILogger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseTool(ABC):
|
|
16
|
+
"""Base class for all Sidekick tools providing common functionality."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, ui_logger: UILogger | None = None):
|
|
19
|
+
"""Initialize the base tool.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
ui_logger: UI logger instance for displaying messages
|
|
23
|
+
"""
|
|
24
|
+
self.ui = ui_logger
|
|
25
|
+
|
|
26
|
+
async def execute(self, *args, **kwargs) -> ToolResult:
|
|
27
|
+
"""Execute the tool with error handling and logging.
|
|
28
|
+
|
|
29
|
+
This method wraps the tool-specific logic with:
|
|
30
|
+
- UI logging of the operation
|
|
31
|
+
- Exception handling (except ModelRetry and ToolExecutionError)
|
|
32
|
+
- Consistent error message formatting
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str: Success message
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ModelRetry: Re-raised to guide the LLM
|
|
39
|
+
ToolExecutionError: Raised for all other errors with structured information
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
if self.ui:
|
|
43
|
+
await self.ui.info(f"{self.tool_name}({self._format_args(*args, **kwargs)})")
|
|
44
|
+
result = await self._execute(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
# For file operations, try to create a git commit for undo tracking
|
|
47
|
+
if isinstance(self, FileBasedTool):
|
|
48
|
+
await self._commit_for_undo()
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
except ModelRetry as e:
|
|
52
|
+
# Log as warning and re-raise for pydantic-ai
|
|
53
|
+
if self.ui:
|
|
54
|
+
await self.ui.warning(str(e))
|
|
55
|
+
raise
|
|
56
|
+
except ToolExecutionError:
|
|
57
|
+
# Already properly formatted, just re-raise
|
|
58
|
+
raise
|
|
59
|
+
except Exception as e:
|
|
60
|
+
# Handle any other exceptions
|
|
61
|
+
await self._handle_error(e, *args, **kwargs)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def tool_name(self) -> ToolName:
|
|
66
|
+
"""Return the display name for this tool."""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def _execute(self, *args, **kwargs) -> ToolResult:
|
|
71
|
+
"""Implement tool-specific logic here.
|
|
72
|
+
|
|
73
|
+
This method should contain the core functionality of the tool.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
str: Success message describing what was done
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ModelRetry: When the LLM needs guidance
|
|
80
|
+
Exception: Any other errors will be caught and handled
|
|
81
|
+
"""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult:
|
|
85
|
+
"""Handle errors by logging and raising proper exceptions.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
error: The exception that was raised
|
|
89
|
+
*args, **kwargs: Original arguments for context
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ToolExecutionError: Always raised with structured error information
|
|
93
|
+
"""
|
|
94
|
+
# Format error message for display
|
|
95
|
+
err_msg = f"Error {self._get_error_context(*args, **kwargs)}: {error}"
|
|
96
|
+
if self.ui:
|
|
97
|
+
await self.ui.error(err_msg)
|
|
98
|
+
|
|
99
|
+
# Raise proper exception instead of returning string
|
|
100
|
+
raise ToolExecutionError(tool_name=self.tool_name, message=str(error), original_error=error)
|
|
101
|
+
|
|
102
|
+
def _format_args(self, *args, **kwargs) -> str:
|
|
103
|
+
"""Format arguments for display in UI logging.
|
|
104
|
+
|
|
105
|
+
Override this method to customize how arguments are displayed.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
str: Formatted argument string
|
|
109
|
+
"""
|
|
110
|
+
# Collect all arguments
|
|
111
|
+
all_args = []
|
|
112
|
+
|
|
113
|
+
# Add positional arguments
|
|
114
|
+
for arg in args:
|
|
115
|
+
if isinstance(arg, str) and len(arg) > 50:
|
|
116
|
+
# Truncate long strings
|
|
117
|
+
all_args.append(f"'{arg[:47]}...'")
|
|
118
|
+
else:
|
|
119
|
+
all_args.append(repr(arg))
|
|
120
|
+
|
|
121
|
+
# Add keyword arguments
|
|
122
|
+
for key, value in kwargs.items():
|
|
123
|
+
if isinstance(value, str) and len(value) > 50:
|
|
124
|
+
all_args.append(f"{key}='{value[:47]}...'")
|
|
125
|
+
else:
|
|
126
|
+
all_args.append(f"{key}={repr(value)}")
|
|
127
|
+
|
|
128
|
+
return ", ".join(all_args)
|
|
129
|
+
|
|
130
|
+
def _get_error_context(self, *args, **kwargs) -> str:
|
|
131
|
+
"""Get context string for error messages.
|
|
132
|
+
|
|
133
|
+
Override this method to provide tool-specific error context.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
str: Context for the error message
|
|
137
|
+
"""
|
|
138
|
+
return f"in {self.tool_name}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class FileBasedTool(BaseTool):
|
|
142
|
+
"""Base class for tools that work with files.
|
|
143
|
+
|
|
144
|
+
Provides common file-related functionality like:
|
|
145
|
+
- Path validation
|
|
146
|
+
- File existence checking
|
|
147
|
+
- Directory creation
|
|
148
|
+
- Encoding handling
|
|
149
|
+
- Git commit for undo tracking
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
async def _commit_for_undo(self) -> None:
|
|
153
|
+
"""Create a git commit for undo tracking after file operations.
|
|
154
|
+
|
|
155
|
+
This method gracefully handles cases where git is not available:
|
|
156
|
+
- No git repository: Warns user about limited undo functionality
|
|
157
|
+
- Git command fails: Warns but doesn't break the main operation
|
|
158
|
+
- Any other error: Silently continues (file operation still succeeds)
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
# Import here to avoid circular imports
|
|
162
|
+
from tunacode.services.undo_service import commit_for_undo, is_in_git_project
|
|
163
|
+
|
|
164
|
+
# Check if we're in a git project first
|
|
165
|
+
if not is_in_git_project():
|
|
166
|
+
if self.ui:
|
|
167
|
+
await self.ui.muted("⚠️ No git repository - undo functionality limited")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Try to create commit with tool name as prefix
|
|
171
|
+
success = commit_for_undo(message_prefix=f"tunacode {self.tool_name.lower()}")
|
|
172
|
+
if success and self.ui:
|
|
173
|
+
await self.ui.muted("• Git commit created for undo tracking")
|
|
174
|
+
elif self.ui:
|
|
175
|
+
await self.ui.muted("⚠️ Could not create git commit - undo may not work")
|
|
176
|
+
except Exception:
|
|
177
|
+
# Silently ignore commit errors - don't break the main file operation
|
|
178
|
+
# The file operation itself succeeded, we just can't track it for undo
|
|
179
|
+
if self.ui:
|
|
180
|
+
try:
|
|
181
|
+
await self.ui.muted("⚠️ Git commit failed - undo functionality limited")
|
|
182
|
+
except:
|
|
183
|
+
# Even the warning failed, just continue silently
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def _format_args(self, filepath: FilePath, *args, **kwargs) -> str:
|
|
187
|
+
"""Format arguments with filepath as first argument."""
|
|
188
|
+
# Always show the filepath first
|
|
189
|
+
all_args = [repr(filepath)]
|
|
190
|
+
|
|
191
|
+
# Add remaining positional arguments
|
|
192
|
+
for arg in args:
|
|
193
|
+
if isinstance(arg, str) and len(arg) > 50:
|
|
194
|
+
all_args.append(f"'{arg[:47]}...'")
|
|
195
|
+
else:
|
|
196
|
+
all_args.append(repr(arg))
|
|
197
|
+
|
|
198
|
+
# Add keyword arguments
|
|
199
|
+
for key, value in kwargs.items():
|
|
200
|
+
if isinstance(value, str) and len(value) > 50:
|
|
201
|
+
all_args.append(f"{key}='{value[:47]}...'")
|
|
202
|
+
else:
|
|
203
|
+
all_args.append(f"{key}={repr(value)}")
|
|
204
|
+
|
|
205
|
+
return ", ".join(all_args)
|
|
206
|
+
|
|
207
|
+
def _get_error_context(self, filepath: FilePath = None, *args, **kwargs) -> str:
|
|
208
|
+
"""Get error context including file path."""
|
|
209
|
+
if filepath:
|
|
210
|
+
return f"handling file '{filepath}'"
|
|
211
|
+
return super()._get_error_context(*args, **kwargs)
|
|
212
|
+
|
|
213
|
+
async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult:
|
|
214
|
+
"""Handle file-specific errors.
|
|
215
|
+
|
|
216
|
+
Overrides base class to create FileOperationError for file-related issues.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ToolExecutionError: Always raised with structured error information
|
|
220
|
+
"""
|
|
221
|
+
filepath = args[0] if args else kwargs.get("filepath", "unknown")
|
|
222
|
+
|
|
223
|
+
# Check if this is a file-related error
|
|
224
|
+
if isinstance(error, (IOError, OSError, PermissionError, FileNotFoundError)):
|
|
225
|
+
# Determine the operation based on the tool name
|
|
226
|
+
operation = self.tool_name.replace("_", " ")
|
|
227
|
+
|
|
228
|
+
# Create a FileOperationError
|
|
229
|
+
file_error = FileOperationError(
|
|
230
|
+
operation=operation, path=str(filepath), message=str(error), original_error=error
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Format error message for display
|
|
234
|
+
err_msg = str(file_error)
|
|
235
|
+
if self.ui:
|
|
236
|
+
await self.ui.error(err_msg)
|
|
237
|
+
|
|
238
|
+
# Raise ToolExecutionError with the file error
|
|
239
|
+
raise ToolExecutionError(
|
|
240
|
+
tool_name=self.tool_name, message=str(file_error), original_error=file_error
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# For non-file errors, use the base class handling
|
|
244
|
+
await super()._handle_error(error, *args, **kwargs)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: sidekick.tools.read_file
|
|
3
|
+
|
|
4
|
+
File reading tool for agent operations in the Sidekick application.
|
|
5
|
+
Provides safe file reading with size limits and proper error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from tunacode.constants import (ERROR_FILE_DECODE, ERROR_FILE_DECODE_DETAILS, ERROR_FILE_NOT_FOUND,
|
|
11
|
+
ERROR_FILE_TOO_LARGE, MAX_FILE_SIZE, MSG_FILE_SIZE_LIMIT)
|
|
12
|
+
from tunacode.exceptions import ToolExecutionError
|
|
13
|
+
from tunacode.tools.base import FileBasedTool
|
|
14
|
+
from tunacode.types import FilePath, ToolResult
|
|
15
|
+
from tunacode.ui import console as default_ui
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReadFileTool(FileBasedTool):
|
|
19
|
+
"""Tool for reading file contents."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def tool_name(self) -> str:
|
|
23
|
+
return "Read"
|
|
24
|
+
|
|
25
|
+
async def _execute(self, filepath: FilePath) -> ToolResult:
|
|
26
|
+
"""Read the contents of a file.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
filepath: The path to the file to read.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ToolResult: The contents of the file or an error message.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
Exception: Any file reading errors
|
|
36
|
+
"""
|
|
37
|
+
# Add a size limit to prevent reading huge files
|
|
38
|
+
if os.path.getsize(filepath) > MAX_FILE_SIZE:
|
|
39
|
+
err_msg = ERROR_FILE_TOO_LARGE.format(filepath=filepath) + MSG_FILE_SIZE_LIMIT
|
|
40
|
+
if self.ui:
|
|
41
|
+
await self.ui.error(err_msg)
|
|
42
|
+
raise ToolExecutionError(tool_name=self.tool_name, message=err_msg, original_error=None)
|
|
43
|
+
|
|
44
|
+
with open(filepath, "r", encoding="utf-8") as file:
|
|
45
|
+
content = file.read()
|
|
46
|
+
return content
|
|
47
|
+
|
|
48
|
+
async def _handle_error(self, error: Exception, filepath: FilePath = None) -> ToolResult:
|
|
49
|
+
"""Handle errors with specific messages for common cases.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ToolExecutionError: Always raised with structured error information
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(error, FileNotFoundError):
|
|
55
|
+
err_msg = ERROR_FILE_NOT_FOUND.format(filepath=filepath)
|
|
56
|
+
elif isinstance(error, UnicodeDecodeError):
|
|
57
|
+
err_msg = (
|
|
58
|
+
ERROR_FILE_DECODE.format(filepath=filepath)
|
|
59
|
+
+ " "
|
|
60
|
+
+ ERROR_FILE_DECODE_DETAILS.format(error=error)
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
# Use parent class handling for other errors
|
|
64
|
+
await super()._handle_error(error, filepath)
|
|
65
|
+
return # super() will raise, this is unreachable
|
|
66
|
+
|
|
67
|
+
if self.ui:
|
|
68
|
+
await self.ui.error(err_msg)
|
|
69
|
+
|
|
70
|
+
raise ToolExecutionError(tool_name=self.tool_name, message=err_msg, original_error=error)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Create the function that maintains the existing interface
|
|
74
|
+
async def read_file(filepath: FilePath) -> ToolResult:
|
|
75
|
+
"""
|
|
76
|
+
Read the contents of a file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
filepath (FilePath): The path to the file to read.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ToolResult: The contents of the file or an error message.
|
|
83
|
+
"""
|
|
84
|
+
tool = ReadFileTool(default_ui)
|
|
85
|
+
try:
|
|
86
|
+
return await tool.execute(filepath)
|
|
87
|
+
except ToolExecutionError as e:
|
|
88
|
+
# Return error message for pydantic-ai compatibility
|
|
89
|
+
return str(e)
|