"""Setup commands to register Skyvern with AI coding tools.""" from __future__ import annotations import json import os import platform import sys from pathlib import Path from urllib.parse import urlparse import typer from dotenv import load_dotenv from rich.syntax import Syntax from skyvern.cli.console import console from skyvern.utils.env_paths import resolve_backend_env_path # NOTE: skyvern/cli/mcp.py has older setup_*_config() helpers called from # `skyvern init`. This module supersedes them with remote-first defaults, # dry-run support, and API key protection. The init-path helpers should be # migrated to use _upsert_mcp_config() in a follow-up. setup_app = typer.Typer(help="Register Skyvern MCP with AI coding tools.") _DEFAULT_REMOTE_URL = "https://mcp.skyvern.com/mcp" # --------------------------------------------------------------------------- # 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 _build_remote_mcp_entry(api_key: str, url: str = _DEFAULT_REMOTE_URL) -> dict: """Build a streamable-http MCP entry for remote/cloud hosting.""" entry: dict = { "type": "streamable-http", "url": url, } if api_key: entry["headers"] = {"x-api-key": api_key} return entry def _build_local_mcp_entry( api_key: str, base_url: str, use_python_path: bool = False, ) -> dict: """Build a stdio MCP entry for local self-hosted mode.""" 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 use_python_path: return { "command": sys.executable, "args": ["-m", "skyvern", "run", "mcp"], "env": env_block, } return { "command": "skyvern", "args": ["run", "mcp"], "env": env_block, } def _has_api_key(entry: dict | None) -> bool: """Check whether an MCP config entry carries an API key (remote or local format).""" if not entry: return False if entry.get("headers", {}).get("x-api-key"): return True if entry.get("env", {}).get("SKYVERN_API_KEY"): return True return False 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.""" if config_path.exists(): try: existing = json.loads(config_path.read_text(encoding="utf-8")) except json.JSONDecodeError: console.print(f"[red]Cannot parse {config_path}. Fix the JSON and re-run.[/red]") raise typer.Exit(code=1) else: existing = {} servers = existing.setdefault("mcpServers", {}) current = servers.get(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(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(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[server_key] = skyvern_entry config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") console.print(f"[green]Configured {tool_name} at {config_path}[/green]") def _build_entry( api_key: str, base_url: str, *, local: bool, use_python_path: bool, url: str | None, ) -> dict: if local: return _build_local_mcp_entry(api_key, base_url, use_python_path=use_python_path) 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) return _build_remote_mcp_entry(api_key, url=remote_url) # --------------------------------------------------------------------------- # Config path resolvers # --------------------------------------------------------------------------- def _claude_desktop_config_path() -> Path: system = platform.system() if system == "Darwin": return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" if system == "Linux": return Path.home() / ".config" / "Claude" / "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 _cursor_config_path() -> Path: return Path.home() / ".cursor" / "mcp.json" def _windsurf_config_path() -> Path: return Path.home() / ".codeium" / "windsurf" / "mcp_config.json" def _claude_code_global_config_path() -> Path: return Path.home() / ".claude.json" # --------------------------------------------------------------------------- # 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="(local only) Use python -m skyvern instead of skyvern entrypoint" ) _url_opt = typer.Option(None, "--url", help="Remote MCP endpoint URL (default: https://mcp.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, ) -> None: env_key, env_url = _get_env_credentials() key = api_key or env_key entry = _build_entry(key, env_url, local=local, use_python_path=use_python_path, url=url) _upsert_mcp_config(config_path, tool_name, entry, dry_run=dry_run, yes=yes) # --------------------------------------------------------------------------- # 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, ) -> None: """Register Skyvern MCP with Claude Desktop (remote by default).""" _run_setup("Claude Desktop", _claude_desktop_config_path(), api_key, dry_run, yes, local, use_python_path, url) @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 to .mcp.json in current dir instead of global config"), ) -> None: """Register Skyvern MCP with Claude Code (remote by default).""" config_path = Path.cwd() / ".mcp.json" if project else _claude_code_global_config_path() _run_setup("Claude Code", config_path, api_key, dry_run, yes, local, use_python_path, url) @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, ) -> 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) @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, ) -> 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)