CLI - skyvern run code (#3738)

This commit is contained in:
Shuchang Zheng
2025-10-16 10:53:55 -07:00
committed by GitHub
parent 8b93cdf5ad
commit 88bd516f88
4 changed files with 228 additions and 17 deletions

View File

@@ -1,8 +1,10 @@
import asyncio import asyncio
import json
import logging
import os import os
import shutil import shutil
import subprocess import subprocess
from typing import Any, List from typing import Any, List, Optional
import psutil import psutil
import typer import typer
@@ -12,14 +14,16 @@ from mcp.server.fastmcp import FastMCP
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import Confirm from rich.prompt import Confirm
from skyvern.cli.console import console
from skyvern.cli.utils import start_services from skyvern.cli.utils import start_services
from skyvern.config import settings from skyvern.config import settings
from skyvern.forge.sdk.core import skyvern_context
from skyvern.forge.sdk.forge_log import setup_logger
from skyvern.library.skyvern import Skyvern from skyvern.library.skyvern import Skyvern
from skyvern.services.script_service import run_script
from skyvern.utils import detect_os from skyvern.utils import detect_os
from skyvern.utils.env_paths import resolve_backend_env_path, resolve_frontend_env_path from skyvern.utils.env_paths import resolve_backend_env_path, resolve_frontend_env_path
from .console import console
run_app = typer.Typer(help="Commands to run Skyvern services such as the API server or UI.") run_app = typer.Typer(help="Commands to run Skyvern services such as the API server or UI.")
mcp = FastMCP("Skyvern") mcp = FastMCP("Skyvern")
@@ -163,3 +167,135 @@ def run_mcp() -> None:
# This breaks the MCP processing because it expects json output only # This breaks the MCP processing because it expects json output only
# console.print(Panel("[bold green]Starting MCP Server...[/bold green]", border_style="green")) # console.print(Panel("[bold green]Starting MCP Server...[/bold green]", border_style="green"))
mcp.run(transport="stdio") mcp.run(transport="stdio")
@run_app.command(
name="code",
context_settings={"allow_interspersed_args": False},
)
def run_code(
script_path: str = typer.Argument(..., help="Path to the Python script to run"),
params: List[str] = typer.Option([], "-p", help="Parameters in format param=value (without leading dash)"),
params_json: str = typer.Option(None, "--params", help="JSON string of parameters"),
params_file: str = typer.Option(None, "--params-file", help="Path to JSON file with parameters"),
ai: Optional[str] = typer.Option(
"fallback", "--ai", help="AI mode to use for the script. Options: fallback, proactive or None"
),
) -> None:
"""Run a Python script with parameters.
Supports three ways to pass parameters (in order of priority):
1. JSON file (highest priority):
skyvern run code main.py --params-file params.json
2. JSON string:
skyvern run code main.py --params '{"param1": "val1", "param2": "val2"}'
3. Individual flags (lowest priority):
skyvern run code main.py -p param1=val1 -p param2=val2
Note: For backward compatibility, leading dashes in -p values are automatically stripped.
"""
# Disable LiteLLM loggers
os.environ["LITELLM_LOG"] = "CRITICAL"
import litellm # noqa: PLC0415
litellm.suppress_debug_info = True
litellm.set_verbose = False
logging.getLogger("LiteLLM").setLevel(logging.CRITICAL)
logging.getLogger("LiteLLM Router").setLevel(logging.CRITICAL)
logging.getLogger("LiteLLM Proxy").setLevel(logging.CRITICAL)
settings.LOG_LEVEL = "CRITICAL"
setup_logger()
# Validate script path
if not script_path:
console.print("[red]❌ Error: No script path provided[/red]")
console.print("[yellow]→ Action: Provide a path to your Python script[/yellow]")
console.print("[blue]Example: skyvern run code main.py -p param1=value1[/blue]")
raise typer.Exit(code=1)
if not os.path.exists(script_path):
console.print("[red]❌ Error: Cannot find script file[/red]")
console.print(f"[yellow]→ Looked for: {script_path}[/yellow]")
console.print("[yellow]→ Action: Check that the file exists and the path is correct[/yellow]")
# Show current directory to help user understand relative paths
console.print(f"[blue]Current directory: {os.getcwd()}[/blue]")
raise typer.Exit(code=1)
if not script_path.endswith(".py"):
console.print("[red]❌ Error: Invalid file type[/red]")
console.print(f"[yellow]→ Provided: {script_path}[/yellow]")
console.print("[yellow]→ Action: Please provide a Python script file ending with .py[/yellow]")
console.print("[blue]Example: skyvern run code my_script.py[/blue]")
raise typer.Exit(code=1)
parameters = {}
# Priority: params_file > params_json > individual -p flags
if params_file:
try:
with open(params_file) as f:
parameters = json.load(f)
console.print(f"[blue]✓ Loaded parameters from file: {params_file}[/blue]")
except FileNotFoundError:
console.print("[red]❌ Error: Cannot find parameters file[/red]")
console.print(f"[yellow]→ Looked for: {params_file}[/yellow]")
console.print("[yellow]→ Action: Check that the file exists and the path is correct[/yellow]")
console.print(f"[blue]Current directory: {os.getcwd()}[/blue]")
raise typer.Exit(code=1)
except json.JSONDecodeError as e:
console.print("[red]❌ Error: Invalid JSON format in parameters file[/red]")
console.print(f"[yellow]→ File: {params_file}[/yellow]")
console.print(f"[yellow]→ Details: {e}[/yellow]")
console.print("[yellow]→ Action: Fix the JSON syntax in your parameters file[/yellow]")
console.print('[blue]Expected format: {{"param1": "value1", "param2": "value2"}}[/blue]')
raise typer.Exit(code=1)
elif params_json:
try:
parameters = json.loads(params_json)
console.print("[blue]✓ Loaded parameters from JSON string[/blue]")
except json.JSONDecodeError as e:
console.print("[red]❌ Error: Invalid JSON format in --params string[/red]")
console.print(f"[yellow]→ Details: {e}[/yellow]")
console.print("[yellow]→ Action: Check your JSON syntax (quotes, brackets, commas)[/yellow]")
console.print('[blue]Example: --params \'{{"param1": "value1", "param2": "value2"}}\'[/blue]')
raise typer.Exit(code=1)
elif params:
for param in params:
# Remove leading dash if present (for backward compatibility)
if param.startswith("-"):
param = param[1:]
if "=" in param:
key, value = param.split("=", 1)
parameters[key] = value
else:
console.print("[yellow]⚠️ Warning: Skipping invalid parameter format[/yellow]")
console.print(f"[yellow]→ Invalid: {param}[/yellow]")
console.print("[yellow]→ Expected format: -p param=value[/yellow]")
console.print("[blue]Example: -p download_start_date=31/07/2025[/blue]")
console.print("[blue]✓ Loaded parameters from command-line flags[/blue]")
console.print(Panel(f"[bold green]Running script: {script_path}[/bold green]", border_style="green"))
if parameters:
console.print("[blue]📋 Parameters:[/blue]")
console.print(f"[blue]{json.dumps(parameters, indent=2)}[/blue]")
else:
console.print("[blue] Running script without parameters[/blue]")
console.print("[dim]Tip: Add parameters with -p, --params, or --params-file[/dim]")
# set up skyvern context
skyvern_context.set(skyvern_context.SkyvernContext(script_mode=True, ai_mode_override=ai))
try:
asyncio.run(run_script(path=script_path, parameters=parameters))
console.print("✅ [green]Script execution completed successfully![/green]")
except Exception as e:
console.print("[red]❌ Error: Script execution failed[/red]")
console.print(f"[yellow]→ Script: {script_path}[/yellow]")
console.print(f"[yellow]→ Details: {e}[/yellow]")
console.print("[yellow]→ Action: Check the error message above and fix any issues in your script[/yellow]")
raise typer.Exit(code=1)

View File

@@ -233,6 +233,21 @@ class SkyvernPage:
and screenshot artifacts after action execution. and screenshot artifacts after action execution.
""" """
# Emoji mapping for different action types
ACTION_EMOJIS = {
ActionType.CLICK: "👆",
ActionType.INPUT_TEXT: "⌨️",
ActionType.UPLOAD_FILE: "📤",
ActionType.DOWNLOAD_FILE: "📥",
ActionType.SELECT_OPTION: "🎯",
ActionType.WAIT: "",
ActionType.SOLVE_CAPTCHA: "🔓",
ActionType.VERIFICATION_CODE: "🔐",
ActionType.SCROLL: "📜",
ActionType.COMPLETE: "",
ActionType.TERMINATE: "🛑",
}
def decorator(fn: Callable) -> Callable: def decorator(fn: Callable) -> Callable:
async def wrapper( async def wrapper(
skyvern_page: SkyvernPage, skyvern_page: SkyvernPage,
@@ -246,6 +261,17 @@ class SkyvernPage:
action_status = ActionStatus.completed action_status = ActionStatus.completed
# Print action in script mode
context = skyvern_context.current()
if context and context.script_mode:
emoji = ACTION_EMOJIS.get(action, "🔧")
action_name = action.value if hasattr(action, "value") else str(action)
print(f"{emoji} {action_name.replace('_', ' ').title()}", end="")
if intention:
print(f": {intention}")
else:
print()
try: try:
call.result = await fn( call.result = await fn(
skyvern_page, *args, intention=intention, data=data, **kwargs skyvern_page, *args, intention=intention, data=data, **kwargs
@@ -253,12 +279,20 @@ class SkyvernPage:
# Note: Action status would be updated to completed here if update method existed # Note: Action status would be updated to completed here if update method existed
# Print success in script mode
if context and context.script_mode:
print(" ✓ Completed")
return call.result return call.result
except Exception as e: except Exception as e:
call.error = e call.error = e
action_status = ActionStatus.failed action_status = ActionStatus.failed
# Note: Action status would be updated to failed here if update method existed # Note: Action status would be updated to failed here if update method existed
# Print failure in script mode
if context and context.script_mode:
print(f" ✗ Failed: {str(e)}")
# LLM fallback hook could go here ... # LLM fallback hook could go here ...
raise raise
finally: finally:
@@ -282,11 +316,20 @@ class SkyvernPage:
async def goto(self, url: str, timeout: float = settings.BROWSER_LOADING_TIMEOUT_MS) -> None: async def goto(self, url: str, timeout: float = settings.BROWSER_LOADING_TIMEOUT_MS) -> None:
url = render_template(url) url = render_template(url)
# Print navigation in script mode
context = skyvern_context.current()
if context and context.script_mode:
print(f"🌐 Navigating to: {url}")
await self.page.goto( await self.page.goto(
url, url,
timeout=timeout, timeout=timeout,
) )
if context and context.script_mode:
print(" ✓ Page loaded")
async def _update_action_reasoning( async def _update_action_reasoning(
self, self,
action_id: str, action_id: str,
@@ -548,6 +591,9 @@ class SkyvernPage:
If the prompt generation or parsing fails for any reason we fall back to If the prompt generation or parsing fails for any reason we fall back to
clicking the originally supplied ``selector``. clicking the originally supplied ``selector``.
""" """
context = skyvern_context.current()
if context and context.ai_mode_override:
ai = context.ai_mode_override
if ai == "fallback": if ai == "fallback":
# try to click the element with the original selector first # try to click the element with the original selector first
error_to_raise = None error_to_raise = None
@@ -739,12 +785,16 @@ class SkyvernPage:
If the prompt generation or parsing fails for any reason we fall back to If the prompt generation or parsing fails for any reason we fall back to
inputting the originally supplied ``text``. inputting the originally supplied ``text``.
""" """
context = skyvern_context.current()
if context and context.ai_mode_override:
ai = context.ai_mode_override
# format the text with the actual value of the parameter if it's a secret when running a workflow # format the text with the actual value of the parameter if it's a secret when running a workflow
if ai == "fallback": if ai == "fallback":
error_to_raise = None error_to_raise = None
try: try:
locator = self.page.locator(selector) locator = self.page.locator(selector)
await handler_utils.input_sequentially(locator, value, timeout=timeout) await handler_utils.input_sequentially(locator, value, timeout=timeout)
return value
except Exception as e: except Exception as e:
error_to_raise = e error_to_raise = e
@@ -820,6 +870,9 @@ class SkyvernPage:
data: str | dict[str, Any] | None = None, data: str | dict[str, Any] | None = None,
timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS, timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS,
) -> str: ) -> str:
context = skyvern_context.current()
if context and context.ai_mode_override:
ai = context.ai_mode_override
if ai == "fallback": if ai == "fallback":
error_to_raise = None error_to_raise = None
try: try:
@@ -927,12 +980,16 @@ class SkyvernPage:
data: str | dict[str, Any] | None = None, data: str | dict[str, Any] | None = None,
timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS, timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS,
) -> str: ) -> str:
context = skyvern_context.current()
if context and context.ai_mode_override:
ai = context.ai_mode_override
value = value or "" value = value or ""
if ai == "fallback": if ai == "fallback":
error_to_raise = None error_to_raise = None
try: try:
locator = self.page.locator(selector) locator = self.page.locator(selector)
await locator.select_option(value, timeout=timeout) await locator.select_option(value, timeout=timeout)
return value
except Exception as e: except Exception as e:
error_to_raise = e error_to_raise = e
if intention: if intention:
@@ -1070,6 +1127,18 @@ class SkyvernPage:
screenshots=scraped_page_refreshed.screenshots, screenshots=scraped_page_refreshed.screenshots,
prompt_name="extract-information", prompt_name="extract-information",
) )
if context and context.script_mode:
print(f"\n✨ 📊 Extracted Information:\n{'-' * 50}")
try:
# Pretty print JSON if result is a dict/list
if isinstance(result, (dict, list)):
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(result)
except Exception:
print(result)
print(f"{'-' * 50}\n")
return result return result
@action_wrap(ActionType.VERIFICATION_CODE) @action_wrap(ActionType.VERIFICATION_CODE)

View File

@@ -44,6 +44,8 @@ class SkyvernContext:
loop_metadata: dict[str, Any] | None = None loop_metadata: dict[str, Any] | None = None
loop_output_values: list[dict[str, Any]] | None = None loop_output_values: list[dict[str, Any]] | None = None
script_run_parameters: dict[str, Any] = field(default_factory=dict) script_run_parameters: dict[str, Any] = field(default_factory=dict)
script_mode: bool = False
ai_mode_override: str | None = None
""" """
Example output value: Example output value:
{"loop_value": "str", "output_parameter": "the key of the parameter", "output_value": Any} {"loop_value": "str", "output_parameter": "the key of the parameter", "output_value": Any}

View File

@@ -1954,20 +1954,24 @@ async def goto(
label: str | None = None, label: str | None = None,
parameters: list[str] | None = None, parameters: list[str] | None = None,
) -> None: ) -> None:
block_validation_output = await _validate_and_get_output_parameter(label, parameters) try:
url = _render_template_with_label(url, label) block_validation_output = await _validate_and_get_output_parameter(label, parameters)
goto_url_block = UrlBlock( url = _render_template_with_label(url, label)
url=url, goto_url_block = UrlBlock(
label=block_validation_output.label, url=url,
output_parameter=block_validation_output.output_parameter, label=block_validation_output.label,
parameters=block_validation_output.input_parameters, output_parameter=block_validation_output.output_parameter,
) parameters=block_validation_output.input_parameters,
await goto_url_block.execute_safe( )
workflow_run_id=block_validation_output.workflow_run_id, await goto_url_block.execute_safe(
parent_workflow_run_block_id=block_validation_output.context.parent_workflow_run_block_id, workflow_run_id=block_validation_output.workflow_run_id,
organization_id=block_validation_output.organization_id, parent_workflow_run_block_id=block_validation_output.context.parent_workflow_run_block_id,
browser_session_id=block_validation_output.browser_session_id, organization_id=block_validation_output.organization_id,
) browser_session_id=block_validation_output.browser_session_id,
)
except Exception:
run_context = script_run_context_manager.ensure_run_context()
await run_context.page.goto(url)
async def prompt( async def prompt(