diff --git a/EvoScientist/cli/_app.py b/EvoScientist/cli/_app.py index ccf06a1..812a629 100644 --- a/EvoScientist/cli/_app.py +++ b/EvoScientist/cli/_app.py @@ -58,3 +58,12 @@ sessions_app = typer.Typer( invoke_without_command=True, ) app.add_typer(sessions_app, name="sessions") + +# Configure subcommand group — re-run a single onboarding section. +configure_app = typer.Typer( + help=( + "Re-run one onboarding section without going through the full wizard.\n" + "Example: EvoSci configure provider" + ), +) +app.add_typer(configure_app, name="configure") diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 770dccb..3479875 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -17,7 +17,7 @@ from rich.table import Table from ..llm.context_window import DEFAULT_CONTEXT_WINDOW_FALLBACK, resolve_context_window from ..paths import ensure_dirs, set_workspace_root from ..stream.console import console -from ._app import app, channel_app, config_app, mcp_app, sessions_app +from ._app import app, channel_app, config_app, configure_app, mcp_app, sessions_app from ._constants import build_metadata from .agent import ( _create_session_workspace, @@ -56,15 +56,256 @@ def onboard( skip_validation: bool = typer.Option( False, "--skip-validation", help="Skip API key validation during setup" ), + # ---- Pre-fill answers (any subset; remaining prompts stay interactive) + provider: str | None = typer.Option( + None, "--provider", help="Pre-set LLM provider (e.g. anthropic, openai)" + ), + model: str | None = typer.Option(None, "--model", help="Pre-set model name"), + api_key: str | None = typer.Option( + None, "--api-key", help="Pre-set API key for the chosen --provider" + ), + tavily_key: str | None = typer.Option( + None, "--tavily-key", help="Pre-set Tavily API key" + ), + workspace_mode: str | None = typer.Option( + None, + "--workspace-mode", + help="Pre-set workspace mode (daemon | run)", + ), + show_thinking: bool | None = typer.Option( + None, + "--show-thinking/--no-show-thinking", + help="Pre-set thinking-panel visibility", + ), + ui: str | None = typer.Option(None, "--ui", help="Pre-set UI backend (tui | cli)"), + port: int | None = typer.Option( + None, "--port", help="Pre-set langgraph dev server port" + ), + # ---- Skip flags + skip_skills: bool = typer.Option( + False, "--skip-skills", help="Skip skills install" + ), + skip_mcp: bool = typer.Option(False, "--skip-mcp", help="Skip MCP server setup"), + skip_latex: bool = typer.Option(False, "--skip-latex", help="Skip LaTeX setup"), + skip_channels: bool = typer.Option( + False, "--skip-channels", help="Skip channels setup" + ), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help="Run without prompts — every required answer must come from a flag", + ), ): - """Interactive setup wizard for EvoScientist + """Interactive setup wizard for EvoScientist. Guides you through configuring API keys, model selection, workspace settings, and agent parameters. + + Any answer can be pre-set via a flag (``--provider anthropic + --model claude-sonnet-4-5 ...``); prompts for unset answers stay + interactive unless ``--non-interactive`` is passed, in which case any + missing required answer aborts the wizard. + """ + from ..config.onboard.constants import ( + VALID_PROVIDERS, + VALID_UI_BACKENDS, + VALID_WORKSPACE_MODES, + ) + from ..config.onboard.prompter import NonInteractivePrompter + + # Validate constrained string flags up-front so a typo doesn't silently + # poison the saved config. Allowed-value sets live in + # ``EvoScientist/config/onboard/constants.py``; a drift test in + # ``tests/test_onboard.py`` keeps them aligned with the interactive + # ``Choice(value=...)`` lists in ``steps.py``. + if ui is not None and ui not in VALID_UI_BACKENDS: + raise typer.BadParameter( + f"--ui must be one of {sorted(VALID_UI_BACKENDS)}", param_hint="--ui" + ) + if workspace_mode is not None and workspace_mode not in VALID_WORKSPACE_MODES: + raise typer.BadParameter( + f"--workspace-mode must be one of {sorted(VALID_WORKSPACE_MODES)}", + param_hint="--workspace-mode", + ) + if provider is not None and provider not in VALID_PROVIDERS: + raise typer.BadParameter( + f"--provider must be one of {sorted(VALID_PROVIDERS)}", + param_hint="--provider", + ) + # Match the interactive prompt's range (1024 < port < 65536). Without + # this check, --port 80 or --port 99999 would land in config and break + # the langgraph dev server on startup. + if port is not None and not (1024 < port < 65536): + raise typer.BadParameter( + "--port must be in the user-port range (1025 — 65535)", + param_hint="--port", + ) + + # Collect flag-supplied answers keyed by the prompt_id wizard steps use. + answers: dict = {} + if ui is not None: + answers["ui"] = ui + if port is not None: + answers["port"] = str(port) + if provider is not None: + answers["provider"] = provider + if model is not None: + answers["model"] = model + if api_key is not None: + answers["api_key"] = api_key + if tavily_key is not None: + answers["tavily_key"] = tavily_key + if workspace_mode is not None: + answers["workspace_mode"] = workspace_mode + if show_thinking is not None: + answers["show_thinking"] = show_thinking + + skip_set = { + section + for section, flag in ( + ("skills", skip_skills), + ("mcp", skip_mcp), + ("latex", skip_latex), + ("channels", skip_channels), + ) + if flag + } + + prompter = None + if answers or skip_set or non_interactive: + prompter = NonInteractivePrompter( + answers=answers, + skip_set=skip_set, + strict=non_interactive, + ) + + _run_onboard_cli(skip_validation=skip_validation, prompter=prompter) + + +# ============================================================================= +# `EvoSci configure
` — re-run one onboarding section +# ============================================================================= + + +_CONFIGURE_SECTIONS = { + "ui": "UI backend", + "port": "LangGraph server port", + "provider": "LLM provider + auth + API key", + "model": "Model + reasoning effort", + "tavily": "Tavily search key", + "workspace": "Workspace mode", + "thinking": "Thinking panel", + "skills": "Skills", + "mcp": "MCP servers", + "latex": "LaTeX (TinyTeX)", + "channels": "Channels", +} + + +def _run_onboard_cli(**kwargs: Any) -> None: + """Invoke the wizard, presenting non-interactive errors as a clean + message + exit code 1 instead of a raw Python traceback. + + The wizard raises ``RuntimeError`` for *expected* non-interactive + failures: rejected ``--api-key`` / ``--tavily-key`` presets, missing + required flags under ``--non-interactive``, or a missing base URL. + Those are user-input problems, not bugs — surface them like any other + CLI validation error rather than dumping a stack trace. """ from ..config import run_onboard - run_onboard(skip_validation=skip_validation) + try: + run_onboard(**kwargs) + except RuntimeError as exc: + console.print(f"[red]✗ {escape(str(exc))}[/red]") + raise typer.Exit(code=1) from exc + + +def _configure_section(section: str, skip_validation: bool = False) -> None: + """Run a single onboarding section, reusing the wizard's step logic.""" + _run_onboard_cli( + skip_validation=skip_validation, + only_sections={section}, + ) + + +@configure_app.command("ui") +def configure_ui(): + """Re-run UI backend (TUI / CLI) selection.""" + _configure_section("ui") + + +@configure_app.command("port") +def configure_port(): + """Re-run langgraph dev server port selection.""" + _configure_section("port") + + +@configure_app.command("provider") +def configure_provider( + skip_validation: bool = typer.Option(False, "--skip-validation"), +): + """Re-run LLM provider, auth mode, and API key prompts. + + Model selection is automatically re-run after provider — the model list + depends on the provider, and silently leaving e.g. ``model="claude-...""`` + when the provider was switched to ``openai`` would break the first + request. Press Enter on the model picker to keep the current default. + """ + _run_onboard_cli( + skip_validation=skip_validation, + only_sections={"provider", "model"}, + ) + + +@configure_app.command("model") +def configure_model(): + """Re-run model selection (and reasoning effort for OpenRouter).""" + _configure_section("model") + + +@configure_app.command("tavily") +def configure_tavily( + skip_validation: bool = typer.Option(False, "--skip-validation"), +): + """Re-run Tavily search-key prompt.""" + _configure_section("tavily", skip_validation=skip_validation) + + +@configure_app.command("workspace") +def configure_workspace(): + """Re-run workspace mode (daemon/run) selection.""" + _configure_section("workspace") + + +@configure_app.command("thinking") +def configure_thinking(): + """Re-run thinking-panel visibility selection.""" + _configure_section("thinking") + + +@configure_app.command("skills") +def configure_skills(): + """Re-run skills install/sync.""" + _configure_section("skills") + + +@configure_app.command("mcp") +def configure_mcp(): + """Re-run MCP server selection.""" + _configure_section("mcp") + + +@configure_app.command("latex") +def configure_latex(): + """Re-run LaTeX (TinyTeX) setup.""" + _configure_section("latex") + + +@configure_app.command("channels") +def configure_channels(): + """Re-run channels selection and per-channel configuration.""" + _configure_section("channels") # ============================================================================= @@ -87,7 +328,7 @@ def channel_setup(): asyncio.set_event_loop(asyncio.new_event_loop()) from ..config import load_config, save_config - from ..config.onboard import _step_channels + from ..config.onboard.channels import _step_channels config = load_config() updates = _step_channels(config) diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py deleted file mode 100644 index 282b143..0000000 --- a/EvoScientist/config/onboard.py +++ /dev/null @@ -1,3500 +0,0 @@ -"""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 ..llm.ollama_discovery import validate_ollama_connection -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", - "LangGraph Port", - "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_dashscope_code_key(api_key: str) -> tuple[bool, str]: - """Validate a DashScope Coding Plan API key (sk-sp-* subscription keys). - - The coding endpoint at coding.dashscope.aliyuncs.com does not expose - /models (returns 404), so validation issues a minimal chat completion - instead of the usual models.list() probe. - - 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://coding.dashscope.aliyuncs.com/v1", - ) - client.chat.completions.create( - model="qwen3-coder-plus", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - return True, "Valid" - except Exception as e: - error_str = str(e).lower() - if ( - "401" in error_str - or "unauthorized" in error_str - or "invalid_api_key" 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}" - - -# ============================================================================= -# 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_langgraph_dev_port(config: EvoScientistConfig) -> int: - """Step 0.5: Choose the local TCP port for the langgraph dev subprocess. - - EvoSci auto-starts a ``langgraph dev`` server in the background to host - deployed sub-agents (writing-agent, data-analysis-agent) when - ``enable_async_subagents`` is True. This step lets the user pick a free - port, with a live conflict check on the configured default. - - Returns the chosen port; caller assigns it to ``config.langgraph_dev_port``. - """ - if not getattr(config, "enable_async_subagents", True): - # User has async disabled — port is irrelevant, no prompt. - return getattr(config, "langgraph_dev_port", 6174) - - from ..langgraph_dev.manager import _is_port_occupied, is_langgraph_dev_running - - current_port = getattr(config, "langgraph_dev_port", 6174) - current_occupied = _is_port_occupied(current_port) - if current_occupied and is_langgraph_dev_running(port=current_port): - # Another EvoSci shell is already serving on this port — reuse, don't - # force the user to renumber. - current_occupied = False - - # Bake the live status into the prompt label so the user sees it WITH - # the question, not as a side-effect line that prints before input. - # Single set of parens, no nesting (mirrors ccproxy's prompt style). - if current_occupied: - prompt_label = ( - f"Enter port for EvoScientist server " - f"(Current: {current_port}, occupied, pick another):" - ) - else: - prompt_label = ( - f"Enter port for EvoScientist server " - f"(Current: {current_port}, available, Enter to keep):" - ) - - def valid_port(value: str) -> bool: - if not value: - # Allow keeping the default only if it's actually free; otherwise - # require the user to pick something else. - return not current_occupied - try: - port = int(value) - except (ValueError, TypeError): - return False - if not (1024 < port < 65536): - return False - # Reject user-typed ports that are already occupied UNLESS the - # occupier is our own langgraph dev (e.g., another EvoSci shell) — - # in that case the runtime will reuse it. - if not _is_port_occupied(port): - return True - return is_langgraph_dev_running(port=port) - - raw = questionary.text( - prompt_label, - validate=valid_port, - style=WIZARD_STYLE, - qmark=QMARK, - ).ask() - - if raw is None: - raise KeyboardInterrupt() - - port = int(raw) if raw else current_port - - # Final probe — warn (don't fail) if the chosen port is still occupied - # by something OTHER than our own langgraph dev. Reuse of an existing - # EvoSci server on that port is fine. They can always change later via: - # EvoSci config set langgraph_dev_port - if _is_port_occupied(port) and not is_langgraph_dev_running(port=port): - console.print( - f" [yellow]⚠ Port {port} is occupied. EvoSci may fail to start its " - f"server. Free the port or change later with: " - f"EvoSci config set langgraph_dev_port [/yellow]" - ) - else: - console.print( - f" [green]✓ EvoScientist will run on http://127.0.0.1:{port}[/green]" - ) - return port - - -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="DashScope Coding Plan (阿里云代码计划 — Qwen models)", - value="dashscope-code", - ), - 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, - ), - "dashscope-code": ( - "DashScope Coding Plan", - config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""), - validate_dashscope_code_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 concurrent.futures import ThreadPoolExecutor, as_completed - - from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR - from ..tools.skills_manager import installed_provenance, resolve_remote_head - - # Collect installed-skill dir names across both tiers. The dir-name match - # is a best-effort fallback for legacy installs without manifests; the - # provenance map below is the authoritative signal (handles packs that - # explode into many child directories with unrelated names). - installed_names: set[str] = set() - for skills_dir in (Path(USER_SKILLS_DIR), Path(GLOBAL_SKILLS_DIR)): - if skills_dir.exists(): - installed_names.update(e.name for e in skills_dir.iterdir() if e.is_dir()) - provenance = installed_provenance() - installed_src = set(provenance) - - 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] - - def _is_installed(source: str) -> bool: - return source in installed_src or _hint_name(source) in installed_names - - # For installed packs with a stored commit, ask GitHub if upstream has - # moved. Bounded parallel calls so onboard stays snappy; failures are - # silently treated as "unknown" (label falls back to plain "installed"). - sources_to_check = [ - skill["source"] - for skill in _RECOMMENDED_SKILLS - if _is_installed(skill["source"]) - and provenance.get(skill["source"], {}).get("commit") - ] - upstream_heads: dict[str, str | None] = {} - if sources_to_check: - with ThreadPoolExecutor(max_workers=min(6, len(sources_to_check))) as ex: - futures = { - ex.submit(resolve_remote_head, src): src for src in sources_to_check - } - for fut in as_completed(futures): - src = futures[fut] - try: - upstream_heads[src] = fut.result() - except Exception: - upstream_heads[src] = None - - def _has_update(source: str) -> bool: - head = upstream_heads.get(source) - recorded = provenance.get(source, {}).get("commit") - return bool(head and recorded and head != recorded) - - choices = [] - for skill in _RECOMMENDED_SKILLS: - src = skill["source"] - if _is_installed(src): - hint = ( - " (installed — update available, re-select to sync)" - if _has_update(src) - else " (installed — re-select to sync)" - ) - choices.append( - Choice( - title=[ - ("", skill["label"]), - ("class:instruction", hint), - ], - value=src, - ) - ) - else: - choices.append(Choice(title=skill["label"], value=src)) - - all_installed = all(_is_installed(skill["source"]) 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 [--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_library - - ok = install_library("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", "qrcode>=7.4"], - "dingtalk": ["aiohttp>=3.9"], - "wechat": [ - "pycryptodome>=3.20", - "aiohttp>=3.9", - "qrcode>=7.4", - "certifi>=2024.0", - ], - "qq": ["qq-botpy>=1.0", "cryptography>=41.0", "qrcode>=7.4"], - } - - # 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", - [], # backend-specific fields prompted in the wechat branch below - ("aiohttp", "qrcode", "Crypto", "certifi"), - "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_library, 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: - _required_imports: tuple[str, ...] = ( - (import_check,) - if isinstance(import_check, str) - else tuple(import_check) - ) - _pkg_ready = False - try: - for _module_name in _required_imports: - __import__(_module_name) - _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_library(p) for p in _pip_pkgs) - else: - _ok = install_library(f"evoscientist[{pip_extra}]") - if _ok: - # Verify the imports actually work now - try: - for _module_name in _required_imports: - __import__(_module_name) - 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 - - # QQ: offer scan-to-configure before falling back to manual entry. - # The bot must already exist at q.qq.com — scanning binds the - # developer's QQ account to it and returns app_id + client_secret. - _qq_scanned = False - _feishu_scanned = False - if ch_name == "qq": - scan_choices = [ - Choice( - title="Scan QR code (recommended — auto-fill App ID & Secret)", - value="scan", - ), - Choice(title="Enter App ID and Secret manually", value="manual"), - ] - scan_choice = questionary.select( - "Configure QQ Bot:", - choices=scan_choices, - default="scan", - style=WIZARD_STYLE, - qmark=f" {QMARK}", - use_indicator=True, - ).ask() - if scan_choice is None: - raise KeyboardInterrupt() - - if scan_choice == "scan": - # Preflight: AES-GCM decryption needs `cryptography`. - # `qrcode` is a soft dep — onboard.py degrades to URL-only display. - try: - import cryptography # noqa: F401 - except ImportError: - console.print( - ' [yellow]✗ QR scan requires "cryptography".[/yellow]' - ) - install_now = questionary.confirm( - 'Install "cryptography" now?', - default=True, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if install_now is None: - raise KeyboardInterrupt() from None - if install_now and install_library("cryptography>=41.0"): - console.print(" [green]✓ Installed cryptography.[/green]") - else: - console.print( - " [yellow]⚠ Falling back to manual entry.[/yellow]" - ) - scan_choice = "manual" - - if scan_choice == "scan": - from ..channels.qq.onboard import qr_register - - console.print( - " [dim]Make sure the bot is registered at" - " https://q.qq.com first — scanning binds an" - " existing app, it does not create one.[/dim]" - ) - try: - creds = qr_register() - except Exception as exc: - console.print(f" [red]✗ Scan failed: {exc}[/red]") - creds = None - - if creds: - updates["qq_app_id"] = creds["app_id"] - updates["qq_app_secret"] = creds["client_secret"] - console.print( - f" [green]✓ Bound QQ Bot (App ID: {creds['app_id']})[/green]" - ) - _qq_scanned = True - else: - console.print( - " [yellow]⚠ Scan did not complete — falling" - " back to manual entry.[/yellow]" - ) - - # Feishu: offer scan-to-create before falling back to manual entry. - # Unlike QQ, this provisions a brand-new PersonalAgent app with the - # required IM permissions attached, then returns app_id + app_secret. - if ch_name == "feishu": - scan_choices = [ - Choice( - title="Scan QR code (recommended — auto-create app, fill App ID & Secret)", - value="scan", - ), - Choice(title="Enter App ID and Secret manually", value="manual"), - ] - scan_choice = questionary.select( - "Configure Feishu / Lark:", - choices=scan_choices, - default="scan", - style=WIZARD_STYLE, - qmark=f" {QMARK}", - use_indicator=True, - ).ask() - if scan_choice is None: - raise KeyboardInterrupt() - - if scan_choice == "scan": - # `qrcode` is the only soft dep needed — onboard prints the URL - # if it's missing, but the UX is much worse, so offer to install. - try: - import qrcode # noqa: F401 - except ImportError: - console.print( - ' [yellow]✗ QR scan looks best with "qrcode".[/yellow]' - ) - install_now = questionary.confirm( - 'Install "qrcode" now?', - default=True, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if install_now is None: - raise KeyboardInterrupt() from None - if install_now and install_library("qrcode>=7.4"): - console.print(" [green]✓ Installed qrcode.[/green]") - - # Region selection — accounts.feishu.cn vs accounts.larksuite.com. - # The poll endpoint auto-switches if the scanning user is on the - # other tenant, so this is just a starting hint. - region_choices = [ - Choice(title="Feishu (飞书, mainland China)", value="feishu"), - Choice(title="Lark (overseas)", value="lark"), - ] - region = questionary.select( - "Region:", - choices=region_choices, - default="feishu", - style=WIZARD_STYLE, - qmark=f" {QMARK}", - use_indicator=True, - ).ask() - if region is None: - raise KeyboardInterrupt() - - if scan_choice == "scan": - from ..channels.feishu.onboard import qr_register - - console.print( - " [dim]A QR code will be printed below — open Feishu or" - " Lark on your phone and scan it. The platform will" - " auto-create a bot app with IM permissions and return" - " the credentials here.[/dim]" - ) - try: - creds = qr_register(initial_domain=region) - except Exception as exc: - console.print(f" [red]✗ Scan failed: {exc}[/red]") - creds = None - - if creds: - updates["feishu_app_id"] = creds["app_id"] - updates["feishu_app_secret"] = creds["app_secret"] - # Sync open-platform domain to the resolved region - updates["feishu_domain"] = ( - "https://open.larksuite.com" - if creds.get("domain") == "lark" - else "https://open.feishu.cn" - ) - bot_name = creds.get("bot_name") - if bot_name: - console.print( - f' [green]✓ Bound Feishu bot "{bot_name}"' - f" (App ID: {creds['app_id']})[/green]" - ) - else: - console.print( - f" [green]✓ Bound Feishu app" - f" (App ID: {creds['app_id']})[/green]" - ) - _feishu_scanned = True - else: - console.print( - " [yellow]⚠ Scan did not complete — falling" - " back to manual entry.[/yellow]" - ) - - # WeChat: pick backend (wecom / wechatmp / personal), then prompt - # backend-specific fields. Personal-WeChat has no static credentials — - # we offer an interactive QR-scan that obtains and persists them. - if ch_name == "wechat": - backend_choices = [ - Choice( - title="WeCom (企业微信应用) — most stable, official API", - value="wecom", - ), - Choice( - title="Official Account (微信公众号) — public-facing bots", - value="wechatmp", - ), - Choice( - title="Personal WeChat (个人微信, iLink) — QR-code scan login", - value="personal", - ), - ] - wechat_backend = questionary.select( - "WeChat backend:", - choices=backend_choices, - default=getattr(config, "wechat_backend", "") or "wecom", - style=WIZARD_STYLE, - qmark=f" {QMARK}", - use_indicator=True, - ).ask() - if wechat_backend is None: - raise KeyboardInterrupt() - updates["wechat_backend"] = wechat_backend - - if wechat_backend == "wecom": - wechat_fields = [ - ("wechat_wecom_corp_id", "WeCom Corp ID", False), - ("wechat_wecom_agent_id", "WeCom Agent ID", False), - ("wechat_wecom_secret", "WeCom Secret", True), - ] - for field_name, prompt_label, is_secret in wechat_fields: - current = getattr(config, field_name, "") - prompt_fn = questionary.password if is_secret else questionary.text - value = prompt_fn( - f"{prompt_label}:", - default=current, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if value is None: - raise KeyboardInterrupt() - updates[field_name] = value.strip() - elif wechat_backend == "wechatmp": - wechat_fields = [ - ("wechat_mp_app_id", "Official Account App ID", False), - ("wechat_mp_app_secret", "Official Account App Secret", True), - ] - for field_name, prompt_label, is_secret in wechat_fields: - current = getattr(config, field_name, "") - prompt_fn = questionary.password if is_secret else questionary.text - value = prompt_fn( - f"{prompt_label}:", - default=current, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if value is None: - raise KeyboardInterrupt() - updates[field_name] = value.strip() - else: # personal - personal_choices = [ - Choice( - title="Scan QR code now (recommended — login to a personal WeChat account)", - value="scan", - ), - Choice( - title="I already have an account_id — enter it manually", - value="manual", - ), - ] - personal_choice = questionary.select( - "Personal WeChat login:", - choices=personal_choices, - default="scan", - style=WIZARD_STYLE, - qmark=f" {QMARK}", - use_indicator=True, - ).ask() - if personal_choice is None: - raise KeyboardInterrupt() - - if personal_choice == "scan": - from ..channels.wechat.personal import _account_dir as _wp_dir - - _accounts_path = _wp_dir() - console.print( - " [dim]A QR code will be printed below — open WeChat on" - " your phone and scan it. The session token is saved" - f" to {_accounts_path}.[/dim]" - ) - try: - import asyncio - - from ..channels.wechat.personal import qr_login - - creds = asyncio.run(qr_login()) - except Exception as exc: - console.print(f" [red]✗ Scan failed: {exc}[/red]") - creds = None - - if creds: - updates["wechat_personal_account_id"] = creds["account_id"] - # Token is persisted on disk by qr_login(); the channel - # reads it from the per-account store at runtime, so we - # intentionally do NOT copy it into the main config here - # (avoids stale duplicates and broader secret exposure). - console.print( - f" [green]✓ Logged in (account_id: " - f"{creds['account_id'][:12]}…)[/green]" - ) - else: - console.print( - " [yellow]⚠ QR login did not complete — falling" - " back to manual entry.[/yellow]" - ) - personal_choice = "manual" - - if personal_choice == "manual": - current_id = getattr(config, "wechat_personal_account_id", "") - account_id = questionary.text( - "iLink account_id (from a previous --qr-login run):", - default=current_id, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if account_id is None: - raise KeyboardInterrupt() - updates["wechat_personal_account_id"] = account_id.strip() - - # Prompt for required fields - if not _qq_scanned and not _feishu_scanned: - 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_library("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, - ) - elif backend == "personal": - from ..channels.wechat.probe import validate_wechat_personal - - return await validate_wechat_personal( - _val("wechat_personal_account_id"), - _val("wechat_personal_token"), - ) - 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 0.5: langgraph dev port (with live conflict check) - config.langgraph_dev_port = _step_langgraph_dev_port(config) - - # 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", - "dashscope-code": "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 diff --git a/EvoScientist/config/onboard/__init__.py b/EvoScientist/config/onboard/__init__.py new file mode 100644 index 0000000..b039564 --- /dev/null +++ b/EvoScientist/config/onboard/__init__.py @@ -0,0 +1,34 @@ +"""Onboarding package. + +The wizard's only package-level public entry point is :func:`run_onboard`. +Everything else lives in submodules — import directly from them: + +- :mod:`EvoScientist.config.onboard.wizard` — orchestrator, ``run_onboard``, + ``STEPS``, ``render_progress`` +- :mod:`EvoScientist.config.onboard.steps` — per-step functions +- :mod:`EvoScientist.config.onboard.channels` — channel selection + setup +- :mod:`EvoScientist.config.onboard.helpers` — API-key prompt, ccproxy, + npx/node, LaTeX, iMessage helpers +- :mod:`EvoScientist.config.onboard.style` — Rich styles + ``_checkbox_ask`` +- :mod:`EvoScientist.config.onboard.validators` — input validators +- :mod:`EvoScientist.config.onboard.prompter` — ``NonInteractivePrompter`` + (CLI-answer container) + ``select_navigation_active`` / ``GoBack`` for + keyboard nav +- :mod:`EvoScientist.config.onboard.constants` — canonical valid-value sets + +This module used to re-export every symbol from every submodule for +backward compat during the initial refactor; those re-exports have since +been removed to keep the public surface narrow. New code should always +import from the submodule that owns the symbol; test code should use +``patch("EvoScientist.config.onboard..")`` paths. +""" + +from __future__ import annotations + +# Sole package-level public entry. ``EvoScientist.config`` re-exports this +# (via lazy ``__getattr__``) so ``from EvoScientist.config import +# run_onboard`` keeps working — that import path is used by the CLI and is +# the only documented external API. +from .wizard import run_onboard + +__all__ = ["run_onboard"] diff --git a/EvoScientist/config/onboard/channels.py b/EvoScientist/config/onboard/channels.py new file mode 100644 index 0000000..9a50929 --- /dev/null +++ b/EvoScientist/config/onboard/channels.py @@ -0,0 +1,958 @@ +"""Channel selection + per-channel configuration. + +`_step_channels` is the big one — over 700 lines that walk the user through +selecting which messaging channels to enable and collecting credentials for +each. +""" + +from __future__ import annotations + +import questionary +from questionary import Choice + +from ..settings import EvoScientistConfig +from .helpers import ( + _setup_imessage, +) +from .style import ( + QMARK, + WIZARD_STYLE, + console, +) + + +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", "qrcode>=7.4"], + "dingtalk": ["aiohttp>=3.9"], + "wechat": [ + "pycryptodome>=3.20", + "aiohttp>=3.9", + "qrcode>=7.4", + "certifi>=2024.0", + ], + "qq": ["qq-botpy>=1.0", "cryptography>=41.0", "qrcode>=7.4"], + } + + # Channel definitions: + # (value, display_name, required_fields, import_check, pip_extra) + # required_fields entries are (field_name, prompt_label, is_secret). + # ``is_secret=True`` triggers a password prompt (no echo, no default echo) + # so bot tokens / OAuth secrets / IMAP+SMTP passwords don't leak into + # terminal scrollback, screen recordings, or support sessions. + _CHANNELS = [ + ( + "telegram", + "Telegram", + [("telegram_bot_token", "Bot token (from @BotFather)", True)], + "telegram", + "telegram", + ), + ( + "discord", + "Discord", + [("discord_bot_token", "Bot token", True)], + "discord", + "discord", + ), + ( + "slack", + "Slack", + [ + ("slack_bot_token", "Bot token (xoxb-...)", True), + ("slack_app_token", "App token for Socket Mode (xapp-...)", True), + ], + "slack_sdk", + "slack", + ), + ( + "feishu", + "Feishu", + [ + ("feishu_app_id", "App ID", False), + ("feishu_app_secret", "App Secret", True), + ], + "aiohttp", + "feishu", + ), + ( + "dingtalk", + "DingTalk", + [ + ("dingtalk_client_id", "Client ID (AppKey)", False), + ("dingtalk_client_secret", "Client Secret (AppSecret)", True), + ], + "aiohttp", + "dingtalk", + ), + ( + "wechat", + "WeChat", + [], # backend-specific fields prompted in the wechat branch below + ("aiohttp", "qrcode", "Crypto", "certifi"), + "wechat", + ), + ( + "email", + "Email", + [ + ("email_imap_host", "IMAP host", False), + ("email_imap_username", "IMAP username", False), + ("email_imap_password", "IMAP password", True), + ("email_smtp_host", "SMTP host", False), + ("email_smtp_username", "SMTP username", False), + ("email_smtp_password", "SMTP password", True), + ("email_from_address", "From address", False), + ], + None, + None, + ), + ( + "qq", + "QQ", + [ + ("qq_app_id", "App ID", False), + ("qq_app_secret", "App Secret", True), + ], + "botpy", + "qq", + ), + ( + "signal", + "Signal", + [("signal_phone_number", "Phone number (E.164)", False)], + 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_library, 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: + _required_imports: tuple[str, ...] = ( + (import_check,) + if isinstance(import_check, str) + else tuple(import_check) + ) + _pkg_ready = False + try: + for _module_name in _required_imports: + __import__(_module_name) + _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_library(p) for p in _pip_pkgs) + else: + _ok = install_library(f"evoscientist[{pip_extra}]") + if _ok: + # Verify the imports actually work now + try: + for _module_name in _required_imports: + __import__(_module_name) + 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: + # Previously-enabled channels are silently dropped from + # ``channel_enabled`` if we just ``continue`` — warn. + if ch_name in _currently_enabled: + console.print( + f" [bold yellow]⚠ {display} will be DISABLED[/bold yellow]" + " [dim](dependency missing — re-run after install)[/dim]" + ) + else: + console.print( + f" [dim]Skipping {display} — dependency not installed.[/dim]" + ) + 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 + + # QQ: offer scan-to-configure before falling back to manual entry. + # The bot must already exist at q.qq.com — scanning binds the + # developer's QQ account to it and returns app_id + client_secret. + _qq_scanned = False + _feishu_scanned = False + if ch_name == "qq": + scan_choices = [ + Choice( + title="Scan QR code (recommended — auto-fill App ID & Secret)", + value="scan", + ), + Choice(title="Enter App ID and Secret manually", value="manual"), + ] + scan_choice = questionary.select( + "Configure QQ Bot:", + choices=scan_choices, + default="scan", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + use_indicator=True, + ).ask() + if scan_choice is None: + raise KeyboardInterrupt() + + if scan_choice == "scan": + # Preflight: AES-GCM decryption needs `cryptography`. + # `qrcode` is a soft dep — onboard.py degrades to URL-only display. + try: + import cryptography # noqa: F401 + except ImportError: + console.print( + ' [yellow]✗ QR scan requires "cryptography".[/yellow]' + ) + install_now = questionary.confirm( + 'Install "cryptography" now?', + default=True, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if install_now is None: + raise KeyboardInterrupt() from None + if install_now and install_library("cryptography>=41.0"): + console.print(" [green]✓ Installed cryptography.[/green]") + else: + console.print( + " [yellow]⚠ Falling back to manual entry.[/yellow]" + ) + scan_choice = "manual" + + if scan_choice == "scan": + from ...channels.qq.onboard import qr_register + + console.print( + " [dim]Make sure the bot is registered at" + " https://q.qq.com first — scanning binds an" + " existing app, it does not create one.[/dim]" + ) + try: + creds = qr_register() + except Exception as exc: + console.print(f" [red]✗ Scan failed: {exc}[/red]") + creds = None + + if creds: + updates["qq_app_id"] = creds["app_id"] + updates["qq_app_secret"] = creds["client_secret"] + console.print( + f" [green]✓ Bound QQ Bot (App ID: {creds['app_id']})[/green]" + ) + _qq_scanned = True + else: + console.print( + " [yellow]⚠ Scan did not complete — falling" + " back to manual entry.[/yellow]" + ) + + # Feishu: offer scan-to-create before falling back to manual entry. + # Unlike QQ, this provisions a brand-new PersonalAgent app with the + # required IM permissions attached, then returns app_id + app_secret. + if ch_name == "feishu": + scan_choices = [ + Choice( + title="Scan QR code (recommended — auto-create app, fill App ID & Secret)", + value="scan", + ), + Choice(title="Enter App ID and Secret manually", value="manual"), + ] + scan_choice = questionary.select( + "Configure Feishu / Lark:", + choices=scan_choices, + default="scan", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + use_indicator=True, + ).ask() + if scan_choice is None: + raise KeyboardInterrupt() + + if scan_choice == "scan": + # `qrcode` is the only soft dep needed — onboard prints the URL + # if it's missing, but the UX is much worse, so offer to install. + try: + import qrcode # noqa: F401 + except ImportError: + console.print( + ' [yellow]✗ QR scan looks best with "qrcode".[/yellow]' + ) + install_now = questionary.confirm( + 'Install "qrcode" now?', + default=True, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if install_now is None: + raise KeyboardInterrupt() from None + if install_now and install_library("qrcode>=7.4"): + console.print(" [green]✓ Installed qrcode.[/green]") + else: + console.print( + " [yellow]⚠ Falling back to manual entry.[/yellow]" + ) + scan_choice = "manual" + + if scan_choice == "scan": + # Region selection — accounts.feishu.cn vs accounts.larksuite.com. + # The poll endpoint auto-switches if the scanning user is on the + # other tenant, so this is just a starting hint. + region_choices = [ + Choice(title="Feishu (飞书, mainland China)", value="feishu"), + Choice(title="Lark (overseas)", value="lark"), + ] + region = questionary.select( + "Region:", + choices=region_choices, + default="feishu", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + use_indicator=True, + ).ask() + if region is None: + raise KeyboardInterrupt() + + if scan_choice == "scan": + from ...channels.feishu.onboard import qr_register + + console.print( + " [dim]A QR code will be printed below — open Feishu or" + " Lark on your phone and scan it. The platform will" + " auto-create a bot app with IM permissions and return" + " the credentials here.[/dim]" + ) + try: + creds = qr_register(initial_domain=region) + except Exception as exc: + console.print(f" [red]✗ Scan failed: {exc}[/red]") + creds = None + + if creds: + updates["feishu_app_id"] = creds["app_id"] + updates["feishu_app_secret"] = creds["app_secret"] + # Sync open-platform domain to the resolved region + updates["feishu_domain"] = ( + "https://open.larksuite.com" + if creds.get("domain") == "lark" + else "https://open.feishu.cn" + ) + bot_name = creds.get("bot_name") + if bot_name: + console.print( + f' [green]✓ Bound Feishu bot "{bot_name}"' + f" (App ID: {creds['app_id']})[/green]" + ) + else: + console.print( + f" [green]✓ Bound Feishu app" + f" (App ID: {creds['app_id']})[/green]" + ) + _feishu_scanned = True + else: + console.print( + " [yellow]⚠ Scan did not complete — falling" + " back to manual entry.[/yellow]" + ) + + # WeChat: pick backend (wecom / wechatmp / personal), then prompt + # backend-specific fields. Personal-WeChat has no static credentials — + # we offer an interactive QR-scan that obtains and persists them. + if ch_name == "wechat": + backend_choices = [ + Choice( + title="WeCom (企业微信应用) — most stable, official API", + value="wecom", + ), + Choice( + title="Official Account (微信公众号) — public-facing bots", + value="wechatmp", + ), + Choice( + title="Personal WeChat (个人微信, iLink) — QR-code scan login", + value="personal", + ), + ] + wechat_backend = questionary.select( + "WeChat backend:", + choices=backend_choices, + default=getattr(config, "wechat_backend", "") or "wecom", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + use_indicator=True, + ).ask() + if wechat_backend is None: + raise KeyboardInterrupt() + updates["wechat_backend"] = wechat_backend + + # Both WeCom and WeChat MP need the same non-empty-required + # treatment as the generic required_fields loop below — newly + # enabling either with blank credentials would leave the channel + # half-configured and only fail at first message. + wechat_newly_enabled = "wechat" not in _currently_enabled + wechat_fields_for_backend: list[tuple[str, str, bool]] = [] + if wechat_backend == "wecom": + wechat_fields_for_backend = [ + ("wechat_wecom_corp_id", "WeCom Corp ID", False), + ("wechat_wecom_agent_id", "WeCom Agent ID", False), + ("wechat_wecom_secret", "WeCom Secret", True), + ] + elif wechat_backend == "wechatmp": + wechat_fields_for_backend = [ + ("wechat_mp_app_id", "Official Account App ID", False), + ("wechat_mp_app_secret", "Official Account App Secret", True), + ] + + if wechat_backend in ("wecom", "wechatmp"): + for field_name, prompt_label, is_secret in wechat_fields_for_backend: + current = getattr(config, field_name, "") + while True: + if is_secret: + masked_hint = ( + f" (current: ***{current[-4:]})" if current else "" + ) + value = questionary.password( + f"{prompt_label}{masked_hint}:", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + else: + value = questionary.text( + f"{prompt_label}:", + default=current, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if value is None: + raise KeyboardInterrupt() + value = value.strip() + if not value and current: + break # keep existing + if not value and wechat_newly_enabled: + console.print( + f" [yellow]{prompt_label} is required to " + "enable WeChat. Press Ctrl+C to cancel.[/yellow]" + ) + continue + updates[field_name] = value + break + elif wechat_backend == "personal": + personal_choices = [ + Choice( + title="Scan QR code now (recommended — login to a personal WeChat account)", + value="scan", + ), + Choice( + title="I already have an account_id — enter it manually", + value="manual", + ), + ] + personal_choice = questionary.select( + "Personal WeChat login:", + choices=personal_choices, + default="scan", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + use_indicator=True, + ).ask() + if personal_choice is None: + raise KeyboardInterrupt() + + if personal_choice == "scan": + from ...channels.wechat.personal import _account_dir as _wp_dir + + _accounts_path = _wp_dir() + console.print( + " [dim]A QR code will be printed below — open WeChat on" + " your phone and scan it. The session token is saved" + f" to {_accounts_path}.[/dim]" + ) + try: + import asyncio + + from ...channels.wechat.personal import qr_login + + creds = asyncio.run(qr_login()) + except Exception as exc: + console.print(f" [red]✗ Scan failed: {exc}[/red]") + creds = None + + if creds: + updates["wechat_personal_account_id"] = creds["account_id"] + # Token is persisted on disk by qr_login(); the channel + # reads it from the per-account store at runtime, so we + # intentionally do NOT copy it into the main config here + # (avoids stale duplicates and broader secret exposure). + console.print( + f" [green]✓ Logged in (account_id: " + f"{creds['account_id'][:12]}…)[/green]" + ) + else: + console.print( + " [yellow]⚠ QR login did not complete — falling" + " back to manual entry.[/yellow]" + ) + personal_choice = "manual" + + if personal_choice == "manual": + current_id = getattr(config, "wechat_personal_account_id", "") + wechat_newly_enabled = "wechat" not in _currently_enabled + while True: + account_id = questionary.text( + "iLink account_id (from a previous --qr-login run):", + default=current_id, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if account_id is None: + raise KeyboardInterrupt() + account_id = account_id.strip() + if not account_id and current_id: + break # keep existing + if not account_id and wechat_newly_enabled: + console.print( + " [yellow]account_id is required to enable " + "WeChat Personal. Press Ctrl+C to cancel.[/yellow]" + ) + continue + updates["wechat_personal_account_id"] = account_id + break + + # Prompt for required fields. Secret fields use ``questionary.password`` + # so the entered value (and the existing one shown as a hint) are + # never echoed to the terminal — see _CHANNELS docstring above. + if not _qq_scanned and not _feishu_scanned: + # "Newly enabled" = this channel wasn't in the user's prior + # ``channel_enabled`` list. Required fields with no existing + # value must be non-empty for newly enabled channels — saving + # blanks leaves the channel half-configured and only surfaces + # the problem on the first message. + newly_enabled = ch_name not in _currently_enabled + for field_name, prompt_label, is_secret in required_fields: + current = getattr(config, field_name, "") + while True: + if is_secret: + masked_hint = ( + f" (current: ***{current[-4:]})" if current else "" + ) + value = questionary.password( + f"{prompt_label}{masked_hint}:", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + else: + value = questionary.text( + f"{prompt_label}:", + default=current, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if value is None: + raise KeyboardInterrupt() + value = value.strip() + + # Empty input + existing value → keep existing (this is + # the "re-run wizard, no change to this field" path). + if not value and current: + break + # Empty input + newly enabling channel → not OK; the + # channel would be enabled with broken creds. Re-prompt. + if not value and newly_enabled: + console.print( + f" [yellow]{prompt_label} is required to enable " + f"{display}. Press Ctrl+C to cancel instead.[/yellow]" + ) + continue + # Empty input + previously enabled but never set + # (unlikely, but tolerate) → still allow blank-through + # so the user isn't blocked re-running configure later. + updates[field_name] = value + break + + # 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_library("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. + # Both are credentials — use password() so they don't echo. + 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, "") + masked_hint = f" (current: ***{current[-4:]})" if current else "" + value = questionary.password( + f"{prompt_label}{masked_hint}:", + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if value is None: + raise KeyboardInterrupt() + value = value.strip() + if not value and current: + # Keep existing value when user just presses Enter. + continue + updates[field_name] = value + + # 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, + ) + elif backend == "personal": + from ...channels.wechat.probe import validate_wechat_personal + + return await validate_wechat_personal( + _val("wechat_personal_account_id"), + _val("wechat_personal_token"), + ) + 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) +# ============================================================================= diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py new file mode 100644 index 0000000..c574d98 --- /dev/null +++ b/EvoScientist/config/onboard/constants.py @@ -0,0 +1,47 @@ +"""Canonical valid-value sets shared by the wizard and CLI flag validation. + +The interactive ``_step_*`` functions in ``steps.py`` use these for ``Choice`` +construction (or are checked against them by tests). The CLI ``onboard`` +command in ``cli/commands.py`` uses them to validate ``--provider`` / +``--ui`` / ``--workspace-mode`` flag inputs. + +Single source of truth — adding a new provider here AND to the corresponding +``Choice(value=...)`` in ``steps.py`` is required; a drift test in +``tests/test_onboard.py`` keeps both sides in sync. +""" + +from __future__ import annotations + +VALID_PROVIDERS: frozenset[str] = frozenset( + { + "anthropic", + "openai", + "google-genai", + "minimax", + "zhipu", + "zhipu-code", + "volcengine", + "dashscope", + "dashscope-code", + "deepseek", + "moonshot", + "kimi-coding", + "ollama", + "nvidia", + "siliconflow", + "openrouter", + "custom-openai", + "custom-anthropic", + } +) + +VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli"}) + +VALID_WORKSPACE_MODES: frozenset[str] = frozenset({"daemon", "run"}) + + +__all__ = [ + "VALID_PROVIDERS", + "VALID_UI_BACKENDS", + "VALID_WORKSPACE_MODES", +] diff --git a/EvoScientist/config/onboard/helpers.py b/EvoScientist/config/onboard/helpers.py new file mode 100644 index 0000000..970330f --- /dev/null +++ b/EvoScientist/config/onboard/helpers.py @@ -0,0 +1,693 @@ +"""Cross-step helpers: API key prompt loop, ccproxy login, npx/node bootstrapping, +LaTeX detection/install, iMessage setup. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + +import questionary + +from ..settings import EvoScientistConfig +from .style import QMARK, WIZARD_STYLE, console +from .validators import ( + validate_anthropic_key, + validate_dashscope_code_key, + validate_dashscope_key, + validate_deepseek_key, + validate_google_key, + validate_kimi_key, + validate_minimax_key, + validate_moonshot_key, + validate_nvidia_key, + validate_openai_key, + validate_openrouter_key, + validate_siliconflow_key, + validate_volcengine_key, + validate_zhipu_key, +) + + +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, + ), + "dashscope-code": ( + "DashScope Coding Plan", + config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""), + validate_dashscope_code_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() + if raw is None: + raise KeyboardInterrupt() + raw = raw.strip() + 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 _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 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_library + + ok = install_library("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 diff --git a/EvoScientist/config/onboard/prompter.py b/EvoScientist/config/onboard/prompter.py new file mode 100644 index 0000000..e02d3e1 --- /dev/null +++ b/EvoScientist/config/onboard/prompter.py @@ -0,0 +1,121 @@ +"""Non-interactive preset container + questionary navigation helpers.""" + +from __future__ import annotations + +from typing import Any + + +class GoBack(Exception): + """Raised inside the provider sub-loop to rewind to provider selection.""" + + +# Sentinel value the back-keybinding writes into the prompt result, and the +# value the trailing ``← Back`` menu item carries. Same string so the two +# code paths converge to a single ``GoBack`` raise. +BACK_SENTINEL = "__back__" + + +def install_navigation_keys( + question, + *, + with_back: bool = False, + sentinel: str = BACK_SENTINEL, +) -> None: + """Add keyboard shortcuts on a questionary select ``Question``. + + Bindings (merged in front of questionary's defaults — Ctrl+C/Ctrl+D still + cancel the wizard): + + - ``→`` — accept the option under the cursor and advance (mirrors Enter). + - ``Esc`` / ``←`` (only when ``with_back=True``) — exit with ``sentinel`` + so the wizard can rewind. Used in the provider sub-loop's auth_mode + prompts. + """ + from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings + + kb = KeyBindings() + + @kb.add("right", eager=True) + def _confirm(event): + try: + from questionary.prompts.common import InquirerControl + except ImportError: # pragma: no cover + return + for window in event.app.layout.find_all_windows(): + ctrl = getattr(window, "content", None) + if isinstance(ctrl, InquirerControl): + pointed = ctrl.get_pointed_at() + ctrl.is_answered = True + event.app.exit(result=pointed.value) + return + + if with_back: + + @kb.add("escape", eager=True) + @kb.add("left", eager=True) + def _back(event): + event.app.exit(result=sentinel) + + question.application.key_bindings = merge_key_bindings( + [kb, question.application.key_bindings] + ) + + +from contextlib import contextmanager # noqa: E402 + + +@contextmanager +def select_navigation_active(): + """Bind ``→`` to confirm on every ``questionary.select`` in the block. + + ``checkbox`` is NOT wrapped: the shared ``→`` handler returns a single + value, but checkbox must return a list of checked items. + """ + import questionary + + original = questionary.select + + def _wrapped(*args, **kwargs): + q = original(*args, **kwargs) + try: + install_navigation_keys(q, with_back=False) + except Exception: + # Don't let a stray keybinding error block the wizard. + pass + return q + + questionary.select = _wrapped + try: + yield + finally: + questionary.select = original + + +class NonInteractivePrompter: + """Container for CLI-supplied wizard answers (``--provider``, ``--model``…). + + ``strict=True`` makes missing presets fatal instead of falling back to + interactive. Wizard reads ``answers`` / ``skip_set`` / ``strict`` directly. + """ + + def __init__( + self, + answers: dict[str, Any] | None = None, + skip_set: set[str] | None = None, + strict: bool = False, + ): + self.answers: dict[str, Any] = dict(answers or {}) + self.skip_set: set[str] = set(skip_set or ()) + self.strict: bool = bool(strict) + + def has(self, prompt_id: str) -> bool: + return prompt_id in self.answers + + +__all__ = [ + "BACK_SENTINEL", + "GoBack", + "NonInteractivePrompter", + "install_navigation_keys", + "select_navigation_active", +] diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py new file mode 100644 index 0000000..3528a7a --- /dev/null +++ b/EvoScientist/config/onboard/steps.py @@ -0,0 +1,1227 @@ +"""Individual wizard step functions. + +Each ``_step_*`` prompts the user for one logical decision and returns the +chosen value. Conditional steps (auth mode, base URL) are only called by +``run_onboard`` when the provider needs them. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import questionary +from prompt_toolkit.formatted_text import FormattedText +from questionary import Choice + +from ...llm import get_models_for_provider +from ...llm.ollama_discovery import validate_ollama_connection +from ..settings import EvoScientistConfig +from .helpers import ( + _auto_install_latexmk, + _check_latex_components, + _detect_tinytex_install_method, + _ensure_npx, + _install_ccproxy, + _install_tinytex, + _print_latex_status, + _prompt_and_validate_api_key, + _prompt_ccproxy_port, + _provider_key_info, + _run_ccproxy_login, +) +from .style import ( + CONFIRM_STYLE, + QMARK, + WIZARD_STYLE, + _checkbox_ask, + _print_step_result, + _print_step_skipped, + console, +) +from .validators import validate_tavily_key + + +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_langgraph_dev_port(config: EvoScientistConfig) -> int: + """Step 0.5: Choose the local TCP port for the langgraph dev subprocess. + + EvoSci auto-starts a ``langgraph dev`` server in the background to host + deployed sub-agents (writing-agent, data-analysis-agent) when + ``enable_async_subagents`` is True. This step lets the user pick a free + port, with a live conflict check on the configured default. + + Returns the chosen port; caller assigns it to ``config.langgraph_dev_port``. + """ + if not getattr(config, "enable_async_subagents", True): + # User has async disabled — port is irrelevant, no prompt. + return getattr(config, "langgraph_dev_port", 6174) + + from ...langgraph_dev.manager import _is_port_occupied, is_langgraph_dev_running + + current_port = getattr(config, "langgraph_dev_port", 6174) + current_occupied = _is_port_occupied(current_port) + if current_occupied and is_langgraph_dev_running(port=current_port): + # Another EvoSci shell is already serving on this port — reuse, don't + # force the user to renumber. + current_occupied = False + + # Bake the live status into the prompt label so the user sees it WITH + # the question, not as a side-effect line that prints before input. + # Single set of parens, no nesting (mirrors ccproxy's prompt style). + if current_occupied: + prompt_label = ( + f"Enter port for EvoScientist server " + f"(Current: {current_port}, occupied, pick another):" + ) + else: + prompt_label = ( + f"Enter port for EvoScientist server " + f"(Current: {current_port}, available, Enter to keep):" + ) + + def valid_port(value: str) -> bool: + if not value: + # Allow keeping the default only if it's actually free; otherwise + # require the user to pick something else. + return not current_occupied + try: + port = int(value) + except (ValueError, TypeError): + return False + if not (1024 < port < 65536): + return False + # Reject user-typed ports that are already occupied UNLESS the + # occupier is our own langgraph dev (e.g., another EvoSci shell) — + # in that case the runtime will reuse it. + if not _is_port_occupied(port): + return True + return is_langgraph_dev_running(port=port) + + raw = questionary.text( + prompt_label, + validate=valid_port, + style=WIZARD_STYLE, + qmark=QMARK, + ).ask() + + if raw is None: + raise KeyboardInterrupt() + + port = int(raw) if raw else current_port + + # Final probe — warn (don't fail) if the chosen port is still occupied + # by something OTHER than our own langgraph dev. Reuse of an existing + # EvoSci server on that port is fine. They can always change later via: + # EvoSci config set langgraph_dev_port + if _is_port_occupied(port) and not is_langgraph_dev_running(port=port): + console.print( + f" [yellow]⚠ Port {port} is occupied. EvoSci may fail to start its " + f"server. Free the port or change later with: " + f"EvoSci config set langgraph_dev_port [/yellow]" + ) + else: + console.print( + f" [green]✓ EvoScientist will run on http://127.0.0.1:{port}[/green]" + ) + return port + + +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="DashScope Coding Plan (阿里云代码计划 — Qwen models)", + value="dashscope-code", + ), + 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 _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() + + from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys + + 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", + ), + questionary.Separator(), + Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), + ] + + current = config.anthropic_auth_mode + if current not in ("api_key", "oauth"): + current = "api_key" + + question = questionary.select( + "Authentication mode [Esc/← to go back]:", + choices=choices, + default=current, + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ) + install_navigation_keys(question, with_back=True) + auth_mode = question.ask() + + if auth_mode is None: + raise KeyboardInterrupt() + if auth_mode == BACK_SENTINEL: + raise GoBack() + + 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 is None: + raise KeyboardInterrupt() + 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 is None: + raise KeyboardInterrupt() + 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() + + from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys + + 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", + ), + questionary.Separator(), + Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), + ] + + current = config.openai_auth_mode + if current not in ("api_key", "oauth"): + current = "api_key" + + question = questionary.select( + "OpenAI authentication mode [Esc/← to go back]:", + choices=choices, + default=current, + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ) + install_navigation_keys(question, with_back=True) + auth_mode = question.ask() + + if auth_mode is None: + raise KeyboardInterrupt() + if auth_mode == BACK_SENTINEL: + raise GoBack() + + 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 is None: + raise KeyboardInterrupt() + 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 is None: + raise KeyboardInterrupt() + 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. + # Keep prompting until a non-empty model name is provided — saving an + # empty string here leaves the first request broken with an opaque + # "model required" error from the provider SDK. + while True: + model = questionary.text( + "Model name:", + style=WIZARD_STYLE, + qmark=QMARK, + placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]), + default=config.model or "", + ).ask() + if model is None: + raise KeyboardInterrupt() + model = model.strip() + if model: + return model + console.print( + " [yellow]Model name cannot be empty for a custom provider. " + "Press Ctrl+C to cancel.[/yellow]" + ) + + 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 _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 concurrent.futures import ThreadPoolExecutor, as_completed + + from ...paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR + from ...tools.skills_manager import installed_provenance, resolve_remote_head + + # Collect installed-skill dir names across both tiers. The dir-name match + # is a best-effort fallback for legacy installs without manifests; the + # provenance map below is the authoritative signal (handles packs that + # explode into many child directories with unrelated names). + installed_names: set[str] = set() + for skills_dir in (Path(USER_SKILLS_DIR), Path(GLOBAL_SKILLS_DIR)): + if skills_dir.exists(): + installed_names.update(e.name for e in skills_dir.iterdir() if e.is_dir()) + provenance = installed_provenance() + installed_src = set(provenance) + + 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] + + def _is_installed(source: str) -> bool: + return source in installed_src or _hint_name(source) in installed_names + + # For installed packs with a stored commit, ask GitHub if upstream has + # moved. Bounded parallel calls so onboard stays snappy; failures are + # silently treated as "unknown" (label falls back to plain "installed"). + sources_to_check = [ + skill["source"] + for skill in _RECOMMENDED_SKILLS + if _is_installed(skill["source"]) + and provenance.get(skill["source"], {}).get("commit") + ] + upstream_heads: dict[str, str | None] = {} + if sources_to_check: + with ThreadPoolExecutor(max_workers=min(6, len(sources_to_check))) as ex: + futures = { + ex.submit(resolve_remote_head, src): src for src in sources_to_check + } + for fut in as_completed(futures): + src = futures[fut] + try: + upstream_heads[src] = fut.result() + except Exception: + upstream_heads[src] = None + + def _has_update(source: str) -> bool: + head = upstream_heads.get(source) + recorded = provenance.get(source, {}).get("commit") + return bool(head and recorded and head != recorded) + + choices = [] + for skill in _RECOMMENDED_SKILLS: + src = skill["source"] + if _is_installed(src): + hint = ( + " (installed — update available, re-select to sync)" + if _has_update(src) + else " (installed — re-select to sync)" + ) + choices.append( + Choice( + title=[ + ("", skill["label"]), + ("class:instruction", hint), + ], + value=src, + ) + ) + else: + choices.append(Choice(title=skill["label"], value=src)) + + all_installed = all(_is_installed(skill["source"]) for skill in _RECOMMENDED_SKILLS) + has_updates = any(_has_update(skill["source"]) for skill in _RECOMMENDED_SKILLS) + if all_installed: + if has_updates: + console.print( + " [green]✓ All recommended skills installed; " + "[yellow]updates available[/yellow] — re-select any to sync.[/green]" + ) + else: + console.print( + " [green]✓ All recommended skills are already installed " + "and up to date.[/green]" + ) + # No updates AND nothing new to install — nothing useful the + # picker can do. + return [] + + prompt_label = ( + "Re-select installed skills to sync, or pick new ones:" + if all_installed + else "Install or Sync predefined skills:" + ) + selected = _checkbox_ask(choices, prompt_label) + + 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 as exc: + console.print( + " [yellow]\u26a0 Could not fetch MCP marketplace index " + f"({type(exc).__name__}). Skipping MCP setup \u2014 " + "you can re-run with [bold]EvoSci configure mcp[/bold] later.[/yellow]" + ) + return [] + 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)) + + # Only declare "all configured" when there ARE recommended servers AND + # every one is already in the user's config \u2014 distinct from "marketplace + # returned nothing" (a transient failure) which is handled above. + if servers and all(srv.name in existing_config for srv in servers): + console.print( + "[green]\u2713 All recommended MCP servers are already configured.[/green]" + ) + return [] + if not servers: + console.print( + " [dim]No recommended MCP servers are tagged for onboarding right now.[/dim]" + ) + 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 [--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 diff --git a/EvoScientist/config/onboard/style.py b/EvoScientist/config/onboard/style.py new file mode 100644 index 0000000..0fb0f33 --- /dev/null +++ b/EvoScientist/config/onboard/style.py @@ -0,0 +1,131 @@ +"""Shared visual elements: Console, prompt styles, checkbox helper, headers. + +These are used across all wizard steps and helpers. Kept in one place so a +single import in each submodule covers UI styling. +""" + +from __future__ import annotations + +import questionary +from prompt_toolkit.styles import Style +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +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 + + +# ============================================================================= +# Headers / per-step result printers +# ============================================================================= + + +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 +# ============================================================================= diff --git a/EvoScientist/config/onboard/validators.py b/EvoScientist/config/onboard/validators.py new file mode 100644 index 0000000..6bd415c --- /dev/null +++ b/EvoScientist/config/onboard/validators.py @@ -0,0 +1,537 @@ +"""Input validators for the onboarding wizard. + +- IntegerValidator / ChoiceValidator: prompt_toolkit Validators +- validate_*_key: per-provider API key validators (live HTTP probes) +""" + +from __future__ import annotations + +from prompt_toolkit.validation import ValidationError, Validator + + +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 +# ============================================================================= + + +# Avoids bare "invalid" — collides with "Invalid request: model X not found". +_AUTH_FAILURE_HINTS = ( + "401", + "403", + "unauthorized", + "forbidden", + "authentication", + "invalid api key", + "invalid_api_key", + "incorrect api key", + "incorrect_api_key", + "api key not valid", + "invalid token", +) + +_TRANSIENT_HINTS = ( + "429", + "rate limit", + "rate_limit", + "ratelimit", + "500", + "502", + "503", + "504", + "timeout", + "timed out", + "connection", + "service unavailable", + "temporarily unavailable", + "upstream", +) + + +def _classify_validation_error(error: BaseException) -> tuple[bool, str] | None: + """Classify a validator exception as auth failure, transient, or unknown. + + Returns ``(False, msg)`` for the first two, ``None`` for unknown so the + caller can fall back to ``f"Error: {e}"``. + """ + s = str(error).lower() + if any(h in s for h in _AUTH_FAILURE_HINTS): + return False, "Invalid API key" + if any(h in s for h in _TRANSIENT_HINTS): + return False, "Validation inconclusive — transient error, try again later" + return None + + +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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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)" + + # NOTE: ``ChatNVIDIA(api_key=...)`` does NOT send a network request — it + # only stores the key in a client object. We must actually invoke the + # API (e.g. ``get_available_models()``) to verify the key is good. + try: + from langchain_nvidia_ai_endpoints import ChatNVIDIA + + client = ChatNVIDIA(api_key=api_key, model="meta/llama-3.1-8b-instruct") + # Force a real authenticated request via model discovery. + client.get_available_models() + return True, "Valid" + + except Exception as e: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + # Google-specific 400 phrasing not in the shared hint list. + error_str = str(e).lower() + if "api_key_invalid" in error_str or "api key invalid" 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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" + # Only 401/403 mean the key is actually rejected. 429 (rate-limit) + # and 5xx (OpenRouter incident) leave the key validity unknown — + # surface the real status so the user doesn't go re-roll a good key + # during an outage. + if resp.status_code in (401, 403): + return False, "Invalid API key" + return False, f"Validation inconclusive (HTTP {resp.status_code})" + except Exception as e: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + return False, f"Error: {e}" + + +def validate_dashscope_code_key(api_key: str) -> tuple[bool, str]: + """Validate a DashScope Coding Plan API key (sk-sp-* subscription keys). + + The coding endpoint at coding.dashscope.aliyuncs.com does not expose + /models (returns 404), so validation issues a minimal chat completion + instead of the usual models.list() probe. + + 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://coding.dashscope.aliyuncs.com/v1", + ) + client.chat.completions.create( + model="qwen3-coder-plus", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1, + ) + return True, "Valid" + except Exception as e: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + 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: + classified = _classify_validation_error(e) + if classified is not None: + return classified + return False, f"Error: {e}" + + +# ============================================================================= +# Display Helpers +# ============================================================================= diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py new file mode 100644 index 0000000..a64224b --- /dev/null +++ b/EvoScientist/config/onboard/wizard.py @@ -0,0 +1,812 @@ +"""Onboarding wizard entry point and progress display.""" + +from __future__ import annotations + +import copy +import os + +import questionary +from rich.panel import Panel +from rich.text import Text + +from ..settings import ( + EvoScientistConfig, + get_config_path, + load_config, + save_config, +) +from .channels import _step_channels +from .steps import ( + _step_anthropic_auth_mode, + _step_base_url, + _step_langgraph_dev_port, + _step_mcp_servers, + _step_minimax_region, + _step_model, + _step_ollama_base_url, + _step_openai_auth_mode, + _step_provider, + _step_provider_api_key, + _step_reasoning_effort, + _step_skills, + _step_tavily_key, + _step_thinking, + _step_tinytex, + _step_ui_backend, + _step_workspace, +) +from .style import ( + CONFIRM_STYLE, + QMARK, + _print_header, + _print_step_skipped, + console, +) + +STEPS = [ + "UI", + "LangGraph Port", + "Provider", + "API Key", + "Model", + "Tavily Key", + "Workspace", + "Thinking", + "Skills", + "MCP Servers", + "LaTeX", + "Channels", +] + + +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 +# ============================================================================= + + +_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", + "dashscope-code": "dashscope_api_key", + "moonshot": "moonshot_api_key", + "kimi-coding": "kimi_api_key", + "custom-openai": "custom_openai_api_key", + "custom-anthropic": "custom_anthropic_api_key", +} + + +def _autosave(config: EvoScientistConfig) -> None: + """Persist current config to disk between phases. + + Silently swallows IO errors so a transient disk issue doesn't abort the + wizard — the final save at the end will surface anything broken. + """ + try: + save_config(config) + except Exception: + pass + + +# Sections offered in Keep/Modify/Reset → which step labels they enable. +_SECTION_LABELS: list[tuple[str, str]] = [ + ("ui", "UI backend"), + ("port", "LangGraph server port"), + ("provider", "LLM provider + auth + API key"), + ("model", "Model + reasoning effort"), + ("tavily", "Tavily search key"), + ("workspace", "Workspace mode"), + ("thinking", "Thinking panel"), + ("skills", "Skills"), + ("mcp", "MCP servers"), + ("latex", "LaTeX (TinyTeX)"), + ("channels", "Channels"), +] +_ALL_SECTIONS: frozenset[str] = frozenset(s for s, _ in _SECTION_LABELS) + +# Each preset flag implies the section(s) it would change. ``--provider`` also +# cascades into ``model`` because the model list depends on the provider — +# silently keeping a stale model id would leave the first request broken. +_FLAG_TO_SECTIONS: dict[str, frozenset[str]] = { + "ui": frozenset({"ui"}), + "port": frozenset({"port"}), + "provider": frozenset({"provider", "model"}), + # ``--api-key`` re-runs the provider section, which can change provider — + # cascade to model for the same reason ``--provider`` does. + "api_key": frozenset({"provider", "model"}), + "model": frozenset({"model"}), + "tavily_key": frozenset({"tavily"}), + "workspace_mode": frozenset({"workspace"}), + "show_thinking": frozenset({"thinking"}), +} + + +def _sections_implied_by_flags(prompter) -> frozenset[str]: + """Sections the user's flag-supplied answers imply should run. + + Empty frozenset means no preset flags were passed (only ``--skip-*`` or + ``--non-interactive`` or no flags at all). + """ + if prompter is None: + return frozenset() + out: set[str] = set() + for pid in prompter.answers: + out |= _FLAG_TO_SECTIONS.get(pid, set()) + return frozenset(out) + + +def _config_has_meaningful_settings(config: EvoScientistConfig) -> bool: + """True if the user has been through onboarding before. + + Compares ``config`` against fresh ``EvoScientistConfig()`` defaults — any + non-default field means the user has customised something previously. + """ + import dataclasses + + default = EvoScientistConfig() + return any( + getattr(config, f.name) != getattr(default, f.name) + for f in dataclasses.fields(config) + ) + + +def _open_existing_config_prompt( + config: EvoScientistConfig, +) -> tuple[frozenset[str], EvoScientistConfig] | None: + """Offer Keep / Modify / Reset on an existing config. + + Returns: + - ``None`` if user chose Keep (wizard should exit early). + - ``(sections, config)`` otherwise: the sections to run and the + (possibly reset) config to operate on. + """ + from questionary import Choice + + from .style import QMARK, WIZARD_STYLE + + choice = questionary.select( + "Found existing configuration. What would you like to do?", + choices=[ + Choice(title="Keep current configuration — exit wizard", value="keep"), + Choice(title="Modify — pick specific sections to update", value="modify"), + Choice(title="Reset — start over from defaults", value="reset"), + ], + default="modify", + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ).ask() + + if choice is None: + raise KeyboardInterrupt() + + if choice == "keep": + console.print() + console.print("[green]✓ Keeping current configuration.[/green]") + console.print(f"[dim] → {get_config_path()}[/dim]") + console.print() + return None + + if choice == "reset": + console.print() + console.print("[yellow]Resetting to defaults …[/yellow]") + return _ALL_SECTIONS, EvoScientistConfig() + + # Modify: ask which sections. + from .style import _checkbox_ask + + section_choices = [ + Choice(title=label, value=sid, checked=False) for sid, label in _SECTION_LABELS + ] + selected = _checkbox_ask( + section_choices, + "Which sections to update? (Space to toggle, Enter to confirm)", + ) + if selected is None: + raise KeyboardInterrupt() + if not selected: + # No section picked → effectively the same as Keep. + console.print() + console.print( + "[green]✓ Nothing selected. Keeping current configuration.[/green]" + ) + console.print() + return None + return frozenset(selected), config + + +def run_onboard( + skip_validation: bool = False, + prompter=None, + only_sections: set[str] | frozenset[str] | None = None, +) -> bool: + """Run the interactive onboarding wizard. + + Args: + skip_validation: Skip API key validation. + prompter: Optional :class:`NonInteractivePrompter` carrying + CLI-supplied answers (``--provider``, ``--model``, …) and + ``skip_set`` (sections to bypass). When None, all prompts + fall through to the interactive questionary form. + only_sections: If given, restrict the wizard to exactly these section + ids — the Keep/Modify/Reset prompt is skipped. Used by ``EvoSci + configure
`` to re-run a single phase. + + Returns: + True if configuration was saved, False if cancelled. + + Behaviour notes + --------------- + Config is **autosaved between phases**: each completed section is written + to ``~/.config/evoscientist/config.yaml`` immediately, so a Ctrl+C does + not lose what's been answered so far. The final ``Save this configuration?`` + prompt is the user's chance to *revert* — declining writes the original + snapshot back to disk. + + .. warning:: + Revert covers **the YAML config file only**. Sections with filesystem + side effects — ``_step_skills`` (downloads + ``npm`` installs), + ``_step_mcp_servers`` (writes to ``mcp.yaml``), ``_step_tinytex`` + (installs TinyTeX), and ``_step_channels`` (may ``pip install`` + channel deps) — execute their side effects *before* the final + confirmation and are **not** rolled back when the user declines to + save. "No" thus restores the YAML but does not uninstall packages, + delete skill files, or remove MCP server entries. + """ + from .prompter import NonInteractivePrompter, select_navigation_active + + p = prompter if isinstance(prompter, NonInteractivePrompter) else None + strict = bool(p and p.strict) + + def _preset(pid: str): + """Return preset answer for ``pid`` if available, else None.""" + return p.answers.get(pid) if p else None + + def _require(pid: str, label: str) -> None: + if strict and not (p and p.has(pid)): + flag = "--" + pid.replace("_", "-") + raise RuntimeError( + f"--non-interactive: missing required answer for {label!r}. " + f"Pass {flag} on the command line." + ) + + try: + with select_navigation_active(): + # Print header once + _print_header() + + # Load existing config as starting point + snapshot for revert. + # Also capture raw file state so a "No" at the final save can + # restore the exact pre-wizard byte content (or remove the file + # entirely if it did not exist before). ``existed`` and + # ``bytes`` are tracked independently so a read failure on a + # file that DID exist doesn't get downgraded to "no file" — + # which would cause revert to delete the user's config. + config = load_config() + snapshot = copy.deepcopy(config) + config_path = get_config_path() + original_file_existed = config_path.exists() + original_file_bytes: bytes | None = None + if original_file_existed: + try: + original_file_bytes = config_path.read_bytes() + except OSError as exc: + console.print( + "[yellow]Warning: could not snapshot existing config " + f"bytes ({exc}); revert will fall back to a re-serialized " + "snapshot, which may not preserve comments / unknown " + "fields.[/yellow]" + ) + + # Decide which sections this run should cover. + # + # - ``only_sections`` (programmatic, e.g. ``configure provider``): + # run exactly those sections, no Keep/Modify/Reset prompt. + # - Any preset flag (``--provider``/``--model``/…): treat as + # explicit user intent — skip Keep/Modify/Reset and run ONLY + # the sections each flag implies (see ``_FLAG_TO_SECTIONS``). + # - Strict ``--non-interactive`` with no preset flags: run all + # sections; the inner ``_require()`` calls will raise on + # missing answers. + # - Otherwise: full wizard, with Keep/Modify/Reset offered when + # an existing config is detected. + sections_to_run: frozenset[str] + implied_sections = _sections_implied_by_flags(p) + if only_sections is not None: + sections_to_run = frozenset(only_sections) + elif implied_sections: + sections_to_run = implied_sections + console.print( + "[dim] CLI flags detected — running only the implied " + f"sections: {', '.join(sorted(implied_sections))}.[/dim]" + ) + console.print( + "[dim] (Use 'EvoSci configure
' or 'EvoSci " + "onboard' with no flags to revisit other sections.)[/dim]" + ) + else: + sections_to_run = _ALL_SECTIONS + if not strict and _config_has_meaningful_settings(config): + result = _open_existing_config_prompt(config) + if result is None: + return True # Keep + sections_to_run, config = result + # NOTE: ``snapshot`` is intentionally NOT refreshed after Reset. + # "Save? = No" must restore the user's pre-wizard config — if + # we re-snapped here, declining the save after Reset would + # silently overwrite the user's previous settings with + # ``EvoScientistConfig()`` defaults. + + # CLI --skip-* flags remove sections entirely. + if p and p.skip_set: + sections_to_run = sections_to_run - p.skip_set + + # In strict --non-interactive mode, optional sections that have + # no flag-driven equivalent (skills / mcp / latex / channels) + # would otherwise still open their interactive pickers — that + # breaks the "no prompts" contract advertised by the flag. + # Auto-skip them unless the caller explicitly opted in by NOT + # passing the corresponding --skip-* flag AND providing answers. + # Today none of these have preset support, so always auto-skip. + if strict: + sections_to_run = sections_to_run - { + "skills", + "mcp", + "latex", + "channels", + } + + console.print( + "[dim] Progress is autosaved after every step. Ctrl+C is safe.[/dim]" + ) + console.print() + + if "ui" in sections_to_run: + _require("ui", "UI backend") + preset_ui = _preset("ui") + if preset_ui is not None: + config.ui_backend = preset_ui + console.print( + f" [green]✓ UI: {preset_ui}[/green] [dim](--ui)[/dim]" + ) + else: + config.ui_backend = _step_ui_backend(config) + _autosave(config) + + if "port" in sections_to_run: + preset_port = _preset("port") + if preset_port is not None: + config.langgraph_dev_port = int(preset_port) + console.print( + f" [green]✓ Port: {preset_port}[/green] [dim](--port)[/dim]" + ) + elif strict: + # ``--non-interactive`` without ``--port`` — keep the + # existing config value (has a sensible default in + # ``EvoScientistConfig``) instead of opening the + # questionary prompt and hanging the wizard. + console.print( + f" [green]✓ Port: {config.langgraph_dev_port} " + "(kept)[/green] [dim](no --port; non-interactive)[/dim]" + ) + else: + config.langgraph_dev_port = _step_langgraph_dev_port(config) + _autosave(config) + + ollama_detected_models: list[str] = [] + if "provider" in sections_to_run: + from .prompter import GoBack + + _require("provider", "LLM provider") + # Provider sub-loop: auth_mode can raise GoBack to re-pick provider. + # We snapshot config at the top of each iteration so a GoBack can + # roll back partial writes (base_url, minimax region, ollama URL, + # provider id itself) — otherwise picking `custom-openai`, entering + # a base URL, going Back, then picking `anthropic` would leave a + # stale ``custom_openai_base_url`` in the final saved config. + while True: + loop_snapshot = copy.deepcopy(config) + preset_provider = _preset("provider") + if preset_provider is not None: + provider = preset_provider + config.provider = provider + console.print( + f" [green]✓ Provider: {provider}[/green] " + "[dim](--provider)[/dim]" + ) + else: + provider = _step_provider(config) + config.provider = provider + + # Step 2a: Base URL (custom-openai, custom-anthropic, + # minimax, ollama). In strict non-interactive mode we + # never call the interactive _step_base_url / + # _step_minimax_region / _step_ollama_base_url helpers — + # fall back to the existing config value or the + # CUSTOM_*_BASE_URL / OLLAMA_BASE_URL env var instead. + # If neither is set for a provider that needs it, raise + # so the user sees the same "missing required answer" + # error as for other required prompts. + if provider == "custom-openai": + current_base_url = ( + config.custom_openai_base_url + or os.environ.get("CUSTOM_OPENAI_BASE_URL", "") + ) + if strict: + if not current_base_url: + raise RuntimeError( + "--non-interactive: custom-openai provider " + "needs a base URL. Set the " + "CUSTOM_OPENAI_BASE_URL env var or run " + "without --non-interactive." + ) + config.custom_openai_base_url = current_base_url + else: + config.custom_openai_base_url = _step_base_url( + config, current_value=current_base_url + ) + elif provider == "custom-anthropic": + current_base_url = ( + config.custom_anthropic_base_url + or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "") + ) + if strict: + if not current_base_url: + raise RuntimeError( + "--non-interactive: custom-anthropic " + "provider needs a base URL. Set the " + "CUSTOM_ANTHROPIC_BASE_URL env var or run " + "without --non-interactive." + ) + config.custom_anthropic_base_url = current_base_url + else: + config.custom_anthropic_base_url = _step_base_url( + config, current_value=current_base_url + ) + elif provider == "minimax": + if strict: + # MiniMax has 2 region URLs; default to whatever + # is already in config, else the Global endpoint. + config.minimax_base_url = ( + config.minimax_base_url + or "https://api.minimax.io/anthropic" + ) + else: + config.minimax_base_url = _step_minimax_region(config) + elif provider == "ollama": + if strict: + # Ollama: existing config value > env var > + # localhost default. Skip the live connection + # validation under strict — model discovery + # happens at runtime anyway. + config.ollama_base_url = ( + config.ollama_base_url + or os.environ.get("OLLAMA_BASE_URL", "") + or "http://localhost:11434" + ) + # ollama_detected_models stays [] — model picker + # will fall back to free-text or the preset. + else: + 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). + # In strict non-interactive mode we assume "api_key". + # The prompt offers a `← Back` choice that raises GoBack so + # the user can re-pick the provider without exiting the wizard. + try: + if provider == "anthropic": + if strict: + config.anthropic_auth_mode = "api_key" + else: + config.anthropic_auth_mode = _step_anthropic_auth_mode( + config + ) + elif provider == "openai": + if strict: + config.openai_auth_mode = "api_key" + else: + config.openai_auth_mode = _step_openai_auth_mode(config) + else: + # Non-Anthropic/OpenAI provider: reset OAuth modes to + # avoid stale oauth config triggering ccproxy at startup. + config.anthropic_auth_mode = "api_key" + config.openai_auth_mode = "api_key" + except GoBack: + # User picked "← Back" — restore config to its state at the + # top of this iteration (drops any base_url / region / + # provider writes), then discard ALL provider-coupled + # presets and re-prompt. Clearing only ``provider`` + # leaves a stale ``--model`` / ``--api-key`` that would + # be re-applied under a different provider, producing + # an invalid pair (e.g. ``provider=openai`` + + # ``model=claude-sonnet-4-5``). + for field_name in vars(loop_snapshot): + setattr( + config, field_name, getattr(loop_snapshot, field_name) + ) + if p: + for stale_key in ("provider", "model", "api_key"): + p.answers.pop(stale_key, None) + ollama_detected_models = [] + console.print(" [dim]↩ Returning to provider selection.[/dim]") + continue + break # auth_mode succeeded — exit sub-loop + + # Step 2c: Provider API Key (skip for Ollama and pure OAuth) + _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: + key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") + preset_api_key = _preset("api_key") + if preset_api_key is not None: + # Validate the preset key against the same validator + # the interactive path uses, unless --skip-validation + # was passed. Interactive flow shows a "Save anyway?" + # confirm on failure; the non-interactive path has no + # way to ask, so a failed validation is fatal. + if not skip_validation: + from .helpers import _provider_key_info + + _info = _provider_key_info(config, provider) + validate_fn = _info[2] if _info else None + if validate_fn is not None: + console.print( + " [dim]Validating preset API key...[/dim]", + end="", + ) + valid, msg = validate_fn(preset_api_key) + if valid: + console.print(f"\r [green]✓ {msg}[/green] ") + else: + console.print(f"\r [red]✗ {msg}[/red] ") + raise RuntimeError( + f"--api-key rejected by {provider} " + f"validator: {msg}. Pass " + "--skip-validation to override." + ) + setattr(config, key_attr, preset_api_key) + console.print( + f" [green]✓ API key: ***{preset_api_key[-4:]}[/green]" + " [dim](--api-key)[/dim]" + ) + else: + _require("api_key", f"{provider} API key") + new_key = _step_provider_api_key( + config, provider, skip_validation + ) + 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") + _autosave(config) + else: + # Provider section skipped — keep prior provider value to drive + # downstream sections that depend on it (e.g., model picker). + provider = config.provider + + if "model" in sections_to_run: + _require("model", "Model") + preset_model = _preset("model") + if preset_model is not None: + config.model = preset_model + console.print( + f" [green]✓ Model: {preset_model}[/green] [dim](--model)[/dim]" + ) + else: + config.model = _step_model( + config, provider, ollama_detected_models=ollama_detected_models + ) + if provider == "openrouter" and _preset("model") is None: + config.reasoning_effort = _step_reasoning_effort(config) + _autosave(config) + + if "tavily" in sections_to_run: + preset_tavily = _preset("tavily_key") + if preset_tavily is not None: + # Validate the preset key like the --api-key path does; + # the non-interactive flow can't show a "Save anyway?" + # prompt, so a failed validation is fatal. + if not skip_validation: + from .validators import validate_tavily_key + + console.print( + " [dim]Validating preset Tavily key...[/dim]", end="" + ) + valid, msg = validate_tavily_key(preset_tavily) + if valid: + console.print(f"\r [green]✓ {msg}[/green] ") + else: + console.print(f"\r [red]✗ {msg}[/red] ") + raise RuntimeError( + f"--tavily-key rejected by validator: {msg}. " + "Pass --skip-validation to override." + ) + config.tavily_api_key = preset_tavily + console.print( + f" [green]✓ Tavily key: ***{preset_tavily[-4:]}[/green]" + " [dim](--tavily-key)[/dim]" + ) + elif strict: + # ``--non-interactive`` without ``--tavily-key`` — Tavily + # is optional (web search). Keep whatever's in config + # (likely empty for first-time setup); never open the + # interactive password prompt under strict. + if config.tavily_api_key: + _print_step_skipped("Tavily Key", "kept current") + else: + _print_step_skipped("Tavily Key", "not set") + else: + new_tavily_key = _step_tavily_key(config, skip_validation) + if new_tavily_key is not None: + config.tavily_api_key = new_tavily_key + elif not config.tavily_api_key: + _print_step_skipped("Tavily Key", "not set") + _autosave(config) + + if "workspace" in sections_to_run: + _require("workspace_mode", "Workspace mode") + preset_ws = _preset("workspace_mode") + if preset_ws is not None: + config.default_mode = preset_ws + console.print( + f" [green]✓ Workspace: {preset_ws}[/green]" + " [dim](--workspace-mode)[/dim]" + ) + else: + config.default_mode = _step_workspace(config) + _autosave(config) + + if "thinking" in sections_to_run: + _require("show_thinking", "Thinking panel") + preset_thinking = _preset("show_thinking") + if preset_thinking is not None: + config.show_thinking = bool(preset_thinking) + console.print( + f" [green]✓ Thinking: {'on' if preset_thinking else 'off'}[/green]" + " [dim](--show-thinking)[/dim]" + ) + else: + config.show_thinking = _step_thinking(config) + _autosave(config) + + if "skills" in sections_to_run: + _step_skills() + + if "mcp" in sections_to_run: + _step_mcp_servers() + + if "latex" in sections_to_run: + _step_tinytex() + + if "channels" in sections_to_run: + for key, value in _step_channels(config).items(): + setattr(config, key, value) + _autosave(config) + + # Final confirmation — opportunity to revert. In strict + # non-interactive mode, skip the prompt and commit silently. + if strict: + save = True + else: + 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: + # User declined — restore exact pre-wizard file state. + # Three cases driven by the capture-time flags: + # 1. ``existed=False`` → file is new, delete it (autosaves + # during the run created it). + # 2. ``existed=True`` + bytes captured → restore bytes + # verbatim, preserves comments / unknown fields. + # 3. ``existed=True`` + bytes None (read failed at capture) + # → fall back to ``save_config(snapshot)`` since we + # can't restore the exact bytes; still better than + # leaving the mid-wizard state in place. + try: + if not original_file_existed: + if config_path.exists(): + config_path.unlink() + elif original_file_bytes is not None: + config_path.write_bytes(original_file_bytes) + else: + save_config(snapshot) + except OSError as exc: + save_config(snapshot) + console.print( + f"[yellow]Revert via raw bytes failed ({exc}); " + "wrote parsed snapshot instead.[/yellow]" + ) + console.print() + console.print( + "[yellow]Reverted to previous configuration " + "(autosaved progress discarded).[/yellow]" + ) + console.print() + return False + + except KeyboardInterrupt: + console.print() + console.print( + "[yellow]Setup interrupted. " + "Progress through the last completed step has been autosaved.[/yellow]" + ) + console.print( + f"[dim] Run [bold]EvoSci onboard[/bold] again to resume — " + f"answers persist in {get_config_path()}.[/dim]" + ) + console.print() + return False diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 33b2fdc..cdb3d49 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -1,20 +1,45 @@ """Tests for EvoScientist onboarding wizard.""" import subprocess -from unittest.mock import Mock, patch +from contextlib import contextmanager +from unittest.mock import MagicMock, Mock, patch import pytest from EvoScientist.config import EvoScientistConfig -from EvoScientist.config.onboard import ( +from EvoScientist.config.onboard.style import ( CONFIRM_STYLE, - STEPS, WIZARD_STYLE, +) +from EvoScientist.config.onboard.validators import ( ChoiceValidator, IntegerValidator, +) +from EvoScientist.config.onboard.wizard import ( + STEPS, render_progress, ) + +@contextmanager +def _patch_all_questionary(mock_q): + """Patch ``questionary`` in every onboard submodule that imports it. + + The wizard delegates to step functions across multiple submodules + (steps/helpers/channels/style/wizard), and each submodule binds its own + ``questionary`` name at import time. Patching just one location wouldn't + intercept calls made from the others. + """ + with ( + patch("EvoScientist.config.onboard.wizard.questionary", mock_q), + patch("EvoScientist.config.onboard.steps.questionary", mock_q), + patch("EvoScientist.config.onboard.helpers.questionary", mock_q), + patch("EvoScientist.config.onboard.channels.questionary", mock_q), + patch("EvoScientist.config.onboard.style.questionary", mock_q), + ): + yield mock_q + + # ============================================================================= # Test STEPS and WIZARD_STYLE constants # ============================================================================= @@ -56,6 +81,102 @@ class TestConstants: assert CONFIRM_STYLE is not WIZARD_STYLE +class TestSharedConstantsAlignment: + """Drift guard: the canonical valid-value sets in + ``EvoScientist.config.onboard.constants`` must match the actual + ``Choice(value=...)`` ids built by the interactive step functions in + ``steps.py``. Without this, adding a new provider to one file but not + the other would silently break either CLI flag validation or the + interactive picker. + """ + + def test_provider_constants_match_step_choices(self): + """Every value in `_step_provider`'s Choice list must be in + ``VALID_PROVIDERS`` — and vice versa.""" + from unittest.mock import MagicMock, patch + + from EvoScientist.config.onboard.constants import VALID_PROVIDERS + from EvoScientist.config.onboard.steps import _step_provider + + # Intercept questionary.select to capture the choices list before + # any user prompt happens. + captured = {} + + def _capture(*args, **kwargs): + captured["choices"] = kwargs.get("choices") or ( + args[1] if len(args) > 1 else [] + ) + fake = MagicMock() + fake.ask.return_value = "anthropic" + return fake + + with patch( + "EvoScientist.config.onboard.steps.questionary.select", + side_effect=_capture, + ): + _step_provider(EvoScientistConfig()) + + actual_provider_ids = {c.value for c in captured["choices"]} + assert actual_provider_ids == set(VALID_PROVIDERS), ( + "VALID_PROVIDERS in constants.py drifted from _step_provider's " + f"choices. Only-in-constants: {set(VALID_PROVIDERS) - actual_provider_ids}; " + f"only-in-choices: {actual_provider_ids - set(VALID_PROVIDERS)}" + ) + + def test_ui_constants_match_step_choices(self): + from EvoScientist.config.onboard.constants import VALID_UI_BACKENDS + + # _step_ui_backend hard-codes "tui" and "cli" — small enough to + # check by direct lookup against the canonical set. + assert VALID_UI_BACKENDS == frozenset({"tui", "cli"}) + + def test_workspace_mode_constants_match_step_choices(self): + from EvoScientist.config.onboard.constants import VALID_WORKSPACE_MODES + + assert VALID_WORKSPACE_MODES == frozenset({"daemon", "run"}) + + def test_valid_providers_aligns_with_provider_key_attr(self): + """Every provider in ``VALID_PROVIDERS`` (except ``openai`` and + ``ollama``, which the wizard handles via fallbacks) must appear in + ``_PROVIDER_KEY_ATTR`` — otherwise the auth-mode flow won't know + which config attribute to write the validated key to.""" + from EvoScientist.config.onboard.constants import VALID_PROVIDERS + from EvoScientist.config.onboard.wizard import _PROVIDER_KEY_ATTR + + expected = set(VALID_PROVIDERS) - {"openai", "ollama"} + missing = expected - set(_PROVIDER_KEY_ATTR.keys()) + assert not missing, ( + "VALID_PROVIDERS has providers missing from _PROVIDER_KEY_ATTR " + f"in wizard.py: {missing}" + ) + extra = set(_PROVIDER_KEY_ATTR.keys()) - expected + assert not extra, ( + f"_PROVIDER_KEY_ATTR in wizard.py has keys not in VALID_PROVIDERS: {extra}" + ) + + def test_valid_providers_aligns_with_provider_key_info(self): + """``_provider_key_info`` uses ``mapping.get(provider, )``, so an unknown provider silently behaves like OpenAI. + Verify every non-``openai`` provider in ``VALID_PROVIDERS`` has an + explicit entry — detected by checking the returned display name is + not the OpenAI fallback string.""" + from EvoScientist.config.onboard.constants import VALID_PROVIDERS + from EvoScientist.config.onboard.helpers import _provider_key_info + + cfg = EvoScientistConfig() + for provider in VALID_PROVIDERS: + display_name, _, _ = _provider_key_info(cfg, provider) + if provider == "openai": + assert display_name == "OpenAI" + else: + assert display_name != "OpenAI", ( + f"Provider {provider!r} is missing from _provider_key_info " + "in helpers.py — it falls through to the OpenAI default, " + "which would silently send the wrong validator and " + "current-key lookup." + ) + + # ============================================================================= # Test render_progress # ============================================================================= @@ -221,11 +342,11 @@ class TestChoiceValidator: class TestStepProvider: def test_returns_selected_provider(self): """Test that _step_provider returns selected provider.""" - from EvoScientist.config.onboard import _step_provider + from EvoScientist.config.onboard.steps import _step_provider config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "anthropic" result = _step_provider(config) @@ -234,11 +355,11 @@ class TestStepProvider: def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_provider raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard import _step_provider + from EvoScientist.config.onboard.steps import _step_provider config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_provider(config) @@ -247,11 +368,11 @@ class TestStepProvider: class TestStepModel: def test_returns_selected_model(self): """Test that _step_model returns selected model.""" - from EvoScientist.config.onboard import _step_model + from EvoScientist.config.onboard.steps import _step_model config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "claude-sonnet-4-5" result = _step_model(config, "anthropic") @@ -259,11 +380,11 @@ class TestStepModel: def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_model raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard import _step_model + from EvoScientist.config.onboard.steps import _step_model config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_model(config, "anthropic") @@ -272,11 +393,11 @@ class TestStepModel: class TestStepWorkspace: def test_returns_daemon_mode(self): """Test workspace step returns selected mode.""" - from EvoScientist.config.onboard import _step_workspace + from EvoScientist.config.onboard.steps import _step_workspace config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "daemon" result = _step_workspace(config) @@ -284,11 +405,11 @@ class TestStepWorkspace: def test_returns_run_mode(self): """Test workspace step returns run mode.""" - from EvoScientist.config.onboard import _step_workspace + from EvoScientist.config.onboard.steps import _step_workspace config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = "run" result = _step_workspace(config) @@ -298,13 +419,13 @@ class TestStepWorkspace: class TestPromptAndValidateApiKey: def test_keep_existing_key_still_validates(self): """Pressing Enter to keep current key should validate the existing key.""" - from EvoScientist.config.onboard import _prompt_and_validate_api_key + from EvoScientist.config.onboard.helpers import _prompt_and_validate_api_key validate_fn = Mock(return_value=(True, "Valid")) with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.questionary") as mock_q, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_q.password.return_value.ask.return_value = "" # keep existing result = _prompt_and_validate_api_key( @@ -319,11 +440,11 @@ class TestPromptAndValidateApiKey: def test_new_key_still_validates(self): """Entering a new key should still run validation.""" - from EvoScientist.config.onboard import _prompt_and_validate_api_key + from EvoScientist.config.onboard.helpers import _prompt_and_validate_api_key validate_fn = Mock(return_value=(True, "valid")) - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.helpers.questionary") as mock_q: mock_q.password.return_value.ask.return_value = "new-key" result = _prompt_and_validate_api_key( "Enter key:", @@ -339,15 +460,15 @@ class TestPromptAndValidateApiKey: class TestValidateImessage: def test_valid_when_cli_found_with_rpc(self): """Test validate_imessage returns valid when imsg CLI found and RPC works.""" - from EvoScientist.config.onboard import validate_imessage + from EvoScientist.config.onboard.helpers import validate_imessage version_result = Mock(returncode=0, stdout="imsg 1.2.3") rpc_result = Mock(returncode=0) with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, - patch("EvoScientist.config.onboard.subprocess") as mock_sub, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub, ): mock_sys.platform = "darwin" mock_shutil.which.return_value = "/opt/homebrew/bin/imsg" @@ -360,10 +481,10 @@ class TestValidateImessage: def test_invalid_when_cli_not_found(self): """Test validate_imessage returns not_installed when imsg CLI missing.""" - from EvoScientist.config.onboard import validate_imessage + from EvoScientist.config.onboard.helpers import validate_imessage with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, ): mock_sys.platform = "darwin" @@ -375,9 +496,9 @@ class TestValidateImessage: def test_invalid_on_non_macos(self): """Test validate_imessage returns invalid on non-macOS.""" - from EvoScientist.config.onboard import validate_imessage + from EvoScientist.config.onboard.helpers import validate_imessage - with patch("EvoScientist.config.onboard.sys") as mock_sys: + with patch("EvoScientist.config.onboard.helpers.sys") as mock_sys: mock_sys.platform = "linux" valid, msg = validate_imessage() @@ -386,15 +507,15 @@ class TestValidateImessage: def test_invalid_when_rpc_not_supported(self): """Test validate_imessage returns invalid when RPC check fails.""" - from EvoScientist.config.onboard import validate_imessage + from EvoScientist.config.onboard.helpers import validate_imessage version_result = Mock(returncode=0, stdout="imsg 0.1.0") rpc_result = Mock(returncode=1) with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, - patch("EvoScientist.config.onboard.subprocess") as mock_sub, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub, ): mock_sys.platform = "darwin" mock_shutil.which.return_value = "/usr/local/bin/imsg" @@ -408,9 +529,9 @@ class TestValidateImessage: class TestInstallImsg: def test_install_success(self): """Test _install_imsg returns True on success.""" - from EvoScientist.config.onboard import _install_imsg + from EvoScientist.config.onboard.helpers import _install_imsg - with patch("EvoScientist.config.onboard.subprocess") as mock_sub: + with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub: mock_sub.run.return_value = Mock(returncode=0) mock_sub.TimeoutExpired = subprocess.TimeoutExpired result = _install_imsg() @@ -419,11 +540,11 @@ class TestInstallImsg: def test_install_brew_not_found(self): """Test _install_imsg handles missing Homebrew.""" - from EvoScientist.config.onboard import _install_imsg + from EvoScientist.config.onboard.helpers import _install_imsg with ( - patch("EvoScientist.config.onboard.subprocess") as mock_sub, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_sub.run.side_effect = FileNotFoundError() mock_sub.TimeoutExpired = subprocess.TimeoutExpired @@ -433,9 +554,9 @@ class TestInstallImsg: def test_install_failure(self): """Test _install_imsg returns False on non-zero exit.""" - from EvoScientist.config.onboard import _install_imsg + from EvoScientist.config.onboard.helpers import _install_imsg - with patch("EvoScientist.config.onboard.subprocess") as mock_sub: + with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub: mock_sub.run.return_value = Mock(returncode=1) mock_sub.TimeoutExpired = subprocess.TimeoutExpired result = _install_imsg() @@ -446,14 +567,14 @@ class TestInstallImsg: class TestSetupImessage: def test_already_installed(self): """Test _setup_imessage returns True when already installed.""" - from EvoScientist.config.onboard import _setup_imessage + from EvoScientist.config.onboard.helpers import _setup_imessage with ( patch( - "EvoScientist.config.onboard.validate_imessage", + "EvoScientist.config.onboard.helpers.validate_imessage", return_value=(True, "imsg at /bin/imsg"), ), - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.console"), ): result = _setup_imessage() @@ -461,14 +582,14 @@ class TestSetupImessage: def test_not_macos(self): """Test _setup_imessage returns False on non-macOS.""" - from EvoScientist.config.onboard import _setup_imessage + from EvoScientist.config.onboard.helpers import _setup_imessage with ( patch( - "EvoScientist.config.onboard.validate_imessage", + "EvoScientist.config.onboard.helpers.validate_imessage", return_value=(False, "iMessage requires macOS"), ), - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.console"), ): result = _setup_imessage() @@ -476,13 +597,15 @@ class TestSetupImessage: def test_install_then_valid(self): """Test _setup_imessage installs and re-validates successfully.""" - from EvoScientist.config.onboard import _setup_imessage + from EvoScientist.config.onboard.helpers import _setup_imessage with ( - patch("EvoScientist.config.onboard.validate_imessage") as mock_val, - patch("EvoScientist.config.onboard._install_imsg", return_value=True), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.validate_imessage") as mock_val, + patch( + "EvoScientist.config.onboard.helpers._install_imsg", return_value=True + ), + patch("EvoScientist.config.onboard.helpers.questionary") as mock_q, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_val.side_effect = [ (False, "not_installed"), # First check @@ -495,15 +618,15 @@ class TestSetupImessage: def test_user_declines_install(self): """Test _setup_imessage returns False when user declines install.""" - from EvoScientist.config.onboard import _setup_imessage + from EvoScientist.config.onboard.helpers import _setup_imessage with ( patch( - "EvoScientist.config.onboard.validate_imessage", + "EvoScientist.config.onboard.helpers.validate_imessage", return_value=(False, "not_installed"), ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.questionary") as mock_q, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_q.confirm.return_value.ask.return_value = False result = _setup_imessage() @@ -514,11 +637,11 @@ class TestSetupImessage: class TestStepSkills: def test_returns_empty_when_none_selected(self): """Test skills step returns empty list when user selects nothing.""" - from EvoScientist.config.onboard import _step_skills + from EvoScientist.config.onboard.steps import _step_skills with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.style.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.checkbox.return_value.ask.return_value = [] result = _step_skills() @@ -527,13 +650,13 @@ class TestStepSkills: def test_installs_selected_skills(self): """Test skills step installs selected skills and returns sources.""" - from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills + from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills source = _RECOMMENDED_SKILLS[0]["source"] with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.style.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.console"), patch("EvoScientist.tools.skills_manager.install_skill") as mock_install, ): mock_q.checkbox.return_value.ask.return_value = [source] @@ -545,13 +668,13 @@ class TestStepSkills: def test_handles_install_failure(self): """Test skills step handles installation errors gracefully.""" - from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills + from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills source = _RECOMMENDED_SKILLS[0]["source"] with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.style.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.console"), patch("EvoScientist.tools.skills_manager.install_skill") as mock_install, ): mock_q.checkbox.return_value.ask.return_value = [source] @@ -562,9 +685,9 @@ class TestStepSkills: def test_raises_keyboard_interrupt_on_cancel(self): """Test skills step raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard import _step_skills + from EvoScientist.config.onboard.steps import _step_skills - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.style.questionary") as mock_q: mock_q.checkbox.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_skills() @@ -572,7 +695,7 @@ class TestStepSkills: def test_detects_pack_via_manifest(self, tmp_path): """A pack source recorded in the manifest is detected as installed even when none of the unpacked child dir names match the source.""" - from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills + from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills # Recreate the EvoSkills-style layout: child dirs in GLOBAL_SKILLS_DIR # whose names share nothing with the pack source URL. @@ -601,8 +724,10 @@ class TestStepSkills: with ( patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir), patch("EvoScientist.paths.USER_SKILLS_DIR", empty_user), - patch("EvoScientist.config.onboard._checkbox_ask", side_effect=_capture), - patch("EvoScientist.config.onboard.console"), + patch( + "EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture + ), + patch("EvoScientist.config.onboard.steps.console"), ): _step_skills() @@ -622,7 +747,7 @@ class TestStepSkills: def test_surfaces_update_available_when_upstream_moved(self, tmp_path): """When a pack records an install-time commit and `git ls-remote` reports a different SHA, the choice label should call out the update.""" - from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills + from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills pack_source = _RECOMMENDED_SKILLS[0]["source"] global_dir = tmp_path / "global" @@ -652,8 +777,10 @@ class TestStepSkills: "EvoScientist.tools.skills_manager.resolve_remote_head", return_value="bbb222", ), - patch("EvoScientist.config.onboard._checkbox_ask", side_effect=_capture), - patch("EvoScientist.config.onboard.console"), + patch( + "EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture + ), + patch("EvoScientist.config.onboard.steps.console"), ): _step_skills() @@ -666,7 +793,7 @@ class TestStepSkills: def test_no_update_hint_when_remote_check_fails(self, tmp_path): """If `git ls-remote` returns None (offline, timeout, etc.), the label must fall back to plain 'installed' — never falsely claim 'update'.""" - from EvoScientist.config.onboard import _RECOMMENDED_SKILLS, _step_skills + from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills pack_source = _RECOMMENDED_SKILLS[0]["source"] global_dir = tmp_path / "global" @@ -694,8 +821,10 @@ class TestStepSkills: "EvoScientist.tools.skills_manager.resolve_remote_head", return_value=None, ), - patch("EvoScientist.config.onboard._checkbox_ask", side_effect=_capture), - patch("EvoScientist.config.onboard.console"), + patch( + "EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture + ), + patch("EvoScientist.config.onboard.steps.console"), ): _step_skills() @@ -710,11 +839,11 @@ class TestStepSkills: class TestStepChannels: def test_returns_disabled_when_skip(self): """Test channels step returns empty dict when user selects nothing.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.channels.questionary") as mock_q: mock_q.checkbox.return_value.ask.return_value = [] result = _step_channels(config) @@ -722,13 +851,16 @@ class TestStepChannels: def test_returns_enabled_when_setup_passes(self): """Test channels step returns enabled when iMessage setup succeeds.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._setup_imessage", return_value=True), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch( + "EvoScientist.config.onboard.channels._setup_imessage", + return_value=True, + ), ): mock_q.checkbox.return_value.ask.return_value = ["imessage"] mock_q.text.return_value.ask.return_value = "" @@ -739,13 +871,16 @@ class TestStepChannels: def test_returns_enabled_with_senders(self): """Test channels step returns enabled with specific senders.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._setup_imessage", return_value=True), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch( + "EvoScientist.config.onboard.channels._setup_imessage", + return_value=True, + ), ): mock_q.checkbox.return_value.ask.return_value = ["imessage"] mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321" @@ -757,13 +892,16 @@ class TestStepChannels: def test_setup_fails_user_declines(self): """Test channels step skips iMessage when setup fails and user declines.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._setup_imessage", return_value=False), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch( + "EvoScientist.config.onboard.channels._setup_imessage", + return_value=False, + ), ): mock_q.checkbox.return_value.ask.return_value = ["imessage"] mock_q.confirm.return_value.ask.return_value = False @@ -774,13 +912,16 @@ class TestStepChannels: def test_setup_fails_user_enables_anyway(self): """Test channels step enables iMessage when setup fails but user confirms.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._setup_imessage", return_value=False), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch( + "EvoScientist.config.onboard.channels._setup_imessage", + return_value=False, + ), ): mock_q.checkbox.return_value.ask.return_value = ["imessage"] mock_q.confirm.return_value.ask.return_value = True @@ -792,18 +933,18 @@ class TestStepChannels: def test_raises_keyboard_interrupt_on_cancel(self): """Test channels step raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.channels.questionary") as mock_q: mock_q.checkbox.return_value.ask.return_value = None with pytest.raises(KeyboardInterrupt): _step_channels(config) def test_telegram_channel_selected(self): """Test channels step handles Telegram selection.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() @@ -819,12 +960,14 @@ class TestStepChannels: return _real_import(name, *args, **kwargs) with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._probe_channel"), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch("EvoScientist.config.onboard.channels._probe_channel"), patch("builtins.__import__", side_effect=_fake_import), ): mock_q.checkbox.return_value.ask.return_value = ["telegram"] - mock_q.text.return_value.ask.return_value = "test-token" + # Bot token is a secret → prompted via questionary.password. + mock_q.password.return_value.ask.return_value = "test-token" + mock_q.text.return_value.ask.return_value = "" result = _step_channels(config) assert result["channel_enabled"] == "telegram" @@ -832,7 +975,7 @@ class TestStepChannels: def test_discord_channel_selected(self): """Test channels step handles Discord selection.""" - from EvoScientist.config.onboard import _step_channels + from EvoScientist.config.onboard.channels import _step_channels config = EvoScientistConfig() @@ -848,12 +991,14 @@ class TestStepChannels: return _real_import(name, *args, **kwargs) with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._probe_channel"), + patch("EvoScientist.config.onboard.channels.questionary") as mock_q, + patch("EvoScientist.config.onboard.channels._probe_channel"), patch("builtins.__import__", side_effect=_fake_import), ): mock_q.checkbox.return_value.ask.return_value = ["discord"] - mock_q.text.return_value.ask.return_value = "discord-token" + # Bot token is a secret → prompted via questionary.password. + mock_q.password.return_value.ask.return_value = "discord-token" + mock_q.text.return_value.ask.return_value = "" result = _step_channels(config) assert result["channel_enabled"] == "discord" @@ -883,7 +1028,7 @@ class TestStepMcpServersNpxFailure: def test_npx_failure_skips_npx_servers(self): """When _ensure_npx returns False, npx-dependent servers must be skipped.""" - from EvoScientist.config.onboard import _step_mcp_servers + from EvoScientist.config.onboard.steps import _step_mcp_servers servers = self._make_test_servers() @@ -893,14 +1038,14 @@ class TestStepMcpServersNpxFailure: return_value=servers, ), patch( - "EvoScientist.config.onboard._checkbox_ask", + "EvoScientist.config.onboard.steps._checkbox_ask", return_value=["npx-server", "url-server"], ), - patch("EvoScientist.config.onboard._ensure_npx", return_value=False), - patch("EvoScientist.config.onboard._check_npx", return_value=False), + patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=False), + patch("EvoScientist.config.onboard.helpers._check_npx", return_value=False), patch("EvoScientist.mcp.client._load_user_config", return_value={}), patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.console"), ): result = _step_mcp_servers() @@ -914,7 +1059,7 @@ class TestStepMcpServersNpxFailure: def test_npx_failure_returns_empty_when_all_npx(self): """When all selected servers are npx-based and npx fails, return [].""" - from EvoScientist.config.onboard import _step_mcp_servers + from EvoScientist.config.onboard.steps import _step_mcp_servers servers = self._make_test_servers() npx_names = [s.name for s in servers if s.command == "npx"] @@ -924,12 +1069,15 @@ class TestStepMcpServersNpxFailure: "EvoScientist.mcp.registry.fetch_marketplace_index", return_value=servers, ), - patch("EvoScientist.config.onboard._checkbox_ask", return_value=npx_names), - patch("EvoScientist.config.onboard._ensure_npx", return_value=False), - patch("EvoScientist.config.onboard._check_npx", return_value=False), + patch( + "EvoScientist.config.onboard.steps._checkbox_ask", + return_value=npx_names, + ), + patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=False), + patch("EvoScientist.config.onboard.helpers._check_npx", return_value=False), patch("EvoScientist.mcp.client._load_user_config", return_value={}), patch("EvoScientist.mcp.client.add_mcp_server") as mock_add, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.console"), ): result = _step_mcp_servers() @@ -940,11 +1088,11 @@ class TestStepMcpServersNpxFailure: class TestStepThinking: def test_returns_show_thinking(self): """Test thinking step returns selected value.""" - from EvoScientist.config.onboard import _step_thinking + from EvoScientist.config.onboard.steps import _step_thinking config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = True result = _step_thinking(config) @@ -952,11 +1100,11 @@ class TestStepThinking: def test_returns_false_when_off(self): """Test thinking step returns False when user selects Off.""" - from EvoScientist.config.onboard import _step_thinking + from EvoScientist.config.onboard.steps import _step_thinking config = EvoScientistConfig(show_thinking=False) - with patch("EvoScientist.config.onboard.questionary") as mock_q: + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: mock_q.select.return_value.ask.return_value = False result = _step_thinking(config) @@ -971,26 +1119,32 @@ class TestStepThinking: class TestRunOnboard: def test_returns_true_on_save(self): """Test that run_onboard returns True when config is saved.""" - from EvoScientist.config.onboard import run_onboard + from EvoScientist.config.onboard.wizard import run_onboard + mock_q = MagicMock() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.load_config") as mock_load, - patch("EvoScientist.config.onboard.save_config") as mock_save, - patch("EvoScientist.config.onboard.console"), - patch("EvoScientist.config.onboard._step_tinytex"), + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), ): # Setup mock config mock_load.return_value = EvoScientistConfig() - # Mock all questionary calls + # Mock all questionary calls — order matches the wizard's select + # sequence: UI → Provider → Anthropic auth_mode → Model → + # Workspace → Thinking → (channels skipped via empty checkbox). mock_q.select.return_value.ask.side_effect = [ "tui", # UI backend "anthropic", # Provider + "api_key", # Anthropic auth mode (API key, not OAuth) "claude-sonnet-4-5", # Model "daemon", # Workspace mode True, # Show thinking - "skip", # Channels: skip ] mock_q.password.return_value.ask.side_effect = [ "", # Provider API key (keep current) @@ -1007,17 +1161,31 @@ class TestRunOnboard: result = run_onboard(skip_validation=True) assert result is True - mock_save.assert_called_once() + # Config is autosaved between phases + once at the end. + mock_save.assert_called() + # Verify the final saved config has the values we picked — guards + # against silent shift bugs where a missing side_effect entry causes + # downstream prompts to consume the wrong values. + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.provider == "anthropic" + assert final_config.model == "claude-sonnet-4-5" + assert final_config.anthropic_auth_mode == "api_key" + assert final_config.ui_backend == "tui" + assert final_config.default_mode == "daemon" def test_returns_false_on_cancel(self): """Test that run_onboard returns False when cancelled.""" - from EvoScientist.config.onboard import run_onboard + from EvoScientist.config.onboard.wizard import run_onboard + mock_q = MagicMock() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.load_config") as mock_load, - patch("EvoScientist.config.onboard.console"), - patch("EvoScientist.config.onboard._step_tinytex"), + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), ): mock_load.return_value = EvoScientistConfig() @@ -1030,24 +1198,28 @@ class TestRunOnboard: def test_returns_false_when_not_saving(self): """Test that run_onboard returns False when user declines to save.""" - from EvoScientist.config.onboard import run_onboard + from EvoScientist.config.onboard.wizard import run_onboard + mock_q = MagicMock() with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard.load_config") as mock_load, - patch("EvoScientist.config.onboard.save_config") as mock_save, - patch("EvoScientist.config.onboard.console"), - patch("EvoScientist.config.onboard._step_tinytex"), + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), ): mock_load.return_value = EvoScientistConfig() mock_q.select.return_value.ask.side_effect = [ "tui", # UI backend - "anthropic", - "claude-sonnet-4-5", - "daemon", + "anthropic", # Provider + "api_key", # Anthropic auth mode + "claude-sonnet-4-5", # Model + "daemon", # Workspace mode True, # Show thinking - "skip", # Channels: skip ] mock_q.password.return_value.ask.side_effect = ["", ""] mock_q.confirm.return_value.ask.side_effect = [ @@ -1061,7 +1233,205 @@ class TestRunOnboard: result = run_onboard(skip_validation=True) assert result is False - mock_save.assert_not_called() + # The wizard autosaves between phases AND writes the original + # snapshot back when the user declines — so save_config IS called. + mock_save.assert_called() + + def test_reset_then_no_restores_pre_wizard_config(self, tmp_path): + """Regression: Reset → No must restore the original user file, + NOT overwrite it with ``EvoScientistConfig()`` defaults. + + Reproduces a bug where the snapshot was refreshed after Reset, so + declining the save silently wiped the user's previous settings. + Verifies the raw-bytes revert path introduced for CodeRabbit's + "file absence on cancel" comment. + """ + from EvoScientist.config.onboard.wizard import run_onboard + from EvoScientist.config.settings import save_config + + config_file = tmp_path / "config.yaml" + existing = EvoScientistConfig( + provider="openai", + model="gpt-5", + openai_api_key="sk-existing", + ui_backend="cli", + ) + + mock_q = MagicMock() + with patch( + "EvoScientist.config.settings.get_config_path", return_value=config_file + ): + save_config(existing) + # Append a comment that ``save_config`` would strip on + # re-serialization. The test then proves revert restored the + # EXACT bytes (not just rewrote the snapshot via save_config + # and got lucky because logical content matched). + with config_file.open("ab") as f: + f.write(b"\n# user manual comment - must survive revert\n") + original_bytes = config_file.read_bytes() + + with ( + _patch_all_questionary(mock_q), + patch( + "EvoScientist.config.onboard.wizard.get_config_path", + return_value=config_file, + ), + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), + ): + mock_q.select.return_value.ask.side_effect = [ + "reset", # Keep/Modify/Reset → Reset + "tui", + "anthropic", + "api_key", + "claude-sonnet-4-5", + "daemon", + True, + ] + mock_q.password.return_value.ask.side_effect = ["", ""] + mock_q.confirm.return_value.ask.side_effect = [False] # Save? = No + mock_q.text.return_value.ask.side_effect = [""] + mock_q.checkbox.return_value.ask.return_value = [] + + result = run_onboard(skip_validation=True) + + assert result is False + assert config_file.exists(), "file should still exist after revert" + assert config_file.read_bytes() == original_bytes, ( + "revert must restore EXACT pre-wizard bytes, not autosaved state" + ) + + def test_revert_removes_file_when_none_existed(self, tmp_path): + """Brand-new user (no config.yaml) declines the final save: the file + created by autosaves should be deleted to match the original state. + """ + from EvoScientist.config.onboard.wizard import run_onboard + + config_file = tmp_path / "config.yaml" + assert not config_file.exists() + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch( + "EvoScientist.config.settings.get_config_path", + return_value=config_file, + ), + patch( + "EvoScientist.config.onboard.wizard.get_config_path", + return_value=config_file, + ), + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), + ): + mock_q.select.return_value.ask.side_effect = [ + "tui", + "anthropic", + "api_key", + "claude-sonnet-4-5", + "daemon", + True, + ] + mock_q.password.return_value.ask.side_effect = ["", ""] + mock_q.confirm.return_value.ask.side_effect = [False] # Save? = No + mock_q.text.return_value.ask.side_effect = [""] + mock_q.checkbox.return_value.ask.return_value = [] + + result = run_onboard(skip_validation=True) + + assert result is False + assert not config_file.exists(), ( + "revert must delete file that was autosaved into existence " + "during the wizard run" + ) + + def test_preset_tavily_key_rejected_by_validator_is_fatal(self): + """``--tavily-key`` preset must be validated like ``--api-key``; + a rejected key raises rather than silently saving.""" + import pytest + + from EvoScientist.config.onboard.prompter import NonInteractivePrompter + from EvoScientist.config.onboard.wizard import run_onboard + + prompter = NonInteractivePrompter(answers={"tavily_key": "tvly-bad"}) + + with ( + patch( + "EvoScientist.config.onboard.wizard.load_config", + return_value=EvoScientistConfig(), + ), + patch("EvoScientist.config.onboard.wizard.save_config"), + patch("EvoScientist.config.onboard.wizard.console"), + patch( + "EvoScientist.config.onboard.validators.validate_tavily_key", + return_value=(False, "Invalid API key"), + ), + ): + with pytest.raises(RuntimeError, match="--tavily-key rejected"): + run_onboard( + skip_validation=False, + prompter=prompter, + only_sections={"tavily"}, + ) + + def test_preset_tavily_key_skips_validation_when_flagged(self): + """``--skip-validation`` must bypass the preset tavily check.""" + from EvoScientist.config.onboard.prompter import NonInteractivePrompter + from EvoScientist.config.onboard.wizard import run_onboard + + prompter = NonInteractivePrompter(answers={"tavily_key": "tvly-bad"}) + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch( + "EvoScientist.config.onboard.wizard.load_config", + return_value=EvoScientistConfig(), + ), + patch("EvoScientist.config.onboard.wizard.save_config"), + patch("EvoScientist.config.onboard.wizard.console"), + patch( + "EvoScientist.config.onboard.validators.validate_tavily_key" + ) as mock_validate, + ): + mock_q.confirm.return_value.ask.side_effect = [True] # Save? = Yes + run_onboard( + skip_validation=True, + prompter=prompter, + only_sections={"tavily"}, + ) + mock_validate.assert_not_called() + + +class TestOnboardCliErrorPresentation: + """The CLI must turn the wizard's non-interactive ``RuntimeError`` signals + into a clean message + exit code 1, not a raw Python traceback.""" + + def test_runtime_error_becomes_clean_typer_exit(self): + import typer + + from EvoScientist.cli.commands import _run_onboard_cli + + with ( + patch( + "EvoScientist.config.onboard.run_onboard", + side_effect=RuntimeError("--tavily-key rejected by validator: nope"), + ), + patch("EvoScientist.cli.commands.console") as mock_console, + ): + with pytest.raises(typer.Exit) as exc_info: + _run_onboard_cli(skip_validation=False) + + assert exc_info.value.exit_code == 1 + # The error message reaches the user via console, not a traceback. + printed = " ".join(str(c.args[0]) for c in mock_console.print.call_args_list) + assert "rejected by validator" in printed # ============================================================================= @@ -1074,11 +1444,11 @@ class TestCheckLatexComponents: def test_all_available(self): """All three components found → all True.""" - from EvoScientist.config.onboard import _check_latex_components + from EvoScientist.config.onboard.helpers import _check_latex_components with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sh.which.return_value = "/usr/local/bin/cmd" mock_sp.TimeoutExpired = subprocess.TimeoutExpired @@ -1088,11 +1458,11 @@ class TestCheckLatexComponents: def test_only_pdflatex(self): """Only pdflatex available.""" - from EvoScientist.config.onboard import _check_latex_components + from EvoScientist.config.onboard.helpers import _check_latex_components with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sh.which.side_effect = lambda cmd: ( "/usr/local/bin/pdflatex" if cmd == "pdflatex" else None @@ -1108,9 +1478,9 @@ class TestCheckLatexComponents: def test_none_available(self): """Nothing found → all False.""" - from EvoScientist.config.onboard import _check_latex_components + from EvoScientist.config.onboard.helpers import _check_latex_components - with patch("EvoScientist.config.onboard.shutil") as mock_sh: + with patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh: mock_sh.which.return_value = None result = _check_latex_components() assert result == { @@ -1125,12 +1495,12 @@ class TestAutoInstallLatexmk: def test_success(self): """tlmgr install latexmk succeeds.""" - from EvoScientist.config.onboard import _auto_install_latexmk + from EvoScientist.config.onboard.helpers import _auto_install_latexmk with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, - patch("EvoScientist.config.onboard.console") as mock_con, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.console") as mock_con, ): mock_sh.which.side_effect = lambda cmd: f"/usr/local/bin/{cmd}" mock_sp.run.return_value = Mock(returncode=0) @@ -1143,12 +1513,12 @@ class TestAutoInstallLatexmk: def test_tlmgr_not_found(self): """tlmgr not on PATH → does nothing.""" - from EvoScientist.config.onboard import _auto_install_latexmk + from EvoScientist.config.onboard.helpers import _auto_install_latexmk with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_sh.which.return_value = None _auto_install_latexmk() @@ -1156,12 +1526,12 @@ class TestAutoInstallLatexmk: def test_install_fails(self): """tlmgr install returns nonzero → warns.""" - from EvoScientist.config.onboard import _auto_install_latexmk + from EvoScientist.config.onboard.helpers import _auto_install_latexmk with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, - patch("EvoScientist.config.onboard.console") as mock_con, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.console") as mock_con, ): mock_sh.which.side_effect = lambda cmd: ( "/usr/local/bin/tlmgr" if cmd == "tlmgr" else None @@ -1180,11 +1550,11 @@ class TestCheckTinytex: def test_found_pdflatex(self): """pdflatex found and working → True.""" - from EvoScientist.config.onboard import _check_tinytex + from EvoScientist.config.onboard.helpers import _check_tinytex with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sh.which.return_value = "/usr/local/bin/pdflatex" mock_sp.TimeoutExpired = subprocess.TimeoutExpired @@ -1193,11 +1563,11 @@ class TestCheckTinytex: def test_tlmgr_only_not_enough(self): """pdflatex missing but tlmgr found → False (pdflatex is required).""" - from EvoScientist.config.onboard import _check_tinytex + from EvoScientist.config.onboard.helpers import _check_tinytex with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sh.which.side_effect = lambda cmd: ( "/usr/local/bin/tlmgr" if cmd == "tlmgr" else None @@ -1208,19 +1578,19 @@ class TestCheckTinytex: def test_not_found(self): """Neither pdflatex nor tlmgr found → False.""" - from EvoScientist.config.onboard import _check_tinytex + from EvoScientist.config.onboard.helpers import _check_tinytex - with patch("EvoScientist.config.onboard.shutil") as mock_sh: + with patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh: mock_sh.which.return_value = None assert _check_tinytex() is False def test_version_timeout(self): """Command found but --version times out → False.""" - from EvoScientist.config.onboard import _check_tinytex + from EvoScientist.config.onboard.helpers import _check_tinytex with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sh.which.return_value = "/usr/local/bin/pdflatex" mock_sp.TimeoutExpired = subprocess.TimeoutExpired @@ -1229,11 +1599,11 @@ class TestCheckTinytex: def test_version_nonzero(self): """Command found but --version returns nonzero → False.""" - from EvoScientist.config.onboard import _check_tinytex + from EvoScientist.config.onboard.helpers import _check_tinytex with ( - patch("EvoScientist.config.onboard.shutil") as mock_sh, - patch("EvoScientist.config.onboard.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, ): mock_sp.TimeoutExpired = subprocess.TimeoutExpired mock_sp.run.return_value = Mock(returncode=1) @@ -1249,11 +1619,11 @@ class TestDetectTinytexInstallMethod: def test_macos_with_curl(self): """macOS with curl → curl method.""" - from EvoScientist.config.onboard import _detect_tinytex_install_method + from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sys.platform = "darwin" mock_sh.which.side_effect = lambda cmd: ( @@ -1265,11 +1635,11 @@ class TestDetectTinytexInstallMethod: def test_linux_wget_fallback(self): """Linux without curl, with wget → wget method.""" - from EvoScientist.config.onboard import _detect_tinytex_install_method + from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sys.platform = "linux" mock_sh.which.side_effect = lambda cmd: ( @@ -1281,11 +1651,11 @@ class TestDetectTinytexInstallMethod: def test_windows_choco(self): """Windows with choco → choco method.""" - from EvoScientist.config.onboard import _detect_tinytex_install_method + from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sys.platform = "win32" mock_sh.which.side_effect = lambda cmd: ( @@ -1297,11 +1667,11 @@ class TestDetectTinytexInstallMethod: def test_windows_scoop(self): """Windows with scoop (no choco) → scoop method.""" - from EvoScientist.config.onboard import _detect_tinytex_install_method + from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sys.platform = "win32" mock_sh.which.side_effect = lambda cmd: ( @@ -1313,11 +1683,11 @@ class TestDetectTinytexInstallMethod: def test_no_tools(self): """No tools available → manual method.""" - from EvoScientist.config.onboard import _detect_tinytex_install_method + from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method with ( - patch("EvoScientist.config.onboard.sys") as mock_sys, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.sys") as mock_sys, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sys.platform = "linux" mock_sh.which.return_value = None @@ -1331,9 +1701,9 @@ class TestInstallTinytex: def test_curl_install_success(self): """curl install succeeds → True.""" - from EvoScientist.config.onboard import _install_tinytex + from EvoScientist.config.onboard.helpers import _install_tinytex - with patch("EvoScientist.config.onboard.subprocess") as mock_sp: + with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp: mock_sp.run.return_value = Mock(returncode=0) mock_sp.TimeoutExpired = subprocess.TimeoutExpired assert _install_tinytex("curl", "curl -sL ... | sh") is True @@ -1344,11 +1714,11 @@ class TestInstallTinytex: def test_curl_install_timeout(self): """curl install times out → False.""" - from EvoScientist.config.onboard import _install_tinytex + from EvoScientist.config.onboard.helpers import _install_tinytex with ( - patch("EvoScientist.config.onboard.subprocess") as mock_sp, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.console"), ): mock_sp.run.side_effect = subprocess.TimeoutExpired("curl", 300) mock_sp.TimeoutExpired = subprocess.TimeoutExpired @@ -1356,11 +1726,11 @@ class TestInstallTinytex: def test_choco_install_success(self): """choco install succeeds → True.""" - from EvoScientist.config.onboard import _install_tinytex + from EvoScientist.config.onboard.helpers import _install_tinytex with ( - patch("EvoScientist.config.onboard.subprocess") as mock_sp, - patch("EvoScientist.config.onboard.shutil") as mock_sh, + patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp, + patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh, ): mock_sh.which.return_value = "C:\\choco\\choco.exe" mock_sp.run.return_value = Mock(returncode=0) @@ -1369,7 +1739,7 @@ class TestInstallTinytex: def test_manual_returns_false(self): """manual method → False immediately.""" - from EvoScientist.config.onboard import _install_tinytex + from EvoScientist.config.onboard.helpers import _install_tinytex assert _install_tinytex("manual", "https://yihui.org/tinytex/") is False @@ -1379,12 +1749,12 @@ class TestStepTinytex: def test_user_declines_prepare(self): """User says No to 'Prepare LaTeX environment?' → skipped.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = False _step_tinytex() @@ -1392,20 +1762,22 @@ class TestStepTinytex: def test_already_installed_all_components(self): """User says Yes, all components available → prints detailed status.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", return_value={ "pdflatex": True, "latexmk": True, "tlmgr": True, }, ), - patch("EvoScientist.config.onboard._print_latex_status") as mock_status, - patch("EvoScientist.config.onboard.console"), + patch( + "EvoScientist.config.onboard.steps._print_latex_status" + ) as mock_status, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True _step_tinytex() @@ -1413,21 +1785,23 @@ class TestStepTinytex: def test_already_installed_missing_latexmk(self): """pdflatex + tlmgr present but latexmk missing → auto-installs.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex with ( - patch("EvoScientist.config.onboard.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", return_value={ "pdflatex": True, "latexmk": False, "tlmgr": True, }, ), - patch("EvoScientist.config.onboard._print_latex_status"), - patch("EvoScientist.config.onboard._auto_install_latexmk") as mock_auto, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps._print_latex_status"), + patch( + "EvoScientist.config.onboard.steps._auto_install_latexmk" + ) as mock_auto, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True _step_tinytex() @@ -1435,27 +1809,27 @@ class TestStepTinytex: def test_user_installs_successfully(self): """Yes → not found → confirms install → succeeds → re-check passes.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False} all_true = {"pdflatex": True, "latexmk": True, "tlmgr": True} with ( patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", side_effect=[all_false, all_true], ), patch( - "EvoScientist.config.onboard._detect_tinytex_install_method", + "EvoScientist.config.onboard.steps._detect_tinytex_install_method", return_value=("curl", "curl ... | sh"), ), patch( - "EvoScientist.config.onboard._install_tinytex", + "EvoScientist.config.onboard.steps._install_tinytex", return_value=True, ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_result") as mock_pr, - patch("EvoScientist.config.onboard._print_latex_status"), - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr, + patch("EvoScientist.config.onboard.steps._print_latex_status"), + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True mock_q.confirm.return_value.ask.return_value = True @@ -1464,21 +1838,21 @@ class TestStepTinytex: def test_user_declines_install(self): """Yes to prepare → not found → declines install → skipped.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False} with ( patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", return_value=all_false, ), patch( - "EvoScientist.config.onboard._detect_tinytex_install_method", + "EvoScientist.config.onboard.steps._detect_tinytex_install_method", return_value=("curl", "curl ... | sh"), ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True mock_q.confirm.return_value.ask.return_value = False @@ -1487,25 +1861,25 @@ class TestStepTinytex: def test_install_fails(self): """Yes → not found → confirms install → install fails.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False} with ( patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", return_value=all_false, ), patch( - "EvoScientist.config.onboard._detect_tinytex_install_method", + "EvoScientist.config.onboard.steps._detect_tinytex_install_method", return_value=("curl", "curl ... | sh"), ), patch( - "EvoScientist.config.onboard._install_tinytex", + "EvoScientist.config.onboard.steps._install_tinytex", return_value=False, ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_result") as mock_pr, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True mock_q.confirm.return_value.ask.return_value = True @@ -1516,25 +1890,25 @@ class TestStepTinytex: def test_installed_but_not_in_path(self): """Install succeeds but pdflatex not yet in PATH → warns user.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False} with ( patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", side_effect=[all_false, all_false], ), patch( - "EvoScientist.config.onboard._detect_tinytex_install_method", + "EvoScientist.config.onboard.steps._detect_tinytex_install_method", return_value=("curl", "curl ... | sh"), ), patch( - "EvoScientist.config.onboard._install_tinytex", + "EvoScientist.config.onboard.steps._install_tinytex", return_value=True, ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_result") as mock_pr, - patch("EvoScientist.config.onboard.console") as mock_con, + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr, + patch("EvoScientist.config.onboard.steps.console") as mock_con, ): mock_q.select.return_value.ask.return_value = True mock_q.confirm.return_value.ask.return_value = True @@ -1549,21 +1923,21 @@ class TestStepTinytex: def test_manual_method(self): """Yes to prepare → not found → manual method → prints URL, no install prompt.""" - from EvoScientist.config.onboard import _step_tinytex + from EvoScientist.config.onboard.steps import _step_tinytex all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False} with ( patch( - "EvoScientist.config.onboard._check_latex_components", + "EvoScientist.config.onboard.steps._check_latex_components", return_value=all_false, ), patch( - "EvoScientist.config.onboard._detect_tinytex_install_method", + "EvoScientist.config.onboard.steps._detect_tinytex_install_method", return_value=("manual", "https://yihui.org/tinytex/"), ), - patch("EvoScientist.config.onboard.questionary") as mock_q, - patch("EvoScientist.config.onboard._print_step_skipped") as mock_ps, - patch("EvoScientist.config.onboard.console"), + patch("EvoScientist.config.onboard.steps.questionary") as mock_q, + patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps, + patch("EvoScientist.config.onboard.steps.console"), ): mock_q.select.return_value.ask.return_value = True _step_tinytex()