diff --git a/.cursorignore b/.cursorignore index 12e8e403..57a5589d 100644 --- a/.cursorignore +++ b/.cursorignore @@ -154,7 +154,7 @@ weights/icon_detect/model.pt weights/icon_detect/model.pt.zip weights/icon_detect/model.pt.zip.part* -libs/omniparser/weights/icon_detect/model.pt +libs/python/omniparser/weights/icon_detect/model.pt # Example test data and output examples/test_data/ diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 00000000..cabc2356 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,66 @@ +# Dev Container Setup + +This repository includes a Dev Container configuration that simplifies the development setup to just 3 steps: + +## Quick Start + +![Clipboard-20250611-180809-459](https://github.com/user-attachments/assets/447eaeeb-0eec-4354-9a82-44446e202e06) + +1. **Install the Dev Containers extension ([VS Code](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) or [WindSurf](https://docs.windsurf.com/windsurf/advanced#dev-containers-beta))** +2. **Open the repository in the Dev Container:** + - Press `Ctrl+Shift+P` (or `⌘+Shift+P` on macOS) + - Select `Dev Containers: Clone Repository in Container Volume...` and paste the repository URL: `https://github.com/trycua/cua.git` (if not cloned) or `Dev Containers: Open Folder in Container...` (if git cloned). + > **Note**: On WindSurf, the post install hook might not run automatically. If so, run `/bin/bash .devcontainer/post-install.sh` manually. +3. **Open the VS Code workspace:** Once the post-install.sh is done running, open the `.vscode/py.code-workspace` workspace and press ![Open Workspace](https://github.com/user-attachments/assets/923bdd43-8c8f-4060-8d78-75bfa302b48c) +. +4. **Run the Agent UI example:** Click ![Run Agent UI](https://github.com/user-attachments/assets/7a61ef34-4b22-4dab-9864-f86bf83e290b) + to start the Gradio UI. If prompted to install **debugpy (Python Debugger)** to enable remote debugging, select 'Yes' to proceed. +5. **Access the Gradio UI:** The Gradio UI will be available at `http://localhost:7860` and will automatically forward to your host machine. + +## What's Included + +The dev container automatically: + +- ✅ Sets up Python 3.11 environment +- ✅ Installs all system dependencies (build tools, OpenGL, etc.) +- ✅ Configures Python paths for all packages +- ✅ Installs Python extensions (Black, Ruff, Pylance) +- ✅ Forwards port 7860 for the Gradio web UI +- ✅ Mounts your source code for live editing +- ✅ Creates the required `.env.local` file + +## Running Examples + +After the container is built, you can run examples directly: + +```bash +# Run the agent UI (Gradio web interface) +python examples/agent_ui_examples.py + +# Run computer examples +python examples/computer_examples.py + +# Run computer UI examples +python examples/computer_ui_examples.py +``` + +The Gradio UI will be available at `http://localhost:7860` and will automatically forward to your host machine. + +## Environment Variables + +You'll need to add your API keys to `.env.local`: + +```bash +# Required for Anthropic provider +ANTHROPIC_API_KEY=your_anthropic_key_here + +# Required for OpenAI provider +OPENAI_API_KEY=your_openai_key_here +``` + +## Notes + +- The container connects to `host.docker.internal:7777` for Lume server communication +- All Python packages are pre-installed and configured +- Source code changes are reflected immediately (no rebuild needed) +- The container uses the same Dockerfile as the regular Docker development environment diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..aa2b82a6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "C/ua - OSS", + "build": { + "dockerfile": "../Dockerfile" + }, + "containerEnv": { + "DISPLAY": "", + "PYLUME_HOST": "host.docker.internal" + }, + "forwardPorts": [7860], + "portsAttributes": { + "7860": { + "label": "C/ua web client (Gradio)", + "onAutoForward": "silent" + } + }, + "postCreateCommand": "/bin/bash .devcontainer/post-install.sh" +} diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh new file mode 100755 index 00000000..1738e635 --- /dev/null +++ b/.devcontainer/post-install.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +WORKSPACE="/workspaces/cua" + +# Setup .env.local +echo "PYTHON_BIN=python" > /workspaces/cua/.env.local + +# Run /scripts/build.sh +./scripts/build.sh + +# --- +# Build is complete. Show user a clear message to open the workspace manually. +# --- + +cat << 'EOM' + +============================================ + 🚀 Build complete! + + 👉 Next steps: + + 1. Open '.vscode/py.code-workspace' + 2. Press 'Open Workspace' + + Happy coding! +============================================ + +EOM diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3852dc32 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf \ No newline at end of file diff --git a/.github/workflows/npm-publish-computer.yml b/.github/workflows/npm-publish-computer.yml new file mode 100644 index 00000000..0dcd26fc --- /dev/null +++ b/.github/workflows/npm-publish-computer.yml @@ -0,0 +1,50 @@ +name: Publish @trycua/computer to npm + +on: + push: + branches: main + +jobs: + publish: + permissions: + id-token: write + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: "24.x" + registry-url: "https://registry.npmjs.org" + + - name: Setup pnpm 10 + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Check if version changed + id: check-version + uses: EndBug/version-check@v2 + with: + file-name: libs/typescript/computer/package.json + diff-search: true + + - name: Install dependencies + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/computer + run: pnpm install --frozen-lockfile + + - name: Build package + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/computer + run: pnpm run build --if-present + + - name: Publish to npm + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/computer + run: pnpm publish --access public --no-git-checks + env: + NPM_CONFIG_PROVENANCE: true + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/npm-publish-core.yml b/.github/workflows/npm-publish-core.yml new file mode 100644 index 00000000..b5f6745d --- /dev/null +++ b/.github/workflows/npm-publish-core.yml @@ -0,0 +1,50 @@ +name: Publish @trycua/core to npm + +on: + push: + branches: main + +jobs: + publish: + permissions: + id-token: write + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: "24.x" + registry-url: "https://registry.npmjs.org" + + - name: Setup pnpm 10 + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Check if version changed + id: check-version + uses: EndBug/version-check@v2 + with: + file-name: libs/typescript/core/package.json + diff-search: true + + - name: Install dependencies + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/core + run: pnpm install --frozen-lockfile + + - name: Build package + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/core + run: pnpm run build --if-present + + - name: Publish to npm + if: steps.check-version.outputs.changed == 'true' + working-directory: ./libs/typescript/core + run: pnpm publish --access public --no-git-checks + env: + NPM_CONFIG_PROVENANCE: true + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-agent.yml b/.github/workflows/publish-agent.yml deleted file mode 100644 index ea03edd6..00000000 --- a/.github/workflows/publish-agent.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: Publish Agent Package - -on: - push: - tags: - - 'agent-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - prepare: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - computer_version: ${{ steps.update-deps.outputs.computer_version }} - som_version: ${{ steps.update-deps.outputs.som_version }} - core_version: ${{ steps.update-deps.outputs.core_version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/agent-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for agent" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Update dependencies to latest versions - id: update-deps - run: | - cd libs/agent - - # Install required package for PyPI API access - pip install requests - - # Create a more robust Python script for PyPI version checking - cat > get_latest_versions.py << 'EOF' - import requests - import json - import sys - - def get_package_version(package_name, fallback="0.1.0"): - try: - response = requests.get(f'https://pypi.org/pypi/{package_name}/json') - print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) - - if response.status_code != 200: - print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) - return fallback - - data = json.loads(response.text) - - if 'info' not in data: - print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) - return fallback - - return data['info']['version'] - except Exception as e: - print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) - return fallback - - # Get latest versions - print(get_package_version('cua-computer')) - print(get_package_version('cua-som')) - print(get_package_version('cua-core')) - EOF - - # Execute the script to get the versions - VERSIONS=($(python get_latest_versions.py)) - LATEST_COMPUTER=${VERSIONS[0]} - LATEST_SOM=${VERSIONS[1]} - LATEST_CORE=${VERSIONS[2]} - - echo "Latest cua-computer version: $LATEST_COMPUTER" - echo "Latest cua-som version: $LATEST_SOM" - echo "Latest cua-core version: $LATEST_CORE" - - # Output the versions for the next job - echo "computer_version=$LATEST_COMPUTER" >> $GITHUB_OUTPUT - echo "som_version=$LATEST_SOM" >> $GITHUB_OUTPUT - echo "core_version=$LATEST_CORE" >> $GITHUB_OUTPUT - - # Determine major version for version constraint - COMPUTER_MAJOR=$(echo $LATEST_COMPUTER | cut -d. -f1) - SOM_MAJOR=$(echo $LATEST_SOM | cut -d. -f1) - CORE_MAJOR=$(echo $LATEST_CORE | cut -d. -f1) - - NEXT_COMPUTER_MAJOR=$((COMPUTER_MAJOR + 1)) - NEXT_SOM_MAJOR=$((SOM_MAJOR + 1)) - NEXT_CORE_MAJOR=$((CORE_MAJOR + 1)) - - # Update dependencies in pyproject.toml - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version of sed needs an empty string for -i - sed -i '' "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml - sed -i '' "s/\"cua-som>=.*,<.*\"/\"cua-som>=$LATEST_SOM,<$NEXT_SOM_MAJOR.0.0\"/" pyproject.toml - sed -i '' "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml - else - # Linux version - sed -i "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml - sed -i "s/\"cua-som>=.*,<.*\"/\"cua-som>=$LATEST_SOM,<$NEXT_SOM_MAJOR.0.0\"/" pyproject.toml - sed -i "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml - fi - - # Display the updated dependencies - echo "Updated dependencies in pyproject.toml:" - grep -E "cua-computer|cua-som|cua-core" pyproject.toml - - publish: - needs: prepare - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "agent" - package_dir: "libs/agent" - version: ${{ needs.prepare.outputs.version }} - is_lume_package: false - base_package_name: "cua-agent" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - - set-env-variables: - needs: [prepare, publish] - runs-on: macos-latest - steps: - - name: Set environment variables for use in other jobs - run: | - echo "COMPUTER_VERSION=${{ needs.prepare.outputs.computer_version }}" >> $GITHUB_ENV - echo "SOM_VERSION=${{ needs.prepare.outputs.som_version }}" >> $GITHUB_ENV - echo "CORE_VERSION=${{ needs.prepare.outputs.core_version }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/publish-computer-server.yml b/.github/workflows/publish-computer-server.yml deleted file mode 100644 index 15eca348..00000000 --- a/.github/workflows/publish-computer-server.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Publish Computer Server Package - -on: - push: - tags: - - 'computer-server-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - outputs: - version: - description: "The version that was published" - value: ${{ jobs.prepare.outputs.version }} - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - prepare: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/computer-server-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for computer-server" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - publish: - needs: prepare - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "computer-server" - package_dir: "libs/computer-server" - version: ${{ needs.prepare.outputs.version }} - is_lume_package: false - base_package_name: "cua-computer-server" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - - set-env-variables: - needs: [prepare, publish] - runs-on: macos-latest - steps: - - name: Set environment variables for use in other jobs - run: | - echo "COMPUTER_VERSION=${{ needs.prepare.outputs.version }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/publish-computer.yml b/.github/workflows/publish-computer.yml deleted file mode 100644 index b2a9fb25..00000000 --- a/.github/workflows/publish-computer.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: Publish Computer Package - -on: - push: - tags: - - 'computer-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - prepare: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - core_version: ${{ steps.update-deps.outputs.core_version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/computer-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for computer" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Update dependencies to latest versions - id: update-deps - run: | - cd libs/computer - # Install required package for PyPI API access - pip install requests - - # Create a more robust Python script for PyPI version checking - cat > get_latest_versions.py << 'EOF' - import requests - import json - import sys - - def get_package_version(package_name, fallback="0.1.0"): - try: - response = requests.get(f'https://pypi.org/pypi/{package_name}/json') - print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) - - if response.status_code != 200: - print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) - return fallback - - data = json.loads(response.text) - - if 'info' not in data: - print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) - return fallback - - return data['info']['version'] - except Exception as e: - print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) - return fallback - - # Get latest versions - print(get_package_version('cua-core')) - EOF - - # Execute the script to get the versions - VERSIONS=($(python get_latest_versions.py)) - LATEST_CORE=${VERSIONS[0]} - - echo "Latest cua-core version: $LATEST_CORE" - - # Output the versions for the next job - echo "core_version=$LATEST_CORE" >> $GITHUB_OUTPUT - - # Determine major version for version constraint - CORE_MAJOR=$(echo $LATEST_CORE | cut -d. -f1) - NEXT_CORE_MAJOR=$((CORE_MAJOR + 1)) - - # Update dependencies in pyproject.toml - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version of sed needs an empty string for -i - sed -i '' "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml - else - # Linux version - sed -i "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml - fi - - # Display the updated dependencies - echo "Updated dependencies in pyproject.toml:" - grep -E "cua-core" pyproject.toml - - publish: - needs: prepare - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "computer" - package_dir: "libs/computer" - version: ${{ needs.prepare.outputs.version }} - is_lume_package: false - base_package_name: "cua-computer" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - - set-env-variables: - needs: [prepare, publish] - runs-on: macos-latest - steps: - - name: Set environment variables for use in other jobs - run: | - echo "CORE_VERSION=${{ needs.prepare.outputs.core_version }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/publish-core.yml b/.github/workflows/publish-core.yml deleted file mode 100644 index 4f868f26..00000000 --- a/.github/workflows/publish-core.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Publish Core Package - -on: - push: - tags: - - 'core-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - prepare: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/core-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for core" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - publish: - needs: prepare - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "core" - package_dir: "libs/core" - version: ${{ needs.prepare.outputs.version }} - is_lume_package: false - base_package_name: "cua-core" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/publish-lume.yml b/.github/workflows/publish-lume.yml index 3b2311ee..b2ea3c4b 100644 --- a/.github/workflows/publish-lume.yml +++ b/.github/workflows/publish-lume.yml @@ -3,17 +3,17 @@ name: Publish Notarized Lume on: push: tags: - - 'lume-v*' + - "lume-v*" workflow_dispatch: inputs: version: - description: 'Version to notarize (without v prefix)' + description: "Version to notarize (without v prefix)" required: true - default: '0.1.0' + default: "0.1.0" workflow_call: inputs: version: - description: 'Version to notarize' + description: "Version to notarize" required: true type: string secrets: @@ -64,7 +64,7 @@ jobs: - name: Create .release directory run: mkdir -p .release - + - name: Set version id: set_version run: | @@ -82,11 +82,11 @@ jobs: echo "Error: No version found in tag or input" exit 1 fi - + # Update version in Main.swift echo "Updating version in Main.swift to $VERSION" sed -i '' "s/static let current: String = \".*\"/static let current: String = \"$VERSION\"/" libs/lume/src/Main.swift - + # Set output for later steps echo "version=$VERSION" >> $GITHUB_OUTPUT @@ -106,18 +106,34 @@ jobs: # Import certificates echo $APPLICATION_CERT_BASE64 | base64 --decode > application.p12 echo $INSTALLER_CERT_BASE64 | base64 --decode > installer.p12 - + # Import certificates silently (minimize output) security import application.p12 -k build.keychain -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/pkgbuild > /dev/null 2>&1 security import installer.p12 -k build.keychain -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/pkgbuild > /dev/null 2>&1 - + # Allow codesign to access the certificates (minimal output) security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null 2>&1 - - # Verify certificates were imported but only show count, not details - echo "Verifying signing identity (showing count only)..." - security find-identity -v -p codesigning | grep -c "valid identities found" || true - + + # Verify certificates were imported + echo "Verifying signing identities..." + CERT_COUNT=$(security find-identity -v -p codesigning build.keychain | grep -c "Developer ID Application" || echo "0") + INSTALLER_COUNT=$(security find-identity -v build.keychain | grep -c "Developer ID Installer" || echo "0") + + if [ "$CERT_COUNT" -eq 0 ]; then + echo "Error: No Developer ID Application certificate found" + security find-identity -v -p codesigning build.keychain + exit 1 + fi + + if [ "$INSTALLER_COUNT" -eq 0 ]; then + echo "Error: No Developer ID Installer certificate found" + security find-identity -v build.keychain + exit 1 + fi + + echo "Found $CERT_COUNT Developer ID Application certificate(s) and $INSTALLER_COUNT Developer ID Installer certificate(s)" + echo "All required certificates verified successfully" + # Clean up certificate files rm application.p12 installer.p12 @@ -137,32 +153,32 @@ jobs: echo "Starting build process..." echo "Swift version: $(swift --version | head -n 1)" echo "Building version: $VERSION" - + # Ensure .release directory exists mkdir -p .release chmod 755 .release - + # Build the project first (redirect verbose output) echo "Building project..." swift build --configuration release > build.log 2>&1 echo "Build completed." - + # Run the notarization script with LOG_LEVEL env var chmod +x scripts/build/build-release-notarized.sh cd scripts/build LOG_LEVEL=minimal ./build-release-notarized.sh - + # Return to the lume directory cd ../.. - + # Debug: List what files were actually created echo "Files in .release directory:" find .release -type f -name "*.tar.gz" -o -name "*.pkg.tar.gz" - + # Get architecture for output filename ARCH=$(uname -m) OS_IDENTIFIER="darwin-${ARCH}" - + # Output paths for later use echo "tarball_path=.release/lume-${VERSION}-${OS_IDENTIFIER}.tar.gz" >> $GITHUB_OUTPUT echo "pkg_path=.release/lume-${VERSION}-${OS_IDENTIFIER}.pkg.tar.gz" >> $GITHUB_OUTPUT @@ -181,12 +197,12 @@ jobs: shasum -a 256 lume-*.tar.gz >> checksums.txt echo '```' >> checksums.txt fi - + checksums=$(cat checksums.txt) echo "checksums<> $GITHUB_OUTPUT echo "$checksums" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - + # Debug: Show all files in the release directory echo "All files in release directory:" ls -la @@ -197,15 +213,15 @@ jobs: VERSION=${{ steps.set_version.outputs.version }} ARCH=$(uname -m) OS_IDENTIFIER="darwin-${ARCH}" - + # Create OS-tagged symlinks ln -sf "lume-${VERSION}-${OS_IDENTIFIER}.tar.gz" "lume-darwin.tar.gz" ln -sf "lume-${VERSION}-${OS_IDENTIFIER}.pkg.tar.gz" "lume-darwin.pkg.tar.gz" - + # Create simple symlinks ln -sf "lume-${VERSION}-${OS_IDENTIFIER}.tar.gz" "lume.tar.gz" ln -sf "lume-${VERSION}-${OS_IDENTIFIER}.pkg.tar.gz" "lume.pkg.tar.gz" - + # List all files (including symlinks) echo "Files with symlinks in release directory:" ls -la @@ -237,10 +253,10 @@ jobs: ./libs/lume/.release/lume.pkg.tar.gz body: | ${{ steps.generate_checksums.outputs.checksums }} - + ### Installation with script /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" ``` generate_release_notes: true - make_latest: true \ No newline at end of file + make_latest: true diff --git a/.github/workflows/publish-mcp-server.yml b/.github/workflows/publish-mcp-server.yml deleted file mode 100644 index e6eccd5a..00000000 --- a/.github/workflows/publish-mcp-server.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Publish MCP Server Package - -on: - push: - tags: - - 'mcp-server-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - outputs: - version: - description: "The version that was published" - value: ${{ jobs.prepare.outputs.version }} - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - prepare: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - agent_version: ${{ steps.update-deps.outputs.agent_version }} - computer_version: ${{ steps.update-deps.outputs.computer_version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/mcp-server-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for mcp-server" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Update dependencies to latest versions - id: update-deps - run: | - cd libs/mcp-server - - # Install required package for PyPI API access - pip install requests - - # Create a Python script for PyPI version checking - cat > get_latest_versions.py << 'EOF' - import requests - import json - import sys - - def get_package_version(package_name, fallback="0.1.0"): - try: - response = requests.get(f'https://pypi.org/pypi/{package_name}/json') - print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) - - if response.status_code != 200: - print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) - return fallback - - data = json.loads(response.text) - - if 'info' not in data: - print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) - return fallback - - return data['info']['version'] - except Exception as e: - print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) - return fallback - - # Get latest versions - print(get_package_version('cua-agent')) - print(get_package_version('cua-computer')) - EOF - - # Execute the script to get the versions - VERSIONS=($(python get_latest_versions.py)) - LATEST_AGENT=${VERSIONS[0]} - LATEST_COMPUTER=${VERSIONS[1]} - - echo "Latest cua-agent version: $LATEST_AGENT" - echo "Latest cua-computer version: $LATEST_COMPUTER" - - # Output the versions for the next job - echo "agent_version=$LATEST_AGENT" >> $GITHUB_OUTPUT - echo "computer_version=$LATEST_COMPUTER" >> $GITHUB_OUTPUT - - # Determine major version for version constraint - AGENT_MAJOR=$(echo $LATEST_AGENT | cut -d. -f1) - COMPUTER_MAJOR=$(echo $LATEST_COMPUTER | cut -d. -f1) - - NEXT_AGENT_MAJOR=$((AGENT_MAJOR + 1)) - NEXT_COMPUTER_MAJOR=$((COMPUTER_MAJOR + 1)) - - # Update dependencies in pyproject.toml - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version of sed needs an empty string for -i - # Update cua-agent with all extras - sed -i '' "s/\"cua-agent\[all\]>=.*,<.*\"/\"cua-agent[all]>=$LATEST_AGENT,<$NEXT_AGENT_MAJOR.0.0\"/" pyproject.toml - sed -i '' "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml - else - # Linux version - sed -i "s/\"cua-agent\[all\]>=.*,<.*\"/\"cua-agent[all]>=$LATEST_AGENT,<$NEXT_AGENT_MAJOR.0.0\"/" pyproject.toml - sed -i "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml - fi - - # Display the updated dependencies - echo "Updated dependencies in pyproject.toml:" - grep -E "cua-agent|cua-computer" pyproject.toml - - publish: - needs: prepare - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "mcp-server" - package_dir: "libs/mcp-server" - version: ${{ needs.prepare.outputs.version }} - is_lume_package: false - base_package_name: "cua-mcp-server" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - - set-env-variables: - needs: [prepare, publish] - runs-on: macos-latest - steps: - - name: Set environment variables for use in other jobs - run: | - echo "AGENT_VERSION=${{ needs.prepare.outputs.agent_version }}" >> $GITHUB_ENV - echo "COMPUTER_VERSION=${{ needs.prepare.outputs.computer_version }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/publish-pylume.yml b/.github/workflows/publish-pylume.yml deleted file mode 100644 index c5bd4f6f..00000000 --- a/.github/workflows/publish-pylume.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Publish Pylume Package - -on: - push: - tags: - - 'pylume-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - outputs: - version: - description: "The version that was published" - value: ${{ jobs.determine-version.outputs.version }} - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - determine-version: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/pylume-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for pylume" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - validate-version: - runs-on: macos-latest - needs: determine-version - steps: - - uses: actions/checkout@v4 - - name: Validate version - id: validate-version - run: | - CODE_VERSION=$(grep '__version__' libs/pylume/pylume/__init__.py | cut -d'"' -f2) - if [ "${{ needs.determine-version.outputs.version }}" != "$CODE_VERSION" ]; then - echo "Version mismatch: expected $CODE_VERSION, got ${{ needs.determine-version.outputs.version }}" - exit 1 - fi - echo "Version validated: $CODE_VERSION" - - publish: - needs: determine-version - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "pylume" - package_dir: "libs/pylume" - version: ${{ needs.determine-version.outputs.version }} - is_lume_package: true - base_package_name: "pylume" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/publish-som.yml b/.github/workflows/publish-som.yml deleted file mode 100644 index b1d53ac8..00000000 --- a/.github/workflows/publish-som.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Publish SOM Package - -on: - push: - tags: - - 'som-v*' - workflow_dispatch: - inputs: - version: - description: 'Version to publish (without v prefix)' - required: true - default: '0.1.0' - workflow_call: - inputs: - version: - description: 'Version to publish' - required: true - type: string - outputs: - version: - description: "The version that was published" - value: ${{ jobs.determine-version.outputs.version }} - -# Adding permissions at workflow level -permissions: - contents: write - -jobs: - determine-version: - runs-on: macos-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - steps: - - uses: actions/checkout@v4 - - - name: Determine version - id: get-version - run: | - if [ "${{ github.event_name }}" == "push" ]; then - # Extract version from tag (for package-specific tags) - if [[ "${{ github.ref }}" =~ ^refs/tags/som-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then - VERSION=${BASH_REMATCH[1]} - else - echo "Invalid tag format for som" - exit 1 - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # Use version from workflow dispatch - VERSION=${{ github.event.inputs.version }} - else - # Use version from workflow_call - VERSION=${{ inputs.version }} - fi - echo "VERSION=$VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - publish: - needs: determine-version - uses: ./.github/workflows/reusable-publish.yml - with: - package_name: "som" - package_dir: "libs/som" - version: ${{ needs.determine-version.outputs.version }} - is_lume_package: false - base_package_name: "cua-som" - secrets: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pypi-publish-agent.yml b/.github/workflows/pypi-publish-agent.yml new file mode 100644 index 00000000..c36c1c1b --- /dev/null +++ b/.github/workflows/pypi-publish-agent.yml @@ -0,0 +1,162 @@ +name: Publish Agent Package + +on: + push: + tags: + - "agent-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + prepare: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + computer_version: ${{ steps.update-deps.outputs.computer_version }} + som_version: ${{ steps.update-deps.outputs.som_version }} + core_version: ${{ steps.update-deps.outputs.core_version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/agent-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for agent" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Update dependencies to latest versions + id: update-deps + run: | + cd libs/python/agent + + # Install required package for PyPI API access + pip install requests + + # Create a more robust Python script for PyPI version checking + cat > get_latest_versions.py << 'EOF' + import requests + import json + import sys + + def get_package_version(package_name, fallback="0.1.0"): + try: + response = requests.get(f'https://pypi.org/pypi/{package_name}/json') + print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) + + if response.status_code != 200: + print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) + return fallback + + data = json.loads(response.text) + + if 'info' not in data: + print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) + return fallback + + return data['info']['version'] + except Exception as e: + print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) + return fallback + + # Get latest versions + print(get_package_version('cua-computer')) + print(get_package_version('cua-som')) + print(get_package_version('cua-core')) + EOF + + # Execute the script to get the versions + VERSIONS=($(python get_latest_versions.py)) + LATEST_COMPUTER=${VERSIONS[0]} + LATEST_SOM=${VERSIONS[1]} + LATEST_CORE=${VERSIONS[2]} + + echo "Latest cua-computer version: $LATEST_COMPUTER" + echo "Latest cua-som version: $LATEST_SOM" + echo "Latest cua-core version: $LATEST_CORE" + + # Output the versions for the next job + echo "computer_version=$LATEST_COMPUTER" >> $GITHUB_OUTPUT + echo "som_version=$LATEST_SOM" >> $GITHUB_OUTPUT + echo "core_version=$LATEST_CORE" >> $GITHUB_OUTPUT + + # Determine major version for version constraint + COMPUTER_MAJOR=$(echo $LATEST_COMPUTER | cut -d. -f1) + SOM_MAJOR=$(echo $LATEST_SOM | cut -d. -f1) + CORE_MAJOR=$(echo $LATEST_CORE | cut -d. -f1) + + NEXT_COMPUTER_MAJOR=$((COMPUTER_MAJOR + 1)) + NEXT_SOM_MAJOR=$((SOM_MAJOR + 1)) + NEXT_CORE_MAJOR=$((CORE_MAJOR + 1)) + + # Update dependencies in pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version of sed needs an empty string for -i + sed -i '' "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml + sed -i '' "s/\"cua-som>=.*,<.*\"/\"cua-som>=$LATEST_SOM,<$NEXT_SOM_MAJOR.0.0\"/" pyproject.toml + sed -i '' "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml + else + # Linux version + sed -i "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml + sed -i "s/\"cua-som>=.*,<.*\"/\"cua-som>=$LATEST_SOM,<$NEXT_SOM_MAJOR.0.0\"/" pyproject.toml + sed -i "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml + fi + + # Display the updated dependencies + echo "Updated dependencies in pyproject.toml:" + grep -E "cua-computer|cua-som|cua-core" pyproject.toml + + publish: + needs: prepare + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "agent" + package_dir: "libs/python/agent" + version: ${{ needs.prepare.outputs.version }} + is_lume_package: false + base_package_name: "cua-agent" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + set-env-variables: + needs: [prepare, publish] + runs-on: macos-latest + steps: + - name: Set environment variables for use in other jobs + run: | + echo "COMPUTER_VERSION=${{ needs.prepare.outputs.computer_version }}" >> $GITHUB_ENV + echo "SOM_VERSION=${{ needs.prepare.outputs.som_version }}" >> $GITHUB_ENV + echo "CORE_VERSION=${{ needs.prepare.outputs.core_version }}" >> $GITHUB_ENV diff --git a/.github/workflows/pypi-publish-computer-server.yml b/.github/workflows/pypi-publish-computer-server.yml new file mode 100644 index 00000000..899cc605 --- /dev/null +++ b/.github/workflows/pypi-publish-computer-server.yml @@ -0,0 +1,80 @@ +name: Publish Computer Server Package + +on: + push: + tags: + - "computer-server-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + outputs: + version: + description: "The version that was published" + value: ${{ jobs.prepare.outputs.version }} + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + prepare: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/computer-server-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for computer-server" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + publish: + needs: prepare + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "computer-server" + package_dir: "libs/python/computer-server" + version: ${{ needs.prepare.outputs.version }} + is_lume_package: false + base_package_name: "cua-computer-server" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + set-env-variables: + needs: [prepare, publish] + runs-on: macos-latest + steps: + - name: Set environment variables for use in other jobs + run: | + echo "COMPUTER_VERSION=${{ needs.prepare.outputs.version }}" >> $GITHUB_ENV diff --git a/.github/workflows/pypi-publish-computer.yml b/.github/workflows/pypi-publish-computer.yml new file mode 100644 index 00000000..ea5d644b --- /dev/null +++ b/.github/workflows/pypi-publish-computer.yml @@ -0,0 +1,140 @@ +name: Publish Computer Package + +on: + push: + tags: + - "computer-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + prepare: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + core_version: ${{ steps.update-deps.outputs.core_version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/computer-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for computer" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Update dependencies to latest versions + id: update-deps + run: | + cd libs/python/computer + # Install required package for PyPI API access + pip install requests + + # Create a more robust Python script for PyPI version checking + cat > get_latest_versions.py << 'EOF' + import requests + import json + import sys + + def get_package_version(package_name, fallback="0.1.0"): + try: + response = requests.get(f'https://pypi.org/pypi/{package_name}/json') + print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) + + if response.status_code != 200: + print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) + return fallback + + data = json.loads(response.text) + + if 'info' not in data: + print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) + return fallback + + return data['info']['version'] + except Exception as e: + print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) + return fallback + + # Get latest versions + print(get_package_version('cua-core')) + EOF + + # Execute the script to get the versions + VERSIONS=($(python get_latest_versions.py)) + LATEST_CORE=${VERSIONS[0]} + + echo "Latest cua-core version: $LATEST_CORE" + + # Output the versions for the next job + echo "core_version=$LATEST_CORE" >> $GITHUB_OUTPUT + + # Determine major version for version constraint + CORE_MAJOR=$(echo $LATEST_CORE | cut -d. -f1) + NEXT_CORE_MAJOR=$((CORE_MAJOR + 1)) + + # Update dependencies in pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version of sed needs an empty string for -i + sed -i '' "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml + else + # Linux version + sed -i "s/\"cua-core>=.*,<.*\"/\"cua-core>=$LATEST_CORE,<$NEXT_CORE_MAJOR.0.0\"/" pyproject.toml + fi + + # Display the updated dependencies + echo "Updated dependencies in pyproject.toml:" + grep -E "cua-core" pyproject.toml + + publish: + needs: prepare + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "computer" + package_dir: "libs/python/computer" + version: ${{ needs.prepare.outputs.version }} + is_lume_package: false + base_package_name: "cua-computer" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + set-env-variables: + needs: [prepare, publish] + runs-on: macos-latest + steps: + - name: Set environment variables for use in other jobs + run: | + echo "CORE_VERSION=${{ needs.prepare.outputs.core_version }}" >> $GITHUB_ENV diff --git a/.github/workflows/pypi-publish-core.yml b/.github/workflows/pypi-publish-core.yml new file mode 100644 index 00000000..a5b993f6 --- /dev/null +++ b/.github/workflows/pypi-publish-core.yml @@ -0,0 +1,63 @@ +name: Publish Core Package + +on: + push: + tags: + - "core-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + prepare: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/core-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for core" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + publish: + needs: prepare + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "core" + package_dir: "libs/python/core" + version: ${{ needs.prepare.outputs.version }} + is_lume_package: false + base_package_name: "cua-core" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/pypi-publish-mcp-server.yml b/.github/workflows/pypi-publish-mcp-server.yml new file mode 100644 index 00000000..cd1b0c2f --- /dev/null +++ b/.github/workflows/pypi-publish-mcp-server.yml @@ -0,0 +1,157 @@ +name: Publish MCP Server Package + +on: + push: + tags: + - "mcp-server-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + outputs: + version: + description: "The version that was published" + value: ${{ jobs.prepare.outputs.version }} + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + prepare: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + agent_version: ${{ steps.update-deps.outputs.agent_version }} + computer_version: ${{ steps.update-deps.outputs.computer_version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/mcp-server-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for mcp-server" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Update dependencies to latest versions + id: update-deps + run: | + cd libs/python/mcp-server + + # Install required package for PyPI API access + pip install requests + + # Create a Python script for PyPI version checking + cat > get_latest_versions.py << 'EOF' + import requests + import json + import sys + + def get_package_version(package_name, fallback="0.1.0"): + try: + response = requests.get(f'https://pypi.org/pypi/{package_name}/json') + print(f"API Response Status for {package_name}: {response.status_code}", file=sys.stderr) + + if response.status_code != 200: + print(f"API request failed for {package_name}, using fallback version", file=sys.stderr) + return fallback + + data = json.loads(response.text) + + if 'info' not in data: + print(f"Missing 'info' key in API response for {package_name}, using fallback version", file=sys.stderr) + return fallback + + return data['info']['version'] + except Exception as e: + print(f"Error fetching version for {package_name}: {str(e)}", file=sys.stderr) + return fallback + + # Get latest versions + print(get_package_version('cua-agent')) + print(get_package_version('cua-computer')) + EOF + + # Execute the script to get the versions + VERSIONS=($(python get_latest_versions.py)) + LATEST_AGENT=${VERSIONS[0]} + LATEST_COMPUTER=${VERSIONS[1]} + + echo "Latest cua-agent version: $LATEST_AGENT" + echo "Latest cua-computer version: $LATEST_COMPUTER" + + # Output the versions for the next job + echo "agent_version=$LATEST_AGENT" >> $GITHUB_OUTPUT + echo "computer_version=$LATEST_COMPUTER" >> $GITHUB_OUTPUT + + # Determine major version for version constraint + AGENT_MAJOR=$(echo $LATEST_AGENT | cut -d. -f1) + COMPUTER_MAJOR=$(echo $LATEST_COMPUTER | cut -d. -f1) + + NEXT_AGENT_MAJOR=$((AGENT_MAJOR + 1)) + NEXT_COMPUTER_MAJOR=$((COMPUTER_MAJOR + 1)) + + # Update dependencies in pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version of sed needs an empty string for -i + # Update cua-agent with all extras + sed -i '' "s/\"cua-agent\[all\]>=.*,<.*\"/\"cua-agent[all]>=$LATEST_AGENT,<$NEXT_AGENT_MAJOR.0.0\"/" pyproject.toml + sed -i '' "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml + else + # Linux version + sed -i "s/\"cua-agent\[all\]>=.*,<.*\"/\"cua-agent[all]>=$LATEST_AGENT,<$NEXT_AGENT_MAJOR.0.0\"/" pyproject.toml + sed -i "s/\"cua-computer>=.*,<.*\"/\"cua-computer>=$LATEST_COMPUTER,<$NEXT_COMPUTER_MAJOR.0.0\"/" pyproject.toml + fi + + # Display the updated dependencies + echo "Updated dependencies in pyproject.toml:" + grep -E "cua-agent|cua-computer" pyproject.toml + + publish: + needs: prepare + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "mcp-server" + package_dir: "libs/python/mcp-server" + version: ${{ needs.prepare.outputs.version }} + is_lume_package: false + base_package_name: "cua-mcp-server" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + set-env-variables: + needs: [prepare, publish] + runs-on: macos-latest + steps: + - name: Set environment variables for use in other jobs + run: | + echo "AGENT_VERSION=${{ needs.prepare.outputs.agent_version }}" >> $GITHUB_ENV + echo "COMPUTER_VERSION=${{ needs.prepare.outputs.computer_version }}" >> $GITHUB_ENV diff --git a/.github/workflows/pypi-publish-pylume.yml b/.github/workflows/pypi-publish-pylume.yml new file mode 100644 index 00000000..91278c00 --- /dev/null +++ b/.github/workflows/pypi-publish-pylume.yml @@ -0,0 +1,82 @@ +name: Publish Pylume Package + +on: + push: + tags: + - "pylume-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + outputs: + version: + description: "The version that was published" + value: ${{ jobs.determine-version.outputs.version }} + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + determine-version: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/pylume-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for pylume" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + validate-version: + runs-on: macos-latest + needs: determine-version + steps: + - uses: actions/checkout@v4 + - name: Validate version + id: validate-version + run: | + CODE_VERSION=$(grep '__version__' libs/python/pylume/pylume/__init__.py | cut -d'"' -f2) + if [ "${{ needs.determine-version.outputs.version }}" != "$CODE_VERSION" ]; then + echo "Version mismatch: expected $CODE_VERSION, got ${{ needs.determine-version.outputs.version }}" + exit 1 + fi + echo "Version validated: $CODE_VERSION" + + publish: + needs: determine-version + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "pylume" + package_dir: "libs/python/pylume" + version: ${{ needs.determine-version.outputs.version }} + is_lume_package: true + base_package_name: "pylume" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/pypi-publish-som.yml b/.github/workflows/pypi-publish-som.yml new file mode 100644 index 00000000..fd80d3e8 --- /dev/null +++ b/.github/workflows/pypi-publish-som.yml @@ -0,0 +1,67 @@ +name: Publish SOM Package + +on: + push: + tags: + - "som-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + default: "0.1.0" + workflow_call: + inputs: + version: + description: "Version to publish" + required: true + type: string + outputs: + version: + description: "The version that was published" + value: ${{ jobs.determine-version.outputs.version }} + +# Adding permissions at workflow level +permissions: + contents: write + +jobs: + determine-version: + runs-on: macos-latest + outputs: + version: ${{ steps.get-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: get-version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + # Extract version from tag (for package-specific tags) + if [[ "${{ github.ref }}" =~ ^refs/tags/som-v([0-9]+\.[0-9]+\.[0-9]+) ]]; then + VERSION=${BASH_REMATCH[1]} + else + echo "Invalid tag format for som" + exit 1 + fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + # Use version from workflow dispatch + VERSION=${{ github.event.inputs.version }} + else + # Use version from workflow_call + VERSION=${{ inputs.version }} + fi + echo "VERSION=$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + publish: + needs: determine-version + uses: ./.github/workflows/pypi-reusable-publish.yml + with: + package_name: "som" + package_dir: "libs/python/som" + version: ${{ needs.determine-version.outputs.version }} + is_lume_package: false + base_package_name: "cua-som" + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/pypi-reusable-publish.yml b/.github/workflows/pypi-reusable-publish.yml new file mode 100644 index 00000000..f1eb045e --- /dev/null +++ b/.github/workflows/pypi-reusable-publish.yml @@ -0,0 +1,280 @@ +name: Reusable Package Publish Workflow + +on: + workflow_call: + inputs: + package_name: + description: "Name of the package (e.g. pylume, computer, agent)" + required: true + type: string + package_dir: + description: "Directory containing the package relative to workspace root (e.g. libs/python/pylume)" + required: true + type: string + version: + description: "Version to publish" + required: true + type: string + is_lume_package: + description: "Whether this package includes the lume binary" + required: false + type: boolean + default: false + base_package_name: + description: "PyPI package name (e.g. pylume, cua-agent)" + required: true + type: string + make_latest: + description: "Whether to mark this release as latest (should only be true for lume)" + required: false + type: boolean + default: false + secrets: + PYPI_TOKEN: + required: true + outputs: + version: + description: "The version that was published" + value: ${{ jobs.build-and-publish.outputs.version }} + +jobs: + build-and-publish: + runs-on: macos-latest + permissions: + contents: write # This permission is needed for creating releases + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for release creation + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Create root pdm.lock file + run: | + # Create an empty pdm.lock file in the root + touch pdm.lock + + - name: Install PDM + uses: pdm-project/setup-pdm@v3 + with: + python-version: "3.11" + cache: true + + - name: Set version + id: set-version + run: | + echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + + - name: Initialize PDM in package directory + run: | + # Make sure we're working with a properly initialized PDM project + cd ${{ inputs.package_dir }} + + # Create pdm.lock if it doesn't exist + if [ ! -f "pdm.lock" ]; then + echo "No pdm.lock found, initializing PDM project..." + pdm lock + fi + + - name: Set version in package + run: | + cd ${{ inputs.package_dir }} + # Replace pdm bump with direct edit of pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version of sed needs an empty string for -i + sed -i '' "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml + else + # Linux version + sed -i "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml + fi + # Verify version was updated + echo "Updated version in pyproject.toml:" + grep "version =" pyproject.toml + + # Conditional step for lume binary download (only for pylume package) + - name: Download and setup lume binary + if: inputs.is_lume_package + run: | + # Create a temporary directory for extraction + mkdir -p temp_lume + + # Download the latest lume release directly + echo "Downloading latest lume version..." + curl -sL "https://github.com/trycua/lume/releases/latest/download/lume.tar.gz" -o temp_lume/lume.tar.gz + + # Extract the tar file (ignore ownership and suppress warnings) + cd temp_lume && tar --no-same-owner -xzf lume.tar.gz + + # Make the binary executable + chmod +x lume + + # Copy the lume binary to the correct location in the pylume package + mkdir -p "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume" + cp lume "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume/lume" + + # Verify the binary exists and is executable + test -x "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume/lume" || { echo "lume binary not found or not executable"; exit 1; } + + # Get the version from the downloaded binary for reference + LUME_VERSION=$(./lume --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") + echo "Using lume version: $LUME_VERSION" + + # Cleanup + cd "${GITHUB_WORKSPACE}" && rm -rf temp_lume + + # Save the lume version for reference + echo "LUME_VERSION=${LUME_VERSION}" >> $GITHUB_ENV + + - name: Build and publish + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: | + cd ${{ inputs.package_dir }} + # Build with PDM + pdm build + + # For pylume package, verify the binary is in the wheel + if [ "${{ inputs.is_lume_package }}" = "true" ]; then + python -m pip install wheel + wheel unpack dist/*.whl --dest temp_wheel + echo "Listing contents of wheel directory:" + find temp_wheel -type f + test -f temp_wheel/pylume-*/pylume/lume || { echo "lume binary not found in wheel"; exit 1; } + rm -rf temp_wheel + echo "Publishing ${{ inputs.base_package_name }} ${VERSION} with lume ${LUME_VERSION}" + else + echo "Publishing ${{ inputs.base_package_name }} ${VERSION}" + fi + + # Install and use twine directly instead of PDM publish + echo "Installing twine for direct publishing..." + pip install twine + + echo "Publishing to PyPI using twine..." + TWINE_USERNAME="__token__" TWINE_PASSWORD="$PYPI_TOKEN" python -m twine upload dist/* + + # Save the wheel file path for the release + WHEEL_FILE=$(ls dist/*.whl | head -1) + echo "WHEEL_FILE=${WHEEL_FILE}" >> $GITHUB_ENV + + - name: Prepare Simple Release Notes + if: startsWith(github.ref, 'refs/tags/') + run: | + # Create release notes based on package type + echo "# ${{ inputs.base_package_name }} v${VERSION}" > release_notes.md + echo "" >> release_notes.md + + if [ "${{ inputs.package_name }}" = "pylume" ]; then + echo "## Python SDK for lume - run macOS and Linux VMs on Apple Silicon" >> release_notes.md + echo "" >> release_notes.md + echo "This package provides Python bindings for the lume virtualization tool." >> release_notes.md + echo "" >> release_notes.md + echo "## Dependencies" >> release_notes.md + echo "* lume binary: v${LUME_VERSION}" >> release_notes.md + elif [ "${{ inputs.package_name }}" = "computer" ]; then + echo "## Computer control library for the Computer Universal Automation (CUA) project" >> release_notes.md + echo "" >> release_notes.md + echo "## Dependencies" >> release_notes.md + echo "* pylume: ${PYLUME_VERSION:-latest}" >> release_notes.md + elif [ "${{ inputs.package_name }}" = "agent" ]; then + echo "## Dependencies" >> release_notes.md + echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md + echo "* cua-som: ${SOM_VERSION:-latest}" >> release_notes.md + echo "" >> release_notes.md + echo "## Installation Options" >> release_notes.md + echo "" >> release_notes.md + echo "### Basic installation with Anthropic" >> release_notes.md + echo '```bash' >> release_notes.md + echo "pip install cua-agent[anthropic]==${VERSION}" >> release_notes.md + echo '```' >> release_notes.md + echo "" >> release_notes.md + echo "### With SOM (recommended)" >> release_notes.md + echo '```bash' >> release_notes.md + echo "pip install cua-agent[som]==${VERSION}" >> release_notes.md + echo '```' >> release_notes.md + echo "" >> release_notes.md + echo "### All features" >> release_notes.md + echo '```bash' >> release_notes.md + echo "pip install cua-agent[all]==${VERSION}" >> release_notes.md + echo '```' >> release_notes.md + elif [ "${{ inputs.package_name }}" = "som" ]; then + echo "## Computer Vision and OCR library for detecting and analyzing UI elements" >> release_notes.md + echo "" >> release_notes.md + echo "This package provides enhanced UI understanding capabilities through computer vision and OCR." >> release_notes.md + elif [ "${{ inputs.package_name }}" = "computer-server" ]; then + echo "## Computer Server for the Computer Universal Automation (CUA) project" >> release_notes.md + echo "" >> release_notes.md + echo "A FastAPI-based server implementation for computer control." >> release_notes.md + echo "" >> release_notes.md + echo "## Dependencies" >> release_notes.md + echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md + echo "" >> release_notes.md + echo "## Usage" >> release_notes.md + echo '```bash' >> release_notes.md + echo "# Run the server" >> release_notes.md + echo "cua-computer-server" >> release_notes.md + echo '```' >> release_notes.md + elif [ "${{ inputs.package_name }}" = "mcp-server" ]; then + echo "## MCP Server for the Computer-Use Agent (CUA)" >> release_notes.md + echo "" >> release_notes.md + echo "This package provides MCP (Model Context Protocol) integration for CUA agents, allowing them to be used with Claude Desktop, Cursor, and other MCP clients." >> release_notes.md + echo "" >> release_notes.md + echo "## Dependencies" >> release_notes.md + echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md + echo "* cua-agent: ${AGENT_VERSION:-latest}" >> release_notes.md + echo "" >> release_notes.md + echo "## Usage" >> release_notes.md + echo '```bash' >> release_notes.md + echo "# Run the MCP server directly" >> release_notes.md + echo "cua-mcp-server" >> release_notes.md + echo '```' >> release_notes.md + echo "" >> release_notes.md + echo "## Claude Desktop Integration" >> release_notes.md + echo "Add to your Claude Desktop configuration (~/.config/claude-desktop/claude_desktop_config.json or OS-specific location):" >> release_notes.md + echo '```json' >> release_notes.md + echo '"mcpServers": {' >> release_notes.md + echo ' "cua-agent": {' >> release_notes.md + echo ' "command": "cua-mcp-server",' >> release_notes.md + echo ' "args": [],' >> release_notes.md + echo ' "env": {' >> release_notes.md + echo ' "CUA_AGENT_LOOP": "OMNI",' >> release_notes.md + echo ' "CUA_MODEL_PROVIDER": "ANTHROPIC",' >> release_notes.md + echo ' "CUA_MODEL_NAME": "claude-3-opus-20240229",' >> release_notes.md + echo ' "ANTHROPIC_API_KEY": "your-api-key",' >> release_notes.md + echo ' "PYTHONIOENCODING": "utf-8"' >> release_notes.md + echo ' }' >> release_notes.md + echo ' }' >> release_notes.md + echo '}' >> release_notes.md + echo '```' >> release_notes.md + fi + + # Add installation section if not agent (which has its own installation section) + if [ "${{ inputs.package_name }}" != "agent" ]; then + echo "" >> release_notes.md + echo "## Installation" >> release_notes.md + echo '```bash' >> release_notes.md + echo "pip install ${{ inputs.base_package_name }}==${VERSION}" >> release_notes.md + echo '```' >> release_notes.md + fi + + echo "Release notes created:" + cat release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + name: "${{ inputs.base_package_name }} v${{ env.VERSION }}" + body_path: release_notes.md + files: ${{ inputs.package_dir }}/${{ env.WHEEL_FILE }} + draft: false + prerelease: false + make_latest: ${{ inputs.package_name == 'lume' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-publish.yml b/.github/workflows/reusable-publish.yml deleted file mode 100644 index 8856b60d..00000000 --- a/.github/workflows/reusable-publish.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Reusable Package Publish Workflow - -on: - workflow_call: - inputs: - package_name: - description: 'Name of the package (e.g. pylume, computer, agent)' - required: true - type: string - package_dir: - description: 'Directory containing the package relative to workspace root (e.g. libs/pylume)' - required: true - type: string - version: - description: 'Version to publish' - required: true - type: string - is_lume_package: - description: 'Whether this package includes the lume binary' - required: false - type: boolean - default: false - base_package_name: - description: 'PyPI package name (e.g. pylume, cua-agent)' - required: true - type: string - make_latest: - description: 'Whether to mark this release as latest (should only be true for lume)' - required: false - type: boolean - default: false - secrets: - PYPI_TOKEN: - required: true - outputs: - version: - description: "The version that was published" - value: ${{ jobs.build-and-publish.outputs.version }} - -jobs: - build-and-publish: - runs-on: macos-latest - permissions: - contents: write # This permission is needed for creating releases - outputs: - version: ${{ steps.set-version.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history for release creation - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Create root pdm.lock file - run: | - # Create an empty pdm.lock file in the root - touch pdm.lock - - - name: Install PDM - uses: pdm-project/setup-pdm@v3 - with: - python-version: '3.11' - cache: true - - - name: Set version - id: set-version - run: | - echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV - echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT - - - name: Initialize PDM in package directory - run: | - # Make sure we're working with a properly initialized PDM project - cd ${{ inputs.package_dir }} - - # Create pdm.lock if it doesn't exist - if [ ! -f "pdm.lock" ]; then - echo "No pdm.lock found, initializing PDM project..." - pdm lock - fi - - - name: Set version in package - run: | - cd ${{ inputs.package_dir }} - # Replace pdm bump with direct edit of pyproject.toml - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS version of sed needs an empty string for -i - sed -i '' "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml - else - # Linux version - sed -i "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml - fi - # Verify version was updated - echo "Updated version in pyproject.toml:" - grep "version =" pyproject.toml - - # Conditional step for lume binary download (only for pylume package) - - name: Download and setup lume binary - if: inputs.is_lume_package - run: | - # Create a temporary directory for extraction - mkdir -p temp_lume - - # Download the latest lume release directly - echo "Downloading latest lume version..." - curl -sL "https://github.com/trycua/lume/releases/latest/download/lume.tar.gz" -o temp_lume/lume.tar.gz - - # Extract the tar file (ignore ownership and suppress warnings) - cd temp_lume && tar --no-same-owner -xzf lume.tar.gz - - # Make the binary executable - chmod +x lume - - # Copy the lume binary to the correct location in the pylume package - mkdir -p "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume" - cp lume "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume/lume" - - # Verify the binary exists and is executable - test -x "${GITHUB_WORKSPACE}/${{ inputs.package_dir }}/pylume/lume" || { echo "lume binary not found or not executable"; exit 1; } - - # Get the version from the downloaded binary for reference - LUME_VERSION=$(./lume --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") - echo "Using lume version: $LUME_VERSION" - - # Cleanup - cd "${GITHUB_WORKSPACE}" && rm -rf temp_lume - - # Save the lume version for reference - echo "LUME_VERSION=${LUME_VERSION}" >> $GITHUB_ENV - - - name: Build and publish - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - cd ${{ inputs.package_dir }} - # Build with PDM - pdm build - - # For pylume package, verify the binary is in the wheel - if [ "${{ inputs.is_lume_package }}" = "true" ]; then - python -m pip install wheel - wheel unpack dist/*.whl --dest temp_wheel - echo "Listing contents of wheel directory:" - find temp_wheel -type f - test -f temp_wheel/pylume-*/pylume/lume || { echo "lume binary not found in wheel"; exit 1; } - rm -rf temp_wheel - echo "Publishing ${{ inputs.base_package_name }} ${VERSION} with lume ${LUME_VERSION}" - else - echo "Publishing ${{ inputs.base_package_name }} ${VERSION}" - fi - - # Install and use twine directly instead of PDM publish - echo "Installing twine for direct publishing..." - pip install twine - - echo "Publishing to PyPI using twine..." - TWINE_USERNAME="__token__" TWINE_PASSWORD="$PYPI_TOKEN" python -m twine upload dist/* - - # Save the wheel file path for the release - WHEEL_FILE=$(ls dist/*.whl | head -1) - echo "WHEEL_FILE=${WHEEL_FILE}" >> $GITHUB_ENV - - - name: Prepare Simple Release Notes - if: startsWith(github.ref, 'refs/tags/') - run: | - # Create release notes based on package type - echo "# ${{ inputs.base_package_name }} v${VERSION}" > release_notes.md - echo "" >> release_notes.md - - if [ "${{ inputs.package_name }}" = "pylume" ]; then - echo "## Python SDK for lume - run macOS and Linux VMs on Apple Silicon" >> release_notes.md - echo "" >> release_notes.md - echo "This package provides Python bindings for the lume virtualization tool." >> release_notes.md - echo "" >> release_notes.md - echo "## Dependencies" >> release_notes.md - echo "* lume binary: v${LUME_VERSION}" >> release_notes.md - elif [ "${{ inputs.package_name }}" = "computer" ]; then - echo "## Computer control library for the Computer Universal Automation (CUA) project" >> release_notes.md - echo "" >> release_notes.md - echo "## Dependencies" >> release_notes.md - echo "* pylume: ${PYLUME_VERSION:-latest}" >> release_notes.md - elif [ "${{ inputs.package_name }}" = "agent" ]; then - echo "## Dependencies" >> release_notes.md - echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md - echo "* cua-som: ${SOM_VERSION:-latest}" >> release_notes.md - echo "" >> release_notes.md - echo "## Installation Options" >> release_notes.md - echo "" >> release_notes.md - echo "### Basic installation with Anthropic" >> release_notes.md - echo '```bash' >> release_notes.md - echo "pip install cua-agent[anthropic]==${VERSION}" >> release_notes.md - echo '```' >> release_notes.md - echo "" >> release_notes.md - echo "### With SOM (recommended)" >> release_notes.md - echo '```bash' >> release_notes.md - echo "pip install cua-agent[som]==${VERSION}" >> release_notes.md - echo '```' >> release_notes.md - echo "" >> release_notes.md - echo "### All features" >> release_notes.md - echo '```bash' >> release_notes.md - echo "pip install cua-agent[all]==${VERSION}" >> release_notes.md - echo '```' >> release_notes.md - elif [ "${{ inputs.package_name }}" = "som" ]; then - echo "## Computer Vision and OCR library for detecting and analyzing UI elements" >> release_notes.md - echo "" >> release_notes.md - echo "This package provides enhanced UI understanding capabilities through computer vision and OCR." >> release_notes.md - elif [ "${{ inputs.package_name }}" = "computer-server" ]; then - echo "## Computer Server for the Computer Universal Automation (CUA) project" >> release_notes.md - echo "" >> release_notes.md - echo "A FastAPI-based server implementation for computer control." >> release_notes.md - echo "" >> release_notes.md - echo "## Dependencies" >> release_notes.md - echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md - echo "" >> release_notes.md - echo "## Usage" >> release_notes.md - echo '```bash' >> release_notes.md - echo "# Run the server" >> release_notes.md - echo "cua-computer-server" >> release_notes.md - echo '```' >> release_notes.md - elif [ "${{ inputs.package_name }}" = "mcp-server" ]; then - echo "## MCP Server for the Computer-Use Agent (CUA)" >> release_notes.md - echo "" >> release_notes.md - echo "This package provides MCP (Model Context Protocol) integration for CUA agents, allowing them to be used with Claude Desktop, Cursor, and other MCP clients." >> release_notes.md - echo "" >> release_notes.md - echo "## Dependencies" >> release_notes.md - echo "* cua-computer: ${COMPUTER_VERSION:-latest}" >> release_notes.md - echo "* cua-agent: ${AGENT_VERSION:-latest}" >> release_notes.md - echo "" >> release_notes.md - echo "## Usage" >> release_notes.md - echo '```bash' >> release_notes.md - echo "# Run the MCP server directly" >> release_notes.md - echo "cua-mcp-server" >> release_notes.md - echo '```' >> release_notes.md - echo "" >> release_notes.md - echo "## Claude Desktop Integration" >> release_notes.md - echo "Add to your Claude Desktop configuration (~/.config/claude-desktop/claude_desktop_config.json or OS-specific location):" >> release_notes.md - echo '```json' >> release_notes.md - echo '"mcpServers": {' >> release_notes.md - echo ' "cua-agent": {' >> release_notes.md - echo ' "command": "cua-mcp-server",' >> release_notes.md - echo ' "args": [],' >> release_notes.md - echo ' "env": {' >> release_notes.md - echo ' "CUA_AGENT_LOOP": "OMNI",' >> release_notes.md - echo ' "CUA_MODEL_PROVIDER": "ANTHROPIC",' >> release_notes.md - echo ' "CUA_MODEL_NAME": "claude-3-opus-20240229",' >> release_notes.md - echo ' "ANTHROPIC_API_KEY": "your-api-key",' >> release_notes.md - echo ' "PYTHONIOENCODING": "utf-8"' >> release_notes.md - echo ' }' >> release_notes.md - echo ' }' >> release_notes.md - echo '}' >> release_notes.md - echo '```' >> release_notes.md - fi - - # Add installation section if not agent (which has its own installation section) - if [ "${{ inputs.package_name }}" != "agent" ]; then - echo "" >> release_notes.md - echo "## Installation" >> release_notes.md - echo '```bash' >> release_notes.md - echo "pip install ${{ inputs.base_package_name }}==${VERSION}" >> release_notes.md - echo '```' >> release_notes.md - fi - - echo "Release notes created:" - cat release_notes.md - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - name: "${{ inputs.base_package_name }} v${{ env.VERSION }}" - body_path: release_notes.md - files: ${{ inputs.package_dir }}/${{ env.WHEEL_FILE }} - draft: false - prerelease: false - make_latest: ${{ inputs.package_name == 'lume' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5f78692..e623dda8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ # C extensions *.so +node_modules/* +*/node_modules +**/node_modules + # Distribution / packaging .Python build/ @@ -155,7 +159,7 @@ weights/icon_detect/model.pt weights/icon_detect/model.pt.zip weights/icon_detect/model.pt.zip.part* -libs/omniparser/weights/icon_detect/model.pt +libs/python/omniparser/weights/icon_detect/model.pt # Example test data and output examples/test_data/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 3ea4279e..acfd84b2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,31 @@ { "configurations": [ + { + "name": "Agent UI", + "type": "debugpy", + "request": "launch", + "program": "examples/agent_ui_examples.py", + "console": "integratedTerminal", + "justMyCode": false, + "python": "${workspaceFolder:cua-root}/.venv/bin/python", + "cwd": "${workspaceFolder:cua-root}", + "env": { + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" + } + }, + { + "name": "Computer UI", + "type": "debugpy", + "request": "launch", + "program": "examples/computer_ui_examples.py", + "console": "integratedTerminal", + "justMyCode": false, + "python": "${workspaceFolder:cua-root}/.venv/bin/python", + "cwd": "${workspaceFolder:cua-root}", + "env": { + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" + } + }, { "name": "Run Computer Examples", "type": "debugpy", @@ -10,7 +36,7 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { @@ -23,20 +49,7 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "Run Agent UI Examples", - "type": "debugpy", - "request": "launch", - "program": "examples/agent_ui_examples.py", - "console": "integratedTerminal", - "justMyCode": false, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { @@ -49,7 +62,7 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { @@ -71,7 +84,7 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { @@ -93,27 +106,27 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { "name": "Run Computer Server", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/libs/computer-server/run_server.py", + "program": "${workspaceFolder}/libs/python/computer-server/run_server.py", "console": "integratedTerminal", "justMyCode": true, "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer:${workspaceFolder:cua-root}/libs/python/agent:${workspaceFolder:cua-root}/libs/python/som:${workspaceFolder:cua-root}/libs/python/pylume" } }, { "name": "Run Computer Server with Args", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/libs/computer-server/run_server.py", + "program": "${workspaceFolder}/libs/python/computer-server/run_server.py", "args": [ "--host", "0.0.0.0", @@ -127,7 +140,7 @@ "python": "${workspaceFolder:cua-root}/.venv/bin/python", "cwd": "${workspaceFolder:cua-root}", "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer-server" + "PYTHONPATH": "${workspaceFolder:cua-root}/libs/python/core:${workspaceFolder:cua-root}/libs/python/computer-server" } }, { diff --git a/.vscode/libs-ts.code-workspace b/.vscode/libs-ts.code-workspace new file mode 100644 index 00000000..732316f2 --- /dev/null +++ b/.vscode/libs-ts.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "name": "libs-ts", + "path": "../libs/typescript" + } + ], + "extensions": { + "recommendations": [ + "biomejs.biome", + ] + } +} \ No newline at end of file diff --git a/.vscode/py.code-workspace b/.vscode/py.code-workspace index eb853f5a..25324251 100644 --- a/.vscode/py.code-workspace +++ b/.vscode/py.code-workspace @@ -6,27 +6,27 @@ }, { "name": "computer", - "path": "../libs/computer" + "path": "../libs/python/computer" }, { "name": "agent", - "path": "../libs/agent" + "path": "../libs/python/agent" }, { "name": "som", - "path": "../libs/som" + "path": "../libs/python/som" }, { "name": "computer-server", - "path": "../libs/computer-server" + "path": "../libs/python/computer-server" }, { "name": "pylume", - "path": "../libs/pylume" + "path": "../libs/python/pylume" }, { "name": "core", - "path": "../libs/core" + "path": "../libs/python/core" } ], "settings": { @@ -47,11 +47,11 @@ "libs" ], "python.analysis.extraPaths": [ - "${workspaceFolder:cua-root}/libs/core", - "${workspaceFolder:cua-root}/libs/computer", - "${workspaceFolder:cua-root}/libs/agent", - "${workspaceFolder:cua-root}/libs/som", - "${workspaceFolder:cua-root}/libs/pylume", + "${workspaceFolder:cua-root}/libs/python/core", + "${workspaceFolder:cua-root}/libs/python/computer", + "${workspaceFolder:cua-root}/libs/python/agent", + "${workspaceFolder:cua-root}/libs/python/som", + "${workspaceFolder:cua-root}/libs/python/pylume", "${workspaceFolder:cua-root}/.vscode/typings" ], "python.envFile": "${workspaceFolder:cua-root}/.env", @@ -99,11 +99,11 @@ } ], "python.autoComplete.extraPaths": [ - "${workspaceFolder:cua-root}/libs/core", - "${workspaceFolder:cua-root}/libs/computer", - "${workspaceFolder:cua-root}/libs/agent", - "${workspaceFolder:cua-root}/libs/som", - "${workspaceFolder:cua-root}/libs/pylume" + "${workspaceFolder:cua-root}/libs/python/core", + "${workspaceFolder:cua-root}/libs/python/computer", + "${workspaceFolder:cua-root}/libs/python/agent", + "${workspaceFolder:cua-root}/libs/python/som", + "${workspaceFolder:cua-root}/libs/python/pylume" ], "python.languageServer": "None", "[python]": { @@ -118,8 +118,8 @@ "examples/agent_examples.py": "python" }, "python.interpreterPaths": { - "examples/computer_examples.py": "${workspaceFolder}/libs/computer/.venv/bin/python", - "examples/agent_examples.py": "${workspaceFolder}/libs/agent/.venv/bin/python" + "examples/computer_examples.py": "${workspaceFolder}/libs/python/computer/.venv/bin/python", + "examples/agent_examples.py": "${workspaceFolder}/libs/python/agent/.venv/bin/python" } }, "tasks": { @@ -148,119 +148,6 @@ } ] }, - "launch": { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Computer Examples", - "type": "debugpy", - "request": "launch", - "program": "examples/computer_examples.py", - "console": "integratedTerminal", - "justMyCode": true, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "Run Agent Examples", - "type": "debugpy", - "request": "launch", - "program": "examples/agent_examples.py", - "console": "integratedTerminal", - "justMyCode": false, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "Run PyLume Examples", - "type": "debugpy", - "request": "launch", - "program": "examples/pylume_examples.py", - "console": "integratedTerminal", - "justMyCode": true, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "SOM: Run Experiments (No OCR)", - "type": "debugpy", - "request": "launch", - "program": "examples/som_examples.py", - "args": [ - "examples/test_data", - "--output-dir", "examples/output", - "--ocr", "none", - "--mode", "experiment" - ], - "console": "integratedTerminal", - "justMyCode": false, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "SOM: Run Experiments (EasyOCR)", - "type": "debugpy", - "request": "launch", - "program": "examples/som_examples.py", - "args": [ - "examples/test_data", - "--output-dir", "examples/output", - "--ocr", "easyocr", - "--mode", "experiment" - ], - "console": "integratedTerminal", - "justMyCode": false, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "Run Computer Server", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/libs/computer-server/run_server.py", - "console": "integratedTerminal", - "justMyCode": true, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer:${workspaceFolder:cua-root}/libs/agent:${workspaceFolder:cua-root}/libs/som:${workspaceFolder:cua-root}/libs/pylume" - } - }, - { - "name": "Run Computer Server with Args", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/libs/computer-server/run_server.py", - "args": [ - "--host", "0.0.0.0", - "--port", "8000", - "--log-level", "debug" - ], - "console": "integratedTerminal", - "justMyCode": false, - "python": "${workspaceFolder:cua-root}/.venv/bin/python", - "cwd": "${workspaceFolder:cua-root}", - "env": { - "PYTHONPATH": "${workspaceFolder:cua-root}/libs/core:${workspaceFolder:cua-root}/libs/computer-server" - } - } - ] - }, "compounds": [ { "name": "Run Computer Examples + Server", diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 00000000..a1390381 --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,86 @@ +# C/ua Compatibility Matrix + +## Table of Contents +- [Host OS Compatibility](#host-os-compatibility) + - [macOS Host](#macos-host) + - [Ubuntu/Linux Host](#ubuntulinux-host) + - [Windows Host](#windows-host) +- [VM Emulation Support](#vm-emulation-support) +- [Model Provider Compatibility](#model-provider-compatibility) + +--- + +## Host OS Compatibility + +*This section shows compatibility based on your **host operating system** (the OS you're running C/ua on).* + +### macOS Host + +| Installation Method | Requirements | Lume | Cloud | Notes | +|-------------------|-------------|------|-------|-------| +| **playground-docker.sh** | Docker Desktop | ✅ Full | ✅ Full | Recommended for quick setup | +| **Dev Container** | VS Code/WindSurf + Docker | ✅ Full | ✅ Full | Best for development | +| **PyPI packages** | Python 3.12+ | ✅ Full | ✅ Full | Most flexible | + +**macOS Host Requirements:** +- macOS 15+ (Sequoia) for local VM support +- Apple Silicon (M1/M2/M3/M4) recommended for best performance +- Docker Desktop for containerized installations + +--- + +### Ubuntu/Linux Host + +| Installation Method | Requirements | Lume | Cloud | Notes | +|-------------------|-------------|------|-------|-------| +| **playground-docker.sh** | Docker Engine | ✅ Full | ✅ Full | Recommended for quick setup | +| **Dev Container** | VS Code/WindSurf + Docker | ✅ Full | ✅ Full | Best for development | +| **PyPI packages** | Python 3.12+ | ✅ Full | ✅ Full | Most flexible | + +**Ubuntu/Linux Host Requirements:** +- Ubuntu 20.04+ or equivalent Linux distribution +- Docker Engine or Docker Desktop +- Python 3.12+ for PyPI installation + +--- + +### Windows Host + +| Installation Method | Requirements | Lume | Winsandbox | Cloud | Notes | +|-------------------|-------------|------|------------|-------|-------| +| **playground-docker.sh** | Docker Desktop + WSL2 | ❌ Not supported | ❌ Not supported | ✅ Full | Requires WSL2 | +| **Dev Container** | VS Code/WindSurf + Docker + WSL2 | ❌ Not supported | ❌ Not supported | ✅ Full | Requires WSL2 | +| **PyPI packages** | Python 3.12+ | ❌ Not supported | ✅ Full | ✅ Full | | + +**Windows Host Requirements:** +- Windows 10/11 with WSL2 enabled for shell script execution +- Docker Desktop with WSL2 backend +- Windows Sandbox feature enabled (for Winsandbox support) +- Python 3.12+ installed in WSL2 or Windows +- **Note**: Lume CLI is not available on Windows - use Cloud or Winsandbox providers + +--- + +## VM Emulation Support + +*This section shows which **virtual machine operating systems** each provider can emulate.* + +| Provider | macOS VM | Ubuntu/Linux VM | Windows VM | Notes | +|----------|----------|-----------------|------------|-------| +| **Lume** | ✅ Full support | ⚠️ Limited support | ⚠️ Limited support | macOS: native; Ubuntu/Linux/Windows: need custom image | +| **Cloud** | 🚧 Coming soon | ✅ Full support | 🚧 Coming soon | Currently Ubuntu only, macOS/Windows in development | +| **Winsandbox** | ❌ Not supported | ❌ Not supported | ✅ Windows only | Windows 10/11 environments only | + +--- + +## Model Provider Compatibility + +*This section shows which **AI model providers** are supported on each host operating system.* + +| Provider | macOS Host | Ubuntu/Linux Host | Windows Host | Notes | +|----------|------------|-------------------|--------------|-------| +| **Anthropic** | ✅ Full support | ✅ Full support | ✅ Full support | Cloud-based API | +| **OpenAI** | ✅ Full support | ✅ Full support | ✅ Full support | Cloud-based API | +| **Ollama** | ✅ Full support | ✅ Full support | ✅ Full support | Local model serving | +| **OpenAI Compatible** | ✅ Full support | ✅ Full support | ✅ Full support | Any OpenAI-compatible API endpoint | +| **MLX VLM** | ✅ macOS only | ❌ Not supported | ❌ Not supported | Apple Silicon required. PyPI installation only. | diff --git a/Dockerfile b/Dockerfile index 4bd8c6e9..9b9f3c47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.11-slim +FROM python:3.12-slim # Set environment variables ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PYTHONPATH="/app/libs/core:/app/libs/computer:/app/libs/agent:/app/libs/som:/app/libs/pylume:/app/libs/computer-server" + PYTHONPATH="/app/libs/python/core:/app/libs/python/computer:/app/libs/python/agent:/app/libs/python/som:/app/libs/python/pylume:/app/libs/python/computer-server:/app/libs/python/mcp-server" # Install system dependencies for ARM architecture RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ iputils-ping \ net-tools \ sed \ + xxd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index e56e7d0e..d418a67a 100644 --- a/README.md +++ b/README.md @@ -51,71 +51,72 @@ **Need to automate desktop tasks? Launch the Computer-Use Agent UI with a single command.** -### Option 1: Fully-managed install (recommended) +### Option 1: Fully-managed install with Docker (recommended) -*I want to be totally guided in the process* +*Docker-based guided install for quick use* **macOS/Linux/Windows (via WSL):** ```bash -# Requires Python 3.11+ -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/scripts/playground.sh)" +# Requires Docker +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/scripts/playground-docker.sh)" ``` -This script will: - -- Ask if you want to use local VMs or C/ua Cloud Containers -- Install necessary dependencies (Lume CLI for local VMs) -- Download VM images if needed -- Install Python packages -- Launch the Computer-Use Agent UI - -### Option 2: Key manual steps - -
-If you are skeptical running one-install scripts - -**For C/ua Agent UI (any system, cloud VMs only):** - -```bash -# Requires Python 3.11+ and C/ua API key -pip install -U "cua-computer[all]" "cua-agent[all]" -python -m agent.ui.gradio.app -``` - -**For Local macOS/Linux VMs (Apple Silicon only):** - -```bash -# 1. Install Lume CLI -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" - -# 2. Pull macOS image -lume pull macos-sequoia-cua:latest - -# 3. Start VM -lume run macos-sequoia-cua:latest - -# 4. Install packages and launch UI -pip install -U "cua-computer[all]" "cua-agent[all]" -python -m agent.ui.gradio.app -``` - -
+This script will guide you through setup using Docker containers and launch the Computer-Use Agent UI. --- -*How it works: Computer module provides secure desktops (Lume CLI locally, [C/ua Cloud Containers](https://trycua.com) remotely), Agent module provides local/API agents with OpenAI AgentResponse format and [trajectory tracing](https://trycua.com/trajectory-viewer).* +### Option 2: [Dev Container](./.devcontainer/README.md) -### Supported [Agent Loops](https://github.com/trycua/cua/blob/main/libs/agent/README.md#agent-loops) +*Best for contributors and development* -- [UITARS-1.5](https://github.com/trycua/cua/blob/main/libs/agent/README.md#agent-loops) - Run locally on Apple Silicon with MLX, or use cloud providers -- [OpenAI CUA](https://github.com/trycua/cua/blob/main/libs/agent/README.md#agent-loops) - Use OpenAI's Computer-Use Preview model -- [Anthropic CUA](https://github.com/trycua/cua/blob/main/libs/agent/README.md#agent-loops) - Use Anthropic's Computer-Use capabilities -- [OmniParser-v2.0](https://github.com/trycua/cua/blob/main/libs/agent/README.md#agent-loops) - Control UI with [Set-of-Marks prompting](https://som-gpt4v.github.io/) using any vision model +This repository includes a [Dev Container](./.devcontainer/README.md) configuration that simplifies setup to a few steps: -# 💻 Developer Guide +1. **Install the Dev Containers extension ([VS Code](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) or [WindSurf](https://docs.windsurf.com/windsurf/advanced#dev-containers-beta))** +2. **Open the repository in the Dev Container:** + - Press `Ctrl+Shift+P` (or `⌘+Shift+P` on macOS) + - Select `Dev Containers: Clone Repository in Container Volume...` and paste the repository URL: `https://github.com/trycua/cua.git` (if not cloned) or `Dev Containers: Open Folder in Container...` (if git cloned). + > **Note**: On WindSurf, the post install hook might not run automatically. If so, run `/bin/bash .devcontainer/post-install.sh` manually. +3. **Open the VS Code workspace:** Once the post-install.sh is done running, open the `.vscode/py.code-workspace` workspace and press ![Open Workspace](https://github.com/user-attachments/assets/923bdd43-8c8f-4060-8d78-75bfa302b48c) +. +4. **Run the Agent UI example:** Click ![Run Agent UI](https://github.com/user-attachments/assets/7a61ef34-4b22-4dab-9864-f86bf83e290b) + to start the Gradio UI. If prompted to install **debugpy (Python Debugger)** to enable remote debugging, select 'Yes' to proceed. +5. **Access the Gradio UI:** The Gradio UI will be available at `http://localhost:7860` and will automatically forward to your host machine. -Follow these steps to use C/ua in your own code. See [Developer Guide](https://docs.trycua.com/home/developer-guide) for building from source. +--- + +### Option 3: PyPI + +*Direct Python package installation* + +```bash +# conda create -yn cua python==3.12 + +pip install -U "cua-computer[all]" "cua-agent[all]" +python -m agent.ui # Start the agent UI +``` + +Or check out the [Usage Guide](#-usage-guide) to learn how to use our Python SDK in your own code. + +--- + +## Supported [Agent Loops](https://github.com/trycua/cua/blob/main/libs/python/agent/README.md#agent-loops) + +- [UITARS-1.5](https://github.com/trycua/cua/blob/main/libs/python/agent/README.md#agent-loops) - Run locally on Apple Silicon with MLX, or use cloud providers +- [OpenAI CUA](https://github.com/trycua/cua/blob/main/libs/python/agent/README.md#agent-loops) - Use OpenAI's Computer-Use Preview model +- [Anthropic CUA](https://github.com/trycua/cua/blob/main/libs/python/agent/README.md#agent-loops) - Use Anthropic's Computer-Use capabilities +- [OmniParser-v2.0](https://github.com/trycua/cua/blob/main/libs/python/agent/README.md#agent-loops) - Control UI with [Set-of-Marks prompting](https://som-gpt4v.github.io/) using any vision model + +## 🖥️ Compatibility + +For detailed compatibility information including host OS support, VM emulation capabilities, and model provider compatibility, see the [Compatibility Matrix](./COMPATIBILITY.md). + +
+
+ +# 🐍 Usage Guide + +Follow these steps to use C/ua in your own Python code. See [Developer Guide](./docs/Developer-Guide.md) for building from source. ### Step 1: Install Lume CLI @@ -227,8 +228,8 @@ docker run -it --rm \ ## Resources -- [How to use the MCP Server with Claude Desktop or other MCP clients](./libs/mcp-server/README.md) - One of the easiest ways to get started with C/ua -- [How to use OpenAI Computer-Use, Anthropic, OmniParser, or UI-TARS for your Computer-Use Agent](./libs/agent/README.md) +- [How to use the MCP Server with Claude Desktop or other MCP clients](./libs/python/mcp-server/README.md) - One of the easiest ways to get started with C/ua +- [How to use OpenAI Computer-Use, Anthropic, OmniParser, or UI-TARS for your Computer-Use Agent](./libs/python/agent/README.md) - [How to use Lume CLI for managing desktops](./libs/lume/README.md) - [Training Computer-Use Models: Collecting Human Trajectories with C/ua (Part 1)](https://www.trycua.com/blog/training-computer-use-models-trajectories-1) - [Build Your Own Operator on macOS (Part 1)](https://www.trycua.com/blog/build-your-own-operator-on-macos-1) @@ -239,13 +240,14 @@ docker run -it --rm \ |--------|-------------|---------------| | [**Lume**](./libs/lume/README.md) | VM management for macOS/Linux using Apple's Virtualization.Framework | `curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh \| bash` | | [**Lumier**](./libs/lumier/README.md) | Docker interface for macOS and Linux VMs | `docker pull trycua/lumier:latest` | -| [**Computer**](./libs/computer/README.md) | Interface for controlling virtual machines | `pip install "cua-computer[all]"` | -| [**Agent**](./libs/agent/README.md) | AI agent framework for automating tasks | `pip install "cua-agent[all]"` | -| [**MCP Server**](./libs/mcp-server/README.md) | MCP server for using CUA with Claude Desktop | `pip install cua-mcp-server` | -| [**SOM**](./libs/som/README.md) | Self-of-Mark library for Agent | `pip install cua-som` | -| [**PyLume**](./libs/pylume/README.md) | Python bindings for Lume | `pip install pylume` | -| [**Computer Server**](./libs/computer-server/README.md) | Server component for Computer | `pip install cua-computer-server` | -| [**Core**](./libs/core/README.md) | Core utilities | `pip install cua-core` | +| [**Computer (Python)**](./libs/python/computer/README.md) | Python Interface for controlling virtual machines | `pip install "cua-computer[all]"` | +| [**Computer (Typescript)**](./libs/typescript/computer/README.md) | Typescript Interface for controlling virtual machines | `npm install @trycua/computer` | +| [**Agent**](./libs/python/agent/README.md) | AI agent framework for automating tasks | `pip install "cua-agent[all]"` | +| [**MCP Server**](./libs/python/mcp-server/README.md) | MCP server for using CUA with Claude Desktop | `pip install cua-mcp-server` | +| [**SOM**](./libs/python/som/README.md) | Self-of-Mark library for Agent | `pip install cua-som` | +| [**Computer Server**](./libs/python/computer-server/README.md) | Server component for Computer | `pip install cua-computer-server` | +| [**Core (Python)**](./libs/python/core/README.md) | Python Core utilities | `pip install cua-core` | +| [**Core (Typescript)**](./libs/typescript/core/README.md) | Typescript Core utilities | `npm install @trycua/core` | ## Computer Interface Reference @@ -253,7 +255,8 @@ For complete examples, see [computer_examples.py](./examples/computer_examples.p ```python # Shell Actions -await computer.interface.run_command(cmd) # Run shell command +result = await computer.interface.run_command(cmd) # Run shell command +# result.stdout, result.stderr, result.returncode # Mouse Actions await computer.interface.left_click(x, y) # Left click at coordinates @@ -288,8 +291,8 @@ await computer.interface.copy_to_clipboard() # Get clipboard content # File System Operations await computer.interface.file_exists(path) # Check if file exists await computer.interface.directory_exists(path) # Check if directory exists -await computer.interface.read_text(path) # Read file content -await computer.interface.write_text(path, content) # Write file content +await computer.interface.read_text(path, encoding="utf-8") # Read file content +await computer.interface.write_text(path, content, encoding="utf-8") # Write file content await computer.interface.read_bytes(path) # Read file content as bytes await computer.interface.write_bytes(path, content) # Write file content as bytes await computer.interface.delete_file(path) # Delete file @@ -399,14 +402,6 @@ Thank you to all our supporters! Ricter Zheng
Ricter Zheng

💻 Rahul Karajgikar
Rahul Karajgikar

💻 trospix
trospix

💻 - Ikko Eltociear Ashimine
Ikko Eltociear Ashimine

💻 - 한석호(MilKyo)
한석호(MilKyo)

💻 - - - Rahim Nathwani
Rahim Nathwani

💻 - Matt Speck
Matt Speck

💻 - FinnBorge
FinnBorge

💻 - Jakub Klapacz
Jakub Klapacz

💻 Evan smith
Evan smith

💻 diff --git a/docs/Developer-Guide.md b/docs/Developer-Guide.md new file mode 100644 index 00000000..49aa82ee --- /dev/null +++ b/docs/Developer-Guide.md @@ -0,0 +1,293 @@ +# Getting Started + +## Project Structure + +The project is organized as a monorepo with these main packages: + +### Python +- `libs/python/core/` - Base package with telemetry support +- `libs/python/computer/` - Computer-use interface (CUI) library +- `libs/python/agent/` - AI agent library with multi-provider support +- `libs/python/som/` - Set-of-Mark parser +- `libs/python/computer-server/` - Server component for VM +- `libs/python/pylume/` - Python bindings for Lume + +### TypeScript +- `libs/typescript/computer/` - Computer-use interface (CUI) library +- `libs/typescript/agent/` - AI agent library with multi-provider support + +### Other +- `libs/lume/` - Lume CLI + +Each package has its own virtual environment and dependencies, managed through PDM. + +## Local Development Setup + +1. Install Lume CLI: + + ```bash + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" + ``` + +2. Clone the repository: + + ```bash + git clone https://github.com/trycua/cua.git + cd cua + ``` + +3. Create a `.env.local` file in the root directory with your API keys: + + ```bash + # Required for Anthropic provider + ANTHROPIC_API_KEY=your_anthropic_key_here + + # Required for OpenAI provider + OPENAI_API_KEY=your_openai_key_here + ``` + +4. Open the workspace in VSCode or Cursor: + + ```bash + # For Cua Python development + code .vscode/py.code-workspace + + # For Lume (Swift) development + code .vscode/lume.code-workspace + ``` + +Using the workspace file is strongly recommended as it: + +- Sets up correct Python environments for each package +- Configures proper import paths +- Enables debugging configurations +- Maintains consistent settings across packages + +## Lume Development + +Refer to the [Lume README](../libs/lume/docs/Development.md) for instructions on how to develop the Lume CLI. + +## Python Development + +There are two ways to install Lume: + +### Run the build script + +Run the build script to set up all packages: + +```bash +./scripts/build.sh +``` + +The build script creates a shared virtual environment for all packages. The workspace configuration automatically handles import paths with the correct Python path settings. + +This will: + +- Create a virtual environment for the project +- Install all packages in development mode +- Set up the correct Python path +- Install development tools + +### Install with PDM + +If PDM is not already installed, you can follow the installation instructions [here](https://pdm-project.org/en/latest/#installation). + +To install with PDM, simply run: + +```console +pdm install -G:all +``` + +This installs all the dependencies for development, testing, and building the docs. If you'd only like development dependencies, you can run: + +```console +pdm install -d +``` + +## Running Examples + +The Python workspace includes launch configurations for all packages: + +- "Run Computer Examples" - Runs computer examples +- "Run Computer API Server" - Runs the computer-server +- "Run Agent Examples" - Runs agent examples +- "SOM" configurations - Various settings for running SOM + +To run examples from VSCode / Cursor: + +1. Press F5 or use the Run/Debug view +2. Select the desired configuration + +The workspace also includes compound launch configurations: + +- "Run Computer Examples + Server" - Runs both the Computer Examples and Server simultaneously + +## Docker Development Environment + +As an alternative to installing directly on your host machine, you can use Docker for development. This approach has several advantages: + +### Prerequisites + +- Docker installed on your machine +- Lume server running on your host (port 7777): `lume serve` + +### Setup and Usage + +1. Build the development Docker image: + + ```bash + ./scripts/run-docker-dev.sh build + ``` + +2. Run an example in the container: + + ```bash + ./scripts/run-docker-dev.sh run computer_examples.py + ``` + +3. Get an interactive shell in the container: + + ```bash + ./scripts/run-docker-dev.sh run --interactive + ``` + +4. Stop any running containers: + + ```bash + ./scripts/run-docker-dev.sh stop + ``` + +### How it Works + +The Docker development environment: + +- Installs all required Python dependencies in the container +- Mounts your source code from the host at runtime +- Automatically configures the connection to use host.docker.internal:7777 for accessing the Lume server on your host machine +- Preserves your code changes without requiring rebuilds (source code is mounted as a volume) + +> **Note**: The Docker container doesn't include the macOS-specific Lume executable. Instead, it connects to the Lume server running on your host machine via host.docker.internal:7777. Make sure to start the Lume server on your host before running examples in the container. + +## Cleanup and Reset + +If you need to clean up the environment (non-docker) and start fresh: + +```bash +./scripts/cleanup.sh +``` + +This will: + +- Remove all virtual environments +- Clean Python cache files and directories +- Remove build artifacts +- Clean PDM-related files +- Reset environment configurations + +## Code Formatting Standards + +The cua project follows strict code formatting standards to ensure consistency across all packages. + +### Python Code Formatting + +#### Tools + +The project uses the following tools for code formatting and linting: + +- **[Black](https://black.readthedocs.io/)**: Code formatter +- **[Ruff](https://beta.ruff.rs/docs/)**: Fast linter and formatter +- **[MyPy](https://mypy.readthedocs.io/)**: Static type checker + +These tools are automatically installed when you set up the development environment using the `./scripts/build.sh` script. + +#### Configuration + +The formatting configuration is defined in the root `pyproject.toml` file: + +```toml +[tool.black] +line-length = 100 +target-version = ["py311"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +select = ["E", "F", "B", "I"] +fix = true + +[tool.ruff.format] +docstring-code-format = true + +[tool.mypy] +strict = true +python_version = "3.11" +ignore_missing_imports = true +disallow_untyped_defs = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +``` + +#### Key Formatting Rules + +- **Line Length**: Maximum of 100 characters +- **Python Version**: Code should be compatible with Python 3.11+ +- **Imports**: Automatically sorted (using Ruff's "I" rule) +- **Type Hints**: Required for all function definitions (strict mypy mode) + +#### IDE Integration + +The repository includes VSCode workspace configurations that enable automatic formatting. When you open the workspace files (as recommended in the setup instructions), the correct formatting settings are automatically applied. + +Python-specific settings in the workspace files: + +```json +"[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } +} +``` + +Recommended VS Code extensions: + +- Black Formatter (ms-python.black-formatter) +- Ruff (charliermarsh.ruff) +- Pylance (ms-python.vscode-pylance) + +#### Manual Formatting + +To manually format code: + +```bash +# Format all Python files using Black +pdm run black . + +# Run Ruff linter with auto-fix +pdm run ruff check --fix . + +# Run type checking with MyPy +pdm run mypy . +``` + +#### Pre-commit Validation + +Before submitting a pull request, ensure your code passes all formatting checks: + +```bash +# Run all checks +pdm run black --check . +pdm run ruff check . +pdm run mypy . +``` + +### Swift Code (Lume) + +For Swift code in the `libs/lume` directory: + +- Follow the [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) +- Use SwiftFormat for consistent formatting +- Code will be automatically formatted on save when using the lume workspace diff --git a/examples/agent_ui_examples.py b/examples/agent_ui_examples.py index 17130f5c..d5a37119 100644 --- a/examples/agent_ui_examples.py +++ b/examples/agent_ui_examples.py @@ -18,4 +18,8 @@ from agent.ui.gradio.app import create_gradio_ui if __name__ == "__main__": print("Launching Computer-Use Agent Gradio UI with advanced features...") app = create_gradio_ui() - app.launch(share=False) + app.launch( + share=False, + server_name="0.0.0.0", + server_port=7860, + ) diff --git a/examples/computer-example-ts/.env.example b/examples/computer-example-ts/.env.example new file mode 100644 index 00000000..0496a574 --- /dev/null +++ b/examples/computer-example-ts/.env.example @@ -0,0 +1,3 @@ +OPENAI_KEY= +CUA_KEY= +CUA_CONTAINER_NAME= \ No newline at end of file diff --git a/examples/computer-example-ts/.gitignore b/examples/computer-example-ts/.gitignore new file mode 100644 index 00000000..9bdf3559 --- /dev/null +++ b/examples/computer-example-ts/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +.env \ No newline at end of file diff --git a/examples/computer-example-ts/.prettierrc b/examples/computer-example-ts/.prettierrc new file mode 100644 index 00000000..23eaef29 --- /dev/null +++ b/examples/computer-example-ts/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true +} \ No newline at end of file diff --git a/examples/computer-example-ts/README.md b/examples/computer-example-ts/README.md new file mode 100644 index 00000000..1e61eab0 --- /dev/null +++ b/examples/computer-example-ts/README.md @@ -0,0 +1,47 @@ +# cua-cloud-openai Example + +This example demonstrates how to control a c/ua Cloud container using the OpenAI `computer-use-preview` model and the `@trycua/computer` TypeScript library. + +## Overview + +- Connects to a c/ua Cloud container via the `@trycua/computer` library +- Sends screenshots and instructions to OpenAI's computer-use model +- Executes AI-generated actions (clicks, typing, etc.) inside the container +- Designed for Linux containers, but can be adapted for other OS types + +## Getting Started + +1. **Install dependencies:** + + ```bash + npm install + ``` + +2. **Set up environment variables:** + Create a `.env` file with the following variables: + - `OPENAI_KEY` — your OpenAI API key + - `CUA_KEY` — your c/ua Cloud API key + - `CUA_CONTAINER_NAME` — the name of your provisioned container + +3. **Run the example:** + + ```bash + npx tsx src/index.ts + ``` + +## Files + +- `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 c/ua Cloud Container 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/examples/computer-example-ts/package.json b/examples/computer-example-ts/package.json new file mode 100644 index 00000000..65210e18 --- /dev/null +++ b/examples/computer-example-ts/package.json @@ -0,0 +1,25 @@ +{ + "name": "computer-example-ts", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts" + }, + "keywords": [], + "author": "", + "license": "MIT", + "packageManager": "pnpm@10.12.3", + "dependencies": { + "@trycua/computer": "^0.1.3", + "dotenv": "^16.5.0", + "openai": "^5.7.0" + }, + "devDependencies": { + "@types/node": "^22.15.33", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} \ No newline at end of file diff --git a/examples/computer-example-ts/pnpm-lock.yaml b/examples/computer-example-ts/pnpm-lock.yaml new file mode 100644 index 00000000..f0752379 --- /dev/null +++ b/examples/computer-example-ts/pnpm-lock.yaml @@ -0,0 +1,507 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@trycua/computer': + specifier: ^0.1.3 + version: 0.1.3 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + openai: + specifier: ^5.7.0 + version: 5.8.2(ws@8.18.3) + devDependencies: + '@types/node': + specifier: ^22.15.33 + version: 22.16.0 + tsx: + specifier: ^4.20.3 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + +packages: + + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@trycua/computer@0.1.3': + resolution: {integrity: sha512-RTDgULV6wQJuTsiwhei9aQO6YQSM1TBQqOCDUPHUbTIjtRqzMvMdwtcKAKxZZptzJcBX14bWtbucY65Wu6IEFg==} + + '@trycua/core@0.1.3': + resolution: {integrity: sha512-sv7BEajJyZ+JNxrOdhao4qCOtRrh+S0XYf64ehAT4UAhLC73Kep06bGa/Uel0Ow5xGXXrg0aiVBL7zO9+/w4/Q==} + + '@types/node@22.16.0': + resolution: {integrity: sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + openai@5.8.2: + resolution: {integrity: sha512-8C+nzoHYgyYOXhHGN6r0fcb4SznuEn1R7YZMvlqDbnCuE0FM2mm3T1HiYW6WIcMS/F1Of2up/cSPjLPaWt0X9Q==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.7.0: + resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} + hasBin: true + + posthog-node@5.1.1: + resolution: {integrity: sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==} + engines: {node: '>=20'} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + + '@trycua/computer@0.1.3': + dependencies: + '@trycua/core': 0.1.3 + pino: 9.7.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@trycua/core@0.1.3': + dependencies: + '@types/uuid': 10.0.0 + pino: 9.7.0 + posthog-node: 5.1.1 + uuid: 11.1.0 + + '@types/node@22.16.0': + dependencies: + undici-types: 6.21.0 + + '@types/uuid@10.0.0': {} + + atomic-sleep@1.0.0: {} + + dotenv@16.6.1: {} + + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + + fast-redact@3.5.0: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + on-exit-leak-free@2.1.2: {} + + openai@5.8.2(ws@8.18.3): + optionalDependencies: + ws: 8.18.3 + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.7.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + posthog-node@5.1.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + resolve-pkg-maps@1.0.0: {} + + safe-stable-stringify@2.5.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tsx@4.20.3: + dependencies: + esbuild: 0.25.5 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + uuid@11.1.0: {} + + ws@8.18.3: {} diff --git a/examples/computer-example-ts/src/helpers.ts b/examples/computer-example-ts/src/helpers.ts new file mode 100644 index 00000000..adad2347 --- /dev/null +++ b/examples/computer-example-ts/src/helpers.ts @@ -0,0 +1,63 @@ +import type { Computer } from "@trycua/computer"; +import type OpenAI from "openai"; + +export async function executeAction( + computer: Computer, + action: OpenAI.Responses.ResponseComputerToolCall["action"], +) { + switch (action.type) { + case "click": { + const { x, y, button } = action; + console.log(`Executing click at (${x}, ${y}) with button '${button}'.`); + await computer.interface.moveCursor(x, y); + if (button === "right") await computer.interface.rightClick(); + else await computer.interface.leftClick(); + break; + } + case "type": + { + const { text } = action; + console.log(`Typing text: ${text}`); + await computer.interface.typeText(text); + } + break; + case "scroll": { + const { x: locX, y: locY, scroll_x, scroll_y } = action; + console.log( + `Scrolling at (${locX}, ${locY}) with offsets (scroll_x=${scroll_x}, scroll_y=${scroll_y}).`, + ); + await computer.interface.moveCursor(locX, locY); + await computer.interface.scroll(scroll_x, scroll_y); + break; + } + case "keypress": { + const { keys } = action; + for (const key of keys) { + console.log(`Pressing key: ${key}.`); + // Map common key names to CUA equivalents + if (key.toLowerCase() === "enter") { + await computer.interface.pressKey("return"); + } else if (key.toLowerCase() === "space") { + await computer.interface.pressKey("space"); + } else { + await computer.interface.pressKey(key); + } + } + break; + } + case "wait": { + console.log(`Waiting for 3 seconds.`); + await new Promise((resolve) => setTimeout(resolve, 3 * 1000)); + break; + } + case "screenshot": { + console.log("Taking screenshot."); + // This is handled automatically in the main loop, but we can take an extra one if requested + const screenshot = await computer.interface.screenshot(); + return screenshot; + } + default: + console.log(`Unrecognized action: ${action.type}`); + break; + } +} diff --git a/examples/computer-example-ts/src/index.ts b/examples/computer-example-ts/src/index.ts new file mode 100644 index 00000000..1077e088 --- /dev/null +++ b/examples/computer-example-ts/src/index.ts @@ -0,0 +1,104 @@ +import { Computer, OSType } from "@trycua/computer"; +import OpenAI from "openai"; +import { executeAction } from "./helpers"; + +import "dotenv/config"; + +const openai = new OpenAI({ apiKey: process.env.OPENAI_KEY }); + +const COMPUTER_USE_PROMPT = "Open firefox and go to trycua.com"; + +// Initialize the Computer Connection +const computer = new Computer({ + apiKey: process.env.CUA_KEY!, + name: process.env.CUA_CONTAINER_NAME!, + osType: OSType.LINUX, +}); + +await computer.run(); +// Take the initial screenshot +const screenshot = await computer.interface.screenshot(); +const screenshotBase64 = screenshot.toString("base64"); + +// Setup openai config for computer use +const computerUseConfig: OpenAI.Responses.ResponseCreateParamsNonStreaming = { + model: "computer-use-preview", + tools: [ + { + type: "computer_use_preview", + display_width: 1024, + display_height: 768, + environment: "linux", // we're using a linux vm + }, + ], + truncation: "auto", +}; + +// Send initial screenshot to the openai computer use model +let res = await openai.responses.create({ + ...computerUseConfig, + input: [ + { + role: "user", + content: [ + // what we want the ai to do + { type: "input_text", text: COMPUTER_USE_PROMPT }, + // current screenshot of the vm + { + type: "input_image", + image_url: `data:image/png;base64,${screenshotBase64}`, + detail: "auto", + }, + ], + }, + ], +}); + +// Loop until there are no more computer use actions. +while (true) { + const computerCalls = res.output.filter((o) => o.type === "computer_call"); + if (computerCalls.length < 1) { + console.log("No more computer calls. Loop complete."); + break; + } + // Get the first call + const call = computerCalls[0]; + const action = call.action; + console.log("Received action from OpenAI Responses API:", action); + let ackChecks: OpenAI.Responses.ResponseComputerToolCall.PendingSafetyCheck[] = + []; + if (call.pending_safety_checks.length > 0) { + console.log("Safety checks pending:", call.pending_safety_checks); + // In a real implementation, you would want to get user confirmation here + ackChecks = call.pending_safety_checks; + } + + // Execute the action in the container + await executeAction(computer, action); + // Wait for changes to process within the container (1sec) + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Capture new screenshot + const newScreenshot = await computer.interface.screenshot(); + const newScreenshotBase64 = newScreenshot.toString("base64"); + + // Screenshot back as computer_call_output + + res = await openai.responses.create({ + ...computerUseConfig, + previous_response_id: res.id, + input: [ + { + type: "computer_call_output", + call_id: call.call_id, + acknowledged_safety_checks: ackChecks, + output: { + type: "computer_screenshot", + image_url: `data:image/png;base64,${newScreenshotBase64}`, + }, + }, + ], + }); +} + +process.exit(); diff --git a/examples/computer-example-ts/tsconfig.json b/examples/computer-example-ts/tsconfig.json new file mode 100644 index 00000000..c606e279 --- /dev/null +++ b/examples/computer-example-ts/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": [ + "es2023" + ], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "types": [ + "node" + ], + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "outDir": "build", + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/examples/computer_ui_examples.py b/examples/computer_ui_examples.py index 4116b41e..0c1d0974 100644 --- a/examples/computer_ui_examples.py +++ b/examples/computer_ui_examples.py @@ -18,7 +18,11 @@ from computer.ui.gradio.app import create_gradio_ui if __name__ == "__main__": print("Launching Computer Interface Gradio UI with advanced features...") app = create_gradio_ui() - app.launch(share=False) + app.launch( + share=False, + server_name="0.0.0.0", + server_port=7860, + ) # Optional: Using the saved dataset # import datasets diff --git a/examples/winsandbox_example.py b/examples/winsandbox_example.py new file mode 100644 index 00000000..9cf1269a --- /dev/null +++ b/examples/winsandbox_example.py @@ -0,0 +1,51 @@ +"""Example of using the Windows Sandbox computer provider. + +Learn more at: https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/ +""" + +import asyncio +from computer import Computer + +async def main(): + """Test the Windows Sandbox provider.""" + + # Create a computer instance using Windows Sandbox + computer = Computer( + provider_type="winsandbox", + os_type="windows", + memory="4GB", + # ephemeral=True, # Always true for Windows Sandbox + ) + + try: + print("Starting Windows Sandbox...") + await computer.run() + + print("Windows Sandbox is ready!") + print(f"IP Address: {await computer.get_ip()}") + + # Test basic functionality + print("Testing basic functionality...") + screenshot = await computer.interface.screenshot() + print(f"Screenshot taken: {len(screenshot)} bytes") + + # Test running a command + print("Testing command execution...") + stdout, stderr = await computer.interface.run_command("echo Hello from Windows Sandbox!") + print(f"Command output: {stdout}") + + print("Press any key to continue...") + input() + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + finally: + print("Stopping Windows Sandbox...") + await computer.stop() + print("Windows Sandbox stopped.") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/libs/computer/computer/interface/linux.py b/libs/computer/computer/interface/linux.py deleted file mode 100644 index e96cde50..00000000 --- a/libs/computer/computer/interface/linux.py +++ /dev/null @@ -1,688 +0,0 @@ -import asyncio -import json -import time -from typing import Any, Dict, List, Optional, Tuple -from PIL import Image - -import websockets - -from ..logger import Logger, LogLevel -from .base import BaseComputerInterface -from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image -from .models import Key, KeyType, MouseButton - - -class LinuxComputerInterface(BaseComputerInterface): - """Interface for Linux.""" - - def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None): - super().__init__(ip_address, username, password, api_key, vm_name) - self._ws = None - self._reconnect_task = None - self._closed = False - self._last_ping = 0 - self._ping_interval = 5 # Send ping every 5 seconds - self._ping_timeout = 120 # Wait 120 seconds for pong response - self._reconnect_delay = 1 # Start with 1 second delay - self._max_reconnect_delay = 30 # Maximum delay between reconnection attempts - self._log_connection_attempts = True # Flag to control connection attempt logging - self._authenticated = False # Track authentication status - self._command_lock = asyncio.Lock() # Lock to ensure only one command at a time - - # Set logger name for Linux interface - self.logger = Logger("cua.interface.linux", LogLevel.NORMAL) - - @property - def ws_uri(self) -> str: - """Get the WebSocket URI using the current IP address. - - Returns: - WebSocket URI for the Computer API Server - """ - protocol = "wss" if self.api_key else "ws" - port = "8443" if self.api_key else "8000" - return f"{protocol}://{self.ip_address}:{port}/ws" - - async def _keep_alive(self): - """Keep the WebSocket connection alive with automatic reconnection.""" - retry_count = 0 - max_log_attempts = 1 # Only log the first attempt at INFO level - log_interval = 500 # Then log every 500th attempt (significantly increased from 30) - last_warning_time = 0 - min_warning_interval = 30 # Minimum seconds between connection lost warnings - min_retry_delay = 0.5 # Minimum delay between connection attempts (500ms) - - while not self._closed: - try: - if self._ws is None or ( - self._ws and self._ws.state == websockets.protocol.State.CLOSED - ): - try: - retry_count += 1 - - # Add a minimum delay between connection attempts to avoid flooding - if retry_count > 1: - await asyncio.sleep(min_retry_delay) - - # Only log the first attempt at INFO level, then every Nth attempt - if retry_count == 1: - self.logger.info(f"Attempting WebSocket connection to {self.ws_uri}") - elif retry_count % log_interval == 0: - self.logger.info( - f"Still attempting WebSocket connection (attempt {retry_count})..." - ) - else: - # All other attempts are logged at DEBUG level - self.logger.debug( - f"Attempting WebSocket connection to {self.ws_uri} (attempt {retry_count})" - ) - - self._ws = await asyncio.wait_for( - websockets.connect( - self.ws_uri, - max_size=1024 * 1024 * 10, # 10MB limit - max_queue=32, - ping_interval=self._ping_interval, - ping_timeout=self._ping_timeout, - close_timeout=5, - compression=None, # Disable compression to reduce overhead - ), - timeout=120, - ) - self.logger.info("WebSocket connection established") - - # Authentication will be handled by the first command that needs it - # Don't do authentication here to avoid recv conflicts - - self._reconnect_delay = 1 # Reset reconnect delay on successful connection - self._last_ping = time.time() - retry_count = 0 # Reset retry count on successful connection - self._authenticated = False # Reset auth status on new connection - - except (asyncio.TimeoutError, websockets.exceptions.WebSocketException) as e: - next_retry = self._reconnect_delay - - # Only log the first error at WARNING level, then every Nth attempt - if retry_count == 1: - self.logger.warning( - f"Computer API Server not ready yet. Will retry automatically." - ) - elif retry_count % log_interval == 0: - self.logger.warning( - f"Still waiting for Computer API Server (attempt {retry_count})..." - ) - else: - # All other errors are logged at DEBUG level - self.logger.debug(f"Connection attempt {retry_count} failed: {e}") - - if self._ws: - try: - await self._ws.close() - except: - pass - self._ws = None - - # Regular ping to check connection - if self._ws and self._ws.state == websockets.protocol.State.OPEN: - try: - if time.time() - self._last_ping >= self._ping_interval: - pong_waiter = await self._ws.ping() - await asyncio.wait_for(pong_waiter, timeout=self._ping_timeout) - self._last_ping = time.time() - except Exception as e: - self.logger.debug(f"Ping failed: {e}") - if self._ws: - try: - await self._ws.close() - except: - pass - self._ws = None - continue - - await asyncio.sleep(1) - - except Exception as e: - current_time = time.time() - # Only log connection lost warnings at most once every min_warning_interval seconds - if current_time - last_warning_time >= min_warning_interval: - self.logger.warning( - f"Computer API Server connection lost. Will retry automatically." - ) - last_warning_time = current_time - else: - # Log at debug level instead - self.logger.debug(f"Connection lost: {e}") - - if self._ws: - try: - await self._ws.close() - except: - pass - self._ws = None - - async def _ensure_connection(self): - """Ensure WebSocket connection is established.""" - if self._reconnect_task is None or self._reconnect_task.done(): - self._reconnect_task = asyncio.create_task(self._keep_alive()) - - retry_count = 0 - max_retries = 5 - - while retry_count < max_retries: - try: - if self._ws and self._ws.state == websockets.protocol.State.OPEN: - return - retry_count += 1 - await asyncio.sleep(1) - except Exception as e: - # Only log at ERROR level for the last retry attempt - if retry_count == max_retries - 1: - self.logger.error( - f"Persistent connection check error after {retry_count} attempts: {e}" - ) - else: - self.logger.debug(f"Connection check error (attempt {retry_count}): {e}") - retry_count += 1 - await asyncio.sleep(1) - continue - - raise ConnectionError("Failed to establish WebSocket connection after multiple retries") - - async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]: - """Send command through WebSocket.""" - max_retries = 3 - retry_count = 0 - last_error = None - - # Acquire lock to ensure only one command is processed at a time - async with self._command_lock: - self.logger.debug(f"Acquired lock for command: {command}") - while retry_count < max_retries: - try: - await self._ensure_connection() - if not self._ws: - raise ConnectionError("WebSocket connection is not established") - - # Handle authentication if needed - if self.api_key and self.vm_name and not self._authenticated: - self.logger.info("Performing authentication handshake...") - auth_message = { - "command": "authenticate", - "params": { - "api_key": self.api_key, - "container_name": self.vm_name - } - } - await self._ws.send(json.dumps(auth_message)) - - # Wait for authentication response - auth_response = await asyncio.wait_for(self._ws.recv(), timeout=10) - auth_result = json.loads(auth_response) - - if not auth_result.get("success"): - error_msg = auth_result.get("error", "Authentication failed") - self.logger.error(f"Authentication failed: {error_msg}") - self._authenticated = False - raise ConnectionError(f"Authentication failed: {error_msg}") - - self.logger.info("Authentication successful") - self._authenticated = True - - message = {"command": command, "params": params or {}} - await self._ws.send(json.dumps(message)) - response = await asyncio.wait_for(self._ws.recv(), timeout=30) - self.logger.debug(f"Completed command: {command}") - return json.loads(response) - except Exception as e: - last_error = e - retry_count += 1 - if retry_count < max_retries: - # Only log at debug level for intermediate retries - self.logger.debug( - f"Command '{command}' failed (attempt {retry_count}/{max_retries}): {e}" - ) - await asyncio.sleep(1) - continue - else: - # Only log at error level for the final failure - self.logger.error( - f"Failed to send command '{command}' after {max_retries} retries" - ) - self.logger.debug(f"Command failure details: {e}") - raise last_error if last_error else RuntimeError("Failed to send command") - - async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0): - """Wait for WebSocket connection to become available.""" - start_time = time.time() - last_error = None - attempt_count = 0 - progress_interval = 10 # Log progress every 10 seconds - last_progress_time = start_time - - # Disable detailed logging for connection attempts - self._log_connection_attempts = False - - try: - self.logger.info( - f"Waiting for Computer API Server to be ready (timeout: {timeout}s)..." - ) - - # Start the keep-alive task if it's not already running - if self._reconnect_task is None or self._reconnect_task.done(): - self._reconnect_task = asyncio.create_task(self._keep_alive()) - - # Wait for the connection to be established - while time.time() - start_time < timeout: - try: - attempt_count += 1 - current_time = time.time() - - # Log progress periodically without flooding logs - if current_time - last_progress_time >= progress_interval: - elapsed = current_time - start_time - self.logger.info( - f"Still waiting for Computer API Server... (elapsed: {elapsed:.1f}s, attempts: {attempt_count})" - ) - last_progress_time = current_time - - # Check if we have a connection - if self._ws and self._ws.state == websockets.protocol.State.OPEN: - # Test the connection with a simple command - try: - await self._send_command("get_screen_size") - elapsed = time.time() - start_time - self.logger.info( - f"Computer API Server is ready (after {elapsed:.1f}s, {attempt_count} attempts)" - ) - return # Connection is fully working - except Exception as e: - last_error = e - self.logger.debug(f"Connection test failed: {e}") - - # Wait before trying again - await asyncio.sleep(interval) - - except Exception as e: - last_error = e - self.logger.debug(f"Connection attempt {attempt_count} failed: {e}") - await asyncio.sleep(interval) - - # If we get here, we've timed out - error_msg = f"Could not connect to {self.ip_address} after {timeout} seconds" - if last_error: - error_msg += f": {str(last_error)}" - self.logger.error(error_msg) - raise TimeoutError(error_msg) - finally: - # Reset to default logging behavior - self._log_connection_attempts = False - - def close(self): - """Close WebSocket connection. - - Note: In host computer server mode, we leave the connection open - to allow other clients to connect to the same server. The server - will handle cleaning up idle connections. - """ - # Only cancel the reconnect task - if self._reconnect_task: - self._reconnect_task.cancel() - - # Don't set closed flag or close websocket by default - # This allows the server to stay connected for other clients - # self._closed = True - # if self._ws: - # asyncio.create_task(self._ws.close()) - # self._ws = None - - def force_close(self): - """Force close the WebSocket connection. - - This method should be called when you want to completely - shut down the connection, not just for regular cleanup. - """ - self._closed = True - if self._reconnect_task: - self._reconnect_task.cancel() - if self._ws: - asyncio.create_task(self._ws.close()) - self._ws = None - - # Mouse Actions - async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None: - await self._send_command("mouse_down", {"x": x, "y": y, "button": button}) - - async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None: - await self._send_command("mouse_up", {"x": x, "y": y, "button": button}) - - async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("left_click", {"x": x, "y": y}) - - async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("right_click", {"x": x, "y": y}) - - async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("double_click", {"x": x, "y": y}) - - async def move_cursor(self, x: int, y: int) -> None: - await self._send_command("move_cursor", {"x": x, "y": y}) - - async def drag_to(self, x: int, y: int, button: "MouseButton" = "left", duration: float = 0.5) -> None: - await self._send_command( - "drag_to", {"x": x, "y": y, "button": button, "duration": duration} - ) - - async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left", duration: float = 0.5) -> None: - await self._send_command( - "drag", {"path": path, "button": button, "duration": duration} - ) - - # Keyboard Actions - async def key_down(self, key: "KeyType") -> None: - await self._send_command("key_down", {"key": key}) - - async def key_up(self, key: "KeyType") -> None: - await self._send_command("key_up", {"key": key}) - - async def type_text(self, text: str) -> None: - # Temporary fix for https://github.com/trycua/cua/issues/165 - # Check if text contains Unicode characters - if any(ord(char) > 127 for char in text): - # For Unicode text, use clipboard and paste - await self.set_clipboard(text) - await self.hotkey(Key.COMMAND, 'v') - else: - # For ASCII text, use the regular typing method - await self._send_command("type_text", {"text": text}) - - async def press(self, key: "KeyType") -> None: - """Press a single key. - - Args: - key: The key to press. Can be any of: - - A Key enum value (recommended), e.g. Key.PAGE_DOWN - - A direct key value string, e.g. 'pagedown' - - A single character string, e.g. 'a' - - Examples: - ```python - # Using enum (recommended) - await interface.press(Key.PAGE_DOWN) - await interface.press(Key.ENTER) - - # Using direct values - await interface.press('pagedown') - await interface.press('enter') - - # Using single characters - await interface.press('a') - ``` - - Raises: - ValueError: If the key type is invalid or the key is not recognized - """ - if isinstance(key, Key): - actual_key = key.value - elif isinstance(key, str): - # Try to convert to enum if it matches a known key - key_or_enum = Key.from_string(key) - actual_key = key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum - else: - raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") - - await self._send_command("press_key", {"key": actual_key}) - - async def press_key(self, key: "KeyType") -> None: - """DEPRECATED: Use press() instead. - - This method is kept for backward compatibility but will be removed in a future version. - Please use the press() method instead. - """ - await self.press(key) - - async def hotkey(self, *keys: "KeyType") -> None: - """Press multiple keys simultaneously. - - Args: - *keys: Multiple keys to press simultaneously. Each key can be any of: - - A Key enum value (recommended), e.g. Key.COMMAND - - A direct key value string, e.g. 'command' - - A single character string, e.g. 'a' - - Examples: - ```python - # Using enums (recommended) - await interface.hotkey(Key.COMMAND, Key.C) # Copy - await interface.hotkey(Key.COMMAND, Key.V) # Paste - - # Using mixed formats - await interface.hotkey(Key.COMMAND, 'a') # Select all - ``` - - Raises: - ValueError: If any key type is invalid or not recognized - """ - actual_keys = [] - for key in keys: - if isinstance(key, Key): - actual_keys.append(key.value) - elif isinstance(key, str): - # Try to convert to enum if it matches a known key - key_or_enum = Key.from_string(key) - actual_keys.append(key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum) - else: - raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") - - await self._send_command("hotkey", {"keys": actual_keys}) - - # Scrolling Actions - async def scroll(self, x: int, y: int) -> None: - await self._send_command("scroll", {"x": x, "y": y}) - - async def scroll_down(self, clicks: int = 1) -> None: - await self._send_command("scroll_down", {"clicks": clicks}) - - async def scroll_up(self, clicks: int = 1) -> None: - await self._send_command("scroll_up", {"clicks": clicks}) - - # Screen Actions - async def screenshot( - self, - boxes: Optional[List[Tuple[int, int, int, int]]] = None, - box_color: str = "#FF0000", - box_thickness: int = 2, - scale_factor: float = 1.0, - ) -> bytes: - """Take a screenshot with optional box drawing and scaling. - - Args: - boxes: Optional list of (x, y, width, height) tuples defining boxes to draw in screen coordinates - box_color: Color of the boxes in hex format (default: "#FF0000" red) - box_thickness: Thickness of the box borders in pixels (default: 2) - scale_factor: Factor to scale the final image by (default: 1.0) - Use > 1.0 to enlarge, < 1.0 to shrink (e.g., 0.5 for half size, 2.0 for double) - - Returns: - bytes: The screenshot image data, optionally with boxes drawn on it and scaled - """ - result = await self._send_command("screenshot") - if not result.get("image_data"): - raise RuntimeError("Failed to take screenshot") - - screenshot = decode_base64_image(result["image_data"]) - - if boxes: - # Get the natural scaling between screen and screenshot - screen_size = await self.get_screen_size() - screenshot_width, screenshot_height = bytes_to_image(screenshot).size - width_scale = screenshot_width / screen_size["width"] - height_scale = screenshot_height / screen_size["height"] - - # Scale box coordinates from screen space to screenshot space - for box in boxes: - scaled_box = ( - int(box[0] * width_scale), # x - int(box[1] * height_scale), # y - int(box[2] * width_scale), # width - int(box[3] * height_scale), # height - ) - screenshot = draw_box( - screenshot, - x=scaled_box[0], - y=scaled_box[1], - width=scaled_box[2], - height=scaled_box[3], - color=box_color, - thickness=box_thickness, - ) - - if scale_factor != 1.0: - screenshot = resize_image(screenshot, scale_factor) - - return screenshot - - async def get_screen_size(self) -> Dict[str, int]: - result = await self._send_command("get_screen_size") - if result["success"] and result["size"]: - return result["size"] - raise RuntimeError("Failed to get screen size") - - async def get_cursor_position(self) -> Dict[str, int]: - result = await self._send_command("get_cursor_position") - if result["success"] and result["position"]: - return result["position"] - raise RuntimeError("Failed to get cursor position") - - # Clipboard Actions - async def copy_to_clipboard(self) -> str: - result = await self._send_command("copy_to_clipboard") - if result["success"] and result["content"]: - return result["content"] - raise RuntimeError("Failed to get clipboard content") - - async def set_clipboard(self, text: str) -> None: - await self._send_command("set_clipboard", {"text": text}) - - # File System Actions - async def file_exists(self, path: str) -> bool: - result = await self._send_command("file_exists", {"path": path}) - return result.get("exists", False) - - async def directory_exists(self, path: str) -> bool: - result = await self._send_command("directory_exists", {"path": path}) - return result.get("exists", False) - - async def list_dir(self, path: str) -> list[str]: - result = await self._send_command("list_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to list directory")) - return result.get("files", []) - - async def read_text(self, path: str) -> str: - result = await self._send_command("read_text", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to read file")) - return result.get("content", "") - - async def write_text(self, path: str, content: str) -> None: - result = await self._send_command("write_text", {"path": path, "content": content}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to write file")) - - async def read_bytes(self, path: str) -> bytes: - result = await self._send_command("read_bytes", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to read file")) - content_b64 = result.get("content_b64", "") - return decode_base64_image(content_b64) - - async def write_bytes(self, path: str, content: bytes) -> None: - result = await self._send_command("write_bytes", {"path": path, "content_b64": encode_base64_image(content)}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to write file")) - - async def delete_file(self, path: str) -> None: - result = await self._send_command("delete_file", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to delete file")) - - async def create_dir(self, path: str) -> None: - result = await self._send_command("create_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to create directory")) - - async def delete_dir(self, path: str) -> None: - result = await self._send_command("delete_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to delete directory")) - - async def run_command(self, command: str) -> Tuple[str, str]: - result = await self._send_command("run_command", {"command": command}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to run command")) - return result.get("stdout", ""), result.get("stderr", "") - - # Accessibility Actions - async def get_accessibility_tree(self) -> Dict[str, Any]: - """Get the accessibility tree of the current screen.""" - result = await self._send_command("get_accessibility_tree") - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to get accessibility tree")) - return result - - async def get_active_window_bounds(self) -> Dict[str, int]: - """Get the bounds of the currently active window.""" - result = await self._send_command("get_active_window_bounds") - if result["success"] and result["bounds"]: - return result["bounds"] - raise RuntimeError("Failed to get active window bounds") - - async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]: - """Convert screenshot coordinates to screen coordinates. - - Args: - x: X coordinate in screenshot space - y: Y coordinate in screenshot space - - Returns: - tuple[float, float]: (x, y) coordinates in screen space - """ - screen_size = await self.get_screen_size() - screenshot = await self.screenshot() - screenshot_img = bytes_to_image(screenshot) - screenshot_width, screenshot_height = screenshot_img.size - - # Calculate scaling factors - width_scale = screen_size["width"] / screenshot_width - height_scale = screen_size["height"] / screenshot_height - - # Convert coordinates - screen_x = x * width_scale - screen_y = y * height_scale - - return screen_x, screen_y - - async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]: - """Convert screen coordinates to screenshot coordinates. - - Args: - x: X coordinate in screen space - y: Y coordinate in screen space - - Returns: - tuple[float, float]: (x, y) coordinates in screenshot space - """ - screen_size = await self.get_screen_size() - screenshot = await self.screenshot() - screenshot_img = bytes_to_image(screenshot) - screenshot_width, screenshot_height = screenshot_img.size - - # Calculate scaling factors - width_scale = screenshot_width / screen_size["width"] - height_scale = screenshot_height / screen_size["height"] - - # Convert coordinates - screenshot_x = x * width_scale - screenshot_y = y * height_scale - - return screenshot_x, screenshot_y diff --git a/libs/lume/scripts/build/build-release-notarized.sh b/libs/lume/scripts/build/build-release-notarized.sh index 19fb2e88..603446b7 100755 --- a/libs/lume/scripts/build/build-release-notarized.sh +++ b/libs/lume/scripts/build/build-release-notarized.sh @@ -72,12 +72,23 @@ cp -f .build/release/lume "$TEMP_ROOT/usr/local/bin/" # Build the installer package log "essential" "Building installer package..." -pkgbuild --root "$TEMP_ROOT" \ +if ! pkgbuild --root "$TEMP_ROOT" \ --identifier "com.trycua.lume" \ --version "1.0" \ --install-location "/" \ --sign "$CERT_INSTALLER_NAME" \ - ./.release/lume.pkg 2> /dev/null + ./.release/lume.pkg; then + log "error" "Failed to build installer package" + exit 1 +fi + +# Verify the package was created +if [ ! -f "./.release/lume.pkg" ]; then + log "error" "Package file ./.release/lume.pkg was not created" + exit 1 +fi + +log "essential" "Package created successfully" # Submit for notarization using stored credentials log "essential" "Submitting for notarization..." @@ -89,24 +100,33 @@ if [ "$LOG_LEVEL" = "minimal" ] || [ "$LOG_LEVEL" = "none" ]; then --password "${APP_SPECIFIC_PASSWORD}" \ --wait 2>&1) - # Just show success or failure + # Check if notarization was successful if echo "$NOTARY_OUTPUT" | grep -q "status: Accepted"; then log "essential" "Notarization successful!" else log "error" "Notarization failed. Please check logs." + log "error" "Notarization output:" + echo "$NOTARY_OUTPUT" + exit 1 fi else # Normal verbose output - xcrun notarytool submit ./.release/lume.pkg \ + if ! xcrun notarytool submit ./.release/lume.pkg \ --apple-id "${APPLE_ID}" \ --team-id "${TEAM_ID}" \ --password "${APP_SPECIFIC_PASSWORD}" \ - --wait + --wait; then + log "error" "Notarization failed" + exit 1 + fi fi # Staple the notarization ticket log "essential" "Stapling notarization ticket..." -xcrun stapler staple ./.release/lume.pkg > /dev/null 2>&1 +if ! xcrun stapler staple ./.release/lume.pkg > /dev/null 2>&1; then + log "error" "Failed to staple notarization ticket" + exit 1 +fi # Create temporary directory for package extraction EXTRACT_ROOT=$(mktemp -d) diff --git a/libs/agent/README.md b/libs/python/agent/README.md similarity index 97% rename from libs/agent/README.md rename to libs/python/agent/README.md index 31d1accd..d1c82a5e 100644 --- a/libs/agent/README.md +++ b/libs/python/agent/README.md @@ -34,10 +34,7 @@ pip install "cua-agent[anthropic]" # Anthropic Cua Loop pip install "cua-agent[uitars]" # UI-Tars support pip install "cua-agent[omni]" # Cua Loop based on OmniParser (includes Ollama for local models) pip install "cua-agent[ui]" # Gradio UI for the agent - -# For local UI-TARS with MLX support, you need to manually install mlx-vlm: -pip install "cua-agent[uitars-mlx]" -pip install git+https://github.com/ddupont808/mlx-vlm.git@stable/fix/qwen2-position-id # PR: https://github.com/Blaizzy/mlx-vlm/pull/349 +pip install "cua-agent[uitars-mlx]" # MLX UI-Tars support ``` ## Run diff --git a/libs/agent/agent/__init__.py b/libs/python/agent/agent/__init__.py similarity index 97% rename from libs/agent/agent/__init__.py rename to libs/python/agent/agent/__init__.py index 230eb91a..70c20add 100644 --- a/libs/agent/agent/__init__.py +++ b/libs/python/agent/agent/__init__.py @@ -6,7 +6,7 @@ import logging __version__ = "0.1.0" # Initialize logging -logger = logging.getLogger("cua.agent") +logger = logging.getLogger("agent") # Initialize telemetry when the package is imported try: diff --git a/libs/agent/agent/core/__init__.py b/libs/python/agent/agent/core/__init__.py similarity index 100% rename from libs/agent/agent/core/__init__.py rename to libs/python/agent/agent/core/__init__.py diff --git a/libs/agent/agent/core/agent.py b/libs/python/agent/agent/core/agent.py similarity index 99% rename from libs/agent/agent/core/agent.py rename to libs/python/agent/agent/core/agent.py index 6f2c6278..e9d3b866 100644 --- a/libs/agent/agent/core/agent.py +++ b/libs/python/agent/agent/core/agent.py @@ -11,10 +11,8 @@ from .types import AgentResponse from .factory import LoopFactory from .provider_config import DEFAULT_MODELS, ENV_VARS -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - class ComputerAgent: """A computer agent that can perform automated tasks using natural language instructions.""" diff --git a/libs/agent/agent/core/base.py b/libs/python/agent/agent/core/base.py similarity index 100% rename from libs/agent/agent/core/base.py rename to libs/python/agent/agent/core/base.py diff --git a/libs/agent/agent/core/callbacks.py b/libs/python/agent/agent/core/callbacks.py similarity index 100% rename from libs/agent/agent/core/callbacks.py rename to libs/python/agent/agent/core/callbacks.py diff --git a/libs/agent/agent/core/experiment.py b/libs/python/agent/agent/core/experiment.py similarity index 100% rename from libs/agent/agent/core/experiment.py rename to libs/python/agent/agent/core/experiment.py diff --git a/libs/agent/agent/core/factory.py b/libs/python/agent/agent/core/factory.py similarity index 100% rename from libs/agent/agent/core/factory.py rename to libs/python/agent/agent/core/factory.py diff --git a/libs/agent/agent/core/messages.py b/libs/python/agent/agent/core/messages.py similarity index 85% rename from libs/agent/agent/core/messages.py rename to libs/python/agent/agent/core/messages.py index 2a582a7a..d2c70558 100644 --- a/libs/agent/agent/core/messages.py +++ b/libs/python/agent/agent/core/messages.py @@ -81,16 +81,27 @@ class StandardMessageManager: if not self.config.num_images_to_keep: return messages - # Find user messages with images + # Find messages with images (both user messages and tool call outputs) image_messages = [] for msg in messages: + has_image = False + + # Check user messages with images if msg["role"] == "user" and isinstance(msg["content"], list): has_image = any( item.get("type") == "image_url" or item.get("type") == "image" for item in msg["content"] ) - if has_image: - image_messages.append(msg) + + # Check assistant messages with tool calls that have images + elif msg["role"] == "assistant" and isinstance(msg["content"], list): + for item in msg["content"]: + if item.get("type") == "tool_result" and "base64_image" in item: + has_image = True + break + + if has_image: + image_messages.append(msg) # If we don't have more images than the limit, return all messages if len(image_messages) <= self.config.num_images_to_keep: @@ -100,13 +111,35 @@ class StandardMessageManager: images_to_keep = image_messages[-self.config.num_images_to_keep :] images_to_remove = image_messages[: -self.config.num_images_to_keep] - # Create a new message list without the older images + # Create a new message list, removing images from older messages result = [] for msg in messages: if msg in images_to_remove: - # Skip this message - continue - result.append(msg) + # Remove images from this message but keep the text content + if msg["role"] == "user" and isinstance(msg["content"], list): + # Keep only text content, remove images + new_content = [ + item for item in msg["content"] + if item.get("type") not in ["image_url", "image"] + ] + if new_content: # Only add if there's still content + result.append({"role": msg["role"], "content": new_content}) + elif msg["role"] == "assistant" and isinstance(msg["content"], list): + # Remove base64_image from tool_result items + new_content = [] + for item in msg["content"]: + if item.get("type") == "tool_result" and "base64_image" in item: + # Create a copy without the base64_image + new_item = {k: v for k, v in item.items() if k != "base64_image"} + new_content.append(new_item) + else: + new_content.append(item) + result.append({"role": msg["role"], "content": new_content}) + else: + # For other message types, keep as is + result.append(msg) + else: + result.append(msg) return result diff --git a/libs/agent/agent/core/provider_config.py b/libs/python/agent/agent/core/provider_config.py similarity index 100% rename from libs/agent/agent/core/provider_config.py rename to libs/python/agent/agent/core/provider_config.py diff --git a/libs/agent/agent/core/telemetry.py b/libs/python/agent/agent/core/telemetry.py similarity index 98% rename from libs/agent/agent/core/telemetry.py rename to libs/python/agent/agent/core/telemetry.py index 3c708b17..d3e33a25 100644 --- a/libs/agent/agent/core/telemetry.py +++ b/libs/python/agent/agent/core/telemetry.py @@ -34,7 +34,7 @@ flush = _default_flush is_telemetry_enabled = _default_is_telemetry_enabled is_telemetry_globally_disabled = _default_is_telemetry_globally_disabled -logger = logging.getLogger("cua.agent.telemetry") +logger = logging.getLogger("agent.telemetry") try: # Import from core telemetry diff --git a/libs/agent/agent/core/tools.py b/libs/python/agent/agent/core/tools.py similarity index 100% rename from libs/agent/agent/core/tools.py rename to libs/python/agent/agent/core/tools.py diff --git a/libs/agent/agent/core/tools/__init__.py b/libs/python/agent/agent/core/tools/__init__.py similarity index 100% rename from libs/agent/agent/core/tools/__init__.py rename to libs/python/agent/agent/core/tools/__init__.py diff --git a/libs/agent/agent/core/tools/base.py b/libs/python/agent/agent/core/tools/base.py similarity index 100% rename from libs/agent/agent/core/tools/base.py rename to libs/python/agent/agent/core/tools/base.py diff --git a/libs/agent/agent/core/tools/bash.py b/libs/python/agent/agent/core/tools/bash.py similarity index 100% rename from libs/agent/agent/core/tools/bash.py rename to libs/python/agent/agent/core/tools/bash.py diff --git a/libs/agent/agent/core/tools/collection.py b/libs/python/agent/agent/core/tools/collection.py similarity index 100% rename from libs/agent/agent/core/tools/collection.py rename to libs/python/agent/agent/core/tools/collection.py diff --git a/libs/agent/agent/core/tools/computer.py b/libs/python/agent/agent/core/tools/computer.py similarity index 100% rename from libs/agent/agent/core/tools/computer.py rename to libs/python/agent/agent/core/tools/computer.py diff --git a/libs/agent/agent/core/tools/edit.py b/libs/python/agent/agent/core/tools/edit.py similarity index 100% rename from libs/agent/agent/core/tools/edit.py rename to libs/python/agent/agent/core/tools/edit.py diff --git a/libs/agent/agent/core/tools/manager.py b/libs/python/agent/agent/core/tools/manager.py similarity index 100% rename from libs/agent/agent/core/tools/manager.py rename to libs/python/agent/agent/core/tools/manager.py diff --git a/libs/agent/agent/core/types.py b/libs/python/agent/agent/core/types.py similarity index 100% rename from libs/agent/agent/core/types.py rename to libs/python/agent/agent/core/types.py diff --git a/libs/agent/agent/core/visualization.py b/libs/python/agent/agent/core/visualization.py similarity index 100% rename from libs/agent/agent/core/visualization.py rename to libs/python/agent/agent/core/visualization.py diff --git a/libs/agent/agent/providers/__init__.py b/libs/python/agent/agent/providers/__init__.py similarity index 100% rename from libs/agent/agent/providers/__init__.py rename to libs/python/agent/agent/providers/__init__.py diff --git a/libs/agent/agent/providers/anthropic/__init__.py b/libs/python/agent/agent/providers/anthropic/__init__.py similarity index 100% rename from libs/agent/agent/providers/anthropic/__init__.py rename to libs/python/agent/agent/providers/anthropic/__init__.py diff --git a/libs/agent/agent/providers/anthropic/api/client.py b/libs/python/agent/agent/providers/anthropic/api/client.py similarity index 100% rename from libs/agent/agent/providers/anthropic/api/client.py rename to libs/python/agent/agent/providers/anthropic/api/client.py diff --git a/libs/agent/agent/providers/anthropic/api/logging.py b/libs/python/agent/agent/providers/anthropic/api/logging.py similarity index 100% rename from libs/agent/agent/providers/anthropic/api/logging.py rename to libs/python/agent/agent/providers/anthropic/api/logging.py diff --git a/libs/agent/agent/providers/anthropic/api_handler.py b/libs/python/agent/agent/providers/anthropic/api_handler.py similarity index 100% rename from libs/agent/agent/providers/anthropic/api_handler.py rename to libs/python/agent/agent/providers/anthropic/api_handler.py diff --git a/libs/agent/agent/providers/anthropic/callbacks/__init__.py b/libs/python/agent/agent/providers/anthropic/callbacks/__init__.py similarity index 100% rename from libs/agent/agent/providers/anthropic/callbacks/__init__.py rename to libs/python/agent/agent/providers/anthropic/callbacks/__init__.py diff --git a/libs/agent/agent/providers/anthropic/callbacks/manager.py b/libs/python/agent/agent/providers/anthropic/callbacks/manager.py similarity index 100% rename from libs/agent/agent/providers/anthropic/callbacks/manager.py rename to libs/python/agent/agent/providers/anthropic/callbacks/manager.py diff --git a/libs/agent/agent/providers/anthropic/loop.py b/libs/python/agent/agent/providers/anthropic/loop.py similarity index 100% rename from libs/agent/agent/providers/anthropic/loop.py rename to libs/python/agent/agent/providers/anthropic/loop.py diff --git a/libs/agent/agent/providers/anthropic/prompts.py b/libs/python/agent/agent/providers/anthropic/prompts.py similarity index 100% rename from libs/agent/agent/providers/anthropic/prompts.py rename to libs/python/agent/agent/providers/anthropic/prompts.py diff --git a/libs/agent/agent/providers/anthropic/response_handler.py b/libs/python/agent/agent/providers/anthropic/response_handler.py similarity index 100% rename from libs/agent/agent/providers/anthropic/response_handler.py rename to libs/python/agent/agent/providers/anthropic/response_handler.py diff --git a/libs/agent/agent/providers/anthropic/tools/__init__.py b/libs/python/agent/agent/providers/anthropic/tools/__init__.py similarity index 100% rename from libs/agent/agent/providers/anthropic/tools/__init__.py rename to libs/python/agent/agent/providers/anthropic/tools/__init__.py diff --git a/libs/agent/agent/providers/anthropic/tools/base.py b/libs/python/agent/agent/providers/anthropic/tools/base.py similarity index 100% rename from libs/agent/agent/providers/anthropic/tools/base.py rename to libs/python/agent/agent/providers/anthropic/tools/base.py diff --git a/libs/agent/agent/providers/anthropic/tools/bash.py b/libs/python/agent/agent/providers/anthropic/tools/bash.py similarity index 92% rename from libs/agent/agent/providers/anthropic/tools/bash.py rename to libs/python/agent/agent/providers/anthropic/tools/bash.py index babbacfd..479e1127 100644 --- a/libs/agent/agent/providers/anthropic/tools/bash.py +++ b/libs/python/agent/agent/providers/anthropic/tools/bash.py @@ -50,8 +50,8 @@ class BashTool(BaseBashTool, BaseAnthropicTool): try: async with asyncio.timeout(self._timeout): - stdout, stderr = await self.computer.interface.run_command(command) - return CLIResult(output=stdout or "", error=stderr or "") + result = await self.computer.interface.run_command(command) + return CLIResult(output=result.stdout or "", error=result.stderr or "") except asyncio.TimeoutError as e: raise ToolError(f"Command timed out after {self._timeout} seconds") from e except Exception as e: diff --git a/libs/agent/agent/providers/anthropic/tools/collection.py b/libs/python/agent/agent/providers/anthropic/tools/collection.py similarity index 100% rename from libs/agent/agent/providers/anthropic/tools/collection.py rename to libs/python/agent/agent/providers/anthropic/tools/collection.py diff --git a/libs/agent/agent/providers/anthropic/tools/computer.py b/libs/python/agent/agent/providers/anthropic/tools/computer.py similarity index 64% rename from libs/agent/agent/providers/anthropic/tools/computer.py rename to libs/python/agent/agent/providers/anthropic/tools/computer.py index 2bb944ea..dd1dc281 100644 --- a/libs/agent/agent/providers/anthropic/tools/computer.py +++ b/libs/python/agent/agent/providers/anthropic/tools/computer.py @@ -205,26 +205,6 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): self.logger.info(f" Coordinates: ({x}, {y})") try: - # Take pre-action screenshot to get current dimensions - pre_screenshot = await self.computer.interface.screenshot() - pre_img = Image.open(io.BytesIO(pre_screenshot)) - - # Scale image to match screen dimensions if needed - if pre_img.size != (self.width, self.height): - self.logger.info( - f"Scaling image from {pre_img.size} to {self.width}x{self.height} to match screen dimensions" - ) - if not isinstance(self.width, int) or not isinstance(self.height, int): - raise ToolError("Screen dimensions must be integers") - size = (int(self.width), int(self.height)) - pre_img = pre_img.resize(size, Image.Resampling.LANCZOS) - # Save the scaled image back to bytes - buffer = io.BytesIO() - pre_img.save(buffer, format="PNG") - pre_screenshot = buffer.getvalue() - - self.logger.info(f" Current dimensions: {pre_img.width}x{pre_img.height}") - # Perform the click action if action == "left_click": self.logger.info(f"Clicking at ({x}, {y})") @@ -242,45 +222,14 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): # Wait briefly for any UI changes await asyncio.sleep(0.5) - # Take and save post-action screenshot - post_screenshot = await self.computer.interface.screenshot() - post_img = Image.open(io.BytesIO(post_screenshot)) - - # Scale post-action image if needed - if post_img.size != (self.width, self.height): - self.logger.info( - f"Scaling post-action image from {post_img.size} to {self.width}x{self.height}" - ) - post_img = post_img.resize( - (self.width, self.height), Image.Resampling.LANCZOS - ) - buffer = io.BytesIO() - post_img.save(buffer, format="PNG") - post_screenshot = buffer.getvalue() - return ToolResult( output=f"Performed {action} at ({x}, {y})", - base64_image=base64.b64encode(post_screenshot).decode(), ) except Exception as e: self.logger.error(f"Error during {action} action: {str(e)}") raise ToolError(f"Failed to perform {action}: {str(e)}") else: try: - # Take pre-action screenshot - pre_screenshot = await self.computer.interface.screenshot() - pre_img = Image.open(io.BytesIO(pre_screenshot)) - - # Scale image if needed - if pre_img.size != (self.width, self.height): - self.logger.info( - f"Scaling image from {pre_img.size} to {self.width}x{self.height}" - ) - if not isinstance(self.width, int) or not isinstance(self.height, int): - raise ToolError("Screen dimensions must be integers") - size = (int(self.width), int(self.height)) - pre_img = pre_img.resize(size, Image.Resampling.LANCZOS) - # Perform the click action if action == "left_click": self.logger.info("Performing left click at current position") @@ -295,25 +244,8 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): # Wait briefly for any UI changes await asyncio.sleep(0.5) - # Take post-action screenshot - post_screenshot = await self.computer.interface.screenshot() - post_img = Image.open(io.BytesIO(post_screenshot)) - - # Scale post-action image if needed - if post_img.size != (self.width, self.height): - self.logger.info( - f"Scaling post-action image from {post_img.size} to {self.width}x{self.height}" - ) - post_img = post_img.resize( - (self.width, self.height), Image.Resampling.LANCZOS - ) - buffer = io.BytesIO() - post_img.save(buffer, format="PNG") - post_screenshot = buffer.getvalue() - return ToolResult( output=f"Performed {action} at current position", - base64_image=base64.b64encode(post_screenshot).decode(), ) except Exception as e: self.logger.error(f"Error during {action} action: {str(e)}") @@ -328,20 +260,6 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): raise ToolError(f"{text} must be a string") try: - # Take pre-action screenshot - pre_screenshot = await self.computer.interface.screenshot() - pre_img = Image.open(io.BytesIO(pre_screenshot)) - - # Scale image if needed - if pre_img.size != (self.width, self.height): - self.logger.info( - f"Scaling image from {pre_img.size} to {self.width}x{self.height}" - ) - if not isinstance(self.width, int) or not isinstance(self.height, int): - raise ToolError("Screen dimensions must be integers") - size = (int(self.width), int(self.height)) - pre_img = pre_img.resize(size, Image.Resampling.LANCZOS) - if action == "key": # Special handling for page up/down on macOS if text.lower() in ["pagedown", "page_down", "page down"]: @@ -378,25 +296,8 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): # Wait briefly for UI changes await asyncio.sleep(0.5) - # Take post-action screenshot - post_screenshot = await self.computer.interface.screenshot() - post_img = Image.open(io.BytesIO(post_screenshot)) - - # Scale post-action image if needed - if post_img.size != (self.width, self.height): - self.logger.info( - f"Scaling post-action image from {post_img.size} to {self.width}x{self.height}" - ) - post_img = post_img.resize( - (self.width, self.height), Image.Resampling.LANCZOS - ) - buffer = io.BytesIO() - post_img.save(buffer, format="PNG") - post_screenshot = buffer.getvalue() - return ToolResult( output=f"Pressed key: {output_text}", - base64_image=base64.b64encode(post_screenshot).decode(), ) elif action == "type": @@ -406,66 +307,13 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): # Wait briefly for UI changes await asyncio.sleep(0.5) - # Take post-action screenshot - post_screenshot = await self.computer.interface.screenshot() - post_img = Image.open(io.BytesIO(post_screenshot)) - - # Scale post-action image if needed - if post_img.size != (self.width, self.height): - self.logger.info( - f"Scaling post-action image from {post_img.size} to {self.width}x{self.height}" - ) - post_img = post_img.resize( - (self.width, self.height), Image.Resampling.LANCZOS - ) - buffer = io.BytesIO() - post_img.save(buffer, format="PNG") - post_screenshot = buffer.getvalue() - return ToolResult( output=f"Typed text: {text}", - base64_image=base64.b64encode(post_screenshot).decode(), ) except Exception as e: self.logger.error(f"Error during {action} action: {str(e)}") raise ToolError(f"Failed to perform {action}: {str(e)}") - elif action in ("screenshot", "cursor_position"): - if text is not None: - raise ToolError(f"text is not accepted for {action}") - if coordinate is not None: - raise ToolError(f"coordinate is not accepted for {action}") - - try: - if action == "screenshot": - # Take screenshot - screenshot = await self.computer.interface.screenshot() - img = Image.open(io.BytesIO(screenshot)) - - # Scale image if needed - if img.size != (self.width, self.height): - self.logger.info( - f"Scaling image from {img.size} to {self.width}x{self.height}" - ) - if not isinstance(self.width, int) or not isinstance(self.height, int): - raise ToolError("Screen dimensions must be integers") - size = (int(self.width), int(self.height)) - img = img.resize(size, Image.Resampling.LANCZOS) - buffer = io.BytesIO() - img.save(buffer, format="PNG") - screenshot = buffer.getvalue() - - return ToolResult(base64_image=base64.b64encode(screenshot).decode()) - - elif action == "cursor_position": - pos = await self.computer.interface.get_cursor_position() - x, y = pos # Unpack the tuple - return ToolResult(output=f"X={int(x)},Y={int(y)}") - - except Exception as e: - self.logger.error(f"Error during {action} action: {str(e)}") - raise ToolError(f"Failed to perform {action}: {str(e)}") - elif action == "scroll": # Implement scroll action direction = kwargs.get("direction", "down") @@ -487,28 +335,20 @@ class ComputerTool(BaseComputerTool, BaseAnthropicTool): # Wait briefly for UI changes await asyncio.sleep(0.5) - # Take post-action screenshot - post_screenshot = await self.computer.interface.screenshot() - post_img = Image.open(io.BytesIO(post_screenshot)) - - # Scale post-action image if needed - if post_img.size != (self.width, self.height): - self.logger.info( - f"Scaling post-action image from {post_img.size} to {self.width}x{self.height}" - ) - post_img = post_img.resize((self.width, self.height), Image.Resampling.LANCZOS) - buffer = io.BytesIO() - post_img.save(buffer, format="PNG") - post_screenshot = buffer.getvalue() - return ToolResult( output=f"Scrolled {direction} by {amount} steps", - base64_image=base64.b64encode(post_screenshot).decode(), ) except Exception as e: self.logger.error(f"Error during scroll action: {str(e)}") raise ToolError(f"Failed to perform scroll: {str(e)}") + elif action == "screenshot": + # Take screenshot + return await self.screenshot() + elif action == "cursor_position": + pos = await self.computer.interface.get_cursor_position() + x, y = pos # Unpack the tuple + return ToolResult(output=f"X={int(x)},Y={int(y)}") raise ToolError(f"Invalid action: {action}") async def screenshot(self): diff --git a/libs/agent/agent/providers/anthropic/tools/edit.py b/libs/python/agent/agent/providers/anthropic/tools/edit.py similarity index 96% rename from libs/agent/agent/providers/anthropic/tools/edit.py rename to libs/python/agent/agent/providers/anthropic/tools/edit.py index e4da1f85..1114b586 100644 --- a/libs/agent/agent/providers/anthropic/tools/edit.py +++ b/libs/python/agent/agent/providers/anthropic/tools/edit.py @@ -95,13 +95,13 @@ class EditTool(BaseEditTool, BaseAnthropicTool): result = await self.computer.interface.run_command( f'[ -e "{str(path)}" ] && echo "exists" || echo "not exists"' ) - exists = result[0].strip() == "exists" + exists = result.stdout.strip() == "exists" if exists: result = await self.computer.interface.run_command( f'[ -d "{str(path)}" ] && echo "dir" || echo "file"' ) - is_dir = result[0].strip() == "dir" + is_dir = result.stdout.strip() == "dir" else: is_dir = False @@ -126,7 +126,7 @@ class EditTool(BaseEditTool, BaseAnthropicTool): result = await self.computer.interface.run_command( f'[ -d "{str(path)}" ] && echo "dir" || echo "file"' ) - is_dir = result[0].strip() == "dir" + is_dir = result.stdout.strip() == "dir" if is_dir: if view_range: @@ -136,7 +136,7 @@ class EditTool(BaseEditTool, BaseAnthropicTool): # List directory contents using ls result = await self.computer.interface.run_command(f'ls -la "{str(path)}"') - contents = result[0] + contents = result.stdout if contents: stdout = f"Here's the files and directories in {path}:\n{contents}\n" else: @@ -272,9 +272,9 @@ class EditTool(BaseEditTool, BaseAnthropicTool): """Read the content of a file using cat command.""" try: result = await self.computer.interface.run_command(f'cat "{str(path)}"') - if result[1]: # If there's stderr output - raise ToolError(f"Error reading file: {result[1]}") - return result[0] + if result.stderr: # If there's stderr output + raise ToolError(f"Error reading file: {result.stderr}") + return result.stdout except Exception as e: raise ToolError(f"Failed to read {path}: {str(e)}") @@ -291,8 +291,8 @@ class EditTool(BaseEditTool, BaseAnthropicTool): {content} EOFCUA""" result = await self.computer.interface.run_command(cmd) - if result[1]: # If there's stderr output - raise ToolError(f"Error writing file: {result[1]}") + if result.stderr: # If there's stderr output + raise ToolError(f"Error writing file: {result.stderr}") except Exception as e: raise ToolError(f"Failed to write to {path}: {str(e)}") diff --git a/libs/agent/agent/providers/anthropic/tools/manager.py b/libs/python/agent/agent/providers/anthropic/tools/manager.py similarity index 100% rename from libs/agent/agent/providers/anthropic/tools/manager.py rename to libs/python/agent/agent/providers/anthropic/tools/manager.py diff --git a/libs/agent/agent/providers/anthropic/tools/run.py b/libs/python/agent/agent/providers/anthropic/tools/run.py similarity index 100% rename from libs/agent/agent/providers/anthropic/tools/run.py rename to libs/python/agent/agent/providers/anthropic/tools/run.py diff --git a/libs/agent/agent/providers/anthropic/types.py b/libs/python/agent/agent/providers/anthropic/types.py similarity index 100% rename from libs/agent/agent/providers/anthropic/types.py rename to libs/python/agent/agent/providers/anthropic/types.py diff --git a/libs/agent/agent/providers/anthropic/utils.py b/libs/python/agent/agent/providers/anthropic/utils.py similarity index 100% rename from libs/agent/agent/providers/anthropic/utils.py rename to libs/python/agent/agent/providers/anthropic/utils.py diff --git a/libs/agent/agent/providers/omni/__init__.py b/libs/python/agent/agent/providers/omni/__init__.py similarity index 100% rename from libs/agent/agent/providers/omni/__init__.py rename to libs/python/agent/agent/providers/omni/__init__.py diff --git a/libs/agent/agent/providers/omni/api_handler.py b/libs/python/agent/agent/providers/omni/api_handler.py similarity index 100% rename from libs/agent/agent/providers/omni/api_handler.py rename to libs/python/agent/agent/providers/omni/api_handler.py diff --git a/libs/agent/agent/providers/omni/clients/anthropic.py b/libs/python/agent/agent/providers/omni/clients/anthropic.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/anthropic.py rename to libs/python/agent/agent/providers/omni/clients/anthropic.py diff --git a/libs/agent/agent/providers/omni/clients/base.py b/libs/python/agent/agent/providers/omni/clients/base.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/base.py rename to libs/python/agent/agent/providers/omni/clients/base.py diff --git a/libs/agent/agent/providers/omni/clients/oaicompat.py b/libs/python/agent/agent/providers/omni/clients/oaicompat.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/oaicompat.py rename to libs/python/agent/agent/providers/omni/clients/oaicompat.py diff --git a/libs/agent/agent/providers/omni/clients/ollama.py b/libs/python/agent/agent/providers/omni/clients/ollama.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/ollama.py rename to libs/python/agent/agent/providers/omni/clients/ollama.py diff --git a/libs/agent/agent/providers/omni/clients/openai.py b/libs/python/agent/agent/providers/omni/clients/openai.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/openai.py rename to libs/python/agent/agent/providers/omni/clients/openai.py diff --git a/libs/agent/agent/providers/omni/clients/utils.py b/libs/python/agent/agent/providers/omni/clients/utils.py similarity index 100% rename from libs/agent/agent/providers/omni/clients/utils.py rename to libs/python/agent/agent/providers/omni/clients/utils.py diff --git a/libs/agent/agent/providers/omni/image_utils.py b/libs/python/agent/agent/providers/omni/image_utils.py similarity index 100% rename from libs/agent/agent/providers/omni/image_utils.py rename to libs/python/agent/agent/providers/omni/image_utils.py diff --git a/libs/agent/agent/providers/omni/loop.py b/libs/python/agent/agent/providers/omni/loop.py similarity index 99% rename from libs/agent/agent/providers/omni/loop.py rename to libs/python/agent/agent/providers/omni/loop.py index 751d4fd3..faffefc0 100644 --- a/libs/agent/agent/providers/omni/loop.py +++ b/libs/python/agent/agent/providers/omni/loop.py @@ -26,10 +26,8 @@ from .api_handler import OmniAPIHandler from .tools.manager import ToolManager from .tools import ToolResult -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - def extract_data(input_string: str, data_type: str) -> str: """Extract content from code blocks.""" pattern = f"```{data_type}" + r"(.*?)(```|$)" diff --git a/libs/agent/agent/providers/omni/parser.py b/libs/python/agent/agent/providers/omni/parser.py similarity index 100% rename from libs/agent/agent/providers/omni/parser.py rename to libs/python/agent/agent/providers/omni/parser.py diff --git a/libs/agent/agent/providers/omni/prompts.py b/libs/python/agent/agent/providers/omni/prompts.py similarity index 100% rename from libs/agent/agent/providers/omni/prompts.py rename to libs/python/agent/agent/providers/omni/prompts.py diff --git a/libs/agent/agent/providers/omni/tools/__init__.py b/libs/python/agent/agent/providers/omni/tools/__init__.py similarity index 100% rename from libs/agent/agent/providers/omni/tools/__init__.py rename to libs/python/agent/agent/providers/omni/tools/__init__.py diff --git a/libs/agent/agent/providers/omni/tools/base.py b/libs/python/agent/agent/providers/omni/tools/base.py similarity index 100% rename from libs/agent/agent/providers/omni/tools/base.py rename to libs/python/agent/agent/providers/omni/tools/base.py diff --git a/libs/agent/agent/providers/omni/tools/bash.py b/libs/python/agent/agent/providers/omni/tools/bash.py similarity index 100% rename from libs/agent/agent/providers/omni/tools/bash.py rename to libs/python/agent/agent/providers/omni/tools/bash.py diff --git a/libs/agent/agent/providers/omni/tools/computer.py b/libs/python/agent/agent/providers/omni/tools/computer.py similarity index 100% rename from libs/agent/agent/providers/omni/tools/computer.py rename to libs/python/agent/agent/providers/omni/tools/computer.py diff --git a/libs/agent/agent/providers/omni/tools/manager.py b/libs/python/agent/agent/providers/omni/tools/manager.py similarity index 100% rename from libs/agent/agent/providers/omni/tools/manager.py rename to libs/python/agent/agent/providers/omni/tools/manager.py diff --git a/libs/agent/agent/providers/omni/utils.py b/libs/python/agent/agent/providers/omni/utils.py similarity index 100% rename from libs/agent/agent/providers/omni/utils.py rename to libs/python/agent/agent/providers/omni/utils.py diff --git a/libs/agent/agent/providers/openai/__init__.py b/libs/python/agent/agent/providers/openai/__init__.py similarity index 100% rename from libs/agent/agent/providers/openai/__init__.py rename to libs/python/agent/agent/providers/openai/__init__.py diff --git a/libs/agent/agent/providers/openai/api_handler.py b/libs/python/agent/agent/providers/openai/api_handler.py similarity index 100% rename from libs/agent/agent/providers/openai/api_handler.py rename to libs/python/agent/agent/providers/openai/api_handler.py diff --git a/libs/agent/agent/providers/openai/loop.py b/libs/python/agent/agent/providers/openai/loop.py similarity index 100% rename from libs/agent/agent/providers/openai/loop.py rename to libs/python/agent/agent/providers/openai/loop.py diff --git a/libs/agent/agent/providers/openai/response_handler.py b/libs/python/agent/agent/providers/openai/response_handler.py similarity index 100% rename from libs/agent/agent/providers/openai/response_handler.py rename to libs/python/agent/agent/providers/openai/response_handler.py diff --git a/libs/agent/agent/providers/openai/tools/__init__.py b/libs/python/agent/agent/providers/openai/tools/__init__.py similarity index 100% rename from libs/agent/agent/providers/openai/tools/__init__.py rename to libs/python/agent/agent/providers/openai/tools/__init__.py diff --git a/libs/agent/agent/providers/openai/tools/base.py b/libs/python/agent/agent/providers/openai/tools/base.py similarity index 100% rename from libs/agent/agent/providers/openai/tools/base.py rename to libs/python/agent/agent/providers/openai/tools/base.py diff --git a/libs/agent/agent/providers/openai/tools/computer.py b/libs/python/agent/agent/providers/openai/tools/computer.py similarity index 86% rename from libs/agent/agent/providers/openai/tools/computer.py rename to libs/python/agent/agent/providers/openai/tools/computer.py index c5602f4e..5575c792 100644 --- a/libs/agent/agent/providers/openai/tools/computer.py +++ b/libs/python/agent/agent/providers/openai/tools/computer.py @@ -61,9 +61,6 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): computer: Computer # The CUA Computer instance logger = logging.getLogger(__name__) - _screenshot_delay = 1.0 # macOS is generally faster than X11 - _scaling_enabled = True - def __init__(self, computer: Computer): """Initialize the computer tool. @@ -185,26 +182,23 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): raise ToolError(f"Failed to execute {type}: {str(e)}") async def handle_click(self, button: str, x: int, y: int) -> ToolResult: - """Handle different click actions.""" + """Handle mouse clicks.""" try: - # Perform requested click action + # Perform the click based on button type if button == "left": await self.computer.interface.left_click(x, y) elif button == "right": await self.computer.interface.right_click(x, y) elif button == "double": await self.computer.interface.double_click(x, y) + else: + raise ToolError(f"Unsupported button type: {button}") - # Wait for UI to update - await asyncio.sleep(0.5) - - # Take screenshot after action - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") + # Wait briefly for UI to update + await asyncio.sleep(0.3) return ToolResult( output=f"Performed {button} click at ({x}, {y})", - base64_image=base64_screenshot, ) except Exception as e: self.logger.error(f"Error in handle_click: {str(e)}") @@ -218,11 +212,7 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): await asyncio.sleep(0.3) - # Take screenshot after typing - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") - - return ToolResult(output=f"Typed: {text}", base64_image=base64_screenshot) + return ToolResult(output=f"Typed: {text}") except Exception as e: self.logger.error(f"Error in handle_typing: {str(e)}") raise ToolError(f"Failed to type '{text}': {str(e)}") @@ -254,11 +244,7 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): # Wait briefly await asyncio.sleep(0.3) - # Take screenshot after action - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") - - return ToolResult(output=f"Pressed key: {key}", base64_image=base64_screenshot) + return ToolResult(output=f"Pressed key: {key}") except Exception as e: self.logger.error(f"Error in handle_key: {str(e)}") raise ToolError(f"Failed to press key '{key}': {str(e)}") @@ -272,11 +258,7 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): # Wait briefly await asyncio.sleep(0.2) - # Take screenshot after action - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") - - return ToolResult(output=f"Moved cursor to ({x}, {y})", base64_image=base64_screenshot) + return ToolResult(output=f"Moved cursor to ({x}, {y})") except Exception as e: self.logger.error(f"Error in handle_mouse_move: {str(e)}") raise ToolError(f"Failed to move cursor to ({x}, {y}): {str(e)}") @@ -296,14 +278,7 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): # Wait for UI to update await asyncio.sleep(0.5) - # Take screenshot after action - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") - - return ToolResult( - output=f"Scrolled at ({x}, {y}) with delta ({scroll_x}, {scroll_y})", - base64_image=base64_screenshot, - ) + return ToolResult(output=f"Scrolled at ({x}, {y}) by ({scroll_x}, {scroll_y})") except Exception as e: self.logger.error(f"Error in handle_scroll: {str(e)}") raise ToolError(f"Failed to scroll at ({x}, {y}): {str(e)}") @@ -331,13 +306,8 @@ class ComputerTool(BaseComputerTool, BaseOpenAITool): # Wait for UI to update await asyncio.sleep(0.5) - # Take screenshot after action - screenshot = await self.computer.interface.screenshot() - base64_screenshot = base64.b64encode(screenshot).decode("utf-8") - return ToolResult( output=f"Dragged from ({path[0]['x']}, {path[0]['y']}) to ({path[-1]['x']}, {path[-1]['y']})", - base64_image=base64_screenshot, ) except Exception as e: self.logger.error(f"Error in handle_drag: {str(e)}") diff --git a/libs/agent/agent/providers/openai/tools/manager.py b/libs/python/agent/agent/providers/openai/tools/manager.py similarity index 100% rename from libs/agent/agent/providers/openai/tools/manager.py rename to libs/python/agent/agent/providers/openai/tools/manager.py diff --git a/libs/agent/agent/providers/openai/types.py b/libs/python/agent/agent/providers/openai/types.py similarity index 100% rename from libs/agent/agent/providers/openai/types.py rename to libs/python/agent/agent/providers/openai/types.py diff --git a/libs/agent/agent/providers/openai/utils.py b/libs/python/agent/agent/providers/openai/utils.py similarity index 100% rename from libs/agent/agent/providers/openai/utils.py rename to libs/python/agent/agent/providers/openai/utils.py diff --git a/libs/agent/agent/providers/uitars/__init__.py b/libs/python/agent/agent/providers/uitars/__init__.py similarity index 100% rename from libs/agent/agent/providers/uitars/__init__.py rename to libs/python/agent/agent/providers/uitars/__init__.py diff --git a/libs/agent/agent/providers/uitars/clients/base.py b/libs/python/agent/agent/providers/uitars/clients/base.py similarity index 100% rename from libs/agent/agent/providers/uitars/clients/base.py rename to libs/python/agent/agent/providers/uitars/clients/base.py diff --git a/libs/agent/agent/providers/uitars/clients/mlxvlm.py b/libs/python/agent/agent/providers/uitars/clients/mlxvlm.py similarity index 100% rename from libs/agent/agent/providers/uitars/clients/mlxvlm.py rename to libs/python/agent/agent/providers/uitars/clients/mlxvlm.py diff --git a/libs/agent/agent/providers/uitars/clients/oaicompat.py b/libs/python/agent/agent/providers/uitars/clients/oaicompat.py similarity index 100% rename from libs/agent/agent/providers/uitars/clients/oaicompat.py rename to libs/python/agent/agent/providers/uitars/clients/oaicompat.py diff --git a/libs/agent/agent/providers/uitars/loop.py b/libs/python/agent/agent/providers/uitars/loop.py similarity index 99% rename from libs/agent/agent/providers/uitars/loop.py rename to libs/python/agent/agent/providers/uitars/loop.py index 133a3b83..a28cfec4 100644 --- a/libs/agent/agent/providers/uitars/loop.py +++ b/libs/python/agent/agent/providers/uitars/loop.py @@ -25,10 +25,8 @@ from .prompts import COMPUTER_USE, SYSTEM_PROMPT, MAC_SPECIFIC_NOTES from .clients.oaicompat import OAICompatClient from .clients.mlxvlm import MLXVLMUITarsClient -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - class UITARSLoop(BaseLoop): """UI-TARS-specific implementation of the agent loop. diff --git a/libs/agent/agent/providers/uitars/prompts.py b/libs/python/agent/agent/providers/uitars/prompts.py similarity index 100% rename from libs/agent/agent/providers/uitars/prompts.py rename to libs/python/agent/agent/providers/uitars/prompts.py diff --git a/libs/agent/agent/providers/uitars/tools/__init__.py b/libs/python/agent/agent/providers/uitars/tools/__init__.py similarity index 100% rename from libs/agent/agent/providers/uitars/tools/__init__.py rename to libs/python/agent/agent/providers/uitars/tools/__init__.py diff --git a/libs/agent/agent/providers/uitars/tools/computer.py b/libs/python/agent/agent/providers/uitars/tools/computer.py similarity index 100% rename from libs/agent/agent/providers/uitars/tools/computer.py rename to libs/python/agent/agent/providers/uitars/tools/computer.py diff --git a/libs/agent/agent/providers/uitars/tools/manager.py b/libs/python/agent/agent/providers/uitars/tools/manager.py similarity index 100% rename from libs/agent/agent/providers/uitars/tools/manager.py rename to libs/python/agent/agent/providers/uitars/tools/manager.py diff --git a/libs/agent/agent/providers/uitars/utils.py b/libs/python/agent/agent/providers/uitars/utils.py similarity index 100% rename from libs/agent/agent/providers/uitars/utils.py rename to libs/python/agent/agent/providers/uitars/utils.py diff --git a/libs/agent/agent/telemetry.py b/libs/python/agent/agent/telemetry.py similarity index 100% rename from libs/agent/agent/telemetry.py rename to libs/python/agent/agent/telemetry.py diff --git a/libs/agent/agent/ui/__init__.py b/libs/python/agent/agent/ui/__init__.py similarity index 100% rename from libs/agent/agent/ui/__init__.py rename to libs/python/agent/agent/ui/__init__.py diff --git a/libs/python/agent/agent/ui/__main__.py b/libs/python/agent/agent/ui/__main__.py new file mode 100644 index 00000000..a40684a8 --- /dev/null +++ b/libs/python/agent/agent/ui/__main__.py @@ -0,0 +1,15 @@ +""" +Main entry point for agent.ui module. + +This allows running the agent UI with: + python -m agent.ui + +Instead of: + python -m agent.ui.gradio.app +""" + +from .gradio.app import create_gradio_ui + +if __name__ == "__main__": + app = create_gradio_ui() + app.launch(share=False, inbrowser=True) diff --git a/libs/agent/agent/ui/gradio/__init__.py b/libs/python/agent/agent/ui/gradio/__init__.py similarity index 100% rename from libs/agent/agent/ui/gradio/__init__.py rename to libs/python/agent/agent/ui/gradio/__init__.py diff --git a/libs/agent/agent/ui/gradio/app.py b/libs/python/agent/agent/ui/gradio/app.py similarity index 97% rename from libs/agent/agent/ui/gradio/app.py rename to libs/python/agent/agent/ui/gradio/app.py index 8d173dd6..f52a3222 100644 --- a/libs/agent/agent/ui/gradio/app.py +++ b/libs/python/agent/agent/ui/gradio/app.py @@ -132,11 +132,19 @@ class GradioChatScreenshotHandler(DefaultCallbackHandler): # Detect if current device is MacOS is_mac = platform.system().lower() == "darwin" +# Detect if lume is available (host device is macOS) +is_lume_available = is_mac or (os.environ.get("PYLUME_HOST", "localhost") != "localhost") + +print("PYLUME_HOST: ", os.environ.get("PYLUME_HOST", "localhost")) +print("is_mac: ", is_mac) +print("Lume available: ", is_lume_available) + # Map model names to specific provider model names MODEL_MAPPINGS = { "openai": { # Default to operator CUA model "default": "computer-use-preview", + "OpenAI: Computer-Use Preview": "computer-use-preview", # Map standard OpenAI model names to CUA-specific model names "gpt-4-turbo": "computer-use-preview", "gpt-4o": "computer-use-preview", @@ -147,9 +155,17 @@ MODEL_MAPPINGS = { "anthropic": { # Default to newest model "default": "claude-3-7-sonnet-20250219", + # New Claude 4 models + "Anthropic: Claude 4 Opus (20250514)": "claude-opus-4-20250514", + "Anthropic: Claude 4 Sonnet (20250514)": "claude-sonnet-4-20250514", + "claude-opus-4-20250514": "claude-opus-4-20250514", + "claude-sonnet-4-20250514": "claude-sonnet-4-20250514", + # Specific Claude models for CUA - "claude-3-5-sonnet-20240620": "claude-3-5-sonnet-20240620", + "Anthropic: Claude 3.7 Sonnet (20250219)": "claude-3-5-sonnet-20240620", + "Anthropic: Claude 3.5 Sonnet (20240620)": "claude-3-7-sonnet-20250219", "claude-3-7-sonnet-20250219": "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20240620": "claude-3-5-sonnet-20240620", # Map standard model names to CUA-specific model names "claude-3-opus": "claude-3-7-sonnet-20250219", "claude-3-sonnet": "claude-3-5-sonnet-20240620", @@ -209,12 +225,12 @@ def get_provider_and_model(model_name: str, loop_provider: str) -> tuple: if agent_loop == AgentLoop.OPENAI: provider = LLMProvider.OPENAI model_name_to_use = MODEL_MAPPINGS["openai"].get( - model_name.lower(), MODEL_MAPPINGS["openai"]["default"] + model_name, MODEL_MAPPINGS["openai"]["default"] ) elif agent_loop == AgentLoop.ANTHROPIC: provider = LLMProvider.ANTHROPIC model_name_to_use = MODEL_MAPPINGS["anthropic"].get( - model_name.lower(), MODEL_MAPPINGS["anthropic"]["default"] + model_name, MODEL_MAPPINGS["anthropic"]["default"] ) elif agent_loop == AgentLoop.OMNI: # Determine provider and clean model name based on the full string from UI @@ -234,33 +250,11 @@ def get_provider_and_model(model_name: str, loop_provider: str) -> tuple: cleaned_model_name = model_name.split("OMNI: Ollama ", 1)[1] elif model_name.startswith("OMNI: Claude "): provider = LLMProvider.ANTHROPIC - # Extract the canonical model name based on the UI string - # e.g., "OMNI: Claude 3.7 Sonnet (20250219)" -> "3.7 Sonnet" and "20250219" - parts = model_name.split(" (") - model_key_part = parts[0].replace("OMNI: Claude ", "") - date_part = parts[1].replace(")", "") if len(parts) > 1 else "" - # Normalize the extracted key part for comparison - # "3.7 Sonnet" -> "37sonnet" - model_key_part_norm = model_key_part.lower().replace(".", "").replace(" ", "") - - cleaned_model_name = MODEL_MAPPINGS["omni"]["default"] # Default if not found - # Find the canonical name in the main Anthropic map - for key_anthropic, val_anthropic in MODEL_MAPPINGS["anthropic"].items(): - # Normalize the canonical key for comparison - # "claude-3-7-sonnet-20250219" -> "claude37sonnet20250219" - key_anthropic_norm = key_anthropic.lower().replace("-", "") - - # Check if the normalized canonical key starts with "claude" + normalized extracted part - # AND contains the date part. - if ( - key_anthropic_norm.startswith("claude" + model_key_part_norm) - and date_part in key_anthropic_norm - ): - cleaned_model_name = ( - val_anthropic # Use the canonical name like "claude-3-7-sonnet-20250219" - ) - break + model_name = model_name.replace("OMNI: ", "Anthropic: ") + cleaned_model_name = MODEL_MAPPINGS["anthropic"].get( + model_name, MODEL_MAPPINGS["anthropic"]["default"] + ) elif model_name.startswith("OMNI: OpenAI "): provider = LLMProvider.OPENAI # Extract the model part, e.g., "GPT-4o mini" @@ -309,6 +303,8 @@ def get_provider_and_model(model_name: str, loop_provider: str) -> tuple: model_name_to_use = MODEL_MAPPINGS["openai"]["default"] agent_loop = AgentLoop.OPENAI + print(f"Mapping {model_name} and {loop_provider} to {provider}, {model_name_to_use}, {agent_loop}") + return provider, model_name_to_use, agent_loop @@ -453,6 +449,9 @@ def create_gradio_ui( # Always show models regardless of API key availability openai_models = ["OpenAI: Computer-Use Preview"] anthropic_models = [ + "Anthropic: Claude 4 Opus (20250514)", + "Anthropic: Claude 4 Sonnet (20250514)", + "Anthropic: Claude 3.7 Sonnet (20250219)", "Anthropic: Claude 3.5 Sonnet (20240620)", ] @@ -460,6 +459,8 @@ def create_gradio_ui( "OMNI: OpenAI GPT-4o", "OMNI: OpenAI GPT-4o mini", "OMNI: OpenAI GPT-4.5-preview", + "OMNI: Claude 4 Opus (20250514)", + "OMNI: Claude 4 Sonnet (20250514)", "OMNI: Claude 3.7 Sonnet (20250219)", "OMNI: Claude 3.5 Sonnet (20240620)" ] @@ -729,20 +730,25 @@ if __name__ == "__main__": with gr.Accordion("Computer Configuration", open=True): # Computer configuration options computer_os = gr.Radio( - choices=["macos", "linux"], + choices=["macos", "linux", "windows"], label="Operating System", value="macos", info="Select the operating system for the computer", ) - # Detect if current device is MacOS + is_windows = platform.system().lower() == "windows" is_mac = platform.system().lower() == "darwin" + providers = ["cloud"] + if is_lume_available: + providers += ["lume"] + if is_windows: + providers += ["winsandbox"] + computer_provider = gr.Radio( - choices=["cloud", "lume"], + choices=providers, label="Provider", value="lume" if is_mac else "cloud", - visible=is_mac, info="Select the computer provider", ) diff --git a/libs/agent/poetry.toml b/libs/python/agent/poetry.toml similarity index 100% rename from libs/agent/poetry.toml rename to libs/python/agent/poetry.toml diff --git a/libs/agent/pyproject.toml b/libs/python/agent/pyproject.toml similarity index 55% rename from libs/agent/pyproject.toml rename to libs/python/agent/pyproject.toml index 8ea6a3fc..7f6af835 100644 --- a/libs/agent/pyproject.toml +++ b/libs/python/agent/pyproject.toml @@ -4,22 +4,22 @@ build-backend = "pdm.backend" [project] name = "cua-agent" -version = "0.1.0" +version = "0.3.0" description = "CUA (Computer Use) Agent for AI-driven computer interaction" readme = "README.md" authors = [ { name = "TryCua", email = "gh@trycua.com" } ] dependencies = [ - "httpx>=0.27.0,<0.29.0", - "aiohttp>=3.9.3,<4.0.0", + "httpx>=0.27.0", + "aiohttp>=3.9.3", "asyncio", - "anyio>=4.4.1,<5.0.0", - "typing-extensions>=4.12.2,<5.0.0", - "pydantic>=2.6.4,<3.0.0", - "rich>=13.7.1,<14.0.0", - "python-dotenv>=1.0.1,<2.0.0", - "cua-computer>=0.2.0,<0.3.0", + "anyio>=4.4.1", + "typing-extensions>=4.12.2", + "pydantic>=2.6.4", + "rich>=13.7.1", + "python-dotenv>=1.0.1", + "cua-computer>=0.3.0,<0.4.0", "cua-core>=0.1.0,<0.2.0", "certifi>=2024.2.2" ] @@ -28,22 +28,21 @@ requires-python = ">=3.11" [project.optional-dependencies] anthropic = [ "anthropic>=0.49.0", - "boto3>=1.35.81,<2.0.0", + "boto3>=1.35.81", ] openai = [ - "openai>=1.14.0,<2.0.0", - "httpx>=0.27.0,<0.29.0", + "openai>=1.14.0", + "httpx>=0.27.0", ] uitars = [ - "httpx>=0.27.0,<0.29.0", + "httpx>=0.27.0", ] uitars-mlx = [ - # The mlx-vlm package needs to be installed manually with: - # pip install git+https://github.com/ddupont808/mlx-vlm.git@stable/fix/qwen2-position-id + "mlx-vlm>=0.1.27; sys_platform == 'darwin'" ] ui = [ - "gradio>=5.23.3,<6.0.0", - "python-dotenv>=1.0.1,<2.0.0", + "gradio>=5.23.3", + "python-dotenv>=1.0.1", ] som = [ "torch>=2.2.1", @@ -52,12 +51,12 @@ som = [ "transformers>=4.38.2", "cua-som>=0.1.0,<0.2.0", # Include all provider dependencies - "anthropic>=0.46.0,<0.47.0", - "boto3>=1.35.81,<2.0.0", - "openai>=1.14.0,<2.0.0", - "groq>=0.4.0,<0.5.0", - "dashscope>=1.13.0,<2.0.0", - "requests>=2.31.0,<3.0.0" + "anthropic>=0.46.0", + "boto3>=1.35.81", + "openai>=1.14.0", + "groq>=0.4.0", + "dashscope>=1.13.0", + "requests>=2.31.0" ] omni = [ "torch>=2.2.1", @@ -65,13 +64,13 @@ omni = [ "ultralytics>=8.0.0", "transformers>=4.38.2", "cua-som>=0.1.0,<0.2.0", - "anthropic>=0.46.0,<0.47.0", - "boto3>=1.35.81,<2.0.0", - "openai>=1.14.0,<2.0.0", - "groq>=0.4.0,<0.5.0", - "dashscope>=1.13.0,<2.0.0", - "requests>=2.31.0,<3.0.0", - "ollama>=0.4.7,<0.5.0" + "anthropic>=0.46.0", + "boto3>=1.35.81", + "openai>=1.14.0", + "groq>=0.4.0", + "dashscope>=1.13.0", + "requests>=2.31.0", + "ollama>=0.4.7" ] all = [ # Include all optional dependencies @@ -80,17 +79,16 @@ all = [ "ultralytics>=8.0.0", "transformers>=4.38.2", "cua-som>=0.1.0,<0.2.0", - "anthropic>=0.46.0,<0.47.0", - "boto3>=1.35.81,<2.0.0", - "openai>=1.14.0,<2.0.0", - "groq>=0.4.0,<0.5.0", - "dashscope>=1.13.0,<2.0.0", - "requests>=2.31.0,<3.0.0", - "ollama>=0.4.7,<0.5.0", - "gradio>=5.23.3,<6.0.0", - "python-dotenv>=1.0.1,<2.0.0" - # mlx-vlm needs to be installed manually with: - # pip install git+https://github.com/ddupont808/mlx-vlm.git@stable/fix/qwen2-position-id + "anthropic>=0.46.0", + "boto3>=1.35.81", + "openai>=1.14.0", + "groq>=0.4.0", + "dashscope>=1.13.0", + "requests>=2.31.0", + "ollama>=0.4.7", + "gradio>=5.23.3", + "python-dotenv>=1.0.1", + "mlx-vlm>=0.1.27; sys_platform == 'darwin'" ] [tool.pdm] diff --git a/libs/computer-server/README.md b/libs/python/computer-server/README.md similarity index 100% rename from libs/computer-server/README.md rename to libs/python/computer-server/README.md diff --git a/libs/computer-server/computer_server/__init__.py b/libs/python/computer-server/computer_server/__init__.py similarity index 100% rename from libs/computer-server/computer_server/__init__.py rename to libs/python/computer-server/computer_server/__init__.py diff --git a/libs/computer-server/computer_server/__main__.py b/libs/python/computer-server/computer_server/__main__.py similarity index 100% rename from libs/computer-server/computer_server/__main__.py rename to libs/python/computer-server/computer_server/__main__.py diff --git a/libs/computer-server/computer_server/cli.py b/libs/python/computer-server/computer_server/cli.py similarity index 50% rename from libs/computer-server/computer_server/cli.py rename to libs/python/computer-server/computer_server/cli.py index 30f7e519..0fcbda00 100644 --- a/libs/computer-server/computer_server/cli.py +++ b/libs/python/computer-server/computer_server/cli.py @@ -3,11 +3,15 @@ Command-line interface for the Computer API server. """ import argparse +import asyncio import logging +import os import sys +import threading from typing import List, Optional from .server import Server +from .watchdog import Watchdog logger = logging.getLogger(__name__) @@ -37,6 +41,22 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: type=str, help="Path to SSL certificate file (enables HTTPS)", ) + parser.add_argument( + "--watchdog", + action="store_true", + help="Enable watchdog monitoring (automatically enabled if CONTAINER_NAME env var is set)", + ) + parser.add_argument( + "--watchdog-interval", + type=int, + default=30, + help="Watchdog ping interval in seconds (default: 30)", + ) + parser.add_argument( + "--no-restart", + action="store_true", + help="Disable automatic server restart in watchdog", + ) return parser.parse_args(args) @@ -51,6 +71,54 @@ def main() -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) + # Check if watchdog should be enabled + container_name = os.environ.get("CONTAINER_NAME") + enable_watchdog = args.watchdog or bool(container_name) + + if container_name: + logger.info(f"Container environment detected (CONTAINER_NAME={container_name}), enabling watchdog") + elif args.watchdog: + logger.info("Watchdog explicitly enabled via --watchdog flag") + + # Start watchdog if enabled + if enable_watchdog: + logger.info(f"Starting watchdog monitoring with {args.watchdog_interval}s interval") + + def run_watchdog_thread(): + """Run watchdog in a separate thread.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # Create CLI args dict for watchdog + cli_args = { + 'host': args.host, + 'port': args.port, + 'log_level': args.log_level, + 'ssl_keyfile': args.ssl_keyfile, + 'ssl_certfile': args.ssl_certfile + } + + # Create watchdog with restart settings + watchdog = Watchdog( + cli_args=cli_args, + ping_interval=args.watchdog_interval + ) + watchdog.restart_enabled = not args.no_restart + + loop.run_until_complete(watchdog.start_monitoring()) + except Exception as e: + logger.error(f"Watchdog error: {e}") + finally: + loop.close() + + # Start watchdog in background thread + watchdog_thread = threading.Thread( + target=run_watchdog_thread, + daemon=True, + name="watchdog" + ) + watchdog_thread.start() + # Create and start the server logger.info(f"Starting CUA Computer API server on {args.host}:{args.port}...") diff --git a/libs/computer-server/computer_server/diorama/__init__.py b/libs/python/computer-server/computer_server/diorama/__init__.py similarity index 100% rename from libs/computer-server/computer_server/diorama/__init__.py rename to libs/python/computer-server/computer_server/diorama/__init__.py diff --git a/libs/computer-server/computer_server/diorama/base.py b/libs/python/computer-server/computer_server/diorama/base.py similarity index 100% rename from libs/computer-server/computer_server/diorama/base.py rename to libs/python/computer-server/computer_server/diorama/base.py diff --git a/libs/computer-server/computer_server/diorama/diorama.py b/libs/python/computer-server/computer_server/diorama/diorama.py similarity index 98% rename from libs/computer-server/computer_server/diorama/diorama.py rename to libs/python/computer-server/computer_server/diorama/diorama.py index fc426a7c..09aa6434 100644 --- a/libs/computer-server/computer_server/diorama/diorama.py +++ b/libs/python/computer-server/computer_server/diorama/diorama.py @@ -15,13 +15,7 @@ from computer_server.diorama.diorama_computer import DioramaComputer from computer_server.handlers.macos import * # simple, nicely formatted logging -logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s] [%(levelname)s] %(message)s', - datefmt='%H:%M:%S', - stream=sys.stdout -) -logger = logging.getLogger("diorama.virtual_desktop") +logger = logging.getLogger(__name__) automation_handler = MacOSAutomationHandler() diff --git a/libs/computer-server/computer_server/diorama/diorama_computer.py b/libs/python/computer-server/computer_server/diorama/diorama_computer.py similarity index 100% rename from libs/computer-server/computer_server/diorama/diorama_computer.py rename to libs/python/computer-server/computer_server/diorama/diorama_computer.py diff --git a/libs/computer-server/computer_server/diorama/draw.py b/libs/python/computer-server/computer_server/diorama/draw.py similarity index 99% rename from libs/computer-server/computer_server/diorama/draw.py rename to libs/python/computer-server/computer_server/diorama/draw.py index 9fce809f..e915b790 100644 --- a/libs/computer-server/computer_server/diorama/draw.py +++ b/libs/python/computer-server/computer_server/diorama/draw.py @@ -28,13 +28,7 @@ import functools import logging # simple, nicely formatted logging -logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s] [%(levelname)s] %(message)s', - datefmt='%H:%M:%S', - stream=sys.stdout -) -logger = logging.getLogger("diorama.draw") +logger = logging.getLogger(__name__) from computer_server.diorama.safezone import ( get_menubar_bounds, diff --git a/libs/computer-server/computer_server/diorama/macos.py b/libs/python/computer-server/computer_server/diorama/macos.py similarity index 100% rename from libs/computer-server/computer_server/diorama/macos.py rename to libs/python/computer-server/computer_server/diorama/macos.py diff --git a/libs/computer-server/computer_server/diorama/safezone.py b/libs/python/computer-server/computer_server/diorama/safezone.py similarity index 100% rename from libs/computer-server/computer_server/diorama/safezone.py rename to libs/python/computer-server/computer_server/diorama/safezone.py diff --git a/libs/computer-server/computer_server/handlers/base.py b/libs/python/computer-server/computer_server/handlers/base.py similarity index 93% rename from libs/computer-server/computer_server/handlers/base.py rename to libs/python/computer-server/computer_server/handlers/base.py index 82a8204e..012a296c 100644 --- a/libs/computer-server/computer_server/handlers/base.py +++ b/libs/python/computer-server/computer_server/handlers/base.py @@ -44,11 +44,6 @@ class BaseFileHandler(ABC): """Write text content to a file.""" pass - @abstractmethod - async def read_bytes(self, path: str) -> Dict[str, Any]: - """Read the binary contents of a file. Sent over the websocket as a base64 string.""" - pass - @abstractmethod async def write_bytes(self, path: str, content_b64: str) -> Dict[str, Any]: """Write binary content to a file. Sent over the websocket as a base64 string.""" @@ -69,6 +64,22 @@ class BaseFileHandler(ABC): """Delete a directory.""" pass + @abstractmethod + async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> Dict[str, Any]: + """Read the binary contents of a file. Sent over the websocket as a base64 string. + + Args: + path: Path to the file + offset: Byte offset to start reading from (default: 0) + length: Number of bytes to read (default: None for entire file) + """ + pass + + @abstractmethod + async def get_file_size(self, path: str) -> Dict[str, Any]: + """Get the size of a file in bytes.""" + pass + class BaseAutomationHandler(ABC): """Abstract base class for OS-specific automation handlers. diff --git a/libs/computer-server/computer_server/handlers/factory.py b/libs/python/computer-server/computer_server/handlers/factory.py similarity index 79% rename from libs/computer-server/computer_server/handlers/factory.py rename to libs/python/computer-server/computer_server/handlers/factory.py index 5a9dc414..962f7fb1 100644 --- a/libs/computer-server/computer_server/handlers/factory.py +++ b/libs/python/computer-server/computer_server/handlers/factory.py @@ -11,6 +11,8 @@ if system == 'darwin': from computer_server.diorama.macos import MacOSDioramaHandler elif system == 'linux': from .linux import LinuxAccessibilityHandler, LinuxAutomationHandler +elif system == 'windows': + from .windows import WindowsAccessibilityHandler, WindowsAutomationHandler from .generic import GenericFileHandler @@ -22,7 +24,7 @@ class HandlerFactory: """Determine the current OS. Returns: - str: The OS type ('darwin' for macOS or 'linux' for Linux) + str: The OS type ('darwin' for macOS, 'linux' for Linux, or 'windows' for Windows) Raises: RuntimeError: If unable to determine the current OS @@ -31,13 +33,14 @@ class HandlerFactory: # Use platform.system() as primary method system = platform.system().lower() if system in ['darwin', 'linux', 'windows']: - return 'darwin' if system == 'darwin' else 'linux' if system == 'linux' else 'windows' + return system - # Fallback to uname if platform.system() doesn't return expected values + # Fallback to uname if platform.system() doesn't return expected values (Unix-like systems only) result = subprocess.run(['uname', '-s'], capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"uname command failed: {result.stderr}") - return result.stdout.strip().lower() + if result.returncode == 0: + return result.stdout.strip().lower() + + raise RuntimeError(f"Unsupported OS: {system}") except Exception as e: raise RuntimeError(f"Failed to determine current OS: {str(e)}") @@ -59,5 +62,7 @@ class HandlerFactory: return MacOSAccessibilityHandler(), MacOSAutomationHandler(), MacOSDioramaHandler(), GenericFileHandler() elif os_type == 'linux': return LinuxAccessibilityHandler(), LinuxAutomationHandler(), BaseDioramaHandler(), GenericFileHandler() + elif os_type == 'windows': + return WindowsAccessibilityHandler(), WindowsAutomationHandler(), BaseDioramaHandler(), GenericFileHandler() else: - raise NotImplementedError(f"OS '{os_type}' is not supported") \ No newline at end of file + raise NotImplementedError(f"OS '{os_type}' is not supported") diff --git a/libs/computer-server/computer_server/handlers/generic.py b/libs/python/computer-server/computer_server/handlers/generic.py similarity index 71% rename from libs/computer-server/computer_server/handlers/generic.py rename to libs/python/computer-server/computer_server/handlers/generic.py index 784900ef..03472fbd 100644 --- a/libs/computer-server/computer_server/handlers/generic.py +++ b/libs/python/computer-server/computer_server/handlers/generic.py @@ -7,7 +7,7 @@ Includes: """ from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, Optional from .base import BaseFileHandler import base64 @@ -47,16 +47,36 @@ class GenericFileHandler(BaseFileHandler): except Exception as e: return {"success": False, "error": str(e)} - async def write_bytes(self, path: str, content_b64: str) -> Dict[str, Any]: + async def write_bytes(self, path: str, content_b64: str, append: bool = False) -> Dict[str, Any]: try: - resolve_path(path).write_bytes(base64.b64decode(content_b64)) + mode = 'ab' if append else 'wb' + with open(resolve_path(path), mode) as f: + f.write(base64.b64decode(content_b64)) return {"success": True} except Exception as e: return {"success": False, "error": str(e)} - async def read_bytes(self, path: str) -> Dict[str, Any]: + async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> Dict[str, Any]: try: - return {"success": True, "content_b64": base64.b64encode(resolve_path(path).read_bytes()).decode('utf-8')} + file_path = resolve_path(path) + with open(file_path, 'rb') as f: + if offset > 0: + f.seek(offset) + + if length is not None: + content = f.read(length) + else: + content = f.read() + + return {"success": True, "content_b64": base64.b64encode(content).decode('utf-8')} + except Exception as e: + return {"success": False, "error": str(e)} + + async def get_file_size(self, path: str) -> Dict[str, Any]: + try: + file_path = resolve_path(path) + size = file_path.stat().st_size + return {"success": True, "size": size} except Exception as e: return {"success": False, "error": str(e)} diff --git a/libs/computer-server/computer_server/handlers/linux.py b/libs/python/computer-server/computer_server/handlers/linux.py similarity index 100% rename from libs/computer-server/computer_server/handlers/linux.py rename to libs/python/computer-server/computer_server/handlers/linux.py diff --git a/libs/computer-server/computer_server/handlers/macos.py b/libs/python/computer-server/computer_server/handlers/macos.py similarity index 100% rename from libs/computer-server/computer_server/handlers/macos.py rename to libs/python/computer-server/computer_server/handlers/macos.py diff --git a/libs/python/computer-server/computer_server/handlers/windows.py b/libs/python/computer-server/computer_server/handlers/windows.py new file mode 100644 index 00000000..d88dfe0b --- /dev/null +++ b/libs/python/computer-server/computer_server/handlers/windows.py @@ -0,0 +1,405 @@ +""" +Windows implementation of automation and accessibility handlers. + +This implementation uses pyautogui for GUI automation and Windows-specific APIs +for accessibility and system operations. +""" +from typing import Dict, Any, List, Tuple, Optional +import logging +import subprocess +import base64 +import os +from io import BytesIO + +# Configure logger +logger = logging.getLogger(__name__) + +# Try to import pyautogui +try: + import pyautogui + logger.info("pyautogui successfully imported, GUI automation available") +except Exception as e: + logger.error(f"pyautogui import failed: {str(e)}. GUI operations will not work.") + pyautogui = None + +# Try to import Windows-specific modules +try: + import win32gui + import win32con + import win32api + logger.info("Windows API modules successfully imported") + WINDOWS_API_AVAILABLE = True +except Exception as e: + logger.error(f"Windows API modules import failed: {str(e)}. Some Windows-specific features will be unavailable.") + WINDOWS_API_AVAILABLE = False + +from .base import BaseAccessibilityHandler, BaseAutomationHandler + +class WindowsAccessibilityHandler(BaseAccessibilityHandler): + """Windows implementation of accessibility handler.""" + + async def get_accessibility_tree(self) -> Dict[str, Any]: + """Get the accessibility tree of the current window.""" + if not WINDOWS_API_AVAILABLE: + return {"success": False, "error": "Windows API not available"} + + try: + # Get the foreground window + hwnd = win32gui.GetForegroundWindow() + if not hwnd: + return {"success": False, "error": "No foreground window found"} + + # Get window information + window_text = win32gui.GetWindowText(hwnd) + rect = win32gui.GetWindowRect(hwnd) + + tree = { + "role": "Window", + "title": window_text, + "position": {"x": rect[0], "y": rect[1]}, + "size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]}, + "children": [] + } + + # Enumerate child windows + def enum_child_proc(hwnd_child, children_list): + try: + child_text = win32gui.GetWindowText(hwnd_child) + child_rect = win32gui.GetWindowRect(hwnd_child) + child_class = win32gui.GetClassName(hwnd_child) + + child_info = { + "role": child_class, + "title": child_text, + "position": {"x": child_rect[0], "y": child_rect[1]}, + "size": {"width": child_rect[2] - child_rect[0], "height": child_rect[3] - child_rect[1]}, + "children": [] + } + children_list.append(child_info) + except Exception as e: + logger.debug(f"Error getting child window info: {e}") + return True + + win32gui.EnumChildWindows(hwnd, enum_child_proc, tree["children"]) + + return {"success": True, "tree": tree} + + except Exception as e: + logger.error(f"Error getting accessibility tree: {e}") + return {"success": False, "error": str(e)} + + async def find_element(self, role: Optional[str] = None, + title: Optional[str] = None, + value: Optional[str] = None) -> Dict[str, Any]: + """Find an element in the accessibility tree by criteria.""" + if not WINDOWS_API_AVAILABLE: + return {"success": False, "error": "Windows API not available"} + + try: + # Find window by title if specified + if title: + hwnd = win32gui.FindWindow(None, title) + if hwnd: + rect = win32gui.GetWindowRect(hwnd) + return { + "success": True, + "element": { + "role": "Window", + "title": title, + "position": {"x": rect[0], "y": rect[1]}, + "size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]} + } + } + + # Find window by class name if role is specified + if role: + hwnd = win32gui.FindWindow(role, None) + if hwnd: + window_text = win32gui.GetWindowText(hwnd) + rect = win32gui.GetWindowRect(hwnd) + return { + "success": True, + "element": { + "role": role, + "title": window_text, + "position": {"x": rect[0], "y": rect[1]}, + "size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]} + } + } + + return {"success": False, "error": "Element not found"} + + except Exception as e: + logger.error(f"Error finding element: {e}") + return {"success": False, "error": str(e)} + +class WindowsAutomationHandler(BaseAutomationHandler): + """Windows implementation of automation handler using pyautogui and Windows APIs.""" + + # Mouse Actions + async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if x is not None and y is not None: + pyautogui.moveTo(x, y) + pyautogui.mouseDown(button=button) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if x is not None and y is not None: + pyautogui.moveTo(x, y) + pyautogui.mouseUp(button=button) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def move_cursor(self, x: int, y: int) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.moveTo(x, y) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if x is not None and y is not None: + pyautogui.moveTo(x, y) + pyautogui.click() + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if x is not None and y is not None: + pyautogui.moveTo(x, y) + pyautogui.rightClick() + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if x is not None and y is not None: + pyautogui.moveTo(x, y) + pyautogui.doubleClick(interval=0.1) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def drag_to(self, x: int, y: int, button: str = "left", duration: float = 0.5) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.dragTo(x, y, duration=duration, button=button) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def drag(self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + if not path: + return {"success": False, "error": "Path is empty"} + + # Move to first position + pyautogui.moveTo(*path[0]) + + # Drag through all positions + for x, y in path[1:]: + pyautogui.dragTo(x, y, duration=duration/len(path), button=button) + + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + # Keyboard Actions + async def key_down(self, key: str) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.keyDown(key) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def key_up(self, key: str) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.keyUp(key) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def type_text(self, text: str) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.write(text) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def press_key(self, key: str) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.press(key) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def hotkey(self, keys: str) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.hotkey(*keys) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + # Scrolling Actions + async def scroll(self, x: int, y: int) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + # pyautogui.scroll() only takes one parameter (vertical scroll) + pyautogui.scroll(y) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def scroll_down(self, clicks: int = 1) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.scroll(-clicks) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def scroll_up(self, clicks: int = 1) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + pyautogui.scroll(clicks) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + # Screen Actions + async def screenshot(self) -> Dict[str, Any]: + if not pyautogui: + return {"success": False, "error": "pyautogui not available"} + + try: + from PIL import Image + screenshot = pyautogui.screenshot() + if not isinstance(screenshot, Image.Image): + return {"success": False, "error": "Failed to capture screenshot"} + + buffered = BytesIO() + screenshot.save(buffered, format="PNG", optimize=True) + buffered.seek(0) + image_data = base64.b64encode(buffered.getvalue()).decode() + return {"success": True, "image_data": image_data} + except Exception as e: + return {"success": False, "error": f"Screenshot error: {str(e)}"} + + async def get_screen_size(self) -> Dict[str, Any]: + try: + if pyautogui: + size = pyautogui.size() + return {"success": True, "size": {"width": size.width, "height": size.height}} + elif WINDOWS_API_AVAILABLE: + # Fallback to Windows API + width = win32api.GetSystemMetrics(win32con.SM_CXSCREEN) + height = win32api.GetSystemMetrics(win32con.SM_CYSCREEN) + return {"success": True, "size": {"width": width, "height": height}} + else: + return {"success": False, "error": "No screen size detection method available"} + except Exception as e: + return {"success": False, "error": str(e)} + + async def get_cursor_position(self) -> Dict[str, Any]: + try: + if pyautogui: + pos = pyautogui.position() + return {"success": True, "position": {"x": pos.x, "y": pos.y}} + elif WINDOWS_API_AVAILABLE: + # Fallback to Windows API + pos = win32gui.GetCursorPos() + return {"success": True, "position": {"x": pos[0], "y": pos[1]}} + else: + return {"success": False, "error": "No cursor position detection method available"} + except Exception as e: + return {"success": False, "error": str(e)} + + # Clipboard Actions + async def copy_to_clipboard(self) -> Dict[str, Any]: + try: + import pyperclip + content = pyperclip.paste() + return {"success": True, "content": content} + except Exception as e: + return {"success": False, "error": str(e)} + + async def set_clipboard(self, text: str) -> Dict[str, Any]: + try: + import pyperclip + pyperclip.copy(text) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + # Command Execution + async def run_command(self, command: str) -> Dict[str, Any]: + try: + # Use cmd.exe for Windows commands + process = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + ) + return { + "success": True, + "stdout": process.stdout, + "stderr": process.stderr, + "return_code": process.returncode + } + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/libs/computer-server/computer_server/main.py b/libs/python/computer-server/computer_server/main.py similarity index 95% rename from libs/computer-server/computer_server/main.py rename to libs/python/computer-server/computer_server/main.py index bdca3693..29b19faf 100644 --- a/libs/computer-server/computer_server/main.py +++ b/libs/python/computer-server/computer_server/main.py @@ -5,6 +5,7 @@ import logging import asyncio import json import traceback +import inspect from contextlib import redirect_stdout, redirect_stderr from io import StringIO from .handlers.factory import HandlerFactory @@ -12,8 +13,8 @@ import os import aiohttp # Set up logging with more detail -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) # Configure WebSocket with larger message size WEBSOCKET_MAX_SIZE = 1024 * 1024 * 10 # 10MB limit @@ -172,6 +173,7 @@ async def websocket_endpoint(websocket: WebSocket): "write_text": manager.file_handler.write_text, "read_bytes": manager.file_handler.read_bytes, "write_bytes": manager.file_handler.write_bytes, + "get_file_size": manager.file_handler.get_file_size, "delete_file": manager.file_handler.delete_file, "create_dir": manager.file_handler.create_dir, "delete_dir": manager.file_handler.delete_dir, @@ -217,7 +219,12 @@ async def websocket_endpoint(websocket: WebSocket): continue try: - result = await handlers[command](**params) + # Filter params to only include those accepted by the handler function + handler_func = handlers[command] + sig = inspect.signature(handler_func) + filtered_params = {k: v for k, v in params.items() if k in sig.parameters} + + result = await handler_func(**filtered_params) await websocket.send_json({"success": True, **result}) except Exception as cmd_error: logger.error(f"Error executing command {command}: {str(cmd_error)}") diff --git a/libs/computer-server/computer_server/server.py b/libs/python/computer-server/computer_server/server.py similarity index 100% rename from libs/computer-server/computer_server/server.py rename to libs/python/computer-server/computer_server/server.py diff --git a/libs/python/computer-server/computer_server/watchdog.py b/libs/python/computer-server/computer_server/watchdog.py new file mode 100644 index 00000000..392d9bc0 --- /dev/null +++ b/libs/python/computer-server/computer_server/watchdog.py @@ -0,0 +1,333 @@ +""" +Watchdog module for monitoring the Computer API server health. +Unix/Linux only - provides process management and restart capabilities. +""" + +import asyncio +import fcntl +import json +import logging +import os +import platform +import subprocess +import sys +import time +import websockets +from typing import Optional + +logger = logging.getLogger(__name__) + + +def instance_already_running(label="watchdog"): + """ + Detect if an an instance with the label is already running, globally + at the operating system level. + + Using `os.open` ensures that the file pointer won't be closed + by Python's garbage collector after the function's scope is exited. + + The lock will be released when the program exits, or could be + released if the file pointer were closed. + """ + + lock_file_pointer = os.open(f"/tmp/instance_{label}.lock", os.O_WRONLY | os.O_CREAT) + + try: + fcntl.lockf(lock_file_pointer, fcntl.LOCK_EX | fcntl.LOCK_NB) + already_running = False + except IOError: + already_running = True + + return already_running + + +class Watchdog: + """Watchdog class to monitor server health via WebSocket connection. + Unix/Linux only - provides restart capabilities. + """ + + def __init__(self, cli_args: Optional[dict] = None, ping_interval: int = 30): + """ + Initialize the watchdog. + + Args: + cli_args: Dictionary of CLI arguments to replicate when restarting + ping_interval: Interval between ping checks in seconds + """ + # Check if running on Unix/Linux + if platform.system() not in ['Linux', 'Darwin']: + raise RuntimeError("Watchdog is only supported on Unix/Linux systems") + + # Store CLI arguments for restart + self.cli_args = cli_args or {} + self.host = self.cli_args.get('host', 'localhost') + self.port = self.cli_args.get('port', 8000) + self.ping_interval = ping_interval + self.container_name = os.environ.get("CONTAINER_NAME") + self.running = False + self.restart_enabled = True + + @property + def ws_uri(self) -> str: + """Get the WebSocket URI using the current IP address. + + 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" + + async def ping(self) -> bool: + """ + Test connection to the WebSocket endpoint. + + Returns: + True if connection successful, False otherwise + """ + 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: + # 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: {response[:100]}...") + return True + except asyncio.TimeoutError: + return False + except Exception as e: + logger.warning(f"Ping failed: {e}") + return False + + def kill_processes_on_port(self, port: int) -> bool: + """ + Kill any processes using the specified port. + + Args: + port: Port number to check and kill processes on + + Returns: + True if processes were killed or none found, False on error + """ + try: + # Find processes using the port + result = subprocess.run( + ["lsof", "-ti", f":{port}"], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0 and result.stdout.strip(): + pids = result.stdout.strip().split('\n') + logger.info(f"Found {len(pids)} processes using port {port}: {pids}") + + # Kill each process + for pid in pids: + if pid.strip(): + try: + subprocess.run(["kill", "-9", pid.strip()], timeout=5) + logger.info(f"Killed process {pid}") + except subprocess.TimeoutExpired: + logger.warning(f"Timeout killing process {pid}") + except Exception as e: + logger.warning(f"Error killing process {pid}: {e}") + + return True + else: + logger.debug(f"No processes found using port {port}") + return True + + except subprocess.TimeoutExpired: + logger.error(f"Timeout finding processes on port {port}") + return False + except Exception as e: + logger.error(f"Error finding processes on port {port}: {e}") + return False + + def restart_server(self) -> bool: + """ + Attempt to restart the server by killing existing processes and starting new one. + + Returns: + True if restart was attempted, False on error + """ + if not self.restart_enabled: + logger.info("Server restart is disabled") + return False + + try: + logger.info("Attempting to restart server...") + + # Kill processes on the port + port_to_kill = 8443 if self.container_name else self.port + if not self.kill_processes_on_port(port_to_kill): + logger.error("Failed to kill processes on port, restart aborted") + return False + + # Wait a moment for processes to die + time.sleep(2) + + # Try to restart the server + # In container mode, we can't easily restart, so just log + if self.container_name: + logger.warning("Container mode detected - cannot restart server automatically") + logger.warning("Container orchestrator should handle restart") + return False + else: + # For local mode, try to restart the CLI + logger.info("Attempting to restart local server...") + + # Get the current Python executable and script + python_exe = sys.executable + + # Try to find the CLI module + try: + # Build command with all original CLI arguments + cmd = [python_exe, "-m", "computer_server.cli"] + + # Add all CLI arguments except watchdog-related ones + for key, value in self.cli_args.items(): + if key in ['watchdog', 'watchdog_interval', 'no_restart']: + continue # Skip watchdog args to avoid recursive watchdog + + # Convert underscores to hyphens for CLI args + arg_name = f"--{key.replace('_', '-')}" + + if isinstance(value, bool): + if value: # Only add flag if True + cmd.append(arg_name) + else: + cmd.extend([arg_name, str(value)]) + + logger.info(f"Starting server with command: {' '.join(cmd)}") + + # Start process in background + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + logger.info("Server restart initiated") + return True + + except Exception as e: + logger.error(f"Failed to restart server: {e}") + return False + + except Exception as e: + logger.error(f"Error during server restart: {e}") + return False + + async def start_monitoring(self) -> None: + """Start the watchdog monitoring loop.""" + self.running = True + logger.info(f"Starting watchdog monitoring for {self.ws_uri}") + logger.info(f"Ping interval: {self.ping_interval} seconds") + if self.container_name: + logger.info(f"Container mode detected: {self.container_name}") + + consecutive_failures = 0 + max_failures = 3 + + while self.running: + try: + success = await self.ping() + + if success: + if consecutive_failures > 0: + logger.info("Server connection restored") + consecutive_failures = 0 + logger.debug("Ping successful") + else: + consecutive_failures += 1 + logger.warning(f"Ping failed ({consecutive_failures}/{max_failures})") + + if consecutive_failures >= max_failures: + logger.error(f"Server appears to be down after {max_failures} consecutive failures") + + # Attempt to restart the server + if self.restart_enabled: + logger.info("Attempting automatic server restart...") + restart_success = self.restart_server() + + if restart_success: + logger.info("Server restart initiated, waiting before next ping...") + # Wait longer after restart attempt + await asyncio.sleep(self.ping_interval * 2) + consecutive_failures = 0 # Reset counter after restart attempt + else: + logger.error("Server restart failed") + else: + logger.warning("Automatic restart is disabled") + + # Wait for next ping interval + await asyncio.sleep(self.ping_interval) + + except asyncio.CancelledError: + logger.info("Watchdog monitoring cancelled") + break + except Exception as e: + logger.error(f"Unexpected error in watchdog loop: {e}") + await asyncio.sleep(self.ping_interval) + + def stop_monitoring(self) -> None: + """Stop the watchdog monitoring.""" + self.running = False + logger.info("Stopping watchdog monitoring") + + +async def run_watchdog(cli_args: Optional[dict] = None, ping_interval: int = 30) -> None: + """ + Run the watchdog monitoring. + + Args: + cli_args: Dictionary of CLI arguments to replicate when restarting + ping_interval: Interval between ping checks in seconds + """ + watchdog = Watchdog(cli_args=cli_args, ping_interval=ping_interval) + + try: + await watchdog.start_monitoring() + except KeyboardInterrupt: + logger.info("Watchdog stopped by user") + finally: + watchdog.stop_monitoring() + + +if __name__ == "__main__": + # For testing the watchdog standalone + import argparse + + parser = argparse.ArgumentParser(description="Run Computer API server watchdog") + parser.add_argument("--host", default="localhost", help="Server host to monitor") + parser.add_argument("--port", type=int, default=8000, help="Server port to monitor") + parser.add_argument("--ping-interval", type=int, default=30, help="Ping interval in seconds") + + args = parser.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + cli_args = { + 'host': args.host, + 'port': args.port + } + asyncio.run(run_watchdog(cli_args, args.ping_interval)) diff --git a/libs/computer-server/examples/__init__.py b/libs/python/computer-server/examples/__init__.py similarity index 100% rename from libs/computer-server/examples/__init__.py rename to libs/python/computer-server/examples/__init__.py diff --git a/libs/computer-server/examples/usage_example.py b/libs/python/computer-server/examples/usage_example.py similarity index 100% rename from libs/computer-server/examples/usage_example.py rename to libs/python/computer-server/examples/usage_example.py diff --git a/libs/computer-server/pyproject.toml b/libs/python/computer-server/pyproject.toml similarity index 92% rename from libs/computer-server/pyproject.toml rename to libs/python/computer-server/pyproject.toml index cbf9821a..6e9e7240 100644 --- a/libs/computer-server/pyproject.toml +++ b/libs/python/computer-server/pyproject.toml @@ -19,7 +19,9 @@ dependencies = [ "pyautogui>=0.9.54", "pynput>=1.8.1", "pillow>=10.2.0", - "aiohttp>=3.9.1" + "aiohttp>=3.9.1", + "pyperclip>=1.9.0", + "websockets>=12.0" ] [project.optional-dependencies] @@ -31,6 +33,9 @@ macos = [ linux = [ "python-xlib>=0.33" ] +windows = [ + "pywin32>=310" +] [project.urls] homepage = "https://github.com/trycua/cua" @@ -80,4 +85,4 @@ disallow_untyped_defs = true check_untyped_defs = true warn_return_any = true show_error_codes = true -warn_unused_ignores = false \ No newline at end of file +warn_unused_ignores = false diff --git a/libs/computer-server/run_server.py b/libs/python/computer-server/run_server.py similarity index 100% rename from libs/computer-server/run_server.py rename to libs/python/computer-server/run_server.py diff --git a/libs/computer-server/test_connection.py b/libs/python/computer-server/test_connection.py similarity index 72% rename from libs/computer-server/test_connection.py rename to libs/python/computer-server/test_connection.py index dee73e8d..de4eb2df 100755 --- a/libs/computer-server/test_connection.py +++ b/libs/python/computer-server/test_connection.py @@ -13,10 +13,16 @@ import argparse import sys -async def test_connection(host="localhost", port=8000, keep_alive=False): +async def test_connection(host="localhost", port=8000, keep_alive=False, container_name=None): """Test connection to the Computer Server.""" - uri = f"ws://{host}:{port}/ws" - print(f"Connecting to {uri}...") + if container_name: + # Container mode: use WSS with container domain and port 8443 + uri = f"wss://{container_name}.containers.cloud.trycua.com:8443/ws" + print(f"Connecting to container {container_name} at {uri}...") + else: + # Local mode: use WS with specified host and port + uri = f"ws://{host}:{port}/ws" + print(f"Connecting to local server at {uri}...") try: async with websockets.connect(uri) as websocket: @@ -54,13 +60,23 @@ def parse_args(): parser = argparse.ArgumentParser(description="Test connection to Computer Server") parser.add_argument("--host", default="localhost", help="Host address (default: localhost)") parser.add_argument("--port", type=int, default=8000, help="Port number (default: 8000)") + parser.add_argument("--container-name", help="Container name for cloud connection (uses WSS and port 8443)") parser.add_argument("--keep-alive", action="store_true", help="Keep connection alive") return parser.parse_args() async def main(): args = parse_args() - success = await test_connection(args.host, args.port, args.keep_alive) + + # Convert hyphenated argument to underscore for function parameter + container_name = getattr(args, 'container_name', None) + + success = await test_connection( + host=args.host, + port=args.port, + keep_alive=args.keep_alive, + container_name=container_name + ) return 0 if success else 1 diff --git a/libs/computer/README.md b/libs/python/computer/README.md similarity index 100% rename from libs/computer/README.md rename to libs/python/computer/README.md diff --git a/libs/computer/computer/__init__.py b/libs/python/computer/computer/__init__.py similarity index 96% rename from libs/computer/computer/__init__.py rename to libs/python/computer/computer/__init__.py index 90d20454..e2f66bfb 100644 --- a/libs/computer/computer/__init__.py +++ b/libs/python/computer/computer/__init__.py @@ -6,14 +6,14 @@ import sys __version__ = "0.1.0" # Initialize logging -logger = logging.getLogger("cua.computer") +logger = logging.getLogger("computer") # Initialize telemetry when the package is imported try: # Import from core telemetry from core.telemetry import ( - is_telemetry_enabled, flush, + is_telemetry_enabled, record_event, ) diff --git a/libs/computer/computer/computer.py b/libs/python/computer/computer/computer.py similarity index 97% rename from libs/computer/computer/computer.py rename to libs/python/computer/computer/computer.py index 4249f3f4..7ba29ee6 100644 --- a/libs/computer/computer/computer.py +++ b/libs/python/computer/computer/computer.py @@ -85,7 +85,7 @@ class Computer: experiments: Optional list of experimental features to enable (e.g. ["app-use"]) """ - self.logger = Logger("cua.computer", verbosity) + self.logger = Logger("computer", verbosity) self.logger.info("Initializing Computer...") # Store original parameters @@ -106,7 +106,15 @@ class Computer: # The default is currently to use non-ephemeral storage if storage and ephemeral and storage != "ephemeral": raise ValueError("Storage path and ephemeral flag cannot be used together") - self.storage = "ephemeral" if ephemeral else storage + + # Windows Sandbox always uses ephemeral storage + if self.provider_type == VMProviderType.WINSANDBOX: + if not ephemeral and storage != None and storage != "ephemeral": + self.logger.warning("Windows Sandbox storage is always ephemeral. Setting ephemeral=True.") + self.ephemeral = True + self.storage = "ephemeral" + else: + self.storage = "ephemeral" if ephemeral else storage # For Lumier provider, store the first shared directory path to use # for VM file sharing @@ -124,11 +132,11 @@ class Computer: # Configure root logger self.verbosity = verbosity - self.logger = Logger("cua", verbosity) + self.logger = Logger("computer", verbosity) # Configure component loggers with proper hierarchy - self.vm_logger = Logger("cua.vm", verbosity) - self.interface_logger = Logger("cua.interface", verbosity) + self.vm_logger = Logger("computer.vm", verbosity) + self.interface_logger = Logger("computer.interface", verbosity) if not use_host_computer_server: if ":" not in image or len(image.split(":")) != 2: @@ -285,6 +293,15 @@ class Computer: api_key=self.api_key, verbose=verbose, ) + elif self.provider_type == VMProviderType.WINSANDBOX: + self.config.vm_provider = VMProviderFactory.create_provider( + self.provider_type, + port=port, + host=host, + storage=storage, + verbose=verbose, + ephemeral=ephemeral, + ) else: raise ValueError(f"Unsupported provider type: {self.provider_type}") self._provider_context = await self.config.vm_provider.__aenter__() @@ -383,7 +400,6 @@ class Computer: # Wait for VM to be ready with a valid IP address self.logger.info("Waiting for VM to be ready with a valid IP address...") try: - # Increased values for Lumier provider which needs more time for initial setup if self.provider_type == VMProviderType.LUMIER: max_retries = 60 # Increased for Lumier VM startup which takes longer retry_delay = 3 # 3 seconds between retries for Lumier @@ -513,7 +529,7 @@ class Computer: return # @property - async def get_ip(self, max_retries: int = 15, retry_delay: int = 2) -> str: + async def get_ip(self, max_retries: int = 15, retry_delay: int = 3) -> str: """Get the IP address of the VM or localhost if using host computer server. This method delegates to the provider's get_ip method, which waits indefinitely diff --git a/libs/computer/computer/diorama_computer.py b/libs/python/computer/computer/diorama_computer.py similarity index 100% rename from libs/computer/computer/diorama_computer.py rename to libs/python/computer/computer/diorama_computer.py diff --git a/libs/computer/computer/helpers.py b/libs/python/computer/computer/helpers.py similarity index 93% rename from libs/computer/computer/helpers.py rename to libs/python/computer/computer/helpers.py index b472c047..8317b8d9 100644 --- a/libs/computer/computer/helpers.py +++ b/libs/python/computer/computer/helpers.py @@ -1,6 +1,7 @@ """ Helper functions and decorators for the Computer module. """ +import logging import asyncio from functools import wraps from typing import Any, Callable, Optional, TypeVar, cast @@ -8,6 +9,8 @@ from typing import Any, Callable, Optional, TypeVar, cast # Global reference to the default computer instance _default_computer = None +logger = logging.getLogger(__name__) + def set_default_computer(computer): """ Set the default computer instance to be used by the remote decorator. @@ -41,7 +44,7 @@ def sandboxed(venv_name: str = "default", computer: str = "default", max_retries try: return await comp.venv_exec(venv_name, func, *args, **kwargs) except Exception as e: - print(f"Attempt {i+1} failed: {e}") + logger.error(f"Attempt {i+1} failed: {e}") await asyncio.sleep(1) if i == max_retries - 1: raise e diff --git a/libs/computer/computer/interface/__init__.py b/libs/python/computer/computer/interface/__init__.py similarity index 100% rename from libs/computer/computer/interface/__init__.py rename to libs/python/computer/computer/interface/__init__.py diff --git a/libs/computer/computer/interface/base.py b/libs/python/computer/computer/interface/base.py similarity index 82% rename from libs/computer/computer/interface/base.py rename to libs/python/computer/computer/interface/base.py index 09cc46f2..183ebd2d 100644 --- a/libs/computer/computer/interface/base.py +++ b/libs/python/computer/computer/interface/base.py @@ -3,8 +3,7 @@ from abc import ABC, abstractmethod from typing import Optional, Dict, Any, Tuple, List from ..logger import Logger, LogLevel -from .models import MouseButton - +from .models import MouseButton, CommandResult class BaseComputerInterface(ABC): """Base class for computer control interfaces.""" @@ -209,8 +208,14 @@ class BaseComputerInterface(ABC): pass @abstractmethod - async def read_bytes(self, path: str) -> bytes: - """Read file binary contents.""" + async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes: + """Read file binary contents with optional seeking support. + + Args: + path: Path to the file + offset: Byte offset to start reading from (default: 0) + length: Number of bytes to read (default: None for entire file) + """ pass @abstractmethod @@ -234,8 +239,36 @@ class BaseComputerInterface(ABC): pass @abstractmethod - async def run_command(self, command: str) -> Tuple[str, str]: - """Run shell command.""" + async def get_file_size(self, path: str) -> int: + """Get the size of a file in bytes.""" + pass + + @abstractmethod + async def run_command(self, command: str) -> CommandResult: + """Run shell command and return structured result. + + Executes a shell command using subprocess.run with shell=True and check=False. + The command is run in the target environment and captures both stdout and stderr. + + Args: + command (str): The shell command to execute + + Returns: + CommandResult: A structured result containing: + - stdout (str): Standard output from the command + - stderr (str): Standard error from the command + - returncode (int): Exit code from the command (0 indicates success) + + Raises: + RuntimeError: If the command execution fails at the system level + + Example: + result = await interface.run_command("ls -la") + if result.returncode == 0: + print(f"Output: {result.stdout}") + else: + print(f"Error: {result.stderr}, Exit code: {result.returncode}") + """ pass # Accessibility Actions diff --git a/libs/computer/computer/interface/factory.py b/libs/python/computer/computer/interface/factory.py similarity index 78% rename from libs/computer/computer/interface/factory.py rename to libs/python/computer/computer/interface/factory.py index 949972c4..3647400e 100644 --- a/libs/computer/computer/interface/factory.py +++ b/libs/python/computer/computer/interface/factory.py @@ -8,7 +8,7 @@ class InterfaceFactory: @staticmethod def create_interface_for_os( - os: Literal['macos', 'linux'], + os: Literal['macos', 'linux', 'windows'], ip_address: str, api_key: Optional[str] = None, vm_name: Optional[str] = None @@ -16,7 +16,7 @@ class InterfaceFactory: """Create an interface for the specified OS. Args: - os: Operating system type ('macos' or 'linux') + os: Operating system type ('macos', 'linux', or 'windows') ip_address: IP address of the computer to control api_key: Optional API key for cloud authentication vm_name: Optional VM name for cloud authentication @@ -30,10 +30,13 @@ class InterfaceFactory: # Import implementations here to avoid circular imports from .macos import MacOSComputerInterface from .linux import LinuxComputerInterface + from .windows import WindowsComputerInterface if os == 'macos': return MacOSComputerInterface(ip_address, api_key=api_key, vm_name=vm_name) elif os == 'linux': return LinuxComputerInterface(ip_address, api_key=api_key, vm_name=vm_name) + elif os == 'windows': + return WindowsComputerInterface(ip_address, api_key=api_key, vm_name=vm_name) else: - raise ValueError(f"Unsupported OS type: {os}") \ No newline at end of file + raise ValueError(f"Unsupported OS type: {os}") diff --git a/libs/computer/computer/interface/macos.py b/libs/python/computer/computer/interface/generic.py similarity index 84% rename from libs/computer/computer/interface/macos.py rename to libs/python/computer/computer/interface/generic.py index 539303e4..a3521816 100644 --- a/libs/computer/computer/interface/macos.py +++ b/libs/python/computer/computer/interface/generic.py @@ -9,13 +9,13 @@ import websockets from ..logger import Logger, LogLevel from .base import BaseComputerInterface from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image -from .models import Key, KeyType, MouseButton +from .models import Key, KeyType, MouseButton, CommandResult -class MacOSComputerInterface(BaseComputerInterface): - """Interface for macOS.""" +class GenericComputerInterface(BaseComputerInterface): + """Generic interface with common functionality for all supported platforms (Windows, Linux, macOS).""" - def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None): + def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None, logger_name: str = "computer.interface.generic"): super().__init__(ip_address, username, password, api_key, vm_name) self._ws = None self._reconnect_task = None @@ -26,10 +26,11 @@ class MacOSComputerInterface(BaseComputerInterface): self._reconnect_delay = 1 # Start with 1 second delay self._max_reconnect_delay = 30 # Maximum delay between reconnection attempts self._log_connection_attempts = True # Flag to control connection attempt logging + self._authenticated = False # Track authentication status self._command_lock = asyncio.Lock() # Lock to ensure only one command at a time - # Set logger name for macOS interface - self.logger = Logger("cua.interface.macos", LogLevel.NORMAL) + # Set logger name for the interface + self.logger = Logger(logger_name, LogLevel.NORMAL) @property def ws_uri(self) -> str: @@ -40,8 +41,439 @@ class MacOSComputerInterface(BaseComputerInterface): """ protocol = "wss" if self.api_key else "ws" port = "8443" if self.api_key else "8000" - return f"{protocol}://{self.ip_address}:{port}/ws" + return f"{protocol}://{self.ip_address}:{port}/ws" + # Mouse actions + async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None: + await self._send_command("mouse_down", {"x": x, "y": y, "button": button}) + + async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None: + await self._send_command("mouse_up", {"x": x, "y": y, "button": button}) + + async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: + await self._send_command("left_click", {"x": x, "y": y}) + + async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: + await self._send_command("right_click", {"x": x, "y": y}) + + async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: + await self._send_command("double_click", {"x": x, "y": y}) + + async def move_cursor(self, x: int, y: int) -> None: + await self._send_command("move_cursor", {"x": x, "y": y}) + + async def drag_to(self, x: int, y: int, button: "MouseButton" = "left", duration: float = 0.5) -> None: + await self._send_command( + "drag_to", {"x": x, "y": y, "button": button, "duration": duration} + ) + + async def drag(self, path: List[Tuple[int, int]], button: "MouseButton" = "left", duration: float = 0.5) -> None: + await self._send_command( + "drag", {"path": path, "button": button, "duration": duration} + ) + + # Keyboard Actions + async def key_down(self, key: "KeyType") -> None: + await self._send_command("key_down", {"key": key}) + + async def key_up(self, key: "KeyType") -> None: + await self._send_command("key_up", {"key": key}) + + async def type_text(self, text: str) -> None: + # Temporary fix for https://github.com/trycua/cua/issues/165 + # Check if text contains Unicode characters + if any(ord(char) > 127 for char in text): + # For Unicode text, use clipboard and paste + await self.set_clipboard(text) + await self.hotkey(Key.COMMAND, 'v') + else: + # For ASCII text, use the regular typing method + await self._send_command("type_text", {"text": text}) + + async def press(self, key: "KeyType") -> None: + """Press a single key. + + Args: + key: The key to press. Can be any of: + - A Key enum value (recommended), e.g. Key.PAGE_DOWN + - A direct key value string, e.g. 'pagedown' + - A single character string, e.g. 'a' + + Examples: + ```python + # Using enum (recommended) + await interface.press(Key.PAGE_DOWN) + await interface.press(Key.ENTER) + + # Using direct values + await interface.press('pagedown') + await interface.press('enter') + + # Using single characters + await interface.press('a') + ``` + + Raises: + ValueError: If the key type is invalid or the key is not recognized + """ + if isinstance(key, Key): + actual_key = key.value + elif isinstance(key, str): + # Try to convert to enum if it matches a known key + key_or_enum = Key.from_string(key) + actual_key = key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum + else: + raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") + + await self._send_command("press_key", {"key": actual_key}) + + async def press_key(self, key: "KeyType") -> None: + """DEPRECATED: Use press() instead. + + This method is kept for backward compatibility but will be removed in a future version. + Please use the press() method instead. + """ + await self.press(key) + + async def hotkey(self, *keys: "KeyType") -> None: + """Press multiple keys simultaneously. + + Args: + *keys: Multiple keys to press simultaneously. Each key can be any of: + - A Key enum value (recommended), e.g. Key.COMMAND + - A direct key value string, e.g. 'command' + - A single character string, e.g. 'a' + + Examples: + ```python + # Using enums (recommended) + await interface.hotkey(Key.COMMAND, Key.C) # Copy + await interface.hotkey(Key.COMMAND, Key.V) # Paste + + # Using mixed formats + await interface.hotkey(Key.COMMAND, 'a') # Select all + ``` + + Raises: + ValueError: If any key type is invalid or not recognized + """ + actual_keys = [] + for key in keys: + if isinstance(key, Key): + actual_keys.append(key.value) + elif isinstance(key, str): + # Try to convert to enum if it matches a known key + key_or_enum = Key.from_string(key) + actual_keys.append(key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum) + else: + raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") + + await self._send_command("hotkey", {"keys": actual_keys}) + + # Scrolling Actions + async def scroll(self, x: int, y: int) -> None: + await self._send_command("scroll", {"x": x, "y": y}) + + async def scroll_down(self, clicks: int = 1) -> None: + await self._send_command("scroll_down", {"clicks": clicks}) + + async def scroll_up(self, clicks: int = 1) -> None: + await self._send_command("scroll_up", {"clicks": clicks}) + + # Screen actions + async def screenshot( + self, + boxes: Optional[List[Tuple[int, int, int, int]]] = None, + box_color: str = "#FF0000", + box_thickness: int = 2, + scale_factor: float = 1.0, + ) -> bytes: + """Take a screenshot with optional box drawing and scaling. + + Args: + boxes: Optional list of (x, y, width, height) tuples defining boxes to draw in screen coordinates + box_color: Color of the boxes in hex format (default: "#FF0000" red) + box_thickness: Thickness of the box borders in pixels (default: 2) + scale_factor: Factor to scale the final image by (default: 1.0) + Use > 1.0 to enlarge, < 1.0 to shrink (e.g., 0.5 for half size, 2.0 for double) + + Returns: + bytes: The screenshot image data, optionally with boxes drawn on it and scaled + """ + result = await self._send_command("screenshot") + if not result.get("image_data"): + raise RuntimeError("Failed to take screenshot") + + screenshot = decode_base64_image(result["image_data"]) + + if boxes: + # Get the natural scaling between screen and screenshot + screen_size = await self.get_screen_size() + screenshot_width, screenshot_height = bytes_to_image(screenshot).size + width_scale = screenshot_width / screen_size["width"] + height_scale = screenshot_height / screen_size["height"] + + # Scale box coordinates from screen space to screenshot space + for box in boxes: + scaled_box = ( + int(box[0] * width_scale), # x + int(box[1] * height_scale), # y + int(box[2] * width_scale), # width + int(box[3] * height_scale), # height + ) + screenshot = draw_box( + screenshot, + x=scaled_box[0], + y=scaled_box[1], + width=scaled_box[2], + height=scaled_box[3], + color=box_color, + thickness=box_thickness, + ) + + if scale_factor != 1.0: + screenshot = resize_image(screenshot, scale_factor) + + return screenshot + + async def get_screen_size(self) -> Dict[str, int]: + result = await self._send_command("get_screen_size") + if result["success"] and result["size"]: + return result["size"] + raise RuntimeError("Failed to get screen size") + + async def get_cursor_position(self) -> Dict[str, int]: + result = await self._send_command("get_cursor_position") + if result["success"] and result["position"]: + return result["position"] + raise RuntimeError("Failed to get cursor position") + + # Clipboard Actions + async def copy_to_clipboard(self) -> str: + result = await self._send_command("copy_to_clipboard") + if result["success"] and result["content"]: + return result["content"] + raise RuntimeError("Failed to get clipboard content") + + async def set_clipboard(self, text: str) -> None: + await self._send_command("set_clipboard", {"text": text}) + + # File Operations + async def _write_bytes_chunked(self, path: str, content: bytes, append: bool = False, chunk_size: int = 1024 * 1024) -> None: + """Write large files in chunks to avoid memory issues.""" + total_size = len(content) + current_offset = 0 + + while current_offset < total_size: + chunk_end = min(current_offset + chunk_size, total_size) + chunk_data = content[current_offset:chunk_end] + + # First chunk uses the original append flag, subsequent chunks always append + chunk_append = append if current_offset == 0 else True + + result = await self._send_command("write_bytes", { + "path": path, + "content_b64": encode_base64_image(chunk_data), + "append": chunk_append + }) + + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to write file chunk")) + + current_offset = chunk_end + + async def write_bytes(self, path: str, content: bytes, append: bool = False) -> None: + # For large files, use chunked writing + if len(content) > 5 * 1024 * 1024: # 5MB threshold + await self._write_bytes_chunked(path, content, append) + return + + result = await self._send_command("write_bytes", {"path": path, "content_b64": encode_base64_image(content), "append": append}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to write file")) + + async def _read_bytes_chunked(self, path: str, offset: int, total_length: int, chunk_size: int = 1024 * 1024) -> bytes: + """Read large files in chunks to avoid memory issues.""" + chunks = [] + current_offset = offset + remaining = total_length + + while remaining > 0: + read_size = min(chunk_size, remaining) + result = await self._send_command("read_bytes", { + "path": path, + "offset": current_offset, + "length": read_size + }) + + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to read file chunk")) + + content_b64 = result.get("content_b64", "") + chunk_data = decode_base64_image(content_b64) + chunks.append(chunk_data) + + current_offset += read_size + remaining -= read_size + + return b''.join(chunks) + + async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes: + # For large files, use chunked reading + if length is None: + # Get file size first to determine if we need chunking + file_size = await self.get_file_size(path) + # If file is larger than 5MB, read in chunks + if file_size > 5 * 1024 * 1024: # 5MB threshold + return await self._read_bytes_chunked(path, offset, file_size - offset if offset > 0 else file_size) + + result = await self._send_command("read_bytes", { + "path": path, + "offset": offset, + "length": length + }) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to read file")) + content_b64 = result.get("content_b64", "") + return decode_base64_image(content_b64) + + async def read_text(self, path: str, encoding: str = 'utf-8') -> str: + """Read text from a file with specified encoding. + + Args: + path: Path to the file to read + encoding: Text encoding to use (default: 'utf-8') + + Returns: + str: The decoded text content of the file + """ + content_bytes = await self.read_bytes(path) + return content_bytes.decode(encoding) + + async def write_text(self, path: str, content: str, encoding: str = 'utf-8', append: bool = False) -> None: + """Write text to a file with specified encoding. + + Args: + path: Path to the file to write + content: Text content to write + encoding: Text encoding to use (default: 'utf-8') + append: Whether to append to the file instead of overwriting + """ + content_bytes = content.encode(encoding) + await self.write_bytes(path, content_bytes, append) + + async def get_file_size(self, path: str) -> int: + result = await self._send_command("get_file_size", {"path": path}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to get file size")) + return result.get("size", 0) + + async def file_exists(self, path: str) -> bool: + result = await self._send_command("file_exists", {"path": path}) + return result.get("exists", False) + + async def directory_exists(self, path: str) -> bool: + result = await self._send_command("directory_exists", {"path": path}) + return result.get("exists", False) + + async def create_dir(self, path: str) -> None: + result = await self._send_command("create_dir", {"path": path}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to create directory")) + + async def delete_file(self, path: str) -> None: + result = await self._send_command("delete_file", {"path": path}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to delete file")) + + async def delete_dir(self, path: str) -> None: + result = await self._send_command("delete_dir", {"path": path}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to delete directory")) + + async def list_dir(self, path: str) -> list[str]: + result = await self._send_command("list_dir", {"path": path}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to list directory")) + return result.get("files", []) + + # Command execution + async def run_command(self, command: str) -> CommandResult: + result = await self._send_command("run_command", {"command": command}) + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to run command")) + return CommandResult( + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + returncode=result.get("return_code", 0) + ) + + # Accessibility Actions + async def get_accessibility_tree(self) -> Dict[str, Any]: + """Get the accessibility tree of the current screen.""" + result = await self._send_command("get_accessibility_tree") + if not result.get("success", False): + raise RuntimeError(result.get("error", "Failed to get accessibility tree")) + return result + + async def get_active_window_bounds(self) -> Dict[str, int]: + """Get the bounds of the currently active window.""" + result = await self._send_command("get_active_window_bounds") + if result["success"] and result["bounds"]: + return result["bounds"] + raise RuntimeError("Failed to get active window bounds") + + async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]: + """Convert screenshot coordinates to screen coordinates. + + Args: + x: X coordinate in screenshot space + y: Y coordinate in screenshot space + + Returns: + tuple[float, float]: (x, y) coordinates in screen space + """ + screen_size = await self.get_screen_size() + screenshot = await self.screenshot() + screenshot_img = bytes_to_image(screenshot) + screenshot_width, screenshot_height = screenshot_img.size + + # Calculate scaling factors + width_scale = screen_size["width"] / screenshot_width + height_scale = screen_size["height"] / screenshot_height + + # Convert coordinates + screen_x = x * width_scale + screen_y = y * height_scale + + return screen_x, screen_y + + async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]: + """Convert screen coordinates to screenshot coordinates. + + Args: + x: X coordinate in screen space + y: Y coordinate in screen space + + Returns: + tuple[float, float]: (x, y) coordinates in screenshot space + """ + screen_size = await self.get_screen_size() + screenshot = await self.screenshot() + screenshot_img = bytes_to_image(screenshot) + screenshot_width, screenshot_height = screenshot_img.size + + # Calculate scaling factors + width_scale = screenshot_width / screen_size["width"] + height_scale = screenshot_height / screen_size["height"] + + # Convert coordinates + screenshot_x = x * width_scale + screenshot_y = y * height_scale + + return screenshot_x, screenshot_y + + # Websocket Methods async def _keep_alive(self): """Keep the WebSocket connection alive with automatic reconnection.""" retry_count = 0 @@ -185,7 +617,7 @@ class MacOSComputerInterface(BaseComputerInterface): except: pass self._ws = None - + async def _ensure_connection(self): """Ensure WebSocket connection is established.""" if self._reconnect_task is None or self._reconnect_task.done(): @@ -337,7 +769,7 @@ class MacOSComputerInterface(BaseComputerInterface): # if self._ws: # asyncio.create_task(self._ws.close()) # self._ws = None - + def force_close(self): """Force close the WebSocket connection. @@ -351,345 +783,3 @@ class MacOSComputerInterface(BaseComputerInterface): asyncio.create_task(self._ws.close()) self._ws = None - async def diorama_cmd(self, action: str, arguments: Optional[dict] = None) -> dict: - """Send a diorama command to the server (macOS only).""" - return await self._send_command("diorama_cmd", {"action": action, "arguments": arguments or {}}) - - # Mouse Actions - async def mouse_down(self, x: Optional[int] = None, y: Optional[int] = None, button: "MouseButton" = "left") -> None: - await self._send_command("mouse_down", {"x": x, "y": y, "button": button}) - - async def mouse_up(self, x: Optional[int] = None, y: Optional[int] = None, button: "MouseButton" = "left") -> None: - await self._send_command("mouse_up", {"x": x, "y": y, "button": button}) - - async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("left_click", {"x": x, "y": y}) - - async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("right_click", {"x": x, "y": y}) - - async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None: - await self._send_command("double_click", {"x": x, "y": y}) - - async def move_cursor(self, x: int, y: int) -> None: - await self._send_command("move_cursor", {"x": x, "y": y}) - - async def drag_to(self, x: int, y: int, button: str = "left", duration: float = 0.5) -> None: - await self._send_command( - "drag_to", {"x": x, "y": y, "button": button, "duration": duration} - ) - - async def drag(self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5) -> None: - await self._send_command( - "drag", {"path": path, "button": button, "duration": duration} - ) - - # Keyboard Actions - async def key_down(self, key: "KeyType") -> None: - await self._send_command("key_down", {"key": key}) - - async def key_up(self, key: "KeyType") -> None: - await self._send_command("key_up", {"key": key}) - - async def type_text(self, text: str) -> None: - # Temporary fix for https://github.com/trycua/cua/issues/165 - # Check if text contains Unicode characters - if any(ord(char) > 127 for char in text): - # For Unicode text, use clipboard and paste - await self.set_clipboard(text) - await self.hotkey(Key.COMMAND, 'v') - else: - # For ASCII text, use the regular typing method - await self._send_command("type_text", {"text": text}) - - async def press(self, key: "KeyType") -> None: - """Press a single key. - - Args: - key: The key to press. Can be any of: - - A Key enum value (recommended), e.g. Key.PAGE_DOWN - - A direct key value string, e.g. 'pagedown' - - A single character string, e.g. 'a' - - Examples: - ```python - # Using enum (recommended) - await interface.press(Key.PAGE_DOWN) - await interface.press(Key.ENTER) - - # Using direct values - await interface.press('pagedown') - await interface.press('enter') - - # Using single characters - await interface.press('a') - ``` - - Raises: - ValueError: If the key type is invalid or the key is not recognized - """ - if isinstance(key, Key): - actual_key = key.value - elif isinstance(key, str): - # Try to convert to enum if it matches a known key - key_or_enum = Key.from_string(key) - actual_key = key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum - else: - raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") - - await self._send_command("press_key", {"key": actual_key}) - - async def press_key(self, key: "KeyType") -> None: - """DEPRECATED: Use press() instead. - - This method is kept for backward compatibility but will be removed in a future version. - Please use the press() method instead. - """ - await self.press(key) - - async def hotkey(self, *keys: "KeyType") -> None: - """Press multiple keys simultaneously. - - Args: - *keys: Multiple keys to press simultaneously. Each key can be any of: - - A Key enum value (recommended), e.g. Key.COMMAND - - A direct key value string, e.g. 'command' - - A single character string, e.g. 'a' - - Examples: - ```python - # Using enums (recommended) - await interface.hotkey(Key.COMMAND, Key.C) # Copy - await interface.hotkey(Key.COMMAND, Key.V) # Paste - - # Using mixed formats - await interface.hotkey(Key.COMMAND, 'a') # Select all - ``` - - Raises: - ValueError: If any key type is invalid or not recognized - """ - actual_keys = [] - for key in keys: - if isinstance(key, Key): - actual_keys.append(key.value) - elif isinstance(key, str): - # Try to convert to enum if it matches a known key - key_or_enum = Key.from_string(key) - actual_keys.append(key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum) - else: - raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.") - - await self._send_command("hotkey", {"keys": actual_keys}) - - # Scrolling Actions - async def scroll(self, x: int, y: int) -> None: - await self._send_command("scroll", {"x": x, "y": y}) - - async def scroll_down(self, clicks: int = 1) -> None: - await self._send_command("scroll_down", {"clicks": clicks}) - - async def scroll_up(self, clicks: int = 1) -> None: - await self._send_command("scroll_up", {"clicks": clicks}) - - # Screen Actions - async def screenshot( - self, - boxes: Optional[List[Tuple[int, int, int, int]]] = None, - box_color: str = "#FF0000", - box_thickness: int = 2, - scale_factor: float = 1.0, - ) -> bytes: - """Take a screenshot with optional box drawing and scaling. - - Args: - boxes: Optional list of (x, y, width, height) tuples defining boxes to draw in screen coordinates - box_color: Color of the boxes in hex format (default: "#FF0000" red) - box_thickness: Thickness of the box borders in pixels (default: 2) - scale_factor: Factor to scale the final image by (default: 1.0) - Use > 1.0 to enlarge, < 1.0 to shrink (e.g., 0.5 for half size, 2.0 for double) - - Returns: - bytes: The screenshot image data, optionally with boxes drawn on it and scaled - """ - result = await self._send_command("screenshot") - if not result.get("image_data"): - raise RuntimeError("Failed to take screenshot") - - screenshot = decode_base64_image(result["image_data"]) - - if boxes: - # Get the natural scaling between screen and screenshot - screen_size = await self.get_screen_size() - screenshot_width, screenshot_height = bytes_to_image(screenshot).size - width_scale = screenshot_width / screen_size["width"] - height_scale = screenshot_height / screen_size["height"] - - # Scale box coordinates from screen space to screenshot space - for box in boxes: - scaled_box = ( - int(box[0] * width_scale), # x - int(box[1] * height_scale), # y - int(box[2] * width_scale), # width - int(box[3] * height_scale), # height - ) - screenshot = draw_box( - screenshot, - x=scaled_box[0], - y=scaled_box[1], - width=scaled_box[2], - height=scaled_box[3], - color=box_color, - thickness=box_thickness, - ) - - if scale_factor != 1.0: - screenshot = resize_image(screenshot, scale_factor) - - return screenshot - - async def get_screen_size(self) -> Dict[str, int]: - result = await self._send_command("get_screen_size") - if result["success"] and result["size"]: - return result["size"] - raise RuntimeError("Failed to get screen size") - - async def get_cursor_position(self) -> Dict[str, int]: - result = await self._send_command("get_cursor_position") - if result["success"] and result["position"]: - return result["position"] - raise RuntimeError("Failed to get cursor position") - - # Clipboard Actions - async def copy_to_clipboard(self) -> str: - result = await self._send_command("copy_to_clipboard") - if result["success"] and result["content"]: - return result["content"] - raise RuntimeError("Failed to get clipboard content") - - async def set_clipboard(self, text: str) -> None: - await self._send_command("set_clipboard", {"text": text}) - - # File System Actions - async def file_exists(self, path: str) -> bool: - result = await self._send_command("file_exists", {"path": path}) - return result.get("exists", False) - - async def directory_exists(self, path: str) -> bool: - result = await self._send_command("directory_exists", {"path": path}) - return result.get("exists", False) - - async def list_dir(self, path: str) -> list[str]: - result = await self._send_command("list_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to list directory")) - return result.get("files", []) - - async def read_text(self, path: str) -> str: - result = await self._send_command("read_text", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to read file")) - return result.get("content", "") - - async def write_text(self, path: str, content: str) -> None: - result = await self._send_command("write_text", {"path": path, "content": content}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to write file")) - - async def read_bytes(self, path: str) -> bytes: - result = await self._send_command("read_bytes", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to read file")) - content_b64 = result.get("content_b64", "") - return decode_base64_image(content_b64) - - async def write_bytes(self, path: str, content: bytes) -> None: - result = await self._send_command("write_bytes", {"path": path, "content_b64": encode_base64_image(content)}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to write file")) - - async def delete_file(self, path: str) -> None: - result = await self._send_command("delete_file", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to delete file")) - - async def create_dir(self, path: str) -> None: - result = await self._send_command("create_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to create directory")) - - async def delete_dir(self, path: str) -> None: - result = await self._send_command("delete_dir", {"path": path}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to delete directory")) - - async def run_command(self, command: str) -> Tuple[str, str]: - result = await self._send_command("run_command", {"command": command}) - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to run command")) - return result.get("stdout", ""), result.get("stderr", "") - - # Accessibility Actions - async def get_accessibility_tree(self) -> Dict[str, Any]: - """Get the accessibility tree of the current screen.""" - result = await self._send_command("get_accessibility_tree") - if not result.get("success", False): - raise RuntimeError(result.get("error", "Failed to get accessibility tree")) - return result - - async def get_active_window_bounds(self) -> Dict[str, int]: - """Get the bounds of the currently active window.""" - result = await self._send_command("get_active_window_bounds") - if result["success"] and result["bounds"]: - return result["bounds"] - raise RuntimeError("Failed to get active window bounds") - - async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]: - """Convert screenshot coordinates to screen coordinates. - - Args: - x: X coordinate in screenshot space - y: Y coordinate in screenshot space - - Returns: - tuple[float, float]: (x, y) coordinates in screen space - """ - screen_size = await self.get_screen_size() - screenshot = await self.screenshot() - screenshot_img = bytes_to_image(screenshot) - screenshot_width, screenshot_height = screenshot_img.size - - # Calculate scaling factors - width_scale = screen_size["width"] / screenshot_width - height_scale = screen_size["height"] / screenshot_height - - # Convert coordinates - screen_x = x * width_scale - screen_y = y * height_scale - - return screen_x, screen_y - - async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]: - """Convert screen coordinates to screenshot coordinates. - - Args: - x: X coordinate in screen space - y: Y coordinate in screen space - - Returns: - tuple[float, float]: (x, y) coordinates in screenshot space - """ - screen_size = await self.get_screen_size() - screenshot = await self.screenshot() - screenshot_img = bytes_to_image(screenshot) - screenshot_width, screenshot_height = screenshot_img.size - - # Calculate scaling factors - width_scale = screenshot_width / screen_size["width"] - height_scale = screenshot_height / screen_size["height"] - - # Convert coordinates - screenshot_x = x * width_scale - screenshot_y = y * height_scale - - return screenshot_x, screenshot_y diff --git a/libs/python/computer/computer/interface/linux.py b/libs/python/computer/computer/interface/linux.py new file mode 100644 index 00000000..174fe07a --- /dev/null +++ b/libs/python/computer/computer/interface/linux.py @@ -0,0 +1,8 @@ +from typing import Optional +from .generic import GenericComputerInterface + +class LinuxComputerInterface(GenericComputerInterface): + """Interface for Linux.""" + + def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None): + super().__init__(ip_address, username, password, api_key, vm_name, "computer.interface.linux") diff --git a/libs/python/computer/computer/interface/macos.py b/libs/python/computer/computer/interface/macos.py new file mode 100644 index 00000000..f4d03a52 --- /dev/null +++ b/libs/python/computer/computer/interface/macos.py @@ -0,0 +1,12 @@ +from .generic import GenericComputerInterface +from typing import Optional + +class MacOSComputerInterface(GenericComputerInterface): + """Interface for macOS.""" + + def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None): + super().__init__(ip_address, username, password, api_key, vm_name, "computer.interface.macos") + + async def diorama_cmd(self, action: str, arguments: Optional[dict] = None) -> dict: + """Send a diorama command to the server (macOS only).""" + return await self._send_command("diorama_cmd", {"action": action, "arguments": arguments or {}}) \ No newline at end of file diff --git a/libs/computer/computer/interface/models.py b/libs/python/computer/computer/interface/models.py similarity index 92% rename from libs/computer/computer/interface/models.py rename to libs/python/computer/computer/interface/models.py index 515b5f2b..223ac321 100644 --- a/libs/computer/computer/interface/models.py +++ b/libs/python/computer/computer/interface/models.py @@ -1,5 +1,17 @@ from enum import Enum from typing import Dict, List, Any, TypedDict, Union, Literal +from dataclasses import dataclass + +@dataclass +class CommandResult: + stdout: str + stderr: str + returncode: int + + def __init__(self, stdout: str, stderr: str, returncode: int): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode # Navigation key literals NavigationKey = Literal['pagedown', 'pageup', 'home', 'end', 'left', 'right', 'up', 'down'] diff --git a/libs/python/computer/computer/interface/windows.py b/libs/python/computer/computer/interface/windows.py new file mode 100644 index 00000000..a874d359 --- /dev/null +++ b/libs/python/computer/computer/interface/windows.py @@ -0,0 +1,8 @@ +from typing import Optional +from .generic import GenericComputerInterface + +class WindowsComputerInterface(GenericComputerInterface): + """Interface for Windows.""" + + def __init__(self, ip_address: str, username: str = "lume", password: str = "lume", api_key: Optional[str] = None, vm_name: Optional[str] = None): + super().__init__(ip_address, username, password, api_key, vm_name, "computer.interface.windows") diff --git a/libs/computer/computer/logger.py b/libs/python/computer/computer/logger.py similarity index 100% rename from libs/computer/computer/logger.py rename to libs/python/computer/computer/logger.py diff --git a/libs/computer/computer/models.py b/libs/python/computer/computer/models.py similarity index 100% rename from libs/computer/computer/models.py rename to libs/python/computer/computer/models.py diff --git a/libs/computer/computer/providers/__init__.py b/libs/python/computer/computer/providers/__init__.py similarity index 100% rename from libs/computer/computer/providers/__init__.py rename to libs/python/computer/computer/providers/__init__.py diff --git a/libs/computer/computer/providers/base.py b/libs/python/computer/computer/providers/base.py similarity index 99% rename from libs/computer/computer/providers/base.py rename to libs/python/computer/computer/providers/base.py index 4a8f8fdf..a3540e0e 100644 --- a/libs/computer/computer/providers/base.py +++ b/libs/python/computer/computer/providers/base.py @@ -10,6 +10,7 @@ class VMProviderType(StrEnum): LUME = "lume" LUMIER = "lumier" CLOUD = "cloud" + WINSANDBOX = "winsandbox" UNKNOWN = "unknown" diff --git a/libs/computer/computer/providers/cloud/__init__.py b/libs/python/computer/computer/providers/cloud/__init__.py similarity index 100% rename from libs/computer/computer/providers/cloud/__init__.py rename to libs/python/computer/computer/providers/cloud/__init__.py diff --git a/libs/computer/computer/providers/cloud/provider.py b/libs/python/computer/computer/providers/cloud/provider.py similarity index 100% rename from libs/computer/computer/providers/cloud/provider.py rename to libs/python/computer/computer/providers/cloud/provider.py diff --git a/libs/computer/computer/providers/factory.py b/libs/python/computer/computer/providers/factory.py similarity index 81% rename from libs/computer/computer/providers/factory.py rename to libs/python/computer/computer/providers/factory.py index 6491b754..98fcd9da 100644 --- a/libs/computer/computer/providers/factory.py +++ b/libs/python/computer/computer/providers/factory.py @@ -112,5 +112,27 @@ class VMProviderFactory: "The CloudProvider is not fully implemented yet. " "Please use LUME or LUMIER provider instead." ) from e + elif provider_type == VMProviderType.WINSANDBOX: + try: + from .winsandbox import WinSandboxProvider, HAS_WINSANDBOX + if not HAS_WINSANDBOX: + raise ImportError( + "pywinsandbox is required for WinSandboxProvider. " + "Please install it with 'pip install -U git+https://github.com/karkason/pywinsandbox.git'" + ) + return WinSandboxProvider( + port=port, + host=host, + storage=storage, + verbose=verbose, + ephemeral=ephemeral, + **kwargs + ) + except ImportError as e: + logger.error(f"Failed to import WinSandboxProvider: {e}") + raise ImportError( + "pywinsandbox is required for WinSandboxProvider. " + "Please install it with 'pip install -U git+https://github.com/karkason/pywinsandbox.git'" + ) from e else: raise ValueError(f"Unsupported provider type: {provider_type}") diff --git a/libs/computer/computer/providers/lume/__init__.py b/libs/python/computer/computer/providers/lume/__init__.py similarity index 100% rename from libs/computer/computer/providers/lume/__init__.py rename to libs/python/computer/computer/providers/lume/__init__.py diff --git a/libs/computer/computer/providers/lume/provider.py b/libs/python/computer/computer/providers/lume/provider.py similarity index 100% rename from libs/computer/computer/providers/lume/provider.py rename to libs/python/computer/computer/providers/lume/provider.py diff --git a/libs/computer/computer/providers/lume_api.py b/libs/python/computer/computer/providers/lume_api.py similarity index 96% rename from libs/computer/computer/providers/lume_api.py rename to libs/python/computer/computer/providers/lume_api.py index fbfaca4b..3cbe1097 100644 --- a/libs/computer/computer/providers/lume_api.py +++ b/libs/python/computer/computer/providers/lume_api.py @@ -66,8 +66,6 @@ def lume_api_get( # Only print the curl command when debug is enabled display_curl_string = ' '.join(display_cmd) - if debug or verbose: - print(f"DEBUG: Executing curl API call: {display_curl_string}") logger.debug(f"Executing API request: {display_curl_string}") # Execute the command - for execution we need to use shell=True to handle URLs with special characters @@ -172,8 +170,6 @@ def lume_api_run( payload["sharedDirectories"] = run_opts["shared_directories"] # Log the payload for debugging - if debug or verbose: - print(f"DEBUG: Payload for {vm_name} run request: {json.dumps(payload, indent=2)}") logger.debug(f"API payload: {json.dumps(payload, indent=2)}") # Construct the curl command @@ -184,11 +180,6 @@ def lume_api_run( api_url ] - # Always print the command for debugging - if debug or verbose: - print(f"DEBUG: Executing curl run API call: {' '.join(cmd)}") - print(f"Run payload: {json.dumps(payload, indent=2)}") - # Execute the command try: result = subprocess.run(cmd, capture_output=True, text=True) @@ -405,8 +396,6 @@ def lume_api_pull( f"http://{host}:{port}/lume/pull" ]) - if debug or verbose: - print(f"DEBUG: Executing curl API call: {' '.join(pull_cmd)}") logger.debug(f"Executing API request: {' '.join(pull_cmd)}") try: @@ -474,8 +463,6 @@ def lume_api_delete( # Only print the curl command when debug is enabled display_curl_string = ' '.join(display_cmd) - if debug or verbose: - print(f"DEBUG: Executing curl API call: {display_curl_string}") logger.debug(f"Executing API request: {display_curl_string}") # Execute the command - for execution we need to use shell=True to handle URLs with special characters diff --git a/libs/computer/computer/providers/lumier/__init__.py b/libs/python/computer/computer/providers/lumier/__init__.py similarity index 100% rename from libs/computer/computer/providers/lumier/__init__.py rename to libs/python/computer/computer/providers/lumier/__init__.py diff --git a/libs/computer/computer/providers/lumier/provider.py b/libs/python/computer/computer/providers/lumier/provider.py similarity index 88% rename from libs/computer/computer/providers/lumier/provider.py rename to libs/python/computer/computer/providers/lumier/provider.py index 14c5620d..67f348be 100644 --- a/libs/computer/computer/providers/lumier/provider.py +++ b/libs/python/computer/computer/providers/lumier/provider.py @@ -305,7 +305,7 @@ class LumierProvider(BaseVMProvider): cmd = ["docker", "run", "-d", "--name", self.container_name] cmd.extend(["-p", f"{self.vnc_port}:8006"]) - print(f"Using specified noVNC_port: {self.vnc_port}") + logger.debug(f"Using specified noVNC_port: {self.vnc_port}") # Set API URL using the API port self._api_url = f"http://{self.host}:{self.api_port}" @@ -324,7 +324,7 @@ class LumierProvider(BaseVMProvider): "-v", f"{storage_dir}:/storage", "-e", f"HOST_STORAGE_PATH={storage_dir}" ]) - print(f"Using persistent storage at: {storage_dir}") + logger.debug(f"Using persistent storage at: {storage_dir}") # Add shared folder volume mount if shared_path is specified if self.shared_path: @@ -337,12 +337,12 @@ class LumierProvider(BaseVMProvider): "-v", f"{shared_dir}:/shared", "-e", f"HOST_SHARED_PATH={shared_dir}" ]) - print(f"Using shared folder at: {shared_dir}") + logger.debug(f"Using shared folder at: {shared_dir}") # Add environment variables # Always use the container_name as the VM_NAME for consistency # Use the VM image passed from the Computer class - print(f"Using VM image: {self.image}") + logger.debug(f"Using VM image: {self.image}") # If ghcr.io is in the image, use the full image name if "ghcr.io" in self.image: @@ -362,22 +362,22 @@ class LumierProvider(BaseVMProvider): # First check if the image exists locally try: - print(f"Checking if Docker image {lumier_image} exists locally...") + logger.debug(f"Checking if Docker image {lumier_image} exists locally...") check_image_cmd = ["docker", "image", "inspect", lumier_image] subprocess.run(check_image_cmd, capture_output=True, check=True) - print(f"Docker image {lumier_image} found locally.") + logger.debug(f"Docker image {lumier_image} found locally.") except subprocess.CalledProcessError: # Image doesn't exist locally - print(f"\nWARNING: Docker image {lumier_image} not found locally.") - print("The system will attempt to pull it from Docker Hub, which may fail if you have network connectivity issues.") - print("If the Docker pull fails, you may need to manually pull the image first with:") - print(f" docker pull {lumier_image}\n") + logger.warning(f"\nWARNING: Docker image {lumier_image} not found locally.") + logger.warning("The system will attempt to pull it from Docker Hub, which may fail if you have network connectivity issues.") + logger.warning("If the Docker pull fails, you may need to manually pull the image first with:") + logger.warning(f" docker pull {lumier_image}\n") # Add the image to the command cmd.append(lumier_image) # Print the Docker command for debugging - print(f"DOCKER COMMAND: {' '.join(cmd)}") + logger.debug(f"DOCKER COMMAND: {' '.join(cmd)}") # Run the container with improved error handling try: @@ -395,8 +395,8 @@ class LumierProvider(BaseVMProvider): raise # Container started, now check VM status with polling - print("Container started, checking VM status...") - print("NOTE: This may take some time while the VM image is being pulled and initialized") + logger.debug("Container started, checking VM status...") + logger.debug("NOTE: This may take some time while the VM image is being pulled and initialized") # Start a background thread to show container logs in real-time import threading @@ -404,8 +404,8 @@ class LumierProvider(BaseVMProvider): def show_container_logs(): # Give the container a moment to start generating logs time.sleep(1) - print(f"\n---- CONTAINER LOGS FOR '{name}' (LIVE) ----") - print("Showing logs as they are generated. Press Ctrl+C to stop viewing logs...\n") + logger.debug(f"\n---- CONTAINER LOGS FOR '{name}' (LIVE) ----") + logger.debug("Showing logs as they are generated. Press Ctrl+C to stop viewing logs...\n") try: # Use docker logs with follow option @@ -415,17 +415,17 @@ class LumierProvider(BaseVMProvider): # Read and print logs line by line for line in process.stdout: - print(line, end='') + logger.debug(line, end='') # Break if process has exited if process.poll() is not None: break except Exception as e: - print(f"\nError showing container logs: {e}") + logger.error(f"\nError showing container logs: {e}") if self.verbose: logger.error(f"Error in log streaming thread: {e}") finally: - print("\n---- LOG STREAMING ENDED ----") + logger.debug("\n---- LOG STREAMING ENDED ----") # Make sure process is terminated if 'process' in locals() and process.poll() is None: process.terminate() @@ -452,11 +452,11 @@ class LumierProvider(BaseVMProvider): else: wait_time = min(30, 5 + (attempt * 2)) - print(f"Waiting {wait_time}s before retry #{attempt+1}...") + logger.debug(f"Waiting {wait_time}s before retry #{attempt+1}...") await asyncio.sleep(wait_time) # Try to get VM status - print(f"Checking VM status (attempt {attempt+1})...") + logger.debug(f"Checking VM status (attempt {attempt+1})...") vm_status = await self.get_vm(name) # Check for API errors @@ -468,20 +468,20 @@ class LumierProvider(BaseVMProvider): # since _lume_api_get already logged the technical details if consecutive_errors == 1 or attempt % 5 == 0: if 'Empty reply from server' in error_msg: - print("API server is starting up - container is running, but API isn't fully initialized yet.") - print("This is expected during the initial VM setup - will continue polling...") + logger.info("API server is starting up - container is running, but API isn't fully initialized yet.") + logger.info("This is expected during the initial VM setup - will continue polling...") else: # Don't repeat the exact same error message each time - logger.debug(f"API request error (attempt {attempt+1}): {error_msg}") + logger.warning(f"API request error (attempt {attempt+1}): {error_msg}") # Just log that we're still working on it if attempt > 3: - print("Still waiting for the API server to become available...") + logger.debug("Still waiting for the API server to become available...") # If we're getting errors but container is running, that's normal during startup if vm_status.get('status') == 'running': if not vm_running: - print("Container is running, waiting for the VM within it to become fully ready...") - print("This might take a minute while the VM initializes...") + logger.info("Container is running, waiting for the VM within it to become fully ready...") + logger.info("This might take a minute while the VM initializes...") vm_running = True # Increase counter and continue @@ -497,35 +497,35 @@ class LumierProvider(BaseVMProvider): # Check if we have an IP address, which means the VM is fully ready if 'ip_address' in vm_status and vm_status['ip_address']: - print(f"VM is now fully running with IP: {vm_status.get('ip_address')}") + logger.info(f"VM is now fully running with IP: {vm_status.get('ip_address')}") if 'vnc_url' in vm_status and vm_status['vnc_url']: - print(f"VNC URL: {vm_status.get('vnc_url')}") + logger.info(f"VNC URL: {vm_status.get('vnc_url')}") return vm_status else: - print("VM is running but still initializing network interfaces...") - print("Waiting for IP address to be assigned...") + logger.debug("VM is running but still initializing network interfaces...") + logger.debug("Waiting for IP address to be assigned...") else: # VM exists but might still be starting up status = vm_status.get('status', 'unknown') - print(f"VM found but status is: {status}. Continuing to poll...") + logger.debug(f"VM found but status is: {status}. Continuing to poll...") # Increase counter for next iteration's delay calculation attempt += 1 # If we reach a very large number of attempts, give a reassuring message but continue if attempt % 10 == 0: - print(f"Still waiting after {attempt} attempts. This might take several minutes for first-time setup.") + logger.debug(f"Still waiting after {attempt} attempts. This might take several minutes for first-time setup.") if not vm_running and attempt >= 20: - print("\nNOTE: First-time VM initialization can be slow as images are downloaded.") - print("If this continues for more than 10 minutes, you may want to check:") - print(" 1. Docker logs with: docker logs " + name) - print(" 2. If your network can access container registries") - print("Press Ctrl+C to abort if needed.\n") + logger.warning("\nNOTE: First-time VM initialization can be slow as images are downloaded.") + logger.warning("If this continues for more than 10 minutes, you may want to check:") + logger.warning(" 1. Docker logs with: docker logs " + name) + logger.warning(" 2. If your network can access container registries") + logger.warning("Press Ctrl+C to abort if needed.\n") # After 150 attempts (likely over 30-40 minutes), return current status if attempt >= 150: - print(f"Reached 150 polling attempts. VM status is: {vm_status.get('status', 'unknown')}") - print("Returning current VM status, but please check Docker logs if there are issues.") + logger.debug(f"Reached 150 polling attempts. VM status is: {vm_status.get('status', 'unknown')}") + logger.debug("Returning current VM status, but please check Docker logs if there are issues.") return vm_status except Exception as e: @@ -535,9 +535,9 @@ class LumierProvider(BaseVMProvider): # If we've had too many consecutive errors, might be a deeper problem if consecutive_errors >= 10: - print(f"\nWARNING: Encountered {consecutive_errors} consecutive errors while checking VM status.") - print("You may need to check the Docker container logs or restart the process.") - print(f"Error details: {str(e)}\n") + logger.warning(f"\nWARNING: Encountered {consecutive_errors} consecutive errors while checking VM status.") + logger.warning("You may need to check the Docker container logs or restart the process.") + logger.warning(f"Error details: {str(e)}\n") # Increase attempt counter for next iteration attempt += 1 @@ -545,7 +545,7 @@ class LumierProvider(BaseVMProvider): # After many consecutive errors, add a delay to avoid hammering the system if attempt > 5: error_delay = min(30, 10 + attempt) - print(f"Multiple connection errors, waiting {error_delay}s before next attempt...") + logger.warning(f"Multiple connection errors, waiting {error_delay}s before next attempt...") await asyncio.sleep(error_delay) except subprocess.CalledProcessError as e: @@ -568,7 +568,7 @@ class LumierProvider(BaseVMProvider): api_ready = False container_running = False - print(f"Waiting for container {container_name} to be ready (timeout: {timeout}s)...") + logger.debug(f"Waiting for container {container_name} to be ready (timeout: {timeout}s)...") while time.time() - start_time < timeout: # Check if container is running @@ -579,7 +579,6 @@ class LumierProvider(BaseVMProvider): if container_status and container_status.startswith("Up"): container_running = True - print(f"Container {container_name} is running") logger.info(f"Container {container_name} is running with status: {container_status}") else: logger.warning(f"Container {container_name} not yet running, status: {container_status}") @@ -603,7 +602,6 @@ class LumierProvider(BaseVMProvider): if result.returncode == 0 and "ok" in result.stdout.lower(): api_ready = True - print(f"API is ready at {api_url}") logger.info(f"API is ready at {api_url}") break else: @@ -621,7 +619,6 @@ class LumierProvider(BaseVMProvider): if vm_result.returncode == 0 and vm_result.stdout.strip(): # VM API responded with something - consider the API ready api_ready = True - print(f"VM API is ready at {vm_api_url}") logger.info(f"VM API is ready at {vm_api_url}") break else: @@ -643,7 +640,6 @@ class LumierProvider(BaseVMProvider): else: curl_error = f"Unknown curl error code: {curl_code}" - print(f"API not ready yet: {curl_error}") logger.info(f"API not ready yet: {curl_error}") except subprocess.SubprocessError as e: logger.warning(f"Error checking API status: {e}") @@ -652,22 +648,19 @@ class LumierProvider(BaseVMProvider): # a bit longer before checking again, as the container may still be initializing elapsed_seconds = time.time() - start_time if int(elapsed_seconds) % 5 == 0: # Only print status every 5 seconds to reduce verbosity - print(f"Waiting for API to initialize... ({elapsed_seconds:.1f}s / {timeout}s)") + logger.debug(f"Waiting for API to initialize... ({elapsed_seconds:.1f}s / {timeout}s)") await asyncio.sleep(3) # Longer sleep between API checks # Handle timeout - if the container is running but API is not ready, that's not # necessarily an error - the API might just need more time to start up if not container_running: - print(f"Timed out waiting for container {container_name} to start") logger.warning(f"Timed out waiting for container {container_name} to start") return False if not api_ready: - print(f"Container {container_name} is running, but API is not fully ready yet.") - print("Proceeding with operations. API will become available shortly.") - print("NOTE: You may see some 'API request failed' messages while the API initializes.") logger.warning(f"Container {container_name} is running, but API is not fully ready yet.") + logger.warning(f"NOTE: You may see some 'API request failed' messages while the API initializes.") # Return True if container is running, even if API isn't ready yet # This allows VM operations to proceed, with appropriate retries for API calls @@ -777,8 +770,8 @@ class LumierProvider(BaseVMProvider): # For follow mode with timeout, we'll run the command and handle the timeout log_cmd.append(container_name) logger.info(f"Following logs for container '{container_name}' with timeout {timeout}s") - print(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") - print(f"Press Ctrl+C to stop following logs\n") + logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") + logger.info(f"Press Ctrl+C to stop following logs\n") try: # Run with timeout @@ -790,7 +783,7 @@ class LumierProvider(BaseVMProvider): process.wait(timeout=timeout) except subprocess.TimeoutExpired: process.terminate() # Stop after timeout - print(f"\n---- LOG FOLLOWING STOPPED (timeout {timeout}s reached) ----") + logger.info(f"\n---- LOG FOLLOWING STOPPED (timeout {timeout}s reached) ----") else: # Without timeout, wait for user interruption process.wait() @@ -798,14 +791,14 @@ class LumierProvider(BaseVMProvider): return "Logs were displayed to console in follow mode" except KeyboardInterrupt: process.terminate() - print("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") + logger.info("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") return "Logs were displayed to console in follow mode (interrupted)" else: # For follow mode without timeout, we'll print a helpful message log_cmd.append(container_name) logger.info(f"Following logs for container '{container_name}' indefinitely") - print(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") - print(f"Press Ctrl+C to stop following logs\n") + logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LIVE) ----") + logger.info(f"Press Ctrl+C to stop following logs\n") try: # Run the command and let it run until interrupted @@ -814,7 +807,7 @@ class LumierProvider(BaseVMProvider): return "Logs were displayed to console in follow mode" except KeyboardInterrupt: process.terminate() - print("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") + logger.info("\n---- LOG FOLLOWING STOPPED (user interrupted) ----") return "Logs were displayed to console in follow mode (interrupted)" else: # For non-follow mode, capture and return the logs as a string @@ -827,11 +820,11 @@ class LumierProvider(BaseVMProvider): # Only print header and logs if there's content if logs.strip(): - print(f"\n---- CONTAINER LOGS FOR '{container_name}' (LAST {num_lines} LINES) ----\n") - print(logs) - print(f"\n---- END OF LOGS ----") + logger.info(f"\n---- CONTAINER LOGS FOR '{container_name}' (LAST {num_lines} LINES) ----\n") + logger.info(logs) + logger.info(f"\n---- END OF LOGS ----") else: - print(f"\nNo logs available for container '{container_name}'") + logger.info(f"\nNo logs available for container '{container_name}'") return logs except subprocess.CalledProcessError as e: diff --git a/libs/python/computer/computer/providers/winsandbox/__init__.py b/libs/python/computer/computer/providers/winsandbox/__init__.py new file mode 100644 index 00000000..715ed7db --- /dev/null +++ b/libs/python/computer/computer/providers/winsandbox/__init__.py @@ -0,0 +1,11 @@ +"""Windows Sandbox provider for CUA Computer.""" + +try: + import winsandbox + HAS_WINSANDBOX = True +except ImportError: + HAS_WINSANDBOX = False + +from .provider import WinSandboxProvider + +__all__ = ["WinSandboxProvider", "HAS_WINSANDBOX"] diff --git a/libs/python/computer/computer/providers/winsandbox/provider.py b/libs/python/computer/computer/providers/winsandbox/provider.py new file mode 100644 index 00000000..6196b96f --- /dev/null +++ b/libs/python/computer/computer/providers/winsandbox/provider.py @@ -0,0 +1,485 @@ +"""Windows Sandbox VM provider implementation using pywinsandbox.""" + +import os +import asyncio +import logging +import time +from typing import Dict, Any, Optional, List + +from ..base import BaseVMProvider, VMProviderType + +# Setup logging +logger = logging.getLogger(__name__) + +try: + import winsandbox + HAS_WINSANDBOX = True +except ImportError: + HAS_WINSANDBOX = False + + +class WinSandboxProvider(BaseVMProvider): + """Windows Sandbox VM provider implementation using pywinsandbox. + + This provider uses Windows Sandbox to create isolated Windows environments. + Storage is always ephemeral with Windows Sandbox. + """ + + def __init__( + self, + port: int = 7777, + host: str = "localhost", + storage: Optional[str] = None, + verbose: bool = False, + ephemeral: bool = True, # Windows Sandbox is always ephemeral + memory_mb: int = 4096, + networking: bool = True, + **kwargs + ): + """Initialize the Windows Sandbox provider. + + Args: + port: Port for the computer server (default: 7777) + host: Host to use for connections (default: localhost) + storage: Storage path (ignored - Windows Sandbox is always ephemeral) + verbose: Enable verbose logging + ephemeral: Always True for Windows Sandbox + memory_mb: Memory allocation in MB (default: 4096) + networking: Enable networking in sandbox (default: True) + """ + if not HAS_WINSANDBOX: + raise ImportError( + "pywinsandbox is required for WinSandboxProvider. " + "Please install it with 'pip install pywinsandbox'" + ) + + self.host = host + self.port = port + self.verbose = verbose + self.memory_mb = memory_mb + self.networking = networking + + # Windows Sandbox is always ephemeral + if not ephemeral: + logger.warning("Windows Sandbox storage is always ephemeral. Ignoring ephemeral=False.") + self.ephemeral = True + + # Storage is always ephemeral for Windows Sandbox + if storage and storage != "ephemeral": + logger.warning("Windows Sandbox does not support persistent storage. Using ephemeral storage.") + self.storage = "ephemeral" + + self.logger = logging.getLogger(__name__) + + # Track active sandboxes + self._active_sandboxes: Dict[str, Any] = {} + + @property + def provider_type(self) -> VMProviderType: + """Get the provider type.""" + return VMProviderType.WINSANDBOX + + async def __aenter__(self): + """Enter async context manager.""" + # Verify Windows Sandbox is available + if not HAS_WINSANDBOX: + raise ImportError("pywinsandbox is not available") + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Exit async context manager.""" + # Clean up any active sandboxes + for name, sandbox in self._active_sandboxes.items(): + try: + sandbox.shutdown() + self.logger.info(f"Terminated sandbox: {name}") + except Exception as e: + self.logger.error(f"Error terminating sandbox {name}: {e}") + + self._active_sandboxes.clear() + + async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: + """Get VM information by name. + + Args: + name: Name of the VM to get information for + storage: Ignored for Windows Sandbox (always ephemeral) + + Returns: + Dictionary with VM information including status, IP address, etc. + """ + if name not in self._active_sandboxes: + return { + "name": name, + "status": "stopped", + "ip_address": None, + "storage": "ephemeral" + } + + sandbox = self._active_sandboxes[name] + + # Check if sandbox is still running + try: + # Try to ping the sandbox to see if it's responsive + try: + sandbox.rpyc.modules.os.getcwd() + sandbox_responsive = True + except Exception: + sandbox_responsive = False + + if not sandbox_responsive: + return { + "name": name, + "status": "starting", + "ip_address": None, + "storage": "ephemeral", + "memory_mb": self.memory_mb, + "networking": self.networking + } + + # Check for computer server address file + server_address_file = r"C:\Users\WDAGUtilityAccount\Desktop\shared_windows_sandbox_dir\server_address" + + try: + # Check if the server address file exists + file_exists = sandbox.rpyc.modules.os.path.exists(server_address_file) + + if file_exists: + # Read the server address file + with sandbox.rpyc.builtin.open(server_address_file, 'r') as f: + server_address = f.read().strip() + + if server_address and ':' in server_address: + # Parse IP:port from the file + ip_address, port = server_address.split(':', 1) + + # Verify the server is actually responding + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + result = sock.connect_ex((ip_address, int(port))) + sock.close() + + if result == 0: + # Server is responding + status = "running" + self.logger.debug(f"Computer server found at {ip_address}:{port}") + else: + # Server file exists but not responding + status = "starting" + ip_address = None + except Exception as e: + self.logger.debug(f"Error checking server connectivity: {e}") + status = "starting" + ip_address = None + else: + # File exists but doesn't contain valid address + status = "starting" + ip_address = None + else: + # Server address file doesn't exist yet + status = "starting" + ip_address = None + + except Exception as e: + self.logger.debug(f"Error checking server address file: {e}") + status = "starting" + ip_address = None + + except Exception as e: + self.logger.error(f"Error checking sandbox status: {e}") + status = "error" + ip_address = None + + return { + "name": name, + "status": status, + "ip_address": ip_address, + "storage": "ephemeral", + "memory_mb": self.memory_mb, + "networking": self.networking + } + + async def list_vms(self) -> List[Dict[str, Any]]: + """List all available VMs.""" + vms = [] + for name in self._active_sandboxes.keys(): + vm_info = await self.get_vm(name) + vms.append(vm_info) + return vms + + async def run_vm(self, image: str, name: str, run_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: + """Run a VM with the given options. + + Args: + image: Image name (ignored for Windows Sandbox - always uses host Windows) + name: Name of the VM to run + run_opts: Dictionary of run options (memory, cpu, etc.) + storage: Ignored for Windows Sandbox (always ephemeral) + + Returns: + Dictionary with VM run status and information + """ + if name in self._active_sandboxes: + return { + "success": False, + "error": f"Sandbox {name} is already running" + } + + try: + # Extract options from run_opts + memory_mb = run_opts.get("memory_mb", self.memory_mb) + if isinstance(memory_mb, str): + # Convert memory string like "4GB" to MB + if memory_mb.upper().endswith("GB"): + memory_mb = int(float(memory_mb[:-2]) * 1024) + elif memory_mb.upper().endswith("MB"): + memory_mb = int(memory_mb[:-2]) + else: + memory_mb = self.memory_mb + + networking = run_opts.get("networking", self.networking) + + # Create folder mappers if shared directories are specified + folder_mappers = [] + shared_directories = run_opts.get("shared_directories", []) + for shared_dir in shared_directories: + if isinstance(shared_dir, dict): + host_path = shared_dir.get("hostPath", "") + elif isinstance(shared_dir, str): + host_path = shared_dir + else: + continue + + if host_path and os.path.exists(host_path): + folder_mappers.append(winsandbox.FolderMapper(host_path)) + + self.logger.info(f"Creating Windows Sandbox: {name}") + self.logger.info(f"Memory: {memory_mb}MB, Networking: {networking}") + if folder_mappers: + self.logger.info(f"Shared directories: {len(folder_mappers)}") + + # Create the sandbox without logon script + try: + # Try with memory_mb parameter (newer pywinsandbox version) + sandbox = winsandbox.new_sandbox( + memory_mb=str(memory_mb), + networking=networking, + folder_mappers=folder_mappers + ) + except TypeError as e: + if "memory_mb" in str(e): + # Fallback for older pywinsandbox version that doesn't support memory_mb + self.logger.warning( + f"Your pywinsandbox version doesn't support memory_mb parameter. " + f"Using default memory settings. To use custom memory settings, " + f"please update pywinsandbox: pip install -U git+https://github.com/karkason/pywinsandbox.git" + ) + sandbox = winsandbox.new_sandbox( + networking=networking, + folder_mappers=folder_mappers + ) + else: + # Re-raise if it's a different TypeError + raise + + # Store the sandbox + self._active_sandboxes[name] = sandbox + + self.logger.info(f"Windows Sandbox {name} created successfully") + + # Setup the computer server in the sandbox + await self._setup_computer_server(sandbox, name) + + return { + "success": True, + "name": name, + "status": "starting", + "memory_mb": memory_mb, + "networking": networking, + "storage": "ephemeral" + } + + except Exception as e: + self.logger.error(f"Failed to create Windows Sandbox {name}: {e}") + # stack trace + import traceback + self.logger.error(f"Stack trace: {traceback.format_exc()}") + return { + "success": False, + "error": f"Failed to create sandbox: {str(e)}" + } + + async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: + """Stop a running VM. + + Args: + name: Name of the VM to stop + storage: Ignored for Windows Sandbox + + Returns: + Dictionary with stop status and information + """ + if name not in self._active_sandboxes: + return { + "success": False, + "error": f"Sandbox {name} is not running" + } + + try: + sandbox = self._active_sandboxes[name] + + # Terminate the sandbox + sandbox.shutdown() + + # Remove from active sandboxes + del self._active_sandboxes[name] + + self.logger.info(f"Windows Sandbox {name} stopped successfully") + + return { + "success": True, + "name": name, + "status": "stopped" + } + + except Exception as e: + self.logger.error(f"Failed to stop Windows Sandbox {name}: {e}") + return { + "success": False, + "error": f"Failed to stop sandbox: {str(e)}" + } + + async def update_vm(self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: + """Update VM configuration. + + Note: Windows Sandbox does not support runtime configuration updates. + The sandbox must be stopped and restarted with new configuration. + + Args: + name: Name of the VM to update + update_opts: Dictionary of update options + storage: Ignored for Windows Sandbox + + Returns: + Dictionary with update status and information + """ + return { + "success": False, + "error": "Windows Sandbox does not support runtime configuration updates. " + "Please stop and restart the sandbox with new configuration." + } + + async def get_ip(self, name: str, storage: Optional[str] = None, retry_delay: int = 2) -> str: + """Get the IP address of a VM, waiting indefinitely until it's available. + + Args: + name: Name of the VM to get the IP for + storage: Ignored for Windows Sandbox + retry_delay: Delay between retries in seconds (default: 2) + + Returns: + IP address of the VM when it becomes available + """ + total_attempts = 0 + + # Loop indefinitely until we get a valid IP + while True: + total_attempts += 1 + + # Log retry message but not on first attempt + if total_attempts > 1: + self.logger.info(f"Waiting for Windows Sandbox {name} IP address (attempt {total_attempts})...") + + try: + # Get VM information + vm_info = await self.get_vm(name, storage=storage) + + # Check if we got a valid IP + ip = vm_info.get("ip_address", None) + if ip and ip != "unknown" and not ip.startswith("0.0.0.0"): + self.logger.info(f"Got valid Windows Sandbox IP address: {ip}") + return ip + + # Check the VM status + status = vm_info.get("status", "unknown") + + # If VM is not running yet, log and wait + if status != "running": + self.logger.info(f"Windows Sandbox is not running yet (status: {status}). Waiting...") + # If VM is running but no IP yet, wait and retry + else: + self.logger.info("Windows Sandbox is running but no valid IP address yet. Waiting...") + + except Exception as e: + self.logger.warning(f"Error getting Windows Sandbox {name} IP: {e}, continuing to wait...") + + # Wait before next retry + await asyncio.sleep(retry_delay) + + # Add progress log every 10 attempts + if total_attempts % 10 == 0: + self.logger.info(f"Still waiting for Windows Sandbox {name} IP after {total_attempts} attempts...") + + async def _setup_computer_server(self, sandbox, name: str, visible: bool = False): + """Setup the computer server in the Windows Sandbox using RPyC. + + Args: + sandbox: The Windows Sandbox instance + name: Name of the sandbox + visible: Whether the opened process should be visible (default: False) + """ + try: + self.logger.info(f"Setting up computer server in sandbox {name}...") + + # Read the PowerShell setup script + script_path = os.path.join(os.path.dirname(__file__), "setup_script.ps1") + with open(script_path, 'r', encoding='utf-8') as f: + setup_script_content = f.read() + + # Write the setup script to the sandbox using RPyC + script_dest_path = r"C:\Users\WDAGUtilityAccount\setup_cua.ps1" + + self.logger.info(f"Writing setup script to {script_dest_path}") + with sandbox.rpyc.builtin.open(script_dest_path, 'w') as f: + f.write(setup_script_content) + + # Execute the PowerShell script in the background + self.logger.info("Executing setup script in sandbox...") + + # Use subprocess to run PowerShell script + import subprocess + powershell_cmd = [ + "powershell.exe", + "-ExecutionPolicy", "Bypass", + "-NoExit", # Keep window open after script completes + "-File", script_dest_path + ] + + # Set creation flags based on visibility preference + if visible: + # CREATE_NEW_CONSOLE - creates a new console window (visible) + creation_flags = 0x00000010 + else: + creation_flags = 0x08000000 # CREATE_NO_WINDOW + + # Start the process using RPyC + process = sandbox.rpyc.modules.subprocess.Popen( + powershell_cmd, + creationflags=creation_flags, + shell=False + ) + + # # Sleep for 30 seconds + # await asyncio.sleep(30) + + ip = await self.get_ip(name) + self.logger.info(f"Sandbox IP: {ip}") + self.logger.info(f"Setup script started in background in sandbox {name} with PID: {process.pid}") + + except Exception as e: + self.logger.error(f"Failed to setup computer server in sandbox {name}: {e}") + import traceback + self.logger.error(f"Stack trace: {traceback.format_exc()}") diff --git a/libs/python/computer/computer/providers/winsandbox/setup_script.ps1 b/libs/python/computer/computer/providers/winsandbox/setup_script.ps1 new file mode 100644 index 00000000..73074764 --- /dev/null +++ b/libs/python/computer/computer/providers/winsandbox/setup_script.ps1 @@ -0,0 +1,124 @@ +# Setup script for Windows Sandbox CUA Computer provider +# This script runs when the sandbox starts + +Write-Host "Starting CUA Computer setup in Windows Sandbox..." + +# Function to find the mapped Python installation from pywinsandbox +function Find-MappedPython { + Write-Host "Looking for mapped Python installation from pywinsandbox..." + + # pywinsandbox maps the host Python installation to the sandbox + # Look for mapped shared folders on the desktop (common pywinsandbox pattern) + $desktopPath = "C:\Users\WDAGUtilityAccount\Desktop" + $sharedFolders = Get-ChildItem -Path $desktopPath -Directory -ErrorAction SilentlyContinue + + foreach ($folder in $sharedFolders) { + # Look for Python executables in shared folders + $pythonPaths = @( + "$($folder.FullName)\python.exe", + "$($folder.FullName)\Scripts\python.exe", + "$($folder.FullName)\bin\python.exe" + ) + + foreach ($pythonPath in $pythonPaths) { + if (Test-Path $pythonPath) { + try { + $version = & $pythonPath --version 2>&1 + if ($version -match "Python") { + Write-Host "Found mapped Python: $pythonPath - $version" + return $pythonPath + } + } catch { + continue + } + } + } + + # Also check subdirectories that might contain Python + $subDirs = Get-ChildItem -Path $folder.FullName -Directory -ErrorAction SilentlyContinue + foreach ($subDir in $subDirs) { + $pythonPath = "$($subDir.FullName)\python.exe" + if (Test-Path $pythonPath) { + try { + $version = & $pythonPath --version 2>&1 + if ($version -match "Python") { + Write-Host "Found mapped Python in subdirectory: $pythonPath - $version" + return $pythonPath + } + } catch { + continue + } + } + } + } + + # Fallback: try common Python commands that might be available + $pythonCommands = @("python", "py", "python3") + foreach ($cmd in $pythonCommands) { + try { + $version = & $cmd --version 2>&1 + if ($version -match "Python") { + Write-Host "Found Python via command '$cmd': $version" + return $cmd + } + } catch { + continue + } + } + + throw "Could not find any Python installation (mapped or otherwise)" +} + +try { + # Step 1: Find the mapped Python installation + Write-Host "Step 1: Finding mapped Python installation..." + $pythonExe = Find-MappedPython + Write-Host "Using Python: $pythonExe" + + # Verify Python works and show version + $pythonVersion = & $pythonExe --version 2>&1 + Write-Host "Python version: $pythonVersion" + + # Step 2: Install cua-computer-server directly + Write-Host "Step 2: Installing cua-computer-server..." + + Write-Host "Upgrading pip..." + & $pythonExe -m pip install --upgrade pip --quiet + + Write-Host "Installing cua-computer-server..." + & $pythonExe -m pip install cua-computer-server --quiet + + Write-Host "cua-computer-server installation completed." + + # Step 3: Start computer server in background + Write-Host "Step 3: Starting computer server in background..." + Write-Host "Starting computer server with: $pythonExe" + + # Start the computer server in the background + $serverProcess = Start-Process -FilePath $pythonExe -ArgumentList "-m", "computer_server.main" -WindowStyle Hidden -PassThru + Write-Host "Computer server started in background with PID: $($serverProcess.Id)" + + # Give it a moment to start + Start-Sleep -Seconds 3 + + # Check if the process is still running + if (Get-Process -Id $serverProcess.Id -ErrorAction SilentlyContinue) { + Write-Host "Computer server is running successfully in background" + } else { + throw "Computer server failed to start or exited immediately" + } + +} catch { + Write-Error "Setup failed: $_" + Write-Host "Error details: $($_.Exception.Message)" + Write-Host "Stack trace: $($_.ScriptStackTrace)" + Write-Host "" + Write-Host "Press any key to close this window..." + $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + exit 1 +} + +Write-Host "" +Write-Host "Setup completed successfully!" +Write-Host "Press any key to close this window..." +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") diff --git a/libs/computer/computer/telemetry.py b/libs/python/computer/computer/telemetry.py similarity index 93% rename from libs/computer/computer/telemetry.py rename to libs/python/computer/computer/telemetry.py index 38be92a9..69d064f8 100644 --- a/libs/computer/computer/telemetry.py +++ b/libs/python/computer/computer/telemetry.py @@ -9,10 +9,10 @@ TELEMETRY_AVAILABLE = False try: from core.telemetry import ( - record_event, increment, is_telemetry_enabled, is_telemetry_globally_disabled, + record_event, ) def increment_counter(counter_name: str, value: int = 1) -> None: @@ -22,14 +22,14 @@ try: def set_dimension(name: str, value: Any) -> None: """Set a dimension that will be attached to all events.""" - logger = logging.getLogger("cua.computer.telemetry") + logger = logging.getLogger("computer.telemetry") logger.debug(f"Setting dimension {name}={value}") TELEMETRY_AVAILABLE = True - logger = logging.getLogger("cua.computer.telemetry") + logger = logging.getLogger("computer.telemetry") logger.info("Successfully imported telemetry") except ImportError as e: - logger = logging.getLogger("cua.computer.telemetry") + logger = logging.getLogger("computer.telemetry") logger.warning(f"Could not import telemetry: {e}") TELEMETRY_AVAILABLE = False @@ -40,7 +40,7 @@ def _noop(*args: Any, **kwargs: Any) -> None: pass -logger = logging.getLogger("cua.computer.telemetry") +logger = logging.getLogger("computer.telemetry") # If telemetry isn't available, use no-op functions if not TELEMETRY_AVAILABLE: diff --git a/libs/computer/computer/ui/__init__.py b/libs/python/computer/computer/ui/__init__.py similarity index 100% rename from libs/computer/computer/ui/__init__.py rename to libs/python/computer/computer/ui/__init__.py diff --git a/libs/python/computer/computer/ui/__main__.py b/libs/python/computer/computer/ui/__main__.py new file mode 100644 index 00000000..abd16f82 --- /dev/null +++ b/libs/python/computer/computer/ui/__main__.py @@ -0,0 +1,15 @@ +""" +Main entry point for computer.ui module. + +This allows running the computer UI with: + python -m computer.ui + +Instead of: + python -m computer.ui.gradio.app +""" + +from .gradio.app import create_gradio_ui + +if __name__ == "__main__": + app = create_gradio_ui() + app.launch() diff --git a/libs/computer/computer/ui/gradio/__init__.py b/libs/python/computer/computer/ui/gradio/__init__.py similarity index 100% rename from libs/computer/computer/ui/gradio/__init__.py rename to libs/python/computer/computer/ui/gradio/__init__.py diff --git a/libs/computer/computer/ui/gradio/app.py b/libs/python/computer/computer/ui/gradio/app.py similarity index 94% rename from libs/computer/computer/ui/gradio/app.py rename to libs/python/computer/computer/ui/gradio/app.py index b1d131d9..8c3708db 100644 --- a/libs/computer/computer/ui/gradio/app.py +++ b/libs/python/computer/computer/ui/gradio/app.py @@ -528,13 +528,15 @@ async def execute(name, action, arguments): return results -async def handle_init_computer(os_choice: str, app_list=None, provider="lume"): - """Initialize the computer instance and tools for macOS or Ubuntu +async def handle_init_computer(os_choice: str, app_list=None, provider="lume", container_name=None, api_key=None): + """Initialize the computer instance and tools for macOS or Ubuntu or Windows Args: - os_choice: The OS to use ("macOS" or "Ubuntu") + os_choice: The OS to use ("macOS" or "Ubuntu" or "Windows") app_list: Optional list of apps to focus on using the app-use experiment - provider: The provider to use ("lume" or "self") + provider: The provider to use ("lume" or "self" or "cloud") + container_name: The container name to use for cloud provider + api_key: The API key to use for cloud provider """ global computer, tool_call_logs, tools @@ -548,6 +550,9 @@ async def handle_init_computer(os_choice: str, app_list=None, provider="lume"): if os_choice == "Ubuntu": os_type_str = "linux" image_str = "ubuntu-noble-vanilla:latest" + elif os_choice == "Windows": + os_type_str = "windows" + image_str = "windows-11-vanilla:latest" else: os_type_str = "macos" image_str = "macos-sequoia-cua:latest" @@ -559,6 +564,22 @@ async def handle_init_computer(os_choice: str, app_list=None, provider="lume"): use_host_computer_server=True, experiments=experiments ) + elif provider == "cloud": + # Use API key from environment variable or field input + cloud_api_key = os.environ.get("CUA_API_KEY") or api_key + computer = Computer( + os_type=os_type_str, + provider_type=VMProviderType.CLOUD, + name=container_name, + api_key=cloud_api_key, + experiments=experiments + ) + elif provider == "winsandbox": + computer = Computer( + os_type="windows", + provider_type=VMProviderType.WINSANDBOX, + experiments=experiments + ) else: computer = Computer( image=image_str, @@ -596,6 +617,10 @@ async def handle_init_computer(os_choice: str, app_list=None, provider="lume"): init_params["apps"] = app_list init_params["experiments"] = ["app-use"] + # Add container name to the log if using cloud provider + if provider == "cloud": + init_params["container_name"] = container_name + result = await execute("computer", "initialize", init_params) return result["screenshot"], json.dumps(tool_call_logs, indent=2) @@ -1065,19 +1090,38 @@ def create_gradio_ui(): with gr.Row(): os_choice = gr.Radio( label="OS", - choices=["macOS", "Ubuntu"], + choices=["macOS", "Ubuntu", "Windows"], value="macOS", - interactive=False # disable until the ubuntu image is ready ) # Provider selection radio provider_choice = gr.Radio( label="Provider", - choices=["lume", "self"], + choices=["lume", "self", "cloud", "winsandbox"], value="lume", - info="'lume' uses a VM, 'self' uses the host computer server" + info="'lume' uses a VM, 'self' uses the host computer server, 'cloud' uses a cloud container" ) + # Container name field for cloud provider (initially hidden) + container_name = gr.Textbox( + label="Container Name", + placeholder="Enter your container name", + visible=False, + info="Get your container from [trycua.com](https://trycua.com/)" + ) + + # Check if CUA_API_KEY is set in environment + has_cua_key = os.environ.get("CUA_API_KEY") is not None + + # API key field for cloud provider (visible only if no env key and cloud selected) + api_key_field = gr.Textbox( + label="CUA API Key", + placeholder="Enter your CUA API key", + type="password", + visible=False, + info="Required for cloud provider. Set CUA_API_KEY environment variable to hide this field." + ) + # App filtering dropdown for app-use experiment app_filter = gr.Dropdown( label="Filter by apps (App-Use)", @@ -1085,6 +1129,22 @@ def create_gradio_ui(): allow_custom_value=True, info="When apps are selected, the computer will focus on those apps using the app-use experiment" ) + + # Function to show/hide container name and API key fields based on provider selection + def update_cloud_fields_visibility(provider): + show_container = provider == "cloud" + show_api_key = provider == "cloud" and not has_cua_key + return ( + gr.update(visible=show_container), + gr.update(visible=show_api_key) + ) + + # Connect provider choice to field visibility + provider_choice.change( + update_cloud_fields_visibility, + inputs=provider_choice, + outputs=[container_name, api_key_field] + ) start_btn = gr.Button("Initialize Computer") @@ -1149,7 +1209,7 @@ def create_gradio_ui(): value=False ) message_submit_btn = gr.Button("Submit Message") - message_status = gr.Textbox(label="Status", value="") + message_status = gr.Textbox(label="Status") with gr.Accordion("Clipboard Operations", open=False): clipboard_content = gr.Textbox(label="Clipboard Content") @@ -1250,7 +1310,7 @@ def create_gradio_ui(): ) img.select(handle_click, inputs=[img, click_type], outputs=[img, action_log]) - start_btn.click(handle_init_computer, inputs=[os_choice, app_filter, provider_choice], outputs=[img, action_log]) + start_btn.click(handle_init_computer, inputs=[os_choice, app_filter, provider_choice, container_name, api_key_field], outputs=[img, action_log]) wait_btn.click(handle_wait, outputs=[img, action_log]) # DONE and FAIL buttons just do a placeholder action diff --git a/libs/computer/computer/utils.py b/libs/python/computer/computer/utils.py similarity index 100% rename from libs/computer/computer/utils.py rename to libs/python/computer/computer/utils.py diff --git a/libs/computer/poetry.toml b/libs/python/computer/poetry.toml similarity index 100% rename from libs/computer/poetry.toml rename to libs/python/computer/poetry.toml diff --git a/libs/computer/pyproject.toml b/libs/python/computer/pyproject.toml similarity index 85% rename from libs/computer/pyproject.toml rename to libs/python/computer/pyproject.toml index c9aa46da..2e564fa9 100644 --- a/libs/computer/pyproject.toml +++ b/libs/python/computer/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "cua-computer" -version = "0.1.0" +version = "0.3.0" description = "Computer-Use Interface (CUI) framework powering Cua" readme = "README.md" authors = [ @@ -26,15 +26,15 @@ lume = [ lumier = [ ] ui = [ - "gradio>=5.23.3,<6.0.0", - "python-dotenv>=1.0.1,<2.0.0", - "datasets>=3.6.0,<4.0.0", + "gradio>=5.23.3", + "python-dotenv>=1.0.1", + "datasets>=3.6.0", ] all = [ # Include all optional dependencies - "gradio>=5.23.3,<6.0.0", - "python-dotenv>=1.0.1,<2.0.0", - "datasets>=3.6.0,<4.0.0", + "gradio>=5.23.3", + "python-dotenv>=1.0.1", + "datasets>=3.6.0", ] [tool.pdm] diff --git a/libs/core/README.md b/libs/python/core/README.md similarity index 100% rename from libs/core/README.md rename to libs/python/core/README.md diff --git a/libs/core/core/__init__.py b/libs/python/core/core/__init__.py similarity index 100% rename from libs/core/core/__init__.py rename to libs/python/core/core/__init__.py diff --git a/libs/core/core/telemetry/__init__.py b/libs/python/core/core/telemetry/__init__.py similarity index 100% rename from libs/core/core/telemetry/__init__.py rename to libs/python/core/core/telemetry/__init__.py diff --git a/libs/core/core/telemetry/client.py b/libs/python/core/core/telemetry/client.py similarity index 99% rename from libs/core/core/telemetry/client.py rename to libs/python/core/core/telemetry/client.py index 22686890..d4eb9c70 100644 --- a/libs/core/core/telemetry/client.py +++ b/libs/python/core/core/telemetry/client.py @@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional from core import __version__ from core.telemetry.sender import send_telemetry -logger = logging.getLogger("cua.telemetry") +logger = logging.getLogger("core.telemetry") # Controls how frequently telemetry will be sent (percentage) TELEMETRY_SAMPLE_RATE = 5 # 5% sampling rate diff --git a/libs/core/core/telemetry/models.py b/libs/python/core/core/telemetry/models.py similarity index 100% rename from libs/core/core/telemetry/models.py rename to libs/python/core/core/telemetry/models.py diff --git a/libs/core/core/telemetry/posthog_client.py b/libs/python/core/core/telemetry/posthog_client.py similarity index 99% rename from libs/core/core/telemetry/posthog_client.py rename to libs/python/core/core/telemetry/posthog_client.py index 8ddb1dd2..ad8a7685 100644 --- a/libs/core/core/telemetry/posthog_client.py +++ b/libs/python/core/core/telemetry/posthog_client.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional import posthog from core import __version__ -logger = logging.getLogger("cua.telemetry") +logger = logging.getLogger("core.telemetry") # Controls how frequently telemetry will be sent (percentage) TELEMETRY_SAMPLE_RATE = 100 # 100% sampling rate (was 5%) diff --git a/libs/core/core/telemetry/sender.py b/libs/python/core/core/telemetry/sender.py similarity index 93% rename from libs/core/core/telemetry/sender.py rename to libs/python/core/core/telemetry/sender.py index db96b1ac..8772868f 100644 --- a/libs/core/core/telemetry/sender.py +++ b/libs/python/core/core/telemetry/sender.py @@ -3,7 +3,7 @@ import logging from typing import Any, Dict -logger = logging.getLogger("cua.telemetry") +logger = logging.getLogger("core.telemetry") def send_telemetry(payload: Dict[str, Any]) -> bool: diff --git a/libs/core/core/telemetry/telemetry.py b/libs/python/core/core/telemetry/telemetry.py similarity index 97% rename from libs/core/core/telemetry/telemetry.py rename to libs/python/core/core/telemetry/telemetry.py index 2a6e052e..f01421cd 100644 --- a/libs/core/core/telemetry/telemetry.py +++ b/libs/python/core/core/telemetry/telemetry.py @@ -30,7 +30,7 @@ def _configure_telemetry_logging() -> None: level = logging.ERROR # Configure the main telemetry logger - telemetry_logger = logging.getLogger("cua.telemetry") + telemetry_logger = logging.getLogger("core.telemetry") telemetry_logger.setLevel(level) @@ -46,11 +46,11 @@ try: POSTHOG_AVAILABLE = True except ImportError: - logger = logging.getLogger("cua.telemetry") + logger = logging.getLogger("core.telemetry") logger.info("PostHog not available. Install with: pdm add posthog") POSTHOG_AVAILABLE = False -logger = logging.getLogger("cua.telemetry") +logger = logging.getLogger("core.telemetry") # Check environment variables for global telemetry opt-out @@ -292,10 +292,9 @@ def set_telemetry_log_level(level: Optional[int] = None) -> None: # Set the level for all telemetry-related loggers telemetry_loggers = [ - "cua.telemetry", "core.telemetry", - "cua.agent.telemetry", - "cua.computer.telemetry", + "agent.telemetry", + "computer.telemetry", "posthog", ] diff --git a/libs/core/poetry.toml b/libs/python/core/poetry.toml similarity index 100% rename from libs/core/poetry.toml rename to libs/python/core/poetry.toml diff --git a/libs/core/pyproject.toml b/libs/python/core/pyproject.toml similarity index 100% rename from libs/core/pyproject.toml rename to libs/python/core/pyproject.toml diff --git a/libs/mcp-server/README.md b/libs/python/mcp-server/README.md similarity index 94% rename from libs/mcp-server/README.md rename to libs/python/mcp-server/README.md index 736ab364..3f3c8bbb 100644 --- a/libs/mcp-server/README.md +++ b/libs/python/mcp-server/README.md @@ -47,7 +47,7 @@ This will install: If you want to simplify installation, you can use this one-liner to download and run the installation script: ```bash -curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/mcp-server/scripts/install_mcp_server.sh | bash +curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/python/mcp-server/scripts/install_mcp_server.sh | bash ``` This script will: @@ -84,7 +84,7 @@ If you want to develop with the cua-mcp-server directly without installation, yo "mcpServers": { "cua-agent": { "command": "/bin/bash", - "args": ["~/cua/libs/mcp-server/scripts/start_mcp_server.sh"], + "args": ["~/cua/libs/python/mcp-server/scripts/start_mcp_server.sh"], "env": { "CUA_AGENT_LOOP": "UITARS", "CUA_MODEL_PROVIDER": "OAICOMPAT", @@ -106,7 +106,7 @@ Just add this to your MCP client's configuration and it will use your local deve ### Troubleshooting -If you get a `/bin/bash: ~/cua/libs/mcp-server/scripts/start_mcp_server.sh: No such file or directory` error, try changing the path to the script to be absolute instead of relative. +If you get a `/bin/bash: ~/cua/libs/python/mcp-server/scripts/start_mcp_server.sh: No such file or directory` error, try changing the path to the script to be absolute instead of relative. To see the logs: ``` diff --git a/libs/mcp-server/mcp_server/__init__.py b/libs/python/mcp-server/mcp_server/__init__.py similarity index 100% rename from libs/mcp-server/mcp_server/__init__.py rename to libs/python/mcp-server/mcp_server/__init__.py diff --git a/libs/mcp-server/mcp_server/__main__.py b/libs/python/mcp-server/mcp_server/__main__.py similarity index 100% rename from libs/mcp-server/mcp_server/__main__.py rename to libs/python/mcp-server/mcp_server/__main__.py diff --git a/libs/mcp-server/mcp_server/server.py b/libs/python/mcp-server/mcp_server/server.py similarity index 100% rename from libs/mcp-server/mcp_server/server.py rename to libs/python/mcp-server/mcp_server/server.py diff --git a/libs/mcp-server/pyproject.toml b/libs/python/mcp-server/pyproject.toml similarity index 90% rename from libs/mcp-server/pyproject.toml rename to libs/python/mcp-server/pyproject.toml index 62fc1a2e..ed2ad435 100644 --- a/libs/mcp-server/pyproject.toml +++ b/libs/python/mcp-server/pyproject.toml @@ -13,8 +13,8 @@ authors = [ ] dependencies = [ "mcp>=1.6.0,<2.0.0", - "cua-agent[all]>=0.2.0,<0.3.0", - "cua-computer>=0.2.0,<0.3.0", + "cua-agent[all]>=0.3.0,<0.4.0", + "cua-computer>=0.3.0,<0.4.0", ] [project.scripts] diff --git a/libs/mcp-server/scripts/install_mcp_server.sh b/libs/python/mcp-server/scripts/install_mcp_server.sh similarity index 100% rename from libs/mcp-server/scripts/install_mcp_server.sh rename to libs/python/mcp-server/scripts/install_mcp_server.sh diff --git a/libs/mcp-server/scripts/start_mcp_server.sh b/libs/python/mcp-server/scripts/start_mcp_server.sh similarity index 66% rename from libs/mcp-server/scripts/start_mcp_server.sh rename to libs/python/mcp-server/scripts/start_mcp_server.sh index 17fd9dab..13257351 100755 --- a/libs/mcp-server/scripts/start_mcp_server.sh +++ b/libs/python/mcp-server/scripts/start_mcp_server.sh @@ -8,7 +8,7 @@ CUA_REPO_DIR="$( cd "$SCRIPT_DIR/../../.." &> /dev/null && pwd )" PYTHON_PATH="${CUA_REPO_DIR}/.venv/bin/python" # Set Python path to include all necessary libraries -export PYTHONPATH="${CUA_REPO_DIR}/libs/mcp-server:${CUA_REPO_DIR}/libs/agent:${CUA_REPO_DIR}/libs/computer:${CUA_REPO_DIR}/libs/core:${CUA_REPO_DIR}/libs/pylume" +export PYTHONPATH="${CUA_REPO_DIR}/libs/python/mcp-server:${CUA_REPO_DIR}/libs/python/agent:${CUA_REPO_DIR}/libs/python/computer:${CUA_REPO_DIR}/libs/python/core:${CUA_REPO_DIR}/libs/python/pylume" # Run the MCP server directly as a module $PYTHON_PATH -m mcp_server.server \ No newline at end of file diff --git a/libs/pylume/README.md b/libs/python/pylume/README.md similarity index 100% rename from libs/pylume/README.md rename to libs/python/pylume/README.md diff --git a/libs/pylume/__init__.py b/libs/python/pylume/__init__.py similarity index 100% rename from libs/pylume/__init__.py rename to libs/python/pylume/__init__.py diff --git a/libs/pylume/pylume/__init__.py b/libs/python/pylume/pylume/__init__.py similarity index 100% rename from libs/pylume/pylume/__init__.py rename to libs/python/pylume/pylume/__init__.py diff --git a/libs/pylume/pylume/client.py b/libs/python/pylume/pylume/client.py similarity index 100% rename from libs/pylume/pylume/client.py rename to libs/python/pylume/pylume/client.py diff --git a/libs/pylume/pylume/exceptions.py b/libs/python/pylume/pylume/exceptions.py similarity index 100% rename from libs/pylume/pylume/exceptions.py rename to libs/python/pylume/pylume/exceptions.py diff --git a/libs/pylume/pylume/lume b/libs/python/pylume/pylume/lume similarity index 100% rename from libs/pylume/pylume/lume rename to libs/python/pylume/pylume/lume diff --git a/libs/pylume/pylume/models.py b/libs/python/pylume/pylume/models.py similarity index 100% rename from libs/pylume/pylume/models.py rename to libs/python/pylume/pylume/models.py diff --git a/libs/pylume/pylume/pylume.py b/libs/python/pylume/pylume/pylume.py similarity index 100% rename from libs/pylume/pylume/pylume.py rename to libs/python/pylume/pylume/pylume.py diff --git a/libs/pylume/pylume/server.py b/libs/python/pylume/pylume/server.py similarity index 100% rename from libs/pylume/pylume/server.py rename to libs/python/pylume/pylume/server.py diff --git a/libs/pylume/pyproject.toml b/libs/python/pylume/pyproject.toml similarity index 100% rename from libs/pylume/pyproject.toml rename to libs/python/pylume/pyproject.toml diff --git a/libs/som/README.md b/libs/python/som/README.md similarity index 100% rename from libs/som/README.md rename to libs/python/som/README.md diff --git a/libs/som/poetry.toml b/libs/python/som/poetry.toml similarity index 100% rename from libs/som/poetry.toml rename to libs/python/som/poetry.toml diff --git a/libs/som/pyproject.toml b/libs/python/som/pyproject.toml similarity index 100% rename from libs/som/pyproject.toml rename to libs/python/som/pyproject.toml diff --git a/libs/som/som/__init__.py b/libs/python/som/som/__init__.py similarity index 100% rename from libs/som/som/__init__.py rename to libs/python/som/som/__init__.py diff --git a/libs/som/som/detect.py b/libs/python/som/som/detect.py similarity index 100% rename from libs/som/som/detect.py rename to libs/python/som/som/detect.py diff --git a/libs/som/som/detection.py b/libs/python/som/som/detection.py similarity index 100% rename from libs/som/som/detection.py rename to libs/python/som/som/detection.py diff --git a/libs/som/som/models.py b/libs/python/som/som/models.py similarity index 100% rename from libs/som/som/models.py rename to libs/python/som/som/models.py diff --git a/libs/som/som/ocr.py b/libs/python/som/som/ocr.py similarity index 100% rename from libs/som/som/ocr.py rename to libs/python/som/som/ocr.py diff --git a/libs/som/som/util/utils.py b/libs/python/som/som/util/utils.py similarity index 100% rename from libs/som/som/util/utils.py rename to libs/python/som/som/util/utils.py diff --git a/libs/som/som/visualization.py b/libs/python/som/som/visualization.py similarity index 100% rename from libs/som/som/visualization.py rename to libs/python/som/som/visualization.py diff --git a/libs/som/tests/test_omniparser.py b/libs/python/som/tests/test_omniparser.py similarity index 100% rename from libs/som/tests/test_omniparser.py rename to libs/python/som/tests/test_omniparser.py diff --git a/libs/typescript/.gitignore b/libs/typescript/.gitignore new file mode 100644 index 00000000..77dae5e3 --- /dev/null +++ b/libs/typescript/.gitignore @@ -0,0 +1,5 @@ +node_modules + +*.log +.DS_Store +.eslintcache diff --git a/libs/typescript/.nvmrc b/libs/typescript/.nvmrc new file mode 100644 index 00000000..dc864a05 --- /dev/null +++ b/libs/typescript/.nvmrc @@ -0,0 +1 @@ +v24.2.0 diff --git a/libs/typescript/README.md b/libs/typescript/README.md new file mode 100644 index 00000000..78fda2e7 --- /dev/null +++ b/libs/typescript/README.md @@ -0,0 +1,119 @@ +# C/UA TypeScript Libraries + +This repository contains TypeScript implementations of the C/UA libraries: + +- `@trycua/core`: Core functionality including telemetry and logging +- `@trycua/computer`: Computer interaction SDK for VM management and control + +## Project Structure + +```text +libs/typescript/ +├── computer/ # Computer SDK package +├── core/ # Core functionality package +├── package.json # Root package configuration +└── pnpm-workspace.yaml # Workspace configuration +``` + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later) +- [pnpm](https://pnpm.io/) (v10 or later) + +## Setup and Installation + +1. Install dependencies for all packages: + +```bash +pnpm install +``` + +1. Build all packages: + +```bash +pnpm build:all +``` + +## Development Workflow + +### Building Packages + +Build all packages in the correct dependency order: + +```bash +pnpm build:all +``` + +Build specific packages: + +```bash +# Build core package +pnpm --filter @trycua/core build + +# Build computer package +pnpm --filter @trycua/computer build +``` + +### Running Tests + +Run tests for all packages: + +```bash +pnpm test:all +``` + +Run tests for specific packages: + +```bash +# Test core package +pnpm --filter @trycua/core test + +# Test computer package +pnpm --filter @trycua/computer test +``` + +### Linting + +Lint all packages: + +```bash +pnpm lint:all +``` + +Fix linting issues: + +```bash +pnpm lint:fix:all +``` + +## Package Details + +### @trycua/core + +Core functionality for C/UA libraries including: + +- Telemetry with PostHog integration +- Common utilities and types + +### @trycua/computer + +Computer interaction SDK for managing and controlling virtual machines: + +- VM provider system (Cloud) +- Interface system for OS-specific interactions +- Screenshot, keyboard, and mouse control +- Command execution + +## Publishing + +Prepare packages for publishing: + +```bash +pnpm -r build +``` + +Publish packages: + +```bash +pnpm -r publish +``` diff --git a/libs/typescript/biome.json b/libs/typescript/biome.json new file mode 100644 index 00000000..a0394eff --- /dev/null +++ b/libs/typescript/biome.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "include": ["core/**/*.ts", "computer/**/*.ts"], + "ignore": ["dist", "node_modules"] + }, + "formatter": { + "enabled": true, + "useEditorconfig": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 80, + "attributePosition": "auto", + "bracketSpacing": true + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useSelfClosingElements": "warn", + "noUnusedTemplateLiteral": "warn", + "noNonNullAssertion": "off" + }, + "a11y": { + "useMediaCaption": "off", + "useKeyWithClickEvents": "warn", + "useKeyWithMouseEvents": "warn", + "noSvgWithoutTitle": "off", + "useButtonType": "warn", + "noAutofocus": "off" + }, + "suspicious": { + "noArrayIndexKey": "off" + }, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedFunctionParameters": "warn", + "noUnusedImports": "warn" + }, + "complexity": { + "useOptionalChain": "info" + }, + "nursery": { + "useSortedClasses": { + "level": "warn", + "fix": "safe", + "options": { + "attributes": ["className"], + "functions": ["cn"] + } + } + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto", + "bracketSpacing": true + } + } +} diff --git a/libs/typescript/computer/.editorconfig b/libs/typescript/computer/.editorconfig new file mode 100644 index 00000000..7095e7fb --- /dev/null +++ b/libs/typescript/computer/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +indent_size = 2 +end_of_line = lf +insert_final_newline = true diff --git a/libs/typescript/computer/.gitattributes b/libs/typescript/computer/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/libs/typescript/computer/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/libs/typescript/computer/.gitignore b/libs/typescript/computer/.gitignore new file mode 100644 index 00000000..e79f2036 --- /dev/null +++ b/libs/typescript/computer/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist + +*.log +.DS_Store +.eslintcache diff --git a/libs/typescript/computer/LICENSE b/libs/typescript/computer/LICENSE new file mode 100644 index 00000000..7ff04379 --- /dev/null +++ b/libs/typescript/computer/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2025 C/UA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/typescript/computer/README.md b/libs/typescript/computer/README.md new file mode 100644 index 00000000..a9d42740 --- /dev/null +++ b/libs/typescript/computer/README.md @@ -0,0 +1,90 @@ +# C/ua Computer TypeScript Library + +The TypeScript library for C/cua Computer - a powerful computer control and automation library. + +## Overview + +This library is a TypeScript port of the Python computer library, providing the same functionality for controlling virtual machines and computer interfaces. It enables programmatic control of virtual machines through various providers and offers a consistent interface for interacting with the VM's operating system. + +## Installation + +```bash +npm install @trycua/computer +# or +pnpm add @trycua/computer +``` + +## Usage + +```typescript +import { Computer } from '@trycua/computer'; + +// Create a new computer instance +const computer = new Computer({ + osType: OSType.LINUX, + name: 's-linux-vm_id' + apiKey: 'your-api-key' +}); + +// Start the computer +await computer.run(); + +// Get the computer interface for interaction +const interface = computer.interface; + +// Take a screenshot +const screenshot = await interface.getScreenshot(); + +// Click at coordinates +await interface.click(500, 300); + +// Type text +await interface.typeText('Hello, world!'); + +// Stop the computer +await computer.stop(); +``` + +## Architecture + +The library is organized into the following structure: + +### Core Components + +- **Computer Factory**: A factory object that creates appropriate computer instances +- **BaseComputer**: Abstract base class with shared functionality for all computer types +- **Types**: Type definitions for configuration options and shared interfaces + +### Provider Implementations + +- **Computer**: Implementation for cloud-based VMs + +## Development + +- Install dependencies: + +```bash +pnpm install +``` + +- Run the unit tests: + +```bash +pnpm test +``` + +- Build the library: + +```bash +pnpm build +``` + +- Type checking: + +```bash +pnpm typecheck +``` + +## License + +[MIT](./LICENSE) License 2025 [C/UA](https://github.com/trycua) diff --git a/libs/typescript/computer/package.json b/libs/typescript/computer/package.json new file mode 100644 index 00000000..ba9c0934 --- /dev/null +++ b/libs/typescript/computer/package.json @@ -0,0 +1,56 @@ +{ + "name": "@trycua/computer", + "version": "0.1.3", + "packageManager": "pnpm@10.11.0", + "description": "Typescript SDK for c/ua computer interaction", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/trycua/cua/tree/feature/computer/typescript/libs/typescript/computer", + "bugs": { + "url": "https://github.com/trycua/cua/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/trycua/cua.git" + }, + "author": "c/ua", + "files": [ + "dist" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "biome lint .", + "lint:fix": "biome lint --fix .", + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest", + "typecheck": "tsc --noEmit", + "release": "bumpp && pnpm publish", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@trycua/core": "^0.1.2", + "pino": "^9.7.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.15.17", + "@types/ws": "^8.18.1", + "bumpp": "^10.1.0", + "happy-dom": "^17.4.7", + "tsdown": "^0.11.9", + "tsx": "^4.19.4", + "typescript": "^5.8.3", + "vitest": "^3.1.3" + } +} \ No newline at end of file diff --git a/libs/typescript/computer/src/computer/index.ts b/libs/typescript/computer/src/computer/index.ts new file mode 100644 index 00000000..d7411b63 --- /dev/null +++ b/libs/typescript/computer/src/computer/index.ts @@ -0,0 +1 @@ +export { BaseComputer, CloudComputer } from './providers'; diff --git a/libs/typescript/computer/src/computer/providers/base.ts b/libs/typescript/computer/src/computer/providers/base.ts new file mode 100644 index 00000000..ffd6da2d --- /dev/null +++ b/libs/typescript/computer/src/computer/providers/base.ts @@ -0,0 +1,117 @@ +import os from "node:os"; +import { Telemetry } from "@trycua/core"; +import pino from "pino"; +import type { OSType } from "../../types"; +import type { BaseComputerConfig, Display, VMProviderType } from "../types"; + +const logger = pino({ name: "computer.provider_base" }); + +/** + * Base Computer class with shared functionality + */ +export abstract class BaseComputer { + protected name: string; + protected osType: OSType; + protected vmProvider?: VMProviderType; + protected telemetry: Telemetry; + + constructor(config: BaseComputerConfig) { + this.name = config.name; + this.osType = config.osType; + this.telemetry = new Telemetry(); + this.telemetry.recordEvent("module_init", { + module: "computer", + version: process.env.npm_package_version, + node_version: process.version, + }); + + this.telemetry.recordEvent("computer_initialized", { + os: os.platform(), + os_version: os.version(), + node_version: process.version, + }); + } + + /** + * Get the name of the computer + */ + getName(): string { + return this.name; + } + + /** + * Get the OS type of the computer + */ + getOSType(): OSType { + return this.osType; + } + + /** + * Get the VM provider type + */ + getVMProviderType(): VMProviderType | undefined { + return this.vmProvider; + } + + /** + * Shared method available to all computer types + */ + async disconnect(): Promise { + logger.info(`Disconnecting from ${this.name}`); + // Implementation would go here + } + + /** + * Parse display string into Display object + * @param display Display string in format "WIDTHxHEIGHT" + * @returns Display object + */ + public static parseDisplayString(display: string): Display { + const match = display.match(/^(\d+)x(\d+)$/); + if (!match) { + throw new Error( + `Invalid display format: ${display}. Expected format: WIDTHxHEIGHT`, + ); + } + + return { + width: Number.parseInt(match[1], 10), + height: Number.parseInt(match[2], 10), + }; + } + + /** + * Parse memory string to MB integer. + * + * Examples: + * "8GB" -> 8192 + * "1024MB" -> 1024 + * "512" -> 512 + * + * @param memoryStr - Memory string to parse + * @returns Memory value in MB + */ + public static parseMemoryString(memoryStr: string): number { + if (!memoryStr) { + return 0; + } + + // Convert to uppercase for case-insensitive matching + const upperStr = memoryStr.toUpperCase().trim(); + + // Extract numeric value and unit + const match = upperStr.match(/^(\d+(?:\.\d+)?)\s*(GB|MB)?$/); + if (!match) { + throw new Error(`Invalid memory format: ${memoryStr}`); + } + + const value = Number.parseFloat(match[1]); + const unit = match[2] || "MB"; // Default to MB if no unit specified + + // Convert to MB + if (unit === "GB") { + return Math.round(value * 1024); + } + return Math.round(value); + } +} diff --git a/libs/typescript/computer/src/computer/providers/cloud.ts b/libs/typescript/computer/src/computer/providers/cloud.ts new file mode 100644 index 00000000..5614fb64 --- /dev/null +++ b/libs/typescript/computer/src/computer/providers/cloud.ts @@ -0,0 +1,94 @@ +import pino from 'pino'; +import { + type BaseComputerInterface, + InterfaceFactory, +} from '../../interface/index'; +import type { CloudComputerConfig, VMProviderType } from '../types'; +import { BaseComputer } from './base'; + +/** + * Cloud-specific computer implementation + */ +export class CloudComputer extends BaseComputer { + protected static vmProviderType: VMProviderType.CLOUD; + protected apiKey: string; + private iface?: BaseComputerInterface; + private initialized = false; + + protected logger = pino({ name: 'computer.provider_cloud' }); + + constructor(config: CloudComputerConfig) { + super(config); + this.apiKey = config.apiKey; + } + + get ip() { + return `${this.name}.containers.cloud.trycua.com`; + } + + /** + * Initialize the cloud VM and interface + */ + async run(): Promise { + if (this.initialized) { + this.logger.info('Computer already initialized, skipping initialization'); + return; + } + + try { + // For cloud provider, the VM is already running, we just need to connect + const ipAddress = this.ip; + this.logger.info(`Connecting to cloud VM at ${ipAddress}`); + + // Create the interface with API key authentication + this.iface = InterfaceFactory.createInterfaceForOS( + this.osType, + ipAddress, + this.apiKey, + this.name + ); + + // Wait for the interface to be ready + this.logger.info('Waiting for interface to be ready...'); + await this.iface.waitForReady(); + + this.initialized = true; + this.logger.info('Cloud computer ready'); + } catch (error) { + this.logger.error(`Failed to initialize cloud computer: ${error}`); + throw new Error(`Failed to initialize cloud computer: ${error}`); + } + } + + /** + * Stop the cloud computer (disconnect interface) + */ + async stop(): Promise { + this.logger.info('Disconnecting from cloud computer...'); + + if (this.iface) { + this.iface.disconnect(); + this.iface = undefined; + } + + this.initialized = false; + this.logger.info('Disconnected from cloud computer'); + } + + /** + * Get the computer interface + */ + get interface(): BaseComputerInterface { + if (!this.iface) { + throw new Error('Computer not initialized. Call run() first.'); + } + return this.iface; + } + + /** + * Disconnect from the cloud computer + */ + async disconnect(): Promise { + await this.stop(); + } +} diff --git a/libs/typescript/computer/src/computer/providers/index.ts b/libs/typescript/computer/src/computer/providers/index.ts new file mode 100644 index 00000000..27faf7d6 --- /dev/null +++ b/libs/typescript/computer/src/computer/providers/index.ts @@ -0,0 +1,2 @@ +export * from './base'; +export * from './cloud'; diff --git a/libs/typescript/computer/src/computer/types.ts b/libs/typescript/computer/src/computer/types.ts new file mode 100644 index 00000000..7bca918b --- /dev/null +++ b/libs/typescript/computer/src/computer/types.ts @@ -0,0 +1,36 @@ +import type { OSType, ScreenSize } from '../types'; + +/** + * Display configuration for the computer. + */ +export interface Display extends ScreenSize { + scale_factor?: number; +} + +/** + * Computer configuration model. + */ +export interface BaseComputerConfig { + /** + * The VM name + * @default "" + */ + name: string; + + /** + * The operating system type ('macos', 'windows', or 'linux') + * @default "macos" + */ + osType: OSType; +} + +export interface CloudComputerConfig extends BaseComputerConfig { + /** + * Optional API key for cloud providers + */ + apiKey: string; +} + +export enum VMProviderType { + CLOUD = 'cloud', +} diff --git a/libs/typescript/computer/src/index.ts b/libs/typescript/computer/src/index.ts new file mode 100644 index 00000000..44b515fe --- /dev/null +++ b/libs/typescript/computer/src/index.ts @@ -0,0 +1,6 @@ +// Export classes +export { CloudComputer as Computer } from './computer'; + +//todo: figure out what types to export and how to do that +// +export { OSType } from './types'; diff --git a/libs/typescript/computer/src/interface/base.ts b/libs/typescript/computer/src/interface/base.ts new file mode 100644 index 00000000..21b63389 --- /dev/null +++ b/libs/typescript/computer/src/interface/base.ts @@ -0,0 +1,363 @@ +/** + * Base interface for computer control. + */ + +import pino from 'pino'; +import WebSocket from 'ws'; +import type { ScreenSize } from '../types'; + +export type MouseButton = 'left' | 'middle' | 'right'; + +export interface CursorPosition { + x: number; + y: number; +} + +export interface AccessibilityNode { + role: string; + title?: string; + value?: string; + description?: string; + bounds?: { + x: number; + y: number; + width: number; + height: number; + }; + children?: AccessibilityNode[]; +} + +/** + * Base class for computer control interfaces. + */ +export abstract class BaseComputerInterface { + protected ipAddress: string; + protected username: string; + protected password: string; + protected closed = false; + protected commandLock: Promise = Promise.resolve(); + protected ws: WebSocket; + protected apiKey?: string; + protected vmName?: string; + + protected logger = pino({ name: 'computer.interface-base' }); + + constructor( + ipAddress: string, + username = 'lume', + password = 'lume', + apiKey?: string, + vmName?: string + ) { + this.ipAddress = ipAddress; + this.username = username; + this.password = password; + this.apiKey = apiKey; + this.vmName = vmName; + + // Initialize WebSocket with headers if needed + const headers: { [key: string]: string } = {}; + if (this.apiKey && this.vmName) { + headers['X-API-Key'] = this.apiKey; + headers['X-VM-Name'] = this.vmName; + } + + // Create the WebSocket instance + this.ws = new WebSocket(this.wsUri, { headers }); + } + + /** + * Get the WebSocket URI for connection. + * Subclasses can override this to customize the URI. + */ + protected get wsUri(): string { + const protocol = this.apiKey ? 'wss' : 'ws'; + + // Check if ipAddress already includes a port + if (this.ipAddress.includes(':')) { + return `${protocol}://${this.ipAddress}/ws`; + } + + // Otherwise, append the default port + const port = this.apiKey ? '8443' : '8000'; + return `${protocol}://${this.ipAddress}:${port}/ws`; + } + + /** + * Wait for interface to be ready. + * @param timeout Maximum time to wait in seconds + * @throws Error if interface is not ready within timeout + */ + async waitForReady(timeout = 60): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout * 1000) { + try { + await this.connect(); + return; + } catch (error) { + console.log(error); + // Wait a bit before retrying + this.logger.error( + `Error connecting to websocket: ${JSON.stringify(error)}` + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + throw new Error(`Interface not ready after ${timeout} seconds`); + } + + /** + * Authenticate with the WebSocket server. + * This should be called immediately after the WebSocket connection is established. + */ + private async authenticate(): Promise { + if (!this.apiKey || !this.vmName) { + // No authentication needed + return; + } + + this.logger.info('Performing authentication handshake...'); + const authMessage = { + command: 'authenticate', + params: { + api_key: this.apiKey, + container_name: this.vmName, + }, + }; + + return new Promise((resolve, reject) => { + const authHandler = (data: WebSocket.RawData) => { + try { + const authResult = JSON.parse(data.toString()); + if (!authResult.success) { + const errorMsg = authResult.error || 'Authentication failed'; + this.logger.error(`Authentication failed: ${errorMsg}`); + this.ws.close(); + reject(new Error(`Authentication failed: ${errorMsg}`)); + } else { + this.logger.info('Authentication successful'); + this.ws.off('message', authHandler); + resolve(); + } + } catch (error) { + this.ws.off('message', authHandler); + reject(error); + } + }; + + this.ws.on('message', authHandler); + this.ws.send(JSON.stringify(authMessage)); + }); + } + + /** + * Connect to the WebSocket server. + */ + public async connect(): Promise { + // If the WebSocket is already open, check if we need to authenticate + if (this.ws.readyState === WebSocket.OPEN) { + this.logger.info( + 'Websocket is open, ensuring authentication is complete.' + ); + return this.authenticate(); + } + + // If the WebSocket is closed or closing, reinitialize it + if ( + this.ws.readyState === WebSocket.CLOSED || + this.ws.readyState === WebSocket.CLOSING + ) { + this.logger.info('Websocket is closed. Reinitializing connection.'); + const headers: { [key: string]: string } = {}; + if (this.apiKey && this.vmName) { + headers['X-API-Key'] = this.apiKey; + headers['X-VM-Name'] = this.vmName; + } + this.ws = new WebSocket(this.wsUri, { headers }); + return this.authenticate(); + } + + // Connect and authenticate + return new Promise((resolve, reject) => { + const onOpen = async () => { + try { + // Always authenticate immediately after connection + await this.authenticate(); + resolve(); + } catch (error) { + reject(error); + } + }; + + // If already connecting, wait for it to complete then authenticate + if (this.ws.readyState === WebSocket.CONNECTING) { + this.ws.addEventListener('open', onOpen, { once: true }); + this.ws.addEventListener('error', (error) => reject(error), { + once: true, + }); + return; + } + + // Set up event handlers + this.ws.on('open', onOpen); + + this.ws.on('error', (error: Error) => { + reject(error); + }); + + this.ws.on('close', () => { + if (!this.closed) { + // Attempt to reconnect + setTimeout(() => this.connect(), 1000); + } + }); + }); + } + + /** + * Send a command to the WebSocket server. + */ + public async sendCommand( + command: string, + params: { [key: string]: unknown } = {} + ): Promise<{ [key: string]: unknown }> { + // Create a new promise for this specific command + const commandPromise = new Promise<{ [key: string]: unknown }>( + (resolve, reject) => { + // Chain it to the previous commands + const executeCommand = async (): Promise<{ + [key: string]: unknown; + }> => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + await this.connect(); + } + + return new Promise<{ [key: string]: unknown }>( + (innerResolve, innerReject) => { + const messageHandler = (data: WebSocket.RawData) => { + try { + const response = JSON.parse(data.toString()); + if (response.error) { + innerReject(new Error(response.error)); + } else { + innerResolve(response); + } + } catch (error) { + innerReject(error); + } + this.ws.off('message', messageHandler); + }; + + this.ws.on('message', messageHandler); + const wsCommand = { command, params }; + this.ws.send(JSON.stringify(wsCommand)); + } + ); + }; + + // Add this command to the lock chain + this.commandLock = this.commandLock.then(() => + executeCommand().then(resolve, reject) + ); + } + ); + + return commandPromise; + } + + /** + * Check if the WebSocket is connected. + */ + public isConnected(): boolean { + return this.ws && this.ws.readyState === WebSocket.OPEN; + } + + /** + * Close the interface connection. + */ + disconnect(): void { + this.closed = true; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.close(); + } else if (this.ws && this.ws.readyState === WebSocket.CONNECTING) { + // If still connecting, terminate the connection attempt + this.ws.terminate(); + } + } + + /** + * Force close the interface connection. + * By default, this just calls close(), but subclasses can override + * to provide more forceful cleanup. + */ + forceClose(): void { + this.disconnect(); + } + + // Mouse Actions + abstract mouseDown( + x?: number, + y?: number, + button?: MouseButton + ): Promise; + abstract mouseUp(x?: number, y?: number, button?: MouseButton): Promise; + abstract leftClick(x?: number, y?: number): Promise; + abstract rightClick(x?: number, y?: number): Promise; + abstract doubleClick(x?: number, y?: number): Promise; + abstract moveCursor(x: number, y: number): Promise; + abstract dragTo( + x: number, + y: number, + button?: MouseButton, + duration?: number + ): Promise; + abstract drag( + path: Array<[number, number]>, + button?: MouseButton, + duration?: number + ): Promise; + + // Keyboard Actions + abstract keyDown(key: string): Promise; + abstract keyUp(key: string): Promise; + abstract typeText(text: string): Promise; + abstract pressKey(key: string): Promise; + abstract hotkey(...keys: string[]): Promise; + + // Scrolling Actions + abstract scroll(x: number, y: number): Promise; + abstract scrollDown(clicks?: number): Promise; + abstract scrollUp(clicks?: number): Promise; + + // Screen Actions + abstract screenshot(): Promise; + abstract getScreenSize(): Promise; + abstract getCursorPosition(): Promise; + + // Clipboard Actions + abstract copyToClipboard(): Promise; + abstract setClipboard(text: string): Promise; + + // File System Actions + abstract fileExists(path: string): Promise; + abstract directoryExists(path: string): Promise; + abstract listDir(path: string): Promise; + abstract readText(path: string): Promise; + abstract writeText(path: string, content: string): Promise; + abstract readBytes(path: string): Promise; + abstract writeBytes(path: string, content: Buffer): Promise; + abstract deleteFile(path: string): Promise; + abstract createDir(path: string): Promise; + abstract deleteDir(path: string): Promise; + abstract runCommand(command: string): Promise<[string, string]>; + + // Accessibility Actions + abstract getAccessibilityTree(): Promise; + abstract toScreenCoordinates(x: number, y: number): Promise<[number, number]>; + abstract toScreenshotCoordinates( + x: number, + y: number + ): Promise<[number, number]>; +} diff --git a/libs/typescript/computer/src/interface/factory.ts b/libs/typescript/computer/src/interface/factory.ts new file mode 100644 index 00000000..60864f0e --- /dev/null +++ b/libs/typescript/computer/src/interface/factory.ts @@ -0,0 +1,57 @@ +/** + * Factory for creating computer interfaces. + */ + +import type { OSType } from '../types'; +import type { BaseComputerInterface } from './base'; +import { LinuxComputerInterface } from './linux'; +import { MacOSComputerInterface } from './macos'; +import { WindowsComputerInterface } from './windows'; + +export const InterfaceFactory = { + /** + * Create an interface for the specified OS. + * + * @param os Operating system type ('macos', 'linux', or 'windows') + * @param ipAddress IP address of the computer to control + * @param apiKey Optional API key for cloud authentication + * @param vmName Optional VM name for cloud authentication + * @returns The appropriate interface for the OS + * @throws Error if the OS type is not supported + */ + createInterfaceForOS( + os: OSType, + ipAddress: string, + apiKey?: string, + vmName?: string + ): BaseComputerInterface { + switch (os) { + case 'macos': + return new MacOSComputerInterface( + ipAddress, + 'lume', + 'lume', + apiKey, + vmName + ); + case 'linux': + return new LinuxComputerInterface( + ipAddress, + 'lume', + 'lume', + apiKey, + vmName + ); + case 'windows': + return new WindowsComputerInterface( + ipAddress, + 'lume', + 'lume', + apiKey, + vmName + ); + default: + throw new Error(`Unsupported OS type: ${os}`); + } + }, +}; diff --git a/libs/typescript/computer/src/interface/index.ts b/libs/typescript/computer/src/interface/index.ts new file mode 100644 index 00000000..285f594b --- /dev/null +++ b/libs/typescript/computer/src/interface/index.ts @@ -0,0 +1,6 @@ +export { BaseComputerInterface } from './base'; +export type { MouseButton, CursorPosition, AccessibilityNode } from './base'; +export { InterfaceFactory } from './factory'; +export { MacOSComputerInterface } from './macos'; +export { LinuxComputerInterface } from './linux'; +export { WindowsComputerInterface } from './windows'; diff --git a/libs/typescript/computer/src/interface/linux.ts b/libs/typescript/computer/src/interface/linux.ts new file mode 100644 index 00000000..5fcf27c3 --- /dev/null +++ b/libs/typescript/computer/src/interface/linux.ts @@ -0,0 +1,14 @@ +/** + * Linux computer interface implementation. + */ + +import { MacOSComputerInterface } from './macos'; + +/** + * Linux interface implementation. + * Since the cloud provider uses the same WebSocket protocol for all OS types, + * we can reuse the macOS implementation. + */ +export class LinuxComputerInterface extends MacOSComputerInterface { + // Linux uses the same WebSocket interface as macOS for cloud provider +} diff --git a/libs/typescript/computer/src/interface/macos.ts b/libs/typescript/computer/src/interface/macos.ts new file mode 100644 index 00000000..7f7383a0 --- /dev/null +++ b/libs/typescript/computer/src/interface/macos.ts @@ -0,0 +1,364 @@ +/** + * macOS computer interface implementation. + */ + +import type { ScreenSize } from '../types'; +import { BaseComputerInterface } from './base'; +import type { AccessibilityNode, CursorPosition, MouseButton } from './base'; + +export class MacOSComputerInterface extends BaseComputerInterface { + // Mouse Actions + async mouseDown( + x?: number, + y?: number, + button: MouseButton = 'left' + ): Promise { + await this.sendCommand('mouse_down', { x, y, button }); + } + + async mouseUp( + x?: number, + y?: number, + button: MouseButton = 'left' + ): Promise { + await this.sendCommand('mouse_up', { x, y, button }); + } + + async leftClick(x?: number, y?: number): Promise { + await this.sendCommand('left_click', { x, y }); + } + + async rightClick(x?: number, y?: number): Promise { + await this.sendCommand('right_click', { x, y }); + } + + async doubleClick(x?: number, y?: number): Promise { + await this.sendCommand('double_click', { x, y }); + } + + async moveCursor(x: number, y: number): Promise { + await this.sendCommand('move_cursor', { x, y }); + } + + async dragTo( + x: number, + y: number, + button: MouseButton = 'left', + duration = 0.5 + ): Promise { + await this.sendCommand('drag_to', { x, y, button, duration }); + } + + async drag( + path: Array<[number, number]>, + button: MouseButton = 'left', + duration = 0.5 + ): Promise { + await this.sendCommand('drag', { path, button, duration }); + } + + // Keyboard Actions + async keyDown(key: string): Promise { + await this.sendCommand('key_down', { key }); + } + + async keyUp(key: string): Promise { + await this.sendCommand('key_up', { key }); + } + + async typeText(text: string): Promise { + await this.sendCommand('type_text', { text }); + } + + async pressKey(key: string): Promise { + await this.sendCommand('press_key', { key }); + } + + async hotkey(...keys: string[]): Promise { + await this.sendCommand('hotkey', { keys }); + } + + // Scrolling Actions + async scroll(x: number, y: number): Promise { + await this.sendCommand('scroll', { x, y }); + } + + async scrollDown(clicks = 1): Promise { + await this.sendCommand('scroll_down', { clicks }); + } + + async scrollUp(clicks = 1): Promise { + await this.sendCommand('scroll_up', { clicks }); + } + + // Screen Actions + async screenshot(): Promise { + const response = await this.sendCommand('screenshot'); + if (!response.image_data) { + throw new Error('Failed to take screenshot'); + } + return Buffer.from(response.image_data as string, 'base64'); + } + + async getScreenSize(): Promise { + const response = await this.sendCommand('get_screen_size'); + if (!response.success || !response.size) { + throw new Error('Failed to get screen size'); + } + return response.size as ScreenSize; + } + + async getCursorPosition(): Promise { + const response = await this.sendCommand('get_cursor_position'); + if (!response.success || !response.position) { + throw new Error('Failed to get cursor position'); + } + return response.position as CursorPosition; + } + + // Clipboard Actions + async copyToClipboard(): Promise { + const response = await this.sendCommand('copy_to_clipboard'); + if (!response.success || !response.content) { + throw new Error('Failed to get clipboard content'); + } + return response.content as string; + } + + async setClipboard(text: string): Promise { + await this.sendCommand('set_clipboard', { text }); + } + + // File System Actions + async fileExists(path: string): Promise { + const response = await this.sendCommand('file_exists', { path }); + return (response.exists as boolean) || false; + } + + async directoryExists(path: string): Promise { + const response = await this.sendCommand('directory_exists', { path }); + return (response.exists as boolean) || false; + } + + async listDir(path: string): Promise { + const response = await this.sendCommand('list_dir', { path }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to list directory'); + } + return (response.files as string[]) || []; + } + + async getFileSize(path: string): Promise { + const response = await this.sendCommand('get_file_size', { path }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to get file size'); + } + return (response.size as number) || 0; + } + + private async readBytesChunked( + path: string, + offset: number, + totalLength: number, + chunkSize: number = 1024 * 1024 + ): Promise { + const chunks: Buffer[] = []; + let currentOffset = offset; + let remaining = totalLength; + + while (remaining > 0) { + const readSize = Math.min(chunkSize, remaining); + const response = await this.sendCommand('read_bytes', { + path, + offset: currentOffset, + length: readSize, + }); + + if (!response.success) { + throw new Error( + (response.error as string) || 'Failed to read file chunk' + ); + } + + const chunkData = Buffer.from(response.content_b64 as string, 'base64'); + chunks.push(chunkData); + + currentOffset += readSize; + remaining -= readSize; + } + + return Buffer.concat(chunks); + } + + private async writeBytesChunked( + path: string, + content: Buffer, + append: boolean = false, + chunkSize: number = 1024 * 1024 + ): Promise { + const totalSize = content.length; + let currentOffset = 0; + + while (currentOffset < totalSize) { + const chunkEnd = Math.min(currentOffset + chunkSize, totalSize); + const chunkData = content.subarray(currentOffset, chunkEnd); + + // First chunk uses the original append flag, subsequent chunks always append + const chunkAppend = currentOffset === 0 ? append : true; + + const response = await this.sendCommand('write_bytes', { + path, + content_b64: chunkData.toString('base64'), + append: chunkAppend, + }); + + if (!response.success) { + throw new Error( + (response.error as string) || 'Failed to write file chunk' + ); + } + + currentOffset = chunkEnd; + } + } + + async readText(path: string, encoding: BufferEncoding = 'utf8'): Promise { + /** + * Read text from a file with specified encoding. + * + * @param path - Path to the file to read + * @param encoding - Text encoding to use (default: 'utf8') + * @returns The decoded text content of the file + */ + const contentBytes = await this.readBytes(path); + return contentBytes.toString(encoding); + } + + async writeText( + path: string, + content: string, + encoding: BufferEncoding = 'utf8', + append: boolean = false + ): Promise { + /** + * Write text to a file with specified encoding. + * + * @param path - Path to the file to write + * @param content - Text content to write + * @param encoding - Text encoding to use (default: 'utf8') + * @param append - Whether to append to the file instead of overwriting + */ + const contentBytes = Buffer.from(content, encoding); + await this.writeBytes(path, contentBytes, append); + } + + async readBytes(path: string, offset: number = 0, length?: number): Promise { + // For large files, use chunked reading + if (length === undefined) { + // Get file size first to determine if we need chunking + const fileSize = await this.getFileSize(path); + // If file is larger than 5MB, read in chunks + if (fileSize > 5 * 1024 * 1024) { + const readLength = offset > 0 ? fileSize - offset : fileSize; + return await this.readBytesChunked(path, offset, readLength); + } + } + + const response = await this.sendCommand('read_bytes', { + path, + offset, + length, + }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to read file'); + } + return Buffer.from(response.content_b64 as string, 'base64'); + } + + async writeBytes(path: string, content: Buffer, append: boolean = false): Promise { + // For large files, use chunked writing + if (content.length > 5 * 1024 * 1024) { + // 5MB threshold + await this.writeBytesChunked(path, content, append); + return; + } + + const response = await this.sendCommand('write_bytes', { + path, + content_b64: content.toString('base64'), + append, + }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to write file'); + } + } + + async deleteFile(path: string): Promise { + const response = await this.sendCommand('delete_file', { path }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to delete file'); + } + } + + async createDir(path: string): Promise { + const response = await this.sendCommand('create_dir', { path }); + if (!response.success) { + throw new Error( + (response.error as string) || 'Failed to create directory' + ); + } + } + + async deleteDir(path: string): Promise { + const response = await this.sendCommand('delete_dir', { path }); + if (!response.success) { + throw new Error( + (response.error as string) || 'Failed to delete directory' + ); + } + } + + async runCommand(command: string): Promise<[string, string]> { + const response = await this.sendCommand('run_command', { command }); + if (!response.success) { + throw new Error((response.error as string) || 'Failed to run command'); + } + return [ + (response.stdout as string) || '', + (response.stderr as string) || '', + ]; + } + + // Accessibility Actions + async getAccessibilityTree(): Promise { + const response = await this.sendCommand('get_accessibility_tree'); + if (!response.success) { + throw new Error( + (response.error as string) || 'Failed to get accessibility tree' + ); + } + return response as unknown as AccessibilityNode; + } + + async toScreenCoordinates(x: number, y: number): Promise<[number, number]> { + const response = await this.sendCommand('to_screen_coordinates', { x, y }); + if (!response.success || !response.coordinates) { + throw new Error('Failed to convert to screen coordinates'); + } + return response.coordinates as [number, number]; + } + + async toScreenshotCoordinates( + x: number, + y: number + ): Promise<[number, number]> { + const response = await this.sendCommand('to_screenshot_coordinates', { + x, + y, + }); + if (!response.success || !response.coordinates) { + throw new Error('Failed to convert to screenshot coordinates'); + } + return response.coordinates as [number, number]; + } +} diff --git a/libs/typescript/computer/src/interface/windows.ts b/libs/typescript/computer/src/interface/windows.ts new file mode 100644 index 00000000..c9f138d1 --- /dev/null +++ b/libs/typescript/computer/src/interface/windows.ts @@ -0,0 +1,14 @@ +/** + * Windows computer interface implementation. + */ + +import { MacOSComputerInterface } from './macos'; + +/** + * Windows interface implementation. + * Since the cloud provider uses the same WebSocket protocol for all OS types, + * we can reuse the macOS implementation. + */ +export class WindowsComputerInterface extends MacOSComputerInterface { + // Windows uses the same WebSocket interface as macOS for cloud provider +} diff --git a/libs/typescript/computer/src/types.ts b/libs/typescript/computer/src/types.ts new file mode 100644 index 00000000..f8a175fe --- /dev/null +++ b/libs/typescript/computer/src/types.ts @@ -0,0 +1,10 @@ +export enum OSType { + MACOS = 'macos', + WINDOWS = 'windows', + LINUX = 'linux', +} + +export interface ScreenSize { + width: number; + height: number; +} diff --git a/libs/typescript/computer/tests/computer/cloud.test.ts b/libs/typescript/computer/tests/computer/cloud.test.ts new file mode 100644 index 00000000..9b927d57 --- /dev/null +++ b/libs/typescript/computer/tests/computer/cloud.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { Computer } from '../../src'; +import { OSType } from '../../src/types'; + +describe('Computer Cloud', () => { + it('Should create computer instance', () => { + const cloud = new Computer({ + apiKey: 'asdf', + name: 's-linux-1234', + osType: OSType.LINUX, + }); + expect(cloud).toBeInstanceOf(Computer); + }); +}); diff --git a/libs/typescript/computer/tests/interface/factory.test.ts b/libs/typescript/computer/tests/interface/factory.test.ts new file mode 100644 index 00000000..e5f3296a --- /dev/null +++ b/libs/typescript/computer/tests/interface/factory.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { InterfaceFactory } from '../../src/interface/factory.ts'; +import { LinuxComputerInterface } from '../../src/interface/linux.ts'; +import { MacOSComputerInterface } from '../../src/interface/macos.ts'; +import { WindowsComputerInterface } from '../../src/interface/windows.ts'; +import { OSType } from '../../src/types.ts'; + +describe('InterfaceFactory', () => { + const testParams = { + ipAddress: '192.168.1.100', + username: 'testuser', + password: 'testpass', + apiKey: 'test-api-key', + vmName: 'test-vm', + }; + + describe('createInterfaceForOS', () => { + it('should create MacOSComputerInterface for macOS', () => { + const interface_ = InterfaceFactory.createInterfaceForOS( + OSType.MACOS, + testParams.ipAddress, + testParams.apiKey, + testParams.vmName + ); + + expect(interface_).toBeInstanceOf(MacOSComputerInterface); + }); + + it('should create LinuxComputerInterface for Linux', () => { + const interface_ = InterfaceFactory.createInterfaceForOS( + OSType.LINUX, + testParams.ipAddress, + testParams.apiKey, + testParams.vmName + ); + + expect(interface_).toBeInstanceOf(LinuxComputerInterface); + }); + + it('should create WindowsComputerInterface for Windows', () => { + const interface_ = InterfaceFactory.createInterfaceForOS( + OSType.WINDOWS, + testParams.ipAddress, + testParams.apiKey, + testParams.vmName + ); + + expect(interface_).toBeInstanceOf(WindowsComputerInterface); + }); + + it('should throw error for unsupported OS type', () => { + expect(() => { + InterfaceFactory.createInterfaceForOS( + 'unsupported' as OSType, + testParams.ipAddress, + testParams.apiKey, + testParams.vmName + ); + }).toThrow('Unsupported OS type: unsupported'); + }); + + it('should create interface without API key and VM name', () => { + const interface_ = InterfaceFactory.createInterfaceForOS( + OSType.MACOS, + testParams.ipAddress + ); + + expect(interface_).toBeInstanceOf(MacOSComputerInterface); + }); + }); +}); diff --git a/libs/typescript/computer/tests/interface/index.test.ts b/libs/typescript/computer/tests/interface/index.test.ts new file mode 100644 index 00000000..1a0cb8ef --- /dev/null +++ b/libs/typescript/computer/tests/interface/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import * as InterfaceExports from '../../src/interface/index.ts'; + +describe('Interface Module Exports', () => { + it('should export InterfaceFactory', () => { + expect(InterfaceExports.InterfaceFactory).toBeDefined(); + expect( + InterfaceExports.InterfaceFactory.createInterfaceForOS + ).toBeDefined(); + }); + + it('should export BaseComputerInterface', () => { + expect(InterfaceExports.BaseComputerInterface).toBeDefined(); + }); + + it('should export MacOSComputerInterface', () => { + expect(InterfaceExports.MacOSComputerInterface).toBeDefined(); + }); + + it('should export LinuxComputerInterface', () => { + expect(InterfaceExports.LinuxComputerInterface).toBeDefined(); + }); + + it('should export WindowsComputerInterface', () => { + expect(InterfaceExports.WindowsComputerInterface).toBeDefined(); + }); + + it('should export all expected interfaces', () => { + const expectedExports = [ + 'InterfaceFactory', + 'BaseComputerInterface', + 'MacOSComputerInterface', + 'LinuxComputerInterface', + 'WindowsComputerInterface', + ]; + + const actualExports = Object.keys(InterfaceExports); + for (const exportName of expectedExports) { + expect(actualExports).toContain(exportName); + } + }); +}); diff --git a/libs/typescript/computer/tests/interface/linux.test.ts b/libs/typescript/computer/tests/interface/linux.test.ts new file mode 100644 index 00000000..ae086fcf --- /dev/null +++ b/libs/typescript/computer/tests/interface/linux.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { LinuxComputerInterface } from '../../src/interface/linux.ts'; +import { MacOSComputerInterface } from '../../src/interface/macos.ts'; + +describe('LinuxComputerInterface', () => { + const testParams = { + ipAddress: 'test.cua.com', // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable + username: 'testuser', + password: 'testpass', + apiKey: 'test-api-key', + vmName: 'test-vm', + }; + + describe('Inheritance', () => { + it('should extend MacOSComputerInterface', () => { + const linuxInterface = new LinuxComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + testParams.apiKey, + testParams.vmName + ); + + expect(linuxInterface).toBeInstanceOf(MacOSComputerInterface); + expect(linuxInterface).toBeInstanceOf(LinuxComputerInterface); + }); + }); +}); diff --git a/libs/typescript/computer/tests/interface/macos.test.ts b/libs/typescript/computer/tests/interface/macos.test.ts new file mode 100644 index 00000000..e655254f --- /dev/null +++ b/libs/typescript/computer/tests/interface/macos.test.ts @@ -0,0 +1,938 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket, WebSocketServer } from 'ws'; +import { MacOSComputerInterface } from '../../src/interface/macos.ts'; + +describe('MacOSComputerInterface', () => { + // Define test parameters + const testParams = { + ipAddress: 'localhost', + username: 'testuser', + password: 'testpass', + // apiKey: "test-api-key", No API Key for local testing + vmName: 'test-vm', + }; + + // WebSocket server mock + let wss: WebSocketServer; + let serverPort: number; + let connectedClients: WebSocket[] = []; + + // Track received messages for verification + interface ReceivedMessage { + action: string; + [key: string]: unknown; + } + let receivedMessages: ReceivedMessage[] = []; + + // Set up WebSocket server before all tests + beforeEach(async () => { + receivedMessages = []; + connectedClients = []; + + // Create WebSocket server on a random available port + wss = new WebSocketServer({ port: 0 }); + serverPort = (wss.address() as { port: number }).port; + + // Update test params with the actual server address + testParams.ipAddress = `localhost:${serverPort}`; + + // Handle WebSocket connections + wss.on('connection', (ws) => { + connectedClients.push(ws); + + // Handle incoming messages + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + receivedMessages.push(message); + + // Send appropriate responses based on action + switch (message.command) { + case 'screenshot': + ws.send( + JSON.stringify({ + image_data: Buffer.from('fake-screenshot-data').toString( + 'base64' + ), + success: true, + }) + ); + break; + case 'get_screen_size': + ws.send( + JSON.stringify({ + size: { width: 1920, height: 1080 }, + success: true, + }) + ); + break; + case 'get_cursor_position': + ws.send( + JSON.stringify({ + position: { x: 100, y: 200 }, + success: true, + }) + ); + break; + case 'copy_to_clipboard': + ws.send( + JSON.stringify({ + content: 'clipboard content', + success: true, + }) + ); + break; + case 'file_exists': + ws.send( + JSON.stringify({ + exists: true, + success: true, + }) + ); + break; + case 'directory_exists': + ws.send( + JSON.stringify({ + exists: true, + success: true, + }) + ); + break; + case 'list_dir': + ws.send( + JSON.stringify({ + files: ['file1.txt', 'file2.txt'], + success: true, + }) + ); + break; + case 'read_text': + ws.send( + JSON.stringify({ + content: 'file content', + success: true, + }) + ); + break; + case 'read_bytes': + ws.send( + JSON.stringify({ + content_b64: Buffer.from('binary content').toString('base64'), + success: true, + }) + ); + break; + case 'run_command': + ws.send( + JSON.stringify({ + stdout: 'command output', + stderr: '', + success: true, + }) + ); + break; + case 'get_accessibility_tree': + ws.send( + JSON.stringify({ + role: 'window', + title: 'Test Window', + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + children: [], + success: true, + }) + ); + break; + case 'to_screen_coordinates': + case 'to_screenshot_coordinates': + ws.send( + JSON.stringify({ + coordinates: [message.params?.x || 0, message.params?.y || 0], + success: true, + }) + ); + break; + default: + // For all other actions, just send success + ws.send(JSON.stringify({ success: true })); + break; + } + } catch (error) { + ws.send(JSON.stringify({ error: (error as Error).message })); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + }); + }); + }); + + // Clean up WebSocket server after each test + afterEach(async () => { + // Close all connected clients + for (const client of connectedClients) { + if (client.readyState === WebSocket.OPEN) { + client.close(); + } + } + + // Close the server + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + }); + + describe('Connection Management', () => { + it('should connect with proper authentication headers', async () => { + const macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + + await macosInterface.connect(); + + // Verify the interface is connected + expect(macosInterface.isConnected()).toBe(true); + expect(connectedClients.length).toBe(1); + + await macosInterface.disconnect(); + }); + + it('should handle connection without API key', async () => { + // Create a separate server that doesn't check auth + const noAuthWss = new WebSocketServer({ port: 0 }); + const noAuthPort = (noAuthWss.address() as { port: number }).port; + + noAuthWss.on('connection', (ws) => { + ws.on('message', () => { + ws.send(JSON.stringify({ success: true })); + }); + }); + + const macosInterface = new MacOSComputerInterface( + `localhost:${noAuthPort}`, + testParams.username, + testParams.password, + undefined, + undefined + ); + + await macosInterface.connect(); + expect(macosInterface.isConnected()).toBe(true); + + await macosInterface.disconnect(); + await new Promise((resolve) => { + noAuthWss.close(() => resolve()); + }); + }); + }); + + describe('Mouse Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should send mouse_down command', async () => { + await macosInterface.mouseDown(100, 200, 'left'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'mouse_down', + params: { + x: 100, + y: 200, + button: 'left', + }, + }); + }); + + it('should send mouse_up command', async () => { + await macosInterface.mouseUp(100, 200, 'right'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'mouse_up', + params: { + x: 100, + y: 200, + button: 'right', + }, + }); + }); + + it('should send left_click command', async () => { + await macosInterface.leftClick(150, 250); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'left_click', + params: { + x: 150, + y: 250, + }, + }); + }); + + it('should send right_click command', async () => { + await macosInterface.rightClick(200, 300); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'right_click', + params: { + x: 200, + y: 300, + }, + }); + }); + + it('should send double_click command', async () => { + await macosInterface.doubleClick(250, 350); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'double_click', + params: { + x: 250, + y: 350, + }, + }); + }); + + it('should send move_cursor command', async () => { + await macosInterface.moveCursor(300, 400); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'move_cursor', + params: { + x: 300, + y: 400, + }, + }); + }); + + it('should send drag_to command', async () => { + await macosInterface.dragTo(400, 500, 'left', 1.5); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'drag_to', + params: { + x: 400, + y: 500, + button: 'left', + duration: 1.5, + }, + }); + }); + + it('should send drag command with path', async () => { + const path: Array<[number, number]> = [ + [100, 100], + [200, 200], + [300, 300], + ]; + await macosInterface.drag(path, 'middle', 2.0); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'drag', + params: { + path: path, + button: 'middle', + duration: 2.0, + }, + }); + }); + }); + + describe('Keyboard Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should send key_down command', async () => { + await macosInterface.keyDown('a'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'key_down', + params: { + key: 'a', + }, + }); + }); + + it('should send key_up command', async () => { + await macosInterface.keyUp('b'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'key_up', + params: { + key: 'b', + }, + }); + }); + + it('should send type_text command', async () => { + await macosInterface.typeText('Hello, World!'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'type_text', + params: { + text: 'Hello, World!', + }, + }); + }); + + it('should send press_key command', async () => { + await macosInterface.pressKey('enter'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'press_key', + params: { + key: 'enter', + }, + }); + }); + + it('should send hotkey command', async () => { + await macosInterface.hotkey('cmd', 'c'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'hotkey', + params: { + keys: ['cmd', 'c'], + }, + }); + }); + }); + + describe('Scrolling Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should send scroll command', async () => { + await macosInterface.scroll(10, -5); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'scroll', + params: { + x: 10, + y: -5, + }, + }); + }); + + it('should send scroll_down command', async () => { + await macosInterface.scrollDown(3); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'scroll_down', + params: { + clicks: 3, + }, + }); + }); + + it('should send scroll_up command', async () => { + await macosInterface.scrollUp(2); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'scroll_up', + params: { + clicks: 2, + }, + }); + }); + }); + + describe('Screen Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should get screenshot', async () => { + const screenshot = await macosInterface.screenshot(); + + expect(screenshot).toBeInstanceOf(Buffer); + expect(screenshot.toString()).toBe('fake-screenshot-data'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'screenshot', + params: {}, + }); + }); + + it('should get screen size', async () => { + const size = await macosInterface.getScreenSize(); + + expect(size).toEqual({ width: 1920, height: 1080 }); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'get_screen_size', + params: {}, + }); + }); + + it('should get cursor position', async () => { + const position = await macosInterface.getCursorPosition(); + + expect(position).toEqual({ x: 100, y: 200 }); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'get_cursor_position', + params: {}, + }); + }); + }); + + describe('Clipboard Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should copy to clipboard', async () => { + const text = await macosInterface.copyToClipboard(); + + expect(text).toBe('clipboard content'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'copy_to_clipboard', + params: {}, + }); + }); + + it('should set clipboard', async () => { + await macosInterface.setClipboard('new clipboard text'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'set_clipboard', + params: { + text: 'new clipboard text', + }, + }); + }); + }); + + describe('File System Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should check file exists', async () => { + const exists = await macosInterface.fileExists('/path/to/file'); + + expect(exists).toBe(true); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'file_exists', + params: { + path: '/path/to/file', + }, + }); + }); + + it('should check directory exists', async () => { + const exists = await macosInterface.directoryExists('/path/to/dir'); + + expect(exists).toBe(true); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'directory_exists', + params: { + path: '/path/to/dir', + }, + }); + }); + + it('should list directory', async () => { + const files = await macosInterface.listDir('/path/to/dir'); + + expect(files).toEqual(['file1.txt', 'file2.txt']); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'list_dir', + params: { + path: '/path/to/dir', + }, + }); + }); + + it('should read text file', async () => { + const content = await macosInterface.readText('/path/to/file.txt'); + + expect(content).toBe('file content'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'read_text', + params: { + path: '/path/to/file.txt', + }, + }); + }); + + it('should write text file', async () => { + await macosInterface.writeText('/path/to/file.txt', 'new content'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'write_text', + params: { + path: '/path/to/file.txt', + content: 'new content', + }, + }); + }); + + it('should read binary file', async () => { + const content = await macosInterface.readBytes('/path/to/file.bin'); + + expect(content).toBeInstanceOf(Buffer); + expect(content.toString()).toBe('binary content'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'read_bytes', + params: { + path: '/path/to/file.bin', + }, + }); + }); + + it('should write binary file', async () => { + const buffer = Buffer.from('binary data'); + await macosInterface.writeBytes('/path/to/file.bin', buffer); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'write_bytes', + params: { + path: '/path/to/file.bin', + content_b64: buffer.toString('base64'), + }, + }); + }); + + it('should delete file', async () => { + await macosInterface.deleteFile('/path/to/file'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'delete_file', + params: { + path: '/path/to/file', + }, + }); + }); + + it('should create directory', async () => { + await macosInterface.createDir('/path/to/new/dir'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'create_dir', + params: { + path: '/path/to/new/dir', + }, + }); + }); + + it('should delete directory', async () => { + await macosInterface.deleteDir('/path/to/dir'); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'delete_dir', + params: { + path: '/path/to/dir', + }, + }); + }); + + it('should run command', async () => { + const [stdout, stderr] = await macosInterface.runCommand('ls -la'); + + expect(stdout).toBe('command output'); + expect(stderr).toBe(''); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'run_command', + params: { + command: 'ls -la', + }, + }); + }); + }); + + describe('Accessibility Actions', () => { + let macosInterface: MacOSComputerInterface; + + beforeEach(async () => { + macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + await macosInterface.connect(); + }); + + afterEach(async () => { + if (macosInterface) { + await macosInterface.disconnect(); + } + }); + + it('should get accessibility tree', async () => { + const tree = await macosInterface.getAccessibilityTree(); + + expect(tree).toEqual({ + role: 'window', + title: 'Test Window', + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + children: [], + success: true, + }); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'get_accessibility_tree', + params: {}, + }); + }); + + it('should convert to screen coordinates', async () => { + const [x, y] = await macosInterface.toScreenCoordinates(100, 200); + + expect(x).toBe(100); + expect(y).toBe(200); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'to_screen_coordinates', + params: { + x: 100, + y: 200, + }, + }); + }); + + it('should convert to screenshot coordinates', async () => { + const [x, y] = await macosInterface.toScreenshotCoordinates(300, 400); + + expect(x).toBe(300); + expect(y).toBe(400); + + const lastMessage = receivedMessages[receivedMessages.length - 1]; + expect(lastMessage).toEqual({ + command: 'to_screenshot_coordinates', + params: { + x: 300, + y: 400, + }, + }); + }); + }); + + describe('Error Handling', () => { + it('should handle WebSocket connection errors', async () => { + // Use a valid but unreachable IP to avoid DNS errors + const macosInterface = new MacOSComputerInterface( + 'localhost:9999', + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + + // Connection should fail + await expect(macosInterface.connect()).rejects.toThrow(); + }); + + it('should handle command errors', async () => { + // Create a server that returns errors + const errorWss = new WebSocketServer({ port: 0 }); + const errorPort = (errorWss.address() as { port: number }).port; + + errorWss.on('connection', (ws) => { + ws.on('message', () => { + ws.send(JSON.stringify({ error: 'Command failed', success: false })); + }); + }); + + const macosInterface = new MacOSComputerInterface( + `localhost:${errorPort}`, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + + await macosInterface.connect(); + + // Command should throw error + await expect(macosInterface.leftClick(100, 100)).rejects.toThrow( + 'Command failed' + ); + + await macosInterface.disconnect(); + await new Promise((resolve) => { + errorWss.close(() => resolve()); + }); + }); + + it('should handle disconnection gracefully', async () => { + const macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + + await macosInterface.connect(); + expect(macosInterface.isConnected()).toBe(true); + + // Disconnect + macosInterface.disconnect(); + expect(macosInterface.isConnected()).toBe(false); + + // Should reconnect automatically on next command + await macosInterface.leftClick(100, 100); + expect(macosInterface.isConnected()).toBe(true); + + await macosInterface.disconnect(); + }); + + it('should handle force close', async () => { + const macosInterface = new MacOSComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + undefined, + testParams.vmName + ); + + await macosInterface.connect(); + expect(macosInterface.isConnected()).toBe(true); + + // Force close + macosInterface.forceClose(); + expect(macosInterface.isConnected()).toBe(false); + }); + }); +}); diff --git a/libs/typescript/computer/tests/interface/windows.test.ts b/libs/typescript/computer/tests/interface/windows.test.ts new file mode 100644 index 00000000..68a7490d --- /dev/null +++ b/libs/typescript/computer/tests/interface/windows.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { MacOSComputerInterface } from '../../src/interface/macos.ts'; +import { WindowsComputerInterface } from '../../src/interface/windows.ts'; + +describe('WindowsComputerInterface', () => { + const testParams = { + ipAddress: '192.0.2.1', // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable + username: 'testuser', + password: 'testpass', + apiKey: 'test-api-key', + vmName: 'test-vm', + }; + + describe('Inheritance', () => { + it('should extend MacOSComputerInterface', () => { + const windowsInterface = new WindowsComputerInterface( + testParams.ipAddress, + testParams.username, + testParams.password, + testParams.apiKey, + testParams.vmName + ); + + expect(windowsInterface).toBeInstanceOf(MacOSComputerInterface); + expect(windowsInterface).toBeInstanceOf(WindowsComputerInterface); + }); + }); +}); diff --git a/libs/typescript/computer/tests/setup.ts b/libs/typescript/computer/tests/setup.ts new file mode 100644 index 00000000..9425b20f --- /dev/null +++ b/libs/typescript/computer/tests/setup.ts @@ -0,0 +1,7 @@ +import { afterAll, afterEach, beforeAll } from 'vitest'; + +beforeAll(() => {}); + +afterAll(() => {}); + +afterEach(() => {}); diff --git a/libs/typescript/computer/tsconfig.json b/libs/typescript/computer/tsconfig.json new file mode 100644 index 00000000..cdcd74de --- /dev/null +++ b/libs/typescript/computer/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "types": ["node"], + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/libs/typescript/computer/tsdown.config.ts b/libs/typescript/computer/tsdown.config.ts new file mode 100644 index 00000000..b3c70ea9 --- /dev/null +++ b/libs/typescript/computer/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig([ + { + entry: ['./src/index.ts'], + platform: 'node', + dts: true, + external: ['child_process', 'util'], + }, +]); diff --git a/libs/typescript/computer/vitest.config.ts b/libs/typescript/computer/vitest.config.ts new file mode 100644 index 00000000..53ab43d4 --- /dev/null +++ b/libs/typescript/computer/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: ['./tests/setup.ts'], + environment: 'node', + globals: true, + }, +}); diff --git a/libs/typescript/core/.editorconfig b/libs/typescript/core/.editorconfig new file mode 100644 index 00000000..7095e7fb --- /dev/null +++ b/libs/typescript/core/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +indent_size = 2 +end_of_line = lf +insert_final_newline = true diff --git a/libs/typescript/core/.gitattributes b/libs/typescript/core/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/libs/typescript/core/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/libs/typescript/core/.gitignore b/libs/typescript/core/.gitignore new file mode 100644 index 00000000..e79f2036 --- /dev/null +++ b/libs/typescript/core/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist + +*.log +.DS_Store +.eslintcache diff --git a/libs/typescript/core/LICENSE b/libs/typescript/core/LICENSE new file mode 100644 index 00000000..74987166 --- /dev/null +++ b/libs/typescript/core/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2025 C/ua + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/typescript/core/README.md b/libs/typescript/core/README.md new file mode 100644 index 00000000..20a77b26 --- /dev/null +++ b/libs/typescript/core/README.md @@ -0,0 +1,27 @@ +# C/ua Core TypeScript Library + +The core c/ua library with support for telemetry and other utilities. + +## Development + +- Install dependencies: + +```bash +pnpm install +``` + +- Run the unit tests: + +```bash +pnpm test +``` + +- Build the library: + +```bash +pnpm build +``` + +## License + +[MIT](./LICENSE) License 2025 [C/UA](https://github.com/trycua) diff --git a/libs/typescript/core/package.json b/libs/typescript/core/package.json new file mode 100644 index 00000000..d15d4138 --- /dev/null +++ b/libs/typescript/core/package.json @@ -0,0 +1,57 @@ +{ + "name": "@trycua/core", + "version": "0.1.3", + "packageManager": "pnpm@10.11.0", + "description": "Typescript SDK for c/ua core.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/trycua/cua/tree/feature/computer/typescript/libs/typescript/computer", + "bugs": { + "url": "https://github.com/trycua/cua/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/trycua/cua.git" + }, + "author": "c/ua", + "files": [ + "dist" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "biome lint .", + "lint:fix": "biome lint --fix .", + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest", + "typecheck": "tsc --noEmit", + "release": "bumpp && pnpm publish", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@types/uuid": "^10.0.0", + "pino": "^9.7.0", + "posthog-node": "^5.1.1", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.15.17", + "@types/ws": "^8.18.1", + "bumpp": "^10.1.0", + "happy-dom": "^17.4.7", + "tsdown": "^0.11.9", + "tsx": "^4.19.4", + "typescript": "^5.8.3", + "vitest": "^3.1.3" + } +} \ No newline at end of file diff --git a/libs/typescript/core/src/index.ts b/libs/typescript/core/src/index.ts new file mode 100644 index 00000000..d986c9d6 --- /dev/null +++ b/libs/typescript/core/src/index.ts @@ -0,0 +1,7 @@ +/** + * This module provides the core telemetry functionality for CUA libraries. + * + * It provides a low-overhead way to collect anonymous usage data. + */ + +export * from './telemetry'; diff --git a/libs/typescript/core/src/telemetry/clients/index.ts b/libs/typescript/core/src/telemetry/clients/index.ts new file mode 100644 index 00000000..450fcfbb --- /dev/null +++ b/libs/typescript/core/src/telemetry/clients/index.ts @@ -0,0 +1 @@ +export * from './posthog'; diff --git a/libs/typescript/core/src/telemetry/clients/posthog.ts b/libs/typescript/core/src/telemetry/clients/posthog.ts new file mode 100644 index 00000000..e42bc449 --- /dev/null +++ b/libs/typescript/core/src/telemetry/clients/posthog.ts @@ -0,0 +1,312 @@ +/** + * Telemetry client using PostHog for collecting anonymous usage data. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pino } from 'pino'; +import { PostHog } from 'posthog-node'; +import { v4 as uuidv4 } from 'uuid'; + +// Controls how frequently telemetry will be sent (percentage) +export const TELEMETRY_SAMPLE_RATE = 100; // 100% sampling rate + +// Public PostHog config for anonymous telemetry +// These values are intentionally public and meant for anonymous telemetry only +// https://posthog.com/docs/product-analytics/troubleshooting#is-it-ok-for-my-api-key-to-be-exposed-and-public +export const PUBLIC_POSTHOG_API_KEY = + 'phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14'; +export const PUBLIC_POSTHOG_HOST = 'https://eu.i.posthog.com'; + +export class PostHogTelemetryClient { + private config: { + enabled: boolean; + sampleRate: number; + posthog: { apiKey: string; host: string }; + }; + private installationId: string; + private initialized = false; + private queuedEvents: { + name: string; + properties: Record; + timestamp: number; + }[] = []; + private startTime: number; // seconds + private posthogClient?: PostHog; + private counters: Record = {}; + + private logger = pino({ name: 'core.telemetry' }); + + constructor() { + // set up config + this.config = { + enabled: true, + sampleRate: TELEMETRY_SAMPLE_RATE, + posthog: { apiKey: PUBLIC_POSTHOG_API_KEY, host: PUBLIC_POSTHOG_HOST }, + }; + // Check for multiple environment variables that can disable telemetry: + // CUA_TELEMETRY=off to disable telemetry (legacy way) + // CUA_TELEMETRY_DISABLED=1 to disable telemetry (new, more explicit way) + const telemetryDisabled = + process.env.CUA_TELEMETRY?.toLowerCase() === 'off' || + ['1', 'true', 'yes', 'on'].includes( + process.env.CUA_TELEMETRY_DISABLED?.toLowerCase() || '' + ); + + this.config.enabled = !telemetryDisabled; + this.config.sampleRate = Number.parseFloat( + process.env.CUA_TELEMETRY_SAMPLE_RATE || String(TELEMETRY_SAMPLE_RATE) + ); + // init client + this.installationId = this._getOrCreateInstallationId(); + this.startTime = Date.now() / 1000; // Convert to seconds + + // Log telemetry status on startup + if (this.config.enabled) { + this.logger.info( + `Telemetry enabled (sampling at ${this.config.sampleRate}%)` + ); + // Initialize PostHog client if config is available + this._initializePosthog(); + } else { + this.logger.info('Telemetry disabled'); + } + } + + /** + * Get or create a random installation ID. + * This ID is not tied to any personal information. + */ + private _getOrCreateInstallationId(): string { + const homeDir = os.homedir(); + const idFile = path.join(homeDir, '.cua', 'installation_id'); + + try { + if (fs.existsSync(idFile)) { + return fs.readFileSync(idFile, 'utf-8').trim(); + } + } catch (error) { + this.logger.debug(`Failed to read installation ID: ${error}`); + } + + // Create new ID if not exists + const newId = uuidv4(); + try { + const dir = path.dirname(idFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(idFile, newId); + return newId; + } catch (error) { + this.logger.debug(`Failed to write installation ID: ${error}`); + } + + // Fallback to in-memory ID if file operations fail + return newId; + } + + /** + * Initialize the PostHog client with configuration. + */ + private _initializePosthog(): boolean { + if (this.initialized) { + return true; + } + + try { + this.posthogClient = new PostHog(this.config.posthog.apiKey, { + host: this.config.posthog.host, + flushAt: 20, // Number of events to batch before sending + flushInterval: 30000, // Send events every 30 seconds + }); + this.initialized = true; + this.logger.debug('PostHog client initialized successfully'); + + // Process any queued events + this._processQueuedEvents(); + return true; + } catch (error) { + this.logger.error(`Failed to initialize PostHog client: ${error}`); + return false; + } + } + + /** + * Process any events that were queued before initialization. + */ + private _processQueuedEvents(): void { + if (!this.posthogClient || this.queuedEvents.length === 0) { + return; + } + + for (const event of this.queuedEvents) { + this._captureEvent(event.name, event.properties); + } + this.queuedEvents = []; + } + + /** + * Capture an event with PostHog. + */ + private _captureEvent( + eventName: string, + properties?: Record + ): void { + if (!this.posthogClient) { + return; + } + + try { + // Add standard properties + const eventProperties = { + ...properties, + version: process.env.npm_package_version || 'unknown', + platform: process.platform, + node_version: process.version, + is_ci: this._isCI, + }; + + this.posthogClient.capture({ + distinctId: this.installationId, + event: eventName, + properties: eventProperties, + }); + } catch (error) { + this.logger.debug(`Failed to capture event: ${error}`); + } + } + + private get _isCI(): boolean { + /** + * Detect if running in CI environment. + */ + return !!( + process.env.CI || + process.env.CONTINUOUS_INTEGRATION || + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.CIRCLECI || + process.env.TRAVIS || + process.env.JENKINS_URL + ); + } + + increment(counterName: string, value = 1) { + /** + * Increment a named counter. + */ + if (!this.config.enabled) { + return; + } + + if (!(counterName in this.counters)) { + this.counters[counterName] = 0; + } + this.counters[counterName] += value; + } + + recordEvent(eventName: string, properties?: Record): void { + /** + * Record an event with optional properties. + */ + if (!this.config.enabled) { + return; + } + + // Increment counter for this event type + const counterKey = `event:${eventName}`; + this.increment(counterKey); + + // Apply sampling + if (Math.random() * 100 > this.config.sampleRate) { + return; + } + + const event = { + name: eventName, + properties: properties || {}, + timestamp: Date.now() / 1000, + }; + + if (this.initialized && this.posthogClient) { + this._captureEvent(eventName, properties); + } else { + // Queue event if not initialized + this.queuedEvents.push(event); + // Try to initialize again + if (this.config.enabled && !this.initialized) { + this._initializePosthog(); + } + } + } + + /** + * Flush any pending events to PostHog. + */ + async flush(): Promise { + if (!this.config.enabled || !this.posthogClient) { + return false; + } + + try { + // Send counter data as a single event + if (Object.keys(this.counters).length > 0) { + this._captureEvent('telemetry_counters', { + counters: { ...this.counters }, + duration: Date.now() / 1000 - this.startTime, + }); + } + + await this.posthogClient.flush(); + this.logger.debug('Telemetry flushed successfully'); + + // Clear counters after sending + this.counters = {}; + return true; + } catch (error) { + this.logger.debug(`Failed to flush telemetry: ${error}`); + return false; + } + } + + enable(): void { + /** + * Enable telemetry collection. + */ + this.config.enabled = true; + this.logger.info('Telemetry enabled'); + if (!this.initialized) { + this._initializePosthog(); + } + } + + async disable(): Promise { + /** + * Disable telemetry collection. + */ + this.config.enabled = false; + await this.posthogClient?.disable(); + this.logger.info('Telemetry disabled'); + } + + get enabled(): boolean { + /** + * Check if telemetry is enabled. + */ + return this.config.enabled; + } + + async shutdown(): Promise { + /** + * Shutdown the telemetry client and flush any pending events. + */ + if (this.posthogClient) { + await this.flush(); + await this.posthogClient.shutdown(); + this.initialized = false; + this.posthogClient = undefined; + } + } +} diff --git a/libs/typescript/core/src/telemetry/index.ts b/libs/typescript/core/src/telemetry/index.ts new file mode 100644 index 00000000..44dd951a --- /dev/null +++ b/libs/typescript/core/src/telemetry/index.ts @@ -0,0 +1,7 @@ +/** + * This module provides the core telemetry functionality for CUA libraries. + * + * It provides a low-overhead way to collect anonymous usage data. + */ + +export { PostHogTelemetryClient as Telemetry } from './clients'; diff --git a/libs/typescript/core/tests/telemetry.test.ts b/libs/typescript/core/tests/telemetry.test.ts new file mode 100644 index 00000000..4c4d47f6 --- /dev/null +++ b/libs/typescript/core/tests/telemetry.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Telemetry } from '../src/'; + +describe('Telemetry', () => { + let telemetry: Telemetry; + beforeEach(() => { + process.env.CUA_TELEMETRY = ''; + process.env.CUA_TELEMETRY_DISABLED = ''; + telemetry = new Telemetry(); + }); + describe('telemetry.enabled', () => { + it('should return false when CUA_TELEMETRY is off', () => { + process.env.CUA_TELEMETRY = 'off'; + telemetry = new Telemetry(); + expect(telemetry.enabled).toBe(false); + }); + + it('should return true when CUA_TELEMETRY is not set', () => { + process.env.CUA_TELEMETRY = ''; + telemetry = new Telemetry(); + expect(telemetry.enabled).toBe(true); + }); + + it('should return false if CUA_TELEMETRY_DISABLED is 1', () => { + process.env.CUA_TELEMETRY_DISABLED = '1'; + telemetry = new Telemetry(); + expect(telemetry.enabled).toBe(false); + }); + }); +}); diff --git a/libs/typescript/core/tsconfig.json b/libs/typescript/core/tsconfig.json new file mode 100644 index 00000000..77b838a8 --- /dev/null +++ b/libs/typescript/core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/libs/typescript/core/tsdown.config.ts b/libs/typescript/core/tsdown.config.ts new file mode 100644 index 00000000..36743757 --- /dev/null +++ b/libs/typescript/core/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig([ + { + entry: ['./src/index.ts'], + platform: 'node', + dts: true, + }, +]); diff --git a/libs/typescript/core/vitest.config.ts b/libs/typescript/core/vitest.config.ts new file mode 100644 index 00000000..94ede10e --- /dev/null +++ b/libs/typescript/core/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({}); diff --git a/libs/typescript/package.json b/libs/typescript/package.json new file mode 100644 index 00000000..0e0d6ce1 --- /dev/null +++ b/libs/typescript/package.json @@ -0,0 +1,29 @@ +{ + "name": "cua-ts", + "version": "1.0.0", + "description": "The c/ua typescript libs.", + "keywords": [], + "author": "c/ua", + "license": "MIT", + "scripts": { + "lint": "biome check", + "lint:fix": "biome check --fix", + "build:core": "pnpm --filter @trycua/core build", + "build:computer": "pnpm --filter @trycua/computer build", + "build": "pnpm build:core && pnpm build:computer", + "test:core": "pnpm --filter @trycua/core test", + "test:computer": "pnpm --filter @trycua/computer test", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck" + }, + "packageManager": "pnpm@10.12.3", + "devDependencies": { + "@biomejs/biome": "^1.9.4" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@biomejs/biome", + "esbuild" + ] + } +} \ No newline at end of file diff --git a/libs/typescript/pnpm-lock.yaml b/libs/typescript/pnpm-lock.yaml new file mode 100644 index 00000000..99485af8 --- /dev/null +++ b/libs/typescript/pnpm-lock.yaml @@ -0,0 +1,1910 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + + computer: + dependencies: + '@trycua/core': + specifier: ^0.1.2 + version: 0.1.2 + pino: + specifier: ^9.7.0 + version: 9.7.0 + ws: + specifier: ^8.18.0 + version: 8.18.3 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@types/node': + specifier: ^22.15.17 + version: 22.15.34 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + bumpp: + specifier: ^10.1.0 + version: 10.2.0 + happy-dom: + specifier: ^17.4.7 + version: 17.6.3 + tsdown: + specifier: ^0.11.9 + version: 0.11.13(typescript@5.8.3) + tsx: + specifier: ^4.19.4 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.3 + version: 3.2.4(@types/node@22.15.34)(happy-dom@17.6.3)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + + core: + dependencies: + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + pino: + specifier: ^9.7.0 + version: 9.7.0 + posthog-node: + specifier: ^5.1.1 + version: 5.1.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@types/node': + specifier: ^22.15.17 + version: 22.15.34 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + bumpp: + specifier: ^10.1.0 + version: 10.2.0 + happy-dom: + specifier: ^17.4.7 + version: 17.6.3 + tsdown: + specifier: ^0.11.9 + version: 0.11.13(typescript@5.8.3) + tsx: + specifier: ^4.19.4 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.3 + version: 3.2.4(@types/node@22.15.34)(happy-dom@17.6.3)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + +packages: + + '@babel/generator@7.27.5': + resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.7': + resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.27.7': + resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.4.3': + resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} + + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + + '@emnapi/wasi-threads@1.0.2': + resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} + + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.10': + resolution: {integrity: sha512-HM2F4B9N4cA0RH2KQiIZOHAZqtP4xGS4IZ+SFe1SIbO4dyjf9MTY2Bo3vHYnm0hglWfXqBrzUBSa+cJfl3Xvrg==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.2': + resolution: {integrity: sha512-gKYheCylLIedI+CSZoDtGkFV9YEBxRRVcfCH7OfAqh4TyUyRjEE6WVE/aXDXX0p8BIe/QgLcaAoI0220KRRFgg==} + + '@jridgewell/trace-mapping@0.3.27': + resolution: {integrity: sha512-VO95AxtSFMelbg3ouljAYnfvTEwSWVt/2YLf+U5Ejd8iT5mXE2Sa/1LGyvySMne2CGsepGLI7KpF3EzE3Aq9Mg==} + + '@napi-rs/wasm-runtime@0.2.11': + resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + + '@oxc-project/types@0.70.0': + resolution: {integrity: sha512-ngyLUpUjO3dpqygSRQDx7nMx8+BmXbWOU4oIwTJFV2MVIDG7knIZwgdwXlQWLg3C3oxg1lS7ppMtPKqKFb7wzw==} + + '@quansync/fs@0.1.3': + resolution: {integrity: sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg==} + engines: {node: '>=20.0.0'} + + '@rolldown/binding-darwin-arm64@1.0.0-beta.9': + resolution: {integrity: sha512-geUG/FUpm+membLC0NQBb39vVyOfguYZ2oyXc7emr6UjH6TeEECT4b0CPZXKFnELareTiU/Jfl70/eEgNxyQeA==} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.9': + resolution: {integrity: sha512-7wPXDwcOtv2I+pWTL2UNpNAxMAGukgBT90Jz4DCfwaYdGvQncF7J0S7IWrRVsRFhBavxM+65RcueE3VXw5UIbg==} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.9': + resolution: {integrity: sha512-agO5mONTNKVrcIt4SRxw5Ni0FOVV3gaH8dIiNp1A4JeU91b9kw7x+JRuNJAQuM2X3pYqVvA6qh13UTNOsaqM/Q==} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9': + resolution: {integrity: sha512-dDNDV9p/8WYDriS9HCcbH6y6+JP38o3enj/pMkdkmkxEnZ0ZoHIfQ9RGYWeRYU56NKBCrya4qZBJx49Jk9LRug==} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9': + resolution: {integrity: sha512-kZKegmHG1ZvfsFIwYU6DeFSxSIcIliXzeznsJHUo9D9/dlVSDi/PUvsRKcuJkQjZoejM6pk8MHN/UfgGdIhPHw==} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9': + resolution: {integrity: sha512-f+VL8mO31pyMJiJPr2aA1ryYONkP2UqgbwK7fKtKHZIeDd/AoUGn3+ujPqDhuy2NxgcJ5H8NaSvDpG1tJMHh+g==} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9': + resolution: {integrity: sha512-GiUEZ0WPjX5LouDoC3O8aJa4h6BLCpIvaAboNw5JoRour/3dC6rbtZZ/B5FC3/ySsN3/dFOhAH97ylQxoZJi7A==} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.9': + resolution: {integrity: sha512-AMb0dicw+QHh6RxvWo4BRcuTMgS0cwUejJRMpSyIcHYnKTbj6nUW4HbWNQuDfZiF27l6F5gEwBS+YLUdVzL9vg==} + cpu: [x64] + os: [linux] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.9': + resolution: {integrity: sha512-+pdaiTx7L8bWKvsAuCE0HAxP1ze1WOLoWGCawcrZbMSY10dMh2i82lJiH6tXGXbfYYwsNWhWE2NyG4peFZvRfQ==} + engines: {node: '>=14.21.3'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9': + resolution: {integrity: sha512-A7kN248viWvb8eZMzQu024TBKGoyoVYBsDG2DtoP8u2pzwoh5yDqUL291u01o4f8uzpUHq8mfwQJmcGChFu8KQ==} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9': + resolution: {integrity: sha512-DzKN7iEYjAP8AK8F2G2aCej3fk43Y/EQrVrR3gF0XREes56chjQ7bXIhw819jv74BbxGdnpPcslhet/cgt7WRA==} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9': + resolution: {integrity: sha512-GMWgTvvbZ8TfBsAiJpoz4SRq3IN3aUMn0rYm8q4I8dcEk4J1uISyfb6ZMzvqW+cvScTWVKWZNqnrmYOKLLUt4w==} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.9': + resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==} + + '@rollup/rollup-android-arm-eabi@4.44.1': + resolution: {integrity: sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.44.1': + resolution: {integrity: sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.44.1': + resolution: {integrity: sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.44.1': + resolution: {integrity: sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.44.1': + resolution: {integrity: sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.44.1': + resolution: {integrity: sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.44.1': + resolution: {integrity: sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.44.1': + resolution: {integrity: sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.44.1': + resolution: {integrity: sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.44.1': + resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.44.1': + resolution: {integrity: sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.44.1': + resolution: {integrity: sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.44.1': + resolution: {integrity: sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.44.1': + resolution: {integrity: sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.44.1': + resolution: {integrity: sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.44.1': + resolution: {integrity: sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.44.1': + resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.44.1': + resolution: {integrity: sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.44.1': + resolution: {integrity: sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.44.1': + resolution: {integrity: sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==} + cpu: [x64] + os: [win32] + + '@trycua/core@0.1.2': + resolution: {integrity: sha512-pSQZaR46OG3MtUCBaneG6RpJD1xfX754VDZ101FM5tkUUiymIrxpQicQEUfhwEBxbI/EmBnmCnVY1AFKvykKzQ==} + + '@tybys/wasm-util@0.9.0': + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.15.34': + resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + ansis@4.1.0: + resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} + engines: {node: '>=14'} + + args-tokenizer@0.3.0: + resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.1.0: + resolution: {integrity: sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==} + engines: {node: '>=20.18.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + birpc@2.4.0: + resolution: {integrity: sha512-5IdNxTyhXHv2UlgnPHQ0h+5ypVmkrYHzL8QT+DwFZ//2N/oNV8Ch+BCRmTJ3x6/z9Axo/cXYBc9eprsUVK/Jsg==} + + bumpp@10.2.0: + resolution: {integrity: sha512-1EJ2NG3M3WYJj4m+GtcxNH6Y7zMQ8q68USMoUGKjM6qFTVXSXCnTxcQSUDV7j4KjLVbk2uK6345Z+6RKOv0w5A==} + engines: {node: '>=18'} + hasBin: true + + c12@3.0.4: + resolution: {integrity: sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dts-resolver@2.1.1: + resolution: {integrity: sha512-3BiGFhB6mj5Kv+W2vdJseQUYW+SKVzAFJL6YNP6ursbrwy1fXHRotfHi3xLNxe4wZl/K8qbAFeCDjZLjzqxxRw==} + engines: {node: '>=20.18.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + empathic@1.1.0: + resolution: {integrity: sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA==} + engines: {node: '>=14'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + happy-dom@17.6.3: + resolution: {integrity: sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==} + engines: {node: '>=20.0.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-fetch-native@1.6.6: + resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} + + nypm@0.6.0: + resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + package-manager-detector@1.3.0: + resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.7.0: + resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} + hasBin: true + + pkg-types@2.1.1: + resolution: {integrity: sha512-eY0QFb6eSwc9+0d/5D2lFFUq+A3n3QNGSy/X2Nvp+6MfzGw2u6EbA7S80actgjY1lkvvI0pqB+a4hioMh443Ew==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + posthog-node@5.1.1: + resolution: {integrity: sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==} + engines: {node: '>=20'} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.13.13: + resolution: {integrity: sha512-Nchx9nQoa4IpfQ/BJzodKMvtJ3H3dT322siAJSp3uvQJ+Pi1qgEjOp7hSQwGSQRhaC5gC+9hparbWEH5oiAL9Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + '@typescript/native-preview': '>=7.0.0-dev.20250601.1' + rolldown: ^1.0.0-beta.9 + typescript: ^5.0.0 + vue-tsc: ~2.2.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-beta.9: + resolution: {integrity: sha512-ZgZky52n6iF0UainGKjptKGrOG4Con2S5sdc4C4y2Oj25D5PHAY8Y8E5f3M2TSd/zlhQs574JlMeTe3vREczSg==} + hasBin: true + peerDependencies: + '@oxc-project/runtime': 0.70.0 + peerDependenciesMeta: + '@oxc-project/runtime': + optional: true + + rollup@4.44.1: + resolution: {integrity: sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.1: + resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + + tsdown@0.11.13: + resolution: {integrity: sha512-VSfoNm8MJXFdg7PJ4p2javgjMRiQQHpkP9N3iBBTrmCixcT6YZ9ZtqYMW3NDHczqR0C0Qnur1HMQr1ZfZcmrng==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-lightningcss: ^0.4.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + publint: + optional: true + typescript: + optional: true + unplugin-lightningcss: + optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig@7.3.2: + resolution: {integrity: sha512-nqG5NNL2wFVGZ0NA/aCFw0oJ2pxSf1lwg4Z5ill8wd7K4KX/rQbHlwbh+bjctXL5Ly1xtzHenHGOK0b+lG6JVg==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.0.0: + resolution: {integrity: sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@babel/generator@7.27.5': + dependencies: + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + '@jridgewell/gen-mapping': 0.3.10 + '@jridgewell/trace-mapping': 0.3.27 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/parser@7.27.7': + dependencies: + '@babel/types': 7.27.7 + + '@babel/types@7.27.7': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@emnapi/core@1.4.3': + dependencies: + '@emnapi/wasi-threads': 1.0.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.0.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + + '@jridgewell/gen-mapping@0.3.10': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.2 + '@jridgewell/trace-mapping': 0.3.27 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.2': {} + + '@jridgewell/trace-mapping@0.3.27': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.2 + + '@napi-rs/wasm-runtime@0.2.11': + dependencies: + '@emnapi/core': 1.4.3 + '@emnapi/runtime': 1.4.3 + '@tybys/wasm-util': 0.9.0 + optional: true + + '@oxc-project/types@0.70.0': {} + + '@quansync/fs@0.1.3': + dependencies: + quansync: 0.2.10 + + '@rolldown/binding-darwin-arm64@1.0.0-beta.9': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.9': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.9': + dependencies: + '@napi-rs/wasm-runtime': 0.2.11 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.9': {} + + '@rollup/rollup-android-arm-eabi@4.44.1': + optional: true + + '@rollup/rollup-android-arm64@4.44.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.44.1': + optional: true + + '@rollup/rollup-darwin-x64@4.44.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.44.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.44.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.44.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.44.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.44.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.44.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.44.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.44.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.44.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.44.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.44.1': + optional: true + + '@trycua/core@0.1.2': + dependencies: + '@types/uuid': 10.0.0 + pino: 9.7.0 + posthog-node: 5.1.1 + uuid: 11.1.0 + + '@tybys/wasm-util@0.9.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.15.34': + dependencies: + undici-types: 6.21.0 + + '@types/uuid@10.0.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.15.34 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + + ansis@4.1.0: {} + + args-tokenizer@0.3.0: {} + + assertion-error@2.0.1: {} + + ast-kit@2.1.0: + dependencies: + '@babel/parser': 7.27.7 + pathe: 2.0.3 + + atomic-sleep@1.0.0: {} + + birpc@2.4.0: {} + + bumpp@10.2.0: + dependencies: + ansis: 4.1.0 + args-tokenizer: 0.3.0 + c12: 3.0.4 + cac: 6.7.14 + escalade: 3.2.0 + jsonc-parser: 3.3.1 + package-manager-detector: 1.3.0 + semver: 7.7.2 + tinyexec: 1.0.1 + tinyglobby: 0.2.14 + yaml: 2.8.0 + transitivePeerDependencies: + - magicast + + c12@3.0.4: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.7 + giget: 2.0.0 + jiti: 2.4.2 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.1.1 + rc9: 2.1.2 + + cac@6.7.14: {} + + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.4 + pathval: 2.0.1 + + check-error@2.1.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + confbox@0.2.2: {} + + consola@3.4.2: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + defu@6.1.4: {} + + destr@2.0.5: {} + + diff@8.0.2: {} + + dotenv@16.6.1: {} + + dts-resolver@2.1.1: {} + + empathic@1.1.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.2.1: {} + + exsolve@1.0.7: {} + + fast-redact@3.5.0: {} + + fdir@6.4.6(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.6 + nypm: 0.6.0 + pathe: 2.0.3 + + happy-dom@17.6.3: + dependencies: + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + hookable@5.5.3: {} + + jiti@2.4.2: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + jsonc-parser@3.3.1: {} + + loupe@3.1.4: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-fetch-native@1.6.6: {} + + nypm@0.6.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.1.1 + tinyexec: 0.3.2 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + package-manager-detector@1.3.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.2: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.7.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + pkg-types@2.1.1: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.7 + pathe: 2.0.3 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + posthog-node@5.1.1: {} + + process-warning@5.0.0: {} + + quansync@0.2.10: {} + + quick-format-unescaped@4.0.4: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.13.13(rolldown@1.0.0-beta.9)(typescript@5.8.3): + dependencies: + '@babel/generator': 7.27.5 + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + ast-kit: 2.1.0 + birpc: 2.4.0 + debug: 4.4.1 + dts-resolver: 2.1.1 + get-tsconfig: 4.10.1 + rolldown: 1.0.0-beta.9 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - oxc-resolver + - supports-color + + rolldown@1.0.0-beta.9: + dependencies: + '@oxc-project/types': 0.70.0 + '@rolldown/pluginutils': 1.0.0-beta.9 + ansis: 4.1.0 + optionalDependencies: + '@rolldown/binding-darwin-arm64': 1.0.0-beta.9 + '@rolldown/binding-darwin-x64': 1.0.0-beta.9 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.9 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.9 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.9 + + rollup@4.44.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.44.1 + '@rollup/rollup-android-arm64': 4.44.1 + '@rollup/rollup-darwin-arm64': 4.44.1 + '@rollup/rollup-darwin-x64': 4.44.1 + '@rollup/rollup-freebsd-arm64': 4.44.1 + '@rollup/rollup-freebsd-x64': 4.44.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.44.1 + '@rollup/rollup-linux-arm-musleabihf': 4.44.1 + '@rollup/rollup-linux-arm64-gnu': 4.44.1 + '@rollup/rollup-linux-arm64-musl': 4.44.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.44.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.44.1 + '@rollup/rollup-linux-riscv64-gnu': 4.44.1 + '@rollup/rollup-linux-riscv64-musl': 4.44.1 + '@rollup/rollup-linux-s390x-gnu': 4.44.1 + '@rollup/rollup-linux-x64-gnu': 4.44.1 + '@rollup/rollup-linux-x64-musl': 4.44.1 + '@rollup/rollup-win32-arm64-msvc': 4.44.1 + '@rollup/rollup-win32-ia32-msvc': 4.44.1 + '@rollup/rollup-win32-x64-msvc': 4.44.1 + fsevents: 2.3.3 + + safe-stable-stringify@2.5.0: {} + + semver@7.7.2: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.9.0: {} + + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.1: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + + tsdown@0.11.13(typescript@5.8.3): + dependencies: + ansis: 4.1.0 + cac: 6.7.14 + chokidar: 4.0.3 + debug: 4.4.1 + diff: 8.0.2 + empathic: 1.1.0 + hookable: 5.5.3 + rolldown: 1.0.0-beta.9 + rolldown-plugin-dts: 0.13.13(rolldown@1.0.0-beta.9)(typescript@5.8.3) + semver: 7.7.2 + tinyexec: 1.0.1 + tinyglobby: 0.2.14 + unconfig: 7.3.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@oxc-project/runtime' + - '@typescript/native-preview' + - oxc-resolver + - supports-color + - vue-tsc + + tslib@2.8.1: + optional: true + + tsx@4.20.3: + dependencies: + esbuild: 0.25.5 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.8.3: {} + + unconfig@7.3.2: + dependencies: + '@quansync/fs': 0.1.3 + defu: 6.1.4 + jiti: 2.4.2 + quansync: 0.2.10 + + undici-types@6.21.0: {} + + uuid@11.1.0: {} + + vite-node@3.2.4(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0): + dependencies: + esbuild: 0.25.5 + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.6 + rollup: 4.44.1 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 22.15.34 + fsevents: 2.3.3 + jiti: 2.4.2 + tsx: 4.20.3 + yaml: 2.8.0 + + vitest@3.2.4(@types/node@22.15.34)(happy-dom@17.6.3)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.0 + debug: 4.4.1 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.0.0(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@22.15.34)(jiti@2.4.2)(tsx@4.20.3)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.34 + happy-dom: 17.6.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.18.3: {} + + yaml@2.8.0: {} diff --git a/libs/typescript/pnpm-workspace.yaml b/libs/typescript/pnpm-workspace.yaml new file mode 100644 index 00000000..aa33c1bc --- /dev/null +++ b/libs/typescript/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "computer" + - "core" diff --git a/notebooks/agent_nb.ipynb b/notebooks/agent_nb.ipynb index 84d67574..e4f6df84 100644 --- a/notebooks/agent_nb.ipynb +++ b/notebooks/agent_nb.ipynb @@ -49,7 +49,7 @@ "# If locally installed, use this instead:\n", "import os\n", "\n", - "os.chdir('../libs/agent')\n", + "os.chdir('../libs/python/agent')\n", "!poetry install\n", "!poetry build\n", "\n", diff --git a/notebooks/computer_nb.ipynb b/notebooks/computer_nb.ipynb index c0bd8460..c95d37f2 100644 --- a/notebooks/computer_nb.ipynb +++ b/notebooks/computer_nb.ipynb @@ -41,7 +41,7 @@ "source": [ "import os\n", "\n", - "os.chdir('../libs/computer')\n", + "os.chdir('../libs/python/computer')\n", "!poetry install\n", "!poetry build\n", "\n", diff --git a/notebooks/computer_server_nb.ipynb b/notebooks/computer_server_nb.ipynb index 536e4e67..fee6e048 100644 --- a/notebooks/computer_server_nb.ipynb +++ b/notebooks/computer_server_nb.ipynb @@ -34,7 +34,7 @@ "# If locally installed, use this instead:\n", "import os\n", "\n", - "os.chdir('../libs/computer-server')\n", + "os.chdir('../libs/python/computer-server')\n", "!pdm install\n", "!pdm build\n", "\n", @@ -109,7 +109,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.7" + "version": "3.12.2" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 36fa43c1..2330fbe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,12 @@ distribution = false [tool.pdm.dev-dependencies] dev = [ - "-e agent @ file:///${PROJECT_ROOT}/libs/agent", - "-e computer @ file:///${PROJECT_ROOT}/libs/computer", - "-e computer-server @ file:///${PROJECT_ROOT}/libs/computer-server", - "-e cua-som @ file:///${PROJECT_ROOT}/libs/som", - "-e mcp-server @ file:///${PROJECT_ROOT}/libs/mcp-server", - "-e pylume @ file:///${PROJECT_ROOT}/libs/pylume", + "-e agent @ file:///${PROJECT_ROOT}/libs/python/agent", + "-e computer @ file:///${PROJECT_ROOT}/libs/python/computer", + "-e computer-server @ file:///${PROJECT_ROOT}/libs/python/computer-server", + "-e cua-som @ file:///${PROJECT_ROOT}/libs/python/som", + "-e mcp-server @ file:///${PROJECT_ROOT}/libs/python/mcp-server", + "-e pylume @ file:///${PROJECT_ROOT}/libs/python/pylume", "black>=23.0.0", "ipykernel>=6.29.5", "jedi>=0.19.2", diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 00000000..13f613d7 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,157 @@ +# PowerShell Build Script for CUA +# Exit on error +$ErrorActionPreference = "Stop" + +# Colors for output +$RED = "Red" +$GREEN = "Green" +$BLUE = "Blue" + +# Function to print step information +function Print-Step { + param([string]$Message) + Write-Host "==> $Message" -ForegroundColor $BLUE +} + +# Function to print success message +function Print-Success { + param([string]$Message) + Write-Host "==> Success: $Message" -ForegroundColor $GREEN +} + +# Function to print error message +function Print-Error { + param([string]$Message) + Write-Host "==> Error: $Message" -ForegroundColor $RED +} + +# Get the script's directory and project root +$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path +$PROJECT_ROOT = Split-Path -Parent $SCRIPT_DIR + +# Change to project root +Set-Location $PROJECT_ROOT + +# Load environment variables from .env.local +if (Test-Path ".env.local") { + Print-Step "Loading environment variables from .env.local..." + Get-Content ".env.local" | ForEach-Object { + if ($_ -match "^([^#][^=]*?)=(.*)$") { + [Environment]::SetEnvironmentVariable($matches[1], $matches[2], "Process") + } + } + Print-Success "Environment variables loaded" +} else { + Print-Error ".env.local file not found" + exit 1 +} + +# Check if conda is available +try { + conda --version | Out-Null + Print-Success "Conda is available" +} catch { + Print-Error "Conda is not available. Please install Anaconda or Miniconda first." + exit 1 +} + +# Create or update conda environment +Print-Step "Creating/updating conda environment 'cua' with Python 3.12..." +try { + # Check if environment exists + $envExists = conda env list | Select-String "^cua\s" + if ($envExists) { + Print-Step "Environment 'cua' already exists. Updating..." + conda env update -n cua -f environment.yml --prune + } else { + Print-Step "Creating new environment 'cua'..." + conda create -n cua python=3.12 -y + } + Print-Success "Conda environment 'cua' ready" +} catch { + Print-Error "Failed to create/update conda environment" + exit 1 +} + +# Activate conda environment +Print-Step "Activating conda environment 'cua'..." +try { + conda activate cua + Print-Success "Environment activated" +} catch { + Print-Error "Failed to activate conda environment 'cua'" + Print-Step "Please run: conda activate cua" + Print-Step "Then re-run this script" + exit 1 +} + +# Clean up existing environments and cache +Print-Step "Cleaning up existing environments..." +Get-ChildItem -Path . -Recurse -Directory -Name "__pycache__" | ForEach-Object { Remove-Item -Path $_ -Recurse -Force } +Get-ChildItem -Path . -Recurse -Directory -Name ".pytest_cache" | ForEach-Object { Remove-Item -Path $_ -Recurse -Force } +Get-ChildItem -Path . -Recurse -Directory -Name "dist" | ForEach-Object { Remove-Item -Path $_ -Recurse -Force } +Get-ChildItem -Path . -Recurse -Directory -Name "*.egg-info" | ForEach-Object { Remove-Item -Path $_ -Recurse -Force } + +# Function to install a package and its dependencies +function Install-Package { + param( + [string]$PackageDir, + [string]$PackageName, + [string]$Extras = "" + ) + + Print-Step "Installing $PackageName..." + Set-Location $PackageDir + + if (Test-Path "pyproject.toml") { + if ($Extras) { + pip install -e ".[$Extras]" + } else { + pip install -e . + } + } else { + Print-Error "No pyproject.toml found in $PackageDir" + Set-Location $PROJECT_ROOT + return $false + } + + Set-Location $PROJECT_ROOT + return $true +} + +# Install packages in order of dependency +Print-Step "Installing packages in development mode..." + +# Install core first (base package with telemetry support) +if (-not (Install-Package "libs/python/core" "core")) { exit 1 } + +# Install pylume (base dependency) +if (-not (Install-Package "libs/python/pylume" "pylume")) { exit 1 } + +# Install computer with all its dependencies and extras +if (-not (Install-Package "libs/python/computer" "computer" "all")) { exit 1 } + +# Install omniparser +if (-not (Install-Package "libs/python/som" "som")) { exit 1 } + +# Install agent with all its dependencies and extras +if (-not (Install-Package "libs/python/agent" "agent" "all")) { exit 1 } + +# Install computer-server +if (-not (Install-Package "libs/python/computer-server" "computer-server")) { exit 1 } + +# Install mcp-server +if (-not (Install-Package "libs/python/mcp-server" "mcp-server")) { exit 1 } + +# Install development tools from root project +Print-Step "Installing development dependencies..." +pip install -e ".[dev,test,docs]" + +# Create a .env file for VS Code to use the virtual environment +Print-Step "Creating .env file for VS Code..." +$pythonPath = "$PROJECT_ROOT/libs/python/core;$PROJECT_ROOT/libs/python/computer;$PROJECT_ROOT/libs/python/agent;$PROJECT_ROOT/libs/python/som;$PROJECT_ROOT/libs/python/pylume;$PROJECT_ROOT/libs/python/computer-server;$PROJECT_ROOT/libs/python/mcp-server" +"PYTHONPATH=$pythonPath" | Out-File -FilePath ".env" -Encoding UTF8 + +Print-Success "All packages installed successfully!" +Print-Step "Your conda environment 'cua' is ready. To activate it:" +Write-Host " conda activate cua" -ForegroundColor Yellow diff --git a/scripts/build.sh b/scripts/build.sh index 5d747816..f2850aae 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -87,25 +87,25 @@ install_package() { print_step "Installing packages in development mode..." # Install core first (base package with telemetry support) -install_package "libs/core" "core" +install_package "libs/python/core" "core" # Install pylume (base dependency) -install_package "libs/pylume" "pylume" +install_package "libs/python/pylume" "pylume" -# Install computer (depends on pylume) -install_package "libs/computer" "computer" +# Install computer with all its dependencies and extras +install_package "libs/python/computer" "computer" "all" # Install omniparser -install_package "libs/som" "som" +install_package "libs/python/som" "som" # Install agent with all its dependencies and extras -install_package "libs/agent" "agent" "all" +install_package "libs/python/agent" "agent" "all" # Install computer-server -install_package "libs/computer-server" "computer-server" +install_package "libs/python/computer-server" "computer-server" # Install mcp-server -install_package "libs/mcp-server" "mcp-server" +install_package "libs/python/mcp-server" "mcp-server" # Install development tools from root project print_step "Installing development dependencies..." @@ -113,7 +113,7 @@ pip install -e ".[dev,test,docs]" # Create a .env file for VS Code to use the virtual environment print_step "Creating .env file for VS Code..." -echo "PYTHONPATH=${PROJECT_ROOT}/libs/core:${PROJECT_ROOT}/libs/computer:${PROJECT_ROOT}/libs/agent:${PROJECT_ROOT}/libs/som:${PROJECT_ROOT}/libs/pylume:${PROJECT_ROOT}/libs/computer-server:${PROJECT_ROOT}/libs/mcp-server" > .env +echo "PYTHONPATH=${PROJECT_ROOT}/libs/python/core:${PROJECT_ROOT}/libs/python/computer:${PROJECT_ROOT}/libs/python/agent:${PROJECT_ROOT}/libs/python/som:${PROJECT_ROOT}/libs/python/pylume:${PROJECT_ROOT}/libs/python/computer-server:${PROJECT_ROOT}/libs/python/mcp-server" > .env print_success "All packages installed successfully!" print_step "Your virtual environment is ready. To activate it:" diff --git a/scripts/playground-docker.sh b/scripts/playground-docker.sh new file mode 100644 index 00000000..61b57901 --- /dev/null +++ b/scripts/playground-docker.sh @@ -0,0 +1,323 @@ +#!/bin/bash + +set -e + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Print with color +print_info() { + echo -e "${BLUE}==> $1${NC}" +} + +print_success() { + echo -e "${GREEN}==> $1${NC}" +} + +print_error() { + echo -e "${RED}==> $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}==> $1${NC}" +} + +echo "🚀 Launching C/ua Computer-Use Agent UI..." + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + print_error "Docker is not installed!" + echo "" + echo "To use C/ua with Docker containers, you need to install Docker first:" + echo "" + echo "📦 Install Docker:" + echo " • macOS: Download Docker Desktop from https://docker.com/products/docker-desktop" + echo " • Windows: Download Docker Desktop from https://docker.com/products/docker-desktop" + echo " • Linux: Follow instructions at https://docs.docker.com/engine/install/" + echo "" + echo "After installing Docker, run this script again." + exit 1 +fi + +# Check if Docker daemon is running +if ! docker info &> /dev/null; then + print_error "Docker is installed but not running!" + echo "" + echo "Please start Docker Desktop and try again." + exit 1 +fi + +print_success "Docker is installed and running!" + +# Save the original working directory +ORIGINAL_DIR="$(pwd)" + +DEMO_DIR="$HOME/.cua" +mkdir -p "$DEMO_DIR" + + +# Check if we're already in the cua repository +# Look for the specific trycua identifier in pyproject.toml +if [[ -f "pyproject.toml" ]] && grep -q "gh@trycua.com" "pyproject.toml"; then + print_success "Already in C/ua repository - using current directory" + REPO_DIR="$ORIGINAL_DIR" + USE_EXISTING_REPO=true +else + # Directories used by the script when not in repo + REPO_DIR="$DEMO_DIR/cua" + USE_EXISTING_REPO=false +fi + +# Function to clean up on exit +cleanup() { + cd "$ORIGINAL_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +echo "" +echo "Choose your C/ua setup:" +echo "1) ☁️ C/ua Cloud Containers (works on any system)" +echo "2) 🖥️ Local macOS VMs (requires Apple Silicon Mac + macOS 15+)" +echo "3) 🖥️ Local Windows VMs (requires Windows 10 / 11)" +echo "" +read -p "Enter your choice (1, 2, or 3): " CHOICE + +if [[ "$CHOICE" == "1" ]]; then + # C/ua Cloud Container setup + echo "" + print_info "Setting up C/ua Cloud Containers..." + echo "" + + # Check if existing .env.local already has CUA_API_KEY + REPO_ENV_FILE="$REPO_DIR/.env.local" + CURRENT_ENV_FILE="$ORIGINAL_DIR/.env.local" + + CUA_API_KEY="" + + # First check current directory + if [[ -f "$CURRENT_ENV_FILE" ]] && grep -q "CUA_API_KEY=" "$CURRENT_ENV_FILE"; then + EXISTING_CUA_KEY=$(grep "CUA_API_KEY=" "$CURRENT_ENV_FILE" | cut -d'=' -f2- | tr -d '"' | tr -d "'" | xargs) + if [[ -n "$EXISTING_CUA_KEY" && "$EXISTING_CUA_KEY" != "your_cua_api_key_here" && "$EXISTING_CUA_KEY" != "" ]]; then + CUA_API_KEY="$EXISTING_CUA_KEY" + fi + fi + + # Then check repo directory if not found in current dir + if [[ -z "$CUA_API_KEY" ]] && [[ -f "$REPO_ENV_FILE" ]] && grep -q "CUA_API_KEY=" "$REPO_ENV_FILE"; then + EXISTING_CUA_KEY=$(grep "CUA_API_KEY=" "$REPO_ENV_FILE" | cut -d'=' -f2- | tr -d '"' | tr -d "'" | xargs) + if [[ -n "$EXISTING_CUA_KEY" && "$EXISTING_CUA_KEY" != "your_cua_api_key_here" && "$EXISTING_CUA_KEY" != "" ]]; then + CUA_API_KEY="$EXISTING_CUA_KEY" + fi + fi + + # If no valid API key found, prompt for one + if [[ -z "$CUA_API_KEY" ]]; then + echo "To use C/ua Cloud Containers, you need to:" + echo "1. Sign up at https://trycua.com" + echo "2. Create a Cloud Container" + echo "3. Generate an Api Key" + echo "" + read -p "Enter your C/ua Api Key: " CUA_API_KEY + + if [[ -z "$CUA_API_KEY" ]]; then + print_error "C/ua Api Key is required for Cloud Containers." + exit 1 + fi + else + print_success "Found existing CUA API key" + fi + + USE_CLOUD=true + COMPUTER_TYPE="cloud" + +elif [[ "$CHOICE" == "2" ]]; then + # Local macOS VM setup + echo "" + print_info "Setting up local macOS VMs..." + + # Check for Apple Silicon Mac + if [[ $(uname -s) != "Darwin" || $(uname -m) != "arm64" ]]; then + print_error "Local macOS VMs require an Apple Silicon Mac (M1/M2/M3/M4)." + echo "💡 Consider using C/ua Cloud Containers instead (option 1)." + exit 1 + fi + + # Check for macOS 15 (Sequoia) or newer + OSVERSION=$(sw_vers -productVersion) + if [[ $(echo "$OSVERSION 15.0" | tr " " "\n" | sort -V | head -n 1) != "15.0" ]]; then + print_error "Local macOS VMs require macOS 15 (Sequoia) or newer. You have $OSVERSION." + echo "💡 Consider using C/ua Cloud Containers instead (option 1)." + exit 1 + fi + + USE_CLOUD=false + COMPUTER_TYPE="macos" + +elif [[ "$CHOICE" == "3" ]]; then + # Local Windows VM setup + echo "" + print_info "Setting up local Windows VMs..." + + # Check if we're on Windows + if [[ $(uname -s) != MINGW* && $(uname -s) != CYGWIN* && $(uname -s) != MSYS* ]]; then + print_error "Local Windows VMs require Windows 10 or 11." + echo "💡 Consider using C/ua Cloud Containers instead (option 1)." + echo "" + echo "🔗 If you are using WSL, refer to the blog post to get started: https://www.trycua.com/blog/windows-sandbox" + exit 1 + fi + + USE_CLOUD=false + COMPUTER_TYPE="windows" + +else + print_error "Invalid choice. Please run the script again and choose 1, 2, or 3." + exit 1 +fi + +print_success "All checks passed! 🎉" + +# Create demo directory and handle repository +if [[ "$USE_EXISTING_REPO" == "true" ]]; then + print_info "Using existing repository in current directory" + cd "$REPO_DIR" +else + # Clone or update the repository + if [[ ! -d "$REPO_DIR" ]]; then + print_info "Cloning C/ua repository..." + cd "$DEMO_DIR" + git clone https://github.com/trycua/cua.git + else + print_info "Updating C/ua repository..." + cd "$REPO_DIR" + git pull origin main + fi + + cd "$REPO_DIR" +fi + +# Create .env.local file with API keys +ENV_FILE="$REPO_DIR/.env.local" +if [[ ! -f "$ENV_FILE" ]]; then + cat > "$ENV_FILE" << EOF +# Uncomment and add your API keys here +# OPENAI_API_KEY=your_openai_api_key_here +# ANTHROPIC_API_KEY=your_anthropic_api_key_here +CUA_API_KEY=your_cua_api_key_here +EOF + print_success "Created .env.local file with API key placeholders" +else + print_success "Found existing .env.local file - keeping your current settings" +fi + +if [[ "$USE_CLOUD" == "true" ]]; then + # Add CUA API key to .env.local if not already present + if ! grep -q "CUA_API_KEY" "$ENV_FILE"; then + echo "CUA_API_KEY=$CUA_API_KEY" >> "$ENV_FILE" + print_success "Added CUA_API_KEY to .env.local" + elif grep -q "CUA_API_KEY=your_cua_api_key_here" "$ENV_FILE"; then + # Update placeholder with actual key + sed -i.bak "s/CUA_API_KEY=your_cua_api_key_here/CUA_API_KEY=$CUA_API_KEY/" "$ENV_FILE" + print_success "Updated CUA_API_KEY in .env.local" + fi +fi + +# Build the Docker image if it doesn't exist +print_info "Checking Docker image..." +if ! docker image inspect cua-dev-image &> /dev/null; then + print_info "Building Docker image (this may take a while)..." + ./scripts/run-docker-dev.sh build +else + print_success "Docker image already exists" +fi + +# Install Lume if needed for local VMs +if [[ "$USE_CLOUD" == "false" && "$COMPUTER_TYPE" == "macos" ]]; then + if ! command -v lume &> /dev/null; then + print_info "Installing Lume CLI..." + curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh | bash + + # Add lume to PATH for this session if it's not already there + if ! command -v lume &> /dev/null; then + export PATH="$PATH:$HOME/.local/bin" + fi + fi + + # Pull the macOS CUA image if not already present + if ! lume ls | grep -q "macos-sequoia-cua"; then + # Check available disk space + IMAGE_SIZE_GB=30 + AVAILABLE_SPACE_KB=$(df -k $HOME | tail -1 | awk '{print $4}') + AVAILABLE_SPACE_GB=$(($AVAILABLE_SPACE_KB / 1024 / 1024)) + + echo "📊 The macOS CUA image will use approximately ${IMAGE_SIZE_GB}GB of disk space." + echo " You currently have ${AVAILABLE_SPACE_GB}GB available on your system." + + # Prompt for confirmation + read -p " Continue? [y]/n: " CONTINUE + CONTINUE=${CONTINUE:-y} + + if [[ $CONTINUE =~ ^[Yy]$ ]]; then + print_info "Pulling macOS CUA image (this may take a while)..." + + # Use caffeinate on macOS to prevent system sleep during the pull + if command -v caffeinate &> /dev/null; then + print_info "Using caffeinate to prevent system sleep during download..." + caffeinate -i lume pull macos-sequoia-cua:latest + else + lume pull macos-sequoia-cua:latest + fi + else + print_error "Installation cancelled." + exit 1 + fi + fi + + # Check if the VM is running + print_info "Checking if the macOS CUA VM is running..." + VM_RUNNING=$(lume ls | grep "macos-sequoia-cua" | grep "running" || echo "") + + if [ -z "$VM_RUNNING" ]; then + print_info "Starting the macOS CUA VM in the background..." + lume run macos-sequoia-cua:latest & + # Wait a moment for the VM to initialize + sleep 5 + print_success "VM started successfully." + else + print_success "macOS CUA VM is already running." + fi +fi + +# Create a convenience script to run the demo +cat > "$DEMO_DIR/start_ui.sh" << EOF +#!/bin/bash +cd "$REPO_DIR" +./scripts/run-docker-dev.sh run agent_ui_examples.py +EOF +chmod +x "$DEMO_DIR/start_ui.sh" + +print_success "Setup complete!" + +if [[ "$USE_CLOUD" == "true" ]]; then + echo "☁️ C/ua Cloud Container setup complete!" +else + echo "🖥️ C/ua Local VM setup complete!" +fi + +echo "📝 Edit $ENV_FILE to update your API keys" +echo "🖥️ Start the playground by running: $DEMO_DIR/start_ui.sh" + +# Start the demo automatically +echo +print_info "Starting the C/ua Computer-Use Agent UI..." +echo "" + +print_success "C/ua Computer-Use Agent UI is now running at http://localhost:7860/" +echo +echo "🌐 Open your browser and go to: http://localhost:7860/" +echo +"$DEMO_DIR/start_ui.sh" diff --git a/scripts/playground.sh b/scripts/playground.sh index 4cdb1ffa..9be712d2 100755 --- a/scripts/playground.sh +++ b/scripts/playground.sh @@ -209,13 +209,6 @@ echo "📦 Updating C/ua packages..." pip install -U pip setuptools wheel Cmake pip install -U cua-computer "cua-agent[all]" -# Install mlx-vlm on Apple Silicon Macs -if [[ $(uname -m) == 'arm64' ]]; then - echo "Installing mlx-vlm for Apple Silicon Macs..." - pip install git+https://github.com/Blaizzy/mlx-vlm.git - # pip install git+https://github.com/ddupont808/mlx-vlm.git@stable/fix/qwen2-position-id -fi - # Create a simple demo script mkdir -p "$DEMO_DIR" diff --git a/scripts/run-docker-dev.sh b/scripts/run-docker-dev.sh index 8f96b355..e4aab8ea 100755 --- a/scripts/run-docker-dev.sh +++ b/scripts/run-docker-dev.sh @@ -24,8 +24,26 @@ IMAGE_NAME="cua-dev-image" CONTAINER_NAME="cua-dev-container" PLATFORM="linux/arm64" +# Detect platform based on architecture +arch=$(uname -m) + +if [[ $arch == x86_64* ]]; then + PLATFORM="linux/amd64" + print_info "X64 Architecture detected, using platform: ${PLATFORM}" +elif [[ $arch == i*86 ]]; then + PLATFORM="linux/386" + print_info "X32 Architecture detected, using platform: ${PLATFORM}" +elif [[ $arch == arm* ]] || [[ $arch == aarch64 ]]; then + PLATFORM="linux/arm64" + print_info "ARM Architecture detected, using platform: ${PLATFORM}" +else + # Fallback to amd64 for unknown architectures + PLATFORM="linux/amd64" + print_info "Unknown architecture ($arch), defaulting to platform: ${PLATFORM}" +fi + # Environment variables -PYTHONPATH="/app/libs/core:/app/libs/computer:/app/libs/agent:/app/libs/som:/app/libs/pylume:/app/libs/computer-server" +PYTHONPATH="/app/libs/python/core:/app/libs/python/computer:/app/libs/python/agent:/app/libs/python/som:/app/libs/python/pylume:/app/libs/python/computer-server:/app/libs/python/mcp-server" # Check if Docker is installed if ! command -v docker &> /dev/null; then @@ -56,6 +74,7 @@ case "$1" in -e PYTHONPATH=${PYTHONPATH} \ -e DISPLAY=${DISPLAY:-:0} \ -e PYLUME_HOST="host.docker.internal" \ + -p 7860:7860 \ ${IMAGE_NAME} bash else # Run the specified example @@ -73,6 +92,7 @@ case "$1" in -e PYTHONPATH=${PYTHONPATH} \ -e DISPLAY=${DISPLAY:-:0} \ -e PYLUME_HOST="host.docker.internal" \ + -p 7860:7860 \ ${IMAGE_NAME} python "/app/examples/$2" fi ;; diff --git a/tests/files.py b/tests/files.py index 388b7656..bcfbb4f5 100644 --- a/tests/files.py +++ b/tests/files.py @@ -28,24 +28,24 @@ for path in pythonpath.split(":"): sys.path.insert(0, path) # Insert at beginning to prioritize print(f"Added to sys.path: {path}") -from computer.computer import Computer +from computer import Computer, VMProviderType @pytest.fixture(scope="session") async def computer(): """Shared Computer instance for all test cases.""" - # # Create a remote Linux computer with C/ua - # computer = Computer( - # os_type="linux", - # api_key=os.getenv("CUA_API_KEY"), - # name=str(os.getenv("CUA_CONTAINER_NAME")), - # provider_type=VMProviderType.CLOUD, - # ) + # Create a remote Linux computer with C/ua + computer = Computer( + os_type="linux", + api_key=os.getenv("CUA_API_KEY"), + name=str(os.getenv("CUA_CONTAINER_NAME")), + provider_type=VMProviderType.CLOUD, + ) # Create a local macOS computer with C/ua # computer = Computer() # Connect to host computer - computer = Computer(use_host_computer_server=True) + # computer = Computer(use_host_computer_server=True) try: await computer.run() @@ -136,6 +136,262 @@ async def test_create_dir(computer): assert exists is True, "Directory should exist" await computer.interface.delete_dir(tmp_dir) + +@pytest.mark.asyncio(loop_scope="session") +async def test_read_bytes_basic(computer): + """Test basic read_bytes functionality.""" + tmp_path = "test_read_bytes.bin" + test_data = b"Hello, World! This is binary data \x00\x01\x02\x03" + + # Write binary data using write_text (assuming it handles bytes) + await computer.interface.write_text(tmp_path, test_data.decode('latin-1')) + + # Read all bytes + read_data = await computer.interface.read_bytes(tmp_path) + assert read_data == test_data, "Binary data should match" + + await computer.interface.delete_file(tmp_path) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_read_bytes_with_offset_and_length(computer): + """Test read_bytes with offset and length parameters.""" + tmp_path = "test_read_bytes_offset.bin" + test_data = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + # Write test data + await computer.interface.write_text(tmp_path, test_data.decode('latin-1')) + + # Test reading with offset only + read_data = await computer.interface.read_bytes(tmp_path, offset=5) + expected = test_data[5:] + assert read_data == expected, f"Data from offset 5 should match. Got: {read_data}, Expected: {expected}" + + # Test reading with offset and length + read_data = await computer.interface.read_bytes(tmp_path, offset=10, length=5) + expected = test_data[10:15] + assert read_data == expected, f"Data from offset 10, length 5 should match. Got: {read_data}, Expected: {expected}" + + # Test reading from beginning with length + read_data = await computer.interface.read_bytes(tmp_path, offset=0, length=10) + expected = test_data[:10] + assert read_data == expected, f"Data from beginning, length 10 should match. Got: {read_data}, Expected: {expected}" + + await computer.interface.delete_file(tmp_path) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_get_file_size(computer): + """Test get_file_size functionality.""" + tmp_path = "test_file_size.txt" + test_content = "A" * 1000 # 1000 bytes + + await computer.interface.write_text(tmp_path, test_content) + + file_size = await computer.interface.get_file_size(tmp_path) + assert file_size == 1000, f"File size should be 1000 bytes, got {file_size}" + + await computer.interface.delete_file(tmp_path) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_read_large_file(computer): + """Test reading a file larger than 10MB to verify chunked reading.""" + tmp_path = "test_large_file.bin" + + # Create a file larger than 10MB (10 * 1024 * 1024 = 10,485,760 bytes) + total_size = 12 * 1024 * 1024 # 12MB + + print(f"Creating large file of {total_size} bytes ({total_size / (1024*1024):.1f}MB)...") + + # Create large file content (this will test the chunked writing functionality) + large_content = b"X" * total_size + + # Write the large file using write_bytes (will automatically use chunked writing) + await computer.interface.write_bytes(tmp_path, large_content) + + # Verify file size + file_size = await computer.interface.get_file_size(tmp_path) + assert file_size == total_size, f"Large file size should be {total_size} bytes, got {file_size}" + + print(f"Large file created successfully: {file_size} bytes") + + # Test reading the entire large file (should use chunked reading) + print("Reading large file...") + read_data = await computer.interface.read_bytes(tmp_path) + assert len(read_data) == total_size, f"Read data size should match file size. Got {len(read_data)}, expected {total_size}" + + # Verify content (should be all 'X' characters) + expected_data = b"X" * total_size + assert read_data == expected_data, "Large file content should be all 'X' characters" + + print("Large file read successfully!") + + # Test reading with offset and length on large file + offset = 5 * 1024 * 1024 # 5MB offset + length = 2 * 1024 * 1024 # 2MB length + read_data = await computer.interface.read_bytes(tmp_path, offset=offset, length=length) + assert len(read_data) == length, f"Partial read size should be {length}, got {len(read_data)}" + assert read_data == b"X" * length, "Partial read content should be all 'X' characters" + + print("Large file partial read successful!") + + # Clean up + await computer.interface.delete_file(tmp_path) + print("Large file test completed successfully!") + +@pytest.mark.asyncio(loop_scope="session") +async def test_read_write_text_with_encoding(computer): + """Test reading and writing text files with different encodings.""" + print("Testing text file operations with different encodings...") + + tmp_path = "test_encoding.txt" + + # Test UTF-8 encoding (default) + utf8_content = "Hello, 世界! 🌍 Ñoño café" + await computer.interface.write_text(tmp_path, utf8_content, encoding='utf-8') + read_utf8 = await computer.interface.read_text(tmp_path, encoding='utf-8') + assert read_utf8 == utf8_content, "UTF-8 content should match" + + # Test ASCII encoding + ascii_content = "Hello, World! Simple ASCII text." + await computer.interface.write_text(tmp_path, ascii_content, encoding='ascii') + read_ascii = await computer.interface.read_text(tmp_path, encoding='ascii') + assert read_ascii == ascii_content, "ASCII content should match" + + # Test Latin-1 encoding + latin1_content = "Café, naïve, résumé" + await computer.interface.write_text(tmp_path, latin1_content, encoding='latin-1') + read_latin1 = await computer.interface.read_text(tmp_path, encoding='latin-1') + assert read_latin1 == latin1_content, "Latin-1 content should match" + + # Clean up + await computer.interface.delete_file(tmp_path) + print("Text encoding test completed successfully!") + +@pytest.mark.asyncio(loop_scope="session") +async def test_write_text_append_mode(computer): + """Test appending text to files.""" + print("Testing text file append mode...") + + tmp_path = "test_append.txt" + + # Write initial content + initial_content = "First line\n" + await computer.interface.write_text(tmp_path, initial_content) + + # Append more content + append_content = "Second line\n" + await computer.interface.write_text(tmp_path, append_content, append=True) + + # Read and verify + final_content = await computer.interface.read_text(tmp_path) + expected_content = initial_content + append_content + assert final_content == expected_content, f"Expected '{expected_content}', got '{final_content}'" + + # Append one more line + third_content = "Third line\n" + await computer.interface.write_text(tmp_path, third_content, append=True) + + # Read and verify final result + final_content = await computer.interface.read_text(tmp_path) + expected_content = initial_content + append_content + third_content + assert final_content == expected_content, f"Expected '{expected_content}', got '{final_content}'" + + # Clean up + await computer.interface.delete_file(tmp_path) + print("Text append test completed successfully!") + +@pytest.mark.asyncio(loop_scope="session") +async def test_large_text_file(computer): + """Test reading and writing large text files (>5MB) to verify chunked operations.""" + print("Testing large text file operations...") + + tmp_path = "test_large_text.txt" + + # Create a large text content (approximately 6MB) + # Each line is about 100 characters, so 60,000 lines ≈ 6MB + line_template = "This is line {:06d} with some additional text to make it longer and reach about 100 chars.\n" + large_content = "" + num_lines = 60000 + + print(f"Generating large text content with {num_lines} lines...") + for i in range(num_lines): + large_content += line_template.format(i) + + content_size_mb = len(large_content.encode('utf-8')) / (1024 * 1024) + print(f"Generated text content size: {content_size_mb:.2f} MB") + + # Write the large text file + print("Writing large text file...") + await computer.interface.write_text(tmp_path, large_content) + + # Read the entire file back + print("Reading large text file...") + read_content = await computer.interface.read_text(tmp_path) + + # Verify content matches + assert read_content == large_content, "Large text file content should match exactly" + + # Test partial reading by reading as bytes and decoding specific portions + print("Testing partial text reading...") + + # Read first 1000 characters worth of bytes + first_1000_chars = large_content[:1000] + first_1000_bytes = first_1000_chars.encode('utf-8') + read_bytes = await computer.interface.read_bytes(tmp_path, offset=0, length=len(first_1000_bytes)) + decoded_partial = read_bytes.decode('utf-8') + assert decoded_partial == first_1000_chars, "Partial text reading should match" + + # Test appending to large file + print("Testing append to large text file...") + append_text = "\n--- APPENDED CONTENT ---\nThis content was appended to the large file.\n" + await computer.interface.write_text(tmp_path, append_text, append=True) + + # Read and verify appended content + final_content = await computer.interface.read_text(tmp_path) + expected_final = large_content + append_text + assert final_content == expected_final, "Appended large text file should match" + + # Clean up + await computer.interface.delete_file(tmp_path) + print("Large text file test completed successfully!") + +@pytest.mark.asyncio(loop_scope="session") +async def test_text_file_edge_cases(computer): + """Test edge cases for text file operations.""" + print("Testing text file edge cases...") + + tmp_path = "test_edge_cases.txt" + + # Test empty file + empty_content = "" + await computer.interface.write_text(tmp_path, empty_content) + read_empty = await computer.interface.read_text(tmp_path) + assert read_empty == empty_content, "Empty file should return empty string" + + # Test file with only whitespace + whitespace_content = " \n\t\r\n \n" + await computer.interface.write_text(tmp_path, whitespace_content) + read_whitespace = await computer.interface.read_text(tmp_path) + assert read_whitespace == whitespace_content, "Whitespace content should be preserved" + + # Test file with special characters and newlines + special_content = "Line 1\nLine 2\r\nLine 3\tTabbed\nSpecial: !@#$%^&*()\n" + await computer.interface.write_text(tmp_path, special_content) + read_special = await computer.interface.read_text(tmp_path) + assert read_special == special_content, "Special characters should be preserved" + + # Test very long single line (no newlines) + long_line = "A" * 10000 # 10KB single line + await computer.interface.write_text(tmp_path, long_line) + read_long_line = await computer.interface.read_text(tmp_path) + assert read_long_line == long_line, "Long single line should be preserved" + + # Clean up + await computer.interface.delete_file(tmp_path) + print("Text file edge cases test completed successfully!") + if __name__ == "__main__": # Run tests directly pytest.main([__file__, "-v"]) diff --git a/tests/shell_bash.py b/tests/shell_bash.py new file mode 100644 index 00000000..af34ff0e --- /dev/null +++ b/tests/shell_bash.py @@ -0,0 +1,86 @@ +""" +Shell Command Tests (Bash) +Tests for the run_command method of the Computer interface using bash commands. +Required environment variables: +- CUA_API_KEY: API key for C/ua cloud provider +- CUA_CONTAINER_NAME: Name of the container to use +""" + +import os +import asyncio +import pytest +from pathlib import Path +import sys +import traceback + +# Load environment variables from .env file +project_root = Path(__file__).parent.parent +env_file = project_root / ".env" +print(f"Loading environment from: {env_file}") +from dotenv import load_dotenv + +load_dotenv(env_file) + +# Add paths to sys.path if needed +pythonpath = os.environ.get("PYTHONPATH", "") +for path in pythonpath.split(":"): + if path and path not in sys.path: + sys.path.insert(0, path) # Insert at beginning to prioritize + print(f"Added to sys.path: {path}") + +from computer import Computer, VMProviderType + +@pytest.fixture(scope="session") +async def computer(): + """Shared Computer instance for all test cases.""" + # Create a remote Linux computer with C/ua + computer = Computer( + os_type="linux", + api_key=os.getenv("CUA_API_KEY"), + name=str(os.getenv("CUA_CONTAINER_NAME")), + provider_type=VMProviderType.CLOUD, + ) + + try: + await computer.run() + yield computer + finally: + await computer.disconnect() + + +# Sample test cases +@pytest.mark.asyncio(loop_scope="session") +async def test_bash_echo_command(computer): + """Test basic echo command with bash.""" + result = await computer.interface.run_command("echo 'Hello World'") + + assert result.stdout.strip() == "Hello World" + assert result.stderr == "" + assert result.returncode == 0 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_bash_ls_command(computer): + """Test ls command to list directory contents.""" + result = await computer.interface.run_command("ls -la /tmp") + + assert result.returncode == 0 + assert result.stderr == "" + assert "total" in result.stdout # ls -la typically starts with "total" + assert "." in result.stdout # Current directory entry + assert ".." in result.stdout # Parent directory entry + + +@pytest.mark.asyncio(loop_scope="session") +async def test_bash_command_with_error(computer): + """Test command that produces an error.""" + result = await computer.interface.run_command("ls /nonexistent_directory_12345") + + assert result.returncode != 0 + assert result.stdout == "" + assert "No such file or directory" in result.stderr or "cannot access" in result.stderr + + +if __name__ == "__main__": + # Run tests directly + pytest.main([__file__, "-v"]) diff --git a/tests/shell_cmd.py b/tests/shell_cmd.py new file mode 100644 index 00000000..a210e453 --- /dev/null +++ b/tests/shell_cmd.py @@ -0,0 +1,87 @@ +""" +Shell Command Tests (CMD) +Tests for the run_command method of the Computer interface using cmd.exe commands. +Required environment variables: +- CUA_API_KEY: API key for C/ua cloud provider +- CUA_CONTAINER_NAME: Name of the container to use +""" + +import os +import asyncio +import pytest +from pathlib import Path +import sys +import traceback + +# Load environment variables from .env file +project_root = Path(__file__).parent.parent +env_file = project_root / ".env" +print(f"Loading environment from: {env_file}") +from dotenv import load_dotenv + +load_dotenv(env_file) + +# Add paths to sys.path if needed +pythonpath = os.environ.get("PYTHONPATH", "") +for path in pythonpath.split(":"): + if path and path not in sys.path: + sys.path.insert(0, path) # Insert at beginning to prioritize + print(f"Added to sys.path: {path}") + +from computer import Computer, VMProviderType + +@pytest.fixture(scope="session") +async def computer(): + """Shared Computer instance for all test cases.""" + # Create a remote Windows computer with C/ua + computer = Computer( + os_type="windows", + api_key=os.getenv("CUA_API_KEY"), + name=str(os.getenv("CUA_CONTAINER_NAME")), + provider_type=VMProviderType.CLOUD, + ) + + try: + await computer.run() + yield computer + finally: + await computer.disconnect() + + +# Sample test cases +@pytest.mark.asyncio(loop_scope="session") +async def test_cmd_echo_command(computer): + """Test basic echo command with cmd.exe.""" + result = await computer.interface.run_command("echo Hello World") + + assert result.stdout.strip() == "Hello World" + assert result.stderr == "" + assert result.returncode == 0 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_cmd_dir_command(computer): + """Test dir command to list directory contents.""" + result = await computer.interface.run_command("dir C:\\") + + assert result.returncode == 0 + assert result.stderr == "" + assert "Directory of C:\\" in result.stdout + assert "bytes" in result.stdout.lower() # dir typically shows file sizes + + +@pytest.mark.asyncio(loop_scope="session") +async def test_cmd_command_with_error(computer): + """Test command that produces an error.""" + result = await computer.interface.run_command("dir C:\\nonexistent_directory_12345") + + assert result.returncode != 0 + assert result.stdout == "" + assert ("File Not Found" in result.stderr or + "cannot find the path" in result.stderr or + "The system cannot find" in result.stderr) + + +if __name__ == "__main__": + # Run tests directly + pytest.main([__file__, "-v"]) diff --git a/tests/venv.py b/tests/venv.py index 7097c2fd..522a4727 100644 --- a/tests/venv.py +++ b/tests/venv.py @@ -29,24 +29,23 @@ for path in pythonpath.split(":"): sys.path.insert(0, path) # Insert at beginning to prioritize print(f"Added to sys.path: {path}") -from computer.computer import Computer -from computer.providers.base import VMProviderType +from computer import Computer, VMProviderType from computer.helpers import sandboxed, set_default_computer @pytest.fixture(scope="session") async def computer(): """Shared Computer instance for all test cases.""" - # # Create a remote Linux computer with C/ua - # computer = Computer( - # os_type="linux", - # api_key=os.getenv("CUA_API_KEY"), - # name=str(os.getenv("CUA_CONTAINER_NAME")), - # provider_type=VMProviderType.CLOUD, - # ) + # Create a remote Linux computer with C/ua + computer = Computer( + os_type="linux", + api_key=os.getenv("CUA_API_KEY"), + name=str(os.getenv("CUA_CONTAINER_NAME")), + provider_type=VMProviderType.CLOUD, + ) - # Create a local macOS computer with C/ua - computer = Computer() + # # Create a local macOS computer with C/ua + # computer = Computer() try: await computer.run() diff --git a/tests/watchdog.py b/tests/watchdog.py new file mode 100644 index 00000000..24b268c2 --- /dev/null +++ b/tests/watchdog.py @@ -0,0 +1,214 @@ +""" +Watchdog Recovery Tests +Tests for the watchdog functionality to ensure server recovery after hanging commands. +Required environment variables: +- CUA_API_KEY: API key for C/ua cloud provider +- CUA_CONTAINER_NAME: Name of the container to use +""" + +import os +import asyncio +import pytest +from pathlib import Path +import sys +import traceback +import time + +# Load environment variables from .env file +project_root = Path(__file__).parent.parent +env_file = project_root / ".env" +print(f"Loading environment from: {env_file}") +from dotenv import load_dotenv + +load_dotenv(env_file) + +# Add paths to sys.path if needed +pythonpath = os.environ.get("PYTHONPATH", "") +for path in pythonpath.split(":"): + if path and path not in sys.path: + sys.path.insert(0, path) # Insert at beginning to prioritize + print(f"Added to sys.path: {path}") + +from computer import Computer, VMProviderType + +@pytest.fixture(scope="session") +async def computer(): + """Shared Computer instance for all test cases.""" + # Create a remote Linux computer with C/ua + computer = Computer( + os_type="linux", + api_key=os.getenv("CUA_API_KEY"), + name=str(os.getenv("CUA_CONTAINER_NAME")), + provider_type=VMProviderType.CLOUD, + ) + + try: + await computer.run() + yield computer + finally: + await computer.disconnect() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_simple_server_ping(computer): + """ + Simple test to verify server connectivity before running watchdog tests. + """ + print("Testing basic server connectivity...") + + try: + result = await computer.interface.run_command("echo 'Server ping test'") + print(f"Ping successful: {result}") + assert result is not None, "Server ping returned None" + print("✅ Server connectivity test passed") + except Exception as e: + print(f"❌ Server ping failed: {e}") + pytest.fail(f"Basic server connectivity test failed: {e}") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_watchdog_recovery_after_hanging_command(computer): + """ + Test that the watchdog can recover the server after a hanging command. + + This test runs two concurrent tasks: + 1. A long-running command that hangs the server (sleep 300 = 5 minutes) + 2. Periodic ping commands every 30 seconds to test server responsiveness + + The watchdog should detect the unresponsive server and restart it. + """ + print("Starting watchdog recovery test...") + + async def hanging_command(): + """Execute a command that sleeps forever to hang the server.""" + try: + print("Starting hanging command (sleep infinity)...") + # Use a very long sleep that should never complete naturally + result = await computer.interface.run_command("sleep 999999") + print(f"Hanging command completed unexpectedly: {result}") + return True # Should never reach here if watchdog works + except Exception as e: + print(f"Hanging command interrupted (expected if watchdog restarts): {e}") + return None # Expected result when watchdog kills the process + + async def ping_server(): + """Ping the server every 30 seconds with echo commands.""" + ping_count = 0 + successful_pings = 0 + failed_pings = 0 + + try: + # Run pings for up to 4 minutes (8 pings at 30-second intervals) + for i in range(8): + try: + ping_count += 1 + print(f"Ping #{ping_count}: Sending echo command...") + + start_time = time.time() + result = await asyncio.wait_for( + computer.interface.run_command(f"echo 'Ping {ping_count} at {int(start_time)}'"), + timeout=10.0 # 10 second timeout for each ping + ) + end_time = time.time() + + print(f"Ping #{ping_count} successful in {end_time - start_time:.2f}s: {result}") + successful_pings += 1 + + except asyncio.TimeoutError: + print(f"Ping #{ping_count} timed out (server may be unresponsive)") + failed_pings += 1 + except Exception as e: + print(f"Ping #{ping_count} failed with exception: {e}") + failed_pings += 1 + + # Wait 30 seconds before next ping + if i < 7: # Don't wait after the last ping + print(f"Waiting 30 seconds before next ping...") + await asyncio.sleep(30) + + print(f"Ping summary: {successful_pings} successful, {failed_pings} failed") + return successful_pings, failed_pings + + except Exception as e: + print(f"Ping server function failed with critical error: {e}") + traceback.print_exc() + return successful_pings, failed_pings + + # Run both tasks concurrently + print("Starting concurrent tasks: hanging command and ping monitoring...") + + try: + # Use asyncio.gather to run both tasks concurrently + hanging_task = asyncio.create_task(hanging_command()) + ping_task = asyncio.create_task(ping_server()) + + # Wait for both tasks to complete or timeout after 5 minutes + done, pending = await asyncio.wait( + [hanging_task, ping_task], + timeout=300, # 5 minute timeout + return_when=asyncio.ALL_COMPLETED + ) + + # Cancel any pending tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Get results from completed tasks + ping_result = None + hanging_result = None + + if ping_task in done: + try: + ping_result = await ping_task + print(f"Ping task completed with result: {ping_result}") + except Exception as e: + print(f"Error getting ping task result: {e}") + traceback.print_exc() + + if hanging_task in done: + try: + hanging_result = await hanging_task + print(f"Hanging task completed with result: {hanging_result}") + except Exception as e: + print(f"Error getting hanging task result: {e}") + traceback.print_exc() + + # Analyze results + if ping_result: + successful_pings, failed_pings = ping_result + + # Test passes if we had some successful pings, indicating recovery + assert successful_pings > 0, f"No successful pings detected. Server may not have recovered." + + # Check if hanging command was killed (indicating watchdog restart) + if hanging_result is None: + print("✅ SUCCESS: Hanging command was killed - watchdog restart detected") + elif hanging_result is True: + print("⚠️ WARNING: Hanging command completed naturally - watchdog may not have restarted") + + # If we had failures followed by successes, that indicates watchdog recovery + if failed_pings > 0 and successful_pings > 0: + print("✅ SUCCESS: Watchdog recovery detected - server became unresponsive then recovered") + # Additional check: hanging command should be None if watchdog worked + assert hanging_result is None, "Expected hanging command to be killed by watchdog restart" + elif successful_pings > 0 and failed_pings == 0: + print("✅ SUCCESS: Server remained responsive throughout test") + + print(f"Test completed: {successful_pings} successful pings, {failed_pings} failed pings") + print(f"Hanging command result: {hanging_result} (None = killed by watchdog, True = completed naturally)") + else: + pytest.fail("Ping task did not complete - unable to assess server recovery") + + except Exception as e: + print(f"Test failed with exception: {e}") + traceback.print_exc() + pytest.fail(f"Watchdog recovery test failed: {e}") + + +if __name__ == "__main__": + # Run tests directly + pytest.main([__file__, "-v"])