tunacode-cli 0.0.34__py3-none-any.whl → 0.0.36__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/__init__.py +62 -0
- tunacode/cli/commands/base.py +99 -0
- tunacode/cli/commands/implementations/__init__.py +37 -0
- tunacode/cli/commands/implementations/conversation.py +115 -0
- tunacode/cli/commands/implementations/debug.py +189 -0
- tunacode/cli/commands/implementations/development.py +77 -0
- tunacode/cli/commands/implementations/model.py +61 -0
- tunacode/cli/commands/implementations/system.py +177 -0
- tunacode/cli/commands/registry.py +229 -0
- tunacode/cli/repl.py +17 -5
- tunacode/configuration/settings.py +9 -2
- tunacode/constants.py +1 -1
- tunacode/core/agents/main.py +12 -2
- tunacode/core/state.py +9 -2
- tunacode/setup.py +7 -2
- tunacode/tools/read_file.py +8 -2
- tunacode/tools/read_file_async_poc.py +18 -10
- tunacode/tools/run_command.py +29 -12
- tunacode/ui/console.py +27 -4
- tunacode/ui/output.py +7 -2
- tunacode/ui/panels.py +24 -5
- tunacode/utils/security.py +208 -0
- tunacode/utils/text_utils.py +6 -2
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/METADATA +1 -1
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/RECORD +29 -20
- tunacode/cli/commands.py +0 -877
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.34.dist-info → tunacode_cli-0.0.36.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""System-level commands for TunaCode CLI."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from ....types import CommandContext
|
|
9
|
+
from ....ui import console as ui
|
|
10
|
+
from ..base import CommandCategory, CommandSpec, SimpleCommand
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HelpCommand(SimpleCommand):
|
|
14
|
+
"""Show help information."""
|
|
15
|
+
|
|
16
|
+
spec = CommandSpec(
|
|
17
|
+
name="help",
|
|
18
|
+
aliases=["/help"],
|
|
19
|
+
description="Show help information",
|
|
20
|
+
category=CommandCategory.SYSTEM,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def __init__(self, command_registry=None):
|
|
24
|
+
self._command_registry = command_registry
|
|
25
|
+
|
|
26
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
27
|
+
await ui.help(self._command_registry)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClearCommand(SimpleCommand):
|
|
31
|
+
"""Clear screen and message history."""
|
|
32
|
+
|
|
33
|
+
spec = CommandSpec(
|
|
34
|
+
name="clear",
|
|
35
|
+
aliases=["/clear"],
|
|
36
|
+
description="Clear the screen and message history",
|
|
37
|
+
category=CommandCategory.NAVIGATION,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
41
|
+
# Patch any orphaned tool calls before clearing
|
|
42
|
+
from tunacode.core.agents.main import patch_tool_messages
|
|
43
|
+
|
|
44
|
+
patch_tool_messages("Conversation cleared", context.state_manager)
|
|
45
|
+
|
|
46
|
+
await ui.clear()
|
|
47
|
+
context.state_manager.session.messages = []
|
|
48
|
+
context.state_manager.session.files_in_context.clear()
|
|
49
|
+
await ui.success("Message history and file context cleared")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RefreshConfigCommand(SimpleCommand):
|
|
53
|
+
"""Refresh configuration from defaults."""
|
|
54
|
+
|
|
55
|
+
spec = CommandSpec(
|
|
56
|
+
name="refresh",
|
|
57
|
+
aliases=["/refresh"],
|
|
58
|
+
description="Refresh configuration from defaults (useful after updates)",
|
|
59
|
+
category=CommandCategory.SYSTEM,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
63
|
+
from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
64
|
+
|
|
65
|
+
# Update current session config with latest defaults
|
|
66
|
+
for key, value in DEFAULT_USER_CONFIG.items():
|
|
67
|
+
if key not in context.state_manager.session.user_config:
|
|
68
|
+
context.state_manager.session.user_config[key] = value
|
|
69
|
+
elif isinstance(value, dict):
|
|
70
|
+
# Merge dict values, preserving user overrides
|
|
71
|
+
for subkey, subvalue in value.items():
|
|
72
|
+
if subkey not in context.state_manager.session.user_config[key]:
|
|
73
|
+
context.state_manager.session.user_config[key][subkey] = subvalue
|
|
74
|
+
|
|
75
|
+
# Show updated max_iterations
|
|
76
|
+
max_iterations = context.state_manager.session.user_config.get("settings", {}).get(
|
|
77
|
+
"max_iterations", 20
|
|
78
|
+
)
|
|
79
|
+
await ui.success(f"Configuration refreshed - max iterations: {max_iterations}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class UpdateCommand(SimpleCommand):
|
|
83
|
+
"""Update TunaCode to the latest version."""
|
|
84
|
+
|
|
85
|
+
spec = CommandSpec(
|
|
86
|
+
name="update",
|
|
87
|
+
aliases=["/update"],
|
|
88
|
+
description="Update TunaCode to the latest version",
|
|
89
|
+
category=CommandCategory.SYSTEM,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
93
|
+
await ui.info("Checking for TunaCode updates...")
|
|
94
|
+
|
|
95
|
+
# Detect installation method
|
|
96
|
+
installation_method = None
|
|
97
|
+
|
|
98
|
+
# Check if installed via pipx
|
|
99
|
+
if shutil.which("pipx"):
|
|
100
|
+
try:
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
["pipx", "list"], capture_output=True, text=True, timeout=10
|
|
103
|
+
)
|
|
104
|
+
pipx_installed = "tunacode" in result.stdout.lower()
|
|
105
|
+
if pipx_installed:
|
|
106
|
+
installation_method = "pipx"
|
|
107
|
+
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Check if installed via pip
|
|
111
|
+
if not installation_method:
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
[sys.executable, "-m", "pip", "show", "tunacode-cli"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
timeout=10,
|
|
118
|
+
)
|
|
119
|
+
if result.returncode == 0:
|
|
120
|
+
installation_method = "pip"
|
|
121
|
+
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
if not installation_method:
|
|
125
|
+
await ui.error("Could not detect TunaCode installation method")
|
|
126
|
+
await ui.muted("Manual update options:")
|
|
127
|
+
await ui.muted(" pipx: pipx upgrade tunacode")
|
|
128
|
+
await ui.muted(" pip: pip install --upgrade tunacode-cli")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Perform update based on detected method
|
|
132
|
+
try:
|
|
133
|
+
if installation_method == "pipx":
|
|
134
|
+
await ui.info("Updating via pipx...")
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
["pipx", "upgrade", "tunacode"],
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=60,
|
|
140
|
+
)
|
|
141
|
+
else: # pip
|
|
142
|
+
await ui.info("Updating via pip...")
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
[
|
|
145
|
+
sys.executable,
|
|
146
|
+
"-m",
|
|
147
|
+
"pip",
|
|
148
|
+
"install",
|
|
149
|
+
"--upgrade",
|
|
150
|
+
"tunacode-cli",
|
|
151
|
+
],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
timeout=60,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if result.returncode == 0:
|
|
158
|
+
await ui.success("TunaCode updated successfully!")
|
|
159
|
+
await ui.muted("Restart TunaCode to use the new version")
|
|
160
|
+
|
|
161
|
+
# Show update output if available
|
|
162
|
+
if result.stdout.strip():
|
|
163
|
+
output_lines = result.stdout.strip().split("\n")
|
|
164
|
+
for line in output_lines[-5:]: # Show last 5 lines
|
|
165
|
+
if line.strip():
|
|
166
|
+
await ui.muted(f" {line}")
|
|
167
|
+
else:
|
|
168
|
+
await ui.error("Update failed")
|
|
169
|
+
if result.stderr:
|
|
170
|
+
await ui.muted(f"Error: {result.stderr.strip()}")
|
|
171
|
+
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
await ui.error("Update timed out")
|
|
174
|
+
except subprocess.CalledProcessError as e:
|
|
175
|
+
await ui.error(f"Update failed: {e}")
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
await ui.error(f"Could not find {installation_method} executable")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Command registry and factory for TunaCode CLI commands."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, List, Optional, Type
|
|
5
|
+
|
|
6
|
+
from ...exceptions import ValidationError
|
|
7
|
+
from ...types import CommandArgs, CommandContext, ProcessRequestCallback
|
|
8
|
+
from .base import Command, CommandCategory
|
|
9
|
+
|
|
10
|
+
# Import all command implementations
|
|
11
|
+
from .implementations.conversation import CompactCommand
|
|
12
|
+
from .implementations.debug import (
|
|
13
|
+
DumpCommand,
|
|
14
|
+
FixCommand,
|
|
15
|
+
IterationsCommand,
|
|
16
|
+
ParseToolsCommand,
|
|
17
|
+
ThoughtsCommand,
|
|
18
|
+
YoloCommand,
|
|
19
|
+
)
|
|
20
|
+
from .implementations.development import BranchCommand, InitCommand
|
|
21
|
+
from .implementations.model import ModelCommand
|
|
22
|
+
from .implementations.system import ClearCommand, HelpCommand, RefreshConfigCommand, UpdateCommand
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CommandDependencies:
|
|
27
|
+
"""Container for command dependencies."""
|
|
28
|
+
|
|
29
|
+
process_request_callback: Optional[ProcessRequestCallback] = None
|
|
30
|
+
command_registry: Optional[Any] = None # Reference to the registry itself
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CommandFactory:
|
|
34
|
+
"""Factory for creating commands with proper dependency injection."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, dependencies: Optional[CommandDependencies] = None):
|
|
37
|
+
self.dependencies = dependencies or CommandDependencies()
|
|
38
|
+
|
|
39
|
+
def create_command(self, command_class: Type[Command]) -> Command:
|
|
40
|
+
"""Create a command instance with proper dependencies."""
|
|
41
|
+
# Special handling for commands that need dependencies
|
|
42
|
+
if command_class == CompactCommand:
|
|
43
|
+
return CompactCommand(self.dependencies.process_request_callback)
|
|
44
|
+
elif command_class == HelpCommand:
|
|
45
|
+
return HelpCommand(self.dependencies.command_registry)
|
|
46
|
+
|
|
47
|
+
# Default creation for commands without dependencies
|
|
48
|
+
return command_class()
|
|
49
|
+
|
|
50
|
+
def update_dependencies(self, **kwargs) -> None:
|
|
51
|
+
"""Update factory dependencies."""
|
|
52
|
+
for key, value in kwargs.items():
|
|
53
|
+
if hasattr(self.dependencies, key):
|
|
54
|
+
setattr(self.dependencies, key, value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CommandRegistry:
|
|
58
|
+
"""Registry for managing commands with auto-discovery and categories."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, factory: Optional[CommandFactory] = None):
|
|
61
|
+
self._commands: Dict[str, Command] = {}
|
|
62
|
+
self._categories: Dict[CommandCategory, List[Command]] = {
|
|
63
|
+
category: [] for category in CommandCategory
|
|
64
|
+
}
|
|
65
|
+
self._factory = factory or CommandFactory()
|
|
66
|
+
self._discovered = False
|
|
67
|
+
|
|
68
|
+
# Set registry reference in factory dependencies
|
|
69
|
+
self._factory.update_dependencies(command_registry=self)
|
|
70
|
+
|
|
71
|
+
def register(self, command: Command) -> None:
|
|
72
|
+
"""Register a command and its aliases."""
|
|
73
|
+
# Register by primary name
|
|
74
|
+
self._commands[command.name] = command
|
|
75
|
+
|
|
76
|
+
# Register all aliases
|
|
77
|
+
for alias in command.aliases:
|
|
78
|
+
self._commands[alias.lower()] = command
|
|
79
|
+
|
|
80
|
+
# Add to category (remove existing instance first to prevent duplicates)
|
|
81
|
+
category_commands = self._categories[command.category]
|
|
82
|
+
# Remove any existing instance of this command class
|
|
83
|
+
self._categories[command.category] = [
|
|
84
|
+
cmd for cmd in category_commands if cmd.__class__ != command.__class__
|
|
85
|
+
]
|
|
86
|
+
# Add the new instance
|
|
87
|
+
self._categories[command.category].append(command)
|
|
88
|
+
|
|
89
|
+
def register_command_class(self, command_class: Type[Command]) -> None:
|
|
90
|
+
"""Register a command class using the factory."""
|
|
91
|
+
command = self._factory.create_command(command_class)
|
|
92
|
+
self.register(command)
|
|
93
|
+
|
|
94
|
+
def discover_commands(self) -> None:
|
|
95
|
+
"""Auto-discover and register all command classes."""
|
|
96
|
+
if self._discovered:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# List of all command classes to register
|
|
100
|
+
command_classes = [
|
|
101
|
+
YoloCommand,
|
|
102
|
+
DumpCommand,
|
|
103
|
+
ThoughtsCommand,
|
|
104
|
+
IterationsCommand,
|
|
105
|
+
ClearCommand,
|
|
106
|
+
FixCommand,
|
|
107
|
+
ParseToolsCommand,
|
|
108
|
+
RefreshConfigCommand,
|
|
109
|
+
UpdateCommand,
|
|
110
|
+
HelpCommand,
|
|
111
|
+
BranchCommand,
|
|
112
|
+
CompactCommand,
|
|
113
|
+
ModelCommand,
|
|
114
|
+
InitCommand,
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Register all discovered commands
|
|
118
|
+
for command_class in command_classes:
|
|
119
|
+
self.register_command_class(command_class)
|
|
120
|
+
|
|
121
|
+
self._discovered = True
|
|
122
|
+
|
|
123
|
+
def register_all_default_commands(self) -> None:
|
|
124
|
+
"""Register all default commands (backward compatibility)."""
|
|
125
|
+
self.discover_commands()
|
|
126
|
+
|
|
127
|
+
def set_process_request_callback(self, callback: ProcessRequestCallback) -> None:
|
|
128
|
+
"""Set the process_request callback for commands that need it."""
|
|
129
|
+
# Only update if callback has changed
|
|
130
|
+
if self._factory.dependencies.process_request_callback == callback:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
self._factory.update_dependencies(process_request_callback=callback)
|
|
134
|
+
|
|
135
|
+
# Re-register CompactCommand with new dependency if already registered
|
|
136
|
+
if "compact" in self._commands:
|
|
137
|
+
self.register_command_class(CompactCommand)
|
|
138
|
+
|
|
139
|
+
async def execute(self, command_text: str, context: CommandContext) -> Any:
|
|
140
|
+
"""
|
|
141
|
+
Execute a command.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
command_text: The full command text
|
|
145
|
+
context: Execution context
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Command-specific return value, or None if command not found
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValidationError: If command is not found or empty
|
|
152
|
+
"""
|
|
153
|
+
# Ensure commands are discovered
|
|
154
|
+
self.discover_commands()
|
|
155
|
+
|
|
156
|
+
parts = command_text.split()
|
|
157
|
+
if not parts:
|
|
158
|
+
raise ValidationError("Empty command")
|
|
159
|
+
|
|
160
|
+
command_name = parts[0].lower()
|
|
161
|
+
args = parts[1:]
|
|
162
|
+
|
|
163
|
+
# First try exact match
|
|
164
|
+
if command_name in self._commands:
|
|
165
|
+
command = self._commands[command_name]
|
|
166
|
+
return await command.execute(args, context)
|
|
167
|
+
|
|
168
|
+
# Try partial matching
|
|
169
|
+
matches = self.find_matching_commands(command_name)
|
|
170
|
+
|
|
171
|
+
if not matches:
|
|
172
|
+
raise ValidationError(f"Unknown command: {command_name}")
|
|
173
|
+
elif len(matches) == 1:
|
|
174
|
+
# Unambiguous match
|
|
175
|
+
command = self._commands[matches[0]]
|
|
176
|
+
return await command.execute(args, context)
|
|
177
|
+
else:
|
|
178
|
+
# Ambiguous - show possibilities
|
|
179
|
+
matches_str = ", ".join(sorted(set(matches)))
|
|
180
|
+
raise ValidationError(
|
|
181
|
+
f"Ambiguous command '{command_name}'. Did you mean: {matches_str}?"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def find_matching_commands(self, partial_command: str) -> List[str]:
|
|
185
|
+
"""
|
|
186
|
+
Find all commands that start with the given partial command.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
partial_command: The partial command to match
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of matching command names
|
|
193
|
+
"""
|
|
194
|
+
self.discover_commands()
|
|
195
|
+
partial = partial_command.lower()
|
|
196
|
+
return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
|
|
197
|
+
|
|
198
|
+
def is_command(self, text: str) -> bool:
|
|
199
|
+
"""Check if text starts with a registered command (supports partial matching)."""
|
|
200
|
+
if not text:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
parts = text.split()
|
|
204
|
+
if not parts:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
command_name = parts[0].lower()
|
|
208
|
+
|
|
209
|
+
# Check exact match first
|
|
210
|
+
if command_name in self._commands:
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
# Check partial match
|
|
214
|
+
return len(self.find_matching_commands(command_name)) > 0
|
|
215
|
+
|
|
216
|
+
def get_command_names(self) -> CommandArgs:
|
|
217
|
+
"""Get all registered command names (including aliases)."""
|
|
218
|
+
self.discover_commands()
|
|
219
|
+
return sorted(self._commands.keys())
|
|
220
|
+
|
|
221
|
+
def get_commands_by_category(self, category: CommandCategory) -> List[Command]:
|
|
222
|
+
"""Get all commands in a specific category."""
|
|
223
|
+
self.discover_commands()
|
|
224
|
+
return self._categories.get(category, [])
|
|
225
|
+
|
|
226
|
+
def get_all_categories(self) -> Dict[CommandCategory, List[Command]]:
|
|
227
|
+
"""Get all commands organized by category."""
|
|
228
|
+
self.discover_commands()
|
|
229
|
+
return self._categories.copy()
|
tunacode/cli/repl.py
CHANGED
|
@@ -22,6 +22,7 @@ from tunacode.core.tool_handler import ToolHandler
|
|
|
22
22
|
from tunacode.exceptions import AgentError, UserAbortError, ValidationError
|
|
23
23
|
from tunacode.ui import console as ui
|
|
24
24
|
from tunacode.ui.tool_ui import ToolUI
|
|
25
|
+
from tunacode.utils.security import CommandSecurityError, safe_subprocess_run
|
|
25
26
|
|
|
26
27
|
from ..types import CommandContext, CommandResult, StateManager, ToolArgs
|
|
27
28
|
from .commands import CommandRegistry
|
|
@@ -320,13 +321,24 @@ async def repl(state_manager: StateManager):
|
|
|
320
321
|
def run_shell():
|
|
321
322
|
try:
|
|
322
323
|
if command:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
# Use secure subprocess execution for shell commands
|
|
325
|
+
# Note: User shell commands are inherently risky but this is by design
|
|
326
|
+
# We validate but allow shell features since it's explicit user intent
|
|
327
|
+
try:
|
|
328
|
+
result = safe_subprocess_run(
|
|
329
|
+
command,
|
|
330
|
+
shell=True,
|
|
331
|
+
validate=True, # Still validate for basic safety
|
|
332
|
+
capture_output=False,
|
|
333
|
+
)
|
|
334
|
+
if result.returncode != 0:
|
|
335
|
+
print(f"\nCommand exited with code {result.returncode}")
|
|
336
|
+
except CommandSecurityError as e:
|
|
337
|
+
print(f"\nSecurity validation failed: {str(e)}")
|
|
338
|
+
print("If you need to run this command, please ensure it's safe.")
|
|
327
339
|
else:
|
|
328
340
|
shell = os.environ.get("SHELL", "bash")
|
|
329
|
-
subprocess.run(shell)
|
|
341
|
+
subprocess.run(shell) # Interactive shell is safe
|
|
330
342
|
except Exception as e:
|
|
331
343
|
print(f"\nShell command failed: {str(e)}")
|
|
332
344
|
|
|
@@ -7,8 +7,15 @@ Handles configuration paths, model registries, and application metadata.
|
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from tunacode.constants import (
|
|
11
|
-
|
|
10
|
+
from tunacode.constants import (
|
|
11
|
+
APP_NAME,
|
|
12
|
+
APP_VERSION,
|
|
13
|
+
CONFIG_FILE_NAME,
|
|
14
|
+
TOOL_READ_FILE,
|
|
15
|
+
TOOL_RUN_COMMAND,
|
|
16
|
+
TOOL_UPDATE_FILE,
|
|
17
|
+
TOOL_WRITE_FILE,
|
|
18
|
+
)
|
|
12
19
|
from tunacode.types import ConfigFile, ConfigPath, ToolName
|
|
13
20
|
|
|
14
21
|
|
tunacode/constants.py
CHANGED
tunacode/core/agents/main.py
CHANGED
|
@@ -23,8 +23,18 @@ from tunacode.tools.read_file import read_file
|
|
|
23
23
|
from tunacode.tools.run_command import run_command
|
|
24
24
|
from tunacode.tools.update_file import update_file
|
|
25
25
|
from tunacode.tools.write_file import write_file
|
|
26
|
-
from tunacode.types import (
|
|
27
|
-
|
|
26
|
+
from tunacode.types import (
|
|
27
|
+
AgentRun,
|
|
28
|
+
ErrorMessage,
|
|
29
|
+
FallbackResponse,
|
|
30
|
+
ModelName,
|
|
31
|
+
PydanticAgent,
|
|
32
|
+
ResponseState,
|
|
33
|
+
SimpleResult,
|
|
34
|
+
ToolCallback,
|
|
35
|
+
ToolCallId,
|
|
36
|
+
ToolName,
|
|
37
|
+
)
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
class ToolBuffer:
|
tunacode/core/state.py
CHANGED
|
@@ -8,8 +8,15 @@ import uuid
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from typing import Any, Optional
|
|
10
10
|
|
|
11
|
-
from tunacode.types import (
|
|
12
|
-
|
|
11
|
+
from tunacode.types import (
|
|
12
|
+
DeviceId,
|
|
13
|
+
InputSessions,
|
|
14
|
+
MessageHistory,
|
|
15
|
+
ModelName,
|
|
16
|
+
SessionId,
|
|
17
|
+
ToolName,
|
|
18
|
+
UserConfig,
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
@dataclass
|
tunacode/setup.py
CHANGED
|
@@ -7,8 +7,13 @@ Provides high-level setup functions for initializing the application and its age
|
|
|
7
7
|
|
|
8
8
|
from typing import Any, Optional
|
|
9
9
|
|
|
10
|
-
from tunacode.core.setup import (
|
|
11
|
-
|
|
10
|
+
from tunacode.core.setup import (
|
|
11
|
+
AgentSetup,
|
|
12
|
+
ConfigSetup,
|
|
13
|
+
EnvironmentSetup,
|
|
14
|
+
GitSafetySetup,
|
|
15
|
+
SetupCoordinator,
|
|
16
|
+
)
|
|
12
17
|
from tunacode.core.state import StateManager
|
|
13
18
|
|
|
14
19
|
|
tunacode/tools/read_file.py
CHANGED
|
@@ -8,8 +8,14 @@ Provides safe file reading with size limits and proper error handling.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import os
|
|
10
10
|
|
|
11
|
-
from tunacode.constants import (
|
|
12
|
-
|
|
11
|
+
from tunacode.constants import (
|
|
12
|
+
ERROR_FILE_DECODE,
|
|
13
|
+
ERROR_FILE_DECODE_DETAILS,
|
|
14
|
+
ERROR_FILE_NOT_FOUND,
|
|
15
|
+
ERROR_FILE_TOO_LARGE,
|
|
16
|
+
MAX_FILE_SIZE,
|
|
17
|
+
MSG_FILE_SIZE_LIMIT,
|
|
18
|
+
)
|
|
13
19
|
from tunacode.exceptions import ToolExecutionError
|
|
14
20
|
from tunacode.tools.base import FileBasedTool
|
|
15
21
|
from tunacode.types import ToolResult
|
|
@@ -11,8 +11,14 @@ import sys
|
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from typing import Optional
|
|
13
13
|
|
|
14
|
-
from tunacode.constants import (
|
|
15
|
-
|
|
14
|
+
from tunacode.constants import (
|
|
15
|
+
ERROR_FILE_DECODE,
|
|
16
|
+
ERROR_FILE_DECODE_DETAILS,
|
|
17
|
+
ERROR_FILE_NOT_FOUND,
|
|
18
|
+
ERROR_FILE_TOO_LARGE,
|
|
19
|
+
MAX_FILE_SIZE,
|
|
20
|
+
MSG_FILE_SIZE_LIMIT,
|
|
21
|
+
)
|
|
16
22
|
from tunacode.exceptions import ToolExecutionError
|
|
17
23
|
from tunacode.tools.base import FileBasedTool
|
|
18
24
|
from tunacode.types import ToolResult
|
|
@@ -150,17 +156,18 @@ async def read_file_async(filepath: str) -> str:
|
|
|
150
156
|
# Benchmarking utilities for testing
|
|
151
157
|
async def benchmark_read_performance():
|
|
152
158
|
"""Benchmark the performance difference between sync and async reads."""
|
|
159
|
+
import contextlib
|
|
160
|
+
import tempfile
|
|
153
161
|
import time
|
|
154
162
|
|
|
155
163
|
from tunacode.tools.read_file import read_file as read_file_sync
|
|
156
164
|
|
|
157
|
-
# Create some test files
|
|
165
|
+
# Create some test files using tempfile for secure temporary file creation
|
|
158
166
|
test_files = []
|
|
159
|
-
for
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
test_files.append(filepath)
|
|
167
|
+
for _ in range(10):
|
|
168
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as temp_file:
|
|
169
|
+
temp_file.write("x" * 10000) # 10KB file
|
|
170
|
+
test_files.append(temp_file.name)
|
|
164
171
|
|
|
165
172
|
# Test synchronous reads (sequential)
|
|
166
173
|
start_time = time.time()
|
|
@@ -174,9 +181,10 @@ async def benchmark_read_performance():
|
|
|
174
181
|
await asyncio.gather(*tasks)
|
|
175
182
|
async_time = time.time() - start_time
|
|
176
183
|
|
|
177
|
-
# Cleanup
|
|
184
|
+
# Cleanup using safe file removal
|
|
178
185
|
for filepath in test_files:
|
|
179
|
-
|
|
186
|
+
with contextlib.suppress(OSError):
|
|
187
|
+
os.unlink(filepath)
|
|
180
188
|
|
|
181
189
|
print(f"Synchronous reads: {sync_time:.3f}s")
|
|
182
190
|
print(f"Async reads: {async_time:.3f}s")
|
tunacode/tools/run_command.py
CHANGED
|
@@ -7,13 +7,21 @@ Provides controlled shell command execution with output capture and truncation.
|
|
|
7
7
|
|
|
8
8
|
import subprocess
|
|
9
9
|
|
|
10
|
-
from tunacode.constants import (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
from tunacode.constants import (
|
|
11
|
+
CMD_OUTPUT_FORMAT,
|
|
12
|
+
CMD_OUTPUT_NO_ERRORS,
|
|
13
|
+
CMD_OUTPUT_NO_OUTPUT,
|
|
14
|
+
CMD_OUTPUT_TRUNCATED,
|
|
15
|
+
COMMAND_OUTPUT_END_SIZE,
|
|
16
|
+
COMMAND_OUTPUT_START_INDEX,
|
|
17
|
+
COMMAND_OUTPUT_THRESHOLD,
|
|
18
|
+
ERROR_COMMAND_EXECUTION,
|
|
19
|
+
MAX_COMMAND_OUTPUT,
|
|
20
|
+
)
|
|
14
21
|
from tunacode.exceptions import ToolExecutionError
|
|
15
22
|
from tunacode.tools.base import BaseTool
|
|
16
23
|
from tunacode.types import ToolResult
|
|
24
|
+
from tunacode.utils.security import CommandSecurityError, safe_subprocess_popen
|
|
17
25
|
|
|
18
26
|
|
|
19
27
|
class RunCommandTool(BaseTool):
|
|
@@ -34,16 +42,23 @@ class RunCommandTool(BaseTool):
|
|
|
34
42
|
|
|
35
43
|
Raises:
|
|
36
44
|
FileNotFoundError: If command not found
|
|
45
|
+
CommandSecurityError: If command fails security validation
|
|
37
46
|
Exception: Any command execution errors
|
|
38
47
|
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
try:
|
|
49
|
+
# Use secure subprocess execution with validation
|
|
50
|
+
process = safe_subprocess_popen(
|
|
51
|
+
command,
|
|
52
|
+
shell=True, # CLI tool requires shell features
|
|
53
|
+
validate=True, # Enable security validation
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.PIPE,
|
|
56
|
+
text=True,
|
|
57
|
+
)
|
|
58
|
+
stdout, stderr = process.communicate()
|
|
59
|
+
except CommandSecurityError as e:
|
|
60
|
+
# Security validation failed - return error without execution
|
|
61
|
+
return f"Security validation failed: {str(e)}"
|
|
47
62
|
output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT
|
|
48
63
|
error = stderr.strip() or CMD_OUTPUT_NO_ERRORS
|
|
49
64
|
resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip()
|
|
@@ -70,6 +85,8 @@ class RunCommandTool(BaseTool):
|
|
|
70
85
|
"""
|
|
71
86
|
if isinstance(error, FileNotFoundError):
|
|
72
87
|
err_msg = ERROR_COMMAND_EXECUTION.format(command=command, error=error)
|
|
88
|
+
elif isinstance(error, CommandSecurityError):
|
|
89
|
+
err_msg = f"Command blocked for security: {str(error)}"
|
|
73
90
|
else:
|
|
74
91
|
# Use parent class handling for other errors
|
|
75
92
|
await super()._handle_error(error, command)
|