mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-29 04:00:13 +00:00
996 lines
34 KiB
Python
996 lines
34 KiB
Python
"""Setup commands to register Skyvern with AI coding tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
from urllib.parse import urlparse
|
|
|
|
import typer
|
|
from dotenv import load_dotenv
|
|
from rich.panel import Panel
|
|
from rich.syntax import Syntax
|
|
from rich.table import Table
|
|
|
|
from skyvern.analytics import capture_setup_event
|
|
from skyvern.cli.auth_command import run_signup
|
|
from skyvern.cli.console import console
|
|
from skyvern.cli.skill_commands import get_skill_dirs
|
|
from skyvern.utils import detect_os, get_windows_appdata_roaming
|
|
from skyvern.utils.env_paths import resolve_backend_env_path
|
|
|
|
# NOTE: These helpers back both `skyvern setup ...` commands and the
|
|
# interactive MCP step used by `skyvern init` / `skyvern quickstart`, plus
|
|
# the MCP switcher in `skyvern/cli/mcp_commands.py`.
|
|
# Keep shared config parsing/writes, local stdio setup, and Claude Code skill
|
|
# installation behavior here so the standalone and wizard flows stay aligned.
|
|
setup_app = typer.Typer(
|
|
help="Register Skyvern MCP with AI coding tools.",
|
|
invoke_without_command=True,
|
|
)
|
|
|
|
_DEFAULT_REMOTE_URL = "https://api.skyvern.com/mcp/"
|
|
_DEFAULT_CLAUDE_DESKTOP_BUNDLE_URL = (
|
|
"https://github.com/Skyvern-AI/skyvern/raw/main/skyvern/cli/mcpb/releases/skyvern-claude-desktop.mcpb"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_env_credentials() -> tuple[str, str]:
|
|
"""Read SKYVERN_API_KEY and SKYVERN_BASE_URL from environment or .env."""
|
|
backend_env = resolve_backend_env_path()
|
|
if backend_env.exists():
|
|
load_dotenv(backend_env, override=False)
|
|
|
|
api_key = os.environ.get("SKYVERN_API_KEY", "")
|
|
base_url = os.environ.get("SKYVERN_BASE_URL", "https://api.skyvern.com")
|
|
return api_key, base_url
|
|
|
|
|
|
def _get_local_env_credentials() -> tuple[str, str]:
|
|
"""Read local SKYVERN_API_KEY and SKYVERN_BASE_URL from environment or .env."""
|
|
backend_env = resolve_backend_env_path()
|
|
if backend_env.exists():
|
|
load_dotenv(backend_env, override=False)
|
|
|
|
api_key = os.environ.get("SKYVERN_API_KEY", "")
|
|
base_url = os.environ.get("SKYVERN_BASE_URL", "")
|
|
return api_key, base_url
|
|
|
|
|
|
def _build_remote_mcp_entry(api_key: str, url: str = _DEFAULT_REMOTE_URL) -> dict:
|
|
"""Build an HTTP MCP entry for remote/cloud hosting."""
|
|
entry: dict = {
|
|
"type": "http",
|
|
"url": url,
|
|
}
|
|
if api_key:
|
|
entry["headers"] = {"x-api-key": api_key}
|
|
return entry
|
|
|
|
|
|
def _build_mcp_remote_bridge_entry(api_key: str, url: str = _DEFAULT_REMOTE_URL) -> dict:
|
|
"""Build an npx mcp-remote entry for clients that only support stdio (e.g. Claude Desktop)."""
|
|
args = ["mcp-remote", url]
|
|
if api_key:
|
|
args.extend(["--header", f"x-api-key:{api_key}"])
|
|
return {
|
|
"command": "npx",
|
|
"args": args,
|
|
}
|
|
|
|
|
|
def _has_node_runtime() -> bool:
|
|
return shutil.which("node") is not None and shutil.which("npx") is not None
|
|
|
|
|
|
def _supports_claude_desktop_bundle() -> bool:
|
|
return platform.system() in {"Darwin", "Windows"}
|
|
|
|
|
|
def _claude_desktop_bundle_message() -> str:
|
|
if not _supports_claude_desktop_bundle():
|
|
return "Claude Desktop remote setup on this platform still requires Node.js because the one-click `.mcpb` installer is only available in Claude Desktop for macOS and Windows."
|
|
return (
|
|
"Claude Desktop remote setup via JSON still uses `mcp-remote`, which requires Node.js.\n"
|
|
f"Download the latest one-click Skyvern bundle (`skyvern-claude-desktop.mcpb`) from: {_DEFAULT_CLAUDE_DESKTOP_BUNDLE_URL}\n"
|
|
"Then double-click the downloaded `.mcpb`, click Install in Claude Desktop, paste your API key, and click Save."
|
|
)
|
|
|
|
|
|
def _build_local_mcp_entry(
|
|
api_key: str,
|
|
base_url: str,
|
|
use_python_path: bool = False,
|
|
command: str | None = None,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> dict:
|
|
"""Build a stdio MCP entry for local self-hosted mode.
|
|
|
|
The active interpreter path is always used so local venv and editable
|
|
installs work without relying on a `skyvern` binary on PATH.
|
|
"""
|
|
env_block: dict[str, str] = {}
|
|
if base_url:
|
|
env_block["SKYVERN_BASE_URL"] = base_url
|
|
if api_key:
|
|
env_block["SKYVERN_API_KEY"] = api_key
|
|
if browser_type:
|
|
env_block["BROWSER_TYPE"] = browser_type
|
|
if browser_remote_debugging_url:
|
|
env_block["BROWSER_REMOTE_DEBUGGING_URL"] = browser_remote_debugging_url
|
|
|
|
_ = use_python_path
|
|
|
|
command_name = command or sys.executable
|
|
if command_name == "skyvern":
|
|
return {
|
|
"command": command_name,
|
|
"args": ["run", "mcp"],
|
|
"env": env_block,
|
|
}
|
|
|
|
return {
|
|
"command": command_name,
|
|
"args": ["-m", "skyvern", "run", "mcp"],
|
|
"env": env_block,
|
|
}
|
|
|
|
|
|
def _has_api_key(entry: dict | None) -> bool:
|
|
"""Check whether an MCP config entry carries an API key (remote, local, or mcp-remote bridge format)."""
|
|
if not entry:
|
|
return False
|
|
if entry.get("headers", {}).get("x-api-key"):
|
|
return True
|
|
if entry.get("http_headers", {}).get("x-api-key"):
|
|
return True
|
|
if entry.get("env", {}).get("SKYVERN_API_KEY"):
|
|
return True
|
|
# mcp-remote bridge: API key is in args as "--header", "x-api-key:..."
|
|
args = entry.get("args", [])
|
|
return any(isinstance(a, str) and a.startswith("x-api-key:") for a in args)
|
|
|
|
|
|
def _mask_key(key: str) -> str:
|
|
"""Mask an API key for display. Always masks, even short keys."""
|
|
if len(key) > 8:
|
|
return key[:4] + "****" + key[-4:]
|
|
if len(key) > 2:
|
|
return key[:2] + "****"
|
|
return "****"
|
|
|
|
|
|
def _mask_secrets(entry: dict) -> dict:
|
|
"""Return a copy of an MCP config entry with API keys masked for display."""
|
|
masked = copy.deepcopy(entry)
|
|
|
|
# Remote HTTP format: headers.x-api-key
|
|
if "headers" in masked and "x-api-key" in masked["headers"]:
|
|
key = masked["headers"]["x-api-key"]
|
|
masked["headers"]["x-api-key"] = _mask_key(key)
|
|
|
|
if "http_headers" in masked and "x-api-key" in masked["http_headers"]:
|
|
key = masked["http_headers"]["x-api-key"]
|
|
masked["http_headers"]["x-api-key"] = _mask_key(key)
|
|
|
|
# Local stdio format: env.SKYVERN_API_KEY
|
|
if "env" in masked and "SKYVERN_API_KEY" in masked["env"]:
|
|
key = masked["env"]["SKYVERN_API_KEY"]
|
|
masked["env"]["SKYVERN_API_KEY"] = _mask_key(key)
|
|
|
|
# mcp-remote bridge format: args contain "x-api-key:..."
|
|
if "args" in masked:
|
|
masked["args"] = [
|
|
(
|
|
"x-api-key:" + _mask_key(a[len("x-api-key:") :])
|
|
if isinstance(a, str) and a.startswith("x-api-key:")
|
|
else a
|
|
)
|
|
for a in masked["args"]
|
|
]
|
|
|
|
return masked
|
|
|
|
|
|
def _load_mcp_config(config_path: Path) -> tuple[dict | None, str | None]:
|
|
"""Load and validate an MCP config file, returning (config, error)."""
|
|
if not config_path.exists():
|
|
return {}, None
|
|
|
|
try:
|
|
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None, f"Cannot parse {config_path}. Fix the JSON and re-run."
|
|
|
|
if not isinstance(existing, dict):
|
|
return None, f"{config_path} must contain a top-level JSON object."
|
|
|
|
servers = existing.get("mcpServers")
|
|
if servers is not None and not isinstance(servers, dict):
|
|
return None, f"{config_path} has invalid `mcpServers`; expected a JSON object."
|
|
|
|
return existing, None
|
|
|
|
|
|
def _read_mcp_config(config_path: Path) -> dict:
|
|
"""Load an MCP config or exit with a user-friendly message."""
|
|
existing, error = _load_mcp_config(config_path)
|
|
if error:
|
|
console.print(f"[red]{error}[/red]")
|
|
raise typer.Exit(code=1)
|
|
return existing or {}
|
|
|
|
|
|
def _find_server_key(servers: dict[str, object], preferred: str = "skyvern") -> str | None:
|
|
"""Find an existing server key case-insensitively."""
|
|
for key in servers:
|
|
if key.lower() == preferred.lower():
|
|
return key
|
|
return None
|
|
|
|
|
|
def _backup_config_path(config_path: Path) -> Path:
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
return config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
|
|
|
|
|
def _write_mcp_config(config_path: Path, config: dict, create_backup: bool = True) -> Path | None:
|
|
"""Write an MCP config, creating a backup of the prior file when overwriting."""
|
|
backup_path: Path | None = None
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if create_backup and config_path.exists():
|
|
backup_path = _backup_config_path(config_path)
|
|
shutil.copy2(config_path, backup_path)
|
|
|
|
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
return backup_path
|
|
|
|
|
|
def _upsert_mcp_config(
|
|
config_path: Path,
|
|
tool_name: str,
|
|
skyvern_entry: dict,
|
|
server_key: str = "skyvern",
|
|
dry_run: bool = False,
|
|
yes: bool = False,
|
|
) -> None:
|
|
"""Read config, diff, prompt, and write. Idempotent."""
|
|
existing = _read_mcp_config(config_path)
|
|
servers = existing.setdefault("mcpServers", {})
|
|
resolved_server_key = _find_server_key(servers, preferred=server_key) or server_key
|
|
current = servers.get(resolved_server_key)
|
|
|
|
if current == skyvern_entry:
|
|
console.print(f"[green]Already configured for {tool_name} (no changes)[/green]")
|
|
return
|
|
|
|
# Block any attempt to overwrite an existing API key with an empty one
|
|
if _has_api_key(current) and not _has_api_key(skyvern_entry):
|
|
console.print(
|
|
"[red bold]Error:[/red bold] Existing config has an API key but the new "
|
|
"config does not. Pass --api-key or set SKYVERN_API_KEY in your environment.",
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
if current is not None:
|
|
console.print(f"[yellow]Config differs from expected for {tool_name}[/yellow]")
|
|
console.print("\n[bold]Current:[/bold]")
|
|
console.print(Syntax(json.dumps(_mask_secrets(current), indent=2), "json"))
|
|
else:
|
|
console.print(f"[bold]Adding Skyvern MCP config for {tool_name}:[/bold]")
|
|
|
|
console.print("\n[bold]New:[/bold]")
|
|
console.print(Syntax(json.dumps(_mask_secrets(skyvern_entry), indent=2), "json"))
|
|
|
|
if dry_run:
|
|
console.print(f"\n[yellow]Dry run -- no changes written to {config_path}[/yellow]")
|
|
return
|
|
|
|
if not yes:
|
|
if not typer.confirm("\nApply changes?"):
|
|
raise typer.Abort()
|
|
|
|
servers[resolved_server_key] = skyvern_entry
|
|
backup_path = _write_mcp_config(config_path, existing, create_backup=True)
|
|
console.print(f"[green]Configured {tool_name} at {config_path}[/green]")
|
|
if backup_path is not None:
|
|
console.print(f"[dim]Backup saved to {backup_path}[/dim]")
|
|
|
|
|
|
def _build_entry(
|
|
api_key: str,
|
|
base_url: str,
|
|
*,
|
|
local: bool,
|
|
use_python_path: bool,
|
|
url: str | None,
|
|
use_mcp_remote_bridge: bool = False,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> dict:
|
|
if local:
|
|
return _build_local_mcp_entry(
|
|
api_key,
|
|
base_url,
|
|
use_python_path=use_python_path,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|
|
remote_url = url or _DEFAULT_REMOTE_URL
|
|
parsed = urlparse(remote_url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
console.print(f"[red]Invalid URL: {remote_url} (must start with http:// or https://)[/red]")
|
|
raise typer.Exit(code=1)
|
|
if use_mcp_remote_bridge:
|
|
return _build_mcp_remote_bridge_entry(api_key, url=remote_url)
|
|
return _build_remote_mcp_entry(api_key, url=remote_url)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DetectedTool:
|
|
"""Describes an AI coding tool that can be auto-detected and configured."""
|
|
|
|
name: str
|
|
config_path_fn: Callable[[], Path]
|
|
is_installed_fn: Callable[[], bool]
|
|
use_mcp_remote_bridge: bool = False
|
|
|
|
|
|
def _is_claude_code_installed() -> bool:
|
|
return shutil.which("claude") is not None
|
|
|
|
|
|
def _is_cursor_installed() -> bool:
|
|
try:
|
|
return _cursor_config_path().parent.is_dir()
|
|
except typer.Exit:
|
|
return False
|
|
|
|
|
|
def _is_windsurf_installed() -> bool:
|
|
try:
|
|
return _windsurf_config_path().parent.is_dir()
|
|
except typer.Exit:
|
|
return False
|
|
|
|
|
|
def _is_claude_desktop_installed() -> bool:
|
|
try:
|
|
return _claude_desktop_config_path().parent.is_dir()
|
|
except typer.Exit:
|
|
return False
|
|
|
|
|
|
def _get_known_tools() -> list[DetectedTool]:
|
|
"""Return all known AI coding tools in detection order.
|
|
|
|
Note: Codex is not included in the guided setup flow yet.
|
|
Its config is TOML rather than the JSON shape used by the current setup commands.
|
|
"""
|
|
return [
|
|
DetectedTool(
|
|
name="Claude Code",
|
|
config_path_fn=_claude_code_default_config_path,
|
|
is_installed_fn=_is_claude_code_installed,
|
|
),
|
|
DetectedTool(
|
|
name="Cursor",
|
|
config_path_fn=_cursor_config_path,
|
|
is_installed_fn=_is_cursor_installed,
|
|
),
|
|
DetectedTool(
|
|
name="Windsurf",
|
|
config_path_fn=_windsurf_config_path,
|
|
is_installed_fn=_is_windsurf_installed,
|
|
),
|
|
DetectedTool(
|
|
name="Claude Desktop",
|
|
config_path_fn=_claude_desktop_config_path,
|
|
is_installed_fn=_is_claude_desktop_installed,
|
|
use_mcp_remote_bridge=True,
|
|
),
|
|
]
|
|
|
|
|
|
def _detect_installed_tools() -> tuple[list[DetectedTool], list[DetectedTool]]:
|
|
"""Detect which AI coding tools are installed.
|
|
|
|
Returns (detected, not_detected) lists.
|
|
"""
|
|
detected: list[DetectedTool] = []
|
|
not_detected: list[DetectedTool] = []
|
|
for tool in _get_known_tools():
|
|
try:
|
|
if tool.is_installed_fn():
|
|
detected.append(tool)
|
|
else:
|
|
not_detected.append(tool)
|
|
except Exception:
|
|
not_detected.append(tool)
|
|
return detected, not_detected
|
|
|
|
|
|
def _acquire_api_key(api_key_flag: str | None, yes: bool) -> str:
|
|
"""Resolve an API key from flag, environment, or interactive login.
|
|
|
|
Priority: --api-key flag > env/dotenv > interactive browser login.
|
|
"""
|
|
if api_key_flag:
|
|
return api_key_flag
|
|
|
|
env_key, _ = _get_env_credentials()
|
|
if env_key:
|
|
return env_key
|
|
|
|
if yes:
|
|
console.print(
|
|
"[red bold]Error:[/red bold] No API key found. Use --api-key to provide one, or run `skyvern login` first."
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
console.print("No API key found. Opening browser to log in...")
|
|
key = run_signup()
|
|
if not key:
|
|
console.print("[red]Login did not return an API key.[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
return key
|
|
|
|
|
|
def _resolve_setup_credentials(*, api_key_flag: str | None, yes: bool, local: bool) -> tuple[str, str]:
|
|
"""Resolve credentials/base URL for local or remote MCP setup."""
|
|
if local:
|
|
env_key, env_url = _get_local_env_credentials()
|
|
resolved_key = api_key_flag or env_key
|
|
if not env_url or not resolved_key:
|
|
console.print(
|
|
"[red bold]Error:[/red bold] Local MCP setup needs SKYVERN_BASE_URL and SKYVERN_API_KEY. "
|
|
"Run `skyvern init` or `skyvern quickstart` in local mode first, or set those env vars manually."
|
|
)
|
|
raise typer.Exit(code=1)
|
|
return resolved_key, env_url
|
|
|
|
resolved_key = _acquire_api_key(api_key_flag, yes)
|
|
_, env_url = _get_env_credentials()
|
|
return resolved_key, env_url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config path resolvers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _claude_desktop_config_path() -> Path:
|
|
system = detect_os()
|
|
if system == "wsl":
|
|
roaming_path = get_windows_appdata_roaming()
|
|
if roaming_path is None:
|
|
console.print("[red]Could not locate Windows AppData\\\\Roaming from WSL.[/red]")
|
|
raise typer.Exit(code=1)
|
|
return Path(roaming_path) / "Claude" / "claude_desktop_config.json"
|
|
if system == "darwin":
|
|
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
if system == "linux":
|
|
candidates = [
|
|
Path.home() / ".config" / "Claude",
|
|
Path.home() / ".local" / "share" / "Claude",
|
|
Path.home() / "Claude",
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate / "claude_desktop_config.json"
|
|
return candidates[0] / "claude_desktop_config.json"
|
|
if system == "windows":
|
|
appdata = os.environ.get("APPDATA")
|
|
if not appdata:
|
|
console.print("[red]APPDATA environment variable not set on Windows.[/red]")
|
|
raise typer.Exit(code=1)
|
|
return Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
console.print(f"[red]Unsupported platform: {system}[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
def _wsl_windows_user_home() -> Path:
|
|
roaming_path = get_windows_appdata_roaming()
|
|
if roaming_path is None:
|
|
console.print("[red]Could not locate Windows AppData\\\\Roaming from WSL.[/red]")
|
|
raise typer.Exit(code=1)
|
|
return roaming_path.parent.parent
|
|
|
|
|
|
def _cursor_config_path() -> Path:
|
|
if detect_os() == "wsl":
|
|
return _wsl_windows_user_home() / ".cursor" / "mcp.json"
|
|
return Path.home() / ".cursor" / "mcp.json"
|
|
|
|
|
|
def _windsurf_config_path() -> Path:
|
|
if detect_os() == "wsl":
|
|
return _wsl_windows_user_home() / ".codeium" / "windsurf" / "mcp_config.json"
|
|
return Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
|
|
|
|
|
|
def _claude_code_global_config_path() -> Path:
|
|
return Path.home() / ".claude.json"
|
|
|
|
|
|
def _claude_code_project_config_path() -> Path:
|
|
return Path.cwd() / ".mcp.json"
|
|
|
|
|
|
def _codex_config_path() -> Path:
|
|
return Path.home() / ".codex" / "config.toml"
|
|
|
|
|
|
_PROJECT_MARKERS = (
|
|
".git",
|
|
".mcp.json",
|
|
"pyproject.toml",
|
|
"package.json",
|
|
"requirements.txt",
|
|
"setup.py",
|
|
"Cargo.toml",
|
|
"go.mod",
|
|
)
|
|
|
|
|
|
def _looks_like_project_dir(path: Path) -> bool:
|
|
return any((path / marker).exists() for marker in _PROJECT_MARKERS)
|
|
|
|
|
|
def _claude_code_config_target(
|
|
*,
|
|
cwd: Path | None = None,
|
|
project: bool = False,
|
|
global_config: bool = False,
|
|
) -> tuple[Path, bool]:
|
|
"""Resolve Claude Code config path and whether project-local skills should be installed."""
|
|
if project and global_config:
|
|
console.print("[red]Choose only one of --project or --global.[/red]")
|
|
raise typer.Exit(code=1)
|
|
|
|
working_dir = cwd or Path.cwd()
|
|
in_project = _looks_like_project_dir(working_dir)
|
|
|
|
if project:
|
|
return working_dir / ".mcp.json", True
|
|
if global_config:
|
|
return _claude_code_global_config_path(), in_project
|
|
if in_project:
|
|
return working_dir / ".mcp.json", True
|
|
return _claude_code_global_config_path(), False
|
|
|
|
|
|
def _claude_code_default_config_path() -> Path:
|
|
return _claude_code_config_target()[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared options
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_api_key_opt = typer.Option(None, "--api-key", "-k", help="Skyvern API key (reads from env if omitted)")
|
|
_dry_run_opt = typer.Option(False, "--dry-run", help="Show changes without writing")
|
|
_yes_opt = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt")
|
|
_local_opt = typer.Option(False, "--local", help="Use local stdio transport instead of remote HTTPS")
|
|
_python_path_opt = typer.Option(
|
|
False,
|
|
"--use-python-path",
|
|
help="Deprecated compatibility flag. Local stdio setup already uses the active Python interpreter path.",
|
|
)
|
|
_url_opt = typer.Option(None, "--url", help="Remote MCP endpoint URL (default: https://api.skyvern.com/mcp/)")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared command body
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_setup(
|
|
tool_name: str,
|
|
config_path: Path,
|
|
api_key: str | None,
|
|
dry_run: bool,
|
|
yes: bool,
|
|
local: bool,
|
|
use_python_path: bool,
|
|
url: str | None,
|
|
*,
|
|
use_mcp_remote_bridge: bool = False,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> None:
|
|
if tool_name == "Claude Desktop" and not local and use_mcp_remote_bridge and not _has_node_runtime():
|
|
console.print(f"[yellow]{_claude_desktop_bundle_message()}[/yellow]")
|
|
raise typer.Exit(code=1)
|
|
|
|
resolved_key, env_url = _resolve_setup_credentials(api_key_flag=api_key, yes=yes, local=local)
|
|
entry = _build_entry(
|
|
resolved_key,
|
|
env_url,
|
|
local=local,
|
|
use_python_path=use_python_path,
|
|
url=url,
|
|
use_mcp_remote_bridge=use_mcp_remote_bridge,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|
|
_upsert_mcp_config(config_path, tool_name, entry, dry_run=dry_run, yes=yes)
|
|
|
|
|
|
def _install_skills(project_dir: Path, dry_run: bool = False) -> None:
|
|
"""Install bundled skills into a project's .claude/skills/ directory.
|
|
|
|
Skips skills that already exist at the destination (non-destructive).
|
|
"""
|
|
skills_dst = project_dir / ".claude" / "skills"
|
|
dirs = get_skill_dirs()
|
|
if not dirs:
|
|
return
|
|
|
|
installed: list[str] = []
|
|
skipped: list[str] = []
|
|
failed: list[str] = []
|
|
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
|
for d in dirs:
|
|
target = skills_dst / d.name
|
|
if target.exists():
|
|
skipped.append(d.name)
|
|
continue
|
|
if not dry_run:
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copytree(d, target, ignore=ignore)
|
|
except OSError as e:
|
|
console.print(f"[yellow]Warning: failed to install skill '{d.name}': {e}[/yellow]")
|
|
failed.append(d.name)
|
|
continue
|
|
installed.append(d.name)
|
|
|
|
if installed:
|
|
names = ", ".join(installed)
|
|
if dry_run:
|
|
console.print(f"\n[yellow]Dry run — would install skills: {names}[/yellow]")
|
|
else:
|
|
console.print(f"\n[green]Installed skills to {skills_dst}: {names}[/green]")
|
|
if "qa" in installed:
|
|
console.print("[bold]Tip:[/bold] Make a frontend change and type /qa to test it in a real browser.")
|
|
if skipped:
|
|
console.print(f"[dim]Skills already installed: {', '.join(skipped)}[/dim]")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guided quickstart (bare `skyvern setup`)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@setup_app.callback(invoke_without_command=True)
|
|
def setup_guided(
|
|
ctx: typer.Context,
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
) -> None:
|
|
"""Guided quickstart: detect installed AI tools and configure MCP for all of them."""
|
|
if ctx.invoked_subcommand is not None:
|
|
return
|
|
|
|
console.print(
|
|
Panel(
|
|
"[bold]Skyvern MCP Setup[/bold]\n\n"
|
|
"This wizard will:\n"
|
|
" 1. Find or create your Skyvern API key\n"
|
|
" 2. Detect installed AI coding tools\n"
|
|
" 3. Configure MCP for each detected tool",
|
|
border_style="blue",
|
|
)
|
|
)
|
|
|
|
# Step 1: Credentials
|
|
console.print("[bold]Step 1: Credentials[/bold]")
|
|
resolved_key, env_url = _resolve_setup_credentials(api_key_flag=api_key, yes=yes, local=local)
|
|
if local:
|
|
console.print("[green]Local SKYVERN_BASE_URL and SKYVERN_API_KEY ready.[/green]\n")
|
|
else:
|
|
console.print("[green]API key ready.[/green]\n")
|
|
capture_setup_event("quickstart-api-key", success=True, extra_data={"local": local})
|
|
|
|
# Step 2: Detect tools
|
|
console.print("[bold]Step 2: Detecting installed AI tools...[/bold]")
|
|
detected, not_detected = _detect_installed_tools()
|
|
|
|
if detected:
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("Tool")
|
|
table.add_column("Config Path")
|
|
table.add_column("Status")
|
|
|
|
for tool in detected:
|
|
try:
|
|
path = str(tool.config_path_fn())
|
|
except (typer.Exit, SystemExit):
|
|
path = "?"
|
|
table.add_row(tool.name, path, "[green]Detected[/green]")
|
|
|
|
console.print(table)
|
|
|
|
if not_detected:
|
|
names = ", ".join(t.name for t in not_detected)
|
|
console.print(f"Not detected: {names}\n")
|
|
else:
|
|
console.print()
|
|
|
|
capture_setup_event(
|
|
"quickstart-detect",
|
|
success=True,
|
|
extra_data={
|
|
"detected": [t.name for t in detected],
|
|
"not_detected": [t.name for t in not_detected],
|
|
},
|
|
)
|
|
|
|
if not detected:
|
|
console.print(
|
|
"[yellow]No supported AI tools detected.[/yellow]\n"
|
|
"You can configure a specific tool manually:\n"
|
|
" skyvern setup claude-code\n"
|
|
" skyvern setup cursor\n"
|
|
" skyvern setup windsurf\n"
|
|
" skyvern setup claude"
|
|
)
|
|
capture_setup_event("quickstart-no-tools", success=True)
|
|
return
|
|
|
|
# Step 3: Configure detected tools
|
|
tool_names = ", ".join(t.name for t in detected)
|
|
console.print(f"[bold]Step 3: Configuring {len(detected)} tool(s)...[/bold]")
|
|
|
|
if not yes and not dry_run:
|
|
if not typer.confirm(f"Configure Skyvern MCP for: {tool_names}?", default=True):
|
|
console.print("[yellow]Setup cancelled.[/yellow]")
|
|
raise typer.Abort()
|
|
|
|
configured: list[str] = []
|
|
failed: list[str] = []
|
|
bundle_recommended: list[str] = []
|
|
|
|
for tool in detected:
|
|
try:
|
|
config_path = tool.config_path_fn()
|
|
use_bridge = tool.use_mcp_remote_bridge and not local
|
|
if tool.name == "Claude Desktop" and use_bridge and not _has_node_runtime():
|
|
bundle_recommended.append(tool.name)
|
|
console.print(
|
|
f"[yellow]Skipping Claude Desktop JSON setup.[/yellow] {_claude_desktop_bundle_message()}"
|
|
)
|
|
continue
|
|
# Pass browser config from env for local mode
|
|
env_browser_type = os.environ.get("BROWSER_TYPE") if local else None
|
|
env_browser_url = os.environ.get("BROWSER_REMOTE_DEBUGGING_URL") if local else None
|
|
entry = _build_entry(
|
|
resolved_key,
|
|
env_url,
|
|
local=local,
|
|
use_python_path=use_python_path,
|
|
url=url,
|
|
use_mcp_remote_bridge=use_bridge,
|
|
browser_type=env_browser_type,
|
|
browser_remote_debugging_url=env_browser_url,
|
|
)
|
|
_upsert_mcp_config(config_path, tool.name, entry, dry_run=dry_run, yes=True)
|
|
if tool.name == "Claude Code":
|
|
_, install_skills = _claude_code_config_target()
|
|
if install_skills:
|
|
_install_skills(Path.cwd(), dry_run=dry_run)
|
|
configured.append(tool.name)
|
|
except (typer.Exit, SystemExit):
|
|
failed.append(tool.name)
|
|
console.print(f"[red]Failed to configure {tool.name}[/red]")
|
|
except Exception as exc:
|
|
failed.append(tool.name)
|
|
console.print(f"[red]Failed to configure {tool.name}: {exc}[/red]")
|
|
|
|
console.print()
|
|
|
|
if configured:
|
|
configured_str = ", ".join(configured)
|
|
console.print(
|
|
Panel(
|
|
f"[bold green]Setup complete![/bold green]\n\n"
|
|
f"Configured {len(configured)} tool(s): {configured_str}\n\n"
|
|
f'Try asking your AI assistant:\n"Use Skyvern to navigate to example.com"',
|
|
border_style="green",
|
|
)
|
|
)
|
|
if bundle_recommended:
|
|
console.print(f"[yellow]Claude Desktop:[/yellow] {_claude_desktop_bundle_message()}")
|
|
capture_setup_event(
|
|
"quickstart-complete",
|
|
success=True,
|
|
extra_data={"configured": configured, "failed": failed, "bundle_recommended": bundle_recommended},
|
|
)
|
|
else:
|
|
if bundle_recommended and not failed:
|
|
console.print(
|
|
Panel(
|
|
f"[bold yellow]Claude Desktop detected[/bold yellow]\n\n{_claude_desktop_bundle_message()}",
|
|
border_style="yellow",
|
|
)
|
|
)
|
|
else:
|
|
console.print("[red]No tools were configured.[/red]")
|
|
capture_setup_event(
|
|
"quickstart-complete",
|
|
success=not failed and bool(bundle_recommended),
|
|
error_type="all_tools_failed" if failed else None,
|
|
extra_data={"failed": failed, "bundle_recommended": bundle_recommended},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@setup_app.command("claude")
|
|
def setup_claude(
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> None:
|
|
"""Register Skyvern MCP with Claude Desktop (remote mode requires Node.js; bundle is recommended otherwise)."""
|
|
_run_setup(
|
|
"Claude Desktop",
|
|
_claude_desktop_config_path(),
|
|
api_key,
|
|
dry_run,
|
|
yes,
|
|
local,
|
|
use_python_path,
|
|
url,
|
|
use_mcp_remote_bridge=not local,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|
|
|
|
|
|
@setup_app.command("claude-desktop", hidden=True)
|
|
def setup_claude_desktop_alias(
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
) -> None:
|
|
"""Backward-compatible alias for `skyvern setup claude`."""
|
|
setup_claude(
|
|
api_key, dry_run, yes, local, use_python_path, url, browser_type=None, browser_remote_debugging_url=None
|
|
)
|
|
|
|
|
|
@setup_app.command("claude-code")
|
|
def setup_claude_code(
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
project: bool = typer.Option(
|
|
False, "--project", help="Write Claude Code MCP config to .mcp.json in the current directory"
|
|
),
|
|
global_config: bool = typer.Option(
|
|
False,
|
|
"--global",
|
|
help="Write Claude Code MCP config to ~/.claude.json even if the current directory is a project",
|
|
),
|
|
skip_skills: bool = typer.Option(False, "--skip-skills", help="Don't install Claude Code skills (e.g. /qa)"),
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> None:
|
|
"""Register Skyvern MCP with Claude Code and install skills (remote by default)."""
|
|
config_path, install_skills = _claude_code_config_target(project=project, global_config=global_config)
|
|
if not project and not global_config:
|
|
target_label = ".mcp.json in the current project" if install_skills else "~/.claude.json"
|
|
console.print(f"[dim]Claude Code target: {target_label}[/dim]")
|
|
_run_setup(
|
|
"Claude Code",
|
|
config_path,
|
|
api_key,
|
|
dry_run,
|
|
yes,
|
|
local,
|
|
use_python_path,
|
|
url,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|
|
|
|
if not skip_skills:
|
|
if install_skills:
|
|
_install_skills(Path.cwd(), dry_run=dry_run)
|
|
else:
|
|
console.print(
|
|
"[dim]Skipping Claude Code skill installation because the current directory does not look like a project. "
|
|
"Re-run inside your repo or pass --project to install /qa and other bundled skills locally.[/dim]"
|
|
)
|
|
|
|
|
|
@setup_app.command("cursor")
|
|
def setup_cursor(
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> None:
|
|
"""Register Skyvern MCP with Cursor (remote by default)."""
|
|
_run_setup(
|
|
"Cursor",
|
|
_cursor_config_path(),
|
|
api_key,
|
|
dry_run,
|
|
yes,
|
|
local,
|
|
use_python_path,
|
|
url,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|
|
|
|
|
|
@setup_app.command("windsurf")
|
|
def setup_windsurf(
|
|
api_key: str | None = _api_key_opt,
|
|
dry_run: bool = _dry_run_opt,
|
|
yes: bool = _yes_opt,
|
|
local: bool = _local_opt,
|
|
use_python_path: bool = _python_path_opt,
|
|
url: str | None = _url_opt,
|
|
browser_type: str | None = None,
|
|
browser_remote_debugging_url: str | None = None,
|
|
) -> None:
|
|
"""Register Skyvern MCP with Windsurf (remote by default)."""
|
|
_run_setup(
|
|
"Windsurf",
|
|
_windsurf_config_path(),
|
|
api_key,
|
|
dry_run,
|
|
yes,
|
|
local,
|
|
use_python_path,
|
|
url,
|
|
browser_type=browser_type,
|
|
browser_remote_debugging_url=browser_remote_debugging_url,
|
|
)
|