Add onboarding wizard with style and validation components (#241)
* Add onboarding wizard with style and validation components - Introduced `style.py` for shared visual elements used in the onboarding wizard. - Created `validators.py` for input validation, including integer and choice validators, and API key validation functions for various providers. - Implemented `wizard.py` as the entry point for the onboarding process, managing user prompts and configuration steps. - Added progress rendering and autosave functionality to enhance user experience during the onboarding process. * feat(onboarding): enhance validation and configuration for onboarding wizard - Added validation for UI backends, workspace modes, and providers in the onboarding command. - Updated channel definitions to include secret field handling for sensitive tokens. - Improved user prompts for required fields, ensuring sensitive data is masked. - Introduced constants for valid providers, UI backends, and workspace modes to maintain consistency. - Implemented tests to ensure alignment between constants and interactive choices in onboarding steps. * feat(onboarding): improve WeChat account ID prompt and validation for newly enabled channels * feat(onboarding): enhance WeChat backend credential prompts and validation * feat(onboarding): refine WeChat backend credential prompts for wecom and wechatmp * Refactor onboarding package for improved structure and clarity - Simplified the onboarding package by removing unnecessary re-exports and consolidating public API to only include `run_onboard`. - Updated `install_back_keys` to `install_navigation_keys` for clarity and consistency in the prompter module. - Enhanced the `NonInteractivePrompter` class to support strict mode, allowing for better handling of non-interactive prompts. - Adjusted the onboarding steps to utilize the new navigation keys installation method. - Improved the `run_onboard` function to handle section implications based on user flags, enhancing the onboarding experience. - Updated tests to reflect changes in imports and ensure compatibility with the new structure. * feat(onboarding): enhance validation logic for non-interactive prompts * refactor(onboarding): streamline onboarding module structure and enhance validation error handling * refactor(onboarding): enhance config revert logic to preserve original file state * refactor(onboarding): enhance tavily key validation and error handling in onboarding process
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 <section>` — 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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.<submodule>.<name>")`` 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"]
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
# =============================================================================
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
@@ -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 <section>`` 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 <section>' 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
|
||||
+616
-242
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user