Skyvern/skyvern/cli/commands.py

434 lines
16 KiB
Python

import json
import os
import shutil
import subprocess
import time
from typing import Optional
import typer
from click import Choice
from dotenv import load_dotenv
from skyvern.utils import detect_os, get_windows_appdata_roaming, migrate_db
load_dotenv()
app = typer.Typer()
run_app = typer.Typer()
app.add_typer(run_app, name="run")
def command_exists(command: str) -> bool:
return shutil.which(command) is not None
def run_command(command: str, check: bool = True) -> tuple[Optional[str], Optional[int]]:
try:
result = subprocess.run(command, shell=True, check=check, capture_output=True, text=True)
return result.stdout.strip(), result.returncode
except subprocess.CalledProcessError as e:
return None, e.returncode
def is_postgres_running() -> bool:
if command_exists("pg_isready"):
result, _ = run_command("pg_isready")
return result is not None and "accepting connections" in result
return False
def database_exists(dbname: str, user: str) -> bool:
check_db_command = f'psql {dbname} -U {user} -c "\\q"'
output, _ = run_command(check_db_command, check=False)
return output is not None
def create_database_and_user() -> None:
print("Creating database user and database...")
run_command("createuser skyvern")
run_command("createdb skyvern -O skyvern")
print("Database and user created successfully.")
def is_docker_running() -> bool:
if not command_exists("docker"):
return False
_, code = run_command("docker info", check=False)
return code == 0
def is_postgres_running_in_docker() -> bool:
_, code = run_command("docker ps | grep -q postgresql-container", check=False)
return code == 0
def is_postgres_container_exists() -> bool:
_, code = run_command("docker ps -a | grep -q postgresql-container", check=False)
return code == 0
def setup_postgresql() -> None:
print("Setting up PostgreSQL...")
if command_exists("psql") and is_postgres_running():
print("PostgreSQL is already running locally.")
if database_exists("skyvern", "skyvern"):
print("Database and user exist.")
else:
create_database_and_user()
return
if not is_docker_running():
print("Docker is not running or not installed. Please install or start Docker and try again.")
exit(1)
if is_postgres_running_in_docker():
print("PostgreSQL is already running in a Docker container.")
else:
print("Attempting to install PostgreSQL via Docker...")
if not is_postgres_container_exists():
run_command(
"docker run --name postgresql-container -e POSTGRES_HOST_AUTH_METHOD=trust -d -p 5432:5432 postgres:14"
)
else:
run_command("docker start postgresql-container")
print("PostgreSQL has been installed and started using Docker.")
print("Waiting for PostgreSQL to start...")
time.sleep(20)
_, code = run_command('docker exec postgresql-container psql -U postgres -c "\\du" | grep -q skyvern', check=False)
if code == 0:
print("Database user exists.")
else:
print("Creating database user...")
run_command("docker exec postgresql-container createuser -U postgres skyvern")
_, code = run_command(
"docker exec postgresql-container psql -U postgres -lqt | cut -d \\| -f 1 | grep -qw skyvern", check=False
)
if code == 0:
print("Database exists.")
else:
print("Creating database...")
run_command("docker exec postgresql-container createdb -U postgres skyvern -O skyvern")
print("Database and user created successfully.")
@app.command(name="init")
def init(
openai_api_key: str = typer.Option(..., help="The OpenAI API key"),
log_level: str = typer.Option("CRITICAL", help="The log level"),
) -> None:
setup_postgresql()
# Generate .env file
with open(".env", "w") as env_file:
env_file.write("ENABLE_OPENAI=true\n")
env_file.write(f"OPENAI_API_KEY={openai_api_key}\n")
env_file.write(f"LOG_LEVEL={log_level}\n")
env_file.write("ARTIFACT_STORAGE_PATH=./artifacts\n")
print(".env file created with the parameters provided.")
@app.command(name="migrate")
def migrate() -> None:
migrate_db()
def get_claude_config_path(host_system: str) -> str:
"""Get the Claude Desktop config file path for the current OS."""
if host_system == "wsl":
roaming_path = get_windows_appdata_roaming()
if roaming_path is None:
raise RuntimeError("Could not locate Windows AppData\\Roaming path from WSL")
return os.path.join(str(roaming_path), "Claude", "claude_desktop_config.json")
base_paths = {
"darwin": ["~/Library/Application Support/Claude"],
"linux": ["~/.config/Claude", "~/.local/share/Claude", "~/Claude"],
}
if host_system == "darwin":
base_path = os.path.expanduser(base_paths["darwin"][0])
return os.path.join(base_path, "claude_desktop_config.json")
if host_system == "linux":
for path in base_paths["linux"]:
full_path = os.path.expanduser(path)
if os.path.exists(full_path):
return os.path.join(full_path, "claude_desktop_config.json")
raise Exception(f"Unsupported host system: {host_system}")
def get_claude_command_config(
host_system: str, path_to_env: str, path_to_server: str, env_vars: str
) -> tuple[str, list]:
"""Get the command and arguments for Claude Desktop configuration."""
base_env_vars = f"{env_vars} ENABLE_OPENAI=true LOG_LEVEL=CRITICAL"
artifacts_path = os.path.join(os.path.abspath("./"), "artifacts")
if host_system == "wsl":
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path} BROWSER_TYPE=chromium-headless"
return "wsl.exe", ["bash", "-c", f"{env_vars} {path_to_env} {path_to_server}"]
if host_system in ["linux", "darwin"]:
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path}"
return path_to_env, [path_to_server]
raise Exception(f"Unsupported host system: {host_system}")
def is_claude_desktop_installed(host_system: str) -> bool:
"""Check if Claude Desktop is installed by looking for its config directory."""
try:
config_path = os.path.dirname(get_claude_config_path(host_system))
return os.path.exists(config_path)
except Exception:
return False
def get_cursor_config_path(host_system: str) -> str:
"""Get the Cursor config file path for the current OS."""
if host_system == "wsl":
roaming_path = get_windows_appdata_roaming()
if roaming_path is None:
raise RuntimeError("Could not locate Windows AppData\\Roaming path from WSL")
return os.path.join(str(roaming_path), ".cursor", "mcp.json")
# For both darwin and linux, use ~/.cursor/mcp.json
return os.path.expanduser("~/.cursor/mcp.json")
def is_cursor_installed(host_system: str) -> bool:
"""Check if Cursor is installed by looking for its config directory."""
try:
config_dir = os.path.expanduser("~/.cursor")
return os.path.exists(config_dir)
except Exception:
return False
def setup_cursor_mcp(host_system: str, path_to_env: str, path_to_server: str, env_vars: str) -> None:
"""Set up Cursor MCP configuration."""
if not is_cursor_installed(host_system):
print("Cursor is not installed. Skipping Cursor MCP setup.")
return
try:
path_cursor_config = get_cursor_config_path(host_system)
except Exception as e:
print(f"Error setting up Cursor: {e}")
return
# Get command configuration
try:
command, args = get_claude_command_config(host_system, path_to_env, path_to_server, env_vars)
except Exception as e:
print(f"Error configuring Cursor command: {e}")
return
# Create or update Cursor config file
os.makedirs(os.path.dirname(path_cursor_config), exist_ok=True)
config = {"Skyvern": {"command": command, "args": args}}
if os.path.exists(path_cursor_config):
try:
with open(path_cursor_config, "r") as f:
existing_config = json.load(f)
existing_config.update(config)
config = existing_config
except json.JSONDecodeError:
pass # Use default config if file is corrupted
with open(path_cursor_config, "w") as f:
json.dump(config, f, indent=2)
print("Cursor MCP configuration updated successfully.")
def setup_claude_desktop(host_system: str, path_to_env: str, path_to_server: str) -> None:
"""Set up Claude Desktop configuration for Skyvern MCP."""
if not is_claude_desktop_installed(host_system):
print("Claude Desktop is not installed. Skipping MCP setup.")
return
# Get config file path
try:
path_claude_config = get_claude_config_path(host_system)
except Exception as e:
print(f"Error setting up Claude Desktop: {e}")
return
# Setup environment variables
env_vars = ""
for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Get command configuration
try:
claude_command, claude_args = get_claude_command_config(host_system, path_to_env, path_to_server, env_vars)
except Exception as e:
print(f"Error configuring Claude Desktop command: {e}")
return
# Create or update Claude config file
os.makedirs(os.path.dirname(path_claude_config), exist_ok=True)
if not os.path.exists(path_claude_config):
with open(path_claude_config, "w") as f:
json.dump({"mcpServers": {}}, f, indent=2)
with open(path_claude_config, "r") as f:
claude_config = json.load(f)
claude_config["mcpServers"].pop("Skyvern", None)
claude_config["mcpServers"]["Skyvern"] = {"command": claude_command, "args": claude_args}
with open(path_claude_config, "w") as f:
json.dump(claude_config, f, indent=2)
print("Claude Desktop configuration updated successfully.")
def get_mcp_server_url(deployment_type: str, host: str = "") -> str:
"""Get the MCP server URL based on deployment type."""
if deployment_type in ["local", "cloud"]:
return os.path.join(os.path.abspath("./skyvern/mcp"), "server.py")
else:
raise ValueError(f"Invalid deployment type: {deployment_type}")
def setup_mcp_config(host_system: str, deployment_type: str, host: str = "") -> tuple[str, str]:
"""Set up MCP configuration based on deployment type."""
if deployment_type in ["local", "cloud"]:
# For local deployment, we need Python environment
python_path = shutil.which("python")
if python_path:
path_to_env = python_path
else:
path_to_env = typer.prompt("Enter the full path to your configured python environment")
return path_to_env, get_mcp_server_url(deployment_type)
else:
raise NotImplementedError()
def get_command_config(host_system: str, command: str, target: str, env_vars: str) -> tuple[str, list]:
"""Get the command and arguments for MCP configuration."""
base_env_vars = f"{env_vars} ENABLE_OPENAI=true LOG_LEVEL=CRITICAL"
artifacts_path = os.path.join(os.path.abspath("./"), "artifacts")
if host_system == "wsl":
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path} BROWSER_TYPE=chromium-headless"
return "wsl.exe", ["bash", "-c", f"{env_vars} {command} {target}"]
if host_system in ["linux", "darwin"]:
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path}"
if target.startswith("http"):
return command, ["-X", "POST", target]
return command, [target]
raise Exception(f"Unsupported host system: {host_system}")
@run_app.command(name="mcp")
def run_mcp() -> None:
"""Configure MCP for different Skyvern deployments."""
host_system = detect_os()
# Prompt for deployment type
deployment_types = ["local", "cloud"]
deployment_type = typer.prompt("Select Skyvern deployment type", type=Choice(deployment_types), default="local")
try:
command, target = setup_mcp_config(host_system, deployment_type)
except Exception as e:
print(f"Error setting up MCP configuration: {e}")
return
# Cloud deployment variables
env_vars = ""
if deployment_type == "cloud":
for key in ["SKYVERN_MCP_CLOUD_URL", "SKYVERN_MCP_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Setup environment variables
for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Configure both Claude Desktop and Cursor
success = False
success |= setup_claude_desktop_config(host_system, command, target, env_vars)
success |= setup_cursor_config(host_system, command, target, env_vars)
if not success:
print("Neither Claude Desktop nor Cursor is installed. Please install at least one of them.")
def setup_claude_desktop_config(host_system: str, command: str, target: str, env_vars: str) -> bool:
"""Set up Claude Desktop configuration with given command and args."""
if not is_claude_desktop_installed(host_system):
return False
try:
claude_command, claude_args = get_command_config(host_system, command, target, env_vars)
path_claude_config = get_claude_config_path(host_system)
os.makedirs(os.path.dirname(path_claude_config), exist_ok=True)
if not os.path.exists(path_claude_config):
with open(path_claude_config, "w") as f:
json.dump({"mcpServers": {}}, f, indent=2)
with open(path_claude_config, "r") as f:
claude_config = json.load(f)
claude_config["mcpServers"].pop("Skyvern", None)
claude_config["mcpServers"]["Skyvern"] = {"command": claude_command, "args": claude_args}
with open(path_claude_config, "w") as f:
json.dump(claude_config, f, indent=2)
print("Claude Desktop configuration updated successfully.")
return True
except Exception as e:
print(f"Error configuring Claude Desktop: {e}")
return False
def setup_cursor_config(host_system: str, command: str, target: str, env_vars: str) -> bool:
"""Set up Cursor configuration with given command and args."""
if not is_cursor_installed(host_system):
return False
try:
cursor_command, cursor_args = get_command_config(host_system, command, target, env_vars)
path_cursor_config = get_cursor_config_path(host_system)
os.makedirs(os.path.dirname(path_cursor_config), exist_ok=True)
config = {"Skyvern": {"command": cursor_command, "args": cursor_args}}
if os.path.exists(path_cursor_config):
try:
with open(path_cursor_config, "r") as f:
existing_config = json.load(f)
existing_config.update(config)
config = existing_config
except json.JSONDecodeError:
pass
with open(path_cursor_config, "w") as f:
json.dump(config, f, indent=2)
print(f"Cursor configuration updated successfully at {path_cursor_config}")
return True
except Exception as e:
print(f"Error configuring Cursor: {e}")
return False