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:
@@ -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
|
||||
Reference in New Issue
Block a user