291 lines
9.8 KiB
Python
291 lines
9.8 KiB
Python
|
|
"""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)
|