Merge branch 'main' into improve-docs-home

This commit is contained in:
f-trycua
2025-11-12 12:56:03 +01:00
20 changed files with 1019 additions and 35 deletions
+10 -10
View File
@@ -4,8 +4,6 @@ name: Test CUA Supporting Models
# Run manually using workflow_dispatch with test_models=true
on:
pull_request_target:
branches: [main, master]
workflow_dispatch:
inputs:
test_models:
@@ -20,7 +18,7 @@ on:
jobs:
# Test all CUA models - runs on PRs, schedules, or when manually triggered
test-all-models:
if: ${{ github.event_name == 'pull_request_target' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false') }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false') }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -42,13 +40,13 @@ jobs:
- gemini-2.5-computer-use-preview-10-2025
# InternVL
- huggingface-local/OpenGVLab/InternVL3_5-1B
# - huggingface-local/OpenGVLab/InternVL3_5-1B
# - huggingface-local/OpenGVLab/InternVL3_5-2B
# - huggingface-local/OpenGVLab/InternVL3_5-4B
# - huggingface-local/OpenGVLab/InternVL3_5-8B
# UI-TARS (supports full computer-use, can run standalone)
- huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B
# - huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B
# Note: OpenCUA, GTA, and Holo are grounding-only models
# They only support predict_click(), not agent.run()
@@ -56,7 +54,7 @@ jobs:
# Moondream (typically used in composed agents)
# Format: moondream3+{any-llm-with-tools}
- moondream3+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools
# - moondream3+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools
# - moondream3+openai/gpt-4o # GPT-4o has VLM + Tools
# OmniParser (typically used in composed agents)
@@ -68,9 +66,9 @@ jobs:
# Format: {grounding-model}+{any-vlm-with-tools}
# These grounding-only models (OpenCUA, GTA, Holo) must be used in composed form
# since they only support predict_click(), not full agent.run()
- huggingface-local/HelloKKMe/GTA1-7B+anthropic/claude-sonnet-4-5-20250929
- huggingface-local/xlangai/OpenCUA-7B+anthropic/claude-sonnet-4-5-20250929
- huggingface-local/Hcompany/Holo1.5-3B+anthropic/claude-sonnet-4-5-20250929
# - huggingface-local/HelloKKMe/GTA1-7B+anthropic/claude-sonnet-4-5-20250929
# - huggingface-local/xlangai/OpenCUA-7B+anthropic/claude-sonnet-4-5-20250929
# - huggingface-local/Hcompany/Holo1.5-3B+anthropic/claude-sonnet-4-5-20250929
steps:
- name: Checkout repository
@@ -219,6 +217,7 @@ jobs:
path: |
tests/agent_loop_testing/test_images/
*.log
if-no-files-found: ignore
retention-days: 7
- name: Upload test summary data
@@ -228,6 +227,7 @@ jobs:
# Unique, slash-free artifact name per matrix entry
name: test-summary-${{ env.SAFE_MODEL_NAME }}
path: test_summary/
if-no-files-found: ignore
retention-days: 1
- name: Set default Slack color
@@ -248,7 +248,7 @@ jobs:
# Summary job that aggregates all model test results
test-summary:
if: ${{ always() && (github.event_name == 'pull_request_target' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false')) }}
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false')) }}
needs: test-all-models
runs-on: ubuntu-latest
steps:
@@ -75,14 +75,23 @@ class Watchdog:
Returns:
WebSocket URI for the Computer API Server
"""
ip_address = (
"localhost"
if not self.container_name
else f"{self.container_name}.containers.cloud.trycua.com"
)
protocol = "wss" if self.container_name else "ws"
port = "8443" if self.container_name else "8000"
return f"{protocol}://{ip_address}:{port}/ws"
if not self.container_name:
return "ws://localhost:8000/ws"
# Try .sandbox.cua.ai first, fallback to .containers.cloud.trycua.com
return f"wss://{self.container_name}.sandbox.cua.ai:8443/ws"
@property
def ws_uri_fallback(self) -> str:
"""Get the fallback WebSocket URI using legacy hostname.
Returns:
Fallback WebSocket URI for the Computer API Server
"""
if not self.container_name:
return "ws://localhost:8000/ws"
return f"wss://{self.container_name}.containers.cloud.trycua.com:8443/ws"
async def ping(self) -> bool:
"""
@@ -91,11 +100,11 @@ class Watchdog:
Returns:
True if connection successful, False otherwise
"""
try:
# Create a simple ping message
ping_message = {"command": "get_screen_size", "params": {}}
# Create a simple ping message
ping_message = {"command": "get_screen_size", "params": {}}
# Try to connect to the WebSocket
# Try primary URI first (.sandbox.cua.ai)
try:
async with websockets.connect(
self.ws_uri, max_size=1024 * 1024 * 10 # 10MB limit to match server
) as websocket:
@@ -105,13 +114,40 @@ class Watchdog:
# Wait for any response or just close
try:
response = await asyncio.wait_for(websocket.recv(), timeout=5)
logger.debug(f"Ping response received: {response[:100]}...")
logger.debug(f"Ping response received from primary URI: {response[:100]}...")
return True
except asyncio.TimeoutError:
return False
except Exception as e:
logger.warning(f"Ping failed: {e}")
return False
logger.debug(f"Primary URI ping failed: {e}")
# Try fallback URI (.containers.cloud.trycua.com)
if self.container_name:
try:
async with websockets.connect(
self.ws_uri_fallback,
max_size=1024 * 1024 * 10, # 10MB limit to match server
) as websocket:
# Send ping message
await websocket.send(json.dumps(ping_message))
# Wait for any response or just close
try:
response = await asyncio.wait_for(websocket.recv(), timeout=5)
logger.debug(
f"Ping response received from fallback URI: {response[:100]}..."
)
return True
except asyncio.TimeoutError:
return False
except Exception as fallback_e:
logger.warning(
f"Both primary and fallback ping failed. Primary: {e}, Fallback: {fallback_e}"
)
return False
else:
logger.warning(f"Ping failed: {e}")
return False
def kill_processes_on_port(self, port: int) -> bool:
"""
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.4.11
current_version = 0.4.12
commit = True
tag = True
tag_name = computer-v{new_version}
@@ -46,6 +46,8 @@ class CloudProvider(BaseVMProvider):
self.api_key = api_key
self.verbose = verbose
self.api_base = (api_base or DEFAULT_API_BASE).rstrip("/")
# Host caching dictionary: {vm_name: host_string}
self._host_cache: Dict[str, str] = {}
@property
def provider_type(self) -> VMProviderType:
@@ -60,12 +62,12 @@ class CloudProvider(BaseVMProvider):
async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Get VM information by querying the VM status endpoint.
- Build hostname via get_ip(name) → "{name}.containers.cloud.trycua.com"
- Build hostname via _get_host_for_vm(name) using cached host or fallback
- Probe https://{hostname}:8443/status with a short timeout
- If JSON contains a "status" field, return it; otherwise infer
- Fallback to DNS resolve check to distinguish unknown vs not_found
"""
hostname = await self.get_ip(name=name)
hostname = await self._get_host_for_vm(name)
# Try HTTPS probe to the computer-server status endpoint (8443)
try:
@@ -118,8 +120,20 @@ class CloudProvider(BaseVMProvider):
vm = dict(item) if isinstance(item, dict) else {}
name = vm.get("name")
password = vm.get("password")
api_host = vm.get("host") # Read host from API response
if isinstance(name, str) and name:
host = f"{name}.containers.cloud.trycua.com"
# Use host from API if available, otherwise fallback to legacy format
if isinstance(api_host, str) and api_host:
host = api_host
# Cache the host for this VM
self._host_cache[name] = host
else:
# Legacy fallback
host = f"{name}.containers.cloud.trycua.com"
# Cache the legacy host
self._host_cache[name] = host
# api_url: always set if missing
if not vm.get("api_url"):
vm["api_url"] = f"https://{host}:8443"
@@ -227,15 +241,73 @@ class CloudProvider(BaseVMProvider):
"message": "update_vm not supported by public API",
}
async def _get_host_for_vm(self, name: str) -> str:
"""
Get the host for a VM, trying multiple approaches:
1. Check cache first
2. Try to refresh cache by calling list_vms
3. Try .sandbox.cua.ai format
4. Fallback to legacy .containers.cloud.trycua.com format
Args:
name: VM name
Returns:
Host string for the VM
"""
# Check cache first
if name in self._host_cache:
return self._host_cache[name]
# Try to refresh cache by calling list_vms
try:
await self.list_vms()
# Check cache again after refresh
if name in self._host_cache:
return self._host_cache[name]
except Exception as e:
logger.warning(f"Failed to refresh VM list for host lookup: {e}")
# Try .sandbox.cua.ai format first
sandbox_host = f"{name}.sandbox.cua.ai"
if await self._test_host_connectivity(sandbox_host):
self._host_cache[name] = sandbox_host
return sandbox_host
# Fallback to legacy format
legacy_host = f"{name}.containers.cloud.trycua.com"
# Cache the legacy host
self._host_cache[name] = legacy_host
return legacy_host
async def _test_host_connectivity(self, hostname: str) -> bool:
"""
Test if a host is reachable by trying to connect to its status endpoint.
Args:
hostname: Host to test
Returns:
True if host is reachable, False otherwise
"""
try:
timeout = aiohttp.ClientTimeout(total=2) # Short timeout for connectivity test
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"https://{hostname}:8443/status"
async with session.get(url, allow_redirects=False) as resp:
# Any response (even error) means the host is reachable
return True
except Exception:
return False
async def get_ip(
self, name: Optional[str] = None, storage: Optional[str] = None, retry_delay: int = 2
) -> str:
"""
Return the VM's IP address as '{container_name}.containers.cloud.trycua.com'.
Uses the provided 'name' argument (the VM name requested by the caller),
falling back to self.name only if 'name' is None.
Retries up to 3 times with retry_delay seconds if hostname is not available.
Return the VM's host address, trying to use cached host from API or falling back to legacy format.
Uses the provided 'name' argument (the VM name requested by the caller).
"""
if name is None:
raise ValueError("VM name is required for CloudProvider.get_ip")
return f"{name}.containers.cloud.trycua.com"
return await self._get_host_for_vm(name)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "pdm.backend"
[project]
name = "cua-computer"
version = "0.4.11"
version = "0.4.12"
description = "Computer-Use Interface (CUI) framework powering Cua"
readme = "README.md"
authors = [
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+105
View File
@@ -0,0 +1,105 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
+76
View File
@@ -0,0 +1,76 @@
# CUA CLI (Bun)
## Install
```bash
bun install
bun link # register package globally
bun link cua-cli # install the global binary `cua`
```
If you want to run without linking:
```bash
bun run ./index.ts -- --help
```
## Commands
- **Auth**
- `cua auth login` opens browser to authorize; stores API key locally
- `cua auth login --api-key sk-...` stores provided key directly
- `cua auth pull` writes/updates `.env` with `CUA_API_KEY`
- `cua auth logout` clears stored API key
- **VMs**
- `cua vm list`
- `cua vm create --os OS --configuration SIZE --region REGION` creates a new VM
- OS: `linux`, `windows`, `macos`
- SIZE: `small`, `medium`, `large`
- REGION: `north-america`, `europe`, `asia-pacific`, `south-america`
- `cua vm delete NAME` deletes a VM
- `cua vm start NAME`
- `cua vm stop NAME`
- `cua vm restart NAME`
- `cua vm vnc NAME` opens NoVNC URL in your browser
- `cua vm chat NAME` opens Dashboard Playground for the VM
## Auth Flow (Dynamic Callback Port)
- CLI starts a small local HTTP server using `Bun.serve({ port: 0 })` which picks an available port.
- Browser is opened to `https://cua.ai/cli-auth?callback_url=http://127.0.0.1:<port>/callback`.
- After you click "Authorize CLI", the browser redirects to the local server with `?token=...`.
- The CLI saves the API key in `~/.config/cua/cli.sqlite`.
> Note: If the browser cannot be opened automatically, copy/paste the printed URL.
## Project Structure
- `index.ts` entry point (shebang + start CLI)
- `src/cli.ts` yargs bootstrapping
- `src/commands/auth.ts` auth/login/pull/logout commands
- `src/commands/vm.ts` vm list/start/stop/restart commands
- `src/auth.ts` browser flow + local callback server (dynamic port)
- `src/http.ts` HTTP helper
- `src/storage.ts` SQLite-backed key-value storage
- `src/config.ts` constants and paths
- `src/util.ts` table printing, .env writer
## Notes
- Stored API key lives at `~/.config/cua/cli.sqlite` under `kv(api_key)`.
- Public API base defaults to `https://api.cua.ai` (override via `CUA_API_BASE`).
- Website base defaults to `https://cua.ai` (override via `CUA_WEBSITE_URL`).
- Authorization header: `Authorization: Bearer <api_key>`.
### Environment overrides
You can point the CLI to alternate deployments:
```bash
export CUA_API_BASE=https://api.staging.cua.ai
export CUA_WEBSITE_URL=https://staging.cua.ai
cua auth login
cua vm chat my-vm # opens https://staging.cua.ai/dashboard/playground?...
```
+64
View File
@@ -0,0 +1,64 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "cua-cli",
"dependencies": {
"yargs": "^18.0.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.33",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/node": ["@types/node@24.9.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
}
}
+7
View File
@@ -0,0 +1,7 @@
#! /usr/bin/env bun
import { runCli } from './src/cli';
runCli().catch((err) => {
console.error(err);
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "cua-cli",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.33"
},
"peerDependencies": {
"typescript": "^5"
},
"bin": {
"cua": "./index.ts"
},
"dependencies": {
"yargs": "^18.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { AUTH_PAGE, CALLBACK_HOST } from './config';
import { setApiKey, getApiKey } from './storage';
import { openInBrowser } from './util';
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
underline: '\x1b[4m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
};
export async function loginViaBrowser(): Promise<string> {
let resolveToken!: (v: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
// dynamic port (0) -> OS chooses available port
const server = Bun.serve({
hostname: CALLBACK_HOST,
port: 0,
fetch(req) {
const u = new URL(req.url);
if (u.pathname !== '/callback') return new Response('Not found', { status: 404 });
const token = u.searchParams.get('token');
if (!token) return new Response('Missing token', { status: 400 });
resolveToken(token);
queueMicrotask(() => server.stop());
return new Response('CLI authorized. You can close this window.', {
status: 200,
headers: { 'content-type': 'text/plain' },
});
},
});
const callbackURL = `http://${CALLBACK_HOST}:${server.port}/callback`;
const url = `${AUTH_PAGE}?callback_url=${encodeURIComponent(callbackURL)}`;
console.log(`${c.cyan}${c.bold}Opening your default browser to authorize the CLI...${c.reset}`);
console.log(`${c.dim}If the browser does not open automatically, copy/paste this URL:${c.reset}`);
console.log(`${c.yellow}${c.underline}${url}${c.reset}`);
await openInBrowser(url);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<string>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error('Timed out waiting for authorization')),
2 * 60 * 1000
);
});
try {
const result = await Promise.race([tokenPromise, timeout]);
if (timeoutId) clearTimeout(timeoutId);
return result;
} finally {
try {
server.stop();
} catch {}
}
}
export async function ensureApiKeyInteractive(): Promise<string> {
const existing = getApiKey();
if (existing) return existing;
const token = await loginViaBrowser();
setApiKey(token);
return token;
}
+11
View File
@@ -0,0 +1,11 @@
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { registerAuthCommands } from './commands/auth';
import { registerVmCommands } from './commands/vm';
export async function runCli() {
let argv = yargs(hideBin(process.argv)).scriptName('cua');
argv = registerAuthCommands(argv);
argv = registerVmCommands(argv);
await argv.demandCommand(1).strict().help().parseAsync();
}
@@ -0,0 +1,46 @@
import { setApiKey, clearApiKey } from '../storage';
import { ensureApiKeyInteractive, loginViaBrowser } from '../auth';
import { writeEnvFile } from '../util';
import type { Argv } from 'yargs';
export function registerAuthCommands(y: Argv) {
return y.command('auth', 'Auth commands', (ya) =>
ya
.command(
'login',
'Open browser to authorize and store API key',
(y) => y.option('api-key', { type: 'string', describe: 'API key to store directly' }),
async (argv: Record<string, unknown>) => {
if (argv['api-key']) {
setApiKey(String(argv['api-key']));
console.log('API key saved');
return;
}
console.log('Opening browser for CLI auth...');
const token = await loginViaBrowser();
setApiKey(token);
console.log('API key saved');
}
)
.command(
'pull',
'Create or update .env with CUA_API_KEY (login if needed)',
() => {},
async (_argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const out = await writeEnvFile(process.cwd(), token);
console.log(`Wrote ${out}`);
}
)
.command(
'logout',
'Remove stored API key',
() => {},
async (_argv: Record<string, unknown>) => {
clearApiKey();
console.log('Logged out');
}
)
.demandCommand(1, 'Specify an auth subcommand')
);
}
+294
View File
@@ -0,0 +1,294 @@
import type { Argv } from 'yargs';
import { ensureApiKeyInteractive } from '../auth';
import { http } from '../http';
import { printVmList, openInBrowser } from '../util';
import { WEBSITE_URL } from '../config';
import type { VmItem } from '../util';
import { clearApiKey } from '../storage';
export function registerVmCommands(y: Argv) {
return y.command('vm', 'VM commands', (yv) =>
yv
.command(
'list',
'List VMs',
() => {},
async (_argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const res = await http('/v1/vms', { token });
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
if (!res.ok) {
console.error(`Request failed: ${res.status}`);
process.exit(1);
}
const data = (await res.json()) as VmItem[];
printVmList(data);
}
)
.command(
'create',
'Create a new VM',
(y) =>
y
.option('os', {
type: 'string',
choices: ['linux', 'windows', 'macos'],
demandOption: true,
describe: 'Operating system',
})
.option('configuration', {
type: 'string',
choices: ['small', 'medium', 'large'],
demandOption: true,
describe: 'VM size configuration',
})
.option('region', {
type: 'string',
choices: ['north-america', 'europe', 'asia-pacific', 'south-america'],
demandOption: true,
describe: 'VM region',
}),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const { os, configuration, region } = argv as {
os: string;
configuration: string;
region: string;
};
const res = await http('/v1/vms', {
token,
method: 'POST',
body: { os, configuration, region },
});
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
if (res.status === 400) {
console.error('Invalid request or unsupported configuration');
process.exit(1);
}
if (res.status === 500) {
console.error('Internal server error');
process.exit(1);
}
if (res.status === 200) {
// VM ready immediately
const data = (await res.json()) as {
status: string;
name: string;
password: string;
host: string;
};
console.log(`VM created and ready: ${data.name}`);
console.log(`Password: ${data.password}`);
console.log(`Host: ${data.host}`);
return;
}
if (res.status === 202) {
// VM provisioning started
const data = (await res.json()) as { status: string; name: string; job_id: string };
console.log(`VM provisioning started: ${data.name}`);
console.log(`Job ID: ${data.job_id}`);
console.log("Use 'cua vm list' to monitor provisioning progress");
return;
}
console.error(`Unexpected status: ${res.status}`);
process.exit(1);
}
)
.command(
'delete <name>',
'Delete a VM',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const res = await http(`/v1/vms/${encodeURIComponent(name)}`, {
token,
method: 'DELETE',
});
if (res.status === 202) {
const body = (await res.json().catch(() => ({}))) as { status?: string };
console.log(`VM deletion initiated: ${body.status ?? 'deleting'}`);
return;
}
if (res.status === 404) {
console.error('VM not found or not owned by you');
process.exit(1);
}
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
console.error(`Unexpected status: ${res.status}`);
process.exit(1);
}
)
.command(
'start <name>',
'Start a VM',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const res = await http(`/v1/vms/${encodeURIComponent(name)}/start`, {
token,
method: 'POST',
});
if (res.status === 204) {
console.log('Start accepted');
return;
}
if (res.status === 404) {
console.error('VM not found');
process.exit(1);
}
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
console.error(`Unexpected status: ${res.status}`);
process.exit(1);
}
)
.command(
'stop <name>',
'Stop a VM',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const res = await http(`/v1/vms/${encodeURIComponent(name)}/stop`, {
token,
method: 'POST',
});
if (res.status === 202) {
const body = (await res.json().catch(() => ({}))) as { status?: string };
console.log(body.status ?? 'stopping');
return;
}
if (res.status === 404) {
console.error('VM not found');
process.exit(1);
}
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
console.error(`Unexpected status: ${res.status}`);
process.exit(1);
}
)
.command(
'restart <name>',
'Restart a VM',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const res = await http(`/v1/vms/${encodeURIComponent(name)}/restart`, {
token,
method: 'POST',
});
if (res.status === 202) {
const body = (await res.json().catch(() => ({}))) as { status?: string };
console.log(body.status ?? 'restarting');
return;
}
if (res.status === 404) {
console.error('VM not found');
process.exit(1);
}
if (res.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
console.error(`Unexpected status: ${res.status}`);
process.exit(1);
}
)
.command(
'vnc <name>',
'Open NoVNC for a VM in your browser',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const listRes = await http('/v1/vms', { token });
if (listRes.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
if (!listRes.ok) {
console.error(`Request failed: ${listRes.status}`);
process.exit(1);
}
const vms = (await listRes.json()) as VmItem[];
const vm = vms.find((v) => v.name === name);
if (!vm) {
console.error('VM not found');
process.exit(1);
}
const host =
vm.host && vm.host.length ? vm.host : `${vm.name}.containers.cloud.trycua.com`;
const url = `https://${host}/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`;
console.log(`Opening NoVNC: ${url}`);
await openInBrowser(url);
}
)
.command(
'chat <name>',
'Open CUA dashboard playground for a VM',
(y) => y.positional('name', { type: 'string', describe: 'VM name' }),
async (argv: Record<string, unknown>) => {
const token = await ensureApiKeyInteractive();
const name = String((argv as any).name);
const listRes = await http('/v1/vms', { token });
if (listRes.status === 401) {
clearApiKey();
console.error("Unauthorized. Try 'cua auth login' again.");
process.exit(1);
}
if (!listRes.ok) {
console.error(`Request failed: ${listRes.status}`);
process.exit(1);
}
const vms = (await listRes.json()) as VmItem[];
const vm = vms.find((v) => v.name === name);
if (!vm) {
console.error('VM not found');
process.exit(1);
}
const host =
vm.host && vm.host.length ? vm.host : `${vm.name}.containers.cloud.trycua.com`;
const base = WEBSITE_URL.replace(/\/$/, '');
const url = `${base}/dashboard/playground?host=${encodeURIComponent(host)}&id=${encodeURIComponent(vm.name)}&name=${encodeURIComponent(vm.name)}&vnc_password=${encodeURIComponent(vm.password)}&fullscreen=true`;
console.log(`Opening Playground: ${url}`);
await openInBrowser(url);
}
)
.demandCommand(1, 'Specify a vm subcommand')
);
}
+17
View File
@@ -0,0 +1,17 @@
export const WEBSITE_URL = Bun.env.CUA_WEBSITE_URL?.replace(/\/$/, '') || 'https://cua.ai';
export const API_BASE = Bun.env.CUA_API_BASE?.replace(/\/$/, '') || 'https://api.cua.ai';
export const AUTH_PAGE = `${WEBSITE_URL}/cli-auth`;
export const CALLBACK_HOST = '127.0.0.1';
export function getConfigDir(): string {
const home = Bun.env.HOME || Bun.env.USERPROFILE || '.';
const dir = `${home}/.cua`;
try {
Bun.spawnSync(['mkdir', '-p', dir]);
} catch {}
return dir;
}
export function getDbPath(): string {
return `${getConfigDir()}/cli.sqlite`;
}
+15
View File
@@ -0,0 +1,15 @@
import { API_BASE } from './config';
export async function http(
path: string,
opts: { method?: string; token: string; body?: any }
): Promise<Response> {
const url = `${API_BASE}${path}`;
const headers: Record<string, string> = { Authorization: `Bearer ${opts.token}` };
if (opts.body) headers['content-type'] = 'application/json';
return fetch(url, {
method: opts.method || 'GET',
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
}
+40
View File
@@ -0,0 +1,40 @@
import { Database } from 'bun:sqlite';
import { getDbPath } from './config';
function getDb(): Database {
const db = new Database(getDbPath());
db.exec('PRAGMA journal_mode = WAL;');
db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);');
return db;
}
export function setApiKey(token: string) {
const db = getDb();
try {
const stmt = db.query(
"INSERT INTO kv (k, v) VALUES ('api_key', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v"
);
stmt.run(token);
} finally {
db.close();
}
}
export function getApiKey(): string | null {
const db = getDb();
try {
const row = db.query("SELECT v FROM kv WHERE k='api_key'").get() as { v: string } | undefined;
return row?.v ?? null;
} finally {
db.close();
}
}
export function clearApiKey() {
const db = getDb();
try {
db.query("DELETE FROM kv WHERE k='api_key'").run();
} finally {
db.close();
}
}
+49
View File
@@ -0,0 +1,49 @@
export async function writeEnvFile(cwd: string, key: string) {
const path = `${cwd}/.env`;
let content = '';
try {
content = await Bun.file(path).text();
} catch {}
const lines = content.split(/\r?\n/).filter(Boolean);
const idx = lines.findIndex((l) => l.startsWith('CUA_API_KEY='));
if (idx >= 0) lines[idx] = `CUA_API_KEY=${key}`;
else lines.push(`CUA_API_KEY=${key}`);
await Bun.write(path, lines.join('\n') + '\n');
return path;
}
export type VmStatus = 'pending' | 'running' | 'stopped' | 'terminated' | 'failed';
export type VmItem = { name: string; password: string; status: VmStatus; host?: string };
export function printVmList(items: VmItem[]) {
const rows: string[][] = [
['NAME', 'STATUS', 'PASSWORD', 'HOST'],
...items.map((v) => [v.name, String(v.status), v.password, v.host || '']),
];
const widths: number[] = [0, 0, 0, 0];
for (const r of rows)
for (let i = 0; i < 4; i++) widths[i] = Math.max(widths[i] ?? 0, (r[i] ?? '').length);
for (const r of rows) console.log(r.map((c, i) => (c ?? '').padEnd(widths[i] ?? 0)).join(' '));
if (items.length === 0) console.log('No VMs found');
}
export async function openInBrowser(url: string) {
const platform = process.platform;
let cmd: string;
let args: string[] = [];
if (platform === 'darwin') {
cmd = 'open';
args = [url];
} else if (platform === 'win32') {
cmd = 'cmd';
args = ['/c', 'start', '', url];
} else {
cmd = 'xdg-open';
args = [url];
}
try {
await Bun.spawn({ cmd: [cmd, ...args] }).exited;
} catch {
console.error(`Failed to open browser. Please visit: ${url}`);
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}