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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
@@ -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