Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

919 lines
27 KiB
Python

"""Skills management commands for CUA CLI.
Skills are recorded demonstrations that can guide agent behavior.
Each skill contains:
- SKILL.md: Markdown file with frontmatter and steps
- trajectory/: Directory with video, events.json, trajectory.json, screenshots
"""
import argparse
import json
import shutil
import webbrowser
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from cua_cli.utils.async_utils import run_async
from cua_cli.utils.output import (
print_error,
print_info,
print_json,
print_success,
print_table,
)
# Skills directory
SKILLS_DIR = Path.home() / ".cua" / "skills"
def register_parser(subparsers: argparse._SubParsersAction) -> None:
"""Register the skills command and subcommands."""
skills_parser = subparsers.add_parser(
"skills",
help="Manage demonstration skills",
description="Record and manage demonstration skills for agent guidance",
)
skills_subparsers = skills_parser.add_subparsers(
dest="skills_command",
help="Skills command",
)
# list command
list_parser = skills_subparsers.add_parser(
"list",
aliases=["ls"],
help="List all saved skills",
)
list_parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
# read command
read_parser = skills_subparsers.add_parser(
"read",
help="Read a skill",
)
read_parser.add_argument(
"name",
help="Skill name",
)
read_parser.add_argument(
"--format",
"-f",
choices=["json", "md"],
default="md",
help="Output format (default: md)",
)
# replay command
replay_parser = skills_subparsers.add_parser(
"replay",
help="Open the video recording for a skill",
)
replay_parser.add_argument(
"name",
help="Skill name",
)
# delete command
delete_parser = skills_subparsers.add_parser(
"delete",
help="Delete a skill",
)
delete_parser.add_argument(
"name",
help="Skill name",
)
# clean command
skills_subparsers.add_parser(
"clean",
help="Delete all skills (with confirmation)",
)
# record command
record_parser = skills_subparsers.add_parser(
"record",
help="Record a demonstration and create a skill",
)
record_parser.add_argument(
"--sandbox",
"-s",
type=str,
help="Sandbox name to connect to",
)
record_parser.add_argument(
"--vnc-url",
"-u",
type=str,
help="Direct VNC URL to connect to",
)
record_parser.add_argument(
"--provider",
"-p",
choices=["anthropic", "openai"],
default="anthropic",
help="LLM provider for captioning (default: anthropic)",
)
record_parser.add_argument(
"--model",
"-m",
type=str,
help="Model to use for captioning",
)
record_parser.add_argument(
"--api-key",
"-k",
type=str,
help="API key for the LLM provider",
)
record_parser.add_argument(
"--name",
"-n",
type=str,
help="Skill name (skips interactive prompt)",
)
record_parser.add_argument(
"--description",
"-d",
type=str,
help="Skill description (skips interactive prompt)",
)
def execute(args: argparse.Namespace) -> int:
"""Execute skills command based on subcommand."""
cmd = getattr(args, "skills_command", None)
if cmd in ("list", "ls"):
return cmd_list(args)
elif cmd == "read":
return cmd_read(args)
elif cmd == "replay":
return cmd_replay(args)
elif cmd == "delete":
return cmd_delete(args)
elif cmd == "clean":
return cmd_clean(args)
elif cmd == "record":
return cmd_record(args)
else:
print_error("Usage: cua skills <command>")
print_info("Commands: list, read, replay, delete, clean, record")
return 1
def _ensure_skills_dir() -> None:
"""Ensure skills directory exists."""
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
def _parse_frontmatter(content: str) -> Optional[dict[str, str]]:
"""Parse YAML frontmatter from markdown content."""
import re
match = re.match(r"^---\n(.*?)\n---\n(.*)$", content, re.DOTALL)
if not match:
return None
frontmatter = match.group(1)
body = match.group(2).strip()
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
if not name_match or not desc_match:
return None
return {
"name": name_match.group(1).strip(),
"description": desc_match.group(1).strip(),
"body": body,
}
def _get_skill_info(skill_dir: Path) -> Optional[dict[str, Any]]:
"""Get skill info from a skill directory."""
skill_path = skill_dir / "SKILL.md"
if not skill_path.exists():
return None
content = skill_path.read_text()
parsed = _parse_frontmatter(content)
if not parsed:
return None
# Try to read trajectory.json for additional info
steps = 0
created = None
trajectory_path = skill_dir / "trajectory" / "trajectory.json"
if trajectory_path.exists():
try:
traj_data = json.loads(trajectory_path.read_text())
steps = len(traj_data.get("trajectory", []))
if traj_data.get("metadata", {}).get("created_at"):
created = traj_data["metadata"]["created_at"]
except Exception:
pass
return {
"name": parsed["name"],
"description": parsed["description"],
"steps": steps,
"created": created,
"path": str(skill_dir),
}
def cmd_list(args: argparse.Namespace) -> int:
"""List all skills."""
_ensure_skills_dir()
skills = []
for skill_dir in sorted(SKILLS_DIR.iterdir()):
if not skill_dir.is_dir():
continue
info = _get_skill_info(skill_dir)
if info:
skills.append(info)
if args.json:
print_json(skills)
return 0
if not skills:
print_info("No skills found.")
print_info("Record a skill with: cua skills record --sandbox <name>")
return 0
# Format for table
rows = []
for skill in skills:
created = "-"
if skill["created"]:
try:
dt = datetime.fromisoformat(skill["created"].replace("Z", "+00:00"))
created = dt.strftime("%Y-%m-%d")
except Exception:
created = skill["created"][:10]
rows.append(
{
"name": skill["name"],
"description": skill["description"][:40]
+ ("..." if len(skill["description"]) > 40 else ""),
"steps": str(skill["steps"]),
"created": created,
}
)
columns = [
("name", "NAME"),
("description", "DESCRIPTION"),
("steps", "STEPS"),
("created", "CREATED"),
]
print_table(rows, columns)
return 0
def cmd_read(args: argparse.Namespace) -> int:
"""Read a skill."""
_ensure_skills_dir()
skill_dir = SKILLS_DIR / args.name
skill_path = skill_dir / "SKILL.md"
if not skill_path.exists():
print_error(f"Skill not found: {args.name}")
return 1
content = skill_path.read_text()
if args.format == "md":
print(content)
return 0
# JSON format - include trajectory data
parsed = _parse_frontmatter(content)
if not parsed:
print_error(f"Invalid skill file format: {args.name}")
return 1
trajectory_path = skill_dir / "trajectory" / "trajectory.json"
trajectory = []
metadata = {}
if trajectory_path.exists():
try:
traj_data = json.loads(trajectory_path.read_text())
trajectory = traj_data.get("trajectory", [])
metadata = traj_data.get("metadata", {})
except Exception as e:
print_error(f"Failed to read trajectory: {e}")
result = {
"name": parsed["name"],
"description": parsed["description"],
"trajectory": trajectory,
"skill_prompt": parsed["body"],
"trajectory_dir": str(skill_dir / "trajectory"),
"metadata": metadata,
}
print_json(result)
return 0
def cmd_replay(args: argparse.Namespace) -> int:
"""Open the video recording for a skill."""
_ensure_skills_dir()
skill_dir = SKILLS_DIR / args.name
if not skill_dir.exists():
print_error(f"Skill not found: {args.name}")
return 1
# Find MP4 file
trajectory_dir = skill_dir / "trajectory"
mp4_files = list(trajectory_dir.glob("*.mp4"))
if not mp4_files:
print_error(f"No video found in: {trajectory_dir}")
return 1
video_path = mp4_files[0]
print_info(f"Opening: {video_path}")
webbrowser.open(f"file://{video_path}")
return 0
def cmd_delete(args: argparse.Namespace) -> int:
"""Delete a skill."""
_ensure_skills_dir()
skill_dir = SKILLS_DIR / args.name
if not skill_dir.exists():
print_error(f"Skill not found: {args.name}")
return 1
shutil.rmtree(skill_dir)
print_success(f"Deleted skill: {args.name}")
return 0
def cmd_clean(args: argparse.Namespace) -> int:
"""Delete all skills with confirmation."""
_ensure_skills_dir()
skills = [d for d in SKILLS_DIR.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
if not skills:
print_info("No skills to clean.")
return 0
print_info("Skills to delete:")
for skill_dir in sorted(skills):
print(f" - {skill_dir.name}")
response = input(f"\nDelete {len(skills)} skill(s)? [y/N]: ").strip().lower()
if response != "y":
print_info("Cancelled.")
return 0
for skill_dir in skills:
shutil.rmtree(skill_dir)
print_success(f"Deleted {len(skills)} skill(s).")
return 0
def cmd_record(args: argparse.Namespace) -> int:
"""Record a demonstration and create a skill.
This is a complex operation that:
1. Starts a WebSocket server to receive the recording
2. Opens the VNC viewer with recording parameters
3. Waits for the recording to complete
4. Extracts frames and captions them with LLM
5. Saves the skill to disk
"""
# Check for required dependencies
if not _check_ffmpeg():
print_error("ffmpeg is required for skill recording.")
print_info("Install with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)")
return 1
if not args.sandbox and not args.vnc_url:
print_error("Either --sandbox or --vnc-url is required")
return 1
# Defer to async implementation
return run_async(_record_skill_async(args))
def _check_ffmpeg() -> bool:
"""Check if ffmpeg is available."""
return shutil.which("ffmpeg") is not None
async def _record_skill_async(args: argparse.Namespace) -> int:
"""Async implementation of skill recording."""
import asyncio
import os
import websockets
# Get LLM API key
provider = args.provider
api_key = args.api_key
if not api_key:
if provider == "openai":
api_key = os.environ.get("OPENAI_API_KEY")
else:
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
env_var = "OPENAI_API_KEY" if provider == "openai" else "ANTHROPIC_API_KEY"
print_error(f"No {provider.upper()} API key found.")
print_info(f"Set {env_var} environment variable or use --api-key flag.")
return 1
model = args.model
if not model:
model = "gpt-4o-mini" if provider == "openai" else "claude-haiku-4-5"
# Start WebSocket server to receive recording
recording_data = bytearray()
recording_complete = asyncio.Event()
async def handle_ws(websocket):
nonlocal recording_data
try:
async for message in websocket:
if isinstance(message, bytes):
recording_data.extend(message)
except websockets.exceptions.ConnectionClosed:
pass
finally:
recording_complete.set()
# Find available port
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("localhost", 0))
port = sock.getsockname()[1]
sock.close()
server = await websockets.serve(handle_ws, "localhost", port)
record_url = f"ws://localhost:{port}"
print_info(f"Recording server started on port {port}")
# Build VNC URL with recording parameters
if args.sandbox:
# Get sandbox VNC URL
from cua_cli.auth.store import require_api_key
from cua_sandbox.transport.cloud import cloud_get_vm
try:
vm = await cloud_get_vm(args.sandbox, api_key=require_api_key())
except Exception:
vm = None
if not vm or vm.get("status") == "not_found":
print_error(f"Sandbox not found: {args.sandbox}")
server.close()
return 1
if vm.get("status") != "running":
print_error(f"Sandbox is not running (status: {vm.get('status')})")
server.close()
return 1
host = vm.get("host", f"{args.sandbox}.sandbox.cua.ai")
password = vm.get("password", "")
from urllib.parse import quote
base_url = (
f"https://{host}/vnc.html?autoconnect=true&password={quote(password)}&show_dot=true"
)
else:
base_url = args.vnc_url
# Add recording parameters
from urllib.parse import parse_qs, urlencode, urlparse
parsed = urlparse(base_url)
params = parse_qs(parsed.query)
params["autorecord"] = ["true"]
params["record_format"] = ["mp4"]
params["record_url"] = [record_url]
recording_url = (
f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(params, doseq=True)}"
)
print_info("\nRecording will start automatically when you connect.")
print_info("When finished, click 'Stop Recording' in the VNC panel.\n")
import webbrowser
webbrowser.open(recording_url)
# Wait for recording (30 min timeout)
try:
await asyncio.wait_for(recording_complete.wait(), timeout=30 * 60)
except asyncio.TimeoutError:
print_error("Recording timeout (30 minutes)")
server.close()
return 1
server.close()
if len(recording_data) == 0:
print_error("No recording data received")
return 1
print_info(f"Received {len(recording_data)} bytes of recording data")
# Get skill name
skill_name = args.name
if not skill_name:
skill_name = input("Enter skill name: ").strip()
while not skill_name or not skill_name.replace("-", "").replace("_", "").isalnum():
print("Use only letters, numbers, hyphens, and underscores.")
skill_name = input("Enter skill name: ").strip()
# Ensure unique name
_ensure_skills_dir()
final_name = skill_name
counter = 1
while (SKILLS_DIR / final_name).exists():
final_name = f"{skill_name}-{counter}"
counter += 1
if final_name != skill_name:
print_info(f'Skill "{skill_name}" exists, using "{final_name}"')
skill_name = final_name
# Get description
description = args.description
if not description:
description = input("Describe what this skill demonstrates: ").strip()
while not description:
print("Description is required.")
description = input("Describe what this skill demonstrates: ").strip()
print_info("\nProcessing recording...")
# Process recording
result = await _process_recording(
recording_data=bytes(recording_data),
skill_name=skill_name,
description=description,
provider=provider,
model=model,
api_key=api_key,
)
if not result:
print_error("Failed to process recording")
return 1
print_success(f"\nSkill saved: {SKILLS_DIR / skill_name / 'SKILL.md'}")
print_info(f"Steps: {result['steps']}")
return 0
async def _process_recording(
recording_data: bytes,
skill_name: str,
description: str,
provider: str,
model: str,
api_key: str,
) -> Optional[dict[str, Any]]:
"""Process recording data and create skill files."""
import struct
import subprocess
import tempfile
# Parse recording format: [4 bytes JSON length][JSON][MP4 data]
if len(recording_data) < 4:
print_error("Recording data too short")
return None
json_length = struct.unpack(">I", recording_data[:4])[0]
if len(recording_data) < 4 + json_length:
print_error("Invalid recording format")
return None
json_bytes = recording_data[4 : 4 + json_length]
mp4_data = recording_data[4 + json_length :]
if not mp4_data:
print_error("No video data in recording")
return None
try:
recording_json = json.loads(json_bytes.decode())
except Exception as e:
print_error(f"Failed to parse recording JSON: {e}")
return None
events = recording_json.get("events", [])
metadata = recording_json.get("metadata", {})
# Create skill directory structure
skill_dir = SKILLS_DIR / skill_name
trajectory_dir = skill_dir / "trajectory"
trajectory_dir.mkdir(parents=True, exist_ok=True)
# Save video
video_path = trajectory_dir / f"{skill_name}.mp4"
video_path.write_bytes(mp4_data)
# Save events
events_path = trajectory_dir / "events.json"
events_path.write_text(json.dumps({"events": events, "metadata": metadata}, indent=2))
# Process each event with LLM captioning
trajectory = []
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
) as progress:
task = progress.add_task("Captioning steps...", total=len(events))
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
for idx, event in enumerate(events):
step_idx = idx + 1
# Extract frame at event timestamp
frame_path = temp_path / f"step_{step_idx}.jpg"
timestamp_sec = max(0, event.get("timestamp", 0) / 1000 - 0.1)
result = subprocess.run(
[
"ffmpeg",
"-y",
"-ss",
f"{timestamp_sec:.3f}",
"-i",
str(video_path),
"-frames:v",
"1",
"-q:v",
"2",
str(frame_path),
],
capture_output=True,
)
if result.returncode != 0 or not frame_path.exists():
# Skip if frame extraction fails
trajectory.append(
{
"step_idx": step_idx,
"caption": {
"observation": "",
"think": "",
"action": event.get("type", ""),
"expectation": "",
},
"raw_event": event,
}
)
progress.update(task, advance=1)
continue
# Caption with LLM
caption = await _caption_step(
frame_path=frame_path,
event=event,
step_idx=step_idx,
description=description,
provider=provider,
model=model,
api_key=api_key,
)
# Save screenshot to trajectory dir
dest_full = trajectory_dir / f"step_{step_idx}_full.jpg"
shutil.copy(frame_path, dest_full)
trajectory.append(
{
"step_idx": step_idx,
"caption": caption,
"raw_event": event,
"screenshot_full": str(dest_full),
}
)
progress.update(task, advance=1)
# Save trajectory
trajectory_json_path = trajectory_dir / "trajectory.json"
trajectory_json_path.write_text(
json.dumps(
{
"events": events,
"trajectory": trajectory,
"metadata": {
"task_description": description,
"total_steps": len(trajectory),
"width": metadata.get("width"),
"height": metadata.get("height"),
"duration": metadata.get("duration"),
"created_at": datetime.now().isoformat(),
},
},
indent=2,
)
)
# Generate skill markdown
steps_text = "\n".join(
[
f"Step {s['step_idx']}: {s['caption'].get('action', s['raw_event'].get('type', ''))}"
for s in trajectory
]
)
skill_prompt = f"""You have been shown a demonstration of how to perform this task:
{description}
The demonstration consisted of the following steps:
{steps_text}
Follow this workflow pattern, adapting as needed for the current screen state.
Total steps: {len(trajectory)}"""
steps_markdown = "\n".join(
[
f"### Step {s['step_idx']}: {s['caption'].get('action', s['raw_event'].get('type', ''))}\n\n"
f"**Context:** {s['caption'].get('observation', '')}\n\n"
f"**Intent:** {s['caption'].get('think', '')}\n\n"
f"**Expected Result:** {s['caption'].get('expectation', '')}\n"
for s in trajectory
]
)
skill_content = f"""---
name: {skill_name}
description: {description}
---
# {skill_name}
{description}
## Steps
{steps_markdown}
## Agent Prompt
{skill_prompt}
"""
skill_path = skill_dir / "SKILL.md"
skill_path.write_text(skill_content)
return {"steps": len(trajectory)}
async def _caption_step(
frame_path: Path,
event: dict,
step_idx: int,
description: str,
provider: str,
model: str,
api_key: str,
) -> dict[str, str]:
"""Caption a single step using LLM."""
import base64
import aiohttp
# Build prompt
prompt = f"""Describe this GUI action step. The overall task is: {description}
Step {step_idx}: {event.get("type", "action")}
Event data: {json.dumps(event.get("data", {}))}
Respond with JSON only:
{{
"Observation": "Describe what you see in the screenshot",
"Think": "Explain the user's likely intention",
"Action": "Describe the action being taken",
"Expectation": "What should happen after this action"
}}"""
# Read image
image_data = frame_path.read_bytes()
image_b64 = base64.b64encode(image_data).decode()
try:
if provider == "openai":
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
},
],
}
],
"temperature": 0.2,
},
) as resp:
if resp.status != 200:
return {
"observation": "",
"think": "",
"action": event.get("type", ""),
"expectation": "",
}
data = await resp.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
else:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": model,
"max_tokens": 1200,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": image_b64,
},
},
],
}
],
},
) as resp:
if resp.status != 200:
return {
"observation": "",
"think": "",
"action": event.get("type", ""),
"expectation": "",
}
data = await resp.json()
text = data.get("content", [{}])[0].get("text", "")
# Parse JSON response
import re
json_match = re.search(r"\{[\s\S]*\}", text)
if json_match:
parsed = json.loads(json_match.group())
return {
"observation": parsed.get("Observation", parsed.get("observation", "")),
"think": parsed.get("Think", parsed.get("think", "")),
"action": parsed.get("Action", parsed.get("action", "")),
"expectation": parsed.get("Expectation", parsed.get("expectation", "")),
}
except Exception:
pass
return {"observation": "", "think": "", "action": event.get("type", ""), "expectation": ""}