chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""CLI commands for CUA."""
|
||||
|
||||
from . import auth, image, mcp, sandbox, skills, trajectory
|
||||
|
||||
__all__ = ["auth", "sandbox", "image", "skills", "mcp", "trajectory"]
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Authentication commands for Cua CLI."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from cua_cli.auth.browser import authenticate_via_browser
|
||||
from cua_cli.auth.store import (
|
||||
ACTIVE_WORKSPACE_KEY,
|
||||
_get_store,
|
||||
clear_credentials,
|
||||
clear_legacy_credentials,
|
||||
delete_workspace,
|
||||
get_active_workspace,
|
||||
get_api_key,
|
||||
list_workspaces,
|
||||
save_api_key,
|
||||
save_workspace,
|
||||
set_active_workspace,
|
||||
)
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import print_error, print_info, print_success
|
||||
from cua_core.http import cua_version_headers
|
||||
|
||||
DEFAULT_API_BASE = "https://api.cua.ai"
|
||||
|
||||
|
||||
def _get_api_base() -> str:
|
||||
return os.environ.get("CUA_API_BASE", DEFAULT_API_BASE).rstrip("/")
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the auth command and subcommands.
|
||||
|
||||
Args:
|
||||
subparsers: The subparsers object from the main parser
|
||||
"""
|
||||
auth_parser = subparsers.add_parser(
|
||||
"auth",
|
||||
help="Authentication commands",
|
||||
description="Manage authentication for Cua cloud services",
|
||||
)
|
||||
|
||||
auth_subparsers = auth_parser.add_subparsers(
|
||||
dest="auth_command",
|
||||
help="Authentication command",
|
||||
)
|
||||
|
||||
# login command
|
||||
login_parser = auth_subparsers.add_parser(
|
||||
"login",
|
||||
help="Authenticate with Cua cloud",
|
||||
description="Authenticate via browser or API key",
|
||||
)
|
||||
login_parser.add_argument(
|
||||
"--api-key",
|
||||
type=str,
|
||||
help="API key for direct authentication (skips browser flow)",
|
||||
)
|
||||
|
||||
# list command
|
||||
auth_subparsers.add_parser(
|
||||
"list",
|
||||
help="List authenticated workspaces",
|
||||
description="Show all authenticated workspaces",
|
||||
)
|
||||
|
||||
# logout command
|
||||
logout_parser = auth_subparsers.add_parser(
|
||||
"logout",
|
||||
help="Clear stored credentials",
|
||||
description="Remove stored authentication credentials",
|
||||
)
|
||||
logout_parser.add_argument(
|
||||
"--workspace",
|
||||
type=str,
|
||||
help="Remove credentials for a specific workspace slug",
|
||||
)
|
||||
logout_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove all workspace credentials",
|
||||
)
|
||||
|
||||
# status command
|
||||
auth_subparsers.add_parser(
|
||||
"status",
|
||||
help="Show authentication status and account info",
|
||||
description="Display current user, credits, and API key info",
|
||||
)
|
||||
|
||||
# env command
|
||||
env_parser = auth_subparsers.add_parser(
|
||||
"env",
|
||||
help="Export API key to .env file",
|
||||
description="Write CUA_API_KEY to .env file in current directory",
|
||||
)
|
||||
env_parser.add_argument(
|
||||
"--file",
|
||||
type=str,
|
||||
default=".env",
|
||||
help="Path to .env file (default: .env)",
|
||||
)
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute auth command based on subcommand.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, non-zero for failure)
|
||||
"""
|
||||
cmd = getattr(args, "auth_command", None)
|
||||
|
||||
if cmd == "login":
|
||||
return cmd_login(args)
|
||||
elif cmd == "logout":
|
||||
return cmd_logout(args)
|
||||
elif cmd == "status":
|
||||
return cmd_status(args)
|
||||
elif cmd == "list":
|
||||
return cmd_list(args)
|
||||
elif cmd == "env":
|
||||
return cmd_env(args)
|
||||
else:
|
||||
print_error("Usage: cua auth <command>")
|
||||
print_info("Commands: login, logout, status, list, env")
|
||||
return 1
|
||||
|
||||
|
||||
def _fetch_me(api_key: str) -> tuple[int, dict]:
|
||||
"""Call /v1/me to get workspace metadata for an API key."""
|
||||
|
||||
async def _do():
|
||||
url = f"{_get_api_base()}/v1/me"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
**cua_version_headers(),
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as resp:
|
||||
try:
|
||||
data = await resp.json(content_type=None)
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
return resp.status, {"error": text}
|
||||
return resp.status, data
|
||||
|
||||
return run_async(_do())
|
||||
|
||||
|
||||
def cmd_login(args: argparse.Namespace) -> int:
|
||||
"""Handle the login command."""
|
||||
if args.api_key:
|
||||
# Direct API key authentication — discover workspace via /v1/me
|
||||
api_key = args.api_key
|
||||
print_info("Authenticating with provided API key...")
|
||||
try:
|
||||
status_code, data = _fetch_me(api_key)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to reach API: {e}")
|
||||
return 1
|
||||
|
||||
if status_code != 200:
|
||||
print_error(f"API key validation failed (HTTP {status_code})")
|
||||
return 1
|
||||
|
||||
ws = data.get("workspace", {})
|
||||
org = data.get("organization", {})
|
||||
slug = ws.get("slug")
|
||||
if slug:
|
||||
name = ws.get("name", slug)
|
||||
org_name = org.get("name", "")
|
||||
save_workspace(slug, api_key, name, org_name)
|
||||
set_active_workspace(slug)
|
||||
print_success(f"Authenticated and switched to workspace: {name} ({slug})")
|
||||
else:
|
||||
# Legacy fallback — no workspace metadata from API
|
||||
save_api_key(api_key)
|
||||
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
||||
print_success("Successfully authenticated!")
|
||||
else:
|
||||
# Browser-based authentication
|
||||
try:
|
||||
result = run_async(authenticate_via_browser())
|
||||
except TimeoutError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
except RuntimeError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if result.workspace_slug:
|
||||
save_workspace(
|
||||
result.workspace_slug,
|
||||
result.token,
|
||||
result.workspace_name,
|
||||
result.org_name,
|
||||
)
|
||||
set_active_workspace(result.workspace_slug)
|
||||
print_success(
|
||||
f"Authenticated and switched to workspace: "
|
||||
f"{result.workspace_name} ({result.workspace_slug})"
|
||||
)
|
||||
else:
|
||||
# Fallback if website didn't send workspace metadata
|
||||
save_api_key(result.token)
|
||||
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
||||
print_success("Successfully authenticated!")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_logout(args: argparse.Namespace) -> int:
|
||||
"""Handle the logout command."""
|
||||
if getattr(args, "all", False):
|
||||
clear_credentials()
|
||||
print_success("All credentials cleared.")
|
||||
return 0
|
||||
|
||||
ws_slug = getattr(args, "workspace", None)
|
||||
if ws_slug:
|
||||
delete_workspace(ws_slug)
|
||||
active = get_active_workspace()
|
||||
if active == ws_slug:
|
||||
# Active workspace was removed — pick another or clear
|
||||
remaining = list_workspaces()
|
||||
if remaining:
|
||||
set_active_workspace(remaining[0]["slug"])
|
||||
print_info(
|
||||
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']})"
|
||||
)
|
||||
else:
|
||||
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
||||
print_success(f"Removed credentials for workspace: {ws_slug}")
|
||||
return 0
|
||||
|
||||
# No flags — remove only the active workspace
|
||||
active = get_active_workspace()
|
||||
if active:
|
||||
delete_workspace(active)
|
||||
remaining = list_workspaces()
|
||||
if remaining:
|
||||
set_active_workspace(remaining[0]["slug"])
|
||||
print_success(f"Logged out of workspace: {active}")
|
||||
print_info(
|
||||
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']}). "
|
||||
f"Use 'cua workspace set <slug>' to switch workspaces."
|
||||
)
|
||||
else:
|
||||
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
||||
print_success(f"Logged out of workspace: {active}")
|
||||
else:
|
||||
clear_legacy_credentials()
|
||||
print_success("Credentials cleared.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""Handle the list command — show all authenticated workspaces."""
|
||||
workspaces = list_workspaces()
|
||||
if not workspaces:
|
||||
print_info("No authenticated workspaces. Run 'cua auth login' to add one.")
|
||||
return 0
|
||||
|
||||
# Group by org
|
||||
by_org: dict[str, list[dict]] = {}
|
||||
for ws in workspaces:
|
||||
org = ws["org"] or "Unknown"
|
||||
by_org.setdefault(org, []).append(ws)
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
c = Console()
|
||||
for org, org_workspaces in by_org.items():
|
||||
c.print(f"[bold]{org}[/bold]")
|
||||
for ws in org_workspaces:
|
||||
name = ws["name"] or ws["slug"]
|
||||
slug = ws["slug"]
|
||||
if ws["is_active"]:
|
||||
c.print(f" [green]* {name} ({slug})[/green]")
|
||||
else:
|
||||
c.print(f" {name} ({slug})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
"""Handle the status command — show auth status and account info."""
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
print_error("Not logged in. Run 'cua auth login' first.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
status_code, data = _fetch_me(api_key)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to reach API: {e}")
|
||||
return 1
|
||||
|
||||
if status_code == 401:
|
||||
# Don't mutate cached workspaces if the token came from the environment
|
||||
if os.environ.get("CUA_API_KEY"):
|
||||
print_error("Session expired. The CUA_API_KEY environment variable is invalid.")
|
||||
return 1
|
||||
|
||||
active = get_active_workspace()
|
||||
if active:
|
||||
delete_workspace(active)
|
||||
print_info(f"Removed expired credentials for workspace: {active}")
|
||||
remaining = list_workspaces()
|
||||
if remaining:
|
||||
set_active_workspace(remaining[0]["slug"])
|
||||
print_info(
|
||||
f"Switched to workspace: {remaining[0]['name']} ({remaining[0]['slug']})"
|
||||
)
|
||||
else:
|
||||
_get_store().delete(ACTIVE_WORKSPACE_KEY)
|
||||
else:
|
||||
clear_legacy_credentials()
|
||||
print_info("Removed expired credentials.")
|
||||
print_error("Session expired. Run 'cua auth login' to re-authenticate.")
|
||||
return 1
|
||||
|
||||
if status_code != 200:
|
||||
print_error(f"Failed to fetch account info (HTTP {status_code})")
|
||||
return 1
|
||||
|
||||
ws = data.get("workspace", {})
|
||||
org = data.get("organization", {})
|
||||
credits = data.get("credits", {})
|
||||
|
||||
active_slug = get_active_workspace()
|
||||
active_label = f" [active: {active_slug}]" if active_slug else ""
|
||||
print_success(f"Logged in to cua.ai{active_label}")
|
||||
print_info(f" Workspace: {ws.get('name', 'unknown')} ({ws.get('slug', 'unknown')})")
|
||||
print_info(f" Organization: {org.get('name', 'unknown')} ({org.get('plan_type', 'unknown')})")
|
||||
print_info(f" Credits: {credits.get('balance', 0):.2f} remaining")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_env(args: argparse.Namespace) -> int:
|
||||
"""Handle the env command - export API key to .env file.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
print_error("Not authenticated. Run 'cua auth login' first.")
|
||||
return 1
|
||||
|
||||
env_file = Path(args.file)
|
||||
env_line = f"CUA_API_KEY={api_key}"
|
||||
|
||||
if env_file.exists():
|
||||
# Read existing content
|
||||
content = env_file.read_text()
|
||||
lines = content.splitlines()
|
||||
|
||||
# Check if CUA_API_KEY already exists
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("CUA_API_KEY="):
|
||||
lines[i] = env_line
|
||||
updated = True
|
||||
break
|
||||
|
||||
if updated:
|
||||
env_file.write_text("\n".join(lines) + "\n")
|
||||
print_success(f"Updated CUA_API_KEY in {env_file}")
|
||||
else:
|
||||
# Append to file
|
||||
with env_file.open("a") as f:
|
||||
if content and not content.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(env_line + "\n")
|
||||
print_success(f"Added CUA_API_KEY to {env_file}")
|
||||
else:
|
||||
# Create new file
|
||||
env_file.write_text(env_line + "\n")
|
||||
print_success(f"Created {env_file} with CUA_API_KEY")
|
||||
|
||||
return 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,673 @@
|
||||
"""Image management commands for CUA CLI.
|
||||
|
||||
Handles both cloud images (push/pull to CUA cloud) and local images
|
||||
(create, clone, shell, info).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from cua_cli.api.client import CloudAPIClient, calculate_file_hash, format_bytes
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_json,
|
||||
print_success,
|
||||
print_table,
|
||||
)
|
||||
|
||||
# Default local image storage
|
||||
LOCAL_IMAGES_DIR = Path.home() / ".local" / "share" / "cua" / "images"
|
||||
|
||||
# Default part size for multi-part upload: 100MB
|
||||
DEFAULT_PART_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the image command and subcommands."""
|
||||
for cmd_name in ("image", "img"):
|
||||
img_parser = subparsers.add_parser(
|
||||
cmd_name,
|
||||
help="Image management commands",
|
||||
description="Manage VM images (cloud and local)",
|
||||
)
|
||||
|
||||
img_subparsers = img_parser.add_subparsers(
|
||||
dest="image_command",
|
||||
help="Image command",
|
||||
)
|
||||
|
||||
# list command
|
||||
list_parser = img_subparsers.add_parser(
|
||||
"list",
|
||||
aliases=["ls"],
|
||||
help="List images",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="List local images only",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--cloud",
|
||||
action="store_true",
|
||||
help="List cloud images only (default)",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--platform",
|
||||
type=str,
|
||||
help="Filter local images by platform",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--format",
|
||||
choices=["table", "json"],
|
||||
default="table",
|
||||
help="Output format for local images",
|
||||
)
|
||||
|
||||
# push command
|
||||
push_parser = img_subparsers.add_parser(
|
||||
"push",
|
||||
help="Push image to cloud storage",
|
||||
)
|
||||
push_parser.add_argument(
|
||||
"name",
|
||||
help="Image name",
|
||||
)
|
||||
push_parser.add_argument(
|
||||
"--file",
|
||||
"-f",
|
||||
type=str,
|
||||
help="Path to image file (default: ~/.local/share/cua/images/<name>/data.img)",
|
||||
)
|
||||
push_parser.add_argument(
|
||||
"--tag",
|
||||
type=str,
|
||||
default="latest",
|
||||
help="Image tag (default: latest)",
|
||||
)
|
||||
push_parser.add_argument(
|
||||
"--type",
|
||||
type=str,
|
||||
default="qcow2",
|
||||
choices=["qcow2", "raw", "vmdk"],
|
||||
help="Image type (default: qcow2)",
|
||||
)
|
||||
|
||||
# pull command
|
||||
pull_parser = img_subparsers.add_parser(
|
||||
"pull",
|
||||
help="Pull image from cloud storage",
|
||||
)
|
||||
pull_parser.add_argument(
|
||||
"name",
|
||||
help="Image name",
|
||||
)
|
||||
pull_parser.add_argument(
|
||||
"--tag",
|
||||
type=str,
|
||||
default="latest",
|
||||
help="Image tag (default: latest)",
|
||||
)
|
||||
pull_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
help="Output file path",
|
||||
)
|
||||
|
||||
# delete command
|
||||
delete_parser = img_subparsers.add_parser(
|
||||
"delete",
|
||||
help="Delete an image",
|
||||
)
|
||||
delete_parser.add_argument(
|
||||
"name",
|
||||
help="Image name",
|
||||
)
|
||||
delete_parser.add_argument(
|
||||
"--tag",
|
||||
type=str,
|
||||
default="latest",
|
||||
help="Image tag (default: latest, for cloud images)",
|
||||
)
|
||||
delete_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip confirmation",
|
||||
)
|
||||
delete_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Delete a local image instead of cloud",
|
||||
)
|
||||
|
||||
# create command (local)
|
||||
create_parser = img_subparsers.add_parser(
|
||||
"create",
|
||||
help="Create a local image from platform",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"platform",
|
||||
help="Platform name (e.g., linux-docker, windows-qemu)",
|
||||
)
|
||||
create_parser.add_argument("--name", help="Image name (default: same as platform)")
|
||||
create_parser.add_argument("--iso", help="Path to ISO file (for QEMU platforms)")
|
||||
create_parser.add_argument(
|
||||
"--download-iso",
|
||||
action="store_true",
|
||||
dest="download_iso",
|
||||
help="Download Windows 11 ISO (~6GB)",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--docker-image", dest="docker_image", help="Override Docker image"
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--distro",
|
||||
default="ubuntu",
|
||||
choices=["ubuntu", "fedora"],
|
||||
help="Linux distribution",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--version",
|
||||
default="14",
|
||||
help="OS version (e.g., 14 for Android, sonoma for macOS)",
|
||||
)
|
||||
create_parser.add_argument("--disk", default="64G", help="Disk size (default: 64G)")
|
||||
create_parser.add_argument("--memory", default="8G", help="Memory (default: 8G)")
|
||||
create_parser.add_argument("--cpus", default="8", help="CPU cores (default: 8)")
|
||||
create_parser.add_argument(
|
||||
"--winarena-apps",
|
||||
action="store_true",
|
||||
dest="winarena_apps",
|
||||
help="Install WinArena benchmark apps (Chrome, LibreOffice, VLC, etc.)",
|
||||
)
|
||||
create_parser.add_argument("--detach", "-d", action="store_true", help="Run in background")
|
||||
create_parser.add_argument("--force", action="store_true", help="Force recreation")
|
||||
create_parser.add_argument(
|
||||
"--skip-pull",
|
||||
action="store_true",
|
||||
dest="skip_pull",
|
||||
help="Don't pull Docker image",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--no-kvm",
|
||||
action="store_true",
|
||||
dest="no_kvm",
|
||||
help="Disable KVM acceleration",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--vnc-port",
|
||||
dest="vnc_port",
|
||||
help="VNC port (default: auto-allocate from 8006)",
|
||||
)
|
||||
create_parser.add_argument(
|
||||
"--api-port",
|
||||
dest="api_port",
|
||||
help="API port (default: auto-allocate from 5000)",
|
||||
)
|
||||
|
||||
# info command (local)
|
||||
info_parser = img_subparsers.add_parser(
|
||||
"info",
|
||||
help="Show local image details",
|
||||
)
|
||||
info_parser.add_argument("name", help="Image name")
|
||||
|
||||
# clone command (local)
|
||||
clone_parser = img_subparsers.add_parser(
|
||||
"clone",
|
||||
help="Clone a local image",
|
||||
)
|
||||
clone_parser.add_argument("source", help="Source image name")
|
||||
clone_parser.add_argument("target", help="Target image name")
|
||||
clone_parser.add_argument("--force", action="store_true", help="Overwrite if target exists")
|
||||
|
||||
# shell command (local)
|
||||
shell_parser = img_subparsers.add_parser(
|
||||
"shell",
|
||||
help="Interactive shell into image (uses overlay by default)",
|
||||
)
|
||||
shell_parser.add_argument("name", help="Image name")
|
||||
shell_parser.add_argument(
|
||||
"--writable",
|
||||
action="store_true",
|
||||
help="Modify golden image directly (dangerous!)",
|
||||
)
|
||||
shell_parser.add_argument("--detach", "-d", action="store_true", help="Run in background")
|
||||
shell_parser.add_argument(
|
||||
"--vnc-port",
|
||||
dest="vnc_port",
|
||||
help="VNC port (default: auto-allocate from 8006)",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--api-port",
|
||||
dest="api_port",
|
||||
help="API port (default: auto-allocate from 5000)",
|
||||
)
|
||||
shell_parser.add_argument("--memory", default="8G", help="Memory (default: 8G)")
|
||||
shell_parser.add_argument("--cpus", default="8", help="CPU cores (default: 8)")
|
||||
shell_parser.add_argument(
|
||||
"--no-kvm",
|
||||
action="store_true",
|
||||
dest="no_kvm",
|
||||
help="Disable KVM acceleration",
|
||||
)
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute image command based on subcommand."""
|
||||
from cua_cli.commands import local_image
|
||||
|
||||
cmd = getattr(args, "image_command", None)
|
||||
|
||||
if cmd in ("list", "ls"):
|
||||
return cmd_list(args)
|
||||
elif cmd == "push":
|
||||
return cmd_push(args)
|
||||
elif cmd == "pull":
|
||||
return cmd_pull(args)
|
||||
elif cmd == "delete":
|
||||
return cmd_delete(args)
|
||||
elif cmd == "create":
|
||||
return local_image.cmd_create(args)
|
||||
elif cmd == "info":
|
||||
return local_image.cmd_info(args)
|
||||
elif cmd == "clone":
|
||||
return local_image.cmd_clone(args)
|
||||
elif cmd == "shell":
|
||||
return local_image.cmd_shell(args)
|
||||
else:
|
||||
print_error("Usage: cua image <command>")
|
||||
print_info("Commands: list, push, pull, delete, create, info, clone, shell")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List images."""
|
||||
show_local = getattr(args, "local", False)
|
||||
show_cloud = getattr(args, "cloud", False)
|
||||
|
||||
# If --local is set, delegate to local_image list
|
||||
if show_local and not show_cloud:
|
||||
from cua_cli.commands import local_image
|
||||
|
||||
return local_image.cmd_local_list(args)
|
||||
|
||||
# Default to cloud if neither specified, or if --cloud is set
|
||||
if not show_local:
|
||||
show_cloud = True
|
||||
|
||||
all_images = []
|
||||
|
||||
if show_cloud:
|
||||
cloud_images = run_async(_list_cloud_images())
|
||||
all_images.extend(cloud_images)
|
||||
|
||||
if show_local:
|
||||
local_images = _list_local_images()
|
||||
all_images.extend(local_images)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print_json(all_images)
|
||||
return 0
|
||||
|
||||
if not all_images:
|
||||
print_info("No images found.")
|
||||
return 0
|
||||
|
||||
columns = [
|
||||
("name", "NAME"),
|
||||
("type", "TYPE"),
|
||||
("tag", "TAG"),
|
||||
("size", "SIZE"),
|
||||
("status", "STATUS"),
|
||||
("created", "CREATED"),
|
||||
]
|
||||
|
||||
print_table(all_images, columns)
|
||||
return 0
|
||||
|
||||
|
||||
async def _list_cloud_images() -> list[dict[str, Any]]:
|
||||
"""List cloud images."""
|
||||
try:
|
||||
client = CloudAPIClient()
|
||||
images = await client.list_images()
|
||||
|
||||
result = []
|
||||
for img in images:
|
||||
versions = img.get("versions", [])
|
||||
if versions:
|
||||
for ver in versions:
|
||||
result.append(
|
||||
{
|
||||
"name": img.get("name", ""),
|
||||
"type": img.get("image_type", ""),
|
||||
"tag": ver.get("tag", ""),
|
||||
"size": format_bytes(ver.get("size_bytes", 0)),
|
||||
"status": ver.get("status", ""),
|
||||
"created": _format_date(ver.get("created_at", "")),
|
||||
"source": "cloud",
|
||||
}
|
||||
)
|
||||
else:
|
||||
result.append(
|
||||
{
|
||||
"name": img.get("name", ""),
|
||||
"type": img.get("image_type", ""),
|
||||
"tag": "-",
|
||||
"size": "-",
|
||||
"status": "-",
|
||||
"created": _format_date(img.get("created_at", "")),
|
||||
"source": "cloud",
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
print_error(f"Failed to list cloud images: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _list_local_images() -> list[dict[str, Any]]:
|
||||
"""List local images."""
|
||||
result = []
|
||||
|
||||
if not LOCAL_IMAGES_DIR.exists():
|
||||
return result
|
||||
|
||||
for image_dir in LOCAL_IMAGES_DIR.iterdir():
|
||||
if not image_dir.is_dir():
|
||||
continue
|
||||
|
||||
name = image_dir.name
|
||||
data_file = image_dir / "data.img"
|
||||
|
||||
if data_file.exists():
|
||||
size = format_bytes(data_file.stat().st_size)
|
||||
created = datetime.fromtimestamp(data_file.stat().st_mtime).strftime("%Y-%m-%d")
|
||||
else:
|
||||
size = "-"
|
||||
created = "-"
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "local",
|
||||
"tag": "latest",
|
||||
"size": size,
|
||||
"status": "ready" if data_file.exists() else "incomplete",
|
||||
"created": created,
|
||||
"source": "local",
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _format_date(date_str: str) -> str:
|
||||
"""Format a date string."""
|
||||
if not date_str:
|
||||
return "-"
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
return date_str[:10] if len(date_str) >= 10 else date_str
|
||||
|
||||
|
||||
def cmd_push(args: argparse.Namespace) -> int:
|
||||
"""Push an image to cloud storage."""
|
||||
name = args.name
|
||||
tag = args.tag
|
||||
image_type = args.type
|
||||
|
||||
# Determine file path
|
||||
if args.file:
|
||||
file_path = Path(args.file)
|
||||
else:
|
||||
# Look in local images directory
|
||||
file_path = LOCAL_IMAGES_DIR / name / "data.img"
|
||||
|
||||
if not file_path.exists():
|
||||
if args.file:
|
||||
print_error(f"File not found: {file_path}")
|
||||
else:
|
||||
print_error(f"Image not found: {name}")
|
||||
print_info(f"Looked in: {file_path}")
|
||||
print_info("Use --file to specify a custom path")
|
||||
return 1
|
||||
|
||||
size_bytes = file_path.stat().st_size
|
||||
print_info(f"Pushing {file_path} ({format_bytes(size_bytes)})")
|
||||
|
||||
return run_async(_push_image(name, tag, image_type, file_path, size_bytes))
|
||||
|
||||
|
||||
async def _push_image(
|
||||
name: str, tag: str, image_type: str, file_path: Path, size_bytes: int
|
||||
) -> int:
|
||||
"""Push an image using multi-part upload."""
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
||||
|
||||
# Calculate checksum
|
||||
print_info("Calculating checksum...")
|
||||
checksum = calculate_file_hash(file_path)
|
||||
print_info(f"Checksum: {checksum}")
|
||||
|
||||
client = CloudAPIClient()
|
||||
|
||||
# Initiate upload
|
||||
print_info("Initiating upload...")
|
||||
status, data = await client.initiate_upload(
|
||||
name=name,
|
||||
tag=tag,
|
||||
image_type=image_type,
|
||||
size_bytes=size_bytes,
|
||||
checksum_sha256=checksum,
|
||||
)
|
||||
|
||||
if status == 401:
|
||||
print_error("Unauthorized. Run 'cua auth login' again.")
|
||||
return 1
|
||||
if status == 409:
|
||||
print_error(f"Image version already exists: {name}:{tag}")
|
||||
return 1
|
||||
if status not in (200, 201):
|
||||
print_error(f"Failed to initiate upload: {data}")
|
||||
return 1
|
||||
|
||||
upload_id = data.get("upload_id")
|
||||
part_size = data.get("part_size", DEFAULT_PART_SIZE)
|
||||
total_parts = data.get("total_parts", 1)
|
||||
|
||||
print_info(f"Upload session: {upload_id}")
|
||||
print_info(f"Parts: {total_parts} x {format_bytes(part_size)}")
|
||||
|
||||
# Read file into memory (for simplicity - could stream for very large files)
|
||||
file_data = file_path.read_bytes()
|
||||
|
||||
completed_parts = []
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
) as progress:
|
||||
task = progress.add_task("Uploading...", total=total_parts)
|
||||
|
||||
for part_num in range(1, total_parts + 1):
|
||||
# Get signed URL
|
||||
status, url_data = await client.get_upload_part_url(name, upload_id, part_num)
|
||||
if status != 200:
|
||||
print_error(f"Failed to get upload URL for part {part_num}")
|
||||
await client.abort_upload(name, upload_id)
|
||||
return 1
|
||||
|
||||
upload_url = url_data.get("upload_url")
|
||||
|
||||
# Calculate part range
|
||||
start = (part_num - 1) * part_size
|
||||
end = min(start + part_size, size_bytes)
|
||||
part_data = file_data[start:end]
|
||||
|
||||
# Upload part
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.put(
|
||||
upload_url,
|
||||
data=part_data,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
print_error(f"Failed to upload part {part_num}: {resp.status}")
|
||||
await client.abort_upload(name, upload_id)
|
||||
return 1
|
||||
|
||||
etag = resp.headers.get("ETag", "")
|
||||
completed_parts.append({"part_number": part_num, "etag": etag})
|
||||
|
||||
progress.update(task, advance=1)
|
||||
|
||||
# Complete upload
|
||||
print_info("Completing upload...")
|
||||
status, result = await client.complete_upload(name, upload_id, completed_parts)
|
||||
|
||||
if status not in (200, 201):
|
||||
print_error(f"Failed to complete upload: {result}")
|
||||
return 1
|
||||
|
||||
print_success(f"Push complete: {name}:{tag}")
|
||||
if isinstance(result, dict):
|
||||
print_info(f"Version ID: {result.get('version_id', 'N/A')}")
|
||||
print_info(f"Status: {result.get('status', 'N/A')}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_pull(args: argparse.Namespace) -> int:
|
||||
"""Pull an image from cloud storage."""
|
||||
name = args.name
|
||||
tag = args.tag
|
||||
output_path = args.output or f"{name}-{tag}.qcow2"
|
||||
|
||||
print_info(f"Pulling {name}:{tag}...")
|
||||
return run_async(_pull_image(name, tag, Path(output_path)))
|
||||
|
||||
|
||||
async def _pull_image(name: str, tag: str, output_path: Path) -> int:
|
||||
"""Pull an image from cloud storage."""
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TextColumn,
|
||||
)
|
||||
|
||||
client = CloudAPIClient()
|
||||
|
||||
# Get download URL
|
||||
status, data = await client.get_download_url(name, tag)
|
||||
|
||||
if status == 401:
|
||||
print_error("Unauthorized. Run 'cua auth login' again.")
|
||||
return 1
|
||||
if status == 404:
|
||||
print_error(f"Image not found: {name}:{tag}")
|
||||
return 1
|
||||
if status != 200:
|
||||
print_error(f"Failed to get download URL: {data}")
|
||||
return 1
|
||||
|
||||
download_url = data.get("download_url")
|
||||
size_bytes = data.get("size_bytes", 0)
|
||||
expected_checksum = data.get("checksum_sha256", "")
|
||||
|
||||
print_info(f"Size: {format_bytes(size_bytes)}")
|
||||
print_info(f"Downloading to {output_path}...")
|
||||
|
||||
# Download file
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
print_error(f"Download failed: {resp.status}")
|
||||
return 1
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
DownloadColumn(),
|
||||
) as progress:
|
||||
task = progress.add_task("Downloading...", total=size_bytes)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
progress.update(task, advance=len(chunk))
|
||||
|
||||
# Verify checksum
|
||||
if expected_checksum:
|
||||
print_info("Verifying checksum...")
|
||||
downloaded_checksum = calculate_file_hash(output_path)
|
||||
|
||||
if downloaded_checksum != expected_checksum:
|
||||
print_error("Checksum mismatch! Download may be corrupted.")
|
||||
print_error(f"Expected: {expected_checksum}")
|
||||
print_error(f"Got: {downloaded_checksum}")
|
||||
return 1
|
||||
|
||||
print_success(f"Checksum verified: {downloaded_checksum}")
|
||||
|
||||
print_success(f"Pull complete: {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace) -> int:
|
||||
"""Delete an image."""
|
||||
# If --local flag, delegate to local_image delete
|
||||
if getattr(args, "local", False):
|
||||
from cua_cli.commands import local_image
|
||||
|
||||
return local_image.cmd_local_delete(args)
|
||||
|
||||
# Cloud delete
|
||||
name = args.name
|
||||
tag = args.tag
|
||||
|
||||
if not args.force:
|
||||
print_info(f"This will delete {name}:{tag}. Use --force to confirm.")
|
||||
return 1
|
||||
|
||||
print_info(f"Deleting {name}:{tag}...")
|
||||
return run_async(_delete_image(name, tag))
|
||||
|
||||
|
||||
async def _delete_image(name: str, tag: str) -> int:
|
||||
"""Delete an image from cloud storage."""
|
||||
client = CloudAPIClient()
|
||||
|
||||
status, data = await client.delete_image(name, tag)
|
||||
|
||||
if status == 401:
|
||||
print_error("Unauthorized. Run 'cua auth login' again.")
|
||||
return 1
|
||||
if status == 404:
|
||||
print_error(f"Image not found: {name}:{tag}")
|
||||
return 1
|
||||
if status not in (200, 202, 204):
|
||||
print_error(f"Delete failed: {data}")
|
||||
return 1
|
||||
|
||||
print_success(f"Deleted: {name}:{tag}")
|
||||
return 0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
"""Platform information commands.
|
||||
|
||||
Platforms are read-only built-in configurations for different environment types.
|
||||
Each platform defines the Docker image, ports, and requirements for running
|
||||
a specific type of sandbox (linux-docker, windows-qemu, etc.).
|
||||
|
||||
Usage:
|
||||
cua platform list # Show available platforms
|
||||
cua platform info <type> # Show platform details
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform as sys_platform
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from cua_cli.utils.output import console, print_error, print_info
|
||||
|
||||
# =============================================================================
|
||||
# Platform Configurations
|
||||
# =============================================================================
|
||||
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"linux-docker": {
|
||||
"image": "trycua/cua-xfce:latest",
|
||||
"description": "Linux GUI container (no KVM required)",
|
||||
"internal_vnc_port": 6901,
|
||||
"internal_api_port": 8000,
|
||||
"requires_kvm": False,
|
||||
"image_marker": None,
|
||||
"os_type": "linux",
|
||||
"boot_timeout": 60,
|
||||
"use_overlays": False,
|
||||
},
|
||||
"linux-qemu": {
|
||||
"image": "trycua/cua-qemu-linux:latest",
|
||||
"description": "Linux VM with QEMU/KVM (OSWorld)",
|
||||
"internal_vnc_port": 8006,
|
||||
"internal_api_port": 5000,
|
||||
"requires_kvm": True,
|
||||
"image_marker": "linux.boot",
|
||||
"os_type": "linux",
|
||||
"boot_timeout": 120,
|
||||
"use_overlays": True,
|
||||
},
|
||||
"windows-qemu": {
|
||||
"image": "trycua/cua-qemu-windows:latest",
|
||||
"description": "Windows VM with QEMU/KVM (Windows Arena)",
|
||||
"internal_vnc_port": 8006,
|
||||
"internal_api_port": 5000,
|
||||
"requires_kvm": True,
|
||||
"image_marker": "windows.boot",
|
||||
"os_type": "windows",
|
||||
"boot_timeout": 180,
|
||||
"use_overlays": True,
|
||||
},
|
||||
"android-qemu": {
|
||||
"image": "trycua/cua-qemu-android:latest",
|
||||
"description": "Android VM with QEMU/KVM",
|
||||
"internal_vnc_port": 8006,
|
||||
"internal_api_port": 5000,
|
||||
"requires_kvm": True,
|
||||
"image_marker": "android.boot",
|
||||
"os_type": "android",
|
||||
"boot_timeout": 120,
|
||||
"use_overlays": True,
|
||||
},
|
||||
"macos-lume": {
|
||||
"image": None,
|
||||
"description": "macOS VM with Apple Virtualization (Lume, Apple Silicon only)",
|
||||
"internal_vnc_port": None,
|
||||
"internal_api_port": 5000,
|
||||
"requires_kvm": False,
|
||||
"image_marker": None,
|
||||
"os_type": "macos",
|
||||
"boot_timeout": 120,
|
||||
"use_overlays": False,
|
||||
"requires_apple_silicon": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def check_docker() -> bool:
|
||||
"""Check if Docker is running."""
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_kvm() -> bool:
|
||||
"""Check if KVM is available.
|
||||
|
||||
On Linux: checks /dev/kvm directly
|
||||
On Windows/macOS with Docker: checks if KVM is available inside Docker's VM
|
||||
"""
|
||||
if sys_platform.system() == "Linux":
|
||||
return os.path.exists("/dev/kvm")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm", "--device=/dev/kvm", "alpine", "test", "-e", "/dev/kvm"],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_lume() -> bool:
|
||||
"""Check if Lume is installed."""
|
||||
try:
|
||||
result = subprocess.run(["lume", "--version"], capture_output=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_image_exists(image_name: str) -> bool:
|
||||
"""Check if a Docker image exists locally."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "images", "-q", image_name], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_platform_config(platform_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get platform configuration by name."""
|
||||
return PLATFORMS.get(platform_name)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List all available platforms."""
|
||||
output_format = getattr(args, "format", "table")
|
||||
|
||||
if output_format == "json":
|
||||
print(json.dumps(PLATFORMS, indent=2))
|
||||
return 0
|
||||
|
||||
print("\nPlatforms")
|
||||
print("=" * 80)
|
||||
|
||||
docker_ok = check_docker()
|
||||
kvm_ok = check_kvm()
|
||||
lume_ok = check_lume() if sys_platform.system() == "Darwin" else False
|
||||
is_macos = sys_platform.system() == "Darwin"
|
||||
is_linux = sys_platform.system() == "Linux"
|
||||
|
||||
print("\nSystem:")
|
||||
print(f" Docker: {'✓ Running' if docker_ok else '✗ Not running'}")
|
||||
if is_linux:
|
||||
print(f" KVM: {'✓ Available' if kvm_ok else '○ Not available (QEMU will be slower)'}")
|
||||
if is_macos:
|
||||
print(f" Lume: {'✓ Installed' if lume_ok else '○ Not installed'}")
|
||||
|
||||
print("\n" + "-" * 80)
|
||||
print(f"\n{'PLATFORM':<18} {'DESCRIPTION':<45} {'STATUS':<12}")
|
||||
print("-" * 80)
|
||||
|
||||
for name, config in PLATFORMS.items():
|
||||
description = config.get("description", "")[:44]
|
||||
|
||||
if name == "macos-lume":
|
||||
if not is_macos:
|
||||
status = "macOS only"
|
||||
style = "dim"
|
||||
elif not lume_ok:
|
||||
status = "needs Lume"
|
||||
style = "yellow"
|
||||
else:
|
||||
status = "ready"
|
||||
style = "green"
|
||||
elif config.get("requires_kvm") and not kvm_ok:
|
||||
if is_linux:
|
||||
status = "no KVM"
|
||||
style = "yellow"
|
||||
else:
|
||||
status = "Linux only"
|
||||
style = "dim"
|
||||
elif not docker_ok:
|
||||
status = "no Docker"
|
||||
style = "red"
|
||||
else:
|
||||
status = "ready"
|
||||
style = "green"
|
||||
|
||||
console.print(f"{name:<18} {description:<45} [{style}]{status:<12}[/{style}]")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("\nCommands:")
|
||||
print(" cua platform info <type> # Show platform details")
|
||||
print(" cua image create <platform> # Create image from platform")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> int:
|
||||
"""Show detailed information about a platform."""
|
||||
name = args.platform
|
||||
config = get_platform_config(name)
|
||||
|
||||
if not config:
|
||||
print_error(f"Unknown platform '{name}'")
|
||||
print_info(f"Available platforms: {', '.join(PLATFORMS.keys())}")
|
||||
return 1
|
||||
|
||||
print(f"\nPlatform: {name}")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\nDescription: {config.get('description', '-')}")
|
||||
print(f"OS Type: {config.get('os_type', '-')}")
|
||||
|
||||
if config.get("image"):
|
||||
print(f"Docker Image: {config['image']}")
|
||||
if check_docker():
|
||||
exists = check_image_exists(config["image"])
|
||||
print(f"Image Pulled: {'✓ Yes' if exists else '✗ No'}")
|
||||
|
||||
print("\nPorts:")
|
||||
if config.get("internal_api_port"):
|
||||
print(f" API Port (internal): {config['internal_api_port']}")
|
||||
if config.get("internal_vnc_port"):
|
||||
print(f" VNC Port (internal): {config['internal_vnc_port']}")
|
||||
|
||||
print("\nRequirements:")
|
||||
if config.get("requires_kvm"):
|
||||
kvm_ok = check_kvm()
|
||||
print(f" KVM: Required {'(✓ available)' if kvm_ok else '(✗ not available)'}")
|
||||
else:
|
||||
print(" KVM: Not required")
|
||||
|
||||
if config.get("requires_apple_silicon"):
|
||||
is_macos = sys_platform.system() == "Darwin"
|
||||
print(
|
||||
f" Apple Silicon: Required {'(✓ running on macOS)' if is_macos else '(✗ not on macOS)'}"
|
||||
)
|
||||
|
||||
if config.get("image_marker"):
|
||||
print(f"\nImage Marker: {config['image_marker']}")
|
||||
print(" (Marker file created in image directory when image is ready)")
|
||||
|
||||
print("\nConfiguration:")
|
||||
print(f" Boot Timeout: {config.get('boot_timeout', 60)}s")
|
||||
print(f" Use Overlays: {'Yes' if config.get('use_overlays') else 'No'}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("\nTo create an image from this platform:")
|
||||
print(f" cua image create {name}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the platform command with the main CLI parser."""
|
||||
platform_parser = subparsers.add_parser(
|
||||
"platform", help="Show available platform configurations"
|
||||
)
|
||||
platform_subparsers = platform_parser.add_subparsers(
|
||||
dest="platform_command", help="Platform command"
|
||||
)
|
||||
|
||||
# platform list
|
||||
list_parser = platform_subparsers.add_parser("list", help="List all available platforms")
|
||||
list_parser.add_argument(
|
||||
"--format", choices=["table", "json"], default="table", help="Output format"
|
||||
)
|
||||
|
||||
# platform info
|
||||
info_parser = platform_subparsers.add_parser("info", help="Show platform details")
|
||||
info_parser.add_argument("platform", help="Platform name (e.g., linux-docker, windows-qemu)")
|
||||
|
||||
platform_parser.set_defaults(platform_command="list")
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute the platform command."""
|
||||
cmd = getattr(args, "platform_command", "list")
|
||||
|
||||
if cmd == "list":
|
||||
return cmd_list(args)
|
||||
elif cmd == "info":
|
||||
return cmd_info(args)
|
||||
else:
|
||||
return cmd_list(args)
|
||||
@@ -0,0 +1,977 @@
|
||||
"""Sandbox management commands for CUA CLI."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
from cua_cli.auth.store import get_api_key, require_api_key
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_json,
|
||||
print_success,
|
||||
print_table,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image string parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY_HOSTNAMES = {
|
||||
"ghcr.io",
|
||||
"docker.io",
|
||||
"registry.hub.docker.com",
|
||||
"quay.io",
|
||||
"gcr.io",
|
||||
"registry.cua.ai",
|
||||
}
|
||||
|
||||
_MACOS_ALIASES = {"macos", "mac", "osx"}
|
||||
_LINUX_ALIASES = {"linux", "ubuntu", "debian", "fedora"}
|
||||
_WINDOWS_ALIASES = {"windows", "win"}
|
||||
_ANDROID_ALIASES = {"android"}
|
||||
|
||||
|
||||
def _parse_image(image_str: str, vm: bool = False):
|
||||
"""Parse an image string into a cua_sandbox Image object.
|
||||
|
||||
Examples::
|
||||
|
||||
"macos" -> Image.macos("26")
|
||||
"macos:sequoia" -> Image.macos("sequoia")
|
||||
"ubuntu:24.04" -> Image.linux("ubuntu", "24.04")
|
||||
"linux" -> Image.linux("ubuntu", "24.04")
|
||||
"windows:11" -> Image.windows("11")
|
||||
"android:14" -> Image.android("14")
|
||||
"ghcr.io/org/img" -> Image.from_registry("ghcr.io/org/img")
|
||||
"""
|
||||
from cua_sandbox import Image
|
||||
|
||||
# Registry reference: contains '/' or starts with a known registry hostname
|
||||
if "/" in image_str:
|
||||
host = image_str.split("/")[0]
|
||||
if "." in host or host in _REGISTRY_HOSTNAMES:
|
||||
return Image.from_registry(image_str)
|
||||
for rh in _REGISTRY_HOSTNAMES:
|
||||
if image_str.startswith(rh):
|
||||
return Image.from_registry(image_str)
|
||||
|
||||
# Split on ':'
|
||||
parts = image_str.split(":", 1)
|
||||
base = parts[0].lower()
|
||||
tag = parts[1] if len(parts) > 1 else None
|
||||
|
||||
if base in _MACOS_ALIASES:
|
||||
version = tag or "26"
|
||||
return Image.macos(version)
|
||||
|
||||
if base in _LINUX_ALIASES:
|
||||
distro = base if base != "linux" else "ubuntu"
|
||||
version = tag or "24.04"
|
||||
kind = "vm" if vm else "container"
|
||||
return Image.linux(distro, version, kind=kind)
|
||||
|
||||
if base in _WINDOWS_ALIASES:
|
||||
version = tag or "11"
|
||||
return Image.windows(version)
|
||||
|
||||
if base in _ANDROID_ALIASES:
|
||||
version = tag or "14"
|
||||
return Image.android(version)
|
||||
|
||||
# Unknown — try registry
|
||||
return Image.from_registry(image_str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory / disk parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_memory(s: str) -> int:
|
||||
"""Convert a memory string to MB.
|
||||
|
||||
"8GB" -> 8192
|
||||
"8192MB" -> 8192
|
||||
"8" -> 8192 (bare number treated as GB)
|
||||
"""
|
||||
s = s.strip()
|
||||
if s.upper().endswith("GB"):
|
||||
return int(float(s[:-2])) * 1024
|
||||
if s.upper().endswith("MB"):
|
||||
return int(float(s[:-2]))
|
||||
# Bare number — treat as GB
|
||||
return int(float(s)) * 1024
|
||||
|
||||
|
||||
def _parse_disk(s: str) -> int:
|
||||
"""Convert a disk string to GB.
|
||||
|
||||
"50GB" -> 50
|
||||
"50" -> 50
|
||||
"51200MB" -> 50
|
||||
"""
|
||||
s = s.strip()
|
||||
if s.upper().endswith("GB"):
|
||||
return int(float(s[:-2]))
|
||||
if s.upper().endswith("MB"):
|
||||
return int(float(s[:-2])) // 1024
|
||||
return int(float(s))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox API URL resolution (for shell / exec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_sandbox_api_url(name: str, local: bool) -> tuple[str, Optional[str]]:
|
||||
"""Resolve the computer-server API URL and optional api_key for a sandbox.
|
||||
|
||||
Returns:
|
||||
Tuple of (api_url, api_key_or_none)
|
||||
"""
|
||||
if local:
|
||||
from cua_sandbox import sandbox_state
|
||||
|
||||
state = sandbox_state.load(name)
|
||||
if not state:
|
||||
raise ValueError(f"Local sandbox '{name}' not found. Check ~/.cua/sandboxes/")
|
||||
url = f"http://{state['host']}:{state['api_port']}"
|
||||
return url, None
|
||||
|
||||
api_key = require_api_key()
|
||||
from cua_sandbox.transport.cloud import CloudTransport, cloud_get_vm
|
||||
|
||||
vm = await cloud_get_vm(name, api_key=api_key)
|
||||
if not vm:
|
||||
raise ValueError(f"Sandbox '{name}' not found")
|
||||
if vm.get("status") == "not_found":
|
||||
raise ValueError(f"Sandbox '{name}' not found")
|
||||
url = CloudTransport._resolve_endpoint(vm)
|
||||
if not url:
|
||||
raise ValueError(f"Sandbox '{name}' has no API URL (is it running?)")
|
||||
return url, api_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the sandbox command and subcommands."""
|
||||
for cmd_name in ("sandbox", "sb"):
|
||||
sb_parser = subparsers.add_parser(
|
||||
cmd_name,
|
||||
help="Sandbox management commands",
|
||||
description="Manage cloud and local sandboxes",
|
||||
)
|
||||
|
||||
sb_subparsers = sb_parser.add_subparsers(
|
||||
dest="sandbox_command",
|
||||
help="Sandbox command",
|
||||
)
|
||||
|
||||
# -- launch ----------------------------------------------------------
|
||||
launch_parser = sb_subparsers.add_parser(
|
||||
"launch",
|
||||
help="Launch a new sandbox",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"image",
|
||||
help="Image to launch (e.g. macos, ubuntu:24.04, windows:11)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Launch a local sandbox",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help="Sandbox name",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--vm",
|
||||
action="store_true",
|
||||
help="Force VM kind for Linux images (default: container)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--cpu",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Number of vCPUs",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--memory",
|
||||
default=None,
|
||||
help="Memory (e.g. 8GB, 4096MB)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--disk",
|
||||
default=None,
|
||||
help="Disk size (e.g. 50GB)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--region",
|
||||
default=None,
|
||||
help="Cloud region",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- ls / list -------------------------------------------------------
|
||||
ls_parser = sb_subparsers.add_parser(
|
||||
"ls",
|
||||
aliases=["list"],
|
||||
help="List sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="List local sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="List both local and cloud sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- info ------------------------------------------------------------
|
||||
info_parser = sb_subparsers.add_parser(
|
||||
"info",
|
||||
aliases=["get"],
|
||||
help="Get sandbox details",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"name",
|
||||
help="Sandbox name",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- suspend ---------------------------------------------------------
|
||||
suspend_parser = sb_subparsers.add_parser(
|
||||
"suspend",
|
||||
help="Suspend a sandbox (preserves memory state)",
|
||||
)
|
||||
suspend_parser.add_argument("name", help="Sandbox name")
|
||||
suspend_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- resume ----------------------------------------------------------
|
||||
resume_parser = sb_subparsers.add_parser(
|
||||
"resume",
|
||||
help="Resume a suspended sandbox",
|
||||
)
|
||||
resume_parser.add_argument("name", help="Sandbox name")
|
||||
resume_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- restart ---------------------------------------------------------
|
||||
restart_parser = sb_subparsers.add_parser(
|
||||
"restart",
|
||||
help="Restart a sandbox",
|
||||
)
|
||||
restart_parser.add_argument("name", help="Sandbox name")
|
||||
restart_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- delete ----------------------------------------------------------
|
||||
delete_parser = sb_subparsers.add_parser(
|
||||
"delete",
|
||||
help="Delete a sandbox",
|
||||
)
|
||||
delete_parser.add_argument("name", help="Sandbox name")
|
||||
delete_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
delete_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
|
||||
# -- vnc -------------------------------------------------------------
|
||||
vnc_parser = sb_subparsers.add_parser(
|
||||
"vnc",
|
||||
help="Open sandbox in browser via VNC",
|
||||
)
|
||||
vnc_parser.add_argument("name", help="Sandbox name")
|
||||
vnc_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- shell -----------------------------------------------------------
|
||||
shell_parser = sb_subparsers.add_parser(
|
||||
"shell",
|
||||
help="Open interactive shell or run command in sandbox",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--cols",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Terminal width (default: auto-detect)",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--rows",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Terminal height (default: auto-detect)",
|
||||
)
|
||||
shell_parser.add_argument("name", help="Sandbox name")
|
||||
shell_parser.add_argument(
|
||||
"shell_command",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to run (optional, opens interactive shell if omitted)",
|
||||
)
|
||||
|
||||
# -- exec ------------------------------------------------------------
|
||||
exec_parser = sb_subparsers.add_parser(
|
||||
"exec",
|
||||
help="Execute command in sandbox (non-interactive)",
|
||||
)
|
||||
exec_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
exec_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
exec_parser.add_argument("name", help="Sandbox name")
|
||||
exec_parser.add_argument(
|
||||
"exec_command",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to execute",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# execute() dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute sandbox command based on subcommand."""
|
||||
cmd = getattr(args, "sandbox_command", None)
|
||||
|
||||
if cmd == "launch":
|
||||
return cmd_launch(args)
|
||||
elif cmd in ("ls", "list"):
|
||||
return cmd_ls(args)
|
||||
elif cmd in ("info", "get"):
|
||||
return cmd_info(args)
|
||||
elif cmd == "suspend":
|
||||
return cmd_suspend(args)
|
||||
elif cmd == "resume":
|
||||
return cmd_resume(args)
|
||||
elif cmd == "restart":
|
||||
return cmd_restart(args)
|
||||
elif cmd == "delete":
|
||||
return cmd_delete(args)
|
||||
elif cmd == "vnc":
|
||||
return cmd_vnc(args)
|
||||
elif cmd == "shell":
|
||||
return cmd_shell(args)
|
||||
elif cmd == "exec":
|
||||
return cmd_exec(args)
|
||||
else:
|
||||
print_error("Usage: cua sandbox <command>")
|
||||
print_info("Commands: launch, ls, info, suspend, resume, restart, delete, vnc, shell, exec")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_launch(args: argparse.Namespace) -> int:
|
||||
"""Launch a new sandbox."""
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
image = _parse_image(args.image, vm=getattr(args, "vm", False))
|
||||
|
||||
memory_mb = _parse_memory(args.memory) if args.memory else None
|
||||
disk_gb = _parse_disk(args.disk) if args.disk else None
|
||||
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
try:
|
||||
if local:
|
||||
sb = await Sandbox.create(
|
||||
image,
|
||||
local=True,
|
||||
name=args.name,
|
||||
)
|
||||
else:
|
||||
create_kwargs: dict[str, Any] = {}
|
||||
if args.name:
|
||||
create_kwargs["name"] = args.name
|
||||
if args.region:
|
||||
create_kwargs["region"] = args.region
|
||||
if getattr(args, "cpu", None):
|
||||
create_kwargs["cpu"] = args.cpu
|
||||
if memory_mb is not None:
|
||||
create_kwargs["memory_mb"] = memory_mb
|
||||
if disk_gb is not None:
|
||||
create_kwargs["disk_gb"] = disk_gb
|
||||
api_key = require_api_key()
|
||||
create_kwargs["api_key"] = api_key
|
||||
sb = await Sandbox.create(image, **create_kwargs)
|
||||
|
||||
name = sb.name
|
||||
await sb.disconnect()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print_error(f"Failed to launch sandbox: {e!r}\n{traceback.format_exc()}")
|
||||
return 1
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print_json({"name": name, "status": "ready"})
|
||||
else:
|
||||
print_success(f"Sandbox '{name}' is ready")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_ls(args: argparse.Namespace) -> int:
|
||||
"""List sandboxes."""
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
show_all = getattr(args, "all", False)
|
||||
local = getattr(args, "local", False)
|
||||
as_json = getattr(args, "json", False)
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
if show_all or local:
|
||||
try:
|
||||
local_list = await Sandbox.list(local=True)
|
||||
for s in local_list:
|
||||
results.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"status": s.status,
|
||||
"source": getattr(s, "source", "local"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if show_all or not local:
|
||||
try:
|
||||
api_key = get_api_key()
|
||||
cloud_list = await Sandbox.list(local=False, api_key=api_key)
|
||||
for s in cloud_list:
|
||||
results.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"status": s.status,
|
||||
"source": "cloud",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if as_json:
|
||||
print_json(results)
|
||||
return 0
|
||||
|
||||
if not results:
|
||||
print_info("No sandboxes found.")
|
||||
return 0
|
||||
|
||||
print_table(
|
||||
results,
|
||||
[("name", "NAME"), ("status", "STATUS"), ("source", "SOURCE")],
|
||||
)
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> int:
|
||||
"""Get sandbox details."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
info = await Sandbox.get_info(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if getattr(args, "json", False):
|
||||
data = {
|
||||
"name": info.name,
|
||||
"status": info.status,
|
||||
}
|
||||
for attr in ("os_type", "host", "region", "created_at", "cpu", "memory_mb", "disk_gb"):
|
||||
val = getattr(info, attr, None)
|
||||
if val is not None:
|
||||
data[attr] = val
|
||||
print_json(data)
|
||||
return 0
|
||||
|
||||
print_info(f"Name: {info.name}")
|
||||
print_info(f"Status: {info.status}")
|
||||
for attr, label in [
|
||||
("os_type", "OS"),
|
||||
("host", "Host"),
|
||||
("region", "Region"),
|
||||
("created_at", "Created"),
|
||||
]:
|
||||
val = getattr(info, attr, None)
|
||||
if val:
|
||||
print_info(f"{label}: {val}")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_suspend(args: argparse.Namespace) -> int:
|
||||
"""Suspend a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.suspend(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to suspend sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is suspending.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_resume(args: argparse.Namespace) -> int:
|
||||
"""Resume a suspended sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.resume(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to resume sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is resuming.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_restart(args: argparse.Namespace) -> int:
|
||||
"""Restart a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.restart(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to restart sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is restarting.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace) -> int:
|
||||
"""Delete a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
force = getattr(args, "force", False)
|
||||
|
||||
if not force:
|
||||
import sys
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
force = True
|
||||
else:
|
||||
try:
|
||||
answer = input(f"Delete sandbox '{args.name}'? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return 1
|
||||
if answer not in ("y", "yes"):
|
||||
print_info("Aborted.")
|
||||
return 0
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.delete(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to delete sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is being deleted.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_vnc(args: argparse.Namespace) -> int:
|
||||
"""Open sandbox in browser via VNC."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
sb = await Sandbox.connect(args.name, local=local, **kwargs)
|
||||
vnc_url = await sb.get_display_url(share=True)
|
||||
await sb.disconnect()
|
||||
except Exception as e:
|
||||
print_error(f"Failed to get VNC URL: {e}")
|
||||
return 1
|
||||
|
||||
print_info(f"Opening VNC: {vnc_url}")
|
||||
webbrowser.open(vnc_url)
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PTY / WebSocket helpers (shell / exec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _default_shell() -> str:
|
||||
return "powershell" if sys.platform == "win32" else "bash"
|
||||
|
||||
|
||||
async def _shell_interactive(
|
||||
name: str,
|
||||
api_url: str,
|
||||
api_key: Optional[str],
|
||||
command: Optional[str],
|
||||
cols: Optional[int],
|
||||
rows: Optional[int],
|
||||
) -> int:
|
||||
"""Run an interactive PTY session via WebSocket."""
|
||||
import signal
|
||||
import threading
|
||||
|
||||
_auto_cols, _auto_rows = shutil.get_terminal_size((80, 24))
|
||||
cols = cols if cols is not None else _auto_cols
|
||||
rows = rows if rows is not None else _auto_rows
|
||||
|
||||
ws_url = api_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"X-Container-Name": name,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
ws_params: dict[str, str] = {
|
||||
"container_name": name,
|
||||
}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
ws_params["api_key"] = api_key
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{api_url}/pty",
|
||||
json={"command": command or _default_shell(), "cols": cols, "rows": rows},
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
raise ValueError("Authentication failed. Is the sandbox running?")
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientResponseError as e:
|
||||
raise ValueError(f"PTY unavailable: {e.status} {e.message}") from e
|
||||
except aiohttp.ClientError as e:
|
||||
raise ValueError(f"Connection failed: {e}") from e
|
||||
|
||||
pid: int = data["pid"]
|
||||
exit_code_cell: list[int] = [0]
|
||||
done_event = asyncio.Event()
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
async def _run_ws() -> None:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.ws_connect(f"{ws_url}/pty/{pid}/ws", params=ws_params) as ws:
|
||||
|
||||
def _stdin_loop() -> None:
|
||||
while not done_event.is_set():
|
||||
try:
|
||||
ch = msvcrt.getch()
|
||||
if ch:
|
||||
encoded = base64.b64encode(ch).decode()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "stdin", "data": encoded})),
|
||||
asyncio.get_event_loop(),
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
t = threading.Thread(target=_stdin_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
async for raw_msg in ws:
|
||||
if raw_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
msg = json.loads(raw_msg.data)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") == "output":
|
||||
chunk = base64.b64decode(msg["data"])
|
||||
sys.stdout.buffer.write(chunk)
|
||||
sys.stdout.buffer.flush()
|
||||
elif msg.get("type") == "exit":
|
||||
exit_code_cell[0] = int(msg.get("code", 0))
|
||||
break
|
||||
elif raw_msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
||||
break
|
||||
done_event.set()
|
||||
|
||||
await _run_ws()
|
||||
else:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
old_settings = termios.tcgetattr(sys.stdin.fileno())
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _run_ws() -> None:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.ws_connect(f"{ws_url}/pty/{pid}/ws", params=ws_params) as ws:
|
||||
|
||||
def _resize(_sig=None, _frame=None) -> None:
|
||||
c, r = shutil.get_terminal_size((80, 24))
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "resize", "cols": c, "rows": r})),
|
||||
loop,
|
||||
)
|
||||
|
||||
signal.signal(signal.SIGWINCH, _resize)
|
||||
|
||||
def _stdin_loop() -> None:
|
||||
while not done_event.is_set():
|
||||
try:
|
||||
ch = sys.stdin.buffer.read(1)
|
||||
if not ch:
|
||||
break
|
||||
encoded = base64.b64encode(ch).decode()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "stdin", "data": encoded})),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
t = threading.Thread(target=_stdin_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
async for raw_msg in ws:
|
||||
if raw_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
msg = json.loads(raw_msg.data)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") == "output":
|
||||
chunk = base64.b64decode(msg["data"])
|
||||
sys.stdout.buffer.write(chunk)
|
||||
sys.stdout.buffer.flush()
|
||||
elif msg.get("type") == "exit":
|
||||
exit_code_cell[0] = int(msg.get("code", 0))
|
||||
break
|
||||
elif raw_msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
||||
break
|
||||
done_event.set()
|
||||
|
||||
try:
|
||||
await _run_ws()
|
||||
finally:
|
||||
try:
|
||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_settings)
|
||||
except Exception:
|
||||
pass
|
||||
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
|
||||
|
||||
return exit_code_cell[0]
|
||||
|
||||
|
||||
async def _exec_noninteractive(
|
||||
name: str,
|
||||
api_url: str,
|
||||
api_key: Optional[str],
|
||||
command: str,
|
||||
) -> dict:
|
||||
"""Execute a command non-interactively and return result."""
|
||||
headers: dict[str, str] = {
|
||||
"X-Container-Name": name,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{api_url}/cmd",
|
||||
json={"command": "run_command", "params": {"command": command}},
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
return {"success": False, "error": "Authentication failed"}
|
||||
text = await resp.text()
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
return json.loads(line[6:])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": f"Unexpected response: {text[:200]}"}
|
||||
|
||||
|
||||
def cmd_shell(args: argparse.Namespace) -> int:
|
||||
"""Open interactive shell or run command in sandbox."""
|
||||
command_parts = getattr(args, "shell_command", [])
|
||||
command = " ".join(command_parts).strip() if command_parts else None
|
||||
cols: Optional[int] = getattr(args, "cols", None)
|
||||
rows: Optional[int] = getattr(args, "rows", None)
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
try:
|
||||
api_url, api_key = await _get_sandbox_api_url(args.name, local)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
if not command:
|
||||
print_error("No command provided for non-interactive mode")
|
||||
return 1
|
||||
result = await _exec_noninteractive(args.name, api_url, api_key, command)
|
||||
if not result.get("success", True):
|
||||
print_error(result.get("error", "Command failed"))
|
||||
return 1
|
||||
stdout = result.get("stdout", "").strip()
|
||||
stderr = result.get("stderr", "").strip()
|
||||
returncode = result.get("returncode") or result.get("return_code") or 0
|
||||
if stdout:
|
||||
print(stdout)
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
return returncode
|
||||
|
||||
try:
|
||||
return await _shell_interactive(args.name, api_url, api_key, command, cols, rows)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_exec(args: argparse.Namespace) -> int:
|
||||
"""Execute command in sandbox (non-interactive)."""
|
||||
command_parts = getattr(args, "exec_command", [])
|
||||
command = " ".join(command_parts).strip() if command_parts else None
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
if not command:
|
||||
print_error("No command provided")
|
||||
print_info("Usage: cua sb exec <name> <command>")
|
||||
return 1
|
||||
|
||||
async def _run() -> int:
|
||||
try:
|
||||
api_url, api_key = await _get_sandbox_api_url(args.name, local)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
result = await _exec_noninteractive(args.name, api_url, api_key, command)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print_json(result)
|
||||
return result.get("returncode") or result.get("return_code") or 0
|
||||
|
||||
if not result.get("success", True):
|
||||
print_error(result.get("error", "Command failed"))
|
||||
return 1
|
||||
|
||||
stdout = result.get("stdout", "").strip()
|
||||
stderr = result.get("stderr", "").strip()
|
||||
returncode = result.get("returncode") or result.get("return_code") or 0
|
||||
|
||||
if stdout:
|
||||
print(stdout)
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
return run_async(_run())
|
||||
@@ -0,0 +1,918 @@
|
||||
"""Skills management commands for CUA CLI.
|
||||
|
||||
Skills are recorded demonstrations that can guide agent behavior.
|
||||
Each skill contains:
|
||||
- SKILL.md: Markdown file with frontmatter and steps
|
||||
- trajectory/: Directory with video, events.json, trajectory.json, screenshots
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_json,
|
||||
print_success,
|
||||
print_table,
|
||||
)
|
||||
|
||||
# Skills directory
|
||||
SKILLS_DIR = Path.home() / ".cua" / "skills"
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the skills command and subcommands."""
|
||||
skills_parser = subparsers.add_parser(
|
||||
"skills",
|
||||
help="Manage demonstration skills",
|
||||
description="Record and manage demonstration skills for agent guidance",
|
||||
)
|
||||
|
||||
skills_subparsers = skills_parser.add_subparsers(
|
||||
dest="skills_command",
|
||||
help="Skills command",
|
||||
)
|
||||
|
||||
# list command
|
||||
list_parser = skills_subparsers.add_parser(
|
||||
"list",
|
||||
aliases=["ls"],
|
||||
help="List all saved skills",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# read command
|
||||
read_parser = skills_subparsers.add_parser(
|
||||
"read",
|
||||
help="Read a skill",
|
||||
)
|
||||
read_parser.add_argument(
|
||||
"name",
|
||||
help="Skill name",
|
||||
)
|
||||
read_parser.add_argument(
|
||||
"--format",
|
||||
"-f",
|
||||
choices=["json", "md"],
|
||||
default="md",
|
||||
help="Output format (default: md)",
|
||||
)
|
||||
|
||||
# replay command
|
||||
replay_parser = skills_subparsers.add_parser(
|
||||
"replay",
|
||||
help="Open the video recording for a skill",
|
||||
)
|
||||
replay_parser.add_argument(
|
||||
"name",
|
||||
help="Skill name",
|
||||
)
|
||||
|
||||
# delete command
|
||||
delete_parser = skills_subparsers.add_parser(
|
||||
"delete",
|
||||
help="Delete a skill",
|
||||
)
|
||||
delete_parser.add_argument(
|
||||
"name",
|
||||
help="Skill name",
|
||||
)
|
||||
|
||||
# clean command
|
||||
skills_subparsers.add_parser(
|
||||
"clean",
|
||||
help="Delete all skills (with confirmation)",
|
||||
)
|
||||
|
||||
# record command
|
||||
record_parser = skills_subparsers.add_parser(
|
||||
"record",
|
||||
help="Record a demonstration and create a skill",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--sandbox",
|
||||
"-s",
|
||||
type=str,
|
||||
help="Sandbox name to connect to",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--vnc-url",
|
||||
"-u",
|
||||
type=str,
|
||||
help="Direct VNC URL to connect to",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--provider",
|
||||
"-p",
|
||||
choices=["anthropic", "openai"],
|
||||
default="anthropic",
|
||||
help="LLM provider for captioning (default: anthropic)",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
type=str,
|
||||
help="Model to use for captioning",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--api-key",
|
||||
"-k",
|
||||
type=str,
|
||||
help="API key for the LLM provider",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--name",
|
||||
"-n",
|
||||
type=str,
|
||||
help="Skill name (skips interactive prompt)",
|
||||
)
|
||||
record_parser.add_argument(
|
||||
"--description",
|
||||
"-d",
|
||||
type=str,
|
||||
help="Skill description (skips interactive prompt)",
|
||||
)
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute skills command based on subcommand."""
|
||||
cmd = getattr(args, "skills_command", None)
|
||||
|
||||
if cmd in ("list", "ls"):
|
||||
return cmd_list(args)
|
||||
elif cmd == "read":
|
||||
return cmd_read(args)
|
||||
elif cmd == "replay":
|
||||
return cmd_replay(args)
|
||||
elif cmd == "delete":
|
||||
return cmd_delete(args)
|
||||
elif cmd == "clean":
|
||||
return cmd_clean(args)
|
||||
elif cmd == "record":
|
||||
return cmd_record(args)
|
||||
else:
|
||||
print_error("Usage: cua skills <command>")
|
||||
print_info("Commands: list, read, replay, delete, clean, record")
|
||||
return 1
|
||||
|
||||
|
||||
def _ensure_skills_dir() -> None:
|
||||
"""Ensure skills directory exists."""
|
||||
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> Optional[dict[str, str]]:
|
||||
"""Parse YAML frontmatter from markdown content."""
|
||||
import re
|
||||
|
||||
match = re.match(r"^---\n(.*?)\n---\n(.*)$", content, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
frontmatter = match.group(1)
|
||||
body = match.group(2).strip()
|
||||
|
||||
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||
desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||
|
||||
if not name_match or not desc_match:
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name_match.group(1).strip(),
|
||||
"description": desc_match.group(1).strip(),
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def _get_skill_info(skill_dir: Path) -> Optional[dict[str, Any]]:
|
||||
"""Get skill info from a skill directory."""
|
||||
skill_path = skill_dir / "SKILL.md"
|
||||
if not skill_path.exists():
|
||||
return None
|
||||
|
||||
content = skill_path.read_text()
|
||||
parsed = _parse_frontmatter(content)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
# Try to read trajectory.json for additional info
|
||||
steps = 0
|
||||
created = None
|
||||
trajectory_path = skill_dir / "trajectory" / "trajectory.json"
|
||||
if trajectory_path.exists():
|
||||
try:
|
||||
traj_data = json.loads(trajectory_path.read_text())
|
||||
steps = len(traj_data.get("trajectory", []))
|
||||
if traj_data.get("metadata", {}).get("created_at"):
|
||||
created = traj_data["metadata"]["created_at"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": parsed["name"],
|
||||
"description": parsed["description"],
|
||||
"steps": steps,
|
||||
"created": created,
|
||||
"path": str(skill_dir),
|
||||
}
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List all skills."""
|
||||
_ensure_skills_dir()
|
||||
|
||||
skills = []
|
||||
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
info = _get_skill_info(skill_dir)
|
||||
if info:
|
||||
skills.append(info)
|
||||
|
||||
if args.json:
|
||||
print_json(skills)
|
||||
return 0
|
||||
|
||||
if not skills:
|
||||
print_info("No skills found.")
|
||||
print_info("Record a skill with: cua skills record --sandbox <name>")
|
||||
return 0
|
||||
|
||||
# Format for table
|
||||
rows = []
|
||||
for skill in skills:
|
||||
created = "-"
|
||||
if skill["created"]:
|
||||
try:
|
||||
dt = datetime.fromisoformat(skill["created"].replace("Z", "+00:00"))
|
||||
created = dt.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
created = skill["created"][:10]
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"name": skill["name"],
|
||||
"description": skill["description"][:40]
|
||||
+ ("..." if len(skill["description"]) > 40 else ""),
|
||||
"steps": str(skill["steps"]),
|
||||
"created": created,
|
||||
}
|
||||
)
|
||||
|
||||
columns = [
|
||||
("name", "NAME"),
|
||||
("description", "DESCRIPTION"),
|
||||
("steps", "STEPS"),
|
||||
("created", "CREATED"),
|
||||
]
|
||||
print_table(rows, columns)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_read(args: argparse.Namespace) -> int:
|
||||
"""Read a skill."""
|
||||
_ensure_skills_dir()
|
||||
|
||||
skill_dir = SKILLS_DIR / args.name
|
||||
skill_path = skill_dir / "SKILL.md"
|
||||
|
||||
if not skill_path.exists():
|
||||
print_error(f"Skill not found: {args.name}")
|
||||
return 1
|
||||
|
||||
content = skill_path.read_text()
|
||||
|
||||
if args.format == "md":
|
||||
print(content)
|
||||
return 0
|
||||
|
||||
# JSON format - include trajectory data
|
||||
parsed = _parse_frontmatter(content)
|
||||
if not parsed:
|
||||
print_error(f"Invalid skill file format: {args.name}")
|
||||
return 1
|
||||
|
||||
trajectory_path = skill_dir / "trajectory" / "trajectory.json"
|
||||
trajectory = []
|
||||
metadata = {}
|
||||
|
||||
if trajectory_path.exists():
|
||||
try:
|
||||
traj_data = json.loads(trajectory_path.read_text())
|
||||
trajectory = traj_data.get("trajectory", [])
|
||||
metadata = traj_data.get("metadata", {})
|
||||
except Exception as e:
|
||||
print_error(f"Failed to read trajectory: {e}")
|
||||
|
||||
result = {
|
||||
"name": parsed["name"],
|
||||
"description": parsed["description"],
|
||||
"trajectory": trajectory,
|
||||
"skill_prompt": parsed["body"],
|
||||
"trajectory_dir": str(skill_dir / "trajectory"),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
print_json(result)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_replay(args: argparse.Namespace) -> int:
|
||||
"""Open the video recording for a skill."""
|
||||
_ensure_skills_dir()
|
||||
|
||||
skill_dir = SKILLS_DIR / args.name
|
||||
if not skill_dir.exists():
|
||||
print_error(f"Skill not found: {args.name}")
|
||||
return 1
|
||||
|
||||
# Find MP4 file
|
||||
trajectory_dir = skill_dir / "trajectory"
|
||||
mp4_files = list(trajectory_dir.glob("*.mp4"))
|
||||
|
||||
if not mp4_files:
|
||||
print_error(f"No video found in: {trajectory_dir}")
|
||||
return 1
|
||||
|
||||
video_path = mp4_files[0]
|
||||
print_info(f"Opening: {video_path}")
|
||||
webbrowser.open(f"file://{video_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace) -> int:
|
||||
"""Delete a skill."""
|
||||
_ensure_skills_dir()
|
||||
|
||||
skill_dir = SKILLS_DIR / args.name
|
||||
if not skill_dir.exists():
|
||||
print_error(f"Skill not found: {args.name}")
|
||||
return 1
|
||||
|
||||
shutil.rmtree(skill_dir)
|
||||
print_success(f"Deleted skill: {args.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_clean(args: argparse.Namespace) -> int:
|
||||
"""Delete all skills with confirmation."""
|
||||
_ensure_skills_dir()
|
||||
|
||||
skills = [d for d in SKILLS_DIR.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
|
||||
|
||||
if not skills:
|
||||
print_info("No skills to clean.")
|
||||
return 0
|
||||
|
||||
print_info("Skills to delete:")
|
||||
for skill_dir in sorted(skills):
|
||||
print(f" - {skill_dir.name}")
|
||||
|
||||
response = input(f"\nDelete {len(skills)} skill(s)? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print_info("Cancelled.")
|
||||
return 0
|
||||
|
||||
for skill_dir in skills:
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
print_success(f"Deleted {len(skills)} skill(s).")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_record(args: argparse.Namespace) -> int:
|
||||
"""Record a demonstration and create a skill.
|
||||
|
||||
This is a complex operation that:
|
||||
1. Starts a WebSocket server to receive the recording
|
||||
2. Opens the VNC viewer with recording parameters
|
||||
3. Waits for the recording to complete
|
||||
4. Extracts frames and captions them with LLM
|
||||
5. Saves the skill to disk
|
||||
"""
|
||||
# Check for required dependencies
|
||||
if not _check_ffmpeg():
|
||||
print_error("ffmpeg is required for skill recording.")
|
||||
print_info("Install with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)")
|
||||
return 1
|
||||
|
||||
if not args.sandbox and not args.vnc_url:
|
||||
print_error("Either --sandbox or --vnc-url is required")
|
||||
return 1
|
||||
|
||||
# Defer to async implementation
|
||||
return run_async(_record_skill_async(args))
|
||||
|
||||
|
||||
def _check_ffmpeg() -> bool:
|
||||
"""Check if ffmpeg is available."""
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
async def _record_skill_async(args: argparse.Namespace) -> int:
|
||||
"""Async implementation of skill recording."""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import websockets
|
||||
|
||||
# Get LLM API key
|
||||
provider = args.provider
|
||||
api_key = args.api_key
|
||||
|
||||
if not api_key:
|
||||
if provider == "openai":
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
else:
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
env_var = "OPENAI_API_KEY" if provider == "openai" else "ANTHROPIC_API_KEY"
|
||||
print_error(f"No {provider.upper()} API key found.")
|
||||
print_info(f"Set {env_var} environment variable or use --api-key flag.")
|
||||
return 1
|
||||
|
||||
model = args.model
|
||||
if not model:
|
||||
model = "gpt-4o-mini" if provider == "openai" else "claude-haiku-4-5"
|
||||
|
||||
# Start WebSocket server to receive recording
|
||||
recording_data = bytearray()
|
||||
recording_complete = asyncio.Event()
|
||||
|
||||
async def handle_ws(websocket):
|
||||
nonlocal recording_data
|
||||
try:
|
||||
async for message in websocket:
|
||||
if isinstance(message, bytes):
|
||||
recording_data.extend(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
recording_complete.set()
|
||||
|
||||
# Find available port
|
||||
import socket
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("localhost", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
server = await websockets.serve(handle_ws, "localhost", port)
|
||||
record_url = f"ws://localhost:{port}"
|
||||
|
||||
print_info(f"Recording server started on port {port}")
|
||||
|
||||
# Build VNC URL with recording parameters
|
||||
if args.sandbox:
|
||||
# Get sandbox VNC URL
|
||||
from cua_cli.auth.store import require_api_key
|
||||
from cua_sandbox.transport.cloud import cloud_get_vm
|
||||
|
||||
try:
|
||||
vm = await cloud_get_vm(args.sandbox, api_key=require_api_key())
|
||||
except Exception:
|
||||
vm = None
|
||||
if not vm or vm.get("status") == "not_found":
|
||||
print_error(f"Sandbox not found: {args.sandbox}")
|
||||
server.close()
|
||||
return 1
|
||||
if vm.get("status") != "running":
|
||||
print_error(f"Sandbox is not running (status: {vm.get('status')})")
|
||||
server.close()
|
||||
return 1
|
||||
host = vm.get("host", f"{args.sandbox}.sandbox.cua.ai")
|
||||
password = vm.get("password", "")
|
||||
from urllib.parse import quote
|
||||
|
||||
base_url = (
|
||||
f"https://{host}/vnc.html?autoconnect=true&password={quote(password)}&show_dot=true"
|
||||
)
|
||||
else:
|
||||
base_url = args.vnc_url
|
||||
|
||||
# Add recording parameters
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
params = parse_qs(parsed.query)
|
||||
params["autorecord"] = ["true"]
|
||||
params["record_format"] = ["mp4"]
|
||||
params["record_url"] = [record_url]
|
||||
recording_url = (
|
||||
f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(params, doseq=True)}"
|
||||
)
|
||||
|
||||
print_info("\nRecording will start automatically when you connect.")
|
||||
print_info("When finished, click 'Stop Recording' in the VNC panel.\n")
|
||||
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(recording_url)
|
||||
|
||||
# Wait for recording (30 min timeout)
|
||||
try:
|
||||
await asyncio.wait_for(recording_complete.wait(), timeout=30 * 60)
|
||||
except asyncio.TimeoutError:
|
||||
print_error("Recording timeout (30 minutes)")
|
||||
server.close()
|
||||
return 1
|
||||
|
||||
server.close()
|
||||
|
||||
if len(recording_data) == 0:
|
||||
print_error("No recording data received")
|
||||
return 1
|
||||
|
||||
print_info(f"Received {len(recording_data)} bytes of recording data")
|
||||
|
||||
# Get skill name
|
||||
skill_name = args.name
|
||||
if not skill_name:
|
||||
skill_name = input("Enter skill name: ").strip()
|
||||
while not skill_name or not skill_name.replace("-", "").replace("_", "").isalnum():
|
||||
print("Use only letters, numbers, hyphens, and underscores.")
|
||||
skill_name = input("Enter skill name: ").strip()
|
||||
|
||||
# Ensure unique name
|
||||
_ensure_skills_dir()
|
||||
final_name = skill_name
|
||||
counter = 1
|
||||
while (SKILLS_DIR / final_name).exists():
|
||||
final_name = f"{skill_name}-{counter}"
|
||||
counter += 1
|
||||
|
||||
if final_name != skill_name:
|
||||
print_info(f'Skill "{skill_name}" exists, using "{final_name}"')
|
||||
skill_name = final_name
|
||||
|
||||
# Get description
|
||||
description = args.description
|
||||
if not description:
|
||||
description = input("Describe what this skill demonstrates: ").strip()
|
||||
while not description:
|
||||
print("Description is required.")
|
||||
description = input("Describe what this skill demonstrates: ").strip()
|
||||
|
||||
print_info("\nProcessing recording...")
|
||||
|
||||
# Process recording
|
||||
result = await _process_recording(
|
||||
recording_data=bytes(recording_data),
|
||||
skill_name=skill_name,
|
||||
description=description,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
if not result:
|
||||
print_error("Failed to process recording")
|
||||
return 1
|
||||
|
||||
print_success(f"\nSkill saved: {SKILLS_DIR / skill_name / 'SKILL.md'}")
|
||||
print_info(f"Steps: {result['steps']}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _process_recording(
|
||||
recording_data: bytes,
|
||||
skill_name: str,
|
||||
description: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
api_key: str,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Process recording data and create skill files."""
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# Parse recording format: [4 bytes JSON length][JSON][MP4 data]
|
||||
if len(recording_data) < 4:
|
||||
print_error("Recording data too short")
|
||||
return None
|
||||
|
||||
json_length = struct.unpack(">I", recording_data[:4])[0]
|
||||
if len(recording_data) < 4 + json_length:
|
||||
print_error("Invalid recording format")
|
||||
return None
|
||||
|
||||
json_bytes = recording_data[4 : 4 + json_length]
|
||||
mp4_data = recording_data[4 + json_length :]
|
||||
|
||||
if not mp4_data:
|
||||
print_error("No video data in recording")
|
||||
return None
|
||||
|
||||
try:
|
||||
recording_json = json.loads(json_bytes.decode())
|
||||
except Exception as e:
|
||||
print_error(f"Failed to parse recording JSON: {e}")
|
||||
return None
|
||||
|
||||
events = recording_json.get("events", [])
|
||||
metadata = recording_json.get("metadata", {})
|
||||
|
||||
# Create skill directory structure
|
||||
skill_dir = SKILLS_DIR / skill_name
|
||||
trajectory_dir = skill_dir / "trajectory"
|
||||
trajectory_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save video
|
||||
video_path = trajectory_dir / f"{skill_name}.mp4"
|
||||
video_path.write_bytes(mp4_data)
|
||||
|
||||
# Save events
|
||||
events_path = trajectory_dir / "events.json"
|
||||
events_path.write_text(json.dumps({"events": events, "metadata": metadata}, indent=2))
|
||||
|
||||
# Process each event with LLM captioning
|
||||
trajectory = []
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
) as progress:
|
||||
task = progress.add_task("Captioning steps...", total=len(events))
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
step_idx = idx + 1
|
||||
|
||||
# Extract frame at event timestamp
|
||||
frame_path = temp_path / f"step_{step_idx}.jpg"
|
||||
timestamp_sec = max(0, event.get("timestamp", 0) / 1000 - 0.1)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{timestamp_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
str(frame_path),
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0 or not frame_path.exists():
|
||||
# Skip if frame extraction fails
|
||||
trajectory.append(
|
||||
{
|
||||
"step_idx": step_idx,
|
||||
"caption": {
|
||||
"observation": "",
|
||||
"think": "",
|
||||
"action": event.get("type", ""),
|
||||
"expectation": "",
|
||||
},
|
||||
"raw_event": event,
|
||||
}
|
||||
)
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
# Caption with LLM
|
||||
caption = await _caption_step(
|
||||
frame_path=frame_path,
|
||||
event=event,
|
||||
step_idx=step_idx,
|
||||
description=description,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Save screenshot to trajectory dir
|
||||
dest_full = trajectory_dir / f"step_{step_idx}_full.jpg"
|
||||
shutil.copy(frame_path, dest_full)
|
||||
|
||||
trajectory.append(
|
||||
{
|
||||
"step_idx": step_idx,
|
||||
"caption": caption,
|
||||
"raw_event": event,
|
||||
"screenshot_full": str(dest_full),
|
||||
}
|
||||
)
|
||||
|
||||
progress.update(task, advance=1)
|
||||
|
||||
# Save trajectory
|
||||
trajectory_json_path = trajectory_dir / "trajectory.json"
|
||||
trajectory_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"events": events,
|
||||
"trajectory": trajectory,
|
||||
"metadata": {
|
||||
"task_description": description,
|
||||
"total_steps": len(trajectory),
|
||||
"width": metadata.get("width"),
|
||||
"height": metadata.get("height"),
|
||||
"duration": metadata.get("duration"),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
# Generate skill markdown
|
||||
steps_text = "\n".join(
|
||||
[
|
||||
f"Step {s['step_idx']}: {s['caption'].get('action', s['raw_event'].get('type', ''))}"
|
||||
for s in trajectory
|
||||
]
|
||||
)
|
||||
|
||||
skill_prompt = f"""You have been shown a demonstration of how to perform this task:
|
||||
{description}
|
||||
|
||||
The demonstration consisted of the following steps:
|
||||
{steps_text}
|
||||
|
||||
Follow this workflow pattern, adapting as needed for the current screen state.
|
||||
Total steps: {len(trajectory)}"""
|
||||
|
||||
steps_markdown = "\n".join(
|
||||
[
|
||||
f"### Step {s['step_idx']}: {s['caption'].get('action', s['raw_event'].get('type', ''))}\n\n"
|
||||
f"**Context:** {s['caption'].get('observation', '')}\n\n"
|
||||
f"**Intent:** {s['caption'].get('think', '')}\n\n"
|
||||
f"**Expected Result:** {s['caption'].get('expectation', '')}\n"
|
||||
for s in trajectory
|
||||
]
|
||||
)
|
||||
|
||||
skill_content = f"""---
|
||||
name: {skill_name}
|
||||
description: {description}
|
||||
---
|
||||
|
||||
# {skill_name}
|
||||
|
||||
{description}
|
||||
|
||||
## Steps
|
||||
|
||||
{steps_markdown}
|
||||
|
||||
## Agent Prompt
|
||||
|
||||
{skill_prompt}
|
||||
"""
|
||||
|
||||
skill_path = skill_dir / "SKILL.md"
|
||||
skill_path.write_text(skill_content)
|
||||
|
||||
return {"steps": len(trajectory)}
|
||||
|
||||
|
||||
async def _caption_step(
|
||||
frame_path: Path,
|
||||
event: dict,
|
||||
step_idx: int,
|
||||
description: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
api_key: str,
|
||||
) -> dict[str, str]:
|
||||
"""Caption a single step using LLM."""
|
||||
import base64
|
||||
|
||||
import aiohttp
|
||||
|
||||
# Build prompt
|
||||
prompt = f"""Describe this GUI action step. The overall task is: {description}
|
||||
|
||||
Step {step_idx}: {event.get("type", "action")}
|
||||
Event data: {json.dumps(event.get("data", {}))}
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"Observation": "Describe what you see in the screenshot",
|
||||
"Think": "Explain the user's likely intention",
|
||||
"Action": "Describe the action being taken",
|
||||
"Expectation": "What should happen after this action"
|
||||
}}"""
|
||||
|
||||
# Read image
|
||||
image_data = frame_path.read_bytes()
|
||||
image_b64 = base64.b64encode(image_data).decode()
|
||||
|
||||
try:
|
||||
if provider == "openai":
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return {
|
||||
"observation": "",
|
||||
"think": "",
|
||||
"action": event.get("type", ""),
|
||||
"expectation": "",
|
||||
}
|
||||
data = await resp.json()
|
||||
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": 1200,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": image_b64,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return {
|
||||
"observation": "",
|
||||
"think": "",
|
||||
"action": event.get("type", ""),
|
||||
"expectation": "",
|
||||
}
|
||||
data = await resp.json()
|
||||
text = data.get("content", [{}])[0].get("text", "")
|
||||
|
||||
# Parse JSON response
|
||||
import re
|
||||
|
||||
json_match = re.search(r"\{[\s\S]*\}", text)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group())
|
||||
return {
|
||||
"observation": parsed.get("Observation", parsed.get("observation", "")),
|
||||
"think": parsed.get("Think", parsed.get("think", "")),
|
||||
"action": parsed.get("Action", parsed.get("action", "")),
|
||||
"expectation": parsed.get("Expectation", parsed.get("expectation", "")),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"observation": "", "think": "", "action": event.get("type", ""), "expectation": ""}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""cua trajectory — manage recorded action trajectories.
|
||||
|
||||
Subcommands:
|
||||
ls List trajectory sessions
|
||||
view Zip + serve locally, open cua.ai/trajectory-viewer
|
||||
clean Delete old sessions
|
||||
stop Stop the local file server
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from cua_cli.utils.trajectory_recorder import (
|
||||
clean_trajectories,
|
||||
list_trajectories,
|
||||
zip_trajectory,
|
||||
)
|
||||
|
||||
_PID_FILE = Path.home() / ".cua" / "trajectory_server.pid"
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register ``cua trajectory`` (and alias ``cua traj``) commands."""
|
||||
for cmd_name in ("trajectory", "traj"):
|
||||
p = subparsers.add_parser(
|
||||
cmd_name,
|
||||
help="Manage recorded action trajectories",
|
||||
description="List, view, and clean trajectory recordings from cua do sessions.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
cua trajectory ls List all sessions
|
||||
cua trajectory ls my-container List sessions for a machine
|
||||
cua trajectory view View latest session in browser
|
||||
cua trajectory view my-container View latest for specific machine
|
||||
cua trajectory view --port 9090 Use a custom port
|
||||
cua trajectory stop Stop the file server
|
||||
cua trajectory clean --older-than 7 Delete sessions older than 7 days
|
||||
cua trajectory clean --machine my-ct -y Delete all sessions for a machine
|
||||
""",
|
||||
)
|
||||
|
||||
sub = p.add_subparsers(dest="traj_action", metavar="action")
|
||||
sub.required = True
|
||||
|
||||
# ls
|
||||
ls_p = sub.add_parser("ls", help="List trajectory sessions")
|
||||
ls_p.add_argument("machine", nargs="?", default=None, help="Filter by machine name")
|
||||
ls_p.add_argument("--json", dest="as_json", action="store_true", help="Output as JSON")
|
||||
|
||||
# view
|
||||
view_p = sub.add_parser("view", help="Zip and open in cua.ai/trajectory-viewer")
|
||||
view_p.add_argument(
|
||||
"target",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Machine name, session timestamp, or path (default: latest session)",
|
||||
)
|
||||
view_p.add_argument(
|
||||
"--port",
|
||||
"-p",
|
||||
type=int,
|
||||
default=8089,
|
||||
help="Port for the local file server (default: 8089)",
|
||||
)
|
||||
|
||||
# clean
|
||||
clean_p = sub.add_parser("clean", help="Delete old trajectory sessions")
|
||||
clean_p.add_argument(
|
||||
"--older-than",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="DAYS",
|
||||
help="Only delete sessions older than DAYS days",
|
||||
)
|
||||
clean_p.add_argument(
|
||||
"--machine", default=None, help="Only delete sessions for this machine"
|
||||
)
|
||||
clean_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
|
||||
|
||||
# stop
|
||||
sub.add_parser("stop", help="Stop the trajectory file server")
|
||||
|
||||
|
||||
# ── ls ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cmd_ls(args: argparse.Namespace) -> int:
|
||||
machine = getattr(args, "machine", None)
|
||||
sessions = list_trajectories(machine=machine)
|
||||
|
||||
if not sessions:
|
||||
if machine:
|
||||
print(f"No trajectory sessions found for '{machine}'.")
|
||||
else:
|
||||
print("No trajectory sessions found.")
|
||||
return 0
|
||||
|
||||
if getattr(args, "as_json", False):
|
||||
print(json.dumps(sessions, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"{'Machine':<20} {'Session':<18} {'Turns':>5} {'Created'}")
|
||||
print("-" * 70)
|
||||
for s in sessions:
|
||||
print(f"{s['machine']:<20} {s['session']:<18} {s['turns']:>5} {s['created']}")
|
||||
return 0
|
||||
|
||||
|
||||
# ── view ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_session(target: str | None) -> str | None:
|
||||
"""Resolve a target to a session path."""
|
||||
if target and Path(target).is_dir():
|
||||
return target
|
||||
|
||||
sessions = list_trajectories()
|
||||
if not sessions:
|
||||
return None
|
||||
|
||||
if target is None:
|
||||
return sessions[-1]["path"]
|
||||
|
||||
# Try as machine name (latest for that machine)
|
||||
machine_sessions = [s for s in sessions if s["machine"] == target]
|
||||
if machine_sessions:
|
||||
return machine_sessions[-1]["path"]
|
||||
|
||||
# Try as session timestamp
|
||||
for s in sessions:
|
||||
if s["session"] == target:
|
||||
return s["path"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Minimal CORS-enabled HTTP server script, run as a subprocess.
|
||||
_SERVER_SCRIPT = """\
|
||||
import sys, os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
class CORSHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
super().end_headers()
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
os.chdir(sys.argv[1])
|
||||
port = int(sys.argv[2])
|
||||
HTTPServer(("127.0.0.1", port), CORSHandler).serve_forever()
|
||||
"""
|
||||
|
||||
|
||||
def _cmd_view(args: argparse.Namespace) -> int:
|
||||
target = getattr(args, "target", None)
|
||||
port = getattr(args, "port", 8089)
|
||||
session_path = _resolve_session(target)
|
||||
|
||||
if not session_path:
|
||||
label = f" for '{target}'" if target else ""
|
||||
print(f"No trajectory session found{label}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
session_dir = Path(session_path)
|
||||
|
||||
# Zip the session
|
||||
zip_path = zip_trajectory(session_dir)
|
||||
zip_name = zip_path.name
|
||||
|
||||
# Stop any existing server
|
||||
_stop_server(quiet=True)
|
||||
|
||||
# Start a CORS-enabled file server in the background
|
||||
serve_dir = str(zip_path.parent)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-c", _SERVER_SCRIPT, serve_dir, str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Save PID
|
||||
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_PID_FILE.write_text(json.dumps({"pid": proc.pid, "port": port}))
|
||||
|
||||
zip_url = f"http://localhost:{port}/{zip_name}"
|
||||
viewer_url = f"https://cua.ai/trajectory-viewer?zip={quote(zip_url, safe='')}"
|
||||
|
||||
machine = session_dir.parent.name
|
||||
session_ts = session_dir.name
|
||||
print(f"Serving: {machine}/{session_ts}")
|
||||
print(f"Viewer: {viewer_url}")
|
||||
print("Stop with: cua trajectory stop")
|
||||
|
||||
try:
|
||||
webbrowser.open(viewer_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ── stop ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _is_our_server(pid: int) -> bool:
|
||||
"""Check if the given PID is a Python process we spawned."""
|
||||
try:
|
||||
os.kill(pid, 0) # Check if process exists (doesn't actually send a signal)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
# On macOS/Linux, verify it's a Python process via /proc or ps
|
||||
try:
|
||||
import platform
|
||||
|
||||
if platform.system() != "Windows":
|
||||
result = subprocess.run(
|
||||
["ps", "-p", str(pid), "-o", "comm="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
comm = result.stdout.strip().lower()
|
||||
return "python" in comm
|
||||
except Exception:
|
||||
pass
|
||||
return True # On Windows or if ps fails, trust the PID file
|
||||
|
||||
|
||||
def _stop_server(quiet: bool = False) -> bool:
|
||||
"""Kill the background file server. Returns True if a server was stopped."""
|
||||
if not _PID_FILE.exists():
|
||||
if not quiet:
|
||||
print("No file server is running.")
|
||||
return False
|
||||
|
||||
try:
|
||||
info = json.loads(_PID_FILE.read_text())
|
||||
pid = info["pid"]
|
||||
except Exception:
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
if not quiet:
|
||||
print("Could not read server PID file.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
stopped = False
|
||||
try:
|
||||
if not _is_our_server(pid):
|
||||
if not quiet:
|
||||
print("Server was not running (stale PID file removed).")
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return False
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
stopped = True
|
||||
if not quiet:
|
||||
print(f"Stopped file server (pid {pid}).")
|
||||
except ProcessLookupError:
|
||||
if not quiet:
|
||||
print("Server was not running (stale PID file removed).")
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
print(f"Failed to stop server: {e}", file=sys.stderr)
|
||||
finally:
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
|
||||
return stopped
|
||||
|
||||
|
||||
def _cmd_stop(_args: argparse.Namespace) -> int:
|
||||
_stop_server()
|
||||
return 0
|
||||
|
||||
|
||||
# ── clean ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cmd_clean(args: argparse.Namespace) -> int:
|
||||
older_than = getattr(args, "older_than", None)
|
||||
machine = getattr(args, "machine", None)
|
||||
yes = getattr(args, "yes", False)
|
||||
|
||||
sessions = list_trajectories(machine=machine)
|
||||
if not sessions:
|
||||
print("No trajectory sessions to clean.")
|
||||
return 0
|
||||
|
||||
if older_than is not None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=older_than)
|
||||
sessions = [s for s in sessions if datetime.fromisoformat(s["created"]) < cutoff]
|
||||
|
||||
if not sessions:
|
||||
print("No sessions match the criteria.")
|
||||
return 0
|
||||
|
||||
if not yes:
|
||||
print(f"Will delete {len(sessions)} session(s):")
|
||||
for s in sessions:
|
||||
print(f" {s['machine']}/{s['session']} ({s['turns']} turns)")
|
||||
try:
|
||||
answer = input("Continue? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nCancelled.")
|
||||
return 1
|
||||
if answer != "y":
|
||||
print("Cancelled.")
|
||||
return 1
|
||||
|
||||
deleted = clean_trajectories(older_than_days=older_than, machine=machine)
|
||||
print(f"Deleted {len(deleted)} session(s).")
|
||||
return 0
|
||||
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
dispatch = {
|
||||
"ls": _cmd_ls,
|
||||
"view": _cmd_view,
|
||||
"clean": _cmd_clean,
|
||||
"stop": _cmd_stop,
|
||||
}
|
||||
handler = dispatch.get(args.traj_action)
|
||||
if not handler:
|
||||
return 1
|
||||
return handler(args)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Workspace commands for Cua CLI."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
from cua_cli.auth.browser import authenticate_via_browser
|
||||
from cua_cli.auth.store import (
|
||||
_get_store,
|
||||
delete_workspace,
|
||||
get_workspace_api_key,
|
||||
list_workspaces,
|
||||
save_workspace,
|
||||
set_active_workspace,
|
||||
)
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import print_error, print_info, print_success
|
||||
from cua_core.http import cua_version_headers
|
||||
|
||||
DEFAULT_API_BASE = "https://api.cua.ai"
|
||||
|
||||
|
||||
def _get_api_base() -> str:
|
||||
return os.environ.get("CUA_API_BASE", DEFAULT_API_BASE).rstrip("/")
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the workspace command and subcommands."""
|
||||
ws_parser = subparsers.add_parser(
|
||||
"workspace",
|
||||
help="Workspace commands",
|
||||
description="Manage workspaces",
|
||||
aliases=["ws"],
|
||||
)
|
||||
|
||||
ws_subparsers = ws_parser.add_subparsers(
|
||||
dest="workspace_command",
|
||||
help="Workspace command",
|
||||
)
|
||||
|
||||
set_parser = ws_subparsers.add_parser(
|
||||
"set",
|
||||
help="Switch active workspace",
|
||||
description="Switch to a different workspace",
|
||||
)
|
||||
set_parser.add_argument(
|
||||
"slug",
|
||||
nargs="?",
|
||||
help="Workspace slug to switch to",
|
||||
)
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute workspace command based on subcommand."""
|
||||
cmd = getattr(args, "workspace_command", None)
|
||||
|
||||
if cmd == "set":
|
||||
return cmd_set(args)
|
||||
else:
|
||||
print_error("Usage: cua workspace <command>")
|
||||
print_info("Commands: set")
|
||||
return 1
|
||||
|
||||
|
||||
def _validate_workspace_key(api_key: str) -> tuple[bool | None, dict]:
|
||||
"""Validate an API key by calling /v1/me.
|
||||
|
||||
Returns:
|
||||
(True, data) — valid key (HTTP 200)
|
||||
(False, data) — explicitly invalid/expired (HTTP 401)
|
||||
(None, {}) — transport error or unexpected HTTP status
|
||||
"""
|
||||
|
||||
async def _do():
|
||||
url = f"{_get_api_base()}/v1/me"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
**cua_version_headers(),
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as resp:
|
||||
try:
|
||||
data = await resp.json(content_type=None)
|
||||
except Exception:
|
||||
return resp.status, {}
|
||||
return resp.status, data
|
||||
|
||||
try:
|
||||
status_code, data = run_async(_do())
|
||||
if status_code == 200:
|
||||
return True, data
|
||||
elif status_code == 401:
|
||||
return False, data
|
||||
else:
|
||||
return None, {}
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
||||
return None, {}
|
||||
except Exception:
|
||||
return None, {}
|
||||
|
||||
|
||||
def cmd_set(args: argparse.Namespace) -> int:
|
||||
"""Handle the workspace set command."""
|
||||
slug = getattr(args, "slug", None)
|
||||
|
||||
if slug:
|
||||
# Check if we have a stored key for this workspace
|
||||
stored_key = get_workspace_api_key(slug)
|
||||
if stored_key:
|
||||
# Validate the stored key
|
||||
valid, _data = _validate_workspace_key(stored_key)
|
||||
if valid is True:
|
||||
set_active_workspace(slug)
|
||||
ws_name = _get_store().get(f"workspace:{slug}:name") or slug
|
||||
print_success(f"Switched to workspace: {ws_name} ({slug})")
|
||||
return 0
|
||||
elif valid is None:
|
||||
# Transport error — don't delete cached credentials
|
||||
print_error(
|
||||
f"Could not verify credentials for '{slug}' (API unreachable). Try again later."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
# Explicitly expired (401) — remove and fall through to browser auth
|
||||
print_info(f"Credentials for '{slug}' are expired. Re-authenticating...")
|
||||
delete_workspace(slug)
|
||||
|
||||
# Not authenticated or stale — trigger browser auth with hint
|
||||
try:
|
||||
result = run_async(authenticate_via_browser(workspace_slug=slug))
|
||||
except (TimeoutError, RuntimeError) as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if result.workspace_slug:
|
||||
if result.workspace_slug != slug:
|
||||
print_error(
|
||||
f"Authenticated workspace '{result.workspace_slug}' does not match "
|
||||
f"requested workspace '{slug}'."
|
||||
)
|
||||
return 1
|
||||
save_workspace(
|
||||
result.workspace_slug,
|
||||
result.token,
|
||||
result.workspace_name,
|
||||
result.org_name,
|
||||
)
|
||||
set_active_workspace(result.workspace_slug)
|
||||
print_success(
|
||||
f"Authenticated and switched to workspace: "
|
||||
f"{result.workspace_name} ({result.workspace_slug})"
|
||||
)
|
||||
else:
|
||||
print_error("Authentication did not return workspace metadata.")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# No slug — interactive selection from authenticated workspaces
|
||||
workspaces = list_workspaces()
|
||||
if not workspaces:
|
||||
print_info("No authenticated workspaces. Run 'cua auth login' to add one.")
|
||||
return 0
|
||||
|
||||
print_info("Select a workspace:")
|
||||
for i, ws in enumerate(workspaces, 1):
|
||||
marker = " (active)" if ws["is_active"] else ""
|
||||
name_part = f" - {ws['name']}" if ws["name"] else ""
|
||||
print(f" {i}. {ws['slug']}{name_part}{marker}")
|
||||
|
||||
try:
|
||||
choice = input("\nEnter number: ").strip()
|
||||
idx = int(choice) - 1
|
||||
if idx < 0 or idx >= len(workspaces):
|
||||
print_error("Invalid selection.")
|
||||
return 1
|
||||
except (ValueError, EOFError, KeyboardInterrupt):
|
||||
print_error("Invalid selection.")
|
||||
return 1
|
||||
|
||||
selected = workspaces[idx]
|
||||
|
||||
# Validate the selected workspace's session before switching
|
||||
stored_key = get_workspace_api_key(selected["slug"])
|
||||
if stored_key:
|
||||
valid, _data = _validate_workspace_key(stored_key)
|
||||
if valid is None:
|
||||
print_error(
|
||||
f"Could not verify credentials for '{selected['slug']}' (API unreachable). "
|
||||
f"Try again later."
|
||||
)
|
||||
return 1
|
||||
elif valid is False:
|
||||
delete_workspace(selected["slug"])
|
||||
print_error(
|
||||
f"Credentials for '{selected['slug']}' are expired. "
|
||||
f"Run 'cua auth login' to re-authenticate."
|
||||
)
|
||||
return 1
|
||||
|
||||
set_active_workspace(selected["slug"])
|
||||
print_success(f"Switched to workspace: {selected['name']} ({selected['slug']})")
|
||||
return 0
|
||||
Reference in New Issue
Block a user