diff --git a/README.md b/README.md index 0e36570b..904d6b70 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,13 @@ This quickstart guide will walk you through getting Skyvern up and running on yo skyvern run ui ``` -5. **Run task** +5. **Check component status** + + ```bash + skyvern status + ``` + +6. **Run task** Run a skyvern task locally: ```python diff --git a/skyvern/cli/commands.py b/skyvern/cli/commands.py index b2826f10..a5f34591 100644 --- a/skyvern/cli/commands.py +++ b/skyvern/cli/commands.py @@ -2,24 +2,32 @@ import typer from dotenv import load_dotenv from .docs import docs_app -from .init_command import init, init_browser, init_mcp +from .init_command import init, init_browser from .run_commands import run_app -from .setup_commands import setup_mcp_command +from .status import status_app from .tasks import tasks_app from .workflow import workflow_app -cli_app = typer.Typer() -cli_app.add_typer(run_app, name="run") -cli_app.add_typer(workflow_app, name="workflow") -cli_app.add_typer(tasks_app, name="tasks") -cli_app.add_typer(docs_app, name="docs") -setup_app = typer.Typer() -cli_app.add_typer(setup_app, name="setup") -init_app = typer.Typer(invoke_without_command=True) +cli_app = typer.Typer( + help=("""[bold]Skyvern CLI[/bold]\nManage and run your local Skyvern environment."""), + no_args_is_help=True, + rich_markup_mode="rich", +) +cli_app.add_typer( + run_app, + name="run", + help="Run Skyvern services like the API server, UI, and MCP.", +) +cli_app.add_typer(workflow_app, name="workflow", help="Workflow management commands.") +cli_app.add_typer(tasks_app, name="tasks", help="Task management commands.") +cli_app.add_typer(docs_app, name="docs", help="Open Skyvern documentation.") +cli_app.add_typer(status_app, name="status", help="Check if Skyvern services are running.") +init_app = typer.Typer( + invoke_without_command=True, + help="Interactively configure Skyvern and its dependencies.", +) cli_app.add_typer(init_app, name="init") -setup_app.command(name="mcp")(setup_mcp_command) - @init_app.callback() def init_callback( @@ -37,12 +45,6 @@ def init_browser_command() -> None: init_browser() -@init_app.command(name="mcp") -def init_mcp_command() -> None: - """Initialize only the MCP server configuration.""" - init_mcp() - - if __name__ == "__main__": # pragma: no cover - manual CLI invocation load_dotenv() cli_app() diff --git a/skyvern/cli/docs.py b/skyvern/cli/docs.py index c026c967..85bccc41 100644 --- a/skyvern/cli/docs.py +++ b/skyvern/cli/docs.py @@ -1,13 +1,31 @@ """Documentation-related CLI helpers.""" +import webbrowser + import typer +from rich.panel import Panel from .console import console -docs_app = typer.Typer() +DOCS_URL = "https://docs.skyvern.com" + +docs_app = typer.Typer( + invoke_without_command=True, + help="Open Skyvern documentation in your browser.", +) -@docs_app.command() -def placeholder() -> None: - """Placeholder command for documentation actions.""" - console.print("Documentation commands are not yet implemented.") +@docs_app.callback() +def docs_callback(ctx: typer.Context) -> None: + """Open the Skyvern documentation in a browser.""" + if ctx.invoked_subcommand is None: + console.print( + Panel( + f"[bold blue]Opening Skyvern docs at [link={DOCS_URL}]{DOCS_URL}[/link][/bold blue]", + border_style="cyan", + ) + ) + try: + webbrowser.open(DOCS_URL) + except Exception as exc: # pragma: no cover - CLI safeguard + console.print(f"[red]Failed to open documentation: {exc}[/red]") diff --git a/skyvern/cli/init_command.py b/skyvern/cli/init_command.py index 016bbc27..cc0a9106 100644 --- a/skyvern/cli/init_command.py +++ b/skyvern/cli/init_command.py @@ -142,8 +142,3 @@ def init_browser() -> None: progress.add_task("[bold blue]Downloading Chromium, this may take a moment...", total=None) subprocess.run(["playwright", "install", "chromium"], check=True) console.print("✅ [green]Chromium installation complete.[/green]") - - -def init_mcp() -> None: - """Initialize only the MCP server configuration.""" - setup_mcp() diff --git a/skyvern/cli/run_commands.py b/skyvern/cli/run_commands.py index a88bfa50..672f297b 100644 --- a/skyvern/cli/run_commands.py +++ b/skyvern/cli/run_commands.py @@ -16,7 +16,7 @@ from skyvern.utils import detect_os from .console import console -run_app = typer.Typer() +run_app = typer.Typer(help="Commands to run Skyvern services such as the API server or UI.") mcp = FastMCP("Skyvern") diff --git a/skyvern/cli/setup_commands.py b/skyvern/cli/setup_commands.py deleted file mode 100644 index a811bed2..00000000 --- a/skyvern/cli/setup_commands.py +++ /dev/null @@ -1,6 +0,0 @@ -from .mcp import setup_mcp - - -def setup_mcp_command() -> None: - """Wrapper command to configure the MCP server.""" - setup_mcp() diff --git a/skyvern/cli/status.py b/skyvern/cli/status.py new file mode 100644 index 00000000..2262dde0 --- /dev/null +++ b/skyvern/cli/status.py @@ -0,0 +1,48 @@ +import os +import socket + +import typer +from rich.table import Table + +from .console import console + +status_app = typer.Typer(help="Check status of Skyvern components.", invoke_without_command=True) + + +def _check_port(port: int) -> bool: + """Return True if a local port is accepting connections.""" + try: + with socket.create_connection(("localhost", port), timeout=0.5): + return True + except OSError: + return False + + +def _status_table() -> Table: + api_port = int(os.getenv("PORT", 8000)) + ui_port = 8080 + db_port = 5432 + + components = [ + ("API server", _check_port(api_port), "skyvern run server"), + ("UI server", _check_port(ui_port), "skyvern run ui"), + ("PostgreSQL", _check_port(db_port), "skyvern init --no-postgres false"), + ] + + table = Table(title="Skyvern Component Status") + table.add_column("Component", style="bold") + table.add_column("Running") + table.add_column("Start Command") + + for name, running, cmd in components: + status = "[green]Yes[/green]" if running else "[red]No[/red]" + table.add_row(name, status, cmd) + + return table + + +@status_app.callback(invoke_without_command=True) +def status_callback(ctx: typer.Context) -> None: + """Show status for API server, UI, and database.""" + if ctx.invoked_subcommand is None: + console.print(_status_table()) diff --git a/skyvern/cli/tasks.py b/skyvern/cli/tasks.py index bda378b1..214cb134 100644 --- a/skyvern/cli/tasks.py +++ b/skyvern/cli/tasks.py @@ -1,13 +1,62 @@ """Task-related CLI helpers.""" +from __future__ import annotations + +import json +import os +from typing import Optional + import typer +from dotenv import load_dotenv +from rich.panel import Panel + +from skyvern.client import Skyvern +from skyvern.config import settings from .console import console -tasks_app = typer.Typer() +tasks_app = typer.Typer(help="Manage Skyvern tasks and operations.") -@tasks_app.command() -def placeholder() -> None: - """Placeholder command for task management.""" - console.print("Task operations are not yet implemented.") +@tasks_app.callback() +def tasks_callback( + ctx: typer.Context, + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Skyvern API key", + envvar="SKYVERN_API_KEY", + ), +) -> None: + """Store API key in Typer context.""" + ctx.obj = {"api_key": api_key} + + +def _get_client(api_key: Optional[str] = None) -> Skyvern: + """Instantiate a Skyvern SDK client using environment variables.""" + load_dotenv() + load_dotenv(".env") + key = api_key or os.getenv("SKYVERN_API_KEY") or settings.SKYVERN_API_KEY + return Skyvern(base_url=settings.SKYVERN_BASE_URL, api_key=key) + + +def _list_workflow_tasks(client: Skyvern, run_id: str) -> list[dict]: + """Return tasks for the given workflow run.""" + resp = client.agent._client_wrapper.httpx_client.request( + "api/v1/tasks", + method="GET", + params={"workflow_run_id": run_id, "page_size": 100, "page": 1}, + ) + resp.raise_for_status() + return resp.json() + + +@tasks_app.command("list") +def list_tasks( + ctx: typer.Context, + workflow_run_id: str = typer.Option(..., "--workflow-run-id", "-r", help="Workflow run ID"), +) -> None: + """List tasks for a workflow run.""" + client = _get_client(ctx.obj.get("api_key") if ctx.obj else None) + tasks = _list_workflow_tasks(client, workflow_run_id) + console.print(Panel(json.dumps(tasks, indent=2), border_style="cyan")) diff --git a/skyvern/cli/workflow.py b/skyvern/cli/workflow.py index 892acaaa..eed77fe3 100644 --- a/skyvern/cli/workflow.py +++ b/skyvern/cli/workflow.py @@ -1,13 +1,115 @@ """Workflow-related CLI helpers.""" +from __future__ import annotations + +import json +import os +from typing import Optional + import typer +from dotenv import load_dotenv +from rich.panel import Panel + +from skyvern.client import Skyvern +from skyvern.config import settings from .console import console +from .tasks import _list_workflow_tasks -workflow_app = typer.Typer() +workflow_app = typer.Typer(help="Manage Skyvern workflows.") -@workflow_app.command() -def placeholder() -> None: - """Placeholder command for workflow operations.""" - console.print("Workflow operations are not yet implemented.") +@workflow_app.callback() +def workflow_callback( + ctx: typer.Context, + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Skyvern API key", + envvar="SKYVERN_API_KEY", + ), +) -> None: + """Store the provided API key in the Typer context.""" + ctx.obj = {"api_key": api_key} + + +def _get_client(api_key: Optional[str] = None) -> Skyvern: + """Instantiate a Skyvern SDK client using environment variables.""" + load_dotenv() + load_dotenv(".env") + key = api_key or os.getenv("SKYVERN_API_KEY") or settings.SKYVERN_API_KEY + return Skyvern(base_url=settings.SKYVERN_BASE_URL, api_key=key) + + +@workflow_app.command("start") +def start_workflow( + ctx: typer.Context, + workflow_id: str = typer.Argument(..., help="Workflow permanent ID"), + parameters: str = typer.Option("{}", "--parameters", "-p", help="JSON parameters for the workflow"), + title: Optional[str] = typer.Option(None, "--title", help="Title for the workflow run"), + max_steps: Optional[int] = typer.Option(None, "--max-steps", help="Override the workflow max steps"), +) -> None: + """Dispatch a workflow run.""" + try: + params_dict = json.loads(parameters) if parameters else {} + except json.JSONDecodeError: + console.print(f"[red]Invalid JSON for parameters: {parameters}[/red]") + raise typer.Exit(code=1) + + client = _get_client(ctx.obj.get("api_key") if ctx.obj else None) + run_resp = client.agent.run_workflow( + workflow_id=workflow_id, + parameters=params_dict, + title=title, + max_steps_override=max_steps, + ) + console.print( + Panel( + f"Started workflow run [bold]{run_resp.run_id}[/bold]", + border_style="green", + ) + ) + + +@workflow_app.command("stop") +def stop_workflow( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="ID of the workflow run"), +) -> None: + """Cancel a running workflow.""" + client = _get_client(ctx.obj.get("api_key") if ctx.obj else None) + client.agent.cancel_run(run_id=run_id) + console.print(Panel(f"Stop signal sent for run {run_id}", border_style="red")) + + +@workflow_app.command("status") +def workflow_status( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="ID of the workflow run"), + tasks: bool = typer.Option(False, "--tasks", help="Show task executions"), +) -> None: + """Retrieve status information for a workflow run.""" + client = _get_client(ctx.obj.get("api_key") if ctx.obj else None) + run = client.agent.get_run(run_id=run_id) + console.print(Panel(run.model_dump_json(indent=2), border_style="cyan")) + if tasks: + task_list = _list_workflow_tasks(client, run_id) + console.print(Panel(json.dumps(task_list, indent=2), border_style="magenta")) + + +@workflow_app.command("list") +def list_workflows( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="Page number"), + page_size: int = typer.Option(10, "--page-size", help="Number of workflows to return"), + template: bool = typer.Option(False, "--template", help="List template workflows"), +) -> None: + """List workflows for the organization.""" + client = _get_client(ctx.obj.get("api_key") if ctx.obj else None) + resp = client.agent._client_wrapper.httpx_client.request( + "api/v1/workflows", + method="GET", + params={"page": page, "page_size": page_size, "template": template}, + ) + resp.raise_for_status() + console.print(Panel(json.dumps(resp.json(), indent=2), border_style="cyan"))