Files
EvoScientist/EvoScientist/config/onboard.py
T

3024 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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