Files
wehub-resource-sync 91e75e620b
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
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

978 lines
31 KiB
Python

"""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())