tunacode-cli 0.0.9__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.9.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.9.dist-info/METADATA +0 -321
  43. tunacode_cli-0.0.9.dist-info/RECORD +0 -73
  44. {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.10.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.10.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.9.dist-info → tunacode_cli-0.0.10.dist-info}/top_level.txt +0 -0
@@ -2,28 +2,49 @@
2
2
 
3
3
  Main agent functionality and coordination for the Sidekick CLI.
4
4
  Provides agent creation, message processing, and tool call management.
5
- Now using tinyAgent instead of pydantic-ai.
6
5
  """
7
6
 
7
+ from datetime import datetime, timezone
8
8
  from typing import Optional
9
9
 
10
- from tunacode.core.state import StateManager
11
- from tunacode.types import AgentRun, ErrorMessage, ModelName, ToolCallback
12
-
13
- # Import tinyAgent implementation
14
- from .tinyagent_main import get_or_create_react_agent
15
- from .tinyagent_main import patch_tool_messages as tinyagent_patch_tool_messages
16
- from .tinyagent_main import process_request_with_tinyagent
17
-
18
- # Wrapper functions for backward compatibility with pydantic-ai interface
10
+ from pydantic_ai import Agent, Tool
11
+ from pydantic_ai.messages import ModelRequest, ToolReturnPart
19
12
 
20
-
21
- def get_or_create_agent(model: ModelName, state_manager: StateManager):
22
- """
23
- Wrapper for backward compatibility.
24
- Returns the ReactAgent instance from tinyAgent.
25
- """
26
- return get_or_create_react_agent(model, state_manager)
13
+ from tunacode.core.state import StateManager
14
+ from tunacode.services.mcp import get_mcp_servers
15
+ from tunacode.tools.read_file import read_file
16
+ from tunacode.tools.run_command import run_command
17
+ from tunacode.tools.update_file import update_file
18
+ from tunacode.tools.write_file import write_file
19
+ from tunacode.types import (AgentRun, ErrorMessage, ModelName, PydanticAgent, ToolCallback,
20
+ ToolCallId, ToolName)
21
+
22
+
23
+ async def _process_node(node, tool_callback: Optional[ToolCallback], state_manager: StateManager):
24
+ if hasattr(node, "request"):
25
+ state_manager.session.messages.append(node.request)
26
+
27
+ if hasattr(node, "model_response"):
28
+ state_manager.session.messages.append(node.model_response)
29
+ for part in node.model_response.parts:
30
+ if part.part_kind == "tool-call" and tool_callback:
31
+ await tool_callback(part, node)
32
+
33
+
34
+ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
35
+ if model not in state_manager.session.agents:
36
+ max_retries = state_manager.session.user_config["settings"]["max_retries"]
37
+ state_manager.session.agents[model] = Agent(
38
+ model=model,
39
+ tools=[
40
+ Tool(read_file, max_retries=max_retries),
41
+ Tool(run_command, max_retries=max_retries),
42
+ Tool(update_file, max_retries=max_retries),
43
+ Tool(write_file, max_retries=max_retries),
44
+ ],
45
+ mcp_servers=get_mcp_servers(state_manager),
46
+ )
47
+ return state_manager.session.agents[model]
27
48
 
28
49
 
29
50
  def patch_tool_messages(
@@ -31,10 +52,57 @@ def patch_tool_messages(
31
52
  state_manager: StateManager = None,
32
53
  ):
33
54
  """
34
- Wrapper for backward compatibility.
35
- TinyAgent handles tool errors internally, so this is mostly a no-op.
55
+ Find any tool calls without responses and add synthetic error responses for them.
56
+ Takes an error message to use in the synthesized tool response.
57
+
58
+ Ignores tools that have corresponding retry prompts as the model is already
59
+ addressing them.
36
60
  """
37
- tinyagent_patch_tool_messages(error_message, state_manager)
61
+ if state_manager is None:
62
+ raise ValueError("state_manager is required for patch_tool_messages")
63
+
64
+ messages = state_manager.session.messages
65
+
66
+ if not messages:
67
+ return
68
+
69
+ # Map tool calls to their tool returns
70
+ tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
71
+ tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
72
+ retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
73
+
74
+ for message in messages:
75
+ if hasattr(message, "parts"):
76
+ for part in message.parts:
77
+ if (
78
+ hasattr(part, "part_kind")
79
+ and hasattr(part, "tool_call_id")
80
+ and part.tool_call_id
81
+ ):
82
+ if part.part_kind == "tool-call":
83
+ tool_calls[part.tool_call_id] = part.tool_name
84
+ elif part.part_kind == "tool-return":
85
+ tool_returns.add(part.tool_call_id)
86
+ elif part.part_kind == "retry-prompt":
87
+ retry_prompts.add(part.tool_call_id)
88
+
89
+ # Identify orphaned tools (those without responses and not being retried)
90
+ for tool_call_id, tool_name in list(tool_calls.items()):
91
+ if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
92
+ messages.append(
93
+ ModelRequest(
94
+ parts=[
95
+ ToolReturnPart(
96
+ tool_name=tool_name,
97
+ content=error_message,
98
+ tool_call_id=tool_call_id,
99
+ timestamp=datetime.now(timezone.utc),
100
+ part_kind="tool-return",
101
+ )
102
+ ],
103
+ kind="request",
104
+ )
105
+ )
38
106
 
39
107
 
40
108
  async def process_request(
@@ -43,35 +111,9 @@ async def process_request(
43
111
  state_manager: StateManager,
44
112
  tool_callback: Optional[ToolCallback] = None,
45
113
  ) -> AgentRun:
46
- """
47
- Process a request using tinyAgent.
48
- Returns a result that mimics the pydantic-ai AgentRun structure.
49
- """
50
- result = await process_request_with_tinyagent(model, message, state_manager, tool_callback)
51
-
52
- # Create a mock AgentRun object for compatibility
53
- class MockAgentRun:
54
- def __init__(self, result_dict):
55
- self._result = result_dict
56
-
57
- @property
58
- def result(self):
59
- class MockResult:
60
- def __init__(self, content):
61
- self._content = content
62
-
63
- @property
64
- def output(self):
65
- return self._content
66
-
67
- return MockResult(self._result.get("result", ""))
68
-
69
- @property
70
- def messages(self):
71
- return state_manager.session.messages
72
-
73
- @property
74
- def model(self):
75
- return self._result.get("model", model)
76
-
77
- return MockAgentRun(result)
114
+ agent = get_or_create_agent(model, state_manager)
115
+ mh = state_manager.session.messages.copy()
116
+ async with agent.iter(message, message_history=mh) as agent_run:
117
+ async for node in agent_run:
118
+ await _process_node(node, tool_callback, state_manager)
119
+ return agent_run
@@ -1,4 +1,4 @@
1
- """Module: sidekick.core.setup.agent_setup
1
+ """Module: tinyagent.core.setup.agent_setup
2
2
 
3
3
  Agent initialization and configuration for the Sidekick CLI.
4
4
  Handles the setup and validation of AI agents with the selected model.
@@ -1,4 +1,4 @@
1
- """Module: sidekick.core.setup.config_setup
1
+ """Module: tinyagent.core.setup.config_setup
2
2
 
3
3
  Configuration system initialization for the Sidekick CLI.
4
4
  Handles user configuration loading, validation, and first-time setup onboarding.
@@ -27,6 +27,7 @@ class ConfigSetup(BaseSetup):
27
27
  self.config_dir: ConfigPath = Path.home() / ".config"
28
28
  self.config_file: ConfigFile = self.config_dir / CONFIG_FILE_NAME
29
29
  self.model_registry = ModelRegistry()
30
+ self.cli_config = None # Will be set if CLI args are provided
30
31
 
31
32
  @property
32
33
  def name(self) -> str:
@@ -41,17 +42,26 @@ class ConfigSetup(BaseSetup):
41
42
  self.state_manager.session.device_id = system.get_device_id()
42
43
  loaded_config = user_configuration.load_config()
43
44
 
45
+ # Handle CLI configuration if provided
46
+ if self.cli_config and any(self.cli_config.values()):
47
+ await self._handle_cli_config(loaded_config)
48
+ return
49
+
44
50
  if loaded_config and not force_setup:
45
51
  # Silent loading
46
52
  # Merge loaded config with defaults to ensure all required keys exist
47
- self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
53
+ self.state_manager.session.user_config = self._merge_with_defaults(
54
+ loaded_config
55
+ )
48
56
  else:
49
57
  if force_setup:
50
58
  await ui.muted("Running setup process, resetting config")
51
59
  else:
52
60
  await ui.muted("No user configuration found, running setup")
53
61
  self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
54
- user_configuration.save_config(self.state_manager) # Save the default config initially
62
+ user_configuration.save_config(
63
+ self.state_manager
64
+ ) # Save the default config initially
55
65
  await self._onboarding()
56
66
 
57
67
  if not self.state_manager.session.user_config.get("default_model"):
@@ -62,21 +72,11 @@ class ConfigSetup(BaseSetup):
62
72
  )
63
73
  )
64
74
 
65
- # Check if the configured model still exists
66
- default_model = self.state_manager.session.user_config["default_model"]
67
- if not self.model_registry.get_model(default_model):
68
- # If model not found, run the onboarding again
69
- await ui.panel(
70
- "Model Not Found",
71
- f"The configured model '[bold]{default_model}[/bold]' is no longer available.\n"
72
- "Let's reconfigure your setup.",
73
- border_style=UI_COLORS["warning"],
74
- )
75
- await self._onboarding()
75
+ # No model validation - trust user's model choice
76
76
 
77
- self.state_manager.session.current_model = self.state_manager.session.user_config[
78
- "default_model"
79
- ]
77
+ self.state_manager.session.current_model = (
78
+ self.state_manager.session.user_config["default_model"]
79
+ )
80
80
 
81
81
  async def validate(self) -> bool:
82
82
  """Validate that configuration is properly set up."""
@@ -88,10 +88,7 @@ class ConfigSetup(BaseSetup):
88
88
  if not self.state_manager.session.user_config.get("default_model"):
89
89
  return False
90
90
 
91
- # Check that the default model is valid
92
- default_model = self.state_manager.session.user_config["default_model"]
93
- if not self.model_registry.get_model(default_model):
94
- return False
91
+ # No model validation - trust user input
95
92
 
96
93
  return True
97
94
 
@@ -112,76 +109,149 @@ class ConfigSetup(BaseSetup):
112
109
 
113
110
  async def _onboarding(self):
114
111
  """Run the onboarding process for new users."""
115
- initial_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
116
-
117
- # Welcome message
118
- message = (
119
- f"Welcome to {APP_NAME}!\n\n"
120
- "Let's configure your AI provider. TunaCode supports:\n"
121
- "• OpenAI (api.openai.com)\n"
122
- "• OpenRouter (openrouter.ai) - Access 100+ models\n"
123
- "• Any OpenAI-compatible API\n"
112
+ initial_config = json.dumps(
113
+ self.state_manager.session.user_config, sort_keys=True
124
114
  )
125
- await ui.panel("Setup", message, border_style=UI_COLORS["primary"])
126
115
 
127
- # Step 1: Ask for base URL
128
- base_url = await ui.input(
129
- "step1",
130
- pretext=" API Base URL (press Enter for OpenAI): ",
131
- default="https://api.openai.com/v1",
132
- state_manager=self.state_manager,
133
- )
134
- base_url = base_url.strip()
135
- if not base_url:
136
- base_url = "https://api.openai.com/v1"
137
-
138
- # Step 2: Ask for API key
139
- if "openrouter.ai" in base_url.lower():
140
- key_prompt = " OpenRouter API Key: "
141
- key_name = "OPENROUTER_API_KEY"
142
- default_model = "openrouter:openai/gpt-4o-mini"
143
- else:
144
- key_prompt = " API Key: "
145
- key_name = "OPENAI_API_KEY"
146
- default_model = "openai:gpt-4o"
116
+ await self._step1_api_keys()
147
117
 
148
- api_key = await ui.input(
149
- "step2",
150
- pretext=key_prompt,
151
- is_password=True,
152
- state_manager=self.state_manager,
153
- )
154
- api_key = api_key.strip()
118
+ # Only continue if at least one API key was provided
119
+ env = self.state_manager.session.user_config.get("env", {})
120
+ has_api_key = any(key.endswith("_API_KEY") and env.get(key) for key in env)
155
121
 
156
- if api_key:
157
- # Set the environment variable
158
- self.state_manager.session.user_config["env"][key_name] = api_key
159
-
160
- # Set base URL in environment for OpenRouter
161
- if "openrouter.ai" in base_url.lower():
162
- import os
163
- os.environ["OPENAI_BASE_URL"] = base_url
164
-
165
- # Set default model
166
- self.state_manager.session.user_config["default_model"] = default_model
122
+ if has_api_key:
123
+ if not self.state_manager.session.user_config.get("default_model"):
124
+ await self._step2_default_model()
167
125
 
168
- # Save configuration
169
- current_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
126
+ # Compare configs to see if anything changed
127
+ current_config = json.dumps(
128
+ self.state_manager.session.user_config, sort_keys=True
129
+ )
170
130
  if initial_config != current_config:
171
131
  if user_configuration.save_config(self.state_manager):
172
- message = (
173
- f"✅ Configuration saved!\n\n"
174
- f"Default model: {default_model}\n"
175
- f"Config file: {self.config_file}\n\n"
176
- f"You can change models anytime with /model"
132
+ message = f"Config saved to: [bold]{self.config_file}[/bold]"
133
+ await ui.panel(
134
+ "Finished", message, top=0, border_style=UI_COLORS["success"]
177
135
  )
178
- await ui.panel("Setup Complete", message, top=0, border_style=UI_COLORS["success"])
179
136
  else:
180
137
  await ui.error("Failed to save configuration.")
181
138
  else:
182
139
  await ui.panel(
183
140
  "Setup canceled",
184
- "An API key is required to use TunaCode.",
141
+ "At least one API key is required.",
185
142
  border_style=UI_COLORS["warning"],
186
143
  )
187
144
 
145
+ async def _step1_api_keys(self):
146
+ """Onboarding step 1: Collect API keys."""
147
+ message = (
148
+ f"Welcome to {APP_NAME}!\n"
149
+ "Let's get you setup. First, we'll need to set some environment variables.\n"
150
+ "Skip the ones you don't need."
151
+ )
152
+ await ui.panel("Setup", message, border_style=UI_COLORS["primary"])
153
+ env_keys = self.state_manager.session.user_config["env"].copy()
154
+ for key in env_keys:
155
+ provider = key_to_title(key)
156
+ val = await ui.input(
157
+ "step1",
158
+ pretext=f" {provider}: ",
159
+ is_password=True,
160
+ state_manager=self.state_manager,
161
+ )
162
+ val = val.strip()
163
+ if val:
164
+ self.state_manager.session.user_config["env"][key] = val
165
+
166
+ async def _step2_default_model(self):
167
+ """Onboarding step 2: Select default model."""
168
+ message = "Which model would you like to use by default?\n\n"
169
+
170
+ model_ids = self.model_registry.list_model_ids()
171
+ for index, model_id in enumerate(model_ids):
172
+ message += f" {index} - {model_id}\n"
173
+ message = message.strip()
174
+
175
+ await ui.panel("Default Model", message, border_style=UI_COLORS["primary"])
176
+ choice = await ui.input(
177
+ "step2",
178
+ pretext=" Default model (#): ",
179
+ validator=ui.ModelValidator(len(model_ids)),
180
+ state_manager=self.state_manager,
181
+ )
182
+ self.state_manager.session.user_config["default_model"] = model_ids[int(choice)]
183
+
184
+ async def _step2_default_model_simple(self):
185
+ """Simple model selection - just enter model name."""
186
+ await ui.muted("Format: provider:model-name")
187
+ await ui.muted("Examples: openai:gpt-4.1, anthropic:claude-3-opus, google-gla:gemini-2.0-flash")
188
+
189
+ while True:
190
+ model_name = await ui.input(
191
+ "step2",
192
+ pretext=" Model (provider:name): ",
193
+ state_manager=self.state_manager,
194
+ )
195
+ model_name = model_name.strip()
196
+
197
+ # Check if provider prefix is present
198
+ if ":" not in model_name:
199
+ await ui.error("Model name must include provider prefix")
200
+ await ui.muted("Format: provider:model-name")
201
+ await ui.muted("You can always change it later with /model")
202
+ continue
203
+
204
+ # No validation - user is responsible for correct model names
205
+ self.state_manager.session.user_config["default_model"] = model_name
206
+ await ui.warning("Model set without validation - verify the model name is correct")
207
+ await ui.success(f"Selected model: {model_name}")
208
+ break
209
+
210
+ async def _handle_cli_config(self, loaded_config: UserConfig) -> None:
211
+ """Handle configuration provided via CLI arguments."""
212
+ # Start with existing config or defaults
213
+ if loaded_config:
214
+ self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
215
+ else:
216
+ self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
217
+
218
+ # Apply CLI overrides
219
+ if self.cli_config.get("key"):
220
+ # Determine which API key to set based on the model or baseurl
221
+ if self.cli_config.get("baseurl") and "openrouter" in self.cli_config["baseurl"]:
222
+ self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = self.cli_config["key"]
223
+ elif self.cli_config.get("model"):
224
+ if "claude" in self.cli_config["model"] or "anthropic" in self.cli_config["model"]:
225
+ self.state_manager.session.user_config["env"]["ANTHROPIC_API_KEY"] = self.cli_config["key"]
226
+ elif "gpt" in self.cli_config["model"] or "openai" in self.cli_config["model"]:
227
+ self.state_manager.session.user_config["env"]["OPENAI_API_KEY"] = self.cli_config["key"]
228
+ elif "gemini" in self.cli_config["model"]:
229
+ self.state_manager.session.user_config["env"]["GEMINI_API_KEY"] = self.cli_config["key"]
230
+ else:
231
+ # Default to OpenRouter for unknown models
232
+ self.state_manager.session.user_config["env"]["OPENROUTER_API_KEY"] = self.cli_config["key"]
233
+
234
+ if self.cli_config.get("baseurl"):
235
+ self.state_manager.session.user_config["env"]["OPENAI_BASE_URL"] = self.cli_config["baseurl"]
236
+
237
+ if self.cli_config.get("model"):
238
+ model = self.cli_config["model"]
239
+ # Require provider prefix
240
+ if ":" not in model:
241
+ raise ConfigurationError(
242
+ f"Model '{model}' must include provider prefix\n"
243
+ "Format: provider:model-name\n"
244
+ "Examples: openai:gpt-4.1, anthropic:claude-3-opus"
245
+ )
246
+
247
+ self.state_manager.session.user_config["default_model"] = model
248
+
249
+ # Set current model
250
+ self.state_manager.session.current_model = self.state_manager.session.user_config["default_model"]
251
+
252
+ # Save the configuration
253
+ if user_configuration.save_config(self.state_manager):
254
+ await ui.warning("Model set without validation - verify the model name is correct")
255
+ await ui.success(f"Configuration saved to: {self.config_file}")
256
+ else:
257
+ await ui.error("Failed to save configuration.")
@@ -1,4 +1,4 @@
1
- """Module: sidekick.core.setup.coordinator
1
+ """Module: tinyagent.core.setup.coordinator
2
2
 
3
3
  Setup orchestration and coordination for the Sidekick CLI.
4
4
  Manages the execution order and validation of all registered setup steps.
@@ -32,7 +32,9 @@ class SetupCoordinator:
32
32
 
33
33
  if not await step.validate():
34
34
  await ui.error(f"Setup validation failed: {step.name}")
35
- raise RuntimeError(f"Setup step '{step.name}' failed validation")
35
+ raise RuntimeError(
36
+ f"Setup step '{step.name}' failed validation"
37
+ )
36
38
  else:
37
39
  # Skip silently
38
40
  pass
@@ -1,4 +1,4 @@
1
- """Module: sidekick.core.setup.environment_setup
1
+ """Module: tunacode.core.setup.environment_setup
2
2
 
3
3
  Environment detection and configuration for the Sidekick CLI.
4
4
  Handles setting up environment variables from user configuration.