Files
Dorod-Sky/skyvern/cli/setup_commands.py

291 lines
9.8 KiB
Python
Raw Normal View History

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