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:
Xi Zhang
2026-05-28 12:42:49 +01:00
committed by GitHub
parent b9ad694467
commit f75bfcda51
13 changed files with 5430 additions and 3746 deletions
+9
View File
@@ -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")
+245 -4
View File
@@ -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
+34
View File
@@ -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"]
+958
View File
@@ -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)
# =============================================================================
+47
View File
@@ -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",
]
+693
View File
@@ -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
+121
View File
@@ -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
+131
View File
@@ -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
# =============================================================================
+537
View File
@@ -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
# =============================================================================
+812
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff