tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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 +2 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/core/agents/utils.py
CHANGED
|
@@ -3,9 +3,8 @@ import importlib
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
import re
|
|
7
6
|
from collections.abc import Iterator
|
|
8
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
11
10
|
from tunacode.constants import (
|
|
@@ -14,13 +13,12 @@ from tunacode.constants import (
|
|
|
14
13
|
JSON_PARSE_MAX_RETRIES,
|
|
15
14
|
READ_ONLY_TOOLS,
|
|
16
15
|
)
|
|
16
|
+
|
|
17
|
+
# Re-export tool parsing functions from agent_components for backward compatibility
|
|
17
18
|
from tunacode.exceptions import ToolBatchingJSONError
|
|
18
19
|
from tunacode.types import (
|
|
19
|
-
ErrorMessage,
|
|
20
20
|
StateManager,
|
|
21
21
|
ToolCallback,
|
|
22
|
-
ToolCallId,
|
|
23
|
-
ToolName,
|
|
24
22
|
)
|
|
25
23
|
from tunacode.ui import console as ui
|
|
26
24
|
from tunacode.utils.retry import retry_json_parse_async
|
|
@@ -271,122 +269,32 @@ async def parse_json_tool_calls(
|
|
|
271
269
|
|
|
272
270
|
async def extract_and_execute_tool_calls(
|
|
273
271
|
text: str, tool_callback: ToolCallback | None, state_manager: StateManager
|
|
274
|
-
):
|
|
275
|
-
"""Extract tool calls from text content and execute them.
|
|
276
|
-
Supports multiple formats for maximum compatibility.
|
|
272
|
+
) -> int:
|
|
277
273
|
"""
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
# Format 1: {"tool": "name", "args": {...}}
|
|
282
|
-
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
283
|
-
|
|
284
|
-
# Format 2: Tool calls in code blocks
|
|
285
|
-
code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
|
|
286
|
-
code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
|
|
287
|
-
|
|
288
|
-
for match in code_matches:
|
|
289
|
-
try:
|
|
290
|
-
# Use retry logic for JSON parsing in code blocks
|
|
291
|
-
tool_data = await retry_json_parse_async(
|
|
292
|
-
match,
|
|
293
|
-
max_retries=JSON_PARSE_MAX_RETRIES,
|
|
294
|
-
base_delay=JSON_PARSE_BASE_DELAY,
|
|
295
|
-
max_delay=JSON_PARSE_MAX_DELAY,
|
|
296
|
-
)
|
|
297
|
-
if "tool" in tool_data and "args" in tool_data:
|
|
298
|
-
|
|
299
|
-
class MockToolCall:
|
|
300
|
-
def __init__(self, tool_name: str, args: dict):
|
|
301
|
-
self.tool_name = tool_name
|
|
302
|
-
self.args = args
|
|
303
|
-
self.tool_call_id = f"codeblock_{datetime.now().timestamp()}"
|
|
304
|
-
|
|
305
|
-
class MockNode:
|
|
306
|
-
pass
|
|
307
|
-
|
|
308
|
-
mock_call = MockToolCall(tool_data["tool"], tool_data["args"])
|
|
309
|
-
mock_node = MockNode()
|
|
310
|
-
|
|
311
|
-
await tool_callback(mock_call, mock_node)
|
|
312
|
-
|
|
313
|
-
if state_manager.session.show_thoughts:
|
|
314
|
-
await ui.muted(f"FALLBACK: Executed {tool_data['tool']} from code block")
|
|
315
|
-
|
|
316
|
-
except json.JSONDecodeError as e:
|
|
317
|
-
# After all retries failed
|
|
318
|
-
logger.error(
|
|
319
|
-
f"Code block JSON parsing failed after {JSON_PARSE_MAX_RETRIES} retries: {e}"
|
|
320
|
-
)
|
|
321
|
-
if state_manager.session.show_thoughts:
|
|
322
|
-
await ui.error(
|
|
323
|
-
f"Failed to parse code block tool JSON after {JSON_PARSE_MAX_RETRIES} retries"
|
|
324
|
-
)
|
|
325
|
-
# Raise custom exception for better error handling
|
|
326
|
-
raise ToolBatchingJSONError(
|
|
327
|
-
json_content=match,
|
|
328
|
-
retry_count=JSON_PARSE_MAX_RETRIES,
|
|
329
|
-
original_error=e,
|
|
330
|
-
) from e
|
|
331
|
-
except (KeyError, Exception) as e:
|
|
332
|
-
if state_manager.session.show_thoughts:
|
|
333
|
-
await ui.error(f"Error parsing code block tool call: {e!s}")
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def patch_tool_messages(
|
|
337
|
-
error_message: ErrorMessage = "Tool operation failed",
|
|
338
|
-
state_manager: StateManager = None,
|
|
339
|
-
):
|
|
340
|
-
"""Find any tool calls without responses and add synthetic error responses for them.
|
|
341
|
-
Takes an error message to use in the synthesized tool response.
|
|
274
|
+
Extract tool calls from text content and execute them.
|
|
275
|
+
Supports multiple formats for maximum compatibility.
|
|
276
|
+
Uses the enhanced parse_json_tool_calls with retry logic.
|
|
342
277
|
|
|
343
|
-
|
|
344
|
-
|
|
278
|
+
Returns:
|
|
279
|
+
int: Number of tools successfully executed
|
|
345
280
|
"""
|
|
346
|
-
if
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
):
|
|
367
|
-
if part.part_kind == "tool-call":
|
|
368
|
-
tool_calls[part.tool_call_id] = part.tool_name
|
|
369
|
-
elif part.part_kind == "tool-return":
|
|
370
|
-
tool_returns.add(part.tool_call_id)
|
|
371
|
-
elif part.part_kind == "retry-prompt":
|
|
372
|
-
retry_prompts.add(part.tool_call_id)
|
|
373
|
-
|
|
374
|
-
# Identify orphaned tools (those without responses and not being retried)
|
|
375
|
-
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
376
|
-
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
377
|
-
# Import ModelRequest and ToolReturnPart lazily
|
|
378
|
-
model_request_cls, tool_return_part_cls, _ = get_model_messages()
|
|
379
|
-
messages.append(
|
|
380
|
-
model_request_cls(
|
|
381
|
-
parts=[
|
|
382
|
-
tool_return_part_cls(
|
|
383
|
-
tool_name=tool_name,
|
|
384
|
-
content=error_message,
|
|
385
|
-
tool_call_id=tool_call_id,
|
|
386
|
-
timestamp=datetime.now(timezone.utc),
|
|
387
|
-
part_kind="tool-return",
|
|
388
|
-
)
|
|
389
|
-
],
|
|
390
|
-
kind="request",
|
|
391
|
-
)
|
|
392
|
-
)
|
|
281
|
+
if not tool_callback:
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
tools_executed = 0
|
|
285
|
+
|
|
286
|
+
# Use the enhanced parse_json_tool_calls with retry logic
|
|
287
|
+
# We need to handle this differently since our parse_json_tool_calls doesn't return a count
|
|
288
|
+
try:
|
|
289
|
+
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
290
|
+
# If we get here without error, at least one tool was likely executed
|
|
291
|
+
# For simplicity, we'll assume 1 tool was executed if no error occurred
|
|
292
|
+
tools_executed = 1
|
|
293
|
+
except ToolBatchingJSONError:
|
|
294
|
+
# Re-raise the error for proper test handling
|
|
295
|
+
raise
|
|
296
|
+
except Exception:
|
|
297
|
+
# Other exceptions mean 0 tools executed
|
|
298
|
+
tools_executed = 0
|
|
299
|
+
|
|
300
|
+
return tools_executed
|
tunacode/core/code_index.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
import threading
|
|
6
|
+
import time
|
|
6
7
|
from collections import defaultdict
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Dict, List, Optional, Set
|
|
@@ -17,6 +18,10 @@ class CodeIndex:
|
|
|
17
18
|
grep searches that can timeout in large repositories.
|
|
18
19
|
"""
|
|
19
20
|
|
|
21
|
+
# Singleton instance
|
|
22
|
+
_instance: Optional["CodeIndex"] = None
|
|
23
|
+
_instance_lock = threading.RLock()
|
|
24
|
+
|
|
20
25
|
# Directories to ignore during indexing
|
|
21
26
|
IGNORE_DIRS = {
|
|
22
27
|
".git",
|
|
@@ -121,8 +126,83 @@ class CodeIndex:
|
|
|
121
126
|
# Cache for directory contents
|
|
122
127
|
self._dir_cache: Dict[Path, List[Path]] = {}
|
|
123
128
|
|
|
129
|
+
# Cache freshness tracking
|
|
130
|
+
self._cache_timestamps: Dict[Path, float] = {}
|
|
131
|
+
self._cache_ttl = 5.0 # 5 seconds TTL for directory cache
|
|
132
|
+
|
|
124
133
|
self._indexed = False
|
|
125
134
|
|
|
135
|
+
@classmethod
|
|
136
|
+
def get_instance(cls, root_dir: Optional[str] = None) -> "CodeIndex":
|
|
137
|
+
"""Get the singleton CodeIndex instance.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
root_dir: Root directory to index. Only used on first call.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The singleton CodeIndex instance.
|
|
144
|
+
"""
|
|
145
|
+
if cls._instance is None:
|
|
146
|
+
with cls._instance_lock:
|
|
147
|
+
if cls._instance is None:
|
|
148
|
+
cls._instance = cls(root_dir)
|
|
149
|
+
return cls._instance
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def reset_instance(cls) -> None:
|
|
153
|
+
"""Reset the singleton instance (for testing)."""
|
|
154
|
+
with cls._instance_lock:
|
|
155
|
+
cls._instance = None
|
|
156
|
+
|
|
157
|
+
def get_directory_contents(self, path: Path) -> List[str]:
|
|
158
|
+
"""Get cached directory contents if available and fresh.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
path: Directory path to check
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of filenames in directory, empty list if not cached/stale
|
|
165
|
+
"""
|
|
166
|
+
with self._lock:
|
|
167
|
+
if path not in self._dir_cache:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
if not self.is_cache_fresh(path):
|
|
171
|
+
# Remove stale entry
|
|
172
|
+
self._dir_cache.pop(path, None)
|
|
173
|
+
self._cache_timestamps.pop(path, None)
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
# Return just the filenames, not Path objects
|
|
177
|
+
return [p.name for p in self._dir_cache[path]]
|
|
178
|
+
|
|
179
|
+
def is_cache_fresh(self, path: Path) -> bool:
|
|
180
|
+
"""Check if cached directory data is still fresh.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
path: Directory path to check
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if cache is fresh, False if stale or missing
|
|
187
|
+
"""
|
|
188
|
+
if path not in self._cache_timestamps:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
age = time.time() - self._cache_timestamps[path]
|
|
192
|
+
return age < self._cache_ttl
|
|
193
|
+
|
|
194
|
+
def update_directory_cache(self, path: Path, entries: List[str]) -> None:
|
|
195
|
+
"""Update the directory cache with fresh data.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
path: Directory path
|
|
199
|
+
entries: List of filenames in the directory
|
|
200
|
+
"""
|
|
201
|
+
with self._lock:
|
|
202
|
+
# Convert filenames back to Path objects for internal storage
|
|
203
|
+
self._dir_cache[path] = [Path(path) / entry for entry in entries]
|
|
204
|
+
self._cache_timestamps[path] = time.time()
|
|
205
|
+
|
|
126
206
|
def build_index(self, force: bool = False) -> None:
|
|
127
207
|
"""Build the file index for the repository.
|
|
128
208
|
|
|
@@ -152,6 +232,7 @@ class CodeIndex:
|
|
|
152
232
|
self._class_definitions.clear()
|
|
153
233
|
self._function_definitions.clear()
|
|
154
234
|
self._dir_cache.clear()
|
|
235
|
+
self._cache_timestamps.clear()
|
|
155
236
|
|
|
156
237
|
def _should_ignore_path(self, path: Path) -> bool:
|
|
157
238
|
"""Check if a path should be ignored during indexing."""
|
|
@@ -183,8 +264,9 @@ class CodeIndex:
|
|
|
183
264
|
self._index_file(entry)
|
|
184
265
|
file_list.append(entry)
|
|
185
266
|
|
|
186
|
-
# Cache directory contents
|
|
267
|
+
# Cache directory contents with timestamp
|
|
187
268
|
self._dir_cache[directory] = file_list
|
|
269
|
+
self._cache_timestamps[directory] = time.time()
|
|
188
270
|
|
|
189
271
|
except PermissionError:
|
|
190
272
|
logger.debug(f"Permission denied: {directory}")
|
|
@@ -352,34 +434,6 @@ class CodeIndex:
|
|
|
352
434
|
|
|
353
435
|
return sorted(self._all_files)
|
|
354
436
|
|
|
355
|
-
def get_directory_contents(self, directory: str) -> List[Path]:
|
|
356
|
-
"""Get cached contents of a directory.
|
|
357
|
-
|
|
358
|
-
Args:
|
|
359
|
-
directory: Directory path relative to root
|
|
360
|
-
|
|
361
|
-
Returns:
|
|
362
|
-
List of file paths in the directory.
|
|
363
|
-
"""
|
|
364
|
-
with self._lock:
|
|
365
|
-
if not self._indexed:
|
|
366
|
-
self.build_index()
|
|
367
|
-
|
|
368
|
-
dir_path = self.root_dir / directory
|
|
369
|
-
if dir_path in self._dir_cache:
|
|
370
|
-
return [p.relative_to(self.root_dir) for p in self._dir_cache[dir_path]]
|
|
371
|
-
|
|
372
|
-
# Fallback to scanning if not in cache
|
|
373
|
-
results = []
|
|
374
|
-
for file_path in self._all_files:
|
|
375
|
-
if str(file_path).startswith(directory + os.sep):
|
|
376
|
-
# Only include direct children
|
|
377
|
-
relative = str(file_path)[len(directory) + 1 :]
|
|
378
|
-
if os.sep not in relative:
|
|
379
|
-
results.append(file_path)
|
|
380
|
-
|
|
381
|
-
return sorted(results)
|
|
382
|
-
|
|
383
437
|
def find_imports(self, module_name: str) -> List[Path]:
|
|
384
438
|
"""Find files that import a specific module.
|
|
385
439
|
|
tunacode/core/setup/__init__.py
CHANGED
|
@@ -3,7 +3,6 @@ from .base import BaseSetup
|
|
|
3
3
|
from .config_setup import ConfigSetup
|
|
4
4
|
from .coordinator import SetupCoordinator
|
|
5
5
|
from .environment_setup import EnvironmentSetup
|
|
6
|
-
from .git_safety_setup import GitSafetySetup
|
|
7
6
|
from .template_setup import TemplateSetup
|
|
8
7
|
|
|
9
8
|
__all__ = [
|
|
@@ -11,7 +10,6 @@ __all__ = [
|
|
|
11
10
|
"SetupCoordinator",
|
|
12
11
|
"ConfigSetup",
|
|
13
12
|
"EnvironmentSetup",
|
|
14
|
-
"GitSafetySetup",
|
|
15
13
|
"AgentSetup",
|
|
16
14
|
"TemplateSetup",
|
|
17
15
|
]
|
|
@@ -11,11 +11,16 @@ from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
|
11
11
|
from tunacode.configuration.models import ModelRegistry
|
|
12
12
|
from tunacode.constants import APP_NAME, CONFIG_FILE_NAME, UI_COLORS
|
|
13
13
|
from tunacode.core.setup.base import BaseSetup
|
|
14
|
+
from tunacode.core.setup.config_wizard import ConfigWizard
|
|
14
15
|
from tunacode.core.state import StateManager
|
|
15
16
|
from tunacode.exceptions import ConfigurationError
|
|
16
17
|
from tunacode.types import ConfigFile, ConfigPath, UserConfig
|
|
17
18
|
from tunacode.ui import console as ui
|
|
18
19
|
from tunacode.utils import system, user_configuration
|
|
20
|
+
from tunacode.utils.api_key_validation import (
|
|
21
|
+
get_required_api_key_for_model,
|
|
22
|
+
validate_api_key_for_model,
|
|
23
|
+
)
|
|
19
24
|
from tunacode.utils.text_utils import key_to_title
|
|
20
25
|
|
|
21
26
|
|
|
@@ -37,13 +42,22 @@ class ConfigSetup(BaseSetup):
|
|
|
37
42
|
"""Config setup should always run to load and merge configuration."""
|
|
38
43
|
return True
|
|
39
44
|
|
|
40
|
-
async def execute(self, force_setup: bool = False) -> None:
|
|
45
|
+
async def execute(self, force_setup: bool = False, wizard_mode: bool = False) -> None:
|
|
41
46
|
"""Setup configuration and run onboarding if needed, with config fingerprint fast path."""
|
|
42
47
|
import hashlib
|
|
43
48
|
|
|
44
49
|
self.state_manager.session.device_id = system.get_device_id()
|
|
45
50
|
loaded_config = user_configuration.load_config()
|
|
46
|
-
|
|
51
|
+
|
|
52
|
+
# Set the loaded config to session BEFORE initializing first-time user
|
|
53
|
+
if loaded_config:
|
|
54
|
+
self.state_manager.session.user_config = loaded_config
|
|
55
|
+
|
|
56
|
+
# Initialize first-time user settings if needed
|
|
57
|
+
user_configuration.initialize_first_time_user(self.state_manager)
|
|
58
|
+
|
|
59
|
+
# Fast path: if config fingerprint matches last loaded and config is already present,
|
|
60
|
+
# skip reprocessing
|
|
47
61
|
new_fp = None
|
|
48
62
|
if loaded_config:
|
|
49
63
|
b = json.dumps(loaded_config, sort_keys=True).encode()
|
|
@@ -52,6 +66,7 @@ class ConfigSetup(BaseSetup):
|
|
|
52
66
|
if (
|
|
53
67
|
loaded_config
|
|
54
68
|
and not force_setup
|
|
69
|
+
and not wizard_mode
|
|
55
70
|
and new_fp
|
|
56
71
|
and last_fp == new_fp
|
|
57
72
|
and getattr(self.state_manager, "_config_valid", False)
|
|
@@ -68,13 +83,17 @@ class ConfigSetup(BaseSetup):
|
|
|
68
83
|
await self._handle_cli_config(loaded_config)
|
|
69
84
|
return
|
|
70
85
|
|
|
71
|
-
if loaded_config and not force_setup:
|
|
86
|
+
if loaded_config and not force_setup and not wizard_mode:
|
|
72
87
|
# Silent loading
|
|
73
88
|
# Merge loaded config with defaults to ensure all required keys exist
|
|
74
89
|
self.state_manager.session.user_config = self._merge_with_defaults(loaded_config)
|
|
75
90
|
else:
|
|
76
|
-
if force_setup:
|
|
77
|
-
|
|
91
|
+
if force_setup or wizard_mode:
|
|
92
|
+
if wizard_mode:
|
|
93
|
+
await ui.muted("Running interactive setup wizard")
|
|
94
|
+
else:
|
|
95
|
+
await ui.muted("Running setup process, resetting config")
|
|
96
|
+
|
|
78
97
|
# Ensure user_config is properly initialized
|
|
79
98
|
if (
|
|
80
99
|
not hasattr(self.state_manager.session, "user_config")
|
|
@@ -89,12 +108,19 @@ class ConfigSetup(BaseSetup):
|
|
|
89
108
|
except ConfigurationError as e:
|
|
90
109
|
await ui.error(str(e))
|
|
91
110
|
raise
|
|
92
|
-
|
|
111
|
+
|
|
112
|
+
if wizard_mode:
|
|
113
|
+
wizard = ConfigWizard(self.state_manager, self.model_registry, self.config_file)
|
|
114
|
+
await wizard.run_onboarding()
|
|
115
|
+
else:
|
|
116
|
+
await self._onboarding()
|
|
93
117
|
else:
|
|
94
|
-
# No config found - show CLI usage
|
|
118
|
+
# No config found - show CLI usage and continue with safe defaults (no crash)
|
|
95
119
|
from tunacode.ui.console import console
|
|
96
120
|
|
|
97
|
-
console.print(
|
|
121
|
+
console.print(
|
|
122
|
+
"\n[bold yellow]No configuration found — using safe defaults.[/bold yellow]"
|
|
123
|
+
)
|
|
98
124
|
console.print("\n[bold]Quick Setup:[/bold]")
|
|
99
125
|
console.print("Configure TunaCode using CLI flags:")
|
|
100
126
|
console.print("\n[blue]Examples:[/blue]")
|
|
@@ -107,24 +133,64 @@ class ConfigSetup(BaseSetup):
|
|
|
107
133
|
"--key 'your-key' --baseurl 'https://openrouter.ai/api/v1'[/green]"
|
|
108
134
|
)
|
|
109
135
|
console.print("\n[yellow]Run 'tunacode --help' for more options[/yellow]\n")
|
|
136
|
+
console.print("\n[cyan]Or use --wizard for guided setup[/cyan]\n")
|
|
110
137
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
# Initialize in-memory defaults so we don't crash
|
|
139
|
+
self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
|
|
140
|
+
# Mark config as not fully validated for the fast path
|
|
141
|
+
setattr(self.state_manager, "_config_valid", False)
|
|
114
142
|
|
|
115
143
|
if not self.state_manager.session.user_config.get("default_model"):
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
# Gracefully apply default model instead of crashing
|
|
145
|
+
self.state_manager.session.user_config["default_model"] = DEFAULT_USER_CONFIG[
|
|
146
|
+
"default_model"
|
|
147
|
+
]
|
|
148
|
+
await ui.warning(
|
|
149
|
+
"No default model set in config; applying safe default "
|
|
150
|
+
f"'{self.state_manager.session.user_config['default_model']}'."
|
|
121
151
|
)
|
|
122
152
|
|
|
123
|
-
#
|
|
153
|
+
# Validate API key exists for the selected model
|
|
154
|
+
model = self.state_manager.session.user_config["default_model"]
|
|
155
|
+
is_valid, error_msg = validate_api_key_for_model(
|
|
156
|
+
model, self.state_manager.session.user_config
|
|
157
|
+
)
|
|
158
|
+
if not is_valid:
|
|
159
|
+
# Try to pick a fallback model based on whichever provider has a key configured
|
|
160
|
+
fallback = self._pick_fallback_model(self.state_manager.session.user_config)
|
|
161
|
+
if fallback and fallback != model:
|
|
162
|
+
await ui.warning(
|
|
163
|
+
"API key missing for selected model; switching to configured provider: "
|
|
164
|
+
f"'{fallback}'."
|
|
165
|
+
)
|
|
166
|
+
self.state_manager.session.user_config["default_model"] = fallback
|
|
167
|
+
model = fallback
|
|
168
|
+
else:
|
|
169
|
+
# No suitable fallback; continue without crashing but mark invalid
|
|
170
|
+
await ui.warning(
|
|
171
|
+
(error_msg or "API key missing for model")
|
|
172
|
+
+ "\nContinuing without provider initialization; run 'tunacode --setup' later."
|
|
173
|
+
)
|
|
174
|
+
setattr(self.state_manager, "_config_valid", False)
|
|
124
175
|
|
|
125
|
-
self.state_manager.session.current_model =
|
|
126
|
-
|
|
127
|
-
|
|
176
|
+
self.state_manager.session.current_model = model
|
|
177
|
+
|
|
178
|
+
def _pick_fallback_model(self, user_config: UserConfig) -> str | None:
|
|
179
|
+
"""Select a reasonable fallback model based on configured API keys."""
|
|
180
|
+
env = (user_config or {}).get("env", {})
|
|
181
|
+
|
|
182
|
+
# Preference order: OpenAI → Anthropic → Google → OpenRouter
|
|
183
|
+
if env.get("OPENAI_API_KEY", "").strip():
|
|
184
|
+
return "openai:gpt-4o"
|
|
185
|
+
if env.get("ANTHROPIC_API_KEY", "").strip():
|
|
186
|
+
return "anthropic:claude-sonnet-4"
|
|
187
|
+
if env.get("GEMINI_API_KEY", "").strip():
|
|
188
|
+
return "google:gemini-2.5-flash"
|
|
189
|
+
if env.get("OPENROUTER_API_KEY", "").strip():
|
|
190
|
+
# Use the project default when OpenRouter is configured
|
|
191
|
+
return DEFAULT_USER_CONFIG.get("default_model", "openrouter:openai/gpt-4.1")
|
|
192
|
+
|
|
193
|
+
return None
|
|
128
194
|
|
|
129
195
|
async def validate(self) -> bool:
|
|
130
196
|
"""Validate that configuration is properly set up."""
|
|
@@ -134,6 +200,30 @@ class ConfigSetup(BaseSetup):
|
|
|
134
200
|
valid = False
|
|
135
201
|
elif not self.state_manager.session.user_config.get("default_model"):
|
|
136
202
|
valid = False
|
|
203
|
+
else:
|
|
204
|
+
# Validate API key exists for the selected model
|
|
205
|
+
model = self.state_manager.session.user_config.get("default_model")
|
|
206
|
+
is_valid, error_msg = validate_api_key_for_model(
|
|
207
|
+
model, self.state_manager.session.user_config
|
|
208
|
+
)
|
|
209
|
+
if not is_valid:
|
|
210
|
+
valid = False
|
|
211
|
+
# Store error message for later use
|
|
212
|
+
setattr(self.state_manager, "_config_error", error_msg)
|
|
213
|
+
|
|
214
|
+
# Provide actionable guidance for manual setup
|
|
215
|
+
required_key, provider_name = get_required_api_key_for_model(model)
|
|
216
|
+
setup_hint = (
|
|
217
|
+
f"Missing API key for {provider_name}.\n"
|
|
218
|
+
f"Either run 'tunacode --wizard' (recommended) or add it manually to: "
|
|
219
|
+
f"{self.config_file}\n\n"
|
|
220
|
+
"Example snippet (add under 'env'):\n"
|
|
221
|
+
' "env": {\n'
|
|
222
|
+
f' "{required_key or "PROVIDER_API_KEY"}": "your-key-here"\n'
|
|
223
|
+
" }\n"
|
|
224
|
+
)
|
|
225
|
+
await ui.error(setup_hint)
|
|
226
|
+
|
|
137
227
|
# Cache result for fastpath
|
|
138
228
|
if valid:
|
|
139
229
|
setattr(self.state_manager, "_config_valid", True)
|