chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""CUA CLI - Unified command-line interface for Computer-Use Agents."""
|
||||
|
||||
__version__ = "0.1.12"
|
||||
@@ -0,0 +1 @@
|
||||
"""API client module for CUA CLI."""
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user