From 88bd516f88b845bd27723d4e009a13b73a54d703 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Thu, 16 Oct 2025 10:53:55 -0700 Subject: [PATCH] CLI - skyvern run code (#3738) --- skyvern/cli/run_commands.py | 142 +++++++++++++++++- .../core/script_generations/skyvern_page.py | 69 +++++++++ skyvern/forge/sdk/core/skyvern_context.py | 2 + skyvern/services/script_service.py | 32 ++-- 4 files changed, 228 insertions(+), 17 deletions(-) diff --git a/skyvern/cli/run_commands.py b/skyvern/cli/run_commands.py index b6d74f29..7196cefe 100644 --- a/skyvern/cli/run_commands.py +++ b/skyvern/cli/run_commands.py @@ -1,8 +1,10 @@ import asyncio +import json +import logging import os import shutil import subprocess -from typing import Any, List +from typing import Any, List, Optional import psutil import typer @@ -12,14 +14,16 @@ from mcp.server.fastmcp import FastMCP from rich.panel import Panel from rich.prompt import Confirm +from skyvern.cli.console import console from skyvern.cli.utils import start_services 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.services.script_service import run_script from skyvern.utils import detect_os 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.") mcp = FastMCP("Skyvern") @@ -163,3 +167,135 @@ def run_mcp() -> None: # This breaks the MCP processing because it expects json output only # console.print(Panel("[bold green]Starting MCP Server...[/bold green]", border_style="green")) 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) diff --git a/skyvern/core/script_generations/skyvern_page.py b/skyvern/core/script_generations/skyvern_page.py index d01513dc..e17802ee 100644 --- a/skyvern/core/script_generations/skyvern_page.py +++ b/skyvern/core/script_generations/skyvern_page.py @@ -233,6 +233,21 @@ class SkyvernPage: 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: async def wrapper( skyvern_page: SkyvernPage, @@ -246,6 +261,17 @@ class SkyvernPage: 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: call.result = await fn( 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 + # Print success in script mode + if context and context.script_mode: + print(" ✓ Completed") + return call.result except Exception as e: call.error = e action_status = ActionStatus.failed # 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 ... raise finally: @@ -282,11 +316,20 @@ class SkyvernPage: async def goto(self, url: str, timeout: float = settings.BROWSER_LOADING_TIMEOUT_MS) -> None: 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( url, timeout=timeout, ) + if context and context.script_mode: + print(" ✓ Page loaded") + async def _update_action_reasoning( self, action_id: str, @@ -548,6 +591,9 @@ class SkyvernPage: If the prompt generation or parsing fails for any reason we fall back to clicking the originally supplied ``selector``. """ + context = skyvern_context.current() + if context and context.ai_mode_override: + ai = context.ai_mode_override if ai == "fallback": # try to click the element with the original selector first error_to_raise = None @@ -739,12 +785,16 @@ class SkyvernPage: If the prompt generation or parsing fails for any reason we fall back to 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 if ai == "fallback": error_to_raise = None try: locator = self.page.locator(selector) await handler_utils.input_sequentially(locator, value, timeout=timeout) + return value except Exception as e: error_to_raise = e @@ -820,6 +870,9 @@ class SkyvernPage: data: str | dict[str, Any] | None = None, timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS, ) -> str: + context = skyvern_context.current() + if context and context.ai_mode_override: + ai = context.ai_mode_override if ai == "fallback": error_to_raise = None try: @@ -927,12 +980,16 @@ class SkyvernPage: data: str | dict[str, Any] | None = None, timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS, ) -> str: + context = skyvern_context.current() + if context and context.ai_mode_override: + ai = context.ai_mode_override value = value or "" if ai == "fallback": error_to_raise = None try: locator = self.page.locator(selector) await locator.select_option(value, timeout=timeout) + return value except Exception as e: error_to_raise = e if intention: @@ -1070,6 +1127,18 @@ class SkyvernPage: screenshots=scraped_page_refreshed.screenshots, 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 @action_wrap(ActionType.VERIFICATION_CODE) diff --git a/skyvern/forge/sdk/core/skyvern_context.py b/skyvern/forge/sdk/core/skyvern_context.py index 754c1f49..21a978d2 100644 --- a/skyvern/forge/sdk/core/skyvern_context.py +++ b/skyvern/forge/sdk/core/skyvern_context.py @@ -44,6 +44,8 @@ class SkyvernContext: loop_metadata: 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_mode: bool = False + ai_mode_override: str | None = None """ Example output value: {"loop_value": "str", "output_parameter": "the key of the parameter", "output_value": Any} diff --git a/skyvern/services/script_service.py b/skyvern/services/script_service.py index 919cc1f8..6a395e19 100644 --- a/skyvern/services/script_service.py +++ b/skyvern/services/script_service.py @@ -1954,20 +1954,24 @@ async def goto( label: str | None = None, parameters: list[str] | None = None, ) -> None: - block_validation_output = await _validate_and_get_output_parameter(label, parameters) - url = _render_template_with_label(url, label) - goto_url_block = UrlBlock( - url=url, - label=block_validation_output.label, - 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, - parent_workflow_run_block_id=block_validation_output.context.parent_workflow_run_block_id, - organization_id=block_validation_output.organization_id, - browser_session_id=block_validation_output.browser_session_id, - ) + try: + block_validation_output = await _validate_and_get_output_parameter(label, parameters) + url = _render_template_with_label(url, label) + goto_url_block = UrlBlock( + url=url, + label=block_validation_output.label, + 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, + parent_workflow_run_block_id=block_validation_output.context.parent_workflow_run_block_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(