From be98606cb558e66c88f4b15aeaa94c0139e58d94 Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Mon, 20 Oct 2025 12:12:14 -0400 Subject: [PATCH 01/36] added cua CLI --- libs/typescript/cua-cli/.gitignore | 34 ++++++ libs/typescript/cua-cli/CLAUDE.md | 106 +++++++++++++++++++ libs/typescript/cua-cli/README.md | 56 ++++++++++ libs/typescript/cua-cli/bun.lock | 63 +++++++++++ libs/typescript/cua-cli/index.ts | 7 ++ libs/typescript/cua-cli/package.json | 19 ++++ libs/typescript/cua-cli/src/auth.ts | 54 ++++++++++ libs/typescript/cua-cli/src/cli.ts | 11 ++ libs/typescript/cua-cli/src/commands/auth.ts | 49 +++++++++ libs/typescript/cua-cli/src/commands/vm.ts | 96 +++++++++++++++++ libs/typescript/cua-cli/src/config.ts | 16 +++ libs/typescript/cua-cli/src/http.ts | 12 +++ libs/typescript/cua-cli/src/storage.ts | 26 +++++ libs/typescript/cua-cli/src/util.ts | 32 ++++++ libs/typescript/cua-cli/tsconfig.json | 29 +++++ 15 files changed, 610 insertions(+) create mode 100644 libs/typescript/cua-cli/.gitignore create mode 100644 libs/typescript/cua-cli/CLAUDE.md create mode 100644 libs/typescript/cua-cli/README.md create mode 100644 libs/typescript/cua-cli/bun.lock create mode 100755 libs/typescript/cua-cli/index.ts create mode 100644 libs/typescript/cua-cli/package.json create mode 100644 libs/typescript/cua-cli/src/auth.ts create mode 100644 libs/typescript/cua-cli/src/cli.ts create mode 100644 libs/typescript/cua-cli/src/commands/auth.ts create mode 100644 libs/typescript/cua-cli/src/commands/vm.ts create mode 100644 libs/typescript/cua-cli/src/config.ts create mode 100644 libs/typescript/cua-cli/src/http.ts create mode 100644 libs/typescript/cua-cli/src/storage.ts create mode 100644 libs/typescript/cua-cli/src/util.ts create mode 100644 libs/typescript/cua-cli/tsconfig.json diff --git a/libs/typescript/cua-cli/.gitignore b/libs/typescript/cua-cli/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/libs/typescript/cua-cli/.gitignore @@ -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 diff --git a/libs/typescript/cua-cli/CLAUDE.md b/libs/typescript/cua-cli/CLAUDE.md new file mode 100644 index 00000000..1ee68904 --- /dev/null +++ b/libs/typescript/cua-cli/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/libs/typescript/cua-cli/README.md b/libs/typescript/cua-cli/README.md new file mode 100644 index 00000000..54d58f5e --- /dev/null +++ b/libs/typescript/cua-cli/README.md @@ -0,0 +1,56 @@ +# 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 start NAME` + - `cua vm stop NAME` + - `cua vm restart NAME` + +## 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:/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: `https://api.cua.ai`. +- Authorization header: `Authorization: Bearer `. diff --git a/libs/typescript/cua-cli/bun.lock b/libs/typescript/cua-cli/bun.lock new file mode 100644 index 00000000..4de964c1 --- /dev/null +++ b/libs/typescript/cua-cli/bun.lock @@ -0,0 +1,63 @@ +{ + "lockfileVersion": 1, + "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=="], + } +} diff --git a/libs/typescript/cua-cli/index.ts b/libs/typescript/cua-cli/index.ts new file mode 100755 index 00000000..b48c5715 --- /dev/null +++ b/libs/typescript/cua-cli/index.ts @@ -0,0 +1,7 @@ +#! /usr/bin/env bun +import { runCli } from "./src/cli"; + +runCli().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/libs/typescript/cua-cli/package.json b/libs/typescript/cua-cli/package.json new file mode 100644 index 00000000..0651e521 --- /dev/null +++ b/libs/typescript/cua-cli/package.json @@ -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" + } +} diff --git a/libs/typescript/cua-cli/src/auth.ts b/libs/typescript/cua-cli/src/auth.ts new file mode 100644 index 00000000..b3659b89 --- /dev/null +++ b/libs/typescript/cua-cli/src/auth.ts @@ -0,0 +1,54 @@ +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", +}; + +// openInBrowser is imported from util + +export async function loginViaBrowser(): Promise { + let resolveToken!: (v: string) => void; + const tokenPromise = new Promise((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); + + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for authorization")), 2 * 60 * 1000)); + try { return await Promise.race([tokenPromise, timeout]); } + finally { try { server.stop(); } catch {} } +} + +export async function ensureApiKeyInteractive(): Promise { + const existing = getApiKey(); + if (existing) return existing; + const token = await loginViaBrowser(); + setApiKey(token); + return token; +} diff --git a/libs/typescript/cua-cli/src/cli.ts b/libs/typescript/cua-cli/src/cli.ts new file mode 100644 index 00000000..2cfa759d --- /dev/null +++ b/libs/typescript/cua-cli/src/cli.ts @@ -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(); +} diff --git a/libs/typescript/cua-cli/src/commands/auth.ts b/libs/typescript/cua-cli/src/commands/auth.ts new file mode 100644 index 00000000..e09beb03 --- /dev/null +++ b/libs/typescript/cua-cli/src/commands/auth.ts @@ -0,0 +1,49 @@ +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) => { + 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) => { + const token = await ensureApiKeyInteractive(); + const out = await writeEnvFile(process.cwd(), token); + console.log(`Wrote ${out}`); + } + ) + .command( + "logout", + "Remove stored API key", + () => {}, + async (_argv: Record) => { + clearApiKey(); + console.log("Logged out"); + } + ) + .demandCommand(1, "Specify an auth subcommand") + ); +} diff --git a/libs/typescript/cua-cli/src/commands/vm.ts b/libs/typescript/cua-cli/src/commands/vm.ts new file mode 100644 index 00000000..0fcb73be --- /dev/null +++ b/libs/typescript/cua-cli/src/commands/vm.ts @@ -0,0 +1,96 @@ +import type { Argv } from "yargs"; +import { ensureApiKeyInteractive } from "../auth"; +import { http } from "../http"; +import { printVmList, openInBrowser } from "../util"; +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) => { + 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( + "start ", + "Start a VM", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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 ", + "Stop a VM", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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 ", + "Restart a VM", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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 ", + "Open NoVNC for a VM in your browser", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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 url = `https://${vm.name}.containers.cloud.trycua.com/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`; + console.log(`Opening NoVNC: ${url}`); + await openInBrowser(url); + } + ) + .demandCommand(1, "Specify a vm subcommand") + ); +} diff --git a/libs/typescript/cua-cli/src/config.ts b/libs/typescript/cua-cli/src/config.ts new file mode 100644 index 00000000..3ef686c2 --- /dev/null +++ b/libs/typescript/cua-cli/src/config.ts @@ -0,0 +1,16 @@ +export const API_BASE = "https://api.cua.ai"; +export const AUTH_PAGE = "https://cua.ai/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/config`; + try { + Bun.spawnSync(["mkdir", "-p", dir]); + } catch {} + return dir; +} + +export function getDbPath(): string { + return `${getConfigDir()}/cli.sqlite`; +} diff --git a/libs/typescript/cua-cli/src/http.ts b/libs/typescript/cua-cli/src/http.ts new file mode 100644 index 00000000..95ff39ea --- /dev/null +++ b/libs/typescript/cua-cli/src/http.ts @@ -0,0 +1,12 @@ +import { API_BASE } from "./config"; + +export async function http(path: string, opts: { method?: string; token: string; body?: any }): Promise { + const url = `${API_BASE}${path}`; + const headers: Record = { 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, + }); +} diff --git a/libs/typescript/cua-cli/src/storage.ts b/libs/typescript/cua-cli/src/storage.ts new file mode 100644 index 00000000..d2c4fa56 --- /dev/null +++ b/libs/typescript/cua-cli/src/storage.ts @@ -0,0 +1,26 @@ +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(); + const stmt = db.query("INSERT INTO kv (k, v) VALUES ('api_key', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v"); + stmt.run(token); +} + +export function getApiKey(): string | null { + const db = getDb(); + const row = db.query("SELECT v FROM kv WHERE k='api_key'").get() as { v: string } | undefined; + return row?.v ?? null; +} + +export function clearApiKey() { + const db = getDb(); + db.query("DELETE FROM kv WHERE k='api_key'").run(); +} diff --git a/libs/typescript/cua-cli/src/util.ts b/libs/typescript/cua-cli/src/util.ts new file mode 100644 index 00000000..503378b8 --- /dev/null +++ b/libs/typescript/cua-cli/src/util.ts @@ -0,0 +1,32 @@ +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 }; + +export function printVmList(items: VmItem[]) { + const rows: string[][] = [["NAME", "STATUS", "PASSWORD"], ...items.map(v => [v.name, String(v.status), v.password])]; + const widths: number[] = [0, 0, 0]; + for (const r of rows) for (let i = 0; i < 3; 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}`); } +} diff --git a/libs/typescript/cua-cli/tsconfig.json b/libs/typescript/cua-cli/tsconfig.json new file mode 100644 index 00000000..bfa0fead --- /dev/null +++ b/libs/typescript/cua-cli/tsconfig.json @@ -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 + } +} From 789b7264b579db2aeb0bb2b0191514328338dafa Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Mon, 20 Oct 2025 14:21:58 -0700 Subject: [PATCH 02/36] Fix CLI hanging --- libs/typescript/cua-cli/src/auth.ts | 15 +++++++++++---- libs/typescript/cua-cli/src/storage.ts | 22 +++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/libs/typescript/cua-cli/src/auth.ts b/libs/typescript/cua-cli/src/auth.ts index b3659b89..e46f4e6b 100644 --- a/libs/typescript/cua-cli/src/auth.ts +++ b/libs/typescript/cua-cli/src/auth.ts @@ -12,7 +12,6 @@ const c = { yellow: "\x1b[33m", }; -// openInBrowser is imported from util export async function loginViaBrowser(): Promise { let resolveToken!: (v: string) => void; @@ -40,9 +39,17 @@ export async function loginViaBrowser(): Promise { console.log(`${c.yellow}${c.underline}${url}${c.reset}`); await openInBrowser(url); - const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for authorization")), 2 * 60 * 1000)); - try { return await Promise.race([tokenPromise, timeout]); } - finally { try { server.stop(); } catch {} } + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, 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 { diff --git a/libs/typescript/cua-cli/src/storage.ts b/libs/typescript/cua-cli/src/storage.ts index d2c4fa56..15f68308 100644 --- a/libs/typescript/cua-cli/src/storage.ts +++ b/libs/typescript/cua-cli/src/storage.ts @@ -10,17 +10,29 @@ function getDb(): Database { export function setApiKey(token: string) { const db = getDb(); - const stmt = db.query("INSERT INTO kv (k, v) VALUES ('api_key', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v"); - stmt.run(token); + 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(); - const row = db.query("SELECT v FROM kv WHERE k='api_key'").get() as { v: string } | undefined; - return row?.v ?? null; + 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(); - db.query("DELETE FROM kv WHERE k='api_key'").run(); + try { + db.query("DELETE FROM kv WHERE k='api_key'").run(); + } finally { + db.close(); + } } From be8bb62bb433694ae9ff26616c333aa0b1c444ac Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Mon, 20 Oct 2025 17:09:53 -0700 Subject: [PATCH 03/36] Added CUA_API_BASE / CUA_WEBSITE_URL to the cua cli config, and `vm chat ` command --- libs/typescript/cua-cli/README.md | 17 ++++++++++++++++- libs/typescript/cua-cli/src/commands/vm.ts | 21 +++++++++++++++++++++ libs/typescript/cua-cli/src/config.ts | 7 ++++--- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/libs/typescript/cua-cli/README.md b/libs/typescript/cua-cli/README.md index 54d58f5e..5e802d83 100644 --- a/libs/typescript/cua-cli/README.md +++ b/libs/typescript/cua-cli/README.md @@ -27,6 +27,8 @@ bun run ./index.ts -- --help - `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) @@ -52,5 +54,18 @@ bun run ./index.ts -- --help ## Notes - Stored API key lives at `~/.config/cua/cli.sqlite` under `kv(api_key)`. -- Public API base: `https://api.cua.ai`. +- 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 `. + +### 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?... +``` diff --git a/libs/typescript/cua-cli/src/commands/vm.ts b/libs/typescript/cua-cli/src/commands/vm.ts index 0fcb73be..3b7f2b20 100644 --- a/libs/typescript/cua-cli/src/commands/vm.ts +++ b/libs/typescript/cua-cli/src/commands/vm.ts @@ -2,6 +2,7 @@ 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"; @@ -91,6 +92,26 @@ export function registerVmCommands(y: Argv) { await openInBrowser(url); } ) + .command( + "chat ", + "Open CUA dashboard playground for a VM", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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.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") ); } diff --git a/libs/typescript/cua-cli/src/config.ts b/libs/typescript/cua-cli/src/config.ts index 3ef686c2..1d4b9212 100644 --- a/libs/typescript/cua-cli/src/config.ts +++ b/libs/typescript/cua-cli/src/config.ts @@ -1,10 +1,11 @@ -export const API_BASE = "https://api.cua.ai"; -export const AUTH_PAGE = "https://cua.ai/cli-auth"; +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/config`; + const dir = `${home}/.config/cua`; try { Bun.spawnSync(["mkdir", "-p", dir]); } catch {} From 10f7227b79a97ff165e8035cb86c0c3b5d0de678 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sat, 8 Nov 2025 21:58:12 -0500 Subject: [PATCH 04/36] optimize readme --- README.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b5fa51b6..85acff2c 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,18 @@ [![Swift](https://img.shields.io/badge/Swift-F05138?logo=swift&logoColor=white)](#) [![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=F0F0F0)](#) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.com/invite/mVnXXpdE85) +[![OSWorld](https://img.shields.io/badge/OSWorld-Benchmark-blue)](https://os-world.github.io/) +[![HUD](https://img.shields.io/badge/HUD-Integration-green)](https://hud.so)
trycua%2Fcua | Trendshift -**Cua** ("koo-ah") is Docker for [Computer-Use Agents](https://www.oneusefulthing.org/p/when-you-give-a-claude-a-mouse) - it enables AI agents to control full operating systems in virtual containers and deploy them locally or to the cloud. +**Cua** ("koo-ah") is an open-source framework for Computer-Use Agents - enabling AI systems to autonomously operate computers through visual understanding and action execution. Used for research, evaluation, and production deployment of desktop, browser, and mobile automation agents. + +## What are Computer-Use Agents? + +Computer-Use Agents (CUAs) are AI systems that can autonomously interact with computer interfaces through visual understanding and action execution. Unlike traditional automation tools that rely on brittle selectors or APIs, CUAs use vision-language models to perceive screen content and reason about interface interactions - enabling them to adapt to UI changes and handle complex, multi-step workflows across applications.
@@ -27,9 +33,9 @@ With the [Computer SDK](#computer-sdk), you can: With the [Agent SDK](#agent-sdk), you can: -- run computer-use models with a [consistent schema](https://cua.ai/docs/docs/agent-sdk/message-format) -- benchmark on OSWorld-Verified, SheetBench-V2, and more [with a single line of code using HUD](https://cua.ai/docs/docs/agent-sdk/integrations/hud) ([Notebook](https://github.com/trycua/cua/blob/main/notebooks/eval_osworld.ipynb)) -- combine UI grounding models with any LLM using [composed agents](https://cua.ai/docs/docs/agent-sdk/supported-agents/composed-agents) +- run computer-use models with a [consistent schema](https://cua.ai/docs/agent-sdk/message-format) +- benchmark on OSWorld-Verified (369 tasks), SheetBench-V2, and ScreenSpot [with a single line of code using HUD](https://cua.ai/docs/agent-sdk/integrations/hud) - see [benchmark results](#research--benchmarks) ([Notebook](https://github.com/trycua/cua/blob/main/notebooks/eval_osworld.ipynb)) +- combine UI grounding models with any LLM using [composed agents](https://cua.ai/docs/agent-sdk/supported-agents/composed-agents) - use new UI agent models and UI grounding models from the Model Zoo below with just a model string (e.g., `ComputerAgent(model="openai/computer-use-preview")`) - use API or local inference by changing a prefix (e.g., `openai/`, `openrouter/`, `ollama/`, `huggingface-local/`, `mlx/`, [etc.](https://docs.litellm.ai/docs/providers)) @@ -194,9 +200,9 @@ Cua uses the OpenAI Agent response format. These are the valid model configurations for `ComputerAgent(model="...")`: -| Configuration | Description | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `{computer-use-model}` | A single model to perform all computer-use tasks | +| Configuration | Description | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `{computer-use-model}` | A single model to perform all computer-use tasks | | `{grounding-model}+{any-vlm-with-tools}` | [Composed](https://cua.ai/docs/docs/agent-sdk/supported-agents/composed-agents) with VLM for captioning and grounding LLM for element detection | | `moondream3+{any-llm-with-tools}` | [Composed](https://cua.ai/docs/docs/agent-sdk/supported-agents/composed-agents) with Moondream3 for captioning and UI element detection | | `human/human` | A [human-in-the-loop](https://cua.ai/docs/docs/agent-sdk/supported-agents/human-in-the-loop) in place of a model | @@ -220,6 +226,34 @@ The following table shows which capabilities are supported by each model: | [Moondream](https://huggingface.co/moondream/moondream3-preview) | | 🎯 | | | | [OmniParser](https://github.com/microsoft/OmniParser) | | 🎯 | | | +**Legend:** + +- 🖥️ **Computer-Use**: Full agentic loop with planning and execution +- 🎯 **Grounding**: UI element detection and click coordinate prediction +- 🛠️ **Tools**: Support for function calling beyond screen interaction +- 👁️ **VLM**: Vision-language understanding + +**Composition Examples:** + +See more examples on our [composition docs](https://cua.ai/docs/agent-sdk/supported-agents/composed-agents). + +```python +# Use OpenAI's GPT-5 for planning with specialized grounding +agent = ComputerAgent(model="huggingface-local/HelloKKMe/GTA1-7B+openai/gpt-5") + +# Composition via OmniParser +agent = ComputerAgent(model="omniparser+openai/gpt-4o") + +# Combine state-of-the-art grounding with powerful reasoning +agent = ComputerAgent(model="huggingface-local/HelloKKMe/GTA1-7B+anthropic/claude-3-5-sonnet-20241022") + +# Combine two different vision models for enhanced capabilities +agent = ComputerAgent(model="huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B+openai/gpt-4o") + +# Use the built-in Moondream3 grounding with any planning mode. +agent = ComputerAgent(model="moondream3+openai/gpt-4o") +``` + ### Model IDs
@@ -333,6 +367,40 @@ pip install cua-som Learn more in the [SOM documentation](./libs/python/som/README.md). +# Recent Updates + +## 2025 + +### September 2025 +- **Hack the North Competition**: First benchmark-driven hackathon track with guaranteed YC interview prize. Winner achieved 68.3% on OSWorld-Tiny ([Blog Post](https://www.cua.ai/blog/hack-the-north)) +- **Global Hackathon Launch**: Ollama × Cua global online competition for creative local/hybrid agents + +### August 2025 +- **v0.4 Release - Composite Agents**: Mix grounding + planning models with `+` operator (e.g., `"GTA-7B+GPT-4o"`) ([Blog Post](https://www.cua.ai/blog/composite-agents)) +- **HUD Integration**: One-line benchmarking on OSWorld-Verified with live trace visualization ([Blog Post](https://www.cua.ai/blog/hud-agent-evals)) +- **Human-in-the-Loop**: Interactive agent mode with `human/human` model string +- **Web-Based Computer Use**: Browser-based agent execution ([Blog Post](https://www.cua.ai/blog/bringing-computer-use-to-the-web)) + +### June 2025 +- **Windows Sandbox Support**: Native Windows agent execution ([Blog Post](https://www.cua.ai/blog/windows-sandbox)) +- **Containerization Evolution**: From Lume to full Docker support ([Blog Post](https://www.cua.ai/blog/lume-to-containerization)) +- **Sandboxed Python Execution**: Secure code execution in agent workflows + +### May 2025 +- **Cua Cloud Containers**: Production-ready cloud deployment with elastic scaling ([Blog Post](https://www.cua.ai/blog/introducing-cua-cloud-containers)) +- **Trajectory Viewer**: Visual debugging tool for agent actions ([Blog Post](https://www.cua.ai/blog/trajectory-viewer)) +- **Training Data Collection**: Tools for creating computer-use training datasets ([Blog Post](https://www.cua.ai/blog/training-computer-use-models-trajectories-1)) +- **App-Use Framework**: Mobile and desktop app automation capabilities + +### April 2025 +- **Agent Framework v0.4**: Unified API for 100+ model configurations +- **UI-TARS Integration**: Local inference support for ByteDance's desktop-optimized model +- **Blog Series**: "Build Your Own Operator" tutorials ([Part 1](https://www.cua.ai/blog/build-your-own-operator-on-macos-1) | [Part 2](https://www.cua.ai/blog/build-your-own-operator-on-macos-2)) + +### March 2025 +- **Initial Public Release**: Core Agent SDK and Computer SDK +- **Lume VM Manager**: macOS VM management tool for local development + # Resources - [Cua Blog](https://www.cua.ai/blog) From d708564c9ffdc0986ce7d07073aa2b6281f31b57 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:10:05 -0500 Subject: [PATCH 05/36] fix broken links --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 85acff2c..5185ac2d 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Computer-Use Agents (CUAs) are AI systems that can autonomously interact with co With the [Computer SDK](#computer-sdk), you can: -- automate Windows, Linux, and macOS VMs with a consistent, [pyautogui-like API](https://cua.ai/docs/docs/libraries/computer#interface-actions) -- create & manage VMs [locally](https://cua.ai/docs/docs/computer-sdk/computers#cua-local-containers) or using [Cua cloud](https://www.cua.ai/) +- automate Windows, Linux, and macOS VMs with a consistent, [pyautogui-like API](https://cua.ai/docs/computer-sdk/commands) +- create & manage VMs [locally](https://cua.ai/docs/quickstart-devs#using-computer) or using [Cua cloud](https://www.cua.ai/) With the [Agent SDK](#agent-sdk), you can: @@ -102,8 +102,8 @@ Core utilities for Cua # Quick Start - [Clone a starter template and run the code in <1 min](https://github.com/trycua/agent-template) -- [Get started with the Cua SDKs](https://cua.ai/docs/docs/quickstart-devs) -- [Get started with the Cua CLI](https://cua.ai/docs/docs/quickstart-cli) +- [Get started with the Cua SDKs](https://cua.ai/docs/quickstart-devs) +- [Get started with the Cua CLI](https://cua.ai/docs/quickstart-cli) # Agent SDK @@ -203,9 +203,9 @@ These are the valid model configurations for `ComputerAgent(model="...")`: | Configuration | Description | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `{computer-use-model}` | A single model to perform all computer-use tasks | -| `{grounding-model}+{any-vlm-with-tools}` | [Composed](https://cua.ai/docs/docs/agent-sdk/supported-agents/composed-agents) with VLM for captioning and grounding LLM for element detection | -| `moondream3+{any-llm-with-tools}` | [Composed](https://cua.ai/docs/docs/agent-sdk/supported-agents/composed-agents) with Moondream3 for captioning and UI element detection | -| `human/human` | A [human-in-the-loop](https://cua.ai/docs/docs/agent-sdk/supported-agents/human-in-the-loop) in place of a model | +| `{grounding-model}+{any-vlm-with-tools}` | [Composed](https://cua.ai/docs/agent-sdk/supported-agents/composed-agents) with VLM for captioning and grounding LLM for element detection | +| `moondream3+{any-llm-with-tools}` | [Composed](https://cua.ai/docs/agent-sdk/supported-agents/composed-agents) with Moondream3 for captioning and UI element detection | +| `human/human` | A [human-in-the-loop](https://cua.ai/docs/agent-sdk/supported-agents/human-in-the-loop) in place of a model | ### Model Capabilities From 7efb5382fac4e4d4d0672c323626200673ccca5d Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:26:14 -0500 Subject: [PATCH 06/36] link checking on all md --- .github/workflows/link-check.yml | 113 +++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/link-check.yml diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 00000000..b31d1e4e --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,113 @@ +name: Link Checker + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + link-check: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore lychee cache + uses: actions/cache@v4 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - name: Run Lychee link checker + uses: lycheeverse/lychee-action@v2 + id: lychee + with: + # Check all markdown files + args: | + --cache + --max-cache-age 1d + --verbose + --no-progress + --exclude-mail + '**/*.md' + # Output results to file for parsing + output: lychee-output.md + # Don't fail the build on broken links (warning mode) + fail: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Parse link check results + id: parse-results + if: always() + run: | + # Initialize defaults + BROKEN="0" + TOTAL="0" + + # Parse lychee output for statistics + if [ -f "lychee-output.md" ]; then + # Extract statistics from lychee output + TOTAL=$(grep -oP 'Total\s+\|\s+\K\d+' lychee-output.md || echo "0") + BROKEN=$(grep -oP 'Errors\s+\|\s+\K\d+' lychee-output.md || echo "0") + + # If stats not found in that format, try alternate parsing + if [ "$TOTAL" = "0" ] && [ "$BROKEN" = "0" ]; then + BROKEN=$(grep -c "❌\|✗\|ERROR" lychee-output.md || echo "0") + fi + fi + + # Set status based on results + if [ "$BROKEN" = "0" ]; then + STATUS_ICON="✅" + STATUS_TEXT="All links are working!" + COLOR="#36a64f" + else + STATUS_ICON="⚠️" + STATUS_TEXT="Found $BROKEN broken link(s)" + COLOR="#ffa500" + fi + + # Extract broken links for summary (limit to first 15 lines) + BROKEN_LINKS="" + if [ -f "lychee-output.md" ] && [ "$BROKEN" != "0" ]; then + # Get the errors section from lychee output + BROKEN_LINKS=$(awk '/## Errors/,/^$/' lychee-output.md | grep -E "^\*|http|^ " | head -15 || echo "") + + # If no errors section found, try to extract failed URLs + if [ -z "$BROKEN_LINKS" ]; then + BROKEN_LINKS=$(grep -B 1 "❌\|✗\|ERROR" lychee-output.md | head -15 || echo "") + fi + fi + + # Export for Slack notification + echo "STATUS_ICON=$STATUS_ICON" >> $GITHUB_ENV + echo "STATUS_TEXT=$STATUS_TEXT" >> $GITHUB_ENV + echo "COLOR=$COLOR" >> $GITHUB_ENV + echo "BROKEN_COUNT=$BROKEN" >> $GITHUB_ENV + echo "TOTAL_COUNT=$TOTAL" >> $GITHUB_ENV + + # Save broken links to multiline env var + { + echo 'BROKEN_LINKS<> $GITHUB_ENV + + - name: Send results to Slack + if: always() + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: ${{ vars.SLACK_CHANNEL }} + SLACK_TITLE: "🔗 Link Check Results" + SLACK_COLOR: ${{ env.COLOR }} + SLACK_MESSAGE: | + *Status:* ${{ env.STATUS_ICON }} ${{ env.STATUS_TEXT }} + *Total Links Checked:* ${{ env.TOTAL_COUNT }} + *Broken Links Found:* ${{ env.BROKEN_COUNT }} + + ${{ env.BROKEN_COUNT != '0' && format('*Sample Broken Links:*\n```\n{0}\n```\n\n_See full details in the workflow run_', env.BROKEN_LINKS) || '' }} + + *Run Details:* ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} From b275a4b3226cef29adfd56627e1fe1dc9ed3e741 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:35:59 -0500 Subject: [PATCH 07/36] fix link check again --- .github/workflows/link-check.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index b31d1e4e..5bef6099 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -1,8 +1,11 @@ name: Link Checker on: - pull_request: - types: [opened, synchronize, reopened] + pull_request_target: + branches: [main, master] + push: + branches: + - main workflow_dispatch: jobs: @@ -65,7 +68,7 @@ jobs: COLOR="#36a64f" else STATUS_ICON="⚠️" - STATUS_TEXT="Found $BROKEN broken link(s)" + STATUS_TEXT="Found ${BROKEN} broken links" COLOR="#ffa500" fi @@ -82,16 +85,22 @@ jobs: fi # Export for Slack notification - echo "STATUS_ICON=$STATUS_ICON" >> $GITHUB_ENV - echo "STATUS_TEXT=$STATUS_TEXT" >> $GITHUB_ENV - echo "COLOR=$COLOR" >> $GITHUB_ENV - echo "BROKEN_COUNT=$BROKEN" >> $GITHUB_ENV - echo "TOTAL_COUNT=$TOTAL" >> $GITHUB_ENV + echo "STATUS_ICON=${STATUS_ICON}" >> $GITHUB_ENV + echo "COLOR=${COLOR}" >> $GITHUB_ENV + echo "BROKEN_COUNT=${BROKEN}" >> $GITHUB_ENV + echo "TOTAL_COUNT=${TOTAL}" >> $GITHUB_ENV + + # Use heredoc for STATUS_TEXT to handle special characters safely + { + echo "STATUS_TEXT<> $GITHUB_ENV # Save broken links to multiline env var { echo 'BROKEN_LINKS<> $GITHUB_ENV From 69e3751fe3d247718fbf17bd9d326bbf28594074 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:40:56 -0500 Subject: [PATCH 08/36] test quickly --- .github/workflows/link-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 5bef6099..16d1c192 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -5,7 +5,7 @@ on: branches: [main, master] push: branches: - - main + # (empty; removed branch filter to run on all PRs) workflow_dispatch: jobs: From ebad22bcd75a04e6bac7eab3ac51ceccf83052b6 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:44:58 -0500 Subject: [PATCH 09/36] link check change --- .github/workflows/link-check.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 16d1c192..45a2f6b8 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -1,11 +1,11 @@ name: Link Checker on: - pull_request_target: + pull_request: branches: [main, master] push: branches: - # (empty; removed branch filter to run on all PRs) + - main workflow_dispatch: jobs: @@ -105,7 +105,7 @@ jobs: } >> $GITHUB_ENV - name: Send results to Slack - if: always() + if: always() && secrets.SLACK_WEBHOOK != '' uses: rtCamp/action-slack-notify@v2 env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} From 9eda80b41932e4f25b23e8f44a0b059a96646ad4 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:45:54 -0500 Subject: [PATCH 10/36] link check change again --- .github/workflows/link-check.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 45a2f6b8..30135e02 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -2,10 +2,6 @@ name: Link Checker on: pull_request: - branches: [main, master] - push: - branches: - - main workflow_dispatch: jobs: From e6e41f31e6bc00f1f440d30744639540b5623974 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:47:19 -0500 Subject: [PATCH 11/36] on push as well --- .github/workflows/link-check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 30135e02..fae0cc1e 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -2,6 +2,7 @@ name: Link Checker on: pull_request: + push: workflow_dispatch: jobs: From 31ea3e7421429504cd5e1d8b133fda914e0f60ed Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:51:50 -0500 Subject: [PATCH 12/36] fix yaml syntax --- .github/workflows/link-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index fae0cc1e..18875cdf 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -102,7 +102,7 @@ jobs: } >> $GITHUB_ENV - name: Send results to Slack - if: always() && secrets.SLACK_WEBHOOK != '' + if: ${{ always() && secrets.SLACK_WEBHOOK != '' }} uses: rtCamp/action-slack-notify@v2 env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} From c4f9db24ac275542204ae5c5746d0893b8619712 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:54:58 -0500 Subject: [PATCH 13/36] secrets fix --- .github/workflows/link-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 18875cdf..fbd4b464 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -102,7 +102,7 @@ jobs: } >> $GITHUB_ENV - name: Send results to Slack - if: ${{ always() && secrets.SLACK_WEBHOOK != '' }} + if: always() uses: rtCamp/action-slack-notify@v2 env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} From 4fc707e1d0e0ae1d055b733c62f1008f5f480d12 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:57:19 -0500 Subject: [PATCH 14/36] simplify link checking syntax --- .github/workflows/link-check.yml | 72 +++++++------------------------- 1 file changed, 14 insertions(+), 58 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index fbd4b464..0170b4d6 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -42,65 +42,22 @@ jobs: id: parse-results if: always() run: | - # Initialize defaults - BROKEN="0" - TOTAL="0" + # Use lychee exit code: 0 = success, >0 = errors found + EXIT_CODE="${{ steps.lychee.outputs.exit_code }}" - # Parse lychee output for statistics - if [ -f "lychee-output.md" ]; then - # Extract statistics from lychee output - TOTAL=$(grep -oP 'Total\s+\|\s+\K\d+' lychee-output.md || echo "0") - BROKEN=$(grep -oP 'Errors\s+\|\s+\K\d+' lychee-output.md || echo "0") - - # If stats not found in that format, try alternate parsing - if [ "$TOTAL" = "0" ] && [ "$BROKEN" = "0" ]; then - BROKEN=$(grep -c "❌\|✗\|ERROR" lychee-output.md || echo "0") - fi - fi - - # Set status based on results - if [ "$BROKEN" = "0" ]; then - STATUS_ICON="✅" - STATUS_TEXT="All links are working!" - COLOR="#36a64f" + # Set status based on exit code + if [ "$EXIT_CODE" = "0" ]; then + echo "STATUS_ICON=✅" >> $GITHUB_ENV + echo "STATUS_TEXT=All links are working" >> $GITHUB_ENV + echo "COLOR=#36a64f" >> $GITHUB_ENV + echo "BROKEN_COUNT=0" >> $GITHUB_ENV else - STATUS_ICON="⚠️" - STATUS_TEXT="Found ${BROKEN} broken links" - COLOR="#ffa500" + echo "STATUS_ICON=⚠️" >> $GITHUB_ENV + echo "STATUS_TEXT=Some links may be broken" >> $GITHUB_ENV + echo "COLOR=#ffa500" >> $GITHUB_ENV + echo "BROKEN_COUNT=Check workflow logs" >> $GITHUB_ENV fi - # Extract broken links for summary (limit to first 15 lines) - BROKEN_LINKS="" - if [ -f "lychee-output.md" ] && [ "$BROKEN" != "0" ]; then - # Get the errors section from lychee output - BROKEN_LINKS=$(awk '/## Errors/,/^$/' lychee-output.md | grep -E "^\*|http|^ " | head -15 || echo "") - - # If no errors section found, try to extract failed URLs - if [ -z "$BROKEN_LINKS" ]; then - BROKEN_LINKS=$(grep -B 1 "❌\|✗\|ERROR" lychee-output.md | head -15 || echo "") - fi - fi - - # Export for Slack notification - echo "STATUS_ICON=${STATUS_ICON}" >> $GITHUB_ENV - echo "COLOR=${COLOR}" >> $GITHUB_ENV - echo "BROKEN_COUNT=${BROKEN}" >> $GITHUB_ENV - echo "TOTAL_COUNT=${TOTAL}" >> $GITHUB_ENV - - # Use heredoc for STATUS_TEXT to handle special characters safely - { - echo "STATUS_TEXT<> $GITHUB_ENV - - # Save broken links to multiline env var - { - echo 'BROKEN_LINKS<> $GITHUB_ENV - - name: Send results to Slack if: always() uses: rtCamp/action-slack-notify@v2 @@ -111,9 +68,8 @@ jobs: SLACK_COLOR: ${{ env.COLOR }} SLACK_MESSAGE: | *Status:* ${{ env.STATUS_ICON }} ${{ env.STATUS_TEXT }} - *Total Links Checked:* ${{ env.TOTAL_COUNT }} - *Broken Links Found:* ${{ env.BROKEN_COUNT }} - ${{ env.BROKEN_COUNT != '0' && format('*Sample Broken Links:*\n```\n{0}\n```\n\n_See full details in the workflow run_', env.BROKEN_LINKS) || '' }} + *Branch:* ${{ github.ref_name }} + *Commit:* ${{ github.event.head_commit.message || 'See PR description' }} *Run Details:* ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} From fcc667f46e58bd5194f1279c30f32c0c8b8e325b Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 17:59:28 -0500 Subject: [PATCH 15/36] lychee fix --- .github/workflows/link-check.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 0170b4d6..a083d943 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -24,13 +24,7 @@ jobs: id: lychee with: # Check all markdown files - args: | - --cache - --max-cache-age 1d - --verbose - --no-progress - --exclude-mail - '**/*.md' + args: --verbose --no-progress --cache --max-cache-age 1d '**/*.md' # Output results to file for parsing output: lychee-output.md # Don't fail the build on broken links (warning mode) @@ -45,17 +39,27 @@ jobs: # Use lychee exit code: 0 = success, >0 = errors found EXIT_CODE="${{ steps.lychee.outputs.exit_code }}" + echo "Exit code: $EXIT_CODE" + + # Show summary if output file exists + if [ -f "lychee-output.md" ]; then + echo "=== Link Check Summary ===" + cat lychee-output.md + fi + # Set status based on exit code if [ "$EXIT_CODE" = "0" ]; then echo "STATUS_ICON=✅" >> $GITHUB_ENV echo "STATUS_TEXT=All links are working" >> $GITHUB_ENV echo "COLOR=#36a64f" >> $GITHUB_ENV - echo "BROKEN_COUNT=0" >> $GITHUB_ENV + elif [ "$EXIT_CODE" = "2" ]; then + echo "STATUS_ICON=❌" >> $GITHUB_ENV + echo "STATUS_TEXT=Link checker failed to run" >> $GITHUB_ENV + echo "COLOR=#dc3545" >> $GITHUB_ENV else echo "STATUS_ICON=⚠️" >> $GITHUB_ENV - echo "STATUS_TEXT=Some links may be broken" >> $GITHUB_ENV + echo "STATUS_TEXT=Found broken links" >> $GITHUB_ENV echo "COLOR=#ffa500" >> $GITHUB_ENV - echo "BROKEN_COUNT=Check workflow logs" >> $GITHUB_ENV fi - name: Send results to Slack From 7eeab8c613e17ed9890a44ebd697328937a2df4e Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 18:08:38 -0500 Subject: [PATCH 16/36] skip some urls --- .github/workflows/link-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index a083d943..9603dc01 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -24,7 +24,7 @@ jobs: id: lychee with: # Check all markdown files - args: --verbose --no-progress --cache --max-cache-age 1d '**/*.md' + args: --verbose --no-progress --cache --max-cache-age 1d --accept 200..=299,403 --exclude '^file://' --exclude 'localhost' --exclude '127\.0\.0\.1' '**/*.md' # Output results to file for parsing output: lychee-output.md # Don't fail the build on broken links (warning mode) From 4ba4d79e6d6f5641453f20cccc825afcc8676f0a Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 18:26:00 -0500 Subject: [PATCH 17/36] fix all broken backlinks --- blog/build-your-own-operator-on-macos-1.md | 6 +++--- blog/build-your-own-operator-on-macos-2.md | 4 ++-- blog/lume-to-containerization.md | 2 +- blog/sandboxed-python-execution.md | 2 +- blog/training-computer-use-models-trajectories-1.md | 2 +- blog/ubuntu-docker-support.md | 2 +- blog/windows-sandbox.md | 2 +- libs/lume/README.md | 2 +- libs/python/computer-server/README.md | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/blog/build-your-own-operator-on-macos-1.md b/blog/build-your-own-operator-on-macos-1.md index dd075e01..bcc83866 100644 --- a/blog/build-your-own-operator-on-macos-1.md +++ b/blog/build-your-own-operator-on-macos-1.md @@ -8,7 +8,7 @@ In this first blogpost, we'll learn how to build our own Computer-Use Operator u - **computer-use-preview** is OpenAI's specialized language model trained to understand and interact with computer interfaces through screenshots. - A **Computer-Use Agent** is an AI agent that can control a computer just like a human would - clicking buttons, typing text, and interacting with applications. -Our Operator will run in an isolated macOS VM, by making use of our [cua-computer](https://github.com/trycua/cua/tree/main/libs/computer) package and [lume virtualization CLI](https://github.com/trycua/cua/tree/main/libs/lume). +Our Operator will run in an isolated macOS VM, by making use of our [cua-computer](https://github.com/trycua/cua/tree/main/libs/python/computer) package and [lume virtualization CLI](https://github.com/trycua/cua/tree/main/libs/lume). Check out what it looks like to use your own Operator from a Gradio app: @@ -567,10 +567,10 @@ In a production setting, you would wrap the action-response cycle in a loop, han ### Next Steps -In the next blogpost, we'll introduce our Agent framework which abstracts away all these tedious implementation steps. This framework provides a higher-level API that handles the interaction loop between OpenAI's computer-use model and the macOS sandbox, allowing you to focus on building sophisticated applications rather than managing the low-level details we've explored here. Can't wait? Check out the [cua-agent](https://github.com/trycua/cua/tree/main/libs/agent) package! +In the next blogpost, we'll introduce our Agent framework which abstracts away all these tedious implementation steps. This framework provides a higher-level API that handles the interaction loop between OpenAI's computer-use model and the macOS sandbox, allowing you to focus on building sophisticated applications rather than managing the low-level details we've explored here. Can't wait? Check out the [cua-agent](https://github.com/trycua/cua/tree/main/libs/python/agent) package! ### Resources - [OpenAI Computer-Use docs](https://platform.openai.com/docs/guides/tools-computer-use) -- [cua-computer](https://github.com/trycua/cua/tree/main/libs/computer) +- [cua-computer](https://github.com/trycua/cua/tree/main/libs/python/computer) - [lume](https://github.com/trycua/cua/tree/main/libs/lume) diff --git a/blog/build-your-own-operator-on-macos-2.md b/blog/build-your-own-operator-on-macos-2.md index bf521b75..1caac809 100644 --- a/blog/build-your-own-operator-on-macos-2.md +++ b/blog/build-your-own-operator-on-macos-2.md @@ -171,7 +171,7 @@ The `cua-agent` framework provides multiple agent loop implementations to abstra - **AgentLoop.OMNI**: The most flexible option that works with virtually any vision-language model including local and open-source ones. Perfect for cost-effective development or when you need to use models without native computer-use capabilities. -These abstractions allow you to easily switch between providers without changing your application code. All loop implementations are available in the [cua-agent GitHub repository](https://github.com/trycua/cua/tree/main/libs/agent/agent/providers). +These abstractions allow you to easily switch between providers without changing your application code. All loop implementations are available in the [cua-agent GitHub repository](https://github.com/trycua/cua/tree/main/libs/python/agent). Choosing the right agent loop depends not only on your API access and technical requirements but also on the specific tasks you need to accomplish. To make an informed decision, it's helpful to understand how these underlying models perform across different computing environments – from desktop operating systems to web browsers and mobile interfaces. @@ -674,7 +674,7 @@ With the basics covered, you might want to explore: ### Resources -- [cua-agent GitHub repository](https://github.com/trycua/cua/tree/main/libs/agent) +- [cua-agent GitHub repository](https://github.com/trycua/cua/tree/main/libs/python/agent) - [Agent Notebook Examples](https://github.com/trycua/cua/blob/main/notebooks/agent_nb.ipynb) - [OpenAI Agent SDK Specification](https://platform.openai.com/docs/api-reference/responses) - [Anthropic API Documentation](https://docs.anthropic.com/en/api/getting-started) diff --git a/blog/lume-to-containerization.md b/blog/lume-to-containerization.md index e6e0f134..d420b031 100644 --- a/blog/lume-to-containerization.md +++ b/blog/lume-to-containerization.md @@ -90,7 +90,7 @@ lume run macos-sequoia-vanilla:latest ### Lumier: Docker-Style VM Management -[Lumier](https://github.com/trycua/lumier) works differently. It lets you use Docker commands to manage VMs. But here's the key: **Docker is just for packaging, not for isolation**. +[Lumier](https://github.com/trycua/cua/tree/main/libs/lumier) works differently. It lets you use Docker commands to manage VMs. But here's the key: **Docker is just for packaging, not for isolation**. What makes Lumier useful: diff --git a/blog/sandboxed-python-execution.md b/blog/sandboxed-python-execution.md index e0eb8391..b45a0ab5 100644 --- a/blog/sandboxed-python-execution.md +++ b/blog/sandboxed-python-execution.md @@ -378,4 +378,4 @@ Happy coding (safely)! --- -_Want to dive deeper? Check out our [sandboxed functions examples](https://github.com/trycua/cua/blob/main/examples/sandboxed_functions_examples.py) and [virtual environment tests](https://github.com/trycua/cua/blob/main/tests/venv.py) on GitHub. Questions? Come chat with us on Discord!_ +_Want to dive deeper? Check out our [sandboxed functions examples](https://github.com/trycua/cua/blob/main/examples/sandboxed_functions_examples.py) and [virtual environment tests](https://github.com/trycua/cua/blob/main/tests/test_venv.py) on GitHub. Questions? Come chat with us on Discord!_ diff --git a/blog/training-computer-use-models-trajectories-1.md b/blog/training-computer-use-models-trajectories-1.md index 040eaea4..1fa83a8e 100644 --- a/blog/training-computer-use-models-trajectories-1.md +++ b/blog/training-computer-use-models-trajectories-1.md @@ -306,6 +306,6 @@ Now that you know how to create and share trajectories, consider these advanced ### Resources -- [Computer-Use Interface GitHub](https://github.com/trycua/cua/tree/main/libs/computer) +- [Computer-Use Interface GitHub](https://github.com/trycua/cua/tree/main/libs/python/computer) - [Hugging Face Datasets Documentation](https://huggingface.co/docs/datasets) - [Example Dataset: ddupont/test-dataset](https://huggingface.co/datasets/ddupont/test-dataset) diff --git a/blog/ubuntu-docker-support.md b/blog/ubuntu-docker-support.md index 774d0438..b489a6e0 100644 --- a/blog/ubuntu-docker-support.md +++ b/blog/ubuntu-docker-support.md @@ -174,7 +174,7 @@ await computer.run() ## Links -- **Docker Provider Docs:** [https://cua.ai/docs/computers/docker](https://cua.ai/docs/computers/docker) +- **Docker Provider Docs:** [https://cua.ai/docs/computers/docker](https://cua.ai/docs/computer-sdk/computers#linux-on-docker) - **KasmVNC:** [https://github.com/kasmtech/KasmVNC](https://github.com/kasmtech/KasmVNC) - **Container Source:** [https://github.com/trycua/cua/tree/main/libs/kasm](https://github.com/trycua/cua/tree/main/libs/kasm) - **Computer SDK:** [https://cua.ai/docs/computer-sdk/computers](https://cua.ai/docs/computer-sdk/computers) diff --git a/blog/windows-sandbox.md b/blog/windows-sandbox.md index ef577611..d0f7f8f0 100644 --- a/blog/windows-sandbox.md +++ b/blog/windows-sandbox.md @@ -239,7 +239,7 @@ But for development, prototyping, and learning Windows RPA workflows, **Windows - [Windows Sandbox Documentation](https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/) - [Cua GitHub Repository](https://github.com/trycua/cua) -- [Agent UI Documentation](https://github.com/trycua/cua/tree/main/libs/agent) +- [Agent UI Documentation](https://github.com/trycua/cua/tree/main/libs/python/agent) - [Join our Discord Community](https://discord.gg/cua-ai) --- diff --git a/libs/lume/README.md b/libs/lume/README.md index 6d1c12a7..0d287b04 100644 --- a/libs/lume/README.md +++ b/libs/lume/README.md @@ -58,7 +58,7 @@ To get set up with Lume for development, read [these instructions](Development.m - [Installation](https://cua.ai/docs/libraries/lume/installation) - [Prebuilt Images](https://cua.ai/docs/libraries/lume/prebuilt-images) - [CLI Reference](https://cua.ai/docs/libraries/lume/cli-reference) -- [HTTP API](https://cuai.ai/docs/libraries/lume/http-api) +- [HTTP API](https://cua.ai/docs/libraries/lume/http-api) - [FAQ](https://cua.ai/docs/libraries/lume/faq) ## Contributing diff --git a/libs/python/computer-server/README.md b/libs/python/computer-server/README.md index 567af7d4..e411bd98 100644 --- a/libs/python/computer-server/README.md +++ b/libs/python/computer-server/README.md @@ -43,4 +43,4 @@ Refer to this notebook for a step-by-step guide on how to use the Computer-Use S - [Commands](https://cua.ai/docs/libraries/computer-server/Commands) - [REST-API](https://cua.ai/docs/libraries/computer-server/REST-API) - [WebSocket-API](https://cua.ai/docs/libraries/computer-server/WebSocket-API) -- [Index](https://cua.ai/docs/libraries/computer-server/index) +- [Index](https://cua.ai/docs/libraries/computer-server) From c0412ffa0e3878fac94c621e379f3285883e3110 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 18:35:07 -0500 Subject: [PATCH 18/36] fix workflow to send slack --- .github/workflows/link-check.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 9603dc01..0b68dcc4 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -1,8 +1,11 @@ name: Link Checker on: - pull_request: + pull_request_target: + branches: [main, master] push: + branches: + - main workflow_dispatch: jobs: @@ -73,7 +76,6 @@ jobs: SLACK_MESSAGE: | *Status:* ${{ env.STATUS_ICON }} ${{ env.STATUS_TEXT }} - *Branch:* ${{ github.ref_name }} - *Commit:* ${{ github.event.head_commit.message || 'See PR description' }} + *Branch:* `${{ github.ref_name }}` - *Run Details:* ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}${{ github.event.pull_request.number && format('?pr={0}', github.event.pull_request.number) || '' }}|View broken links> From fac0403c7827e49064ca72e791a1190e1ecd7db2 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 18:35:39 -0500 Subject: [PATCH 19/36] remove one more broken blog --- libs/python/computer-server/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libs/python/computer-server/README.md b/libs/python/computer-server/README.md index e411bd98..59e6f0e2 100644 --- a/libs/python/computer-server/README.md +++ b/libs/python/computer-server/README.md @@ -32,12 +32,6 @@ To install the Computer-Use Interface (CUI): pip install cua-computer-server ``` -## Run - -Refer to this notebook for a step-by-step guide on how to use the Computer-Use Server on the host system or VM: - -- [Computer-Use Server](../../notebooks/computer_server_nb.ipynb) - ## Docs - [Commands](https://cua.ai/docs/libraries/computer-server/Commands) From 4954397f0b7cd475353f1e17f67e3f8efa14da53 Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Sun, 9 Nov 2025 19:34:06 -0500 Subject: [PATCH 20/36] fix 2 more broken links --- examples/computer-example-ts/README.md | 8 -------- libs/python/computer-server/README.md | 6 ++++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/computer-example-ts/README.md b/examples/computer-example-ts/README.md index 7e7fc81e..b83838ce 100644 --- a/examples/computer-example-ts/README.md +++ b/examples/computer-example-ts/README.md @@ -34,14 +34,6 @@ This example demonstrates how to control a Cua Cloud Sandbox using the OpenAI `c - `src/index.ts` — Main example script - `src/helpers.ts` — Helper for executing actions on the container -## Further Reading - -For a step-by-step tutorial and more detailed explanation, see the accompanying blog post: - -➡️ [Controlling a Cua Cloud Sandbox with JavaScript](https://placeholder-url-to-blog-post.com) - -_(This link will be updated once the article is published.)_ - --- If you have questions or issues, please open an issue or contact the maintainers. diff --git a/libs/python/computer-server/README.md b/libs/python/computer-server/README.md index 59e6f0e2..e411bd98 100644 --- a/libs/python/computer-server/README.md +++ b/libs/python/computer-server/README.md @@ -32,6 +32,12 @@ To install the Computer-Use Interface (CUI): pip install cua-computer-server ``` +## Run + +Refer to this notebook for a step-by-step guide on how to use the Computer-Use Server on the host system or VM: + +- [Computer-Use Server](../../notebooks/computer_server_nb.ipynb) + ## Docs - [Commands](https://cua.ai/docs/libraries/computer-server/Commands) From 04e93687adac9537ba465784a14320785eadea5a Mon Sep 17 00:00:00 2001 From: Sarina Li Date: Mon, 10 Nov 2025 11:14:41 -0500 Subject: [PATCH 21/36] fix new line + ai gen citing --- docs/content/docs/computer-sdk/computers.mdx | 13 ++----------- docs/content/docs/computer-sdk/tracing-api.mdx | 5 ----- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/content/docs/computer-sdk/computers.mdx b/docs/content/docs/computer-sdk/computers.mdx index 238c12e0..45b70c22 100644 --- a/docs/content/docs/computer-sdk/computers.mdx +++ b/docs/content/docs/computer-sdk/computers.mdx @@ -3,17 +3,8 @@ title: Cua Computers description: Understanding Cua computer types and connection methods --- - - A corresponding{' '} - - Jupyter Notebook - {' '} - and{' '} - - NodeJS project - {' '} - are available for this documentation. - +{/* prettier-ignore */} +A corresponding Jupyter Notebook and NodeJS project are available for this documentation. Before we can automate apps using AI, we need to first connect to a Computer Server to give the AI a safe environment to execute workflows in. diff --git a/docs/content/docs/computer-sdk/tracing-api.mdx b/docs/content/docs/computer-sdk/tracing-api.mdx index 06c889f3..79b4b0a5 100644 --- a/docs/content/docs/computer-sdk/tracing-api.mdx +++ b/docs/content/docs/computer-sdk/tracing-api.mdx @@ -7,11 +7,6 @@ description: Record computer interactions for debugging, training, and analysis The Computer tracing API provides a powerful way to record computer interactions for debugging, training, analysis, and compliance purposes. Inspired by Playwright's tracing functionality, it offers flexible recording options and standardized output formats. - - The tracing API addresses GitHub issue #299 by providing a unified recording interface that works - with any Computer usage pattern, not just ComputerAgent. - - ## Overview The tracing API allows you to: From 3c03ea51c9a15a6b26c131ea8be2217d4a673afe Mon Sep 17 00:00:00 2001 From: Tamoghno Kandar <55907205+tamoghnokandar@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:12:17 -0800 Subject: [PATCH 22/36] Add files via upload --- libs/python/agent/agent/loops/gelato.py | 188 ++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 libs/python/agent/agent/loops/gelato.py diff --git a/libs/python/agent/agent/loops/gelato.py b/libs/python/agent/agent/loops/gelato.py new file mode 100644 index 00000000..c66475f8 --- /dev/null +++ b/libs/python/agent/agent/loops/gelato.py @@ -0,0 +1,188 @@ +""" +Gelato agent loop implementation for click prediction using litellm.acompletion +Model: https://huggingface.co/mlfoundations/Gelato-30B-A3B +Code: https://github.com/mlfoundations/Gelato/tree/main +""" + +import asyncio +import base64 +import json +import math +import re +import uuid +from io import BytesIO +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Union + +import litellm +from PIL import Image + +from ..decorators import register_agent +from ..loops.base import AsyncAgentConfig +from ..types import AgentCapability, AgentResponse, Messages, Tools + +SYSTEM_PROMPT = ''' +You are an expert UI element locator. Given a GUI image and a user's element description, provide the coordinates of the specified element as a single (x,y) point. For elements with area, return the center point. + +Output the coordinate pair exactly: +(x,y) +''' + + +def extract_coordinates(raw_string): + """ + Extract the coordinates from the raw string. + Args: + raw_string: str (e.g. "(100, 200)") + Returns: + x: float (e.g. 100.0) + y: float (e.g. 200.0) + """ + try: + matches = re.findall(r"\((-?\d*\.?\d+),\s*(-?\d*\.?\d+)\)", raw_string) + return [tuple(map(int, match)) for match in matches][0] + except: + return 0,0 + + + +def smart_resize( + height: int, width: int, factor: int = 28, min_pixels: int = 3136, max_pixels: int = 8847360 +) -> Tuple[int, int]: + """Smart resize function similar to qwen_vl_utils.""" + # Calculate the total pixels + total_pixels = height * width + + # If already within bounds, return original dimensions + if min_pixels <= total_pixels <= max_pixels: + # Round to nearest factor + new_height = (height // factor) * factor + new_width = (width // factor) * factor + return new_height, new_width + + # Calculate scaling factor + if total_pixels > max_pixels: + scale = (max_pixels / total_pixels) ** 0.5 + else: + scale = (min_pixels / total_pixels) ** 0.5 + + # Apply scaling + new_height = int(height * scale) + new_width = int(width * scale) + + # Round to nearest factor + new_height = (new_height // factor) * factor + new_width = (new_width // factor) * factor + + # Ensure minimum size + new_height = max(new_height, factor) + new_width = max(new_width, factor) + + return new_height, new_width + + +@register_agent(models=r".*Gelato.*") +class GelatoConfig(AsyncAgentConfig): + """Gelato agent configuration implementing AsyncAgentConfig protocol for click prediction.""" + + def __init__(self): + self.current_model = None + self.last_screenshot_b64 = None + + async def predict_step( + self, + messages: List[Dict[str, Any]], + model: str, + tools: Optional[List[Dict[str, Any]]] = None, + max_retries: Optional[int] = None, + stream: bool = False, + computer_handler=None, + _on_api_start=None, + _on_api_end=None, + _on_usage=None, + _on_screenshot=None, + **kwargs, + ) -> Dict[str, Any]: + raise NotImplementedError() + + async def predict_click( + self, model: str, image_b64: str, instruction: str, **kwargs + ) -> Optional[Tuple[float, float]]: + """ + Predict click coordinates using UI-Ins model via litellm.acompletion. + + Args: + model: The UI-Ins model name + image_b64: Base64 encoded image + instruction: Instruction for where to click + + Returns: + Tuple of (x, y) coordinates or None if prediction fails + """ + # Decode base64 image + image_data = base64.b64decode(image_b64) + image = Image.open(BytesIO(image_data)) + width, height = image.width, image.height + + # Smart resize the image (similar to qwen_vl_utils) + resized_height, resized_width = smart_resize( + height, + width, + factor=28, # Default factor for Qwen models + min_pixels=3136, + max_pixels=4096 * 2160, + ) + resized_image = image.resize((resized_width, resized_height)) + scale_x, scale_y = width / resized_width, height / resized_height + + # Convert resized image back to base64 + buffered = BytesIO() + resized_image.save(buffered, format="PNG") + resized_image_b64 = base64.b64encode(buffered.getvalue()).decode() + + # Prepare system and user messages + system_message = { + "role": "system", + "content": [ + { + "type": "text", + "text": SYSTEM_PROMPT.strip() + } + ], + } + + user_message = { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{resized_image_b64}"}, + }, + {"type": "text", "text": instruction}, + ], + } + + # Prepare API call kwargs + api_kwargs = { + "model": model, + "messages": [system_message, user_message], + "max_tokens": 2056, + "temperature": 0.0, + **kwargs, + } + + # Use liteLLM acompletion + response = await litellm.acompletion(**api_kwargs) + + # Extract response text + output_text = response.choices[0].message.content # type: ignore + + # Extract and rescale coordinates + pred_x, pred_y = extract_coordinates(output_text) # type: ignore + pred_x *= scale_x + pred_y *= scale_y + + return (math.floor(pred_x), math.floor(pred_y)) + + def get_capabilities(self) -> List[AgentCapability]: + """Return the capabilities supported by this agent.""" + return ["click"] \ No newline at end of file From 2f5f887b3d4123ed51a88835c06c837b72fdf63f Mon Sep 17 00:00:00 2001 From: Tamoghno Kandar <55907205+tamoghnokandar@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:13:05 -0800 Subject: [PATCH 23/36] Update __init__.py --- libs/python/agent/agent/loops/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/python/agent/agent/loops/__init__.py b/libs/python/agent/agent/loops/__init__.py index ab23ac27..5006c102 100644 --- a/libs/python/agent/agent/loops/__init__.py +++ b/libs/python/agent/agent/loops/__init__.py @@ -17,6 +17,7 @@ from . import ( opencua, qwen, uitars, + gelato, ) __all__ = [ @@ -33,4 +34,5 @@ __all__ = [ "moondream3", "gemini", "qwen", + "gelato", ] From c20084129d531052ff8b2d9a5c1a7ec434921e44 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 10 Nov 2025 15:12:32 -0500 Subject: [PATCH 24/36] Removed agent test per PR --- .github/workflows/test-cua-models.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-cua-models.yml b/.github/workflows/test-cua-models.yml index 43e7af38..023abce3 100644 --- a/.github/workflows/test-cua-models.yml +++ b/.github/workflows/test-cua-models.yml @@ -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 @@ -248,7 +246,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: From ef842cf1e6b540e1bbe82e247746a223c72a1f2d Mon Sep 17 00:00:00 2001 From: Tamoghno Kandar <55907205+tamoghnokandar@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:19:01 -0800 Subject: [PATCH 25/36] Add files via upload --- libs/python/agent/agent/loops/gelato.py | 29 ++++++++++--------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/libs/python/agent/agent/loops/gelato.py b/libs/python/agent/agent/loops/gelato.py index c66475f8..e3032472 100644 --- a/libs/python/agent/agent/loops/gelato.py +++ b/libs/python/agent/agent/loops/gelato.py @@ -4,28 +4,25 @@ Model: https://huggingface.co/mlfoundations/Gelato-30B-A3B Code: https://github.com/mlfoundations/Gelato/tree/main """ -import asyncio import base64 -import json import math import re -import uuid from io import BytesIO -from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple import litellm from PIL import Image from ..decorators import register_agent from ..loops.base import AsyncAgentConfig -from ..types import AgentCapability, AgentResponse, Messages, Tools +from ..types import AgentCapability -SYSTEM_PROMPT = ''' +SYSTEM_PROMPT = """ You are an expert UI element locator. Given a GUI image and a user's element description, provide the coordinates of the specified element as a single (x,y) point. For elements with area, return the center point. Output the coordinate pair exactly: (x,y) -''' +""" def extract_coordinates(raw_string): @@ -41,12 +38,15 @@ def extract_coordinates(raw_string): matches = re.findall(r"\((-?\d*\.?\d+),\s*(-?\d*\.?\d+)\)", raw_string) return [tuple(map(int, match)) for match in matches][0] except: - return 0,0 - + return 0, 0 def smart_resize( - height: int, width: int, factor: int = 28, min_pixels: int = 3136, max_pixels: int = 8847360 + height: int, + width: int, + factor: int = 28, + min_pixels: int = 3136, + max_pixels: int = 8847360, ) -> Tuple[int, int]: """Smart resize function similar to qwen_vl_utils.""" # Calculate the total pixels @@ -142,12 +142,7 @@ class GelatoConfig(AsyncAgentConfig): # Prepare system and user messages system_message = { "role": "system", - "content": [ - { - "type": "text", - "text": SYSTEM_PROMPT.strip() - } - ], + "content": [{"type": "text", "text": SYSTEM_PROMPT.strip()}], } user_message = { @@ -185,4 +180,4 @@ class GelatoConfig(AsyncAgentConfig): def get_capabilities(self) -> List[AgentCapability]: """Return the capabilities supported by this agent.""" - return ["click"] \ No newline at end of file + return ["click"] From a1c394bcc2fb8981a5d0c08abbec17e69cf4a64e Mon Sep 17 00:00:00 2001 From: Tamoghno Kandar <55907205+tamoghnokandar@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:20:13 -0800 Subject: [PATCH 26/36] Add files via upload --- libs/python/agent/agent/loops/__init__.py | 76 +++++++++++------------ 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/libs/python/agent/agent/loops/__init__.py b/libs/python/agent/agent/loops/__init__.py index 5006c102..88535e89 100644 --- a/libs/python/agent/agent/loops/__init__.py +++ b/libs/python/agent/agent/loops/__init__.py @@ -1,38 +1,38 @@ -""" -Agent loops for agent -""" - -# Import the loops to register them -from . import ( - anthropic, - composed_grounded, - gemini, - glm45v, - gta1, - holo, - internvl, - moondream3, - omniparser, - openai, - opencua, - qwen, - uitars, - gelato, -) - -__all__ = [ - "anthropic", - "openai", - "uitars", - "omniparser", - "gta1", - "composed_grounded", - "glm45v", - "opencua", - "internvl", - "holo", - "moondream3", - "gemini", - "qwen", - "gelato", -] +""" +Agent loops for agent +""" + +# Import the loops to register them +from . import ( + anthropic, + composed_grounded, + gelato, + gemini, + glm45v, + gta1, + holo, + internvl, + moondream3, + omniparser, + openai, + opencua, + qwen, + uitars, +) + +__all__ = [ + "anthropic", + "openai", + "uitars", + "omniparser", + "gta1", + "composed_grounded", + "glm45v", + "opencua", + "internvl", + "holo", + "moondream3", + "gemini", + "qwen", + "gelato", +] From f2dfe865fd080cfd200d138fd04b5fe63488c32b Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Tue, 11 Nov 2025 12:11:48 -0500 Subject: [PATCH 27/36] added create and delete cli --- libs/typescript/cua-cli/README.md | 5 ++ libs/typescript/cua-cli/src/commands/vm.ts | 100 +++++++++++++++++++++ libs/typescript/cua-cli/src/util.ts | 8 +- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/libs/typescript/cua-cli/README.md b/libs/typescript/cua-cli/README.md index 5e802d83..b5843428 100644 --- a/libs/typescript/cua-cli/README.md +++ b/libs/typescript/cua-cli/README.md @@ -24,6 +24,11 @@ bun run ./index.ts -- --help - **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` diff --git a/libs/typescript/cua-cli/src/commands/vm.ts b/libs/typescript/cua-cli/src/commands/vm.ts index 3b7f2b20..ded4f950 100644 --- a/libs/typescript/cua-cli/src/commands/vm.ts +++ b/libs/typescript/cua-cli/src/commands/vm.ts @@ -32,6 +32,106 @@ export function registerVmCommands(y: Argv) { 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) => { + 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 ", + "Delete a VM", + (y) => y.positional("name", { type: "string", describe: "VM name" }), + async (argv: Record) => { + 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 ", "Start a VM", diff --git a/libs/typescript/cua-cli/src/util.ts b/libs/typescript/cua-cli/src/util.ts index 503378b8..2d1a1578 100644 --- a/libs/typescript/cua-cli/src/util.ts +++ b/libs/typescript/cua-cli/src/util.ts @@ -10,12 +10,12 @@ export async function writeEnvFile(cwd: string, key: string) { } export type VmStatus = "pending" | "running" | "stopped" | "terminated" | "failed"; -export type VmItem = { name: string; password: string; status: VmStatus }; +export type VmItem = { name: string; password: string; status: VmStatus; host?: string }; export function printVmList(items: VmItem[]) { - const rows: string[][] = [["NAME", "STATUS", "PASSWORD"], ...items.map(v => [v.name, String(v.status), v.password])]; - const widths: number[] = [0, 0, 0]; - for (const r of rows) for (let i = 0; i < 3; i++) widths[i] = Math.max(widths[i] ?? 0, (r[i] ?? "").length); + 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"); } From 6832cfe922469486aa38b326e5cda9e70591106d Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Tue, 11 Nov 2025 12:14:31 -0500 Subject: [PATCH 28/36] changed dir to .cua --- libs/typescript/cua-cli/bun.lock | 1 + libs/typescript/cua-cli/src/config.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/typescript/cua-cli/bun.lock b/libs/typescript/cua-cli/bun.lock index 4de964c1..50bf2d87 100644 --- a/libs/typescript/cua-cli/bun.lock +++ b/libs/typescript/cua-cli/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "cua-cli", diff --git a/libs/typescript/cua-cli/src/config.ts b/libs/typescript/cua-cli/src/config.ts index 1d4b9212..16e93662 100644 --- a/libs/typescript/cua-cli/src/config.ts +++ b/libs/typescript/cua-cli/src/config.ts @@ -5,7 +5,7 @@ export const CALLBACK_HOST = "127.0.0.1"; export function getConfigDir(): string { const home = Bun.env.HOME || Bun.env.USERPROFILE || "."; - const dir = `${home}/.config/cua`; + const dir = `${home}/.cua`; try { Bun.spawnSync(["mkdir", "-p", dir]); } catch {} From 679eafe7c7e053c48caf16b017d94227d955776c Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Tue, 11 Nov 2025 12:44:24 -0500 Subject: [PATCH 29/36] update computer SDK and computer-server SDK to support the new .sandbox.cua.ai domain --- .../computer_server/watchdog.py | 61 +++++++++---- .../computer/providers/cloud/provider.py | 88 +++++++++++++++++-- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/libs/python/computer-server/computer_server/watchdog.py b/libs/python/computer-server/computer_server/watchdog.py index 7c9ca83f..460c51c6 100644 --- a/libs/python/computer-server/computer_server/watchdog.py +++ b/libs/python/computer-server/computer_server/watchdog.py @@ -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 """ + # Create a simple ping message + ping_message = {"command": "get_screen_size", "params": {}} + + # Try primary URI first (.sandbox.cua.ai) try: - # Create a simple ping message - ping_message = {"command": "get_screen_size", "params": {}} - - # Try to connect to the WebSocket async with websockets.connect( self.ws_uri, max_size=1024 * 1024 * 10 # 10MB limit to match server ) as websocket: @@ -105,13 +114,35 @@ 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: """ diff --git a/libs/python/computer/computer/providers/cloud/provider.py b/libs/python/computer/computer/providers/cloud/provider.py index 7d479686..8db233a2 100644 --- a/libs/python/computer/computer/providers/cloud/provider.py +++ b/libs/python/computer/computer/providers/cloud/provider.py @@ -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) From ff957a7d04d18742429e43863791b194478c87cd Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Tue, 11 Nov 2025 17:18:18 -0500 Subject: [PATCH 30/36] run uv run pre-commit run --all-files --- .../computer_server/watchdog.py | 21 +- .../computer/providers/cloud/provider.py | 22 +- libs/typescript/cua-cli/CLAUDE.md | 1 - libs/typescript/cua-cli/README.md | 2 +- libs/typescript/cua-cli/index.ts | 2 +- libs/typescript/cua-cli/src/auth.ts | 45 +- libs/typescript/cua-cli/src/cli.ts | 10 +- libs/typescript/cua-cli/src/commands/auth.ts | 85 ++-- libs/typescript/cua-cli/src/commands/vm.ts | 464 ++++++++++-------- libs/typescript/cua-cli/src/config.ts | 10 +- libs/typescript/cua-cli/src/http.ts | 11 +- libs/typescript/cua-cli/src/storage.ts | 12 +- libs/typescript/cua-cli/src/util.ts | 47 +- 13 files changed, 419 insertions(+), 313 deletions(-) diff --git a/libs/python/computer-server/computer_server/watchdog.py b/libs/python/computer-server/computer_server/watchdog.py index 460c51c6..50ade796 100644 --- a/libs/python/computer-server/computer_server/watchdog.py +++ b/libs/python/computer-server/computer_server/watchdog.py @@ -77,10 +77,10 @@ class Watchdog: """ 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. @@ -90,7 +90,7 @@ class Watchdog: """ 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: @@ -102,7 +102,7 @@ class Watchdog: """ # Create a simple ping message ping_message = {"command": "get_screen_size", "params": {}} - + # Try primary URI first (.sandbox.cua.ai) try: async with websockets.connect( @@ -120,12 +120,13 @@ class Watchdog: return False except Exception as e: 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 + 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)) @@ -133,12 +134,16 @@ 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 from fallback URI: {response[:100]}...") + 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}") + logger.warning( + f"Both primary and fallback ping failed. Primary: {e}, Fallback: {fallback_e}" + ) return False else: logger.warning(f"Ping failed: {e}") diff --git a/libs/python/computer/computer/providers/cloud/provider.py b/libs/python/computer/computer/providers/cloud/provider.py index 8db233a2..5664f18b 100644 --- a/libs/python/computer/computer/providers/cloud/provider.py +++ b/libs/python/computer/computer/providers/cloud/provider.py @@ -121,7 +121,7 @@ class CloudProvider(BaseVMProvider): name = vm.get("name") password = vm.get("password") api_host = vm.get("host") # Read host from API response - + if isinstance(name, str) and name: # Use host from API if available, otherwise fallback to legacy format if isinstance(api_host, str) and api_host: @@ -133,7 +133,7 @@ class CloudProvider(BaseVMProvider): 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" @@ -248,17 +248,17 @@ class CloudProvider(BaseVMProvider): 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() @@ -267,26 +267,26 @@ class CloudProvider(BaseVMProvider): 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 """ @@ -309,5 +309,5 @@ class CloudProvider(BaseVMProvider): """ if name is None: raise ValueError("VM name is required for CloudProvider.get_ip") - + return await self._get_host_for_vm(name) diff --git a/libs/typescript/cua-cli/CLAUDE.md b/libs/typescript/cua-cli/CLAUDE.md index 1ee68904..5fa3f4d5 100644 --- a/libs/typescript/cua-cli/CLAUDE.md +++ b/libs/typescript/cua-cli/CLAUDE.md @@ -1,4 +1,3 @@ - Default to using Bun instead of Node.js. - Use `bun ` instead of `node ` or `ts-node ` diff --git a/libs/typescript/cua-cli/README.md b/libs/typescript/cua-cli/README.md index b5843428..3a8004f7 100644 --- a/libs/typescript/cua-cli/README.md +++ b/libs/typescript/cua-cli/README.md @@ -72,5 +72,5 @@ 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?... +cua vm chat my-vm # opens https://staging.cua.ai/dashboard/playground?... ``` diff --git a/libs/typescript/cua-cli/index.ts b/libs/typescript/cua-cli/index.ts index b48c5715..ca291983 100755 --- a/libs/typescript/cua-cli/index.ts +++ b/libs/typescript/cua-cli/index.ts @@ -1,5 +1,5 @@ #! /usr/bin/env bun -import { runCli } from "./src/cli"; +import { runCli } from './src/cli'; runCli().catch((err) => { console.error(err); diff --git a/libs/typescript/cua-cli/src/auth.ts b/libs/typescript/cua-cli/src/auth.ts index e46f4e6b..28bef6df 100644 --- a/libs/typescript/cua-cli/src/auth.ts +++ b/libs/typescript/cua-cli/src/auth.ts @@ -1,21 +1,22 @@ -import { AUTH_PAGE, CALLBACK_HOST } from "./config"; -import { setApiKey, getApiKey } from "./storage"; -import { openInBrowser } from "./util"; +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", + 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 { let resolveToken!: (v: string) => void; - const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); + const tokenPromise = new Promise((resolve) => { + resolveToken = resolve; + }); // dynamic port (0) -> OS chooses available port const server = Bun.serve({ @@ -23,12 +24,15 @@ export async function loginViaBrowser(): Promise { 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 }); + 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" } }); + return new Response('CLI authorized. You can close this window.', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); }, }); @@ -41,14 +45,19 @@ export async function loginViaBrowser(): Promise { let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error("Timed out waiting for authorization")), 2 * 60 * 1000); + 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 {} + try { + server.stop(); + } catch {} } } diff --git a/libs/typescript/cua-cli/src/cli.ts b/libs/typescript/cua-cli/src/cli.ts index 2cfa759d..b47280b8 100644 --- a/libs/typescript/cua-cli/src/cli.ts +++ b/libs/typescript/cua-cli/src/cli.ts @@ -1,10 +1,10 @@ -import yargs from "yargs"; -import { hideBin } from "yargs/helpers"; -import { registerAuthCommands } from "./commands/auth"; -import { registerVmCommands } from "./commands/vm"; +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"); + let argv = yargs(hideBin(process.argv)).scriptName('cua'); argv = registerAuthCommands(argv); argv = registerVmCommands(argv); await argv.demandCommand(1).strict().help().parseAsync(); diff --git a/libs/typescript/cua-cli/src/commands/auth.ts b/libs/typescript/cua-cli/src/commands/auth.ts index e09beb03..8a9878c5 100644 --- a/libs/typescript/cua-cli/src/commands/auth.ts +++ b/libs/typescript/cua-cli/src/commands/auth.ts @@ -1,49 +1,46 @@ -import { setApiKey, clearApiKey } from "../storage"; -import { ensureApiKeyInteractive, loginViaBrowser } from "../auth"; -import { writeEnvFile } from "../util"; -import type { Argv } from "yargs"; +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) => { - 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"); + 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) => { + if (argv['api-key']) { + setApiKey(String(argv['api-key'])); + console.log('API key saved'); + return; } - ) - .command( - "pull", - "Create or update .env with CUA_API_KEY (login if needed)", - () => {}, - async (_argv: Record) => { - const token = await ensureApiKeyInteractive(); - const out = await writeEnvFile(process.cwd(), token); - console.log(`Wrote ${out}`); - } - ) - .command( - "logout", - "Remove stored API key", - () => {}, - async (_argv: Record) => { - clearApiKey(); - console.log("Logged out"); - } - ) - .demandCommand(1, "Specify an auth subcommand") + 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) => { + const token = await ensureApiKeyInteractive(); + const out = await writeEnvFile(process.cwd(), token); + console.log(`Wrote ${out}`); + } + ) + .command( + 'logout', + 'Remove stored API key', + () => {}, + async (_argv: Record) => { + clearApiKey(); + console.log('Logged out'); + } + ) + .demandCommand(1, 'Specify an auth subcommand') ); } diff --git a/libs/typescript/cua-cli/src/commands/vm.ts b/libs/typescript/cua-cli/src/commands/vm.ts index ded4f950..843a49dd 100644 --- a/libs/typescript/cua-cli/src/commands/vm.ts +++ b/libs/typescript/cua-cli/src/commands/vm.ts @@ -1,217 +1,291 @@ -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"; +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) => { - 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); + return y.command('vm', 'VM commands', (yv) => + yv + .command( + 'list', + 'List VMs', + () => {}, + async (_argv: Record) => { + 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); } - ) - .command( - "create", - "Create a new VM", - (y) => y - .option("os", { - type: "string", - choices: ["linux", "windows", "macos"], + 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" + describe: 'Operating system', }) - .option("configuration", { - type: "string", - choices: ["small", "medium", "large"], + .option('configuration', { + type: 'string', + choices: ['small', 'medium', 'large'], demandOption: true, - describe: "VM size configuration" + describe: 'VM size configuration', }) - .option("region", { - type: "string", - choices: ["north-america", "europe", "asia-pacific", "south-america"], + .option('region', { + type: 'string', + choices: ['north-america', 'europe', 'asia-pacific', 'south-america'], demandOption: true, - describe: "VM region" + describe: 'VM region', }), - async (argv: Record) => { - 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 } - }); + async (argv: Record) => { + const token = await ensureApiKeyInteractive(); + const { os, configuration, region } = argv as { + os: string; + configuration: string; + region: string; + }; - if (res.status === 401) { - clearApiKey(); - console.error("Unauthorized. Try 'cua auth login' again."); - process.exit(1); - } + const res = await http('/v1/vms', { + token, + method: 'POST', + body: { os, configuration, region }, + }); - 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}`); + if (res.status === 401) { + clearApiKey(); + console.error("Unauthorized. Try 'cua auth login' again."); process.exit(1); } - ) - .command( - "delete ", - "Delete a VM", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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}`); + + if (res.status === 400) { + console.error('Invalid request or unsupported configuration'); process.exit(1); } - ) - .command( - "start ", - "Start a VM", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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); + + if (res.status === 500) { + console.error('Internal server error'); + process.exit(1); } - ) - .command( - "stop ", - "Stop a VM", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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); + + 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; } - ) - .command( - "restart ", - "Restart a VM", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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); + + 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; } - ) - .command( - "vnc ", - "Open NoVNC for a VM in your browser", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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 url = `https://${vm.name}.containers.cloud.trycua.com/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`; - console.log(`Opening NoVNC: ${url}`); - await openInBrowser(url); + + console.error(`Unexpected status: ${res.status}`); + process.exit(1); + } + ) + .command( + 'delete ', + 'Delete a VM', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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; } - ) - .command( - "chat ", - "Open CUA dashboard playground for a VM", - (y) => y.positional("name", { type: "string", describe: "VM name" }), - async (argv: Record) => { - 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.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); + + if (res.status === 404) { + console.error('VM not found or not owned by you'); + process.exit(1); } - ) - .demandCommand(1, "Specify a vm subcommand") + + 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 ', + 'Start a VM', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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 ', + 'Stop a VM', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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 ', + 'Restart a VM', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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 ', + 'Open NoVNC for a VM in your browser', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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 url = `https://${vm.name}.containers.cloud.trycua.com/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`; + console.log(`Opening NoVNC: ${url}`); + await openInBrowser(url); + } + ) + .command( + 'chat ', + 'Open CUA dashboard playground for a VM', + (y) => y.positional('name', { type: 'string', describe: 'VM name' }), + async (argv: Record) => { + 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.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') ); } diff --git a/libs/typescript/cua-cli/src/config.ts b/libs/typescript/cua-cli/src/config.ts index 16e93662..e8183065 100644 --- a/libs/typescript/cua-cli/src/config.ts +++ b/libs/typescript/cua-cli/src/config.ts @@ -1,13 +1,13 @@ -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 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 const CALLBACK_HOST = '127.0.0.1'; export function getConfigDir(): string { - const home = Bun.env.HOME || Bun.env.USERPROFILE || "."; + const home = Bun.env.HOME || Bun.env.USERPROFILE || '.'; const dir = `${home}/.cua`; try { - Bun.spawnSync(["mkdir", "-p", dir]); + Bun.spawnSync(['mkdir', '-p', dir]); } catch {} return dir; } diff --git a/libs/typescript/cua-cli/src/http.ts b/libs/typescript/cua-cli/src/http.ts index 95ff39ea..57b7c408 100644 --- a/libs/typescript/cua-cli/src/http.ts +++ b/libs/typescript/cua-cli/src/http.ts @@ -1,11 +1,14 @@ -import { API_BASE } from "./config"; +import { API_BASE } from './config'; -export async function http(path: string, opts: { method?: string; token: string; body?: any }): Promise { +export async function http( + path: string, + opts: { method?: string; token: string; body?: any } +): Promise { const url = `${API_BASE}${path}`; const headers: Record = { Authorization: `Bearer ${opts.token}` }; - if (opts.body) headers["content-type"] = "application/json"; + if (opts.body) headers['content-type'] = 'application/json'; return fetch(url, { - method: opts.method || "GET", + method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined, }); diff --git a/libs/typescript/cua-cli/src/storage.ts b/libs/typescript/cua-cli/src/storage.ts index 15f68308..ea4a6d3a 100644 --- a/libs/typescript/cua-cli/src/storage.ts +++ b/libs/typescript/cua-cli/src/storage.ts @@ -1,17 +1,19 @@ -import { Database } from "bun:sqlite"; -import { getDbPath } from "./config"; +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);"); + 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"); + 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(); diff --git a/libs/typescript/cua-cli/src/util.ts b/libs/typescript/cua-cli/src/util.ts index 2d1a1578..16705b2e 100644 --- a/libs/typescript/cua-cli/src/util.ts +++ b/libs/typescript/cua-cli/src/util.ts @@ -1,32 +1,49 @@ export async function writeEnvFile(cwd: string, key: string) { const path = `${cwd}/.env`; - let content = ""; - try { content = await Bun.file(path).text(); } catch {} + 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"); + 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 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 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"); + 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}`); } + 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}`); + } } From 791c4ddf85f578d7a0e0a81fa167285b75b077b0 Mon Sep 17 00:00:00 2001 From: Dillon DuPont Date: Tue, 11 Nov 2025 17:22:37 -0500 Subject: [PATCH 31/36] use host column in cli --- libs/typescript/cua-cli/src/commands/vm.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/typescript/cua-cli/src/commands/vm.ts b/libs/typescript/cua-cli/src/commands/vm.ts index 843a49dd..c9a4548b 100644 --- a/libs/typescript/cua-cli/src/commands/vm.ts +++ b/libs/typescript/cua-cli/src/commands/vm.ts @@ -251,7 +251,9 @@ export function registerVmCommands(y: Argv) { console.error('VM not found'); process.exit(1); } - const url = `https://${vm.name}.containers.cloud.trycua.com/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`; + 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); } @@ -279,7 +281,8 @@ export function registerVmCommands(y: Argv) { console.error('VM not found'); process.exit(1); } - const host = `${vm.name}.containers.cloud.trycua.com`; + 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}`); From e7bb78d7dd97333d0d9402e426e0542a64ed8e1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Nov 2025 22:25:19 +0000 Subject: [PATCH 32/36] Bump cua-computer to v0.4.12 --- libs/python/computer/.bumpversion.cfg | 2 +- libs/python/computer/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/python/computer/.bumpversion.cfg b/libs/python/computer/.bumpversion.cfg index be266fe6..379a605e 100644 --- a/libs/python/computer/.bumpversion.cfg +++ b/libs/python/computer/.bumpversion.cfg @@ -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} diff --git a/libs/python/computer/pyproject.toml b/libs/python/computer/pyproject.toml index 1e4f49c4..7040abbd 100644 --- a/libs/python/computer/pyproject.toml +++ b/libs/python/computer/pyproject.toml @@ -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 = [ From 3464d8e6eb2145e89c965740499fdbcb6a53ed93 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 12 Nov 2025 01:52:22 -0500 Subject: [PATCH 33/36] Gate local-model matrix entries in test workflow - add workflow_dispatch flag and env var check to run huggingface-local models only when requested - annotate matrix with requires_local_weights to support gating logic - ignore missing logs/images when uploading artifacts so runs without outputs succeed - simplify summary aggregation to handle partial matrices --- .github/workflows/test-cua-models.yml | 81 ++++++++++++++++----------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test-cua-models.yml b/.github/workflows/test-cua-models.yml index 023abce3..cd29323a 100644 --- a/.github/workflows/test-cua-models.yml +++ b/.github/workflows/test-cua-models.yml @@ -11,6 +11,11 @@ on: required: false default: true type: boolean + include_local_models: + description: "Also run huggingface-local models (requires large disk / self-hosted runner)" + required: false + default: false + type: boolean schedule: # Runs at 3 PM UTC (8 AM PDT) daily - cron: "0 15 * * *" @@ -18,35 +23,47 @@ on: jobs: # Test all CUA models - runs on PRs, schedules, or when manually triggered test-all-models: - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false') }} + if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false')) && (!matrix.requires_local_weights || fromJSON(inputs.include_local_models || 'false') || vars.RUN_LOCAL_MODELS == 'true') }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - model: + include: # Claude Sonnet/Haiku - - anthropic/claude-sonnet-4-5-20250929 - - anthropic/claude-haiku-4-5-20251001 - - anthropic/claude-opus-4-1-20250805 + - model: anthropic/claude-sonnet-4-5-20250929 + requires_local_weights: false + - model: anthropic/claude-haiku-4-5-20251001 + requires_local_weights: false + - model: anthropic/claude-opus-4-1-20250805 + requires_local_weights: false # OpenAI CU Preview - - openai/computer-use-preview + - model: openai/computer-use-preview + requires_local_weights: false # GLM-V - - openrouter/z-ai/glm-4.5v - # - huggingface-local/zai-org/GLM-4.5V # Requires local model setup + - model: openrouter/z-ai/glm-4.5v + requires_local_weights: false + # - model: huggingface-local/zai-org/GLM-4.5V # Requires local model setup + # requires_local_weights: true # Gemini CU Preview - - gemini-2.5-computer-use-preview-10-2025 + - model: gemini-2.5-computer-use-preview-10-2025 + requires_local_weights: false # InternVL - - huggingface-local/OpenGVLab/InternVL3_5-1B - # - huggingface-local/OpenGVLab/InternVL3_5-2B - # - huggingface-local/OpenGVLab/InternVL3_5-4B - # - huggingface-local/OpenGVLab/InternVL3_5-8B + - model: huggingface-local/OpenGVLab/InternVL3_5-1B + requires_local_weights: true + # - model: huggingface-local/OpenGVLab/InternVL3_5-2B + # requires_local_weights: true + # - model: huggingface-local/OpenGVLab/InternVL3_5-4B + # requires_local_weights: true + # - model: huggingface-local/OpenGVLab/InternVL3_5-8B + # requires_local_weights: true # UI-TARS (supports full computer-use, can run standalone) - - huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B + - model: huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B + requires_local_weights: true # Note: OpenCUA, GTA, and Holo are grounding-only models # They only support predict_click(), not agent.run() @@ -54,21 +71,28 @@ 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+openai/gpt-4o # GPT-4o has VLM + Tools + - model: moondream3+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools + requires_local_weights: false + # - model: moondream3+openai/gpt-4o # GPT-4o has VLM + Tools + # requires_local_weights: false # OmniParser (typically used in composed agents) # Format: omniparser+{any-vlm-with-tools} - - omniparser+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools - # - omniparser+openai/gpt-4o # GPT-4o has VLM + Tools + - model: omniparser+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools + requires_local_weights: false + # - model: omniparser+openai/gpt-4o # GPT-4o has VLM + Tools + # requires_local_weights: false # Other grounding models + VLM with tools # 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 + - model: huggingface-local/HelloKKMe/GTA1-7B+anthropic/claude-sonnet-4-5-20250929 + requires_local_weights: true + - model: huggingface-local/xlangai/OpenCUA-7B+anthropic/claude-sonnet-4-5-20250929 + requires_local_weights: true + - model: huggingface-local/Hcompany/Holo1.5-3B+anthropic/claude-sonnet-4-5-20250929 + requires_local_weights: true steps: - name: Checkout repository @@ -218,6 +242,7 @@ jobs: tests/agent_loop_testing/test_images/ *.log retention-days: 7 + if-no-files-found: ignore - name: Upload test summary data if: always() @@ -227,6 +252,7 @@ jobs: name: test-summary-${{ env.SAFE_MODEL_NAME }} path: test_summary/ retention-days: 1 + if-no-files-found: ignore - name: Set default Slack color if: always() && env.SLACK_COLOR == '' @@ -268,10 +294,6 @@ jobs: # Create directory if it doesn't exist mkdir -p all_summaries - # Get list of models being tested in this run from the matrix - # This helps filter out artifacts from previous runs when testing locally - EXPECTED_MODELS="${{ join(matrix.model, ' ') }}" - # Aggregate all results PASSED_COUNT=0 FAILED_COUNT=0 @@ -295,15 +317,6 @@ jobs: continue fi - # Filter: Only include models that are in the current matrix - # This prevents including artifacts from previous workflow runs - if [ -n "$EXPECTED_MODELS" ]; then - if ! echo "$EXPECTED_MODELS" | grep -q "$MODEL"; then - echo "Skipping model from previous run: $MODEL" - continue - fi - fi - # Mark as processed processed_models[$MODEL]="1" From b7866cfe900536b4180ab52f5b03d622267fdb45 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 12 Nov 2025 02:00:54 -0500 Subject: [PATCH 34/36] Update test-cua-models.yml --- .github/workflows/test-cua-models.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-cua-models.yml b/.github/workflows/test-cua-models.yml index cd29323a..79fa33e9 100644 --- a/.github/workflows/test-cua-models.yml +++ b/.github/workflows/test-cua-models.yml @@ -25,6 +25,8 @@ jobs: test-all-models: if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false')) && (!matrix.requires_local_weights || fromJSON(inputs.include_local_models || 'false') || vars.RUN_LOCAL_MODELS == 'true') }} runs-on: ubuntu-latest + env: + ALLOW_LOCAL_MODELS: ${{ (github.event_name == 'workflow_dispatch' && fromJSON(inputs.include_local_models || 'false')) || vars.RUN_LOCAL_MODELS == 'true' }} strategy: fail-fast: false matrix: @@ -95,15 +97,23 @@ jobs: requires_local_weights: true steps: + - name: Skip local model on hosted runner + if: ${{ matrix.requires_local_weights && env.ALLOW_LOCAL_MODELS != 'true' }} + run: | + echo "Skipping ${{ matrix.model }} because local weights are disabled. Set include_local_models=true or vars.RUN_LOCAL_MODELS to run this entry." + - name: Checkout repository + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/checkout@v4 - name: Set up uv and Python + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: astral-sh/setup-uv@v4 with: python-version: "3.12" - name: Cache system packages + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: /var/cache/apt @@ -112,12 +122,14 @@ jobs: ${{ runner.os }}-apt- - name: Install system dependencies + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} timeout-minutes: 20 run: | sudo apt-get update sudo apt-get install -y libgl1-mesa-dri libglib2.0-0 - name: Cache Python dependencies (uv) + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: | @@ -128,6 +140,7 @@ jobs: ${{ runner.os }}-uv- - name: Install CUA dependencies (uv) + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} run: | # Remove existing venv if it exists (from cache restore) to avoid interactive prompt rm -rf .venv @@ -138,6 +151,7 @@ jobs: uv pip install pytest - name: Cache HuggingFace models + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: ~/.cache/huggingface @@ -147,12 +161,14 @@ jobs: # Large cache - models can be several GB each and are reused across runs - name: Record test start time + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} run: echo "TEST_START_TIME=$(date +%s)" >> $GITHUB_ENV env: # Ensure HuggingFace uses consistent cache location HF_HOME: ~/.cache/huggingface - name: Test model with agent loop + if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} id: test_model timeout-minutes: 20 continue-on-error: true @@ -167,7 +183,7 @@ jobs: HF_TOKEN: ${{ secrets.HF_TOKEN }} - name: Calculate test duration and prepare message - if: always() + if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} run: | TEST_END_TIME=$(date +%s) @@ -234,7 +250,7 @@ jobs: echo "SAFE_MODEL_NAME=${SAFE_MODEL_NAME}" >> $GITHUB_ENV - name: Upload test results - if: always() + if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.model }} @@ -245,7 +261,7 @@ jobs: if-no-files-found: ignore - name: Upload test summary data - if: always() + if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} uses: actions/upload-artifact@v4 with: # Unique, slash-free artifact name per matrix entry @@ -255,7 +271,7 @@ jobs: if-no-files-found: ignore - name: Set default Slack color - if: always() && env.SLACK_COLOR == '' + if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') && env.SLACK_COLOR == '' }} run: echo "SLACK_COLOR=#36a64f" >> $GITHUB_ENV # Individual model notifications disabled - only summary is sent From a6b1c44216433792dc4c31f605180c2920bc1b45 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 12 Nov 2025 02:08:01 -0500 Subject: [PATCH 35/36] Update test-cua-models.yml --- .github/workflows/test-cua-models.yml | 107 ++++++++++---------------- 1 file changed, 40 insertions(+), 67 deletions(-) diff --git a/.github/workflows/test-cua-models.yml b/.github/workflows/test-cua-models.yml index 79fa33e9..1ae28eac 100644 --- a/.github/workflows/test-cua-models.yml +++ b/.github/workflows/test-cua-models.yml @@ -11,11 +11,6 @@ on: required: false default: true type: boolean - include_local_models: - description: "Also run huggingface-local models (requires large disk / self-hosted runner)" - required: false - default: false - type: boolean schedule: # Runs at 3 PM UTC (8 AM PDT) daily - cron: "0 15 * * *" @@ -23,49 +18,35 @@ on: jobs: # Test all CUA models - runs on PRs, schedules, or when manually triggered test-all-models: - if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false')) && (!matrix.requires_local_weights || fromJSON(inputs.include_local_models || 'false') || vars.RUN_LOCAL_MODELS == 'true') }} + if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || fromJSON(inputs.test_models || 'false') }} runs-on: ubuntu-latest - env: - ALLOW_LOCAL_MODELS: ${{ (github.event_name == 'workflow_dispatch' && fromJSON(inputs.include_local_models || 'false')) || vars.RUN_LOCAL_MODELS == 'true' }} strategy: fail-fast: false matrix: - include: + model: # Claude Sonnet/Haiku - - model: anthropic/claude-sonnet-4-5-20250929 - requires_local_weights: false - - model: anthropic/claude-haiku-4-5-20251001 - requires_local_weights: false - - model: anthropic/claude-opus-4-1-20250805 - requires_local_weights: false + - anthropic/claude-sonnet-4-5-20250929 + - anthropic/claude-haiku-4-5-20251001 + - anthropic/claude-opus-4-1-20250805 # OpenAI CU Preview - - model: openai/computer-use-preview - requires_local_weights: false + - openai/computer-use-preview # GLM-V - - model: openrouter/z-ai/glm-4.5v - requires_local_weights: false - # - model: huggingface-local/zai-org/GLM-4.5V # Requires local model setup - # requires_local_weights: true + - openrouter/z-ai/glm-4.5v + # - huggingface-local/zai-org/GLM-4.5V # Requires local model setup # Gemini CU Preview - - model: gemini-2.5-computer-use-preview-10-2025 - requires_local_weights: false + - gemini-2.5-computer-use-preview-10-2025 # InternVL - - model: huggingface-local/OpenGVLab/InternVL3_5-1B - requires_local_weights: true - # - model: huggingface-local/OpenGVLab/InternVL3_5-2B - # requires_local_weights: true - # - model: huggingface-local/OpenGVLab/InternVL3_5-4B - # requires_local_weights: true - # - model: huggingface-local/OpenGVLab/InternVL3_5-8B - # requires_local_weights: true + # - 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) - - model: huggingface-local/ByteDance-Seed/UI-TARS-1.5-7B - requires_local_weights: true + # - 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() @@ -73,47 +54,32 @@ jobs: # Moondream (typically used in composed agents) # Format: moondream3+{any-llm-with-tools} - - model: moondream3+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools - requires_local_weights: false - # - model: moondream3+openai/gpt-4o # GPT-4o has VLM + Tools - # requires_local_weights: false + # - 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) # Format: omniparser+{any-vlm-with-tools} - - model: omniparser+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools - requires_local_weights: false - # - model: omniparser+openai/gpt-4o # GPT-4o has VLM + Tools - # requires_local_weights: false + - omniparser+anthropic/claude-sonnet-4-5-20250929 # Claude has VLM + Tools + # - omniparser+openai/gpt-4o # GPT-4o has VLM + Tools # Other grounding models + VLM with tools # 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() - - model: huggingface-local/HelloKKMe/GTA1-7B+anthropic/claude-sonnet-4-5-20250929 - requires_local_weights: true - - model: huggingface-local/xlangai/OpenCUA-7B+anthropic/claude-sonnet-4-5-20250929 - requires_local_weights: true - - model: huggingface-local/Hcompany/Holo1.5-3B+anthropic/claude-sonnet-4-5-20250929 - requires_local_weights: true + # - 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: Skip local model on hosted runner - if: ${{ matrix.requires_local_weights && env.ALLOW_LOCAL_MODELS != 'true' }} - run: | - echo "Skipping ${{ matrix.model }} because local weights are disabled. Set include_local_models=true or vars.RUN_LOCAL_MODELS to run this entry." - - name: Checkout repository - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/checkout@v4 - name: Set up uv and Python - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: astral-sh/setup-uv@v4 with: python-version: "3.12" - name: Cache system packages - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: /var/cache/apt @@ -122,14 +88,12 @@ jobs: ${{ runner.os }}-apt- - name: Install system dependencies - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} timeout-minutes: 20 run: | sudo apt-get update sudo apt-get install -y libgl1-mesa-dri libglib2.0-0 - name: Cache Python dependencies (uv) - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: | @@ -140,7 +104,6 @@ jobs: ${{ runner.os }}-uv- - name: Install CUA dependencies (uv) - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} run: | # Remove existing venv if it exists (from cache restore) to avoid interactive prompt rm -rf .venv @@ -151,7 +114,6 @@ jobs: uv pip install pytest - name: Cache HuggingFace models - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} uses: actions/cache@v4 with: path: ~/.cache/huggingface @@ -161,14 +123,12 @@ jobs: # Large cache - models can be several GB each and are reused across runs - name: Record test start time - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} run: echo "TEST_START_TIME=$(date +%s)" >> $GITHUB_ENV env: # Ensure HuggingFace uses consistent cache location HF_HOME: ~/.cache/huggingface - name: Test model with agent loop - if: ${{ !matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true' }} id: test_model timeout-minutes: 20 continue-on-error: true @@ -183,7 +143,7 @@ jobs: HF_TOKEN: ${{ secrets.HF_TOKEN }} - name: Calculate test duration and prepare message - if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} + if: always() run: | TEST_END_TIME=$(date +%s) @@ -250,28 +210,28 @@ jobs: echo "SAFE_MODEL_NAME=${SAFE_MODEL_NAME}" >> $GITHUB_ENV - name: Upload test results - if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} + if: always() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.model }} path: | tests/agent_loop_testing/test_images/ *.log - retention-days: 7 if-no-files-found: ignore + retention-days: 7 - name: Upload test summary data - if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') }} + if: always() uses: actions/upload-artifact@v4 with: # Unique, slash-free artifact name per matrix entry name: test-summary-${{ env.SAFE_MODEL_NAME }} path: test_summary/ - retention-days: 1 if-no-files-found: ignore + retention-days: 1 - name: Set default Slack color - if: ${{ always() && (!matrix.requires_local_weights || env.ALLOW_LOCAL_MODELS == 'true') && env.SLACK_COLOR == '' }} + if: always() && env.SLACK_COLOR == '' run: echo "SLACK_COLOR=#36a64f" >> $GITHUB_ENV # Individual model notifications disabled - only summary is sent @@ -310,6 +270,10 @@ jobs: # Create directory if it doesn't exist mkdir -p all_summaries + # Get list of models being tested in this run from the matrix + # This helps filter out artifacts from previous runs when testing locally + EXPECTED_MODELS="${{ join(matrix.model, ' ') }}" + # Aggregate all results PASSED_COUNT=0 FAILED_COUNT=0 @@ -333,6 +297,15 @@ jobs: continue fi + # Filter: Only include models that are in the current matrix + # This prevents including artifacts from previous workflow runs + if [ -n "$EXPECTED_MODELS" ]; then + if ! echo "$EXPECTED_MODELS" | grep -q "$MODEL"; then + echo "Skipping model from previous run: $MODEL" + continue + fi + fi + # Mark as processed processed_models[$MODEL]="1" From b716f2feaa77bd6b7e09d748222e4bab002bb1f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Nov 2025 12:17:00 +0000 Subject: [PATCH 36/36] Bump cua-agent to v0.4.38 --- libs/python/agent/.bumpversion.cfg | 2 +- libs/python/agent/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/python/agent/.bumpversion.cfg b/libs/python/agent/.bumpversion.cfg index ab6acb97..ef4bfda4 100644 --- a/libs/python/agent/.bumpversion.cfg +++ b/libs/python/agent/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.4.37 +current_version = 0.4.38 commit = True tag = True tag_name = agent-v{new_version} diff --git a/libs/python/agent/pyproject.toml b/libs/python/agent/pyproject.toml index fbb4bc9b..e240e4ff 100644 --- a/libs/python/agent/pyproject.toml +++ b/libs/python/agent/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "cua-agent" -version = "0.4.37" +version = "0.4.38" description = "CUA (Computer Use) Agent for AI-driven computer interaction" readme = "README.md" authors = [