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
+14
View File
@@ -0,0 +1,14 @@
[bumpversion]
current_version = 0.1.12
commit = True
tag = True
tag_name = cli-v{new_version}
message = Bump cua-cli to v{new_version}
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:cua_cli/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
+108
View File
@@ -0,0 +1,108 @@
# CUA CLI
Unified command-line interface for CUA (Computer-Use Agents).
## Installation
```bash
pip install cua-cli
```
## Usage
```bash
# Authentication
cua auth login # Authenticate via browser
cua auth login --api-key # Authenticate with API key
cua auth list # List authenticated workspaces
cua auth status # Show auth status and credits
cua auth logout # Remove active workspace credentials
cua auth logout --workspace <slug> # Remove specific workspace
cua auth logout --all # Remove all credentials
cua auth env # Export API key to .env file
# Workspace Management
cua ws set <slug> # Switch active workspace
cua ws set # Interactive workspace picker
# Sandbox Management
cua sb list # List all sandboxes
cua sb create --os linux --size medium --region north-america
cua sb get <name> # Get sandbox details
cua sb start <name> # Start a stopped sandbox
cua sb stop <name> # Stop a running sandbox
cua sb restart <name> # Restart a sandbox
cua sb suspend <name> # Suspend a sandbox
cua sb delete <name> # Delete a sandbox
cua sb vnc <name> # Open sandbox in browser
# Image Management
cua image list # List cloud images
cua image list --local # List local images
cua image push <name> # Upload image to cloud
cua image pull <name> # Download image from cloud
cua image delete <name> # Delete cloud image
# Skills Management
cua skills list # List recorded skills
cua skills read <name> # Read a skill's content
cua skills record <name> # Record a new skill
cua skills replay <name> # Replay a skill
cua skills delete <name> # Delete a skill
cua skills clean # Delete all skills
# MCP Server (for AI assistants)
cua serve-mcp # Start MCP server with all permissions
cua serve-mcp --permissions sandbox:all,computer:readonly
```
## Installation Options
```bash
# Basic installation
pip install cua-cli
# With MCP server support
pip install cua-cli[mcp]
# With skills recording (VLM captioning)
pip install cua-cli[skills]
# Full installation
pip install cua-cli[all]
```
## MCP Integration
To use CUA with Claude Code or other MCP-compatible AI assistants:
```bash
# Add CUA as an MCP server
claude mcp add cua -- cua serve-mcp
# With specific permissions
claude mcp add cua -- cua serve-mcp --permissions sandbox:all,computer:readonly
# With a default sandbox
claude mcp add cua -- cua serve-mcp --sandbox my-sandbox
```
### Available Permissions
- `all` - All permissions
- `sandbox:all` - Full sandbox management
- `sandbox:readonly` - List and get sandboxes only
- `computer:all` - Full computer control
- `computer:readonly` - Screenshots only
- `skills:all` - Full skills management
- `skills:readonly` - List and read skills only
Individual permissions: `sandbox:list`, `sandbox:create`, `sandbox:delete`, `sandbox:start`, `sandbox:stop`, `sandbox:restart`, `sandbox:suspend`, `sandbox:get`, `sandbox:vnc`, `computer:screenshot`, `computer:click`, `computer:type`, `computer:key`, `computer:scroll`, `computer:drag`, `computer:hotkey`, `computer:clipboard`, `computer:file`, `computer:shell`, `computer:window`, `skills:list`, `skills:read`, `skills:record`, `skills:delete`
## Environment Variables
- `CUA_API_KEY`: API key for authentication
- `CUA_API_BASE`: API base URL (default: `https://api.cua.ai`)
- `CUA_WEBSITE_URL`: Website URL for OAuth (default: https://cua.ai)
- `CUA_MCP_PERMISSIONS`: Default MCP permissions (comma-separated)
- `CUA_SANDBOX`: Default sandbox name for computer commands
+3
View File
@@ -0,0 +1,3 @@
"""CUA CLI - Unified command-line interface for Computer-Use Agents."""
__version__ = "0.1.12"
@@ -0,0 +1 @@
"""API client module for CUA CLI."""
+144
View File
@@ -0,0 +1,144 @@
"""HTTP API client for CUA cloud services."""
import hashlib
import os
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
import aiohttp
from cua_cli.auth.store import require_api_key
from cua_core.http import cua_version_headers
DEFAULT_API_BASE = "https://api.cua.ai"
def get_api_base() -> str:
"""Get the API base URL."""
return os.environ.get("CUA_API_BASE", DEFAULT_API_BASE).rstrip("/")
class CloudAPIClient:
"""HTTP client for CUA cloud API."""
def __init__(self, api_key: Optional[str] = None, api_base: Optional[str] = None):
self.api_key = api_key or require_api_key()
self.api_base = api_base or get_api_base()
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
**cua_version_headers(),
}
async def _request(
self,
method: str,
path: str,
json: Optional[dict] = None,
timeout: int = 30,
) -> tuple[int, Any]:
"""Make an HTTP request to the API.
Returns:
Tuple of (status_code, response_data)
"""
url = f"{self.api_base}{path}"
headers = self._headers()
if json is not None:
headers["Content-Type"] = "application/json"
async with aiohttp.ClientSession() as session:
timeout_obj = aiohttp.ClientTimeout(total=timeout)
async with session.request(
method, url, headers=headers, json=json, timeout=timeout_obj
) as resp:
try:
data = await resp.json(content_type=None)
except Exception:
data = await resp.text()
return resp.status, data
# Image API methods
async def list_images(self) -> list[dict[str, Any]]:
"""List all images in the workspace."""
status, data = await self._request("GET", "/v1/images")
if status == 200 and isinstance(data, list):
return data
return []
async def initiate_upload(
self,
name: str,
tag: str,
image_type: str,
size_bytes: int,
checksum_sha256: str,
) -> tuple[int, dict[str, Any]]:
"""Initiate a multi-part upload session.
Returns:
Tuple of (status_code, session_data)
"""
path = f"/v1/images/{quote(name, safe='')}/upload"
body = {
"tag": tag,
"image_type": image_type,
"size_bytes": size_bytes,
"checksum_sha256": checksum_sha256,
}
return await self._request("POST", path, json=body)
async def get_upload_part_url(
self, name: str, upload_id: str, part_number: int
) -> tuple[int, dict[str, Any]]:
"""Get a signed URL for uploading a part."""
path = f"/v1/images/{quote(name, safe='')}/upload/{upload_id}/part/{part_number}"
return await self._request("GET", path)
async def complete_upload(
self, name: str, upload_id: str, parts: list[dict[str, Any]]
) -> tuple[int, dict[str, Any]]:
"""Complete a multi-part upload."""
path = f"/v1/images/{quote(name, safe='')}/upload/{upload_id}/complete"
return await self._request("POST", path, json={"parts": parts})
async def abort_upload(self, name: str, upload_id: str) -> tuple[int, Any]:
"""Abort an upload session."""
path = f"/v1/images/{quote(name, safe='')}/upload/{upload_id}"
return await self._request("DELETE", path)
async def get_download_url(self, name: str, tag: str) -> tuple[int, dict[str, Any]]:
"""Get a signed URL for downloading an image."""
path = f"/v1/images/{quote(name, safe='')}/download?tag={quote(tag, safe='')}"
return await self._request("GET", path)
async def delete_image(self, name: str, tag: str) -> tuple[int, Any]:
"""Delete an image version."""
path = f"/v1/images/{quote(name, safe='')}?tag={quote(tag, safe='')}"
return await self._request("DELETE", path)
def calculate_file_hash(file_path: Path) -> str:
"""Calculate SHA256 hash of a file."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def format_bytes(size_bytes: int) -> str:
"""Format bytes in human-readable format."""
if size_bytes == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
k = 1024
i = 0
while size_bytes >= k and i < len(units) - 1:
size_bytes /= k
i += 1
return f"{size_bytes:.2f} {units[i]}"
@@ -0,0 +1,5 @@
"""Authentication module for CUA CLI."""
from .store import CredentialStore, clear_credentials, get_api_key, save_api_key
__all__ = ["CredentialStore", "get_api_key", "save_api_key", "clear_credentials"]
+177
View File
@@ -0,0 +1,177 @@
"""Browser-based OAuth authentication for Cua CLI."""
import asyncio
import os
import platform
import subprocess
import urllib.parse
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from typing import Optional
# Default URLs
DEFAULT_WEBSITE_URL = "https://cua.ai"
# Timeout for browser authentication (2 minutes)
AUTH_TIMEOUT_SECONDS = 120
@dataclass
class AuthResult:
"""Result from browser authentication containing token and workspace metadata."""
token: str
workspace_slug: str
workspace_name: str
org_name: str
class CallbackHandler(BaseHTTPRequestHandler):
"""HTTP request handler for OAuth callback."""
token: Optional[str] = None
workspace_slug: Optional[str] = None
workspace_name: Optional[str] = None
org_name: Optional[str] = None
error: Optional[str] = None
def do_GET(self) -> None:
"""Handle GET request from OAuth callback."""
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if "token" in params:
CallbackHandler.token = params["token"][0]
CallbackHandler.workspace_slug = params.get("workspace_slug", [""])[0]
CallbackHandler.workspace_name = params.get("workspace_name", [""])[0]
CallbackHandler.org_name = params.get("org_name", [""])[0]
self._send_response(
200,
"<html><body><h1>Authentication successful!</h1>"
"<p>You can close this window and return to the terminal.</p></body></html>",
)
elif "error" in params:
CallbackHandler.error = params.get("error_description", params["error"])[0]
self._send_response(
400,
f"<html><body><h1>Authentication failed</h1>"
f"<p>{CallbackHandler.error}</p></body></html>",
)
else:
self._send_response(
400,
"<html><body><h1>Invalid callback</h1>"
"<p>Missing token parameter.</p></body></html>",
)
def _send_response(self, status: int, body: str) -> None:
"""Send an HTTP response."""
self.send_response(status)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, format: str, *args) -> None:
"""Suppress default logging."""
pass
def open_browser(url: str) -> bool:
"""Open a URL in the default browser.
Args:
url: The URL to open
Returns:
True if the browser was opened successfully
"""
system = platform.system()
try:
if system == "Darwin":
subprocess.run(["open", url], check=True)
elif system == "Windows":
subprocess.run(["cmd", "/c", "start", url], check=True, shell=True)
else: # Linux and others
subprocess.run(["xdg-open", url], check=True)
return True
except (subprocess.SubprocessError, FileNotFoundError):
return False
async def authenticate_via_browser(
website_url: Optional[str] = None,
workspace_slug: Optional[str] = None,
) -> AuthResult:
"""Authenticate via browser OAuth flow.
Starts a local HTTP server to receive the callback, opens the browser
to the authentication page, and waits for the token.
Args:
website_url: Base URL for the authentication page. Defaults to CUA_WEBSITE_URL
env var or https://cua.ai
workspace_slug: Optional workspace slug to pre-select in the browser
Returns:
AuthResult with token and workspace metadata
Raises:
TimeoutError: If authentication times out
RuntimeError: If authentication fails
"""
website_url = website_url or os.environ.get("CUA_WEBSITE_URL", DEFAULT_WEBSITE_URL)
# Reset handler state
CallbackHandler.token = None
CallbackHandler.workspace_slug = None
CallbackHandler.workspace_name = None
CallbackHandler.org_name = None
CallbackHandler.error = None
# Start local server on dynamic port
server = HTTPServer(("localhost", 0), CallbackHandler)
port = server.server_address[1]
callback_url = f"http://localhost:{port}"
# Build auth URL
encoded_callback = urllib.parse.quote(callback_url, safe="")
auth_url = f"{website_url}/cli-auth?callback_url={encoded_callback}"
if workspace_slug:
auth_url += f"&workspace_slug={urllib.parse.quote(workspace_slug, safe='')}"
# Start server in background thread
server_thread = Thread(target=server.handle_request, daemon=True)
server_thread.start()
# Open browser
print("Opening browser for authentication...")
if not open_browser(auth_url):
print("Could not open browser automatically.")
print(f"Please visit: {auth_url}")
# Wait for callback with timeout
try:
for _ in range(AUTH_TIMEOUT_SECONDS):
if CallbackHandler.token or CallbackHandler.error:
break
await asyncio.sleep(1)
else:
raise TimeoutError("Authentication timed out. Please try again.")
if CallbackHandler.error:
raise RuntimeError(f"Authentication failed: {CallbackHandler.error}")
if not CallbackHandler.token:
raise RuntimeError("Authentication failed: No token received")
return AuthResult(
token=CallbackHandler.token,
workspace_slug=CallbackHandler.workspace_slug or "",
workspace_name=CallbackHandler.workspace_name or "",
org_name=CallbackHandler.org_name or "",
)
finally:
server.server_close()
+293
View File
@@ -0,0 +1,293 @@
"""SQLite-based credential storage for Cua CLI."""
import os
import sqlite3
from pathlib import Path
from typing import Optional
# Default storage location
CUA_DIR = Path.home() / ".cua"
CREDENTIALS_DB = CUA_DIR / "credentials.db"
# Key names
API_KEY_NAME = "api_key"
# Workspace key patterns
ACTIVE_WORKSPACE_KEY = "active_workspace"
class CredentialStore:
"""SQLite-based credential store with WAL mode for concurrent access."""
def __init__(self, db_path: Path | None = None):
"""Initialize the credential store.
Args:
db_path: Path to the SQLite database. Defaults to ~/.cua/credentials.db
"""
self.db_path = db_path or CREDENTIALS_DB
self._ensure_db()
def _ensure_db(self) -> None:
"""Ensure the database and table exist."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
try:
# Enable WAL mode for better concurrent access
conn.execute("PRAGMA journal_mode=WAL")
# Create key-value table
conn.execute("""
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.commit()
finally:
conn.close()
def get(self, key: str) -> Optional[str]:
"""Get a value from the store.
Args:
key: The key to look up
Returns:
The value, or None if not found
"""
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute("SELECT value FROM kv WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else None
finally:
conn.close()
def set(self, key: str, value: str) -> None:
"""Set a value in the store.
Args:
key: The key to set
value: The value to store
"""
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)",
(key, value),
)
conn.commit()
finally:
conn.close()
def delete(self, key: str) -> bool:
"""Delete a value from the store.
Args:
key: The key to delete
Returns:
True if the key was deleted, False if it didn't exist
"""
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute("DELETE FROM kv WHERE key = ?", (key,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def delete_by_prefix(self, prefix: str) -> int:
"""Delete all keys matching a prefix.
Args:
prefix: The key prefix to match (uses LIKE prefix%)
Returns:
Number of rows deleted
"""
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute("DELETE FROM kv WHERE key LIKE ?", (prefix + "%",))
conn.commit()
return cursor.rowcount
finally:
conn.close()
def list_by_prefix(self, prefix: str) -> list[tuple[str, str]]:
"""List all key-value pairs matching a prefix.
Args:
prefix: The key prefix to match (uses LIKE prefix%)
Returns:
List of (key, value) tuples
"""
conn = sqlite3.connect(self.db_path)
try:
cursor = conn.execute("SELECT key, value FROM kv WHERE key LIKE ?", (prefix + "%",))
return cursor.fetchall()
finally:
conn.close()
def clear(self) -> None:
"""Clear all stored credentials."""
conn = sqlite3.connect(self.db_path)
try:
conn.execute("DELETE FROM kv")
conn.commit()
finally:
conn.close()
# Module-level convenience functions
_store: Optional[CredentialStore] = None
def _get_store() -> CredentialStore:
"""Get the global credential store instance."""
global _store
if _store is None:
_store = CredentialStore()
return _store
def save_workspace(slug: str, api_key: str, name: str, org: str) -> None:
"""Save workspace credentials and metadata."""
store = _get_store()
store.set(f"workspace:{slug}:api_key", api_key)
store.set(f"workspace:{slug}:name", name)
store.set(f"workspace:{slug}:org", org)
def get_active_workspace() -> Optional[str]:
"""Get the slug of the currently active workspace."""
return _get_store().get(ACTIVE_WORKSPACE_KEY)
def set_active_workspace(slug: str) -> None:
"""Set the active workspace by slug."""
_get_store().set(ACTIVE_WORKSPACE_KEY, slug)
def list_workspaces() -> list[dict]:
"""List all authenticated workspaces.
Returns:
List of dicts with keys: slug, name, org, is_active
"""
store = _get_store()
active = store.get(ACTIVE_WORKSPACE_KEY)
rows = store.list_by_prefix("workspace:")
# Group by slug — extract from keys like workspace:<slug>:api_key
slugs: dict[str, dict] = {}
for key, value in rows:
parts = key.split(":")
if len(parts) != 3:
continue
slug = parts[1]
field = parts[2]
if slug not in slugs:
slugs[slug] = {"slug": slug, "name": "", "org": "", "is_active": False}
if field == "name":
slugs[slug]["name"] = value
elif field == "org":
slugs[slug]["org"] = value
# Only include workspaces that have an api_key stored
result = []
for key, _value in rows:
parts = key.split(":")
if len(parts) == 3 and parts[2] == "api_key" and parts[1] in slugs:
ws = slugs[parts[1]]
ws["is_active"] = ws["slug"] == active
result.append(ws)
result.sort(key=lambda ws: ws["slug"])
return result
def get_workspace_api_key(slug: str) -> Optional[str]:
"""Get the API key for a specific workspace."""
return _get_store().get(f"workspace:{slug}:api_key")
def delete_workspace(slug: str) -> None:
"""Delete all stored data for a workspace."""
store = _get_store()
store.delete(f"workspace:{slug}:api_key")
store.delete(f"workspace:{slug}:name")
store.delete(f"workspace:{slug}:org")
def clear_all_workspaces() -> None:
"""Delete all workspace keys and the active workspace marker."""
store = _get_store()
store.delete_by_prefix("workspace:")
store.delete(ACTIVE_WORKSPACE_KEY)
def get_api_key() -> Optional[str]:
"""Get the stored API key.
Priority:
1. CUA_API_KEY environment variable
2. Active workspace's API key
3. Legacy 'api_key' key (backward compat)
Returns:
The API key, or None if not found
"""
# Environment variable takes precedence
env_key = os.environ.get("CUA_API_KEY")
if env_key:
return env_key
# Active workspace
store = _get_store()
active_slug = store.get(ACTIVE_WORKSPACE_KEY)
if active_slug:
ws_key = store.get(f"workspace:{active_slug}:api_key")
if ws_key:
return ws_key
# Legacy fallback
return store.get(API_KEY_NAME)
def save_api_key(api_key: str) -> None:
"""Save an API key to the credential store (legacy path)."""
_get_store().set(API_KEY_NAME, api_key)
def clear_credentials() -> None:
"""Clear all stored credentials including workspaces and legacy key."""
store = _get_store()
clear_all_workspaces()
store.delete(API_KEY_NAME)
def clear_legacy_credentials() -> None:
"""Clear only the legacy API key (not workspace data)."""
_get_store().delete(API_KEY_NAME)
def require_api_key() -> str:
"""Get the API key, raising an error if not found.
Returns:
The API key
Raises:
RuntimeError: If no API key is configured
"""
api_key = get_api_key()
if not api_key:
raise RuntimeError(
"No API key configured. Run 'cua auth login' to authenticate, "
"or set the CUA_API_KEY environment variable."
)
return api_key
@@ -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
+127
View File
@@ -0,0 +1,127 @@
"""Main entry point for Cua CLI."""
import argparse
import logging
import sys
import time
from cua_cli import __version__
from cua_cli.commands import auth, do, image, mcp, platform, sandbox, skills, trajectory
from cua_cli.commands import workspace as workspace_cmd
from cua_cli.utils.output import print_error
try:
from cua_core.telemetry import is_telemetry_enabled, record_event
_TELEMETRY_AVAILABLE = True
except ImportError:
_TELEMETRY_AVAILABLE = False
def is_telemetry_enabled() -> bool: # type: ignore[misc]
return False
def record_event(event_name: str, properties: dict | None = None) -> None: # type: ignore[misc]
pass
def create_parser() -> argparse.ArgumentParser:
"""Create the main argument parser with all subcommands."""
parser = argparse.ArgumentParser(
prog="cua",
description="Cua CLI - Unified command-line interface for Computer-Use Agents",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="For more information, visit https://docs.trycua.com",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"cua {__version__}",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Register command modules
auth.register_parser(subparsers)
sandbox.register_parser(subparsers)
image.register_parser(subparsers)
platform.register_parser(subparsers)
skills.register_parser(subparsers)
mcp.register_parser(subparsers)
do.register_parser(subparsers)
do.register_host_consent_parser(subparsers)
trajectory.register_parser(subparsers)
workspace_cmd.register_parser(subparsers)
return parser
def main() -> int:
"""Main entry point for the CLI."""
# Suppress noisy INFO logs from dependencies (computer, core.telemetry, etc.)
# Must set on specific loggers since they configure their own handlers at import time
logging.basicConfig(level=logging.WARNING)
for name in ("computer", "cua_core", "cua_core.telemetry", "httpx", "httpcore", "cua_sandbox"):
logging.getLogger(name).setLevel(logging.WARNING)
parser = create_parser()
args = parser.parse_args()
if args.command is None:
parser.print_help()
return 0
subcommand = getattr(args, "subcommand", None)
_t_start = time.monotonic()
exit_code = 0
try:
# Dispatch to command modules
if args.command == "auth":
exit_code = auth.execute(args)
elif args.command in ("sandbox", "sb"):
exit_code = sandbox.execute(args)
elif args.command in ("image", "img"):
exit_code = image.execute(args)
elif args.command == "platform":
exit_code = platform.execute(args)
elif args.command == "skills":
exit_code = skills.execute(args)
elif args.command == "serve-mcp":
exit_code = mcp.execute(args)
elif args.command == "do":
exit_code = do.execute(args)
elif args.command == "do-host-consent":
exit_code = do.execute_host_consent(args)
elif args.command in ("trajectory", "traj"):
exit_code = trajectory.execute(args)
elif args.command in ("workspace", "ws"):
exit_code = workspace_cmd.execute(args)
else:
print_error(f"Unknown command: {args.command}")
exit_code = 1
return exit_code
except KeyboardInterrupt:
print_error("Operation cancelled")
exit_code = 130
return exit_code
except Exception as e:
print_error(str(e))
exit_code = 1
return exit_code
finally:
if _TELEMETRY_AVAILABLE and is_telemetry_enabled():
record_event(
"cli_command",
{
"command": args.command,
"subcommand": subcommand,
"status": "success" if exit_code == 0 else "error",
"exit_code": exit_code,
"duration_seconds": round(time.monotonic() - _t_start, 3),
},
)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,21 @@
"""Utility modules for CUA CLI."""
from .async_utils import run_async
from .output import (
print_error,
print_info,
print_json,
print_success,
print_table,
print_warning,
)
__all__ = [
"print_table",
"print_json",
"print_error",
"print_success",
"print_warning",
"print_info",
"run_async",
]
@@ -0,0 +1,20 @@
"""Async utilities for CUA CLI."""
import asyncio
from typing import Any, Coroutine, TypeVar
T = TypeVar("T")
def run_async(coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine synchronously.
This is the standard pattern for CLI commands that need to call async code.
Args:
coro: The coroutine to run
Returns:
The result of the coroutine
"""
return asyncio.run(coro)
+103
View File
@@ -0,0 +1,103 @@
"""Docker utilities for CUA CLI."""
import platform
import shutil
import subprocess
from pathlib import Path
def find_free_port(start: int = 5000, end: int = 9000) -> int:
"""Find a free port in the given range.
Args:
start: Start of port range (inclusive)
end: End of port range (exclusive)
Returns:
First available port in the range
Raises:
RuntimeError: If no free port found
"""
import socket
for port in range(start, end):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", port))
return port
except OSError:
continue
raise RuntimeError(f"No free port found in range {start}-{end}")
def allocate_ports(vnc_default: int = 8006, api_default: int = 5000) -> tuple[int, int]:
"""Allocate VNC and API ports, auto-selecting if defaults are in use.
Args:
vnc_default: Preferred VNC port
api_default: Preferred API port
Returns:
Tuple of (vnc_port, api_port)
"""
import socket
def is_port_free(port: int) -> bool:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", port))
return True
except OSError:
return False
vnc_port = vnc_default if is_port_free(vnc_default) else find_free_port(8000, 9000)
api_port = api_default if is_port_free(api_default) else find_free_port(5000, 6000)
return vnc_port, api_port
def create_overlay_copy(golden_path: Path, overlay_path: Path, verbose: bool = False) -> None:
"""Copy golden image to overlay directory for COW-like behavior.
This is a temporary workaround until proper QEMU overlay support is added.
Uses native `cp -a` on Unix for speed (5x faster than Python shutil).
Falls back to shutil.copytree on Windows.
WIP: https://github.com/trycua/cua/issues/699
Args:
golden_path: Path to golden image directory
overlay_path: Path to overlay directory (will be created/cleaned)
verbose: Print progress messages
Raises:
RuntimeError: If copy fails
"""
if overlay_path.exists():
shutil.rmtree(overlay_path)
overlay_path.mkdir(parents=True, exist_ok=True)
if verbose:
print(f" Source: {golden_path}")
print(f" Overlay: {overlay_path}")
print(" (This may take a while for large images)")
print(" WIP: https://github.com/trycua/cua/issues/699")
if platform.system() == "Windows":
try:
for item in golden_path.iterdir():
src = golden_path / item.name
dst = overlay_path / item.name
if src.is_dir():
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
except Exception as e:
raise RuntimeError(f"Failed to create overlay: {e}")
else:
result = subprocess.run(
["cp", "-a", f"{golden_path}/.", str(overlay_path)], capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create overlay: {result.stderr or 'cp failed'}")
@@ -0,0 +1,66 @@
"""Output formatting utilities for CUA CLI."""
import json
from typing import Any
from rich.console import Console
from rich.table import Table
console = Console()
error_console = Console(stderr=True)
def print_table(
data: list[dict[str, Any]],
columns: list[tuple[str, str]] | None = None,
title: str | None = None,
) -> None:
"""Print data as a formatted table.
Args:
data: List of dictionaries to display
columns: List of (key, header) tuples. If None, uses all keys from first item.
title: Optional table title
"""
if not data:
console.print("[dim]No data to display[/dim]")
return
table = Table(title=title, show_header=True, header_style="bold")
if columns is None:
columns = [(k, k.upper()) for k in data[0].keys()]
for _, header in columns:
table.add_column(header)
for item in data:
row = [str(item.get(key, "")) for key, _ in columns]
table.add_row(*row)
console.print(table)
def print_json(data: Any) -> None:
"""Print data as formatted JSON."""
console.print_json(json.dumps(data, indent=2, default=str))
def print_error(message: str) -> None:
"""Print an error message to stderr."""
error_console.print(f"[red]Error:[/red] {message}")
def print_success(message: str) -> None:
"""Print a success message."""
console.print(f"[green]{message}[/green]")
def print_warning(message: str) -> None:
"""Print a warning message."""
console.print(f"[yellow]Warning:[/yellow] {message}")
def print_info(message: str) -> None:
"""Print an info message."""
console.print(f"[blue]{message}[/blue]")
@@ -0,0 +1,30 @@
"""XDG Base Directory helpers for CUA CLI."""
import os
from pathlib import Path
def get_xdg_data_home() -> Path:
"""Get XDG_DATA_HOME, defaulting to ~/.local/share per spec."""
xdg_data = os.environ.get("XDG_DATA_HOME")
if xdg_data:
return Path(xdg_data)
return Path.home() / ".local" / "share"
def get_xdg_state_home() -> Path:
"""Get XDG_STATE_HOME, defaulting to ~/.local/state per spec."""
xdg_state = os.environ.get("XDG_STATE_HOME")
if xdg_state:
return Path(xdg_state)
return Path.home() / ".local" / "state"
def get_data_dir() -> Path:
"""Get the CUA data directory (for images)."""
return get_xdg_data_home() / "cua"
def get_state_dir() -> Path:
"""Get the CUA state directory (for registries)."""
return get_xdg_state_home() / "cua"
@@ -0,0 +1,148 @@
"""Image registry for CUA CLI.
Manages a JSON registry of locally stored images at ~/.local/state/cua/images.json.
"""
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from cua_cli.utils.paths import get_data_dir, get_state_dir
def get_image_registry_path() -> Path:
"""Get the image registry file path."""
return get_state_dir() / "images.json"
def load_image_registry() -> Dict[str, Any]:
"""Load the image registry."""
registry_path = get_image_registry_path()
if registry_path.exists():
try:
data = json.loads(registry_path.read_text())
return data.get("images", data)
except Exception:
return {}
return {}
def save_image_registry(registry: Dict[str, Any]) -> None:
"""Save the image registry."""
registry_path = get_image_registry_path()
registry_path.parent.mkdir(parents=True, exist_ok=True)
data = {"version": 1, "images": registry}
registry_path.write_text(json.dumps(data, indent=2))
def register_image(
name: str,
platform: str,
path: Path,
description: str = "",
docker_image: Optional[str] = None,
config: Optional[Dict] = None,
parent: Optional[str] = None,
tags: Optional[List[str]] = None,
apps_installed: Optional[List[str]] = None,
) -> None:
"""Register an image in the registry."""
from cua_cli.commands.platform import PLATFORMS
registry = load_image_registry()
entry: Dict[str, Any] = {
"platform": platform,
"path": str(path),
"description": description,
"created_at": datetime.now().isoformat(),
}
if docker_image:
entry["docker_image"] = docker_image
if config:
entry["config"] = config
if parent:
entry["parent"] = parent
if tags:
entry["tags"] = tags
if apps_installed:
entry["apps_installed"] = apps_installed
platform_config = PLATFORMS.get(platform, {})
if platform_config.get("image_marker"):
entry["marker_file"] = platform_config["image_marker"]
registry[name] = entry
save_image_registry(registry)
def unregister_image(name: str) -> None:
"""Remove an image from the registry."""
registry = load_image_registry()
if name in registry:
del registry[name]
save_image_registry(registry)
def get_image_info(name: str) -> Optional[Dict]:
"""Get image info by name."""
registry = load_image_registry()
return registry.get(name)
def list_images() -> Dict[str, Any]:
"""List all registered images."""
return load_image_registry()
def auto_discover_images() -> None:
"""Scan images directory and register any unregistered images.
Handles images created with --detach mode that never got registered.
"""
from cua_cli.commands.platform import PLATFORMS
images_dir = get_data_dir() / "images"
if not images_dir.exists():
return
registry = load_image_registry()
modified = False
for image_dir in images_dir.iterdir():
if not image_dir.is_dir():
continue
name = image_dir.name
if name in registry:
continue
for platform_name, config in PLATFORMS.items():
marker = config.get("image_marker")
if not marker:
continue
marker_path = image_dir / marker
if marker_path.exists():
print(f"Auto-registering discovered image: {name} ({platform_name})")
entry = {
"platform": platform_name,
"path": str(image_dir),
"description": f"Auto-discovered {platform_name} image",
"created_at": datetime.now().isoformat(),
"docker_image": config.get("image"),
"config": {"memory": "8G", "cpus": "8"},
"tags": ["auto-discovered"],
"marker_file": marker,
}
registry[name] = entry
modified = True
break
if modified:
save_image_registry(registry)
@@ -0,0 +1,262 @@
"""Trajectory recording utility for cua do actions.
Records each action into a replayable trajectory compatible with the
TrajectoryViewer at cua.ai/trajectory-viewer.
All state is file-based (each `cua do` invocation is a separate process).
The current session path is stored in ~/.cua/do_target.json under the
``trajectory_session`` key.
Directory layout::
~/.cua/trajectories/{machine_name}/{YYYYMMDD-HHMMSS}/
turn_001/
screenshot.png
turn_001_agent_response.json
turn_002/
screenshot.png
turn_002_agent_response.json
"""
from __future__ import annotations
import json
import shutil
import time
import uuid
import zipfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
_CUA_DIR = Path.home() / ".cua"
_TRAJECTORIES_DIR = _CUA_DIR / "trajectories"
_STATE_FILE = _CUA_DIR / "do_target.json"
# ── state helpers ────────────────────────────────────────────────────────────
def _load_state() -> dict:
if _STATE_FILE.exists():
try:
return json.loads(_STATE_FILE.read_text())
except Exception:
pass
return {}
def _save_state(state: dict) -> None:
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
_STATE_FILE.write_text(json.dumps(state, indent=2))
# ── session management ───────────────────────────────────────────────────────
def ensure_session(state: dict) -> Path:
"""Return the current session directory, creating one if needed.
If ``trajectory_session`` is already set in *state* and the directory
exists, it is reused. Otherwise a new timestamped directory is created
under ``~/.cua/trajectories/{machine_name}/`` and persisted to state.
"""
existing = state.get("trajectory_session")
if existing:
p = Path(existing)
if p.is_dir():
return p
machine = state.get("name") or state.get("provider") or "unknown"
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
session_dir = _TRAJECTORIES_DIR / machine / ts
session_dir.mkdir(parents=True, exist_ok=True)
state["trajectory_session"] = str(session_dir)
_save_state(state)
return session_dir
def reset_session(state: dict) -> None:
"""Clear ``trajectory_session`` from state (called on ``cua do switch``)."""
state.pop("trajectory_session", None)
_save_state(state)
# ── turn recording ───────────────────────────────────────────────────────────
def get_next_turn_number(session_dir: Path) -> int:
"""Scan for existing ``turn_NNN`` dirs and return the next number."""
max_n = 0
for child in session_dir.iterdir():
if child.is_dir() and child.name.startswith("turn_"):
try:
n = int(child.name.split("_", 1)[1])
if n > max_n:
max_n = n
except (ValueError, IndexError):
pass
return max_n + 1
def _build_action_dict(action_type: str, action_params: dict[str, Any]) -> dict[str, Any]:
"""Build the ``action`` dict for the agent-response JSON."""
action: dict[str, Any] = {"type": action_type}
action.update(action_params)
return action
def record_turn(
session_dir: Path,
action_type: str,
action_params: dict[str, Any],
screenshot_bytes: bytes | None = None,
) -> Path:
"""Write a single turn to the session directory.
Creates ``turn_NNN/screenshot.png`` (if *screenshot_bytes* provided)
and ``turn_NNN/turn_NNN_agent_response.json``.
Returns the turn directory.
"""
turn_num = get_next_turn_number(session_dir)
turn_name = f"turn_{turn_num:03d}"
turn_dir = session_dir / turn_name
turn_dir.mkdir(parents=True, exist_ok=True)
# Screenshot
if screenshot_bytes:
(turn_dir / "screenshot.png").write_bytes(screenshot_bytes)
# Agent response JSON (TrajectoryViewer-compatible)
call_id = f"call_{uuid.uuid4().hex[:12]}"
ts_ms = int(time.time())
response_json: dict[str, Any] = {
"model": "cua-cli",
"response": {
"id": f"resp_{int(time.time() * 1000)}",
"object": "response",
"created_at": ts_ms,
"status": "completed",
"model": "cua-cli",
"output": [
{
"type": "computer_call",
"id": call_id,
"call_id": call_id,
"action": _build_action_dict(action_type, action_params),
"pending_safety_checks": [],
"status": "completed",
}
],
},
}
json_path = turn_dir / f"{turn_name}_agent_response.json"
json_path.write_text(json.dumps(response_json, indent=2))
return turn_dir
# ── listing / inspection ─────────────────────────────────────────────────────
def list_trajectories(machine: str | None = None) -> list[dict[str, Any]]:
"""Return a list of trajectory sessions.
Each entry is ``{machine, session, path, turns, created}``.
If *machine* is given, only that machine's sessions are returned.
"""
results: list[dict[str, Any]] = []
if not _TRAJECTORIES_DIR.is_dir():
return results
machines = [_TRAJECTORIES_DIR / machine] if machine else sorted(_TRAJECTORIES_DIR.iterdir())
for machine_dir in machines:
if not machine_dir.is_dir():
continue
for session_dir in sorted(machine_dir.iterdir()):
if not session_dir.is_dir():
continue
turns = sum(
1 for c in session_dir.iterdir() if c.is_dir() and c.name.startswith("turn_")
)
# Parse created timestamp from dir name
try:
created = datetime.strptime(session_dir.name, "%Y%m%d-%H%M%S")
except ValueError:
created = datetime.fromtimestamp(session_dir.stat().st_ctime)
results.append(
{
"machine": machine_dir.name,
"session": session_dir.name,
"path": str(session_dir),
"turns": turns,
"created": created.isoformat(),
}
)
return results
# ── zipping ──────────────────────────────────────────────────────────────────
def zip_trajectory(session_path: str | Path) -> Path:
"""Create a zip of a session directory in TrajectoryViewer-compatible format.
Returns the path to the created zip file (placed next to the session dir).
"""
session_dir = Path(session_path)
zip_path = session_dir.parent / f"{session_dir.name}.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for child in sorted(session_dir.rglob("*")):
if child.is_file():
zf.write(child, child.relative_to(session_dir))
return zip_path
# ── cleanup ──────────────────────────────────────────────────────────────────
def clean_trajectories(
older_than_days: int | None = None,
machine: str | None = None,
) -> list[str]:
"""Delete trajectory sessions matching the criteria.
Returns a list of deleted session paths.
"""
deleted: list[str] = []
if not _TRAJECTORIES_DIR.is_dir():
return deleted
cutoff = datetime.now() - timedelta(days=older_than_days) if older_than_days else None
machines = [_TRAJECTORIES_DIR / machine] if machine else sorted(_TRAJECTORIES_DIR.iterdir())
for machine_dir in machines:
if not machine_dir.is_dir():
continue
for session_dir in sorted(machine_dir.iterdir()):
if not session_dir.is_dir():
continue
if cutoff:
try:
created = datetime.strptime(session_dir.name, "%Y%m%d-%H%M%S")
except ValueError:
created = datetime.fromtimestamp(session_dir.stat().st_ctime)
if created >= cutoff:
continue
shutil.rmtree(session_dir)
deleted.append(str(session_dir))
# Also remove zip if it exists
zip_path = session_dir.parent / f"{session_dir.name}.zip"
if zip_path.exists():
zip_path.unlink()
# Remove empty machine dirs
if machine_dir.is_dir() and not any(machine_dir.iterdir()):
machine_dir.rmdir()
return deleted
+113
View File
@@ -0,0 +1,113 @@
[project]
name = "cua-cli"
version = "0.1.12"
description = "Unified CLI for CUA - Computer-Use Agents"
readme = "README.md"
license = "MIT"
authors = [
{ name = "TryCua", email = "hello@trycua.com" }
]
keywords = [
"computer-use",
"cli",
"cloud",
"sandbox",
"agents",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Environment :: Console",
]
requires-python = ">=3.12,<3.14"
dependencies = [
# Core CUA packages
"cua-computer>=0.5.0",
"cua-core>=0.3.0,<0.4.0",
# Host automation (used by cua do switch host)
"cua-auto[all]>=0.1.0",
# HTTP client
"aiohttp>=3.9.0",
# CLI output
"rich>=13.0.0",
# Image processing for skills
"pillow>=10.0.0",
# WebSocket for skills recording
"websockets>=12.0",
]
[project.optional-dependencies]
# MCP server support
mcp = [
"fastmcp>=2.0",
]
# Skills recording with LLM captioning
skills = [
"litellm==1.86.2",
]
# Full installation
all = [
"cua-cli[mcp,skills]",
]
# Development
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.0.0",
"ruff>=0.1.0",
"respx>=0.20.0",
]
[project.urls]
Homepage = "https://github.com/trycua/cua"
Documentation = "https://docs.trycua.com"
Repository = "https://github.com/trycua/cua"
Issues = "https://github.com/trycua/cua/issues"
[project.scripts]
cua = "cua_cli.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build]
include = [
"cua_cli/**",
"README.md",
"LICENSE",
]
exclude = [
"cua_cli/**/__pycache__",
"cua_cli/**/tests",
"**/.DS_Store",
]
[tool.hatch.build.targets.wheel]
packages = ["cua_cli"]
[tool.hatch.build.targets.sdist]
include = [
"cua_cli/**",
"README.md",
"LICENSE",
"pyproject.toml",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+1
View File
@@ -0,0 +1 @@
"""Tests for cua-cli."""
@@ -0,0 +1 @@
"""API module tests."""
@@ -0,0 +1 @@
"""Auth module tests."""
@@ -0,0 +1,230 @@
"""Tests for auth.store module."""
import sqlite3
import threading
from unittest.mock import patch
import pytest
from cua_cli.auth.store import (
CredentialStore,
clear_credentials,
get_api_key,
require_api_key,
save_api_key,
)
class TestCredentialStore:
"""Tests for CredentialStore class."""
def test_init_creates_database(self, tmp_path):
"""Test that initializing creates the database file."""
db_path = tmp_path / "test.db"
CredentialStore(db_path)
assert db_path.exists()
def test_init_creates_parent_directory(self, tmp_path):
"""Test that initializing creates parent directories."""
db_path = tmp_path / "nested" / "dir" / "test.db"
CredentialStore(db_path)
assert db_path.parent.exists()
assert db_path.exists()
def test_set_and_get(self, tmp_path):
"""Test setting and getting a value."""
store = CredentialStore(tmp_path / "test.db")
store.set("test_key", "test_value")
result = store.get("test_key")
assert result == "test_value"
def test_get_nonexistent_key(self, tmp_path):
"""Test getting a nonexistent key returns None."""
store = CredentialStore(tmp_path / "test.db")
result = store.get("nonexistent")
assert result is None
def test_set_overwrites_existing(self, tmp_path):
"""Test that setting an existing key overwrites the value."""
store = CredentialStore(tmp_path / "test.db")
store.set("key", "value1")
store.set("key", "value2")
result = store.get("key")
assert result == "value2"
def test_delete_existing_key(self, tmp_path):
"""Test deleting an existing key returns True."""
store = CredentialStore(tmp_path / "test.db")
store.set("key", "value")
result = store.delete("key")
assert result is True
assert store.get("key") is None
def test_delete_nonexistent_key(self, tmp_path):
"""Test deleting a nonexistent key returns False."""
store = CredentialStore(tmp_path / "test.db")
result = store.delete("nonexistent")
assert result is False
def test_clear_removes_all(self, tmp_path):
"""Test that clear removes all values."""
store = CredentialStore(tmp_path / "test.db")
store.set("key1", "value1")
store.set("key2", "value2")
store.clear()
assert store.get("key1") is None
assert store.get("key2") is None
def test_wal_mode_enabled(self, tmp_path):
"""Test that WAL mode is enabled for concurrent access."""
db_path = tmp_path / "test.db"
CredentialStore(db_path)
conn = sqlite3.connect(db_path)
try:
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
assert mode.lower() == "wal"
finally:
conn.close()
def test_concurrent_access(self, tmp_path):
"""Test that concurrent access works with WAL mode."""
db_path = tmp_path / "test.db"
store = CredentialStore(db_path)
results = []
errors = []
def writer():
try:
for i in range(10):
store.set(f"key_{i}", f"value_{i}")
except Exception as e:
errors.append(e)
def reader():
try:
for i in range(10):
result = store.get(f"key_{i}")
if result is not None:
results.append(result)
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=writer),
threading.Thread(target=reader),
threading.Thread(target=reader),
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
class TestGetApiKey:
"""Tests for get_api_key function."""
def test_returns_env_var_first(self, monkeypatch, tmp_path):
"""Test that environment variable takes precedence."""
# Set up a store with a different value
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.setenv("CUA_API_KEY", "env-key")
save_api_key("stored-key")
result = get_api_key()
assert result == "env-key"
def test_falls_back_to_stored(self, monkeypatch, tmp_path):
"""Test that stored key is used when env var not set."""
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.delenv("CUA_API_KEY", raising=False)
save_api_key("stored-key")
result = get_api_key()
assert result == "stored-key"
def test_returns_none_when_not_found(self, monkeypatch, tmp_path):
"""Test that None is returned when no key is found."""
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.delenv("CUA_API_KEY", raising=False)
result = get_api_key()
assert result is None
class TestSaveApiKey:
"""Tests for save_api_key function."""
def test_saves_api_key(self, tmp_path, monkeypatch):
"""Test that API key is saved correctly."""
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.delenv("CUA_API_KEY", raising=False)
save_api_key("my-api-key")
result = get_api_key()
assert result == "my-api-key"
class TestClearCredentials:
"""Tests for clear_credentials function."""
def test_clears_all_credentials(self, tmp_path, monkeypatch):
"""Test that all credentials are cleared."""
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.delenv("CUA_API_KEY", raising=False)
save_api_key("my-api-key")
clear_credentials()
result = get_api_key()
assert result is None
class TestRequireApiKey:
"""Tests for require_api_key function."""
def test_returns_key_when_available(self, monkeypatch):
"""Test that key is returned when available."""
monkeypatch.setenv("CUA_API_KEY", "my-api-key")
result = require_api_key()
assert result == "my-api-key"
def test_raises_when_not_available(self, monkeypatch, tmp_path):
"""Test that RuntimeError is raised when no key available."""
with patch("cua_cli.auth.store.CREDENTIALS_DB", tmp_path / "test.db"):
with patch("cua_cli.auth.store._store", None):
monkeypatch.delenv("CUA_API_KEY", raising=False)
with pytest.raises(RuntimeError) as exc_info:
require_api_key()
assert "No API key configured" in str(exc_info.value)
assert "cua auth login" in str(exc_info.value)
@@ -0,0 +1 @@
"""Command module tests."""
@@ -0,0 +1,200 @@
"""Tests for auth command module."""
import argparse
from unittest.mock import patch
from cua_cli.commands import auth
class TestRegisterParser:
"""Tests for register_parser function."""
def test_registers_auth_command(self):
"""Test that auth command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
auth.register_parser(subparsers)
args = parser.parse_args(["auth", "login"])
assert args.auth_command == "login"
def test_login_has_api_key_flag(self):
"""Test that login has --api-key flag."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
auth.register_parser(subparsers)
args = parser.parse_args(["auth", "login", "--api-key", "my-key"])
assert args.api_key == "my-key"
def test_logout_command(self):
"""Test that logout command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
auth.register_parser(subparsers)
args = parser.parse_args(["auth", "logout"])
assert args.auth_command == "logout"
def test_env_command(self):
"""Test that env command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
auth.register_parser(subparsers)
args = parser.parse_args(["auth", "env"])
assert args.auth_command == "env"
class TestExecute:
"""Tests for execute function."""
def test_dispatch_to_login(self, args_namespace):
"""Test dispatch to login command."""
args = args_namespace(command="auth", auth_command="login", api_key=False)
with patch.object(auth, "cmd_login", return_value=0) as mock_cmd:
result = auth.execute(args)
mock_cmd.assert_called_once_with(args)
assert result == 0
def test_dispatch_to_logout(self, args_namespace):
"""Test dispatch to logout command."""
args = args_namespace(command="auth", auth_command="logout")
with patch.object(auth, "cmd_logout", return_value=0) as mock_cmd:
auth.execute(args)
mock_cmd.assert_called_once_with(args)
def test_dispatch_to_env(self, args_namespace):
"""Test dispatch to env command."""
args = args_namespace(command="auth", auth_command="env")
with patch.object(auth, "cmd_env", return_value=0) as mock_cmd:
auth.execute(args)
mock_cmd.assert_called_once_with(args)
def test_unknown_command_returns_error(self, args_namespace):
"""Test that unknown command returns error."""
args = args_namespace(command="auth", auth_command=None)
with patch.object(auth, "print_error"):
result = auth.execute(args)
assert result == 1
class TestCmdLogin:
"""Tests for cmd_login function."""
def test_login_with_api_key_direct(self, args_namespace):
"""Test login with --api-key flag and value."""
args = args_namespace(api_key="test-api-key")
with patch.object(auth, "get_api_key", return_value=None):
with patch.object(auth, "save_api_key") as mock_save:
with patch.object(auth, "print_info"):
with patch.object(auth, "print_success"):
result = auth.cmd_login(args)
mock_save.assert_called_once_with("test-api-key")
assert result == 0
def test_login_already_authenticated(self, args_namespace):
"""Test login when already authenticated."""
args = args_namespace(api_key=None)
with patch.object(auth, "get_api_key", return_value="existing-key"):
with patch.object(auth, "print_info") as mock_info:
result = auth.cmd_login(args)
assert result == 0
mock_info.assert_called()
def test_login_browser_flow(self, args_namespace):
"""Test login with browser OAuth flow."""
args = args_namespace(api_key=None)
with patch.object(auth, "get_api_key", return_value=None):
with patch.object(auth, "run_async") as mock_run:
mock_run.return_value = "browser-api-key"
with patch.object(auth, "save_api_key") as mock_save:
with patch.object(auth, "print_success"):
result = auth.cmd_login(args)
mock_save.assert_called_once_with("browser-api-key")
assert result == 0
def test_login_browser_flow_timeout(self, args_namespace):
"""Test login when browser flow times out."""
args = args_namespace(api_key=None)
with patch.object(auth, "get_api_key", return_value=None):
with patch.object(auth, "run_async") as mock_run:
mock_run.side_effect = TimeoutError("Authentication timed out")
with patch.object(auth, "print_error"):
result = auth.cmd_login(args)
assert result == 1
class TestCmdLogout:
"""Tests for cmd_logout function."""
def test_logout_clears_credentials(self, args_namespace):
"""Test that logout clears credentials."""
args = args_namespace()
with patch.object(auth, "clear_credentials") as mock_clear:
with patch.object(auth, "print_success"):
result = auth.cmd_logout(args)
mock_clear.assert_called_once()
assert result == 0
class TestCmdEnv:
"""Tests for cmd_env function."""
def test_env_writes_to_dotenv(self, args_namespace, tmp_path, monkeypatch):
"""Test that env command writes to .env file."""
args = args_namespace(file=str(tmp_path / ".env"))
with patch.object(auth, "get_api_key", return_value="test-api-key"):
with patch.object(auth, "print_success"):
result = auth.cmd_env(args)
assert result == 0
env_file = tmp_path / ".env"
assert env_file.exists()
content = env_file.read_text()
assert "CUA_API_KEY=test-api-key" in content
def test_env_no_credentials_fails(self, args_namespace, tmp_path):
"""Test that env command fails when not logged in."""
args = args_namespace(file=str(tmp_path / ".env"))
with patch.object(auth, "get_api_key", return_value=None):
with patch.object(auth, "print_error"):
result = auth.cmd_env(args)
assert result == 1
def test_env_appends_to_existing_dotenv(self, args_namespace, tmp_path):
"""Test that env command appends to existing .env file."""
env_file = tmp_path / ".env"
env_file.write_text("EXISTING_VAR=value\n")
args = args_namespace(file=str(env_file))
with patch.object(auth, "get_api_key", return_value="test-api-key"):
with patch.object(auth, "print_success"):
result = auth.cmd_env(args)
assert result == 0
content = env_file.read_text()
assert "EXISTING_VAR=value" in content
assert "CUA_API_KEY=test-api-key" in content
@@ -0,0 +1,297 @@
"""Tests for image command module."""
import argparse
from unittest.mock import patch
from cua_cli.commands import image
class TestRegisterParser:
"""Tests for register_parser function."""
def test_registers_image_command(self):
"""Test that image command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "list"])
assert args.image_command == "list"
def test_registers_img_alias(self):
"""Test that img alias is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["img", "list"])
assert args.image_command == "list"
def test_list_has_json_flag(self):
"""Test that list command has --json flag."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "list", "--json"])
assert args.json is True
def test_list_has_local_flag(self):
"""Test that list command has --local flag."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "list", "--local"])
assert args.local is True
def test_push_command(self):
"""Test that push command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "push", "my-image"])
assert args.image_command == "push"
assert args.name == "my-image"
def test_pull_command(self):
"""Test that pull command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "pull", "my-image"])
assert args.image_command == "pull"
assert args.name == "my-image"
def test_delete_command(self):
"""Test that delete command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
image.register_parser(subparsers)
args = parser.parse_args(["image", "delete", "my-image", "--force"])
assert args.image_command == "delete"
assert args.name == "my-image"
assert args.force is True
class TestExecute:
"""Tests for execute function."""
def test_dispatch_to_list(self, args_namespace):
"""Test dispatch to list command."""
args = args_namespace(
command="image",
image_command="list",
json=False,
local=False,
cloud=False,
)
with patch.object(image, "cmd_list", return_value=0) as mock_cmd:
result = image.execute(args)
mock_cmd.assert_called_once_with(args)
assert result == 0
def test_dispatch_to_push(self, args_namespace):
"""Test dispatch to push command."""
args = args_namespace(command="image", image_command="push", name="my-image")
with patch.object(image, "cmd_push", return_value=0) as mock_cmd:
image.execute(args)
mock_cmd.assert_called_once_with(args)
def test_dispatch_to_pull(self, args_namespace):
"""Test dispatch to pull command."""
args = args_namespace(command="image", image_command="pull", name="my-image")
with patch.object(image, "cmd_pull", return_value=0) as mock_cmd:
image.execute(args)
mock_cmd.assert_called_once_with(args)
def test_dispatch_to_delete(self, args_namespace):
"""Test dispatch to delete command."""
args = args_namespace(command="image", image_command="delete", name="my-image")
with patch.object(image, "cmd_delete", return_value=0) as mock_cmd:
image.execute(args)
mock_cmd.assert_called_once_with(args)
def test_unknown_command_returns_error(self, args_namespace):
"""Test that unknown command returns error."""
args = args_namespace(command="image", image_command=None)
with patch.object(image, "print_error"):
result = image.execute(args)
assert result == 1
class TestCmdList:
"""Tests for cmd_list function."""
def test_list_cloud_images(self, args_namespace, sample_cloud_images, mock_api_key):
"""Test listing cloud images."""
args = args_namespace(json=False, local=False, cloud=True)
with patch.object(image, "run_async") as mock_run:
mock_run.return_value = [
{
"name": "ubuntu-22.04",
"type": "qcow2",
"tag": "latest",
"size": "5.0 GB",
"status": "ready",
"created": "2024-01-10",
"source": "cloud",
}
]
with patch.object(image, "print_table") as mock_print:
result = image.cmd_list(args)
assert result == 0
mock_print.assert_called_once()
def test_list_local_images(self, args_namespace, tmp_path, monkeypatch):
"""Test listing local images delegates to local_image."""
args = args_namespace(json=False, local=True, cloud=False)
with patch("cua_cli.commands.local_image.cmd_local_list", return_value=0) as mock_local:
result = image.cmd_list(args)
assert result == 0
mock_local.assert_called_once_with(args)
def test_list_empty(self, args_namespace, tmp_path, monkeypatch):
"""Test listing when no local images exist."""
args = args_namespace(json=False, local=True, cloud=False)
with patch("cua_cli.commands.local_image.cmd_local_list", return_value=0) as mock_local:
result = image.cmd_list(args)
assert result == 0
mock_local.assert_called_once()
def test_list_json_output(self, args_namespace, mock_api_key):
"""Test JSON output format."""
args = args_namespace(json=True, local=False, cloud=True)
with patch.object(image, "run_async") as mock_run:
mock_run.return_value = [{"name": "test", "source": "cloud"}]
with patch.object(image, "print_json") as mock_print:
result = image.cmd_list(args)
assert result == 0
mock_print.assert_called_once()
class TestCmdPush:
"""Tests for cmd_push function."""
def test_push_success(self, args_namespace, temp_image_file, mock_api_key):
"""Test successful image push."""
args = args_namespace(
name="test-image",
tag="latest",
type="qcow2",
file=str(temp_image_file),
)
with patch.object(image, "run_async") as mock_run:
mock_run.return_value = 0
result = image.cmd_push(args)
assert result == 0
def test_push_file_not_found(self, args_namespace, mock_api_key):
"""Test push with nonexistent file."""
args = args_namespace(
name="test-image",
tag="latest",
type="qcow2",
file="/nonexistent/path",
)
with patch.object(image, "print_error") as mock_error:
result = image.cmd_push(args)
assert result == 1
mock_error.assert_called()
class TestCmdPull:
"""Tests for cmd_pull function."""
def test_pull_success(self, args_namespace, tmp_path, mock_api_key):
"""Test successful image pull."""
args = args_namespace(
name="test-image",
tag="latest",
output=str(tmp_path / "output.qcow2"),
)
with patch.object(image, "run_async") as mock_run:
mock_run.return_value = 0
result = image.cmd_pull(args)
assert result == 0
class TestCmdDelete:
"""Tests for cmd_delete function."""
def test_delete_with_force(self, args_namespace, mock_api_key):
"""Test delete with --force flag."""
args = args_namespace(name="test-image", tag="latest", force=True, local=False)
with patch.object(image, "run_async") as mock_run:
mock_run.return_value = 0
result = image.cmd_delete(args)
assert result == 0
def test_delete_without_force_prompts(self, args_namespace, mock_api_key):
"""Test delete without --force shows prompt."""
args = args_namespace(name="test-image", tag="latest", force=False, local=False)
with patch.object(image, "print_info") as mock_print:
result = image.cmd_delete(args)
assert result == 1
mock_print.assert_called()
def test_delete_local_delegates(self, args_namespace):
"""Test delete with --local delegates to local_image."""
args = args_namespace(name="test-image", tag="latest", force=True, local=True)
with patch("cua_cli.commands.local_image.cmd_local_delete", return_value=0) as mock_local:
result = image.cmd_delete(args)
assert result == 0
mock_local.assert_called_once_with(args)
class TestHelperFunctions:
"""Tests for helper functions."""
def test_format_date_valid(self):
"""Test formatting a valid ISO date."""
result = image._format_date("2024-01-15T10:30:00Z")
assert result == "2024-01-15"
def test_format_date_empty(self):
"""Test formatting empty string."""
result = image._format_date("")
assert result == "-"
def test_format_date_invalid(self):
"""Test formatting invalid date."""
result = image._format_date("not-a-date")
assert result == "not-a-date" # Falls back to first 10 chars
@@ -0,0 +1,232 @@
"""Tests for MCP server command module."""
import argparse
import sys
from unittest.mock import patch
from cua_cli.commands.mcp import (
PERMISSION_GROUPS,
Permission,
execute,
parse_permissions,
register_parser,
)
class TestPermission:
"""Tests for Permission enum."""
def test_all_permissions_have_values(self):
"""Test that all permissions have string values."""
for perm in Permission:
assert isinstance(perm.value, str)
assert ":" in perm.value
def test_sandbox_permissions(self):
"""Test sandbox permission values."""
assert Permission.SANDBOX_LIST.value == "sandbox:list"
assert Permission.SANDBOX_CREATE.value == "sandbox:create"
assert Permission.SANDBOX_DELETE.value == "sandbox:delete"
def test_computer_permissions(self):
"""Test computer permission values."""
assert Permission.COMPUTER_SCREENSHOT.value == "computer:screenshot"
assert Permission.COMPUTER_CLICK.value == "computer:click"
assert Permission.COMPUTER_TYPE.value == "computer:type"
def test_skills_permissions(self):
"""Test skills permission values."""
assert Permission.SKILLS_LIST.value == "skills:list"
assert Permission.SKILLS_READ.value == "skills:read"
assert Permission.SKILLS_RECORD.value == "skills:record"
class TestPermissionGroups:
"""Tests for permission groups."""
def test_sandbox_all_group(self):
"""Test sandbox:all group contains all sandbox permissions."""
group = PERMISSION_GROUPS["sandbox:all"]
assert Permission.SANDBOX_LIST in group
assert Permission.SANDBOX_CREATE in group
assert Permission.SANDBOX_DELETE in group
assert Permission.SANDBOX_START in group
assert Permission.SANDBOX_STOP in group
def test_sandbox_readonly_group(self):
"""Test sandbox:readonly group contains only read permissions."""
group = PERMISSION_GROUPS["sandbox:readonly"]
assert Permission.SANDBOX_LIST in group
assert Permission.SANDBOX_GET in group
assert Permission.SANDBOX_CREATE not in group
assert Permission.SANDBOX_DELETE not in group
def test_computer_all_group(self):
"""Test computer:all group contains all computer permissions."""
group = PERMISSION_GROUPS["computer:all"]
assert Permission.COMPUTER_SCREENSHOT in group
assert Permission.COMPUTER_CLICK in group
assert Permission.COMPUTER_TYPE in group
assert Permission.COMPUTER_SHELL in group
def test_computer_readonly_group(self):
"""Test computer:readonly group contains only screenshot."""
group = PERMISSION_GROUPS["computer:readonly"]
assert Permission.COMPUTER_SCREENSHOT in group
assert Permission.COMPUTER_CLICK not in group
def test_all_group_contains_everything(self):
"""Test that 'all' group contains all permissions."""
group = PERMISSION_GROUPS["all"]
assert len(group) == len(Permission)
class TestParsePermissions:
"""Tests for parse_permissions function."""
def test_empty_string_returns_empty_set(self):
"""Test that empty string returns empty set."""
result = parse_permissions("")
assert result == set()
def test_single_permission(self):
"""Test parsing a single permission."""
result = parse_permissions("sandbox:list")
assert result == {Permission.SANDBOX_LIST}
def test_multiple_permissions(self):
"""Test parsing multiple permissions."""
result = parse_permissions("sandbox:list,sandbox:create")
assert result == {Permission.SANDBOX_LIST, Permission.SANDBOX_CREATE}
def test_permission_with_spaces(self):
"""Test parsing permissions with spaces."""
result = parse_permissions("sandbox:list, sandbox:create")
assert result == {Permission.SANDBOX_LIST, Permission.SANDBOX_CREATE}
def test_group_expansion(self):
"""Test that groups are expanded."""
result = parse_permissions("sandbox:readonly")
assert Permission.SANDBOX_LIST in result
assert Permission.SANDBOX_GET in result
def test_all_group(self):
"""Test that 'all' group includes everything."""
result = parse_permissions("all")
assert len(result) == len(Permission)
def test_mixed_groups_and_permissions(self):
"""Test mixing groups and individual permissions."""
result = parse_permissions("sandbox:readonly,computer:screenshot")
assert Permission.SANDBOX_LIST in result
assert Permission.SANDBOX_GET in result
assert Permission.COMPUTER_SCREENSHOT in result
def test_unknown_permission_ignored(self):
"""Test that unknown permissions are ignored with warning."""
with patch("cua_cli.commands.mcp.logger") as mock_logger:
result = parse_permissions("sandbox:list,unknown:perm")
assert result == {Permission.SANDBOX_LIST}
mock_logger.warning.assert_called()
class TestRegisterParser:
"""Tests for register_parser function."""
def test_registers_serve_mcp_command(self):
"""Test that serve-mcp command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
register_parser(subparsers)
args = parser.parse_args(["serve-mcp"])
assert hasattr(args, "permissions")
def test_has_permissions_flag(self):
"""Test that --permissions flag is available."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
register_parser(subparsers)
args = parser.parse_args(["serve-mcp", "--permissions", "sandbox:all"])
assert args.permissions == "sandbox:all"
def test_has_sandbox_flag(self):
"""Test that --sandbox flag is available."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
register_parser(subparsers)
args = parser.parse_args(["serve-mcp", "--sandbox", "my-sandbox"])
assert args.sandbox == "my-sandbox"
class TestExecute:
"""Tests for execute function."""
def test_missing_fastmcp_returns_error(self, args_namespace, capsys):
"""Test that missing fastmcp dependency returns error."""
args = args_namespace(permissions="", sandbox="")
# Temporarily remove mcp modules to simulate ImportError
original_modules = {}
for mod in list(sys.modules.keys()):
if mod.startswith("mcp"):
original_modules[mod] = sys.modules.pop(mod)
try:
with patch.dict(
"sys.modules", {"mcp": None, "mcp.server": None, "mcp.server.fastmcp": None}
):
result = execute(args)
finally:
# Restore modules
sys.modules.update(original_modules)
assert result == 1
def test_permissions_from_env_var(self, args_namespace, monkeypatch):
"""Test that permissions are read from environment variable."""
monkeypatch.setenv("CUA_MCP_PERMISSIONS", "sandbox:readonly")
permissions = parse_permissions("sandbox:readonly")
assert Permission.SANDBOX_LIST in permissions
assert Permission.SANDBOX_CREATE not in permissions
def test_permissions_from_args_override_env(self, args_namespace, monkeypatch):
"""Test that command line args override environment variable."""
args = args_namespace(permissions="computer:all", sandbox="")
monkeypatch.setenv("CUA_MCP_PERMISSIONS", "sandbox:readonly")
# The args.permissions should take precedence
assert args.permissions == "computer:all"
class TestMCPToolRegistration:
"""Tests for MCP tool registration based on permissions."""
def test_permission_affects_tool_registration(self):
"""Test that permissions control which tools would be registered."""
# This is a unit test of the permission logic, not the actual registration
# The actual registration requires fastmcp which may not be installed
all_perms = parse_permissions("all")
assert len(all_perms) == len(Permission)
readonly_perms = parse_permissions("sandbox:readonly")
assert Permission.SANDBOX_LIST in readonly_perms
assert Permission.SANDBOX_CREATE not in readonly_perms
# Test that the registration functions exist and are callable
from cua_cli.commands.mcp import (
_register_computer_tools,
_register_sandbox_tools,
_register_skills_tools,
)
assert callable(_register_sandbox_tools)
assert callable(_register_computer_tools)
assert callable(_register_skills_tools)
@@ -0,0 +1,671 @@
"""Tests for sandbox command module."""
import argparse
from unittest.mock import AsyncMock, MagicMock, patch
from cua_cli.commands import sandbox
class TestRegisterParser:
"""Tests for register_parser function."""
def test_registers_sandbox_command(self):
"""Test that sandbox command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
# Parse a sandbox command to verify it's registered
args = parser.parse_args(["sandbox", "list"])
assert args.sandbox_command == "list"
def test_registers_sb_alias(self):
"""Test that sb alias is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
# Parse using alias
args = parser.parse_args(["sb", "list"])
assert args.sandbox_command == "list"
def test_list_command_has_json_flag(self):
"""Test that list command has --json flag."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(["sandbox", "list", "--json"])
assert args.json is True
def test_create_command_has_required_args(self):
"""Test that create command has required arguments."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(
[
"sandbox",
"create",
"--os",
"linux",
"--size",
"medium",
"--region",
"north-america",
]
)
assert args.os == "linux"
assert args.size == "medium"
assert args.region == "north-america"
class TestExecute:
"""Tests for execute function."""
def test_dispatch_to_list(self, args_namespace):
"""Test dispatch to list command."""
args = args_namespace(
command="sandbox", sandbox_command="list", json=False, show_passwords=False
)
with patch.object(sandbox, "cmd_list", return_value=0) as mock_cmd:
result = sandbox.execute(args)
mock_cmd.assert_called_once_with(args)
assert result == 0
def test_dispatch_to_list_alias(self, args_namespace):
"""Test dispatch to list command via ls alias."""
args = args_namespace(command="sb", sandbox_command="ls", json=False, show_passwords=False)
with patch.object(sandbox, "cmd_list", return_value=0) as mock_cmd:
sandbox.execute(args)
mock_cmd.assert_called_once_with(args)
def test_unknown_command_returns_error(self, args_namespace):
"""Test that unknown command returns error."""
args = args_namespace(command="sandbox", sandbox_command=None)
with patch.object(sandbox, "print_error") as mock_error:
result = sandbox.execute(args)
assert result == 1
mock_error.assert_called()
class TestCmdList:
"""Tests for cmd_list function."""
def test_list_sandboxes_success(self, args_namespace, sample_vm_list, mock_api_key):
"""Test listing sandboxes successfully."""
args = args_namespace(json=False, show_passwords=False)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=sample_vm_list)
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_table") as mock_print:
result = sandbox.cmd_list(args)
assert result == 0
mock_print.assert_called_once()
def test_list_sandboxes_empty(self, args_namespace, mock_api_key):
"""Test listing when no sandboxes exist."""
args = args_namespace(json=False, show_passwords=False)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[])
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_info") as mock_print:
result = sandbox.cmd_list(args)
assert result == 0
mock_print.assert_called_with("No sandboxes found.")
def test_list_sandboxes_json_output(self, args_namespace, sample_vm_list, mock_api_key):
"""Test JSON output format."""
args = args_namespace(json=True, show_passwords=False)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=sample_vm_list)
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_json") as mock_print:
result = sandbox.cmd_list(args)
assert result == 0
mock_print.assert_called_once_with(sample_vm_list)
class TestCmdCreate:
"""Tests for cmd_create function."""
def test_create_sandbox_success(self, args_namespace, mock_api_key):
"""Test creating a sandbox successfully."""
args = args_namespace(
os="linux",
size="medium",
region="north-america",
json=False,
)
# Mock the API response (status 202 = provisioning)
async def mock_api_request(*args, **kwargs):
return (
202,
{
"status": "provisioning",
"name": "new-sandbox",
"host": "sandbox.example.com",
},
)
with patch.object(sandbox, "_api_request", side_effect=mock_api_request):
with patch.object(sandbox, "print_info"):
result = sandbox.cmd_create(args)
assert result == 0
def test_create_sandbox_error(self, args_namespace, mock_api_key):
"""Test handling create error."""
args = args_namespace(
os="linux",
size="medium",
region="north-america",
json=False,
)
# Mock the API response (status 500 = error)
async def mock_api_request(*args, **kwargs):
return (500, "Quota exceeded")
with patch.object(sandbox, "_api_request", side_effect=mock_api_request):
with patch.object(sandbox, "print_error") as mock_error:
result = sandbox.cmd_create(args)
assert result == 1
mock_error.assert_called()
class TestCmdGet:
"""Tests for cmd_get function."""
def test_get_sandbox_success(self, args_namespace, sample_vm, mock_api_key):
"""Test getting sandbox details."""
args = args_namespace(
name="test-sandbox-1",
json=False,
show_passwords=False,
show_vnc_url=False,
)
vm_dict = {
"name": sample_vm.name,
"status": sample_vm.status,
"os_type": sample_vm.os_type,
"host": "sandbox.example.com",
}
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[vm_dict])
mock_provider.get_vm = AsyncMock(return_value={"status": "running"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_info"):
result = sandbox.cmd_get(args)
assert result == 0
def test_get_sandbox_not_found(self, args_namespace, mock_api_key):
"""Test getting nonexistent sandbox."""
args = args_namespace(
name="nonexistent",
json=False,
show_passwords=False,
show_vnc_url=False,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[])
mock_provider.get_vm = AsyncMock(return_value={"status": "not_found"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error") as mock_error:
result = sandbox.cmd_get(args)
assert result == 1
mock_error.assert_called()
class TestCmdStart:
"""Tests for cmd_start function."""
def test_start_sandbox_success(self, args_namespace, mock_api_key):
"""Test starting a sandbox."""
args = args_namespace(name="test-sandbox")
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.run_vm = AsyncMock(return_value={"status": "starting"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_success"):
result = sandbox.cmd_start(args)
assert result == 0
def test_start_sandbox_not_found(self, args_namespace, mock_api_key):
"""Test starting nonexistent sandbox."""
args = args_namespace(name="nonexistent")
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.run_vm = AsyncMock(return_value={"status": "not_found"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error"):
result = sandbox.cmd_start(args)
assert result == 1
class TestCmdStop:
"""Tests for cmd_stop function."""
def test_stop_sandbox_success(self, args_namespace, mock_api_key):
"""Test stopping a sandbox."""
args = args_namespace(name="test-sandbox")
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.stop_vm = AsyncMock(return_value={"status": "stopping"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_success"):
result = sandbox.cmd_stop(args)
assert result == 0
class TestCmdRestart:
"""Tests for cmd_restart function."""
def test_restart_sandbox_success(self, args_namespace, mock_api_key):
"""Test restarting a sandbox."""
args = args_namespace(name="test-sandbox")
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.restart_vm = AsyncMock(return_value={"status": "restarting"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_success"):
result = sandbox.cmd_restart(args)
assert result == 0
class TestCmdSuspend:
"""Tests for cmd_suspend function."""
def test_suspend_sandbox_success(self, args_namespace, mock_api_key):
"""Test suspending a sandbox."""
args = args_namespace(name="test-sandbox")
# Mock the API response (status 202 = suspending)
async def mock_api_request(*args, **kwargs):
return (202, {"status": "suspending"})
with patch.object(sandbox, "_api_request", side_effect=mock_api_request):
with patch.object(sandbox, "print_success"):
result = sandbox.cmd_suspend(args)
assert result == 0
def test_suspend_unsupported(self, args_namespace, mock_api_key):
"""Test suspend on unsupported sandbox."""
args = args_namespace(name="test-sandbox")
# Mock the API response (status 400 = unsupported)
async def mock_api_request(*args, **kwargs):
return (400, "Suspend not supported for Windows")
with patch.object(sandbox, "_api_request", side_effect=mock_api_request):
with patch.object(sandbox, "print_error"):
result = sandbox.cmd_suspend(args)
assert result == 1
class TestCmdDelete:
"""Tests for cmd_delete function."""
def test_delete_sandbox_success(self, args_namespace, mock_api_key):
"""Test deleting a sandbox."""
args = args_namespace(name="test-sandbox")
# Mock the API response (status 202 = deleting)
async def mock_api_request(*args, **kwargs):
return (202, {"status": "deleting"})
with patch.object(sandbox, "_api_request", side_effect=mock_api_request):
with patch.object(sandbox, "print_success"):
result = sandbox.cmd_delete(args)
assert result == 0
class TestCmdVnc:
"""Tests for cmd_vnc function."""
def test_vnc_opens_browser(self, args_namespace, mock_api_key, mock_webbrowser):
"""Test VNC opens browser with correct URL."""
args = args_namespace(name="test-sandbox")
vm_info = {
"name": "test-sandbox",
"vnc_url": "https://vnc.example.com/test",
}
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[vm_info])
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_info"):
result = sandbox.cmd_vnc(args)
assert result == 0
mock_webbrowser.assert_called_once_with("https://vnc.example.com/test")
def test_vnc_constructs_url_from_host(self, args_namespace, mock_api_key, mock_webbrowser):
"""Test VNC constructs URL when vnc_url not provided."""
args = args_namespace(name="test-sandbox")
vm_info = {
"name": "test-sandbox",
"host": "sandbox.example.com",
"password": "secret123",
}
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[vm_info])
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_info"):
result = sandbox.cmd_vnc(args)
assert result == 0
mock_webbrowser.assert_called_once()
# Check URL contains host and encoded password
call_url = mock_webbrowser.call_args[0][0]
assert "sandbox.example.com" in call_url
assert "secret123" in call_url
def test_vnc_sandbox_not_found(self, args_namespace, mock_api_key):
"""Test VNC with nonexistent sandbox."""
args = args_namespace(name="nonexistent")
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.list_vms = AsyncMock(return_value=[])
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error"):
result = sandbox.cmd_vnc(args)
assert result == 1
class TestCmdShell:
"""Tests for cmd_shell function."""
def test_shell_parser_registration(self):
"""Test that shell command is registered with correct arguments."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
# Test basic shell command
args = parser.parse_args(["sb", "shell", "my-sandbox"])
assert args.name == "my-sandbox"
assert args.sandbox_command == "shell"
def test_shell_with_command(self):
"""Test shell command with a command to execute."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(["sb", "shell", "my-sandbox", "ls", "-la"])
assert args.name == "my-sandbox"
assert args.shell_command == ["ls", "-la"]
def test_shell_with_cols_rows(self):
"""Test shell command with terminal size options.
Options must come before name due to argparse REMAINDER behavior.
Usage: cua sb shell --cols 120 --rows 40 mybox [command...]
"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(
["sb", "shell", "--cols", "120", "--rows", "40", "my-sandbox", "ls"]
)
assert args.cols == 120
assert args.rows == 40
assert args.name == "my-sandbox"
assert args.shell_command == ["ls"]
def test_shell_sandbox_not_found(self, args_namespace, mock_api_key):
"""Test shell with nonexistent sandbox."""
args = args_namespace(
name="nonexistent",
shell_command=[],
cols=None,
rows=None,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(return_value={"status": "not_found"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error") as mock_error:
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = sandbox.cmd_shell(args)
assert result == 1
mock_error.assert_called()
def test_shell_no_api_url(self, args_namespace, mock_api_key):
"""Test shell when sandbox has no API URL."""
args = args_namespace(
name="test-sandbox",
shell_command=["echo", "hello"],
cols=None,
rows=None,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(return_value={"status": "stopped", "api_url": None})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error") as mock_error:
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
result = sandbox.cmd_shell(args)
assert result == 1
mock_error.assert_called()
class TestCmdExec:
"""Tests for cmd_exec function."""
def test_exec_parser_registration(self):
"""Test that exec command is registered with correct arguments."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(["sb", "exec", "my-sandbox", "echo", "hello"])
assert args.name == "my-sandbox"
assert args.sandbox_command == "exec"
assert args.exec_command == ["echo", "hello"]
def test_exec_with_json_flag(self):
"""Test exec command with --json flag.
--json must come before name due to argparse REMAINDER behavior.
Usage: cua sb exec --json mybox <command...>
"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
sandbox.register_parser(subparsers)
args = parser.parse_args(["sb", "exec", "--json", "my-sandbox", "echo", "hello"])
assert args.json is True
assert args.name == "my-sandbox"
assert args.exec_command == ["echo", "hello"]
def test_exec_no_command_error(self, args_namespace, mock_api_key):
"""Test exec with no command returns error."""
args = args_namespace(
name="test-sandbox",
exec_command=[],
json=False,
)
with patch.object(sandbox, "print_error") as mock_error:
result = sandbox.cmd_exec(args)
assert result == 1
mock_error.assert_called_with("No command provided")
def test_exec_sandbox_not_found(self, args_namespace, mock_api_key):
"""Test exec with nonexistent sandbox."""
args = args_namespace(
name="nonexistent",
exec_command=["echo", "hello"],
json=False,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(return_value={"status": "not_found"})
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "print_error") as mock_error:
result = sandbox.cmd_exec(args)
assert result == 1
mock_error.assert_called()
def test_exec_success(self, args_namespace, mock_api_key, capsys):
"""Test successful command execution."""
args = args_namespace(
name="test-sandbox",
exec_command=["echo", "hello"],
json=False,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(
return_value={"status": "running", "api_url": "https://sandbox.example.com:8443"}
)
async def mock_exec(*a, **kw):
return {"success": True, "stdout": "hello\n", "stderr": "", "returncode": 0}
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "_exec_noninteractive", side_effect=mock_exec):
result = sandbox.cmd_exec(args)
assert result == 0
captured = capsys.readouterr()
assert "hello" in captured.out
def test_exec_json_output(self, args_namespace, mock_api_key):
"""Test exec with JSON output."""
args = args_namespace(
name="test-sandbox",
exec_command=["echo", "hello"],
json=True,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(
return_value={"status": "running", "api_url": "https://sandbox.example.com:8443"}
)
async def mock_exec(*a, **kw):
return {"success": True, "stdout": "hello\n", "stderr": "", "returncode": 0}
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "_exec_noninteractive", side_effect=mock_exec):
with patch.object(sandbox, "print_json") as mock_json:
result = sandbox.cmd_exec(args)
assert result == 0
mock_json.assert_called_once()
def test_exec_command_failure(self, args_namespace, mock_api_key, capsys):
"""Test exec when command returns non-zero exit code."""
args = args_namespace(
name="test-sandbox",
exec_command=["false"],
json=False,
)
mock_provider = MagicMock()
mock_provider.__aenter__ = AsyncMock(return_value=mock_provider)
mock_provider.__aexit__ = AsyncMock(return_value=None)
mock_provider.get_vm = AsyncMock(
return_value={"status": "running", "api_url": "https://sandbox.example.com:8443"}
)
async def mock_exec(*a, **kw):
return {"success": True, "stdout": "", "stderr": "error", "returncode": 1}
with patch.object(sandbox, "_get_provider", return_value=mock_provider):
with patch.object(sandbox, "_exec_noninteractive", side_effect=mock_exec):
result = sandbox.cmd_exec(args)
assert result == 1
@@ -0,0 +1,235 @@
"""Tests for skills command module."""
import argparse
from unittest.mock import patch
from cua_cli.commands import skills
class TestRegisterParser:
"""Tests for register_parser function."""
def test_registers_skills_command(self):
"""Test that skills command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
skills.register_parser(subparsers)
args = parser.parse_args(["skills", "list"])
assert args.skills_command == "list"
def test_record_command_exists(self):
"""Test that record command is registered."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
skills.register_parser(subparsers)
args = parser.parse_args(["skills", "record"])
assert args.skills_command == "record"
def test_record_command_has_sandbox_flag(self):
"""Test that record command has --sandbox flag."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
skills.register_parser(subparsers)
args = parser.parse_args(["skills", "record", "--sandbox", "my-sandbox"])
assert args.sandbox == "my-sandbox"
def test_read_command_has_name_arg(self):
"""Test that read command has name argument."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
skills.register_parser(subparsers)
args = parser.parse_args(["skills", "read", "my-skill"])
assert args.name == "my-skill"
class TestExecute:
"""Tests for execute function."""
def test_dispatch_to_list(self, args_namespace):
"""Test dispatch to list command."""
args = args_namespace(command="skills", skills_command="list", json=False)
with patch.object(skills, "cmd_list", return_value=0) as mock_cmd:
result = skills.execute(args)
mock_cmd.assert_called_once_with(args)
assert result == 0
def test_dispatch_to_read(self, args_namespace):
"""Test dispatch to read command."""
args = args_namespace(command="skills", skills_command="read", name="my-skill")
with patch.object(skills, "cmd_read", return_value=0) as mock_cmd:
skills.execute(args)
mock_cmd.assert_called_once_with(args)
def test_dispatch_to_delete(self, args_namespace):
"""Test dispatch to delete command."""
args = args_namespace(command="skills", skills_command="delete", name="my-skill")
with patch.object(skills, "cmd_delete", return_value=0) as mock_cmd:
skills.execute(args)
mock_cmd.assert_called_once_with(args)
def test_unknown_command_returns_error(self, args_namespace):
"""Test that unknown command returns error."""
args = args_namespace(command="skills", skills_command=None)
with patch.object(skills, "print_error"):
result = skills.execute(args)
assert result == 1
class TestCmdList:
"""Tests for cmd_list function."""
def test_list_empty_directory(self, args_namespace, temp_skills_dir):
"""Test listing when no skills exist."""
args = args_namespace(json=False)
with patch.object(skills, "print_info") as mock_print:
result = skills.cmd_list(args)
assert result == 0
mock_print.assert_called()
def test_list_with_skills(self, args_namespace, sample_skill, temp_skills_dir):
"""Test listing existing skills."""
args = args_namespace(json=False)
with patch.object(skills, "print_table") as mock_print:
result = skills.cmd_list(args)
assert result == 0
mock_print.assert_called_once()
# Verify the skill is in the output
call_args = mock_print.call_args[0]
skill_data = call_args[0]
assert len(skill_data) == 1
assert skill_data[0]["name"] == "test-skill"
def test_list_json_output(self, args_namespace, sample_skill, temp_skills_dir):
"""Test JSON output format."""
args = args_namespace(json=True)
with patch.object(skills, "print_json") as mock_print:
result = skills.cmd_list(args)
assert result == 0
mock_print.assert_called_once()
class TestCmdRead:
"""Tests for cmd_read function."""
def test_read_existing_skill(self, args_namespace, sample_skill, temp_skills_dir, capsys):
"""Test reading an existing skill."""
args = args_namespace(name="test-skill", format="md")
result = skills.cmd_read(args)
assert result == 0
# Verify content was printed to stdout
captured = capsys.readouterr()
assert "test-skill" in captured.out
def test_read_nonexistent_skill(self, args_namespace, temp_skills_dir):
"""Test reading a nonexistent skill."""
args = args_namespace(name="nonexistent", format="md")
with patch.object(skills, "print_error") as mock_error:
result = skills.cmd_read(args)
assert result == 1
mock_error.assert_called()
class TestCmdDelete:
"""Tests for cmd_delete function."""
def test_delete_existing_skill(self, args_namespace, sample_skill, temp_skills_dir):
"""Test deleting an existing skill."""
args = args_namespace(name="test-skill")
assert sample_skill.exists()
with patch.object(skills, "print_success"):
result = skills.cmd_delete(args)
assert result == 0
assert not sample_skill.exists()
def test_delete_nonexistent_skill(self, args_namespace, temp_skills_dir):
"""Test deleting a nonexistent skill."""
args = args_namespace(name="nonexistent")
with patch.object(skills, "print_error") as mock_error:
result = skills.cmd_delete(args)
assert result == 1
mock_error.assert_called()
class TestCmdClean:
"""Tests for cmd_clean function."""
def test_clean_removes_all_skills(self, args_namespace, sample_skill, temp_skills_dir):
"""Test that clean removes all skills."""
args = args_namespace()
assert sample_skill.exists()
with patch("builtins.input", return_value="y"):
with patch.object(skills, "print_success"):
result = skills.cmd_clean(args)
assert result == 0
# Verify no skills remain
remaining = list(temp_skills_dir.iterdir())
assert len(remaining) == 0
def test_clean_empty_directory(self, args_namespace, temp_skills_dir):
"""Test clean on empty directory."""
args = args_namespace()
with patch.object(skills, "print_info"):
result = skills.cmd_clean(args)
assert result == 0
class TestCmdReplay:
"""Tests for cmd_replay function."""
def test_replay_existing_skill(
self, args_namespace, sample_skill, temp_skills_dir, mock_webbrowser
):
"""Test replaying an existing skill opens the video."""
args = args_namespace(name="test-skill")
with patch.object(skills, "print_info"):
result = skills.cmd_replay(args)
assert result == 0
# Verify webbrowser was called with the video path
mock_webbrowser.assert_called_once()
call_url = mock_webbrowser.call_args[0][0]
assert "test-skill.mp4" in call_url
def test_replay_nonexistent_skill(self, args_namespace, temp_skills_dir):
"""Test replaying a nonexistent skill."""
args = args_namespace(name="nonexistent")
with patch.object(skills, "print_error") as mock_error:
result = skills.cmd_replay(args)
assert result == 1
mock_error.assert_called()
+254
View File
@@ -0,0 +1,254 @@
"""Shared test fixtures for cua-cli tests."""
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Mock external modules before importing cua_cli
# This allows tests to run without cua-computer installed
mock_providers = MagicMock()
mock_providers.VMProviderFactory = MagicMock()
mock_providers.VMProviderType = MagicMock()
mock_providers.VMProviderType.CLOUD = "cloud"
mock_computer = MagicMock()
mock_computer.providers = mock_providers
mock_computer.providers.cloud = MagicMock()
mock_computer.providers.cloud.provider = MagicMock()
mock_computer.providers.cloud.provider.CloudProvider = MagicMock()
sys.modules["computer"] = mock_computer
sys.modules["computer.providers"] = mock_providers
sys.modules["computer.providers.cloud"] = mock_computer.providers.cloud
sys.modules["computer.providers.cloud.provider"] = mock_computer.providers.cloud.provider
@pytest.fixture
def disable_telemetry(monkeypatch):
"""Disable telemetry for tests."""
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
@pytest.fixture
def temp_credentials_db(tmp_path, monkeypatch):
"""Provide a temporary credentials database."""
cua_dir = tmp_path / ".cua"
cua_dir.mkdir(parents=True)
db_path = cua_dir / "credentials.db"
# Patch the CREDENTIALS_DB path in the store module
monkeypatch.setattr("cua_cli.auth.store.CREDENTIALS_DB", db_path)
yield db_path
if db_path.exists():
db_path.unlink()
@pytest.fixture
def mock_api_key(monkeypatch):
"""Set a mock API key in environment."""
monkeypatch.setenv("CUA_API_KEY", "test-api-key-12345")
return "test-api-key-12345"
@pytest.fixture
def clear_api_key_env(monkeypatch):
"""Ensure no API key is in environment."""
monkeypatch.delenv("CUA_API_KEY", raising=False)
@pytest.fixture
def mock_aiohttp_session():
"""Mock aiohttp.ClientSession for API tests."""
with patch("aiohttp.ClientSession") as mock_session:
mock_instance = AsyncMock()
mock_session.return_value.__aenter__.return_value = mock_instance
mock_session.return_value.__aexit__.return_value = None
yield mock_instance
@pytest.fixture
def mock_cloud_provider():
"""Mock CloudProvider for sandbox tests."""
with patch("cua_cli.commands.sandbox.VMProviderFactory") as mock_factory:
mock_provider = AsyncMock()
mock_provider.__aenter__.return_value = mock_provider
mock_provider.__aexit__.return_value = None
mock_factory.create_provider.return_value = mock_provider
yield mock_provider
@pytest.fixture
def sample_vm():
"""Sample VM object for testing."""
vm = MagicMock()
vm.name = "test-sandbox-1"
vm.status = "running"
vm.os_type = "linux"
vm.created_at = "2024-01-15T10:00:00Z"
vm.size = "medium"
vm.region = "north-america"
vm.vnc_url = "https://vnc.example.com/test-sandbox-1"
vm.server_url = "https://server.example.com:8000"
return vm
@pytest.fixture
def sample_vm_list(sample_vm):
"""Sample VM list for testing."""
vm2 = MagicMock()
vm2.name = "test-sandbox-2"
vm2.status = "stopped"
vm2.os_type = "macos"
vm2.created_at = "2024-01-14T08:30:00Z"
vm2.size = "large"
vm2.region = "europe"
vm2.vnc_url = None
vm2.server_url = None
return [sample_vm, vm2]
@pytest.fixture
def temp_skills_dir(tmp_path, monkeypatch):
"""Provide a temporary skills directory."""
skills_dir = tmp_path / ".cua" / "skills"
skills_dir.mkdir(parents=True)
# Patch the SKILLS_DIR in the skills module
monkeypatch.setattr("cua_cli.commands.skills.SKILLS_DIR", skills_dir)
return skills_dir
@pytest.fixture
def sample_skill(temp_skills_dir):
"""Create a sample skill for testing."""
skill_dir = temp_skills_dir / "test-skill"
skill_dir.mkdir()
# Create SKILL.md with proper frontmatter format expected by _parse_frontmatter
skill_file = skill_dir / "SKILL.md"
skill_file.write_text("""---
name: test-skill
description: A sample skill for testing
---
# Test Skill
A sample skill for testing.
## Steps
1. Click on the button
2. Type some text
3. Press Enter
""")
# Create trajectory directory with trajectory.json and a video file
trajectory_dir = skill_dir / "trajectory"
trajectory_dir.mkdir()
# Create trajectory.json for proper skill info extraction
trajectory_json = trajectory_dir / "trajectory.json"
trajectory_json.write_text("""{
"trajectory": [
{"step_idx": 1, "caption": {"action": "click"}},
{"step_idx": 2, "caption": {"action": "type"}}
],
"metadata": {
"created_at": "2024-01-15T10:00:00Z"
}
}""")
# Create a fake MP4 file for replay tests
video_file = trajectory_dir / "test-skill.mp4"
video_file.write_bytes(b"fake mp4 content")
return skill_dir
@pytest.fixture
def mock_image_api_client():
"""Mock CloudAPIClient for image tests."""
with patch("cua_cli.commands.image.CloudAPIClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
yield mock_client
@pytest.fixture
def sample_cloud_images():
"""Sample cloud images for testing."""
return [
{
"name": "ubuntu-22.04",
"image_type": "qcow2",
"created_at": "2024-01-10T12:00:00Z",
"versions": [
{
"tag": "latest",
"size_bytes": 5368709120,
"status": "ready",
"created_at": "2024-01-10T12:00:00Z",
},
{
"tag": "v1.0",
"size_bytes": 5000000000,
"status": "ready",
"created_at": "2024-01-05T10:00:00Z",
},
],
},
{
"name": "macos-sonoma",
"image_type": "qcow2",
"created_at": "2024-01-08T09:00:00Z",
"versions": [
{
"tag": "latest",
"size_bytes": 21474836480,
"status": "ready",
"created_at": "2024-01-08T09:00:00Z",
},
],
},
]
@pytest.fixture
def temp_image_file(tmp_path):
"""Create a temporary image file for upload tests."""
image_file = tmp_path / "test-image" / "data.img"
image_file.parent.mkdir(parents=True)
# Create a small test file
image_file.write_bytes(b"fake image content" * 1000)
return image_file
@pytest.fixture
def mock_webbrowser():
"""Mock webbrowser.open for browser tests."""
with patch("webbrowser.open") as mock_open:
yield mock_open
@pytest.fixture
def mock_rich_console():
"""Mock rich console for output tests."""
with patch("cua_cli.utils.output.Console") as mock_console_class:
mock_console = MagicMock()
mock_console_class.return_value = mock_console
yield mock_console
@pytest.fixture
def args_namespace():
"""Create an argparse.Namespace factory for command tests."""
import argparse
def create_args(**kwargs):
return argparse.Namespace(**kwargs)
return create_args
+166
View File
@@ -0,0 +1,166 @@
"""Tests for the main CLI entry point."""
import argparse
import sys
from unittest.mock import patch
import pytest
from cua_cli.main import create_parser, main
class TestCreateParser:
"""Tests for create_parser function."""
def test_creates_parser(self):
"""Test that parser is created."""
parser = create_parser()
assert isinstance(parser, argparse.ArgumentParser)
assert parser.prog == "cua"
def test_has_version_flag(self):
"""Test that --version flag is present."""
parser = create_parser()
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--version"])
assert exc_info.value.code == 0
def test_has_auth_command(self):
"""Test that auth command is registered."""
parser = create_parser()
args = parser.parse_args(["auth", "login"])
assert args.command == "auth"
assert args.auth_command == "login"
def test_has_sandbox_command(self):
"""Test that sandbox command is registered."""
parser = create_parser()
args = parser.parse_args(["sandbox", "list"])
assert args.command == "sandbox"
assert args.sandbox_command == "list"
def test_has_sb_alias(self):
"""Test that sb alias works."""
parser = create_parser()
args = parser.parse_args(["sb", "list"])
assert args.command == "sb"
assert args.sandbox_command == "list"
def test_has_image_command(self):
"""Test that image command is registered."""
parser = create_parser()
args = parser.parse_args(["image", "list"])
assert args.command == "image"
assert args.image_command == "list"
def test_has_skills_command(self):
"""Test that skills command is registered."""
parser = create_parser()
args = parser.parse_args(["skills", "list"])
assert args.command == "skills"
assert args.skills_command == "list"
def test_has_serve_mcp_command(self):
"""Test that serve-mcp command is registered."""
parser = create_parser()
args = parser.parse_args(["serve-mcp"])
assert args.command == "serve-mcp"
class TestMain:
"""Tests for main function."""
def test_no_args_shows_help(self, capsys):
"""Test that no arguments shows help."""
with patch.object(sys, "argv", ["cua"]):
result = main()
assert result == 0
captured = capsys.readouterr()
assert "usage:" in captured.out.lower() or "cua" in captured.out
def test_dispatch_to_auth(self):
"""Test dispatch to auth command."""
with patch.object(sys, "argv", ["cua", "auth", "logout"]):
with patch("cua_cli.commands.auth.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_dispatch_to_sandbox(self):
"""Test dispatch to sandbox command."""
with patch.object(sys, "argv", ["cua", "sandbox", "list"]):
with patch("cua_cli.commands.sandbox.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_dispatch_to_sb_alias(self):
"""Test dispatch to sb alias."""
with patch.object(sys, "argv", ["cua", "sb", "list"]):
with patch("cua_cli.commands.sandbox.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_dispatch_to_image(self):
"""Test dispatch to image command."""
with patch.object(sys, "argv", ["cua", "image", "list"]):
with patch("cua_cli.commands.image.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_dispatch_to_skills(self):
"""Test dispatch to skills command."""
with patch.object(sys, "argv", ["cua", "skills", "list"]):
with patch("cua_cli.commands.skills.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_dispatch_to_mcp(self):
"""Test dispatch to serve-mcp command."""
with patch.object(sys, "argv", ["cua", "serve-mcp"]):
with patch("cua_cli.commands.mcp.execute", return_value=0) as mock_execute:
result = main()
mock_execute.assert_called_once()
assert result == 0
def test_keyboard_interrupt_returns_130(self):
"""Test that KeyboardInterrupt returns exit code 130."""
with patch.object(sys, "argv", ["cua", "sandbox", "list"]):
with patch("cua_cli.commands.sandbox.execute", side_effect=KeyboardInterrupt):
result = main()
assert result == 130
def test_exception_returns_1(self):
"""Test that exceptions return exit code 1."""
with patch.object(sys, "argv", ["cua", "sandbox", "list"]):
with patch("cua_cli.commands.sandbox.execute", side_effect=Exception("Test error")):
result = main()
assert result == 1
def test_unknown_command_returns_1(self):
"""Test that unknown commands are handled."""
# Mock parse_args to return an unknown command
with patch.object(sys, "argv", ["cua", "unknown"]):
# This should trigger SystemExit from argparse
with pytest.raises(SystemExit):
main()
@@ -0,0 +1 @@
"""Utils module tests."""
@@ -0,0 +1,95 @@
"""Tests for async utilities."""
import asyncio
import pytest
from cua_cli.utils.async_utils import run_async
class TestRunAsync:
"""Tests for run_async function."""
def test_runs_coroutine(self):
"""Test that coroutine is executed."""
async def sample_coro():
return "result"
result = run_async(sample_coro())
assert result == "result"
def test_returns_correct_value(self):
"""Test that return value is correct."""
async def add(a, b):
return a + b
result = run_async(add(2, 3))
assert result == 5
def test_propagates_exception(self):
"""Test that exceptions are propagated."""
async def failing_coro():
raise ValueError("Test error")
with pytest.raises(ValueError) as exc_info:
run_async(failing_coro())
assert "Test error" in str(exc_info.value)
def test_handles_async_sleep(self):
"""Test that async sleep works correctly."""
async def with_sleep():
await asyncio.sleep(0.01)
return "done"
result = run_async(with_sleep())
assert result == "done"
def test_handles_nested_coroutines(self):
"""Test that nested coroutines work."""
async def inner():
return 42
async def outer():
return await inner()
result = run_async(outer())
assert result == 42
def test_handles_none_result(self):
"""Test that None result is handled."""
async def returns_none():
pass
result = run_async(returns_none())
assert result is None
def test_handles_list_result(self):
"""Test that list result is handled."""
async def returns_list():
return [1, 2, 3]
result = run_async(returns_list())
assert result == [1, 2, 3]
def test_handles_dict_result(self):
"""Test that dict result is handled."""
async def returns_dict():
return {"key": "value"}
result = run_async(returns_dict())
assert result == {"key": "value"}
@@ -0,0 +1,163 @@
"""Tests for output utilities."""
from unittest.mock import patch
from cua_cli.utils.output import (
print_error,
print_info,
print_json,
print_success,
print_table,
print_warning,
)
class TestPrintTable:
"""Tests for print_table function."""
def test_prints_table_with_data(self):
"""Test printing a table with data."""
data = [
{"name": "test1", "status": "running"},
{"name": "test2", "status": "stopped"},
]
columns = [("name", "NAME"), ("status", "STATUS")]
with patch("cua_cli.utils.output.console") as mock_console:
print_table(data, columns)
mock_console.print.assert_called_once()
def test_handles_empty_data(self):
"""Test handling empty data list."""
data = []
with patch("cua_cli.utils.output.console") as mock_console:
print_table(data)
mock_console.print.assert_called_once()
# Should print "No data" message
call_args = mock_console.print.call_args[0][0]
assert "No data" in call_args
def test_auto_generates_columns(self):
"""Test that columns are auto-generated from data keys."""
data = [{"foo": "bar", "baz": "qux"}]
with patch("cua_cli.utils.output.console") as mock_console:
print_table(data)
mock_console.print.assert_called_once()
def test_handles_missing_keys(self):
"""Test handling data items with missing keys."""
data = [
{"name": "test1", "status": "running"},
{"name": "test2"}, # Missing status
]
columns = [("name", "NAME"), ("status", "STATUS")]
with patch("cua_cli.utils.output.console") as mock_console:
print_table(data, columns)
mock_console.print.assert_called_once()
def test_with_title(self):
"""Test table with title."""
data = [{"name": "test"}]
with patch("cua_cli.utils.output.console") as mock_console:
print_table(data, title="Test Table")
mock_console.print.assert_called_once()
class TestPrintJson:
"""Tests for print_json function."""
def test_prints_dict(self):
"""Test printing a dictionary as JSON."""
data = {"key": "value", "number": 42}
with patch("cua_cli.utils.output.console") as mock_console:
print_json(data)
mock_console.print_json.assert_called_once()
def test_prints_list(self):
"""Test printing a list as JSON."""
data = [{"name": "test1"}, {"name": "test2"}]
with patch("cua_cli.utils.output.console") as mock_console:
print_json(data)
mock_console.print_json.assert_called_once()
def test_handles_nested_data(self):
"""Test printing nested data structures."""
data = {
"level1": {
"level2": {
"value": 123,
}
}
}
with patch("cua_cli.utils.output.console") as mock_console:
print_json(data)
mock_console.print_json.assert_called_once()
class TestPrintError:
"""Tests for print_error function."""
def test_prints_to_stderr(self):
"""Test that error is printed to stderr."""
with patch("cua_cli.utils.output.error_console") as mock_console:
print_error("Test error message")
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "Error:" in call_args
assert "Test error message" in call_args
class TestPrintSuccess:
"""Tests for print_success function."""
def test_prints_success_message(self):
"""Test printing success message."""
with patch("cua_cli.utils.output.console") as mock_console:
print_success("Operation successful")
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "Operation successful" in call_args
class TestPrintWarning:
"""Tests for print_warning function."""
def test_prints_warning_message(self):
"""Test printing warning message."""
with patch("cua_cli.utils.output.console") as mock_console:
print_warning("This is a warning")
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "Warning:" in call_args
assert "This is a warning" in call_args
class TestPrintInfo:
"""Tests for print_info function."""
def test_prints_info_message(self):
"""Test printing info message."""
with patch("cua_cli.utils.output.console") as mock_console:
print_info("Information message")
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "Information message" in call_args
+4872
View File
File diff suppressed because it is too large Load Diff