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,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
|