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.

Files changed (65) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/__init__.py +4 -0
  3. tunacode/cli/commands.py +632 -0
  4. tunacode/cli/main.py +47 -0
  5. tunacode/cli/repl.py +251 -0
  6. tunacode/configuration/__init__.py +1 -0
  7. tunacode/configuration/defaults.py +26 -0
  8. tunacode/configuration/models.py +69 -0
  9. tunacode/configuration/settings.py +32 -0
  10. tunacode/constants.py +129 -0
  11. tunacode/context.py +83 -0
  12. tunacode/core/__init__.py +0 -0
  13. tunacode/core/agents/__init__.py +0 -0
  14. tunacode/core/agents/main.py +119 -0
  15. tunacode/core/setup/__init__.py +17 -0
  16. tunacode/core/setup/agent_setup.py +41 -0
  17. tunacode/core/setup/base.py +37 -0
  18. tunacode/core/setup/config_setup.py +179 -0
  19. tunacode/core/setup/coordinator.py +45 -0
  20. tunacode/core/setup/environment_setup.py +62 -0
  21. tunacode/core/setup/git_safety_setup.py +188 -0
  22. tunacode/core/setup/undo_setup.py +32 -0
  23. tunacode/core/state.py +43 -0
  24. tunacode/core/tool_handler.py +57 -0
  25. tunacode/exceptions.py +105 -0
  26. tunacode/prompts/system.txt +71 -0
  27. tunacode/py.typed +0 -0
  28. tunacode/services/__init__.py +1 -0
  29. tunacode/services/mcp.py +86 -0
  30. tunacode/services/undo_service.py +244 -0
  31. tunacode/setup.py +50 -0
  32. tunacode/tools/__init__.py +0 -0
  33. tunacode/tools/base.py +244 -0
  34. tunacode/tools/read_file.py +89 -0
  35. tunacode/tools/run_command.py +107 -0
  36. tunacode/tools/update_file.py +117 -0
  37. tunacode/tools/write_file.py +82 -0
  38. tunacode/types.py +259 -0
  39. tunacode/ui/__init__.py +1 -0
  40. tunacode/ui/completers.py +129 -0
  41. tunacode/ui/console.py +74 -0
  42. tunacode/ui/constants.py +16 -0
  43. tunacode/ui/decorators.py +59 -0
  44. tunacode/ui/input.py +95 -0
  45. tunacode/ui/keybindings.py +27 -0
  46. tunacode/ui/lexers.py +46 -0
  47. tunacode/ui/output.py +109 -0
  48. tunacode/ui/panels.py +156 -0
  49. tunacode/ui/prompt_manager.py +117 -0
  50. tunacode/ui/tool_ui.py +187 -0
  51. tunacode/ui/validators.py +23 -0
  52. tunacode/utils/__init__.py +0 -0
  53. tunacode/utils/bm25.py +55 -0
  54. tunacode/utils/diff_utils.py +69 -0
  55. tunacode/utils/file_utils.py +41 -0
  56. tunacode/utils/ripgrep.py +17 -0
  57. tunacode/utils/system.py +336 -0
  58. tunacode/utils/text_utils.py +87 -0
  59. tunacode/utils/user_configuration.py +54 -0
  60. tunacode_cli-0.0.1.dist-info/METADATA +242 -0
  61. tunacode_cli-0.0.1.dist-info/RECORD +65 -0
  62. tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
  63. tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
  64. tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
  65. tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,119 @@
1
+ """Module: sidekick.core.agents.main
2
+
3
+ Main agent functionality and coordination for the Sidekick CLI.
4
+ Provides agent creation, message processing, and tool call management.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+ from pydantic_ai import Agent, Tool
11
+ from pydantic_ai.messages import ModelRequest, ToolReturnPart
12
+
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]
48
+
49
+
50
+ def patch_tool_messages(
51
+ error_message: ErrorMessage = "Tool operation failed",
52
+ state_manager: StateManager = None,
53
+ ):
54
+ """
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.
60
+ """
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
+ )
106
+
107
+
108
+ async def process_request(
109
+ model: ModelName,
110
+ message: str,
111
+ state_manager: StateManager,
112
+ tool_callback: Optional[ToolCallback] = None,
113
+ ) -> AgentRun:
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
@@ -0,0 +1,17 @@
1
+ from .agent_setup import AgentSetup
2
+ from .base import BaseSetup
3
+ from .config_setup import ConfigSetup
4
+ from .coordinator import SetupCoordinator
5
+ from .environment_setup import EnvironmentSetup
6
+ from .git_safety_setup import GitSafetySetup
7
+ from .undo_setup import UndoSetup
8
+
9
+ __all__ = [
10
+ "BaseSetup",
11
+ "SetupCoordinator",
12
+ "ConfigSetup",
13
+ "EnvironmentSetup",
14
+ "GitSafetySetup",
15
+ "UndoSetup",
16
+ "AgentSetup",
17
+ ]
@@ -0,0 +1,41 @@
1
+ """Module: sidekick.core.setup.agent_setup
2
+
3
+ Agent initialization and configuration for the Sidekick CLI.
4
+ Handles the setup and validation of AI agents with the selected model.
5
+ """
6
+
7
+ from typing import Any, Optional
8
+
9
+ from tunacode.core.setup.base import BaseSetup
10
+ from tunacode.core.state import StateManager
11
+ from tunacode.ui import console as ui
12
+
13
+
14
+ class AgentSetup(BaseSetup):
15
+ """Setup step for agent initialization."""
16
+
17
+ def __init__(self, state_manager: StateManager, agent: Optional[Any] = None):
18
+ super().__init__(state_manager)
19
+ self.agent = agent
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "Agent"
24
+
25
+ async def should_run(self, force_setup: bool = False) -> bool:
26
+ """Agent setup should run if an agent is provided."""
27
+ return self.agent is not None
28
+
29
+ async def execute(self, force_setup: bool = False) -> None:
30
+ """Initialize the agent with the current model."""
31
+ if self.agent is not None:
32
+ await ui.info(f"Initializing Agent({self.state_manager.session.current_model})")
33
+ self.agent.agent = self.agent.get_agent()
34
+
35
+ async def validate(self) -> bool:
36
+ """Validate that agent was initialized correctly."""
37
+ if self.agent is None:
38
+ return True # No agent to validate
39
+
40
+ # Check if agent was initialized
41
+ return hasattr(self.agent, "agent") and self.agent.agent is not None
@@ -0,0 +1,37 @@
1
+ """Module: sidekick.core.setup.base
2
+
3
+ Base setup step abstraction for the Sidekick CLI initialization process.
4
+ Defines the contract that all setup steps must implement.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from tunacode.core.state import StateManager
10
+
11
+
12
+ class BaseSetup(ABC):
13
+ """Base class for all setup steps."""
14
+
15
+ def __init__(self, state_manager: StateManager):
16
+ self.state_manager = state_manager
17
+
18
+ @property
19
+ @abstractmethod
20
+ def name(self) -> str:
21
+ """Return the name of this setup step."""
22
+ pass
23
+
24
+ @abstractmethod
25
+ async def should_run(self, force_setup: bool = False) -> bool:
26
+ """Determine if this setup step should run."""
27
+ pass
28
+
29
+ @abstractmethod
30
+ async def execute(self, force_setup: bool = False) -> None:
31
+ """Execute the setup step."""
32
+ pass
33
+
34
+ @abstractmethod
35
+ async def validate(self) -> bool:
36
+ """Validate that the setup was successful."""
37
+ pass
@@ -0,0 +1,179 @@
1
+ """Module: sidekick.core.setup.config_setup
2
+
3
+ Configuration system initialization for the Sidekick CLI.
4
+ Handles user configuration loading, validation, and first-time setup onboarding.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
11
+ from tunacode.configuration.models import ModelRegistry
12
+ from tunacode.constants import APP_NAME, CONFIG_FILE_NAME, UI_COLORS
13
+ from tunacode.core.setup.base import BaseSetup
14
+ from tunacode.core.state import StateManager
15
+ from tunacode.exceptions import ConfigurationError
16
+ from tunacode.types import ConfigFile, ConfigPath, UserConfig
17
+ from tunacode.ui import console as ui
18
+ from tunacode.utils import system, user_configuration
19
+ from tunacode.utils.text_utils import key_to_title
20
+
21
+
22
+ class ConfigSetup(BaseSetup):
23
+ """Setup step for configuration and onboarding."""
24
+
25
+ def __init__(self, state_manager: StateManager):
26
+ super().__init__(state_manager)
27
+ self.config_dir: ConfigPath = Path.home() / ".config"
28
+ self.config_file: ConfigFile = self.config_dir / CONFIG_FILE_NAME
29
+ self.model_registry = ModelRegistry()
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return "Configuration"
34
+
35
+ async def should_run(self, force_setup: bool = False) -> bool:
36
+ """Config setup should always run to load and merge configuration."""
37
+ return True
38
+
39
+ async def execute(self, force_setup: bool = False) -> None:
40
+ """Setup configuration and run onboarding if needed."""
41
+ self.state_manager.session.device_id = system.get_device_id()
42
+ loaded_config = user_configuration.load_config()
43
+
44
+ if loaded_config and not force_setup:
45
+ # Silent loading
46
+ # Merge loaded config with defaults to ensure all required keys exist
47
+ self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
48
+ else:
49
+ if force_setup:
50
+ await ui.muted("Running setup process, resetting config")
51
+ else:
52
+ await ui.muted("No user configuration found, running setup")
53
+ self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
54
+ user_configuration.save_config(self.state_manager) # Save the default config initially
55
+ await self._onboarding()
56
+
57
+ if not self.state_manager.session.user_config.get("default_model"):
58
+ raise ConfigurationError(
59
+ (
60
+ f"No default model found in config at [bold]{self.config_file}[/bold]\n\n"
61
+ "Run [code]sidekick --setup[/code] to rerun the setup process."
62
+ )
63
+ )
64
+
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
+ await ui.panel(
69
+ "Model Not Found",
70
+ f"The configured model '[bold]{default_model}[/bold]' is no longer available.\n"
71
+ "Please select a new default model.",
72
+ border_style=UI_COLORS["warning"],
73
+ )
74
+ await self._step2_default_model()
75
+ user_configuration.save_config(self.state_manager)
76
+
77
+ self.state_manager.session.current_model = self.state_manager.session.user_config[
78
+ "default_model"
79
+ ]
80
+
81
+ async def validate(self) -> bool:
82
+ """Validate that configuration is properly set up."""
83
+ # Check that we have a user config
84
+ if not self.state_manager.session.user_config:
85
+ return False
86
+
87
+ # Check that we have a default model
88
+ if not self.state_manager.session.user_config.get("default_model"):
89
+ return False
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
95
+
96
+ return True
97
+
98
+ def _merge_with_defaults(self, loaded_config: UserConfig) -> UserConfig:
99
+ """Merge loaded config with defaults to ensure all required keys exist."""
100
+ # Start with loaded config if available, otherwise use defaults
101
+ if loaded_config:
102
+ merged = loaded_config.copy()
103
+
104
+ # Add missing top-level keys from defaults
105
+ for key, default_value in DEFAULT_USER_CONFIG.items():
106
+ if key not in merged:
107
+ merged[key] = default_value
108
+
109
+ return merged
110
+ else:
111
+ return DEFAULT_USER_CONFIG.copy()
112
+
113
+ async def _onboarding(self):
114
+ """Run the onboarding process for new users."""
115
+ initial_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
116
+
117
+ await self._step1_api_keys()
118
+
119
+ # Only continue if at least one API key was provided
120
+ env = self.state_manager.session.user_config.get("env", {})
121
+ has_api_key = any(key.endswith("_API_KEY") and env.get(key) for key in env)
122
+
123
+ if has_api_key:
124
+ if not self.state_manager.session.user_config.get("default_model"):
125
+ await self._step2_default_model()
126
+
127
+ # Compare configs to see if anything changed
128
+ current_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
129
+ if initial_config != current_config:
130
+ if user_configuration.save_config(self.state_manager):
131
+ message = f"Config saved to: [bold]{self.config_file}[/bold]"
132
+ await ui.panel("Finished", message, top=0, border_style=UI_COLORS["success"])
133
+ else:
134
+ await ui.error("Failed to save configuration.")
135
+ else:
136
+ await ui.panel(
137
+ "Setup canceled",
138
+ "At least one API key is required.",
139
+ border_style=UI_COLORS["warning"],
140
+ )
141
+
142
+ async def _step1_api_keys(self):
143
+ """Onboarding step 1: Collect API keys."""
144
+ message = (
145
+ f"Welcome to {APP_NAME}!\n"
146
+ "Let's get you setup. First, we'll need to set some environment variables.\n"
147
+ "Skip the ones you don't need."
148
+ )
149
+ await ui.panel("Setup", message, border_style=UI_COLORS["primary"])
150
+ env_keys = self.state_manager.session.user_config["env"].copy()
151
+ for key in env_keys:
152
+ provider = key_to_title(key)
153
+ val = await ui.input(
154
+ "step1",
155
+ pretext=f" {provider}: ",
156
+ is_password=True,
157
+ state_manager=self.state_manager,
158
+ )
159
+ val = val.strip()
160
+ if val:
161
+ self.state_manager.session.user_config["env"][key] = val
162
+
163
+ async def _step2_default_model(self):
164
+ """Onboarding step 2: Select default model."""
165
+ message = "Which model would you like to use by default?\n\n"
166
+
167
+ model_ids = self.model_registry.list_model_ids()
168
+ for index, model_id in enumerate(model_ids):
169
+ message += f" {index} - {model_id}\n"
170
+ message = message.strip()
171
+
172
+ await ui.panel("Default Model", message, border_style=UI_COLORS["primary"])
173
+ choice = await ui.input(
174
+ "step2",
175
+ pretext=" Default model (#): ",
176
+ validator=ui.ModelValidator(len(model_ids)),
177
+ state_manager=self.state_manager,
178
+ )
179
+ self.state_manager.session.user_config["default_model"] = model_ids[int(choice)]
@@ -0,0 +1,45 @@
1
+ """Module: sidekick.core.setup.coordinator
2
+
3
+ Setup orchestration and coordination for the Sidekick CLI.
4
+ Manages the execution order and validation of all registered setup steps.
5
+ """
6
+
7
+ from typing import List
8
+
9
+ from tunacode.core.setup.base import BaseSetup
10
+ from tunacode.core.state import StateManager
11
+ from tunacode.ui import console as ui
12
+
13
+
14
+ class SetupCoordinator:
15
+ """Coordinator for running all setup steps in order."""
16
+
17
+ def __init__(self, state_manager: StateManager):
18
+ self.state_manager = state_manager
19
+ self.setup_steps: List[BaseSetup] = []
20
+
21
+ def register_step(self, step: BaseSetup) -> None:
22
+ """Register a setup step to be run."""
23
+ self.setup_steps.append(step)
24
+
25
+ async def run_setup(self, force_setup: bool = False) -> None:
26
+ """Run all registered setup steps in order."""
27
+ for step in self.setup_steps:
28
+ try:
29
+ if await step.should_run(force_setup):
30
+ # Silent setup - no messages
31
+ await step.execute(force_setup)
32
+
33
+ if not await step.validate():
34
+ await ui.error(f"Setup validation failed: {step.name}")
35
+ raise RuntimeError(f"Setup step '{step.name}' failed validation")
36
+ else:
37
+ # Skip silently
38
+ pass
39
+ except Exception as e:
40
+ await ui.error(f"Setup failed at step '{step.name}': {str(e)}")
41
+ raise
42
+
43
+ def clear_steps(self) -> None:
44
+ """Clear all registered setup steps."""
45
+ self.setup_steps.clear()
@@ -0,0 +1,62 @@
1
+ """Module: sidekick.core.setup.environment_setup
2
+
3
+ Environment detection and configuration for the Sidekick CLI.
4
+ Handles setting up environment variables from user configuration.
5
+ """
6
+
7
+ import os
8
+
9
+ from tunacode.core.setup.base import BaseSetup
10
+ from tunacode.core.state import StateManager
11
+ from tunacode.types import EnvConfig
12
+ from tunacode.ui import console as ui
13
+
14
+
15
+ class EnvironmentSetup(BaseSetup):
16
+ """Setup step for environment variables."""
17
+
18
+ def __init__(self, state_manager: StateManager):
19
+ super().__init__(state_manager)
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "Environment Variables"
24
+
25
+ async def should_run(self, force_setup: bool = False) -> bool:
26
+ """Environment setup should always run to set env vars from config."""
27
+ return True
28
+
29
+ async def execute(self, force_setup: bool = False) -> None:
30
+ """Set environment variables from the config file."""
31
+ if "env" not in self.state_manager.session.user_config or not isinstance(
32
+ self.state_manager.session.user_config["env"], dict
33
+ ):
34
+ self.state_manager.session.user_config["env"] = {}
35
+
36
+ env_dict: EnvConfig = self.state_manager.session.user_config["env"]
37
+ env_set_count = 0
38
+
39
+ for key, value in env_dict.items():
40
+ if not isinstance(value, str):
41
+ await ui.warning(f"Invalid env value in config: {key}")
42
+ continue
43
+ value = value.strip()
44
+ if value:
45
+ os.environ[key] = value
46
+ env_set_count += 1
47
+
48
+ # Silent env setup
49
+
50
+ async def validate(self) -> bool:
51
+ """Validate that environment variables were set correctly."""
52
+ # Check that at least one API key environment variable is set
53
+ env_dict = self.state_manager.session.user_config.get("env", {})
54
+ for key, value in env_dict.items():
55
+ if key.endswith("_API_KEY") and value and value.strip():
56
+ # Check if it was actually set in the environment
57
+ if os.environ.get(key) == value.strip():
58
+ return True
59
+
60
+ # If no API keys are configured, that's still valid
61
+ # (user might be using other auth methods)
62
+ return True