3024 lines
95 KiB
Python
3024 lines
95 KiB
Python
"""Interactive onboarding wizard for EvoScientist.
|
||
|
||
Guides users through initial setup including API keys, model selection,
|
||
workspace settings, and agent parameters. Uses flow-style arrow-key selection UI.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import questionary
|
||
from prompt_toolkit.formatted_text import FormattedText
|
||
from prompt_toolkit.styles import Style
|
||
from prompt_toolkit.validation import ValidationError, Validator
|
||
from questionary import Choice
|
||
from rich.console import Console
|
||
from rich.panel import Panel
|
||
from rich.text import Text
|
||
|
||
from ..llm import get_models_for_provider
|
||
from .settings import (
|
||
EvoScientistConfig,
|
||
get_config_path,
|
||
load_config,
|
||
save_config,
|
||
)
|
||
|
||
console = Console()
|
||
|
||
|
||
# =============================================================================
|
||
# Wizard Style
|
||
# =============================================================================
|
||
|
||
WIZARD_STYLE = Style.from_dict(
|
||
{
|
||
"qmark": "fg:#00bcd4 bold", # Cyan question mark
|
||
"question": "bold", # Bold question text
|
||
"answer": "fg:#4caf50 bold", # Green selected answer
|
||
"pointer": "fg:#4caf50", # Green pointer (»)
|
||
"highlighted": "noreverse bold", # No background, bold text
|
||
"selected": "fg:#4caf50 bold", # Green ● indicator
|
||
"separator": "fg:#6c6c6c", # Dim separator
|
||
"disabled": "fg:#858585", # Dim disabled indicator (-)
|
||
"instruction": "fg:#858585", # Dim instructions
|
||
"text": "fg:#858585", # Dim gray ○ and unselected text
|
||
}
|
||
)
|
||
|
||
CONFIRM_STYLE = Style.from_dict(
|
||
{
|
||
"qmark": "fg:#e69500 bold", # Orange warning mark (!)
|
||
"question": "bold",
|
||
"answer": "fg:#4caf50 bold",
|
||
"instruction": "fg:#858585",
|
||
"text": "",
|
||
}
|
||
)
|
||
|
||
QMARK = "❯"
|
||
|
||
# Installed-item indicator style for disabled checkbox choices.
|
||
_INSTALLED_INDICATOR = ("fg:#4caf50", "✓ ")
|
||
|
||
|
||
def _checkbox_ask(choices, message: str, **kwargs):
|
||
"""``questionary.checkbox`` that renders disabled items with ✓ instead of ``-``.
|
||
|
||
Temporarily patches the rendering so the hard-coded ``"- "`` prefix for
|
||
disabled choices is replaced by a green ``"✓ "`` — keeping alignment with
|
||
the ``○`` indicator of normal choices.
|
||
"""
|
||
from questionary.prompts.common import InquirerControl
|
||
|
||
original = InquirerControl._get_choice_tokens
|
||
|
||
def _patched(self):
|
||
tokens = original(self)
|
||
return [
|
||
_INSTALLED_INDICATOR
|
||
if cls == "class:disabled" and text == "- "
|
||
else (cls, text)
|
||
for cls, text in tokens
|
||
]
|
||
|
||
InquirerControl._get_choice_tokens = _patched
|
||
try:
|
||
return questionary.checkbox(
|
||
message,
|
||
choices=choices,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
**kwargs,
|
||
).ask()
|
||
finally:
|
||
InquirerControl._get_choice_tokens = original
|
||
|
||
|
||
STEPS = [
|
||
"UI",
|
||
"Provider",
|
||
"API Key",
|
||
"Model",
|
||
"Tavily Key",
|
||
"Workspace",
|
||
"Thinking",
|
||
"Skills",
|
||
"MCP Servers",
|
||
"LaTeX",
|
||
"Channels",
|
||
]
|
||
|
||
|
||
# =============================================================================
|
||
# Validators
|
||
# =============================================================================
|
||
|
||
|
||
class IntegerValidator(Validator):
|
||
"""Validates that input is a positive integer."""
|
||
|
||
def __init__(self, min_value: int = 1, max_value: int = 100):
|
||
self.min_value = min_value
|
||
self.max_value = max_value
|
||
|
||
def validate(self, document) -> None:
|
||
text = document.text.strip()
|
||
if not text:
|
||
return # Allow empty for default
|
||
try:
|
||
value = int(text)
|
||
if value < self.min_value or value > self.max_value:
|
||
raise ValidationError(
|
||
message=f"Must be between {self.min_value} and {self.max_value}"
|
||
)
|
||
except ValueError as e:
|
||
raise ValidationError(message="Must be a valid integer") from e
|
||
|
||
|
||
class ChoiceValidator(Validator):
|
||
"""Validates that input is one of the allowed choices."""
|
||
|
||
def __init__(self, choices: list[str], allow_empty: bool = True):
|
||
self.choices = choices
|
||
self.allow_empty = allow_empty
|
||
|
||
def validate(self, document) -> None:
|
||
text = document.text.strip().lower()
|
||
if not text and self.allow_empty:
|
||
return
|
||
if text not in [c.lower() for c in self.choices]:
|
||
raise ValidationError(message=f"Must be one of: {', '.join(self.choices)}")
|
||
|
||
|
||
# =============================================================================
|
||
# API Key Validation
|
||
# =============================================================================
|
||
|
||
|
||
def validate_anthropic_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate an Anthropic API key by making a test request.
|
||
|
||
Args:
|
||
api_key: The API key to validate.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import anthropic
|
||
|
||
client = anthropic.Anthropic(api_key=api_key)
|
||
# Make a minimal request to validate the key
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except anthropic.AuthenticationError:
|
||
return False, "Invalid API key"
|
||
except Exception as e:
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_openai_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate an OpenAI API key by making a test request.
|
||
|
||
Args:
|
||
api_key: The API key to validate.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(api_key=api_key)
|
||
# Make a minimal request to validate the key
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except openai.AuthenticationError:
|
||
return False, "Invalid API key"
|
||
except Exception as e:
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_nvidia_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate an NVIDIA API key by making a test request.
|
||
|
||
Args:
|
||
api_key: The API key to validate.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
from langchain_nvidia_ai_endpoints import ChatNVIDIA
|
||
|
||
ChatNVIDIA(api_key=api_key, model="meta/llama-3.1-8b-instruct")
|
||
return True, "Valid"
|
||
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_google_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a Google GenAI API key by making a test request.
|
||
|
||
Args:
|
||
api_key: The API key to validate.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
from google import genai
|
||
|
||
client = genai.Client(api_key=api_key)
|
||
# Make a minimal request to validate the key
|
||
pager = client.models.list(config={"page_size": 1})
|
||
next(iter(pager)) # fetch first model only
|
||
return True, "Valid"
|
||
except StopIteration:
|
||
# Empty result but request succeeded — key is valid
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"400" in error_str
|
||
or "401" in error_str
|
||
or "403" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "api key" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_minimax_key(
|
||
api_key: str,
|
||
base_url: str = "https://api.minimaxi.com/anthropic",
|
||
) -> tuple[bool, str]:
|
||
"""Validate a MiniMax API key without consuming tokens.
|
||
|
||
Sends a messages.create() with an empty model string. MiniMax checks
|
||
auth *before* validating request params, so a valid key returns 400
|
||
(bad model) while an invalid key returns 401.
|
||
|
||
Args:
|
||
api_key: The MiniMax API key to validate.
|
||
base_url: Anthropic-compatible endpoint (global or mainland China).
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import anthropic
|
||
|
||
client = anthropic.Anthropic(
|
||
api_key=api_key,
|
||
base_url=base_url,
|
||
)
|
||
client.messages.create(
|
||
model="",
|
||
max_tokens=1,
|
||
messages=[{"role": "user", "content": "hi"}],
|
||
)
|
||
# Unexpected success — treat as valid
|
||
return True, "Valid"
|
||
except anthropic.AuthenticationError:
|
||
return False, "Invalid API key"
|
||
except anthropic.APIStatusError:
|
||
# Any non-auth HTTP error (400 bad model, 500 insufficient balance,
|
||
# etc.) means the key itself was accepted → treat as valid.
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_siliconflow_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a SiliconFlow API key by making a test request.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(
|
||
api_key=api_key, base_url="https://api.siliconflow.cn/v1"
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_openrouter_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate an OpenRouter API key via the authenticated /auth/key endpoint.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import httpx
|
||
|
||
resp = httpx.get(
|
||
"https://openrouter.ai/api/v1/auth/key",
|
||
headers={"Authorization": f"Bearer {api_key}"},
|
||
timeout=10,
|
||
)
|
||
if resp.status_code == 200:
|
||
return True, "Valid"
|
||
return False, "Invalid API key"
|
||
except Exception as e:
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_deepseek_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a DeepSeek API key by making a test request.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(api_key=api_key, base_url="https://api.deepseek.com")
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_zhipu_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a ZhipuAI API key by making a test request.
|
||
|
||
Uses the general endpoint for validation — both zhipu and zhipu-code
|
||
share the same API key, only the base_url differs at runtime.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(
|
||
api_key=api_key, base_url="https://open.bigmodel.cn/api/paas/v4"
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_volcengine_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a Volcengine API key by making a test request.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(
|
||
api_key=api_key,
|
||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_dashscope_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a DashScope API key by making a test request.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(
|
||
api_key=api_key,
|
||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if (
|
||
"401" in error_str
|
||
or "unauthorized" in error_str
|
||
or "invalid" in error_str
|
||
or "authentication" in error_str
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_moonshot_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a Moonshot API key by making a test request.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import openai
|
||
|
||
client = openai.OpenAI(
|
||
api_key=api_key,
|
||
base_url="https://api.moonshot.cn/v1",
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if any(
|
||
k in error_str for k in ("401", "unauthorized", "invalid", "authentication")
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_kimi_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a Kimi Coding Plan API key by making a test request.
|
||
|
||
Uses the Anthropic-compatible endpoint at api.kimi.com/coding/.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
import anthropic
|
||
|
||
client = anthropic.Anthropic(
|
||
api_key=api_key,
|
||
base_url="https://api.kimi.com/coding/",
|
||
default_headers={"User-Agent": "claude-code/0.1.0"},
|
||
)
|
||
client.models.list()
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if any(
|
||
k in error_str for k in ("401", "unauthorized", "invalid", "authentication")
|
||
):
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_tavily_key(api_key: str) -> tuple[bool, str]:
|
||
"""Validate a Tavily API key by making a test request.
|
||
|
||
Args:
|
||
api_key: The API key to validate.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
if not api_key:
|
||
return True, "Skipped (no key provided)"
|
||
|
||
try:
|
||
from tavily import TavilyClient
|
||
|
||
client = TavilyClient(api_key=api_key)
|
||
# Make a minimal search to validate
|
||
client.search("test", max_results=1)
|
||
return True, "Valid"
|
||
except Exception as e:
|
||
error_str = str(e).lower()
|
||
if "invalid" in error_str or "unauthorized" in error_str or "401" in error_str:
|
||
return False, "Invalid API key"
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def validate_ollama_connection(base_url: str) -> tuple[bool, str, list[str]]:
|
||
"""Validate that Ollama is reachable at the given base URL.
|
||
|
||
Args:
|
||
base_url: The Ollama server base URL.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message, model_names).
|
||
model_names is a list of pulled model names (empty if unreachable).
|
||
"""
|
||
if not base_url:
|
||
return True, "Skipped (no URL provided)", []
|
||
|
||
try:
|
||
import httpx
|
||
|
||
resp = httpx.get(f"{base_url.rstrip('/')}/api/tags", timeout=5)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
models = data.get("models", [])
|
||
names = [m.get("name", "?") for m in models]
|
||
if names:
|
||
preview = ", ".join(names[:5])
|
||
return True, f"Connected — {len(names)} model(s): {preview}", names
|
||
return True, "Connected (no models pulled yet)", []
|
||
return False, f"HTTP {resp.status_code}", []
|
||
except Exception as e:
|
||
return False, f"Cannot reach Ollama: {e}", []
|
||
|
||
|
||
# =============================================================================
|
||
# Display Helpers
|
||
# =============================================================================
|
||
|
||
|
||
def _print_header() -> None:
|
||
"""Print the wizard header."""
|
||
console.print()
|
||
console.print(
|
||
Panel.fit(
|
||
Text.from_markup(
|
||
"[bold cyan]EvoScientist Setup Wizard[/bold cyan]\n\n"
|
||
"This wizard will help you configure EvoScientist.\n"
|
||
"Press Ctrl+C at any time to cancel."
|
||
),
|
||
border_style="cyan",
|
||
)
|
||
)
|
||
console.print()
|
||
|
||
|
||
def _print_step_result(step_name: str, value: str, success: bool = True) -> None:
|
||
"""Print a completed step result inline.
|
||
|
||
Args:
|
||
step_name: Name of the step.
|
||
value: The selected/entered value.
|
||
success: Whether the step was successful (affects icon).
|
||
"""
|
||
icon = "[green]✓[/green]" if success else "[red]✗[/red]"
|
||
console.print(f" {icon} [bold]{step_name}:[/bold] [cyan]{value}[/cyan]")
|
||
|
||
|
||
def _print_step_skipped(step_name: str, reason: str = "kept current") -> None:
|
||
"""Print a skipped step result inline.
|
||
|
||
Args:
|
||
step_name: Name of the step.
|
||
reason: Reason for skipping.
|
||
"""
|
||
console.print(f" [dim]○ {step_name}: {reason}[/dim]")
|
||
|
||
|
||
# =============================================================================
|
||
# Step Functions
|
||
# =============================================================================
|
||
|
||
|
||
def _step_ui_backend(config: EvoScientistConfig) -> str:
|
||
"""Step 0: Select UI backend (Rich CLI or Textual TUI).
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected backend name ("tui" or "cli").
|
||
"""
|
||
choices = [
|
||
Choice(title="TUI (full-screen interface, recommended)", value="tui"),
|
||
Choice(title="CLI (classic terminal, lightweight)", value="cli"),
|
||
]
|
||
|
||
# Map legacy values to current ones
|
||
_legacy_map = {"textual": "tui", "rich": "cli"}
|
||
default_backend = _legacy_map.get(config.ui_backend, config.ui_backend)
|
||
if default_backend not in ("tui", "cli"):
|
||
default_backend = "tui"
|
||
|
||
backend = questionary.select(
|
||
"Select UI mode:",
|
||
choices=choices,
|
||
default=default_backend,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if backend is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return backend
|
||
|
||
|
||
def _step_provider(config: EvoScientistConfig) -> str:
|
||
"""Step 1: Select LLM provider.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected provider name.
|
||
"""
|
||
choices = [
|
||
# Direct providers
|
||
Choice(title="Anthropic (Claude models — API / OAuth)", value="anthropic"),
|
||
Choice(title="OpenAI (GPT models — API / OAuth)", value="openai"),
|
||
Choice(title="Google GenAI (Gemini models)", value="google-genai"),
|
||
Choice(
|
||
title="MiniMax (M2 — M2.7 models, 204K context, thinking)", value="minimax"
|
||
),
|
||
Choice(title="ZhipuAI (智谱 — GLM models)", value="zhipu"),
|
||
Choice(
|
||
title="ZhipuAI CodePlan (智谱代码计划 — GLM models for coding)",
|
||
value="zhipu-code",
|
||
),
|
||
Choice(
|
||
title="Volcengine (火山引擎 — Doubao models)",
|
||
value="volcengine",
|
||
),
|
||
Choice(
|
||
title="DashScope (阿里云 — Qwen models)",
|
||
value="dashscope",
|
||
),
|
||
Choice(
|
||
title="DeepSeek (DeepSeek-R1, DeepSeek-V3)",
|
||
value="deepseek",
|
||
),
|
||
Choice(
|
||
title="Moonshot (月之暗面 — Moonshot models)",
|
||
value="moonshot",
|
||
),
|
||
Choice(
|
||
title="Kimi Coding Plan (Kimi 代码计划 — coding-focused)",
|
||
value="kimi-coding",
|
||
),
|
||
# Local
|
||
Choice(title="Ollama (local models)", value="ollama"),
|
||
# Third-party / aggregator
|
||
Choice(title="NVIDIA (third party — limited free requests)", value="nvidia"),
|
||
Choice(
|
||
title="SiliconFlow (aggregator — GLM, Kimi, MiniMax, etc.)",
|
||
value="siliconflow",
|
||
),
|
||
Choice(
|
||
title="OpenRouter (aggregator — Grok, Gemini, Qwen, etc.)",
|
||
value="openrouter",
|
||
),
|
||
Choice(
|
||
title="OpenAI-compatible (third-party OpenAI endpoint)",
|
||
value="custom-openai",
|
||
),
|
||
Choice(
|
||
title="Claude-compatible (third-party Anthropic endpoint)",
|
||
value="custom-anthropic",
|
||
),
|
||
]
|
||
|
||
# Set default based on current config
|
||
valid_providers = {c.value for c in choices}
|
||
default = config.provider if config.provider in valid_providers else "anthropic"
|
||
|
||
provider = questionary.select(
|
||
"Select your LLM provider:",
|
||
choices=choices,
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if provider is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return provider
|
||
|
||
|
||
_MINIMAX_REGIONS: dict[str, str] = {
|
||
"global": "https://api.minimax.io/anthropic",
|
||
"cn": "https://api.minimaxi.com/anthropic",
|
||
}
|
||
|
||
|
||
def _step_minimax_region(config: EvoScientistConfig) -> str:
|
||
"""Step 2a (MiniMax): Select API region.
|
||
|
||
MiniMax has two regional endpoints — Global (api.minimax.io) and
|
||
Mainland China (api.minimaxi.com). API keys are region-bound.
|
||
|
||
Returns:
|
||
The selected base URL.
|
||
"""
|
||
current = config.minimax_base_url or os.environ.get("MINIMAX_BASE_URL", "")
|
||
if current == _MINIMAX_REGIONS["global"]:
|
||
default = "global"
|
||
else:
|
||
default = "cn"
|
||
|
||
region = questionary.select(
|
||
"Select MiniMax API region (must match where your key was created):",
|
||
choices=[
|
||
Choice(
|
||
title="Global (api.minimax.io — platform.minimax.io keys)",
|
||
value="global",
|
||
),
|
||
Choice(
|
||
title="Mainland China (api.minimaxi.com — platform.minimaxi.com keys)",
|
||
value="cn",
|
||
),
|
||
],
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if region is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return _MINIMAX_REGIONS[region]
|
||
|
||
|
||
def _provider_key_info(config: EvoScientistConfig, provider: str):
|
||
"""Return (display_name, current_value, validate_fn) for a provider."""
|
||
mapping = {
|
||
"anthropic": (
|
||
"Anthropic",
|
||
config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", ""),
|
||
validate_anthropic_key,
|
||
),
|
||
"minimax": (
|
||
"MiniMax",
|
||
config.minimax_api_key or os.environ.get("MINIMAX_API_KEY", ""),
|
||
lambda key: validate_minimax_key(
|
||
key,
|
||
base_url=config.minimax_base_url
|
||
or os.environ.get(
|
||
"MINIMAX_BASE_URL", "https://api.minimaxi.com/anthropic"
|
||
),
|
||
),
|
||
),
|
||
"nvidia": (
|
||
"NVIDIA",
|
||
config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", ""),
|
||
validate_nvidia_key,
|
||
),
|
||
"google-genai": (
|
||
"Google",
|
||
config.google_api_key or os.environ.get("GOOGLE_API_KEY", ""),
|
||
validate_google_key,
|
||
),
|
||
"siliconflow": (
|
||
"SiliconFlow",
|
||
config.siliconflow_api_key or os.environ.get("SILICONFLOW_API_KEY", ""),
|
||
validate_siliconflow_key,
|
||
),
|
||
"openrouter": (
|
||
"OpenRouter",
|
||
config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""),
|
||
validate_openrouter_key,
|
||
),
|
||
"deepseek": (
|
||
"DeepSeek",
|
||
config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""),
|
||
validate_deepseek_key,
|
||
),
|
||
"zhipu": (
|
||
"ZhipuAI",
|
||
config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""),
|
||
validate_zhipu_key,
|
||
),
|
||
"zhipu-code": (
|
||
"ZhipuAI CodePlan",
|
||
config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""),
|
||
validate_zhipu_key,
|
||
),
|
||
"volcengine": (
|
||
"Volcengine",
|
||
config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""),
|
||
validate_volcengine_key,
|
||
),
|
||
"dashscope": (
|
||
"DashScope",
|
||
config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""),
|
||
validate_dashscope_key,
|
||
),
|
||
"moonshot": (
|
||
"Moonshot",
|
||
config.moonshot_api_key or os.environ.get("MOONSHOT_API_KEY", ""),
|
||
validate_moonshot_key,
|
||
),
|
||
"kimi-coding": (
|
||
"Kimi Coding Plan",
|
||
config.kimi_api_key or os.environ.get("KIMI_API_KEY", ""),
|
||
validate_kimi_key,
|
||
),
|
||
"custom-openai": (
|
||
"OpenAI-compatible",
|
||
config.custom_openai_api_key or os.environ.get("CUSTOM_OPENAI_API_KEY", ""),
|
||
None,
|
||
),
|
||
"custom-anthropic": (
|
||
"Custom Anthropic",
|
||
config.custom_anthropic_api_key
|
||
or os.environ.get("CUSTOM_ANTHROPIC_API_KEY", ""),
|
||
None,
|
||
),
|
||
"ollama": ("Ollama", "__no_key__", None),
|
||
}
|
||
return mapping.get(
|
||
provider,
|
||
(
|
||
"OpenAI",
|
||
config.openai_api_key or os.environ.get("OPENAI_API_KEY", ""),
|
||
validate_openai_key,
|
||
),
|
||
)
|
||
|
||
|
||
def _prompt_and_validate_api_key(
|
||
prompt_text: str,
|
||
current: str,
|
||
validate_fn,
|
||
skip_validation: bool = False,
|
||
placeholder=None,
|
||
) -> str | None:
|
||
"""Prompt user for an API key, validate, offer save-anyway on failure.
|
||
|
||
Args:
|
||
prompt_text: The question shown to the user.
|
||
current: Currently stored key value (may be empty).
|
||
validate_fn: Callable(key) -> (bool, str).
|
||
skip_validation: If True, skip the validation step entirely.
|
||
placeholder: Optional placeholder for the password input.
|
||
|
||
Returns:
|
||
New key string if the user entered one, or None to keep existing.
|
||
"""
|
||
kwargs: dict = {"style": WIZARD_STYLE, "qmark": QMARK}
|
||
if placeholder is not None:
|
||
kwargs["placeholder"] = placeholder
|
||
|
||
new_key = questionary.password(prompt_text, **kwargs).ask()
|
||
if new_key is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
new_key = new_key.strip()
|
||
|
||
# Determine which key to validate: new input or existing
|
||
key_to_validate = new_key or current
|
||
|
||
if not key_to_validate:
|
||
return None
|
||
|
||
if not skip_validation and validate_fn is not None:
|
||
console.print(" [dim]Validating...[/dim]", end="")
|
||
valid, msg = validate_fn(key_to_validate)
|
||
if valid:
|
||
console.print(f"\r [green]\u2713 {msg}[/green] ")
|
||
return new_key or None
|
||
else:
|
||
console.print(f"\r [red]\u2717 {msg}[/red] ")
|
||
if not new_key:
|
||
# Existing key is invalid — warn but keep (user didn't change it)
|
||
return None
|
||
save_anyway = questionary.confirm(
|
||
"Save anyway?",
|
||
default=False,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if save_anyway is None:
|
||
raise KeyboardInterrupt()
|
||
return new_key if save_anyway else None
|
||
|
||
return new_key or None
|
||
|
||
|
||
def _prompt_ccproxy_port(config: EvoScientistConfig) -> None:
|
||
"""Prompt the user for a ccproxy port and save it to config."""
|
||
|
||
def valid_port(value: str) -> bool:
|
||
if not value: # empty = keep default
|
||
return True
|
||
try:
|
||
return 0 < int(value) < 2**16
|
||
except (ValueError, TypeError):
|
||
return False
|
||
|
||
current_port = getattr(config, "ccproxy_port", 8000)
|
||
try:
|
||
raw = questionary.text(
|
||
f"Enter port number for ccproxy to run on (Current: {current_port}, Enter to keep):",
|
||
validate=valid_port,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
ccproxy_port = int(raw) if raw else current_port
|
||
except (ValueError, TypeError):
|
||
ccproxy_port = current_port
|
||
console.print(f" [dim]Using default port: {ccproxy_port}[/dim]")
|
||
|
||
config.ccproxy_port = ccproxy_port
|
||
console.print(
|
||
f" [green]✓ ccproxy will run on http://127.0.0.1:{ccproxy_port}[/green]"
|
||
)
|
||
|
||
|
||
def _run_ccproxy_login(provider: str, label: str) -> None:
|
||
"""Run ccproxy auth login for the given provider and show status."""
|
||
from ..ccproxy_manager import _ccproxy_exe, check_ccproxy_auth
|
||
|
||
console.print(" [dim]Opening browser for authentication...[/dim]")
|
||
try:
|
||
proc = subprocess.run(
|
||
[_ccproxy_exe() or "ccproxy", "auth", "login", provider],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
)
|
||
for line in proc.stdout.splitlines():
|
||
if line.strip().startswith("https://"):
|
||
console.print(f" [dim]Visit: {line.strip()}[/dim]")
|
||
break
|
||
authed, msg = check_ccproxy_auth(provider)
|
||
if authed:
|
||
console.print(f" [green]✓ {label}: {msg}[/green]")
|
||
else:
|
||
console.print(f" [red]Authentication failed: {msg}[/red]")
|
||
except subprocess.TimeoutExpired:
|
||
console.print(" [red]Login timed out.[/red]")
|
||
except Exception as exc:
|
||
console.print(f" [red]Login error: {exc}[/red]")
|
||
|
||
|
||
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
|
||
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected auth mode: "api_key", "oauth", or "auto".
|
||
"""
|
||
from ..ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
|
||
|
||
ccproxy_available = is_ccproxy_available()
|
||
|
||
choices = [
|
||
Choice(title="API Key (direct Anthropic access)", value="api_key"),
|
||
Choice(
|
||
title="Claude Code OAuth (via ccproxy — no API key needed)"
|
||
+ (
|
||
""
|
||
if ccproxy_available
|
||
else " [requires: pip install evoscientist[oauth]]"
|
||
),
|
||
value="oauth",
|
||
),
|
||
]
|
||
|
||
current = config.anthropic_auth_mode
|
||
if current not in ("api_key", "oauth"):
|
||
current = "api_key"
|
||
|
||
auth_mode = questionary.select(
|
||
"Authentication mode:",
|
||
choices=choices,
|
||
default=current,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if auth_mode is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if auth_mode == "oauth" and not ccproxy_available:
|
||
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
|
||
console.print()
|
||
install = questionary.confirm(
|
||
'Install ccproxy now? (pip install "evoscientist[oauth]")',
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if install is None:
|
||
raise KeyboardInterrupt()
|
||
if install:
|
||
console.print()
|
||
if _install_ccproxy():
|
||
console.print(" [green]✓ ccproxy installed successfully.[/green]")
|
||
else:
|
||
console.print(" [yellow]Falling back to API key mode.[/yellow]")
|
||
return "api_key"
|
||
else:
|
||
console.print(
|
||
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
|
||
)
|
||
return "api_key"
|
||
|
||
if auth_mode == "oauth":
|
||
_prompt_ccproxy_port(config)
|
||
|
||
# If OAuth selected, check auth status and offer login
|
||
if auth_mode in ("oauth", "auto"):
|
||
authed, msg = check_ccproxy_auth()
|
||
if authed:
|
||
console.print(f" [green]✓ OAuth: {msg}[/green]")
|
||
relogin = questionary.confirm(
|
||
"Re-authenticate to refresh credentials?",
|
||
default=False,
|
||
style=CONFIRM_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if relogin:
|
||
_run_ccproxy_login("claude_api", "OAuth")
|
||
else:
|
||
console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]")
|
||
login = questionary.confirm(
|
||
"Log in to Claude now?",
|
||
default=True,
|
||
style=CONFIRM_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if login:
|
||
_run_ccproxy_login("claude_api", "OAuth")
|
||
|
||
return auth_mode
|
||
|
||
|
||
def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
|
||
"""Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth).
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected auth mode: "api_key" or "oauth".
|
||
"""
|
||
from ..ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
|
||
|
||
ccproxy_available = is_ccproxy_available()
|
||
|
||
choices = [
|
||
Choice(title="API Key (direct OpenAI access)", value="api_key"),
|
||
Choice(
|
||
title="Codex OAuth (via ccproxy — no API key needed)"
|
||
+ (
|
||
""
|
||
if ccproxy_available
|
||
else " [requires: pip install evoscientist[oauth]]"
|
||
),
|
||
value="oauth",
|
||
),
|
||
]
|
||
|
||
current = config.openai_auth_mode
|
||
if current not in ("api_key", "oauth"):
|
||
current = "api_key"
|
||
|
||
auth_mode = questionary.select(
|
||
"OpenAI authentication mode:",
|
||
choices=choices,
|
||
default=current,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if auth_mode is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if auth_mode == "oauth" and not ccproxy_available:
|
||
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
|
||
console.print()
|
||
install = questionary.confirm(
|
||
'Install ccproxy now? (pip install "evoscientist[oauth]")',
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if install is None:
|
||
raise KeyboardInterrupt()
|
||
if install:
|
||
console.print()
|
||
if _install_ccproxy():
|
||
console.print(" [green]✓ ccproxy installed successfully.[/green]")
|
||
else:
|
||
console.print(" [yellow]Falling back to API key mode.[/yellow]")
|
||
return "api_key"
|
||
else:
|
||
console.print(
|
||
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
|
||
)
|
||
return "api_key"
|
||
|
||
# If OAuth selected, prompt for port and check auth status
|
||
if auth_mode == "oauth":
|
||
_prompt_ccproxy_port(config)
|
||
authed, msg = check_ccproxy_auth("codex")
|
||
if authed:
|
||
console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
|
||
relogin = questionary.confirm(
|
||
"Re-authenticate to refresh credentials?",
|
||
default=False,
|
||
style=CONFIRM_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if relogin:
|
||
_run_ccproxy_login("codex", "Codex OAuth")
|
||
else:
|
||
console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]")
|
||
login = questionary.confirm(
|
||
"Log in to Codex now?",
|
||
default=True,
|
||
style=CONFIRM_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if login:
|
||
_run_ccproxy_login("codex", "Codex OAuth")
|
||
|
||
return auth_mode
|
||
|
||
|
||
def _step_provider_api_key(
|
||
config: EvoScientistConfig,
|
||
provider: str,
|
||
skip_validation: bool = False,
|
||
) -> str | None:
|
||
"""Step 2: Enter API key for the selected provider.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
provider: Selected provider name.
|
||
skip_validation: Skip API key validation.
|
||
|
||
Returns:
|
||
New API key or None if unchanged.
|
||
"""
|
||
key_name, current, validate_fn = _provider_key_info(config, provider)
|
||
|
||
hint = f"Current: ***{current[-4:]}" if current else "Not set"
|
||
prompt_text = f"Enter {key_name} API key ({hint}, Enter to keep):"
|
||
|
||
return _prompt_and_validate_api_key(
|
||
prompt_text,
|
||
current,
|
||
validate_fn,
|
||
skip_validation,
|
||
)
|
||
|
||
|
||
def _step_base_url(config: EvoScientistConfig, current_value: str | None = None) -> str:
|
||
"""Prompt for custom provider base URL.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
current_value: Current base URL value (if None, defaults to empty).
|
||
|
||
Returns:
|
||
Base URL string.
|
||
"""
|
||
current = current_value if current_value is not None else ""
|
||
hint = f"Current: {current}" if current else ""
|
||
default = current or ""
|
||
|
||
url = questionary.text(
|
||
f"Base URL{' (' + hint + ', Enter to keep)' if hint else ''}:",
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
placeholder=FormattedText([("fg:#858585", " e.g. https://api.example.com/v1")])
|
||
if not default
|
||
else None,
|
||
).ask()
|
||
if url is None:
|
||
raise KeyboardInterrupt()
|
||
return url.strip()
|
||
|
||
|
||
def _step_ollama_base_url(config: EvoScientistConfig) -> tuple[str, list[str]]:
|
||
"""Prompt for Ollama server base URL and validate connection.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Tuple of (base_url, detected_model_names).
|
||
"""
|
||
current = config.ollama_base_url or os.environ.get("OLLAMA_BASE_URL", "")
|
||
default = current or "http://localhost:11434"
|
||
|
||
url = questionary.text(
|
||
f"Ollama base URL (Enter for {default}):",
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
if url is None:
|
||
raise KeyboardInterrupt()
|
||
url = url.strip()
|
||
|
||
detected_models: list[str] = []
|
||
if url:
|
||
console.print(" [dim]Checking Ollama connection...[/dim]", end="")
|
||
valid, msg, detected_models = validate_ollama_connection(url)
|
||
if valid:
|
||
console.print(f"\r [green]\u2713 {msg}[/green] ")
|
||
else:
|
||
console.print(f"\r [yellow]\u2717 {msg}[/yellow] ")
|
||
console.print(" [dim]You can start Ollama later and it will work.[/dim]")
|
||
|
||
return url, detected_models
|
||
|
||
|
||
def _step_model(
|
||
config: EvoScientistConfig,
|
||
provider: str,
|
||
*,
|
||
ollama_detected_models: list[str] | None = None,
|
||
) -> str:
|
||
"""Step 3: Select model for the provider.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
provider: Selected provider name.
|
||
ollama_detected_models: Model names detected from a live Ollama server.
|
||
|
||
Returns:
|
||
Selected model name.
|
||
"""
|
||
# Ollama: show only what's actually pulled on the server
|
||
if provider == "ollama":
|
||
if ollama_detected_models:
|
||
_CUSTOM_SENTINEL = "__custom__"
|
||
choices = [
|
||
Choice(title=name, value=name) for name in ollama_detected_models
|
||
]
|
||
choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL))
|
||
|
||
default = ollama_detected_models[0]
|
||
if config.model in ollama_detected_models:
|
||
default = config.model
|
||
|
||
selected = questionary.select(
|
||
"Select model:",
|
||
choices=choices,
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
if selected is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if selected != _CUSTOM_SENTINEL:
|
||
return selected
|
||
|
||
# No detected models (server down or empty) — direct text input
|
||
if not ollama_detected_models:
|
||
console.print(
|
||
" [dim]No models detected — type the model name you plan to pull.[/dim]"
|
||
)
|
||
model = questionary.text(
|
||
"Model name:",
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
placeholder=FormattedText([("fg:#858585", " e.g. qwen3-coder-next")]),
|
||
).ask()
|
||
if model is None:
|
||
raise KeyboardInterrupt()
|
||
model = model.strip()
|
||
if not model:
|
||
model = "qwen3-coder-next"
|
||
console.print(f" [dim]Using default: {model}[/dim]")
|
||
return model
|
||
|
||
# Get models for the selected provider
|
||
entries = get_models_for_provider(provider)
|
||
|
||
if not entries:
|
||
# Custom / unknown provider: direct text input
|
||
model = questionary.text(
|
||
"Model name:",
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]),
|
||
).ask()
|
||
if model is None:
|
||
raise KeyboardInterrupt()
|
||
return model
|
||
|
||
provider_models = [name for name, _ in entries]
|
||
|
||
# Create choices with model IDs as hints
|
||
_CUSTOM_SENTINEL = "__custom__"
|
||
choices = []
|
||
for name, model_id in entries:
|
||
choices.append(Choice(title=f"{name} ({model_id})", value=name))
|
||
choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL))
|
||
|
||
# Determine default
|
||
if config.model in provider_models:
|
||
default = config.model
|
||
else:
|
||
default = provider_models[0]
|
||
|
||
selected = questionary.select(
|
||
"Select model:",
|
||
choices=choices,
|
||
default=default,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if selected is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if selected != _CUSTOM_SENTINEL:
|
||
return selected
|
||
|
||
model = questionary.text(
|
||
"Model name:",
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]),
|
||
).ask()
|
||
if model is None:
|
||
raise KeyboardInterrupt()
|
||
model = model.strip()
|
||
if not model:
|
||
model = provider_models[0]
|
||
console.print(f" [dim]Using default: {model}[/dim]")
|
||
return model
|
||
|
||
|
||
def _step_reasoning_effort(config: EvoScientistConfig) -> str:
|
||
"""Step 3.5: Configure OpenRouter reasoning effort level.
|
||
|
||
Only shown when the selected provider is OpenRouter. See:
|
||
https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected reasoning effort level, or empty string to use default.
|
||
"""
|
||
effort_choices = [
|
||
Choice(title="xhigh — ~95% of max_tokens for reasoning", value="xhigh"),
|
||
Choice(title="high — ~80% of max_tokens (recommended)", value="high"),
|
||
Choice(title="medium — ~50% of max_tokens", value="medium"),
|
||
Choice(title="low — ~20% of max_tokens", value="low"),
|
||
Choice(title="minimal — ~10% of max_tokens", value="minimal"),
|
||
Choice(title="none — disable reasoning entirely", value="none"),
|
||
]
|
||
|
||
current = config.reasoning_effort or "high"
|
||
effort = questionary.select(
|
||
"Select reasoning effort level:",
|
||
choices=effort_choices,
|
||
default=current,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if effort is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return effort
|
||
|
||
|
||
def _step_tavily_key(
|
||
config: EvoScientistConfig,
|
||
skip_validation: bool = False,
|
||
) -> str | None:
|
||
"""Step 4: Enter Tavily API key for web search.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
skip_validation: Skip API key validation.
|
||
|
||
Returns:
|
||
New API key or None if unchanged.
|
||
"""
|
||
current = config.tavily_api_key or os.environ.get("TAVILY_API_KEY", "")
|
||
|
||
hint = f"Current: ***{current[-4:]}" if current else "Not set"
|
||
prompt_text = f"Tavily API key for web search ({hint}, Enter to keep):"
|
||
|
||
return _prompt_and_validate_api_key(
|
||
prompt_text,
|
||
current,
|
||
validate_tavily_key,
|
||
skip_validation,
|
||
placeholder=FormattedText([("fg:#858585", " (recommended for web search)")]),
|
||
)
|
||
|
||
|
||
def _step_workspace(config: EvoScientistConfig) -> str:
|
||
"""Step 5: Configure workspace mode.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Selected mode ("daemon" or "run").
|
||
"""
|
||
mode_choices = [
|
||
Choice(
|
||
title="Daemon (persistent workspace)",
|
||
value="daemon",
|
||
),
|
||
Choice(
|
||
title="Run (isolated per-session)",
|
||
value="run",
|
||
),
|
||
]
|
||
|
||
mode = questionary.select(
|
||
"Default workspace mode:",
|
||
choices=mode_choices,
|
||
default=config.default_mode,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if mode is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return mode
|
||
|
||
|
||
def _step_thinking(config: EvoScientistConfig) -> bool:
|
||
"""Step 6: Configure thinking panel visibility.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Whether to show thinking panels in the interface.
|
||
"""
|
||
thinking_choices = [
|
||
Choice(title="On (show model reasoning)", value=True),
|
||
Choice(title="Off (hide model reasoning)", value=False),
|
||
]
|
||
|
||
show_thinking = questionary.select(
|
||
"Show thinking panel?",
|
||
choices=thinking_choices,
|
||
default=config.show_thinking,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if show_thinking is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
return show_thinking
|
||
|
||
|
||
_RECOMMENDED_SKILLS = [
|
||
# ── Official (EvoScientist) ──
|
||
{
|
||
"label": "EvoSci Skills (optimized for EvoScientist — paper planning, writing, review, etc.) 👈 Recommended",
|
||
"source": "EvoScientist/EvoSkills@skills",
|
||
},
|
||
# ── Third-party (K-Dense) ──
|
||
{
|
||
"label": "Scientific Skills (147 research & experiment skills, third party by K-Dense)",
|
||
"source": "K-Dense-AI/claude-scientific-skills@scientific-skills",
|
||
},
|
||
{
|
||
"label": "Scientific Writer (23 writing, review & presentation skills, third party by K-Dense)",
|
||
"source": "K-Dense-AI/claude-scientific-writer@skills",
|
||
},
|
||
# ── Third-party (Orchestra Research) ──
|
||
{
|
||
"label": "AI Research Skills (85 skills for training, evaluation, deployment, etc., third party by Orchestra Research)",
|
||
"source": "Orchestra-Research/AI-Research-SKILLs",
|
||
},
|
||
# ── Third-party (Anthropic) ──
|
||
{
|
||
"label": "Anthropic Skills (co-authoring, design, etc., third party by Anthropic)",
|
||
"source": "anthropics/skills@skills",
|
||
},
|
||
# ── Third-party (HuggingFace) ──
|
||
{
|
||
"label": "HuggingFace Skills (dataset creation, model training & evaluation, third party by HuggingFace)",
|
||
"source": "huggingface/skills@skills",
|
||
},
|
||
]
|
||
|
||
|
||
def _check_npx() -> bool:
|
||
"""Check if npx is available on the system.
|
||
|
||
Uses shutil.which() to resolve the executable path, which correctly
|
||
finds .cmd/.bat wrappers on Windows (e.g., npx.cmd).
|
||
|
||
Returns:
|
||
True if npx is found and working.
|
||
"""
|
||
npx = shutil.which("npx")
|
||
if not npx:
|
||
return False
|
||
try:
|
||
result = subprocess.run(
|
||
[npx, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
return result.returncode == 0
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
return False
|
||
|
||
|
||
def _detect_node_install_method() -> tuple[str, str]:
|
||
"""Detect the best way to install Node.js for this environment.
|
||
|
||
Returns:
|
||
Tuple of (method_name, install_command).
|
||
"""
|
||
# Conda environment (any platform)
|
||
if os.environ.get("CONDA_PREFIX"):
|
||
return "conda", "conda install -y nodejs"
|
||
|
||
# macOS with Homebrew
|
||
if sys.platform == "darwin":
|
||
try:
|
||
result = subprocess.run(
|
||
["brew", "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5,
|
||
)
|
||
if result.returncode == 0:
|
||
return "brew", "brew install node"
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
pass
|
||
|
||
# Windows: winget (built-in on Win 10+) or chocolatey
|
||
if sys.platform == "win32":
|
||
if shutil.which("winget"):
|
||
return "winget", "winget install OpenJS.NodeJS.LTS"
|
||
if shutil.which("choco"):
|
||
return "choco", "choco install nodejs-lts -y"
|
||
|
||
return "manual", "https://nodejs.org"
|
||
|
||
|
||
def _install_node(method: str, command: str) -> bool:
|
||
"""Install Node.js using the detected method.
|
||
|
||
Returns:
|
||
True if installation succeeded.
|
||
"""
|
||
if method == "manual":
|
||
return False
|
||
|
||
parts = command.split()
|
||
exe = shutil.which(parts[0]) or parts[0]
|
||
try:
|
||
proc = subprocess.run(
|
||
[exe, *parts[1:]],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
)
|
||
return proc.returncode == 0
|
||
except FileNotFoundError:
|
||
console.print(f" [red]✗ {method} not found[/red]")
|
||
return False
|
||
except subprocess.TimeoutExpired:
|
||
console.print(" [red]✗ Installation timed out[/red]")
|
||
return False
|
||
except Exception as e:
|
||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||
return False
|
||
|
||
|
||
def _ensure_npx(reason: str) -> bool:
|
||
"""Check for npx and offer to install Node.js if missing.
|
||
|
||
Args:
|
||
reason: Why npx is needed (shown in the warning message).
|
||
|
||
Returns:
|
||
True if npx is available (was already present or just installed).
|
||
"""
|
||
if _check_npx():
|
||
return True
|
||
|
||
console.print(f" [yellow]✗ npx not found — {reason}[/yellow]")
|
||
method, command = _detect_node_install_method()
|
||
|
||
if method != "manual":
|
||
install_node = questionary.confirm(
|
||
f"Install Node.js via {method}? ({command})",
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if install_node is None:
|
||
raise KeyboardInterrupt()
|
||
if install_node:
|
||
console.print(" [dim]Installing Node.js...[/dim]")
|
||
if _install_node(method, command):
|
||
if _check_npx():
|
||
console.print(" [green]✓ npx now available[/green]")
|
||
return True
|
||
else:
|
||
console.print(
|
||
" [yellow]✗ npx still not found after install[/yellow]"
|
||
)
|
||
else:
|
||
console.print(" [red]✗ Installation failed[/red]")
|
||
else:
|
||
console.print(f" [dim]Install Node.js: {command}[/dim]")
|
||
|
||
return False
|
||
|
||
|
||
# =============================================================================
|
||
# TinyTeX (LaTeX) helpers
|
||
# =============================================================================
|
||
|
||
|
||
def _check_latex_components() -> dict[str, bool]:
|
||
"""Check which LaTeX components are available.
|
||
|
||
Returns:
|
||
Dict mapping component name to availability:
|
||
``{"pdflatex": bool, "latexmk": bool, "tlmgr": bool}``.
|
||
"""
|
||
result: dict[str, bool] = {}
|
||
for cmd in ("pdflatex", "latexmk", "tlmgr"):
|
||
exe = shutil.which(cmd)
|
||
if not exe:
|
||
result[cmd] = False
|
||
continue
|
||
try:
|
||
proc = subprocess.run(
|
||
[exe, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
result[cmd] = proc.returncode == 0
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
result[cmd] = False
|
||
return result
|
||
|
||
|
||
def _check_tinytex() -> bool:
|
||
"""Check if a usable LaTeX distribution is available.
|
||
|
||
Returns:
|
||
True if pdflatex is found and working.
|
||
"""
|
||
return _check_latex_components().get("pdflatex", False)
|
||
|
||
|
||
def _detect_tinytex_install_method() -> tuple[str, str]:
|
||
"""Detect the best way to install TinyTeX for this platform.
|
||
|
||
Returns:
|
||
Tuple of (method_name, install_command_or_url).
|
||
"""
|
||
if sys.platform == "win32":
|
||
if shutil.which("choco"):
|
||
return "choco", "choco install tinytex -y"
|
||
if shutil.which("scoop"):
|
||
return "scoop", "scoop install tinytex"
|
||
return "manual", "https://yihui.org/tinytex/"
|
||
|
||
# macOS and Linux: use the official install script
|
||
if shutil.which("curl"):
|
||
return (
|
||
"curl",
|
||
'curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
|
||
)
|
||
if shutil.which("wget"):
|
||
return (
|
||
"wget",
|
||
'wget -qO- "https://yihui.org/tinytex/install-bin-unix.sh" | sh',
|
||
)
|
||
|
||
return "manual", "https://yihui.org/tinytex/"
|
||
|
||
|
||
def _install_tinytex(method: str, command: str) -> bool:
|
||
"""Install TinyTeX using the detected method.
|
||
|
||
Returns:
|
||
True if installation succeeded.
|
||
"""
|
||
if method == "manual":
|
||
return False
|
||
|
||
if method in ("curl", "wget"):
|
||
# Pipe-to-shell commands must run through the shell
|
||
try:
|
||
proc = subprocess.run(
|
||
command,
|
||
shell=True, # user confirmed install in wizard
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=300,
|
||
)
|
||
return proc.returncode == 0
|
||
except subprocess.TimeoutExpired:
|
||
console.print(" [red]✗ Installation timed out[/red]")
|
||
return False
|
||
except Exception as e:
|
||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||
return False
|
||
|
||
# choco / scoop
|
||
parts = command.split()
|
||
exe = shutil.which(parts[0]) or parts[0]
|
||
try:
|
||
proc = subprocess.run(
|
||
[exe, *parts[1:]],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=300,
|
||
)
|
||
return proc.returncode == 0
|
||
except FileNotFoundError:
|
||
console.print(f" [red]✗ {method} not found[/red]")
|
||
return False
|
||
except subprocess.TimeoutExpired:
|
||
console.print(" [red]✗ Installation timed out[/red]")
|
||
return False
|
||
except Exception as e:
|
||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||
return False
|
||
|
||
|
||
def _print_latex_status(components: dict[str, bool]) -> None:
|
||
"""Print a single-line status showing all LaTeX components."""
|
||
parts: list[str] = []
|
||
for cmd, _role in (
|
||
("pdflatex", "compiler"),
|
||
("latexmk", "build tool"),
|
||
("tlmgr", "package manager"),
|
||
):
|
||
if components.get(cmd, False):
|
||
parts.append(f"[green]✓ {cmd}[/green]")
|
||
else:
|
||
parts.append(f"[yellow]✗ {cmd}[/yellow]")
|
||
console.print(" " + " ".join(parts))
|
||
|
||
|
||
def _auto_install_latexmk() -> None:
|
||
"""Auto-install latexmk via tlmgr when it is missing."""
|
||
console.print(" [dim]Installing latexmk via tlmgr...[/dim]")
|
||
tlmgr = shutil.which("tlmgr")
|
||
if not tlmgr:
|
||
return
|
||
try:
|
||
proc = subprocess.run(
|
||
[tlmgr, "install", "latexmk"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
)
|
||
if proc.returncode == 0 and shutil.which("latexmk"):
|
||
console.print(" [green]✓ latexmk installed[/green]")
|
||
else:
|
||
console.print(
|
||
" [yellow]⚠ Failed to install latexmk"
|
||
" (run: tlmgr install latexmk)[/yellow]"
|
||
)
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
console.print(
|
||
" [yellow]⚠ Failed to install latexmk"
|
||
" (run: tlmgr install latexmk)[/yellow]"
|
||
)
|
||
|
||
|
||
def _step_tinytex() -> None:
|
||
"""Step 9: Prepare LaTeX environment (TinyTeX).
|
||
|
||
Asks the user whether they want to set up LaTeX for paper compilation.
|
||
If yes, checks for an existing installation and offers to install TinyTeX
|
||
when none is found. The agent can auto-install missing LaTeX packages at
|
||
runtime via ``tlmgr``, so only the base TinyTeX is needed here.
|
||
"""
|
||
latex_choices = [
|
||
Choice(title="No need (skip LaTeX setup)", value=False),
|
||
Choice(title="Install now (TinyTeX compiler)", value=True),
|
||
]
|
||
prepare = questionary.select(
|
||
"LaTeX environment (needed to compile .tex → .pdf):",
|
||
choices=latex_choices,
|
||
default=False,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
|
||
if prepare is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if not prepare:
|
||
_print_step_skipped("LaTeX", "skipped")
|
||
console.print(
|
||
" [dim]Install later:"
|
||
' curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh[/dim]'
|
||
)
|
||
return
|
||
|
||
# User wants LaTeX — check existing installation
|
||
console.print(" [dim]Checking LaTeX environment...[/dim]")
|
||
|
||
components = _check_latex_components()
|
||
|
||
if components["pdflatex"]:
|
||
# Already installed — show detailed status
|
||
_print_latex_status(components)
|
||
# Auto-fix missing latexmk if tlmgr is available
|
||
if not components["latexmk"] and components["tlmgr"]:
|
||
_auto_install_latexmk()
|
||
return
|
||
|
||
# Not installed — detect install method and offer
|
||
console.print(" [yellow]✗ pdflatex not found[/yellow]")
|
||
method, command = _detect_tinytex_install_method()
|
||
|
||
if method == "manual":
|
||
_print_step_skipped("LaTeX", "manual install needed")
|
||
console.print(f" [dim]Install TinyTeX: {command}[/dim]")
|
||
return
|
||
|
||
install = questionary.confirm(
|
||
f"Install TinyTeX via {method}?",
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
|
||
if install is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if not install:
|
||
_print_step_skipped("LaTeX", "skipped")
|
||
console.print(f" [dim]Install later: {command}[/dim]")
|
||
return
|
||
|
||
console.print(" [dim]Installing TinyTeX (this may take a minute)...[/dim]")
|
||
if _install_tinytex(method, command):
|
||
post = _check_latex_components()
|
||
if post["pdflatex"]:
|
||
_print_latex_status(post)
|
||
_print_step_result("LaTeX", "TinyTeX installed")
|
||
else:
|
||
console.print(" [green]✓ TinyTeX installed[/green]")
|
||
console.print(
|
||
" [yellow]⚠ Restart your terminal"
|
||
" for pdflatex to appear in PATH[/yellow]"
|
||
)
|
||
_print_step_result("LaTeX", "installed (restart terminal for PATH)")
|
||
else:
|
||
console.print(f" [dim]Install manually: {command}[/dim]")
|
||
_print_step_result("LaTeX", "installation failed", success=False)
|
||
|
||
|
||
def _step_skills() -> list[str]:
|
||
"""Step 7: Optionally install recommended skills.
|
||
|
||
Shows checkbox first. Already-installed skills are shown as disabled
|
||
so users don't accidentally reinstall them. If user selects nothing,
|
||
checks npx as an easter egg — confirms skill discovery is available,
|
||
or offers to install Node.js if missing.
|
||
|
||
Returns:
|
||
List of skill sources that were selected (empty if skipped).
|
||
"""
|
||
from ..paths import USER_SKILLS_DIR
|
||
|
||
# Collect names of already-installed user skills
|
||
skills_dir = Path(USER_SKILLS_DIR)
|
||
installed_names: set[str] = set()
|
||
if skills_dir.exists():
|
||
installed_names = {e.name for e in skills_dir.iterdir() if e.is_dir()}
|
||
|
||
def _hint_name(source: str) -> str:
|
||
"""Derive expected skill directory name from source URL."""
|
||
if "@" in source and "://" not in source:
|
||
return source.split("@", 1)[1].strip()
|
||
return source.rstrip("/").rsplit("/", 1)[-1]
|
||
|
||
choices = []
|
||
for skill in _RECOMMENDED_SKILLS:
|
||
if _hint_name(skill["source"]) in installed_names:
|
||
choices.append(
|
||
Choice(
|
||
title=[
|
||
("", skill["label"]),
|
||
("class:instruction", " (already installed)"),
|
||
],
|
||
value=skill["source"],
|
||
disabled=True,
|
||
)
|
||
)
|
||
else:
|
||
choices.append(Choice(title=skill["label"], value=skill["source"]))
|
||
|
||
all_installed = all(
|
||
_hint_name(skill["source"]) in installed_names for skill in _RECOMMENDED_SKILLS
|
||
)
|
||
if all_installed:
|
||
console.print(
|
||
" [green]✓ All recommended skills are already installed.[/green]"
|
||
)
|
||
return []
|
||
|
||
selected = _checkbox_ask(choices, "Install or Sync predefined skills:")
|
||
|
||
if selected is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if not selected:
|
||
# Verify skill discovery environment
|
||
console.print(" [dim]Checking skill discovery environment...[/dim]")
|
||
has_npx = _ensure_npx("skill discovery requires Node.js")
|
||
if has_npx:
|
||
_print_step_skipped("Skills", "none selected — good choice!")
|
||
console.print(" [green]✓ npx found — skill discovery available[/green]")
|
||
console.print(
|
||
" [yellow bold]* Less is more[/yellow bold] [dim](EvoScientist can discover and install skills on its own)[/dim]"
|
||
)
|
||
else:
|
||
_print_step_skipped("Skills", "none selected")
|
||
|
||
return []
|
||
|
||
from ..tools.skills_manager import install_skill
|
||
|
||
installed = []
|
||
for source in selected:
|
||
label = next(s["label"] for s in _RECOMMENDED_SKILLS if s["source"] == source)
|
||
try:
|
||
result = install_skill(source)
|
||
if result.get("success"):
|
||
_print_step_result("Skill", label)
|
||
installed.append(source)
|
||
else:
|
||
_print_step_result(
|
||
"Skill", f"{label} — {result.get('error', 'failed')}", success=False
|
||
)
|
||
except Exception as e:
|
||
_print_step_result("Skill", f"{label} — {e}", success=False)
|
||
|
||
return installed
|
||
|
||
|
||
def _step_mcp_servers() -> list[str]:
|
||
"""Step 8: Optionally install recommended MCP servers.
|
||
|
||
Shows a checkbox list of recommended servers. Already-configured servers
|
||
are shown as disabled so users don't accidentally override them.
|
||
Selected ones are added to the user MCP config via ``install_mcp_server()``.
|
||
|
||
Handles env-key prompts, pip package installs, and URL-based servers.
|
||
|
||
Returns:
|
||
List of server names that were installed.
|
||
"""
|
||
from ..mcp.client import _load_user_config
|
||
from ..mcp.registry import fetch_marketplace_index, install_mcp_server
|
||
|
||
try:
|
||
all_servers = fetch_marketplace_index()
|
||
except Exception:
|
||
all_servers = []
|
||
servers = [s for s in all_servers if "onboarding" in s.tags]
|
||
existing_config = _load_user_config()
|
||
|
||
choices = []
|
||
for srv in servers:
|
||
if srv.name in existing_config:
|
||
choices.append(
|
||
Choice(
|
||
title=[
|
||
("", srv.label),
|
||
("class:instruction", " (already configured)"),
|
||
],
|
||
value=srv.name,
|
||
disabled=True,
|
||
)
|
||
)
|
||
else:
|
||
choices.append(Choice(title=srv.label, value=srv.name))
|
||
|
||
all_installed = all(srv.name in existing_config for srv in servers)
|
||
if all_installed:
|
||
console.print(
|
||
"[green]\u2713 All recommended MCP servers are already configured.[/green]"
|
||
)
|
||
return []
|
||
|
||
selected = _checkbox_ask(choices, "Install recommended MCP servers:")
|
||
|
||
if selected is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if not selected:
|
||
_print_step_skipped("MCP Servers", "none selected")
|
||
console.print(
|
||
" [dim]Add later with: EvoSci mcp add <name> <command> [--env-ref KEY] -- [args][/dim]"
|
||
)
|
||
return []
|
||
|
||
# Check if any selected servers require npx
|
||
needs_npx = any(srv.command == "npx" for srv in servers if srv.name in selected)
|
||
if needs_npx:
|
||
if not _ensure_npx("some MCP servers require Node.js"):
|
||
npx_servers = {
|
||
srv.name
|
||
for srv in servers
|
||
if srv.name in selected and srv.command == "npx"
|
||
}
|
||
selected = [s for s in selected if s not in npx_servers]
|
||
if npx_servers:
|
||
console.print(
|
||
f" [yellow]\u26a0 Skipping {', '.join(sorted(npx_servers))} (npx not available)[/yellow]"
|
||
)
|
||
if not selected:
|
||
return []
|
||
|
||
installed = []
|
||
for name in selected:
|
||
srv = next(s for s in servers if s.name == name)
|
||
try:
|
||
if install_mcp_server(srv):
|
||
_print_step_result("MCP", f"{name}")
|
||
installed.append(name)
|
||
else:
|
||
_print_step_result(
|
||
"MCP", f"{name} — installation failed", success=False
|
||
)
|
||
except Exception as e:
|
||
_print_step_result("MCP", f"{name} — {e}", success=False)
|
||
|
||
return installed
|
||
|
||
|
||
def validate_imessage() -> tuple[bool, str]:
|
||
"""Validate iMessage environment by checking for the imsg CLI.
|
||
|
||
Returns:
|
||
Tuple of (is_valid, message).
|
||
"""
|
||
# macOS only
|
||
if sys.platform != "darwin":
|
||
return False, "iMessage requires macOS"
|
||
|
||
from ..channels.imessage.probe import find_cli
|
||
|
||
cli_path = find_cli()
|
||
if not cli_path:
|
||
return False, "not_installed"
|
||
|
||
# Check version
|
||
try:
|
||
result = subprocess.run(
|
||
[cli_path, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5,
|
||
)
|
||
version = result.stdout.strip() if result.returncode == 0 else None
|
||
except Exception:
|
||
version = None
|
||
|
||
# Check RPC support
|
||
try:
|
||
result = subprocess.run(
|
||
[cli_path, "rpc", "--help"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5,
|
||
)
|
||
rpc_ok = result.returncode == 0
|
||
except Exception:
|
||
rpc_ok = False
|
||
|
||
if not rpc_ok:
|
||
return (
|
||
False,
|
||
f"imsg found at {cli_path} but RPC not supported (update with: brew upgrade imsg)",
|
||
)
|
||
|
||
version_str = f" ({version})" if version else ""
|
||
return True, f"imsg{version_str} at {cli_path}"
|
||
|
||
|
||
def _install_ccproxy() -> bool:
|
||
"""Run pip install for ccproxy (evoscientist[oauth]).
|
||
|
||
Uses uv pip install when available (uv-managed envs don't ship pip).
|
||
|
||
Returns:
|
||
True if installation succeeded and ccproxy is available.
|
||
"""
|
||
from ..ccproxy_manager import is_ccproxy_available
|
||
from ..mcp.registry import install_pip_package
|
||
|
||
ok = install_pip_package("evoscientist[oauth]")
|
||
if not ok:
|
||
console.print(" [red]✗ Installation failed.[/red]")
|
||
return False
|
||
return is_ccproxy_available()
|
||
|
||
|
||
def _install_imsg() -> bool:
|
||
"""Run brew install for imsg CLI.
|
||
|
||
Returns:
|
||
True if installation succeeded.
|
||
"""
|
||
try:
|
||
proc = subprocess.run(
|
||
["brew", "install", "steipete/tap/imsg"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
)
|
||
return proc.returncode == 0
|
||
except FileNotFoundError:
|
||
console.print(" [red]✗ Homebrew not found[/red]")
|
||
console.print(" [dim]Install Homebrew first: https://brew.sh[/dim]")
|
||
return False
|
||
except subprocess.TimeoutExpired:
|
||
console.print(" [red]✗ Installation timed out[/red]")
|
||
return False
|
||
except Exception as e:
|
||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||
return False
|
||
|
||
|
||
def _setup_imessage() -> bool:
|
||
"""Guide the user through iMessage setup: install, validate, test.
|
||
|
||
Returns:
|
||
True if iMessage is ready to use.
|
||
"""
|
||
# Step 1: Validate
|
||
console.print(" [dim]Checking iMessage environment...[/dim]")
|
||
valid, msg = validate_imessage()
|
||
|
||
if valid:
|
||
console.print(f" [green]✓ {msg}[/green]")
|
||
return True
|
||
|
||
if msg == "iMessage requires macOS":
|
||
console.print(f" [red]✗ {msg}[/red]")
|
||
return False
|
||
|
||
if msg == "not_installed":
|
||
console.print(" [yellow]✗ imsg CLI not installed[/yellow]")
|
||
console.print()
|
||
|
||
# Step 2: Offer to install
|
||
install = questionary.confirm(
|
||
"Install imsg via Homebrew? (brew install steipete/tap/imsg)",
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
|
||
if install is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if install:
|
||
console.print()
|
||
if _install_imsg():
|
||
console.print()
|
||
# Re-validate after install
|
||
valid, msg = validate_imessage()
|
||
if valid:
|
||
console.print(f" [green]✓ {msg}[/green]")
|
||
return True
|
||
else:
|
||
console.print(f" [red]✗ {msg}[/red]")
|
||
return False
|
||
else:
|
||
return False
|
||
else:
|
||
console.print(
|
||
" [dim]Skipped. Install manually: brew install steipete/tap/imsg[/dim]"
|
||
)
|
||
return False
|
||
else:
|
||
# RPC not supported or other issue
|
||
console.print(f" [red]✗ {msg}[/red]")
|
||
return False
|
||
|
||
|
||
def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
|
||
"""Step: Select channels to enable on startup.
|
||
|
||
Presents a multi-select list of supported channels.
|
||
For each selected channel, prompts for required credentials
|
||
and validates them via the channel's probe function.
|
||
|
||
Args:
|
||
config: Current configuration.
|
||
|
||
Returns:
|
||
Dict mapping config field names to their new values.
|
||
Empty dict when the user skips or selects nothing.
|
||
"""
|
||
# Currently enabled channels
|
||
_currently_enabled = {
|
||
t.strip()
|
||
for t in (getattr(config, "channel_enabled", "") or "").split(",")
|
||
if t.strip()
|
||
}
|
||
# Legacy iMessage compat
|
||
if (
|
||
getattr(config, "imessage_enabled", False)
|
||
and "imessage" not in _currently_enabled
|
||
):
|
||
_currently_enabled.add("imessage")
|
||
|
||
# Direct pip packages for each channel extra. Used to install the
|
||
# exact dependency without requiring the evoscientist package itself
|
||
# to be resolvable on PyPI (e.g. editable / dev installs).
|
||
_CHANNEL_PIP_DEPS: dict[str, list[str]] = {
|
||
"telegram": ["python-telegram-bot>=21.0"],
|
||
"discord": ["discord.py>=2.3"],
|
||
"slack": ["slack-sdk>=3.27", "aiohttp>=3.9"],
|
||
"feishu": ["aiohttp>=3.9"],
|
||
"dingtalk": ["aiohttp>=3.9"],
|
||
"wechat": ["pycryptodome>=3.20", "aiohttp>=3.9"],
|
||
"qq": ["qq-botpy>=1.0"],
|
||
}
|
||
|
||
# Channel definitions: (value, display_name, required_fields, import_check, pip_extra)
|
||
# import_check: module name to try importing; None = no check needed
|
||
_CHANNELS = [
|
||
(
|
||
"telegram",
|
||
"Telegram",
|
||
[("telegram_bot_token", "Bot token (from @BotFather)")],
|
||
"telegram",
|
||
"telegram",
|
||
),
|
||
(
|
||
"discord",
|
||
"Discord",
|
||
[("discord_bot_token", "Bot token")],
|
||
"discord",
|
||
"discord",
|
||
),
|
||
(
|
||
"slack",
|
||
"Slack",
|
||
[
|
||
("slack_bot_token", "Bot token (xoxb-...)"),
|
||
("slack_app_token", "App token for Socket Mode (xapp-...)"),
|
||
],
|
||
"slack_sdk",
|
||
"slack",
|
||
),
|
||
(
|
||
"feishu",
|
||
"Feishu",
|
||
[("feishu_app_id", "App ID"), ("feishu_app_secret", "App Secret")],
|
||
"aiohttp",
|
||
"feishu",
|
||
),
|
||
(
|
||
"dingtalk",
|
||
"DingTalk",
|
||
[
|
||
("dingtalk_client_id", "Client ID (AppKey)"),
|
||
("dingtalk_client_secret", "Client Secret (AppSecret)"),
|
||
],
|
||
"aiohttp",
|
||
"dingtalk",
|
||
),
|
||
(
|
||
"wechat",
|
||
"WeChat",
|
||
[
|
||
("wechat_wecom_corp_id", "WeCom Corp ID"),
|
||
("wechat_wecom_agent_id", "WeCom Agent ID"),
|
||
("wechat_wecom_secret", "WeCom Secret"),
|
||
],
|
||
"aiohttp",
|
||
"wechat",
|
||
),
|
||
(
|
||
"email",
|
||
"Email",
|
||
[
|
||
("email_imap_host", "IMAP host"),
|
||
("email_imap_username", "IMAP username"),
|
||
("email_imap_password", "IMAP password"),
|
||
("email_smtp_host", "SMTP host"),
|
||
("email_smtp_username", "SMTP username"),
|
||
("email_smtp_password", "SMTP password"),
|
||
("email_from_address", "From address"),
|
||
],
|
||
None,
|
||
None,
|
||
),
|
||
(
|
||
"qq",
|
||
"QQ",
|
||
[("qq_app_id", "App ID"), ("qq_app_secret", "App Secret")],
|
||
"botpy",
|
||
"qq",
|
||
),
|
||
(
|
||
"signal",
|
||
"Signal",
|
||
[("signal_phone_number", "Phone number (E.164)")],
|
||
None,
|
||
None,
|
||
),
|
||
("imessage", "iMessage", [], None, None), # handled via _setup_imessage()
|
||
]
|
||
|
||
choices = [
|
||
Choice(
|
||
title=display,
|
||
value=value,
|
||
checked=value in _currently_enabled,
|
||
)
|
||
for value, display, *_ in _CHANNELS
|
||
]
|
||
|
||
selected = questionary.checkbox(
|
||
"Select channels to enable (Space to toggle, Enter to confirm):",
|
||
choices=choices,
|
||
style=WIZARD_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
|
||
if selected is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
updates: dict[str, object] = {}
|
||
|
||
if not selected:
|
||
updates["channel_enabled"] = ""
|
||
updates["imessage_enabled"] = False
|
||
return updates
|
||
|
||
from ..mcp.registry import install_pip_package, pip_install_hint
|
||
|
||
# Build a lookup for channel definitions
|
||
_ch_lookup = {
|
||
v: (v, d, fields, imp, extra) for v, d, fields, imp, extra in _CHANNELS
|
||
}
|
||
|
||
enabled_channels: list[str] = []
|
||
|
||
for ch_name in selected:
|
||
_, display, required_fields, import_check, pip_extra = _ch_lookup[ch_name]
|
||
console.print(f"\n [bold cyan]── {display} ──[/bold cyan]")
|
||
|
||
# Check pip dependency before proceeding
|
||
if import_check:
|
||
_pkg_ready = False
|
||
try:
|
||
__import__(import_check)
|
||
_pkg_ready = True
|
||
except ImportError:
|
||
console.print(" [yellow]✗ Required package not installed.[/yellow]")
|
||
# Determine packages to install
|
||
_pip_pkgs = _CHANNEL_PIP_DEPS.get(pip_extra, []) if pip_extra else []
|
||
_pkg_display = (
|
||
" ".join(f'"{p}"' for p in _pip_pkgs)
|
||
if _pip_pkgs
|
||
else f'"evoscientist[{pip_extra}]"'
|
||
)
|
||
install_now = questionary.confirm(
|
||
f"Install {_pkg_display} now?",
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if install_now is None:
|
||
raise KeyboardInterrupt() from None
|
||
if install_now:
|
||
console.print(f" [dim]Installing {_pkg_display}...[/dim]")
|
||
if _pip_pkgs:
|
||
_ok = all(install_pip_package(p) for p in _pip_pkgs)
|
||
else:
|
||
_ok = install_pip_package(f"evoscientist[{pip_extra}]")
|
||
if _ok:
|
||
# Verify the import actually works now
|
||
try:
|
||
__import__(import_check)
|
||
console.print(" [green]✓ Installed successfully.[/green]")
|
||
_pkg_ready = True
|
||
except ImportError:
|
||
console.print(
|
||
" [red]✗ Package installed but import failed.[/red]"
|
||
)
|
||
console.print(
|
||
" [dim]Try restarting and running:[/dim] evosci channel setup"
|
||
)
|
||
else:
|
||
console.print(" [red]✗ Installation failed.[/red]")
|
||
console.print(
|
||
f" [dim]Run manually:[/dim] {pip_install_hint()} {_pkg_display}"
|
||
)
|
||
if not _pkg_ready:
|
||
continue
|
||
|
||
# Special handling for iMessage
|
||
if ch_name == "imessage":
|
||
ready = _setup_imessage()
|
||
if not ready:
|
||
console.print()
|
||
enable_anyway = questionary.confirm(
|
||
"Enable iMessage anyway? (will try to connect on startup)",
|
||
default=False,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if enable_anyway is None:
|
||
raise KeyboardInterrupt()
|
||
if not enable_anyway:
|
||
continue
|
||
# Allowed senders
|
||
senders = questionary.text(
|
||
"Allowed senders (comma-separated, empty = all):",
|
||
default=getattr(config, "imessage_allowed_senders", ""),
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if senders is None:
|
||
raise KeyboardInterrupt()
|
||
updates["imessage_enabled"] = True
|
||
updates["imessage_allowed_senders"] = senders.strip()
|
||
enabled_channels.append("imessage")
|
||
continue
|
||
|
||
# Prompt for required fields
|
||
for field_name, prompt_label in required_fields:
|
||
current = getattr(config, field_name, "")
|
||
value = questionary.text(
|
||
f"{prompt_label}:",
|
||
default=current,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if value is None:
|
||
raise KeyboardInterrupt()
|
||
updates[field_name] = value.strip()
|
||
|
||
# Feishu: subscription mode + optional fields
|
||
if ch_name == "feishu":
|
||
mode_choices = [
|
||
Choice(
|
||
title="Webhook (requires public IP / port forwarding)",
|
||
value="webhook",
|
||
),
|
||
Choice(
|
||
title="WebSocket long connection (no public IP needed)",
|
||
value="websocket",
|
||
),
|
||
]
|
||
sub_mode = questionary.select(
|
||
"Subscription mode:",
|
||
choices=mode_choices,
|
||
default="webhook",
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
use_indicator=True,
|
||
).ask()
|
||
if sub_mode is None:
|
||
raise KeyboardInterrupt()
|
||
updates["feishu_subscription_mode"] = sub_mode
|
||
|
||
if sub_mode == "websocket":
|
||
# WebSocket mode needs lark-oapi SDK
|
||
try:
|
||
__import__("lark_oapi")
|
||
except ImportError:
|
||
console.print(
|
||
' [yellow]✗ WebSocket mode requires "lark-oapi".[/yellow]'
|
||
)
|
||
install_sdk = questionary.confirm(
|
||
'Install "lark-oapi>=1.4.0" now?',
|
||
default=True,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if install_sdk is None:
|
||
raise KeyboardInterrupt() from None
|
||
if install_sdk:
|
||
console.print(' [dim]Installing "lark-oapi"...[/dim]')
|
||
if install_pip_package("lark-oapi>=1.4.0"):
|
||
console.print(" [green]✓ Installed successfully.[/green]")
|
||
else:
|
||
console.print(" [red]✗ Installation failed.[/red]")
|
||
console.print(
|
||
f" [dim]Run manually:[/dim] {pip_install_hint()} "
|
||
'"lark-oapi>=1.4.0"'
|
||
)
|
||
else:
|
||
# Webhook mode: prompt optional verification/encryption fields
|
||
console.print(
|
||
" [dim]The following fields are optional"
|
||
" (press Enter to skip):[/dim]"
|
||
)
|
||
for field_name, prompt_label in [
|
||
("feishu_verification_token", "Verification Token (optional)"),
|
||
("feishu_encrypt_key", "Encrypt Key (optional)"),
|
||
]:
|
||
current = getattr(config, field_name, "")
|
||
value = questionary.text(
|
||
f"{prompt_label}:",
|
||
default=current,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if value is None:
|
||
raise KeyboardInterrupt()
|
||
updates[field_name] = value.strip()
|
||
|
||
# Allowed senders (common for all channels)
|
||
senders_field = f"{ch_name}_allowed_senders"
|
||
if hasattr(config, senders_field):
|
||
senders = questionary.text(
|
||
"Allowed senders (comma-separated, empty = all):",
|
||
default=getattr(config, senders_field, ""),
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
).ask()
|
||
if senders is None:
|
||
raise KeyboardInterrupt()
|
||
updates[senders_field] = senders.strip()
|
||
|
||
# Probe validation
|
||
_probe_channel(ch_name, config, updates)
|
||
|
||
enabled_channels.append(ch_name)
|
||
|
||
updates["channel_enabled"] = ",".join(enabled_channels)
|
||
# Keep legacy field in sync
|
||
updates["imessage_enabled"] = "imessage" in enabled_channels
|
||
|
||
# --- Common prompt: send thinking (shown when any channel is enabled) ---
|
||
if enabled_channels:
|
||
console.print("\n [bold cyan]── Channel Settings ──[/bold cyan]")
|
||
thinking_choices = [
|
||
Choice(title="On (forward model reasoning)", value=True),
|
||
Choice(title="Off (only send final responses)", value=False),
|
||
]
|
||
|
||
send_thinking = questionary.select(
|
||
"Send thinking panel in channel?",
|
||
choices=thinking_choices,
|
||
default=config.channel_send_thinking,
|
||
style=WIZARD_STYLE,
|
||
qmark=f" {QMARK}",
|
||
use_indicator=True,
|
||
).ask()
|
||
|
||
if send_thinking is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
updates["channel_send_thinking"] = send_thinking
|
||
|
||
return updates
|
||
|
||
|
||
def _probe_channel(
|
||
ch_name: str,
|
||
config: EvoScientistConfig,
|
||
updates: dict[str, object],
|
||
) -> None:
|
||
"""Run the probe for a channel type and print the result.
|
||
|
||
Non-fatal: prints a warning on failure but does not prevent enabling.
|
||
"""
|
||
import asyncio
|
||
|
||
def _val(key: str, fallback: str = "") -> str:
|
||
"""Get a value from updates first, then config, then fallback."""
|
||
if key in updates:
|
||
return str(updates[key])
|
||
return str(getattr(config, key, fallback))
|
||
|
||
console.print(" [dim]Validating credentials...[/dim]")
|
||
|
||
async def _run() -> tuple[bool, str]:
|
||
if ch_name == "telegram":
|
||
from ..channels.telegram.probe import validate_telegram_token
|
||
|
||
return await validate_telegram_token(
|
||
_val("telegram_bot_token"),
|
||
_val("telegram_proxy") or None,
|
||
)
|
||
elif ch_name == "discord":
|
||
from ..channels.discord.probe import validate_discord_token
|
||
|
||
return await validate_discord_token(
|
||
_val("discord_bot_token"),
|
||
_val("discord_proxy") or None,
|
||
)
|
||
elif ch_name == "slack":
|
||
from ..channels.slack.probe import validate_slack_tokens
|
||
|
||
return await validate_slack_tokens(
|
||
_val("slack_bot_token"),
|
||
_val("slack_app_token") or None,
|
||
_val("slack_proxy") or None,
|
||
)
|
||
elif ch_name == "wechat":
|
||
backend = _val("wechat_backend", "wecom")
|
||
if backend == "wechatmp":
|
||
from ..channels.wechat.probe import validate_wechat_mp
|
||
|
||
return await validate_wechat_mp(
|
||
_val("wechat_mp_app_id"),
|
||
_val("wechat_mp_app_secret"),
|
||
_val("wechat_proxy") or None,
|
||
)
|
||
else:
|
||
from ..channels.wechat.probe import validate_wecom
|
||
|
||
return await validate_wecom(
|
||
_val("wechat_wecom_corp_id"),
|
||
_val("wechat_wecom_secret"),
|
||
_val("wechat_proxy") or None,
|
||
)
|
||
elif ch_name == "feishu":
|
||
from ..channels.feishu.probe import validate_feishu_credentials
|
||
|
||
return await validate_feishu_credentials(
|
||
_val("feishu_app_id"),
|
||
_val("feishu_app_secret"),
|
||
_val("feishu_domain", "https://open.feishu.cn"),
|
||
)
|
||
elif ch_name == "dingtalk":
|
||
from ..channels.dingtalk.probe import validate_dingtalk
|
||
|
||
return await validate_dingtalk(
|
||
_val("dingtalk_client_id"),
|
||
_val("dingtalk_client_secret"),
|
||
_val("dingtalk_proxy") or None,
|
||
)
|
||
elif ch_name == "email":
|
||
from ..channels.email.probe import validate_email_imap
|
||
|
||
return await validate_email_imap(
|
||
_val("email_imap_host"),
|
||
int(_val("email_imap_port", "993")),
|
||
_val("email_imap_username"),
|
||
_val("email_imap_password"),
|
||
_val("email_imap_use_ssl", "True").lower() not in ("false", "0", "no"),
|
||
)
|
||
elif ch_name == "qq":
|
||
from ..channels.qq.probe import validate_qq
|
||
|
||
return await validate_qq(
|
||
_val("qq_app_id"),
|
||
_val("qq_app_secret"),
|
||
)
|
||
elif ch_name == "signal":
|
||
from ..channels.signal.probe import validate_signal
|
||
|
||
return await validate_signal(
|
||
_val("signal_phone_number"),
|
||
_val("signal_cli_path", "signal-cli"),
|
||
int(_val("signal_rpc_port", "7583")),
|
||
)
|
||
else:
|
||
return True, "No probe available"
|
||
|
||
try:
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
if loop.is_running():
|
||
import nest_asyncio # type: ignore[import-untyped]
|
||
|
||
nest_asyncio.apply()
|
||
except RuntimeError:
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
|
||
ok, detail = loop.run_until_complete(_run())
|
||
if ok:
|
||
console.print(f" [green]✓ {detail}[/green]")
|
||
else:
|
||
console.print(f" [yellow]⚠ {detail}[/yellow]")
|
||
console.print(
|
||
" [dim]Channel will still be enabled — check credentials later.[/dim]"
|
||
)
|
||
except Exception as e:
|
||
console.print(f" [yellow]⚠ Could not validate: {e}[/yellow]")
|
||
console.print(
|
||
" [dim]Channel will still be enabled — check credentials later.[/dim]"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Progress Rendering (for tests and potential future use)
|
||
# =============================================================================
|
||
|
||
|
||
def render_progress(current_step: int, completed: set[int]) -> Panel:
|
||
"""Render the progress indicator panel.
|
||
|
||
Args:
|
||
current_step: Index of the current step (0-based).
|
||
completed: Set of completed step indices.
|
||
|
||
Returns:
|
||
A Rich Panel displaying the progress.
|
||
"""
|
||
lines = []
|
||
for i, step_name in enumerate(STEPS):
|
||
if i in completed:
|
||
icon = Text("●", style="green bold")
|
||
label = Text(f" {step_name}", style="green")
|
||
elif i == current_step:
|
||
icon = Text("◉", style="cyan bold")
|
||
label = Text(f" {step_name}", style="cyan bold")
|
||
else:
|
||
icon = Text("○", style="dim")
|
||
label = Text(f" {step_name}", style="dim")
|
||
|
||
line = Text()
|
||
line.append_text(icon)
|
||
line.append_text(label)
|
||
lines.append(line)
|
||
|
||
# Add connector line between steps
|
||
if i < len(STEPS) - 1:
|
||
if i in completed:
|
||
connector_style = "green"
|
||
elif i == current_step:
|
||
connector_style = "cyan"
|
||
else:
|
||
connector_style = "dim"
|
||
lines.append(Text("│", style=connector_style))
|
||
|
||
# Join all lines with newlines
|
||
content = Text("\n").join(lines)
|
||
return Panel(content, title="[bold]EvoScientist Setup[/bold]", border_style="blue")
|
||
|
||
|
||
# =============================================================================
|
||
# Main onboard function
|
||
# =============================================================================
|
||
|
||
|
||
def run_onboard(skip_validation: bool = False) -> bool:
|
||
"""Run the interactive onboarding wizard.
|
||
|
||
Args:
|
||
skip_validation: Skip API key validation.
|
||
|
||
Returns:
|
||
True if configuration was saved, False if cancelled.
|
||
"""
|
||
try:
|
||
# Print header once
|
||
_print_header()
|
||
|
||
# Load existing config as starting point
|
||
config = load_config()
|
||
|
||
# Step 0: UI Backend
|
||
ui_backend = _step_ui_backend(config)
|
||
config.ui_backend = ui_backend
|
||
|
||
# Step 1: Provider
|
||
provider = _step_provider(config)
|
||
config.provider = provider
|
||
|
||
# Step 2a: Base URL (custom-openai, custom-anthropic, minimax, or ollama)
|
||
ollama_detected_models: list[str] = []
|
||
if provider == "custom-openai":
|
||
current_base_url = config.custom_openai_base_url or os.environ.get(
|
||
"CUSTOM_OPENAI_BASE_URL", ""
|
||
)
|
||
base_url = _step_base_url(config, current_value=current_base_url)
|
||
config.custom_openai_base_url = base_url
|
||
elif provider == "custom-anthropic":
|
||
current_base_url = config.custom_anthropic_base_url or os.environ.get(
|
||
"CUSTOM_ANTHROPIC_BASE_URL", ""
|
||
)
|
||
base_url = _step_base_url(config, current_value=current_base_url)
|
||
config.custom_anthropic_base_url = base_url
|
||
elif provider == "minimax":
|
||
config.minimax_base_url = _step_minimax_region(config)
|
||
elif provider == "ollama":
|
||
ollama_url, ollama_detected_models = _step_ollama_base_url(config)
|
||
config.ollama_base_url = ollama_url
|
||
|
||
# Step 2b: Auth mode (Anthropic or OpenAI — API key vs OAuth)
|
||
if provider == "anthropic":
|
||
auth_mode = _step_anthropic_auth_mode(config)
|
||
config.anthropic_auth_mode = auth_mode
|
||
elif provider == "openai":
|
||
auth_mode = _step_openai_auth_mode(config)
|
||
config.openai_auth_mode = auth_mode
|
||
else:
|
||
# Non-Anthropic/OpenAI provider: reset OAuth modes to avoid
|
||
# stale oauth config triggering ccproxy requirement on startup
|
||
config.anthropic_auth_mode = "api_key"
|
||
config.openai_auth_mode = "api_key"
|
||
|
||
# Step 2c: Provider API Key (skip for Ollama — no key needed,
|
||
# and for Anthropic/OpenAI pure OAuth — key provided by ccproxy)
|
||
_PROVIDER_KEY_ATTR = {
|
||
"anthropic": "anthropic_api_key",
|
||
"minimax": "minimax_api_key",
|
||
"nvidia": "nvidia_api_key",
|
||
"google-genai": "google_api_key",
|
||
"siliconflow": "siliconflow_api_key",
|
||
"openrouter": "openrouter_api_key",
|
||
"deepseek": "deepseek_api_key",
|
||
"zhipu": "zhipu_api_key",
|
||
"zhipu-code": "zhipu_api_key",
|
||
"volcengine": "volcengine_api_key",
|
||
"dashscope": "dashscope_api_key",
|
||
"moonshot": "moonshot_api_key",
|
||
"kimi-coding": "kimi_api_key",
|
||
"custom-openai": "custom_openai_api_key",
|
||
"custom-anthropic": "custom_anthropic_api_key",
|
||
}
|
||
_skip_api_key = (
|
||
provider == "ollama"
|
||
or (provider == "anthropic" and config.anthropic_auth_mode == "oauth")
|
||
or (provider == "openai" and config.openai_auth_mode == "oauth")
|
||
)
|
||
if not _skip_api_key:
|
||
new_key = _step_provider_api_key(config, provider, skip_validation)
|
||
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
|
||
if new_key is not None:
|
||
setattr(config, key_attr, new_key)
|
||
elif not getattr(config, key_attr):
|
||
_print_step_skipped("API Key", "not set")
|
||
|
||
# Step 3: Model
|
||
model = _step_model(
|
||
config, provider, ollama_detected_models=ollama_detected_models
|
||
)
|
||
config.model = model
|
||
|
||
# Step 3.5: Reasoning Effort (OpenRouter only)
|
||
if provider == "openrouter":
|
||
effort = _step_reasoning_effort(config)
|
||
config.reasoning_effort = effort
|
||
|
||
# Step 4: Tavily Key
|
||
new_tavily_key = _step_tavily_key(config, skip_validation)
|
||
if new_tavily_key is not None:
|
||
config.tavily_api_key = new_tavily_key
|
||
else:
|
||
if not config.tavily_api_key:
|
||
_print_step_skipped("Tavily Key", "not set")
|
||
|
||
# Step 5: Workspace
|
||
mode = _step_workspace(config)
|
||
config.default_mode = mode
|
||
|
||
# Step 6: Thinking
|
||
show_thinking = _step_thinking(config)
|
||
config.show_thinking = show_thinking
|
||
|
||
# Step 7: Skills
|
||
_step_skills()
|
||
|
||
# Step 8: MCP Servers
|
||
_step_mcp_servers()
|
||
|
||
# Step 9: LaTeX (TinyTeX)
|
||
_step_tinytex()
|
||
|
||
# Step 10: Channels
|
||
channel_updates = _step_channels(config)
|
||
for key, value in channel_updates.items():
|
||
setattr(config, key, value)
|
||
|
||
# Confirm save
|
||
console.print()
|
||
save = questionary.confirm(
|
||
"Save this configuration?",
|
||
default=True,
|
||
style=CONFIRM_STYLE,
|
||
qmark=QMARK,
|
||
).ask()
|
||
|
||
if save is None:
|
||
raise KeyboardInterrupt()
|
||
|
||
if save:
|
||
save_config(config)
|
||
console.print()
|
||
console.print("[green]✓ Configuration saved![/green]")
|
||
console.print(f"[dim] → {get_config_path()}[/dim]")
|
||
console.print()
|
||
return True
|
||
else:
|
||
console.print()
|
||
console.print("[yellow]Configuration not saved.[/yellow]")
|
||
console.print()
|
||
return False
|
||
|
||
except KeyboardInterrupt:
|
||
console.print()
|
||
console.print("[yellow]Setup cancelled.[/yellow]")
|
||
console.print()
|
||
return False
|