Skyvern/skyvern/cli/setup_commands.py
Suchintan 0afc59348e
Streamline CLI quickstart: browser setup, LLM models, Ollama support (#5214)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
2026-03-23 23:42:52 -04:00

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,
)