From fca003ecb931c9215dbad365bb7cd878014eb1dc Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 23 May 2026 17:25:56 +0000 Subject: [PATCH 1/5] fix(cua-driver-rs)(uninstall): converge uninstall.{ps1,sh} + UAC self-elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together let -AutoStart installs roll back cleanly: 1. uninstall.ps1 self-elevates via UAC when it detects an -AutoStart install. The cua-driver-serve task is registered at RunLevel=Highest (autostart.rs:127, since 2026-05-21 for UWP/AppContainer support), so the daemon spawned by it runs at High IL and a non-elevated process — even the same user who installed it — could neither terminate the daemon nor delete the task. Detection: schtasks /Query OR a running cua-driver.exe. Re-exec via Start-Process -Verb RunAs; supports both `-File` invocation and `irm | iex` (materialises body to tempfile). 2. uninstall.sh inlines what _uninstall-rust.sh used to do — single canonical script per shell, mirrors uninstall.ps1's shape. Same --experimental-rust / --backend=rust / --backend=swift flag set, same non-macOS Rust auto-select. _uninstall-rust.sh deleted. 3. cd-rust-cua-driver.yml drops the cp _uninstall-rust.sh release-upload line + updated comment. installation.mdx docs page reflects the new one-file-per-shell shape and the .ps1 self-elevation behaviour. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/cd-rust-cua-driver.yml | 13 +- .../guide/getting-started/installation.mdx | 8 +- libs/cua-driver/scripts/_uninstall-rust.sh | 353 -------------- libs/cua-driver/scripts/uninstall.ps1 | 75 ++- libs/cua-driver/scripts/uninstall.sh | 461 ++++++++++++++---- 5 files changed, 460 insertions(+), 450 deletions(-) delete mode 100755 libs/cua-driver/scripts/_uninstall-rust.sh diff --git a/.github/workflows/cd-rust-cua-driver.yml b/.github/workflows/cd-rust-cua-driver.yml index fd86dde53b..47d159dd04 100644 --- a/.github/workflows/cd-rust-cua-driver.yml +++ b/.github/workflows/cd-rust-cua-driver.yml @@ -446,14 +446,15 @@ jobs: cp libs/cua-driver/scripts/install.ps1 release-upload/install.ps1 cp libs/cua-driver/scripts/_install-rust.sh release-upload/_install-rust.sh - # Uninstaller scripts — mirror the install side. The canonical - # uninstall.sh dispatches to the Swift uninstall path by default - # and to _uninstall-rust.sh on --experimental-rust / non-macOS - # auto-detect. uninstall.ps1 is the canonical Windows - # uninstaller (cua-driver-rs only — no Swift Windows build). + # Uninstaller scripts — single canonical file per shell (no + # `_uninstall-rust.sh` helper). uninstall.sh handles both Swift + # (macOS default) and Rust (Linux auto-select, macOS opt-in via + # --experimental-rust / --backend=rust) inline. uninstall.ps1 + # is the Windows uninstaller (cua-driver-rs only — no Swift + # Windows build) and self-elevates via UAC when an -AutoStart + # install (RunLevel=Highest task) needs to be torn down. cp libs/cua-driver/scripts/uninstall.sh release-upload/uninstall.sh cp libs/cua-driver/scripts/uninstall.ps1 release-upload/uninstall.ps1 - cp libs/cua-driver/scripts/_uninstall-rust.sh release-upload/_uninstall-rust.sh # Skill pack — single platform-agnostic tarball fetched by # `cua-driver skills install`. The .md files are identical across diff --git a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx index 5e87086e59..33cabec9e3 100644 --- a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx +++ b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx @@ -607,7 +607,9 @@ One canonical uninstall URL per platform mirrors the install side. Flag handling | Linux | Rust port (auto-detected — Swift binary is macOS-only) | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/uninstall.sh)"` | | Windows | Rust port | `irm https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/uninstall.ps1 \| iex` | -`uninstall.sh` parses the same `--experimental-rust` / `--backend=rust` / `--backend=swift` flag set as `install.sh` and delegates to a colocated private helper, `_uninstall-rust.sh` (under `libs/cua-driver/scripts/`), when the Rust backend is selected. On Linux (and any non-macOS `bash` host) it auto-detects and runs the Rust uninstall without any flag. +`uninstall.sh` parses the same `--experimental-rust` / `--backend=rust` / `--backend=swift` flag set as `install.sh` and handles both backends inline — single file, no `_uninstall-rust.sh` helper (mirrors the one-file-per-shell shape of `uninstall.ps1`). On Linux (and any non-macOS `bash` host) it auto-detects and runs the Rust uninstall without any flag. + +`uninstall.ps1` self-elevates via UAC when it detects an `-AutoStart` install — the `cua-driver-serve` Scheduled Task is registered at `RunLevel=Highest` (see [Autostart](../autostart)), so the daemon spawned by it runs at High IL and a non-elevated process (even the same user that installed it) can't terminate it or delete the task. The script detects either condition upfront and re-spawns itself elevated; the in-place path runs without UAC when no autostart artifacts are present. **What each uninstall removes** @@ -616,10 +618,10 @@ One canonical uninstall URL per platform mirrors the install side. Flag handling | Swift (macOS) | `~/.local/bin/cua-driver`, `/usr/local/bin/cua-driver` (legacy) | `/Applications/CuaDriver.app`, `~/.cua-driver`, `~/Library/Application Support/Cua Driver`, `~/Library/Caches/cua-driver` | `~/Library/LaunchAgents/com.trycua.cua_driver_updater.plist` (legacy ≤ 0.0.5) | `cua-driver` symlinks under `~/.claude/skills`, `~/.agents/skills`, `~/.openclaw/skills`, `~/.config/opencode/skills` | Scrubbed from `~/.claude.json` | | Rust — macOS | `~/.local/bin/cua-driver` *(only when it resolves into `CuaDriver.app` and `~/.cua-driver-rs/` exists)* | `/Applications/CuaDriver.app` (current), `/Applications/CuaDriverRs.app` (legacy, pre-rename), `~/.cua-driver-rs/` (entire tree) | `~/Library/LaunchAgents/com.trycua.cua-driver-rs.plist` | `cua-driver-rs` symlinks under the same agent dirs | Scrubbed from `~/.claude.json` | | Rust — Linux | `~/.local/bin/cua-driver` *(when it resolves into `~/.cua-driver-rs/`)* | `~/.cua-driver-rs/` (entire tree) | `~/.config/systemd/user/cua-driver-rs.service` (stop + disable + remove) | Same as above | Scrubbed from `~/.claude.json` | -| Rust — Windows | `%LOCALAPPDATA%\Programs\trycua\cua-driver-rs\bin` *(directory junction)* | `%USERPROFILE%\.cua-driver-rs\` (entire tree, including `packages\current` junction) | Scheduled Task `cua-driver-serve` (`schtasks /Delete`) | Junctions under `%USERPROFILE%\.claude\skills`, `.agents\skills`, `.openclaw\skills`, `%APPDATA%\opencode\skills` | Not auto-edited — closing message prints `claude mcp remove cua-driver-rs` | +| Rust — Windows | `%LOCALAPPDATA%\Programs\Cua\cua-driver\bin` *(directory junction; legacy `Programs\trycua\cua-driver-rs\bin` also swept)* | `%USERPROFILE%\.cua-driver\` (entire tree, including `packages\current` junction; legacy `~\.cua-driver-rs\` also swept) | Scheduled Task `cua-driver-serve` (`schtasks /Delete`; needs UAC — script self-elevates) | Junctions under `%USERPROFILE%\.claude\skills`, `.agents\skills`, `.openclaw\skills`, `%APPDATA%\opencode\skills` | Not auto-edited — closing message prints `claude mcp remove cua-driver-rs` | -**Safety invariants** — every uninstall script (`uninstall.sh`, `_uninstall-rust.sh`, `uninstall.ps1`) refuses to clobber a real directory at a path where the matching installer could have only created a symlink / junction (Linux/macOS use `[[ -L ]]`; Windows checks `IO_REPARSE_TAG_MOUNT_POINT`). A user who hand-managed any of these dirs keeps theirs untouched. Re-running an uninstall on an already-clean system prints `nothing to remove` per item — never errors. +**Safety invariants** — every uninstall script (`uninstall.sh`, `uninstall.ps1`) refuses to clobber a real directory at a path where the matching installer could have only created a symlink / junction (Linux/macOS use `[[ -L ]]`; Windows checks `IO_REPARSE_TAG_MOUNT_POINT`). A user who hand-managed any of these dirs keeps theirs untouched. Re-running an uninstall on an already-clean system prints `nothing to remove` per item — never errors. diff --git a/libs/cua-driver/scripts/_uninstall-rust.sh b/libs/cua-driver/scripts/_uninstall-rust.sh deleted file mode 100755 index 037c6dc0fd..0000000000 --- a/libs/cua-driver/scripts/_uninstall-rust.sh +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# _uninstall-rust.sh — private helper invoked by libs/cua-driver/scripts/uninstall.sh -# (the canonical user-facing uninstaller) when --backend=rust is selected or -# the script auto-detects a non-macOS host. Not intended for direct -# invocation; user-facing one-liners always go through the parent -# uninstall.sh, which forwards args + handles backend selection. -# -# Mirrors _install-rust.sh's on-disk knowledge — removes everything the -# install script laid down: -# -# Linux: -# - ~/.local/bin/cua-driver symlink (only when it resolves to a -# cua-driver-rs path — a Swift-driver symlink is left in place) -# - ~/.cua-driver-rs/ (entire package home: telemetry id, install -# marker, versioned releases, current symlink, lockfile) -# - ~/.config/systemd/user/cua-driver-rs.service (if --autostart was -# used via install-local.sh — stop + disable + remove) -# - Skill symlinks under ~/.claude/skills/cua-driver-rs, ~/.agents/ -# skills/cua-driver-rs, ~/.openclaw/skills/cua-driver-rs, -# ~/.config/opencode/skills/cua-driver-rs -# -# macOS: -# - /Applications/CuaDriver.app bundle (and legacy CuaDriverRs.app if present) -# - ~/.local/bin/cua-driver symlink (only when it resolves into -# /Applications/CuaDriver.app — see note on the bundle-id share below) -# - ~/.cua-driver-rs/ (entire package home) -# - ~/Library/LaunchAgents/com.trycua.cua-driver-rs.plist (if -# --autostart was used via install-local.sh — unload + remove) -# - Skill symlinks under ~/.claude/skills/cua-driver-rs, etc. -# -# Also scrubs Claude MCP registrations in ~/.claude.json that point at -# the cua-driver-rs binary. -# -# Does NOT revoke TCC grants on macOS — same conservative stance as the -# Swift uninstall.sh. The closing message points at the tccutil commands -# for a clean re-install flow. -set -euo pipefail - -USER_BIN_LINK="$HOME/.local/bin/cua-driver" -# Canonical bundle path (post-rename — shares bundle id `com.trycua.driver` -# with the Swift driver). The Rust install replaces Swift here; both -# the Rust and Swift uninstallers target this path. -APP_BUNDLE="/Applications/CuaDriver.app" -# Legacy bundle path from earlier Rust releases that coexisted with -# Swift under a separate name. Cleaned up if found. -LEGACY_APP_BUNDLE="/Applications/CuaDriverRs.app" -HOME_DIR="${CUA_DRIVER_RS_HOME:-$HOME/.cua-driver-rs}" -LAUNCHAGENT_PLIST="$HOME/Library/LaunchAgents/com.trycua.cua-driver-rs.plist" -SYSTEMD_USER_UNIT="$HOME/.config/systemd/user/cua-driver-rs.service" -SKILL_PACK_NAME="cua-driver-rs" - -OS="$(uname -s 2>/dev/null || echo unknown)" - -log() { printf '==> %s\n' "$*"; } - -# Resolve a symlink target to an absolute path. realpath -e fails when the -# target is missing — we want to inspect dangling symlinks too (a leftover -# from a half-removed install should still be cleaned up here), so fall -# back to readlink + manual normalize when realpath errors out. -resolve_link() { - local link="$1" - if [[ ! -L "$link" ]]; then - printf ''; return 0 - fi - local target - if target="$(realpath "$link" 2>/dev/null)"; then - printf '%s' "$target" - return 0 - fi - # Dangling symlink — readlink still returns the stored target. Make - # it absolute by joining with the link's dirname for non-absolute - # stored values. - target="$(readlink "$link" 2>/dev/null || true)" - case "$target" in - /*) printf '%s' "$target" ;; - *) printf '%s/%s' "$(cd -- "$(dirname -- "$link")" && pwd)" "$target" ;; - esac -} - -# --- CLI symlink --------------------------------------------------------- -# -# Only remove ~/.local/bin/cua-driver when it resolves into a cua-driver-rs -# install. Post-rename, the Rust install lives at /Applications/CuaDriver.app -# — the SAME path the Swift driver uses, with the same bundle id -# `com.trycua.driver`. Path-based detection alone can't distinguish -# them. We rely on the presence of `$HOME_DIR` (~/.cua-driver-rs/) — the -# Rust-specific state dir — as the marker that this is a Rust install. -# Pre-rename installs at /Applications/CuaDriverRs.app are still cleaned -# up unambiguously by path. -if [[ -L "$USER_BIN_LINK" ]]; then - RESOLVED="$(resolve_link "$USER_BIN_LINK")" - case "$RESOLVED" in - *"CuaDriverRs.app"*|*"/Applications/CuaDriver.app"*|*"$HOME_DIR"*|*".cua-driver-rs"*) - rm -f "$USER_BIN_LINK" - log "removed $USER_BIN_LINK -> $RESOLVED" - ;; - *) - log "$USER_BIN_LINK resolves to $RESOLVED (not a cua-driver-rs path; skipping)" - ;; - esac -elif [[ -e "$USER_BIN_LINK" ]]; then - log "$USER_BIN_LINK exists but is not a symlink (skipping; refusing to clobber a real file)" -else - log "no CLI symlink at $USER_BIN_LINK (skipping)" -fi - -# --- Autostart (Linux systemd --user) ----------------------------------- -# -# install-local.sh --autostart registers ~/.config/systemd/user/ -# cua-driver-rs.service. Stop + disable + remove if present so the -# daemon doesn't come back at the next logon. systemctl --user no-ops -# gracefully on a non-systemd host (returns non-zero, which we swallow). -if [[ "$OS" == "Linux" && -f "$SYSTEMD_USER_UNIT" ]]; then - if command -v systemctl >/dev/null 2>&1; then - systemctl --user stop cua-driver-rs.service 2>/dev/null || true - systemctl --user disable cua-driver-rs.service 2>/dev/null || true - log "stopped + disabled systemd --user unit cua-driver-rs.service" - fi - rm -f "$SYSTEMD_USER_UNIT" - log "removed $SYSTEMD_USER_UNIT" - if command -v systemctl >/dev/null 2>&1; then - systemctl --user daemon-reload 2>/dev/null || true - fi -elif [[ "$OS" == "Linux" ]]; then - log "no systemd --user unit at $SYSTEMD_USER_UNIT (skipping)" -fi - -# --- Autostart (macOS LaunchAgent) -------------------------------------- -# -# install-local.sh --autostart on macOS registers -# ~/Library/LaunchAgents/com.trycua.cua-driver-rs.plist. Unload (so the -# running daemon stops) + remove the plist. -if [[ "$OS" == "Darwin" && -f "$LAUNCHAGENT_PLIST" ]]; then - launchctl unload "$LAUNCHAGENT_PLIST" 2>/dev/null || true - rm -f "$LAUNCHAGENT_PLIST" - log "removed LaunchAgent $LAUNCHAGENT_PLIST" -elif [[ "$OS" == "Darwin" ]]; then - log "no LaunchAgent at $LAUNCHAGENT_PLIST (skipping)" -fi - -# --- .app bundle (macOS only) ------------------------------------------- -# -# Removes /Applications/CuaDriver.app (current canonical Rust path, -# shared with the Swift driver via bundle id `com.trycua.driver`) AND -# /Applications/CuaDriverRs.app (legacy, pre-rename releases). -# Removing the shared `.app` path is correct here: a user who runs -# `uninstall.sh --experimental-rust` wants the binary on disk gone; -# if they previously had the Swift binary at the same path, they've -# already overwritten it with Rust on install. Re-installing the -# Swift driver afterward is a re-run of the canonical `install.sh`. -if [[ "$OS" == "Darwin" ]]; then - for bundle_path in "$APP_BUNDLE" "$LEGACY_APP_BUNDLE"; do - if [[ -d "$bundle_path" ]]; then - SUDO="" - if [[ ! -w "$(dirname "$bundle_path")" ]]; then - SUDO="sudo" - fi - $SUDO rm -rf "$bundle_path" - log "removed $bundle_path" - else - log "no app bundle at $bundle_path (skipping)" - fi - done -fi - -# --- Package home ------------------------------------------------------- -# -# Everything under $CUA_DRIVER_RS_HOME (default ~/.cua-driver-rs): -# telemetry id, install marker, versioned releases, current symlink, -# lockfile, local skill copy, version_check.json cache. -if [[ -d "$HOME_DIR" ]]; then - rm -rf "$HOME_DIR" - log "removed $HOME_DIR" -else - log "no package home at $HOME_DIR (skipping)" -fi - -# --- Agent skill symlinks ----------------------------------------------- -# -# Only remove when the link is a symlink — never clobber a real -# directory (a dev user with a hand-managed skills dir is safe). Note -# we don't check the target here because `cua-driver skills install` -# writes platform-dependent targets (the local copy under $HOME_DIR/ -# skills/cua-driver-rs/), and on Windows the `/skills` shape -# is different. The [[ -L ]] check is the load-bearing safety bar. -for SKILL_LINK in \ - "$HOME/.claude/skills/$SKILL_PACK_NAME" \ - "$HOME/.agents/skills/$SKILL_PACK_NAME" \ - "$HOME/.openclaw/skills/$SKILL_PACK_NAME" \ - "$HOME/.config/opencode/skills/$SKILL_PACK_NAME"; do - if [[ -L "$SKILL_LINK" ]]; then - rm -f "$SKILL_LINK" - log "removed skill symlink $SKILL_LINK" - elif [[ -d "$SKILL_LINK" ]]; then - log "$SKILL_LINK is a real directory, not a symlink (skipping)" - else - log "no skill symlink at $SKILL_LINK (skipping)" - fi -done - -# --- Claude Code MCP registrations -------------------------------------- -# -# Same scrub shape as the Swift uninstall.sh, keyed on the cua-driver-rs -# binary name + the per-platform install paths. Unrelated MCP servers -# are left alone. -CLAUDE_JSON="$HOME/.claude.json" -if [[ -f "$CLAUDE_JSON" ]] && command -v python3 >/dev/null 2>&1; then - PY_OUTPUT="$( - CLAUDE_JSON="$CLAUDE_JSON" HOME_DIR="$HOME_DIR" python3 <<'PY' -import json -import os -import shutil -import sys -import tempfile -import time - -path = os.environ["CLAUDE_JSON"] -home_dir = os.environ.get("HOME_DIR", "") - -try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) -except Exception as exc: - print(f"could not read Claude config {path}: {exc}", file=sys.stderr) - raise SystemExit(0) - -removed = [] - -def text_parts(value): - if isinstance(value, str): - return [value] - if isinstance(value, list): - return [item for item in value if isinstance(item, str)] - return [] - -def invokes_cua_driver_rs(server): - if not isinstance(server, dict): - return False - parts = [] - parts.extend(text_parts(server.get("command"))) - parts.extend(text_parts(server.get("args"))) - joined = " ".join(parts) - # Match the Rust-port-specific anchors: bundle name, package home, - # explicit ".cua-driver-rs" segment. Plain "cua-driver" alone is - # ambiguous (the Swift binary uses the same filename), so we key on - # the Rust-specific paths here. The user can run the Swift - # uninstall.sh separately if they have both. - if home_dir and home_dir in joined: - return True - return ("CuaDriverRs.app" in joined - or "/Applications/CuaDriver.app" in joined - or ".cua-driver-rs" in joined - or "cua-driver-rs" in joined) - -def should_remove(name, server): - return name in {"cua-driver-rs"} or invokes_cua_driver_rs(server) - -def scrub_servers(servers, scope): - if not isinstance(servers, dict): - return - for name in list(servers.keys()): - if should_remove(name, servers[name]): - del servers[name] - removed.append(f"{scope}:{name}") - -scrub_servers(data.get("mcpServers"), "user") - -projects = data.get("projects") -if isinstance(projects, dict): - for project in projects.values(): - if isinstance(project, dict): - scrub_servers(project.get("mcpServers"), "project") - -if not removed: - raise SystemExit(0) - -backup = f"{path}.bak-cua-driver-rs-uninstall-{int(time.time())}" -shutil.copy2(path, backup) - -directory = os.path.dirname(path) or "." -fd, tmp_path = tempfile.mkstemp( - prefix=".claude.json.", - suffix=".tmp", - dir=directory, - text=True, -) -try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") - os.replace(tmp_path, path) -except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise - -print(f"removed Claude MCP registration(s): {', '.join(removed)}") -print(f"backed up Claude config to {backup}") -PY - )" - if [[ -n "$PY_OUTPUT" ]]; then - while IFS= read -r line; do - log "$line" - done <<< "$PY_OUTPUT" - else - log "no Claude MCP registrations for cua-driver-rs found in $CLAUDE_JSON" - fi -else - log "no Claude config cleanup via python3 (missing $CLAUDE_JSON or python3)" -fi - -# Best-effort CLI cleanup. `claude mcp remove` only touches the active -# project / user scopes — fine to run; it's a no-op when the entries -# were already scrubbed above. -if command -v claude >/dev/null 2>&1; then - for SERVER in cua-driver-rs; do - for SCOPE in local project user; do - if claude mcp remove "$SERVER" -s "$SCOPE" >/dev/null 2>&1; then - log "removed Claude MCP server $SERVER from $SCOPE scope" - fi - done - done -else - log "claude CLI not found (skipping Claude MCP CLI cleanup)" -fi - -# --- Closing message ---------------------------------------------------- - -if [[ "$OS" == "Darwin" ]]; then - cat << 'FINALUNMSG' - -cua-driver-rs uninstalled. - -TCC grants (Accessibility + Screen Recording) remain in System -Settings > Privacy & Security. Reset them explicitly if you want a -clean re-install flow: - - tccutil reset Accessibility com.trycua.driver - tccutil reset ScreenCapture com.trycua.driver - - (Note: `com.trycua.driver` is shared with the Swift cua-driver. - Resetting it clears grants for both backends. If you still use the - Swift driver, skip this step and let macOS keep the grants — the - next Swift launch will re-use them.) -FINALUNMSG -else - cat << 'FINALUNMSG' - -cua-driver-rs uninstalled. -FINALUNMSG -fi diff --git a/libs/cua-driver/scripts/uninstall.ps1 b/libs/cua-driver/scripts/uninstall.ps1 index 9d22ca2dcf..caa45f41b4 100644 --- a/libs/cua-driver/scripts/uninstall.ps1 +++ b/libs/cua-driver/scripts/uninstall.ps1 @@ -1,4 +1,4 @@ -# cua-driver-rs uninstaller (Windows) — removes everything install.ps1 +# cua-driver-rs uninstaller (Windows) — removes everything install.ps1 # laid down: the Scheduled Task autostart entry, running daemon # processes, the directory junctions wiring the visible bin dir back to # a per-version release dir, the entire package home tree, and any skill @@ -51,6 +51,18 @@ # each major delete. The one-liner is interactive by # default so a stray paste doesn't accidentally wipe a # working install. +# +# Elevation: +# `install.ps1 -AutoStart` (and `cua-driver autostart enable`) register +# the `cua-driver-serve` Scheduled Task at RunLevel=Highest — the +# daemon spawned by it then runs at High IL so it can drive UWP / +# AppContainer apps (Calculator, Settings, Photos — see +# autostart.rs:127). Side-effect: a non-elevated process (even the same +# user that installed it) can NOT terminate the daemon or delete the +# task — both fail with Access Denied, and the binary stays locked +# under ~\.cua-driver\... . If we detect either condition at startup +# we self-elevate via UAC; otherwise we run in-place. Mirrors the +# install side's elevation pattern in autostart.rs:215-223. [CmdletBinding()] param( @@ -61,6 +73,65 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" +# ---------- Elevation pre-check ------------------------------------------- + +$AutoStartTask = "cua-driver-serve" + +function Test-IsElevated { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-NeedsElevation { + # Either the autostart task exists (RunLevel=Highest, so deleting it + # needs admin) or a cua-driver.exe is running (its parent was the + # elevated task, so terminating it needs admin too). Detecting either + # upfront lets us self-elevate before we start tearing things down + # — otherwise the non-elevated path silently swallows access-denied + # from schtasks /Delete + Stop-Process and leaves a dangling install. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & schtasks.exe /Query /TN $AutoStartTask 2>$null | Out-Null + $hasTask = ($LASTEXITCODE -eq 0) + } finally { + $ErrorActionPreference = $prevEAP + } + $hasDaemon = @(Get-Process -Name "cua-driver" -ErrorAction SilentlyContinue).Count -gt 0 + return ($hasTask -or $hasDaemon) +} + +if (-not (Test-IsElevated) -and (Test-NeedsElevation)) { + Write-Host "==> cua-driver-rs uninstaller: detected -AutoStart install state" -ForegroundColor Cyan + Write-Host " (the 'cua-driver-serve' task is RunLevel=Highest and/or a daemon is" + Write-Host " running at High IL). Removing them needs admin — triggering UAC prompt." + + # Re-exec self elevated. $MyInvocation.MyCommand.Path is set when invoked + # from a file on disk; empty when piped through `irm ... | iex` (the + # canonical one-liner). For the iex case we materialize the script body + # to a tempfile and re-exec from there — RunAs needs a file path. + $forwarded = @() + if ($Force) { $forwarded += '-Force' } + + $scriptPath = $MyInvocation.MyCommand.Path + if (-not $scriptPath) { + $tmp = Join-Path $env:TEMP ("cua-driver-uninstall-" + [Guid]::NewGuid().ToString('N') + ".ps1") + $body = $MyInvocation.MyCommand.Definition + Set-Content -LiteralPath $tmp -Value $body -Encoding UTF8 + $scriptPath = $tmp + } + + $argList = @('-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', $scriptPath) + $forwarded + try { + $proc = Start-Process -FilePath powershell.exe -ArgumentList $argList -Verb RunAs -PassThru -Wait -ErrorAction Stop + exit $proc.ExitCode + } catch { + Write-Host "error: failed to elevate ($($_.Exception.Message))" -ForegroundColor Red + Write-Host " Re-run this script from an elevated PowerShell instead:" -ForegroundColor Yellow + Write-Host " Right-click PowerShell → Run as Administrator, then re-run the uninstall." -ForegroundColor Yellow + exit 1 + } +} + # ---------- Path resolution (mirrors install.ps1) ------------------------- if ($env:CUA_DRIVER_RS_INSTALL_DIR) { @@ -87,7 +158,7 @@ $LegacyHomeDir = Join-Path $env:USERPROFILE ".cua-driver-rs" $PackagesDir = Join-Path $HomeDir "packages" $CurrentDir = Join-Path $PackagesDir "current" -$AutoStartTask = "cua-driver-serve" +# $AutoStartTask hoisted to the elevation pre-check block above. # Skill junctions — mirrors the AGENTS list in # libs/cua-driver-rs/crates/cua-driver/src/skills.rs (the verb that diff --git a/libs/cua-driver/scripts/uninstall.sh b/libs/cua-driver/scripts/uninstall.sh index e66aa5c46d..d11e472475 100755 --- a/libs/cua-driver/scripts/uninstall.sh +++ b/libs/cua-driver/scripts/uninstall.sh @@ -1,29 +1,54 @@ #!/usr/bin/env bash -# cua-driver uninstaller. Removes everything install.sh laid down — Swift -# driver by default, cua-driver-rs (Rust port) when --experimental-rust / -# --backend=rust is passed (mirrors install.sh's flag set), or auto-detects -# the Rust port on non-macOS hosts (same as install.sh). +# cua-driver uninstaller (Swift driver + Rust port, all platforms in a +# single file). Mirrors uninstall.ps1 on Windows: one canonical script +# per shell, no private `_uninstall-rust.sh` helper. +# +# Behaviour by host + flag: +# macOS + no flag → Swift uninstall (today's default) +# macOS + --backend=swift → Swift uninstall (explicit no-op default) +# macOS + --experimental-rust → Rust port uninstall (Swift binary untouched) +# macOS + --backend=rust → same as --experimental-rust +# Linux/ + no flag → auto-selects Rust uninstall +# other (Swift binary is macOS-only; nothing to remove on Linux) +# Linux/ + --backend=swift → no-op (still allowed for compatibility) # # Swift uninstall removes: -# - ~/.local/bin/cua-driver symlink +# - ~/.local/bin/cua-driver symlink (+ legacy /usr/local/bin/cua-driver) # - /Applications/CuaDriver.app bundle # - ~/.cua-driver/ (telemetry id + install marker) # - ~/Library/Application Support/Cua Driver/ (config.json) # - ~/Library/Caches/cua-driver/ (daemon/cache state) +# - Skill symlinks under ~/.claude/skills/cua-driver, ~/.agents/skills/ +# cua-driver, ~/.openclaw/skills/cua-driver, ~/.config/opencode/ +# skills/cua-driver (only when they point at our app bundle) +# - Claude MCP registrations in ~/.claude.json (cua-driver / cua-computer-use) # -# Rust uninstall (--experimental-rust / --backend=rust / non-macOS) delegates -# to a colocated private helper, _uninstall-rust.sh — see that script for -# the full list of paths it removes. +# Rust uninstall removes: +# Linux: +# - ~/.local/bin/cua-driver symlink (only when it resolves to a +# cua-driver-rs path — a Swift-driver symlink is left in place) +# - ~/.cua-driver-rs/ (entire package home: telemetry id, install +# marker, versioned releases, current symlink, lockfile) +# - ~/.config/systemd/user/cua-driver-rs.service (if --autostart +# was used via install-local.sh — stop + disable + remove) +# - Skill symlinks under ~/.claude/skills/cua-driver-rs, ~/.agents/ +# skills/cua-driver-rs, ~/.openclaw/skills/cua-driver-rs, +# ~/.config/opencode/skills/cua-driver-rs +# macOS: +# - /Applications/CuaDriver.app bundle (+ legacy CuaDriverRs.app) +# - ~/.local/bin/cua-driver symlink (only when it resolves into +# /Applications/CuaDriver.app) +# - ~/.cua-driver-rs/ (entire package home) +# - ~/Library/LaunchAgents/com.trycua.cua-driver-rs.plist (if +# --autostart was used via install-local.sh — unload + remove) +# - Skill symlinks under ~/.claude/skills/cua-driver-rs, etc. # -# Does NOT revoke TCC grants (Accessibility + Screen Recording) on macOS. +# Also scrubs Claude MCP registrations in ~/.claude.json that match +# the active backend. # -# Flags: -# --experimental-rust uninstall the cua-driver-rs (Rust port) backend -# instead of the Swift binary. Delegates to -# libs/cua-driver/scripts/_uninstall-rust.sh. The -# Swift binary (if present) is left untouched. -# Also accepted as --backend=rust. -# --backend=swift explicit no-op default (Swift uninstall). +# Does NOT revoke TCC grants on macOS (Accessibility + Screen Recording). +# The closing message points at the tccutil commands for a clean +# re-install flow. # # Usage: # /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/uninstall.sh)" @@ -32,20 +57,16 @@ # /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/uninstall.sh)" -- --experimental-rust # # # Linux auto-detects and removes the Rust port without any flag. +# +# Env overrides (mirror install side): +# CUA_DRIVER_RS_HOME Rust package home to remove (default ~/.cua-driver-rs) set -euo pipefail -# Rust-backend delegation target. The Rust uninstall logic is a private -# helper script colocated with this one — _uninstall-rust.sh — so that -# this directory holds the single user-facing uninstall.sh per platform. -# `--experimental-rust` below either execs the on-disk helper (dev / -# checked-out-tree case) or curls this URL and pipes it to bash -# (`curl ... | bash` uninstall case). Mirrors install.sh's pattern. -RUST_UNINSTALLER_URL="https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/_uninstall-rust.sh" - -# Lightweight flag parsing — same two-pass shape as install.sh so the -# argv shapes stay bit-compatible across install/uninstall and a future -# Rust-only flag added to _uninstall-rust.sh flows through here without -# edits to this file. +# ---------------------------------------------------------------------- +# Flag parsing — same two-pass shape as install.sh so the argv shapes +# stay bit-compatible across install/uninstall and a future Rust-only +# flag flows through without edits. +# ---------------------------------------------------------------------- USE_RUST_BACKEND=0 FORWARDED_ARGS=() PASSTHROUGH=0 @@ -66,71 +87,341 @@ while [[ $# -gt 0 ]]; do esac done -# --- Auto-delegate to Rust on non-macOS --------------------------------- -# -# The Swift binary is macOS-only — there's nothing for the Swift uninstall -# path to remove on Linux. Auto-set USE_RUST_BACKEND=1 so a single -# canonical URL works on every platform: `curl … cua-driver/scripts/ -# uninstall.sh | bash` removes the Swift driver on macOS (today's default) -# and the Rust port on Linux. macOS users who want to remove the Rust -# port still pass `--experimental-rust` / `--backend=rust` explicitly. +# Auto-select Rust on non-macOS. The Swift binary is macOS-only — there's +# nothing for the Swift uninstall path to remove on Linux. Same logic as +# install.sh so a single canonical URL works on every platform. +OS="$(uname -s 2>/dev/null || echo unknown)" AUTO_RUST=0 -if [[ "$USE_RUST_BACKEND" == "0" && "$(uname -s 2>/dev/null)" != "Darwin" ]]; then +if [[ "$USE_RUST_BACKEND" == "0" && "$OS" != "Darwin" ]]; then USE_RUST_BACKEND=1 AUTO_RUST=1 - printf 'note: detected non-macOS host (%s); auto-selecting the cua-driver-rs Rust uninstall.\n' \ - "$(uname -s 2>/dev/null || echo unknown)" >&2 + printf 'note: detected non-macOS host (%s); auto-selecting the cua-driver-rs Rust uninstall.\n' "$OS" >&2 printf ' Pass --backend=swift to force the Swift uninstall path (will be a no-op on non-Darwin).\n' >&2 fi -# --- Optional delegation to the experimental Rust backend --------------- -# -# If the user opted in with --experimental-rust / --backend=rust, hand the -# rest of argv to _uninstall-rust.sh and exit. The Swift uninstall path -# below is never touched in this case, so the Swift binary (if present) -# is left exactly as-is. +if [[ "$USE_RUST_BACKEND" == "1" && "$AUTO_RUST" == "0" ]]; then + printf 'note: uninstalling cua-driver-rs (Rust port). The Swift binary won'"'"'t be touched.\n' >&2 +elif [[ "$USE_RUST_BACKEND" == "1" && "$AUTO_RUST" == "1" ]]; then + printf 'note: uninstalling cua-driver-rs (the Rust port — canonical on non-macOS).\n' >&2 +fi + +# ---------------------------------------------------------------------- +# Shared helpers +# ---------------------------------------------------------------------- +log() { printf '==> %s\n' "$*"; } + +# Resolve a symlink target to an absolute path. realpath -e fails when +# the target is missing — we want to inspect dangling symlinks too (a +# leftover from a half-removed install should still be cleaned up), so +# fall back to readlink + manual normalize when realpath errors out. +resolve_link() { + local link="$1" + if [[ ! -L "$link" ]]; then + printf ''; return 0 + fi + local target + if target="$(realpath "$link" 2>/dev/null)"; then + printf '%s' "$target" + return 0 + fi + target="$(readlink "$link" 2>/dev/null || true)" + case "$target" in + /*) printf '%s' "$target" ;; + *) printf '%s/%s' "$(cd -- "$(dirname -- "$link")" && pwd)" "$target" ;; + esac +} + +# ---------------------------------------------------------------------- +# Rust uninstall branch (Linux + opt-in on macOS). +# ---------------------------------------------------------------------- if [[ "$USE_RUST_BACKEND" == "1" ]]; then - if [[ "$AUTO_RUST" == "0" ]]; then - # Explicit opt-in on macOS. - printf 'note: uninstalling cua-driver-rs (Rust port). The Swift binary won'"'"'t be touched.\n' >&2 + USER_BIN_LINK="$HOME/.local/bin/cua-driver" + # Canonical bundle path (post-rename — shares bundle id + # `com.trycua.driver` with the Swift driver). The Rust install + # replaces Swift here; both uninstallers target this path. + APP_BUNDLE="/Applications/CuaDriver.app" + # Legacy bundle path from earlier Rust releases that coexisted with + # Swift under a separate name. Cleaned up if found. + LEGACY_APP_BUNDLE="/Applications/CuaDriverRs.app" + HOME_DIR="${CUA_DRIVER_RS_HOME:-$HOME/.cua-driver-rs}" + LAUNCHAGENT_PLIST="$HOME/Library/LaunchAgents/com.trycua.cua-driver-rs.plist" + SYSTEMD_USER_UNIT="$HOME/.config/systemd/user/cua-driver-rs.service" + SKILL_PACK_NAME="cua-driver-rs" + + # --- CLI symlink --- + # Only remove ~/.local/bin/cua-driver when it resolves into a + # cua-driver-rs install. Post-rename, the Rust install lives at + # /Applications/CuaDriver.app — the SAME path the Swift driver uses, + # with the same bundle id `com.trycua.driver`. Path-based detection + # alone can't distinguish them. We rely on the presence of $HOME_DIR + # (~/.cua-driver-rs/) — the Rust-specific state dir — as the marker + # that this is a Rust install. Pre-rename installs at + # /Applications/CuaDriverRs.app are still cleaned up unambiguously + # by path. + if [[ -L "$USER_BIN_LINK" ]]; then + RESOLVED="$(resolve_link "$USER_BIN_LINK")" + case "$RESOLVED" in + *"CuaDriverRs.app"*|*"/Applications/CuaDriver.app"*|*"$HOME_DIR"*|*".cua-driver-rs"*) + rm -f "$USER_BIN_LINK" + log "removed $USER_BIN_LINK -> $RESOLVED" + ;; + *) + log "$USER_BIN_LINK resolves to $RESOLVED (not a cua-driver-rs path; skipping)" + ;; + esac + elif [[ -e "$USER_BIN_LINK" ]]; then + log "$USER_BIN_LINK exists but is not a symlink (skipping; refusing to clobber a real file)" else - # Auto-selected on Linux/other — drop the "experimental" qualifier - # (Rust is the canonical install path on every non-macOS platform). - printf 'note: uninstalling cua-driver-rs (the Rust port — canonical on non-macOS).\n' >&2 + log "no CLI symlink at $USER_BIN_LINK (skipping)" fi - # Prefer the on-disk copy when this script is running from a checked-out - # tree (dev / CI). Falls back to curling the canonical URL for the - # `curl ... | bash` uninstall path, where $BASH_SOURCE is unset / -. - LOCAL_RUST_UNINSTALLER="" - if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "-" && -f "${BASH_SOURCE[0]}" ]]; then - SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - CANDIDATE="$SCRIPT_DIR/_uninstall-rust.sh" - if [[ -f "$CANDIDATE" ]]; then - LOCAL_RUST_UNINSTALLER="$CANDIDATE" + # --- Autostart (Linux systemd --user) --- + # install-local.sh --autostart registers + # ~/.config/systemd/user/cua-driver-rs.service. Stop + disable + + # remove if present so the daemon doesn't come back at next logon. + # systemctl --user no-ops gracefully on a non-systemd host. + if [[ "$OS" == "Linux" && -f "$SYSTEMD_USER_UNIT" ]]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl --user stop cua-driver-rs.service 2>/dev/null || true + systemctl --user disable cua-driver-rs.service 2>/dev/null || true + log "stopped + disabled systemd --user unit cua-driver-rs.service" fi + rm -f "$SYSTEMD_USER_UNIT" + log "removed $SYSTEMD_USER_UNIT" + if command -v systemctl >/dev/null 2>&1; then + systemctl --user daemon-reload 2>/dev/null || true + fi + elif [[ "$OS" == "Linux" ]]; then + log "no systemd --user unit at $SYSTEMD_USER_UNIT (skipping)" + fi + + # --- Autostart (macOS LaunchAgent) --- + # install-local.sh --autostart on macOS registers + # ~/Library/LaunchAgents/com.trycua.cua-driver-rs.plist. Unload (so + # the running daemon stops) + remove the plist. + if [[ "$OS" == "Darwin" && -f "$LAUNCHAGENT_PLIST" ]]; then + launchctl unload "$LAUNCHAGENT_PLIST" 2>/dev/null || true + rm -f "$LAUNCHAGENT_PLIST" + log "removed LaunchAgent $LAUNCHAGENT_PLIST" + elif [[ "$OS" == "Darwin" ]]; then + log "no LaunchAgent at $LAUNCHAGENT_PLIST (skipping)" fi - # macOS ships bash 3.2, which trips `set -u` when expanding an empty - # array via "${arr[@]}" — guard with the +alt-value pattern so the - # zero-arg case becomes a literal no-expansion. - if [[ -n "$LOCAL_RUST_UNINSTALLER" ]]; then - exec /bin/bash "$LOCAL_RUST_UNINSTALLER" ${FORWARDED_ARGS[@]+"${FORWARDED_ARGS[@]}"} + # --- .app bundle (macOS only) --- + # Removes /Applications/CuaDriver.app (current canonical Rust path, + # shared with the Swift driver via bundle id `com.trycua.driver`) + # AND /Applications/CuaDriverRs.app (legacy, pre-rename releases). + # Removing the shared `.app` path is correct here: a user who runs + # `uninstall.sh --experimental-rust` wants the binary on disk gone; + # if they previously had the Swift binary at the same path, they've + # already overwritten it with Rust on install. Re-installing the + # Swift driver afterward is a re-run of the canonical install.sh. + if [[ "$OS" == "Darwin" ]]; then + for bundle_path in "$APP_BUNDLE" "$LEGACY_APP_BUNDLE"; do + if [[ -d "$bundle_path" ]]; then + SUDO="" + if [[ ! -w "$(dirname "$bundle_path")" ]]; then + SUDO="sudo" + fi + $SUDO rm -rf "$bundle_path" + log "removed $bundle_path" + else + log "no app bundle at $bundle_path (skipping)" + fi + done + fi + + # --- Package home --- + # Everything under $CUA_DRIVER_RS_HOME (default ~/.cua-driver-rs): + # telemetry id, install marker, versioned releases, current + # symlink, lockfile, local skill copy, version_check.json cache. + if [[ -d "$HOME_DIR" ]]; then + rm -rf "$HOME_DIR" + log "removed $HOME_DIR" else - if ! command -v curl >/dev/null 2>&1; then - printf 'error: curl not found on PATH; cannot fetch %s\n' "$RUST_UNINSTALLER_URL" >&2 - exit 1 + log "no package home at $HOME_DIR (skipping)" + fi + + # --- Agent skill symlinks --- + # Only remove when the link is a symlink — never clobber a real + # directory (a dev user with a hand-managed skills dir is safe). + # We don't check the target here because `cua-driver skills install` + # writes platform-dependent targets (the local copy under $HOME_DIR/ + # skills/cua-driver-rs/). The [[ -L ]] check is the load-bearing + # safety bar. + for SKILL_LINK in \ + "$HOME/.claude/skills/$SKILL_PACK_NAME" \ + "$HOME/.agents/skills/$SKILL_PACK_NAME" \ + "$HOME/.openclaw/skills/$SKILL_PACK_NAME" \ + "$HOME/.config/opencode/skills/$SKILL_PACK_NAME"; do + if [[ -L "$SKILL_LINK" ]]; then + rm -f "$SKILL_LINK" + log "removed skill symlink $SKILL_LINK" + elif [[ -d "$SKILL_LINK" ]]; then + log "$SKILL_LINK is a real directory, not a symlink (skipping)" + else + log "no skill symlink at $SKILL_LINK (skipping)" fi - # `exec` so the Rust uninstaller replaces this process — we don't - # want to fall through to the Swift uninstall path on any error here. - RUST_UNINSTALLER_SCRIPT="$(curl -fsSL "$RUST_UNINSTALLER_URL")" || { - printf 'error: failed to download Rust uninstaller from %s\n' "$RUST_UNINSTALLER_URL" >&2 - exit 1 - } - exec /bin/bash -c "$RUST_UNINSTALLER_SCRIPT" cua-driver-rs-uninstall ${FORWARDED_ARGS[@]+"${FORWARDED_ARGS[@]}"} + done + + # --- Claude Code MCP registrations --- + # Same scrub shape as the Swift branch, keyed on the cua-driver-rs + # binary name + the per-platform install paths. Unrelated MCP + # servers are left alone. + CLAUDE_JSON="$HOME/.claude.json" + if [[ -f "$CLAUDE_JSON" ]] && command -v python3 >/dev/null 2>&1; then + PY_OUTPUT="$( + CLAUDE_JSON="$CLAUDE_JSON" HOME_DIR="$HOME_DIR" python3 <<'PY' +import json +import os +import shutil +import sys +import tempfile +import time + +path = os.environ["CLAUDE_JSON"] +home_dir = os.environ.get("HOME_DIR", "") + +try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +except Exception as exc: + print(f"could not read Claude config {path}: {exc}", file=sys.stderr) + raise SystemExit(0) + +removed = [] + +def text_parts(value): + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [item for item in value if isinstance(item, str)] + return [] + +def invokes_cua_driver_rs(server): + if not isinstance(server, dict): + return False + parts = [] + parts.extend(text_parts(server.get("command"))) + parts.extend(text_parts(server.get("args"))) + joined = " ".join(parts) + # Match the Rust-port-specific anchors: bundle name, package home, + # explicit ".cua-driver-rs" segment. Plain "cua-driver" alone is + # ambiguous (the Swift binary uses the same filename), so we key on + # the Rust-specific paths here. The user can run the Swift + # uninstall.sh path (the same script with no flag on macOS) + # separately if they have both. + if home_dir and home_dir in joined: + return True + return ("CuaDriverRs.app" in joined + or "/Applications/CuaDriver.app" in joined + or ".cua-driver-rs" in joined + or "cua-driver-rs" in joined) + +def should_remove(name, server): + return name in {"cua-driver-rs"} or invokes_cua_driver_rs(server) + +def scrub_servers(servers, scope): + if not isinstance(servers, dict): + return + for name in list(servers.keys()): + if should_remove(name, servers[name]): + del servers[name] + removed.append(f"{scope}:{name}") + +scrub_servers(data.get("mcpServers"), "user") + +projects = data.get("projects") +if isinstance(projects, dict): + for project in projects.values(): + if isinstance(project, dict): + scrub_servers(project.get("mcpServers"), "project") + +if not removed: + raise SystemExit(0) + +backup = f"{path}.bak-cua-driver-rs-uninstall-{int(time.time())}" +shutil.copy2(path, backup) + +directory = os.path.dirname(path) or "." +fd, tmp_path = tempfile.mkstemp( + prefix=".claude.json.", + suffix=".tmp", + dir=directory, + text=True, +) +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + os.replace(tmp_path, path) +except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + +print(f"removed Claude MCP registration(s): {', '.join(removed)}") +print(f"backed up Claude config to {backup}") +PY + )" + if [[ -n "$PY_OUTPUT" ]]; then + while IFS= read -r line; do + log "$line" + done <<< "$PY_OUTPUT" + else + log "no Claude MCP registrations for cua-driver-rs found in $CLAUDE_JSON" + fi + else + log "no Claude config cleanup via python3 (missing $CLAUDE_JSON or python3)" + fi + + # Best-effort CLI cleanup. `claude mcp remove` only touches the + # active project / user scopes — fine to run; it's a no-op when the + # entries were already scrubbed above. + if command -v claude >/dev/null 2>&1; then + for SERVER in cua-driver-rs; do + for SCOPE in local project user; do + if claude mcp remove "$SERVER" -s "$SCOPE" >/dev/null 2>&1; then + log "removed Claude MCP server $SERVER from $SCOPE scope" + fi + done + done + else + log "claude CLI not found (skipping Claude MCP CLI cleanup)" + fi + + # --- Closing message --- + if [[ "$OS" == "Darwin" ]]; then + cat << 'FINALUNMSG' + +cua-driver-rs uninstalled. + +TCC grants (Accessibility + Screen Recording) remain in System +Settings > Privacy & Security. Reset them explicitly if you want a +clean re-install flow: + + tccutil reset Accessibility com.trycua.driver + tccutil reset ScreenCapture com.trycua.driver + + (Note: `com.trycua.driver` is shared with the Swift cua-driver. + Resetting it clears grants for both backends. If you still use the + Swift driver, skip this step and let macOS keep the grants — the + next Swift launch will re-use them.) +FINALUNMSG + else + cat << 'FINALUNMSG' + +cua-driver-rs uninstalled. +FINALUNMSG fi + exit 0 fi +# ---------------------------------------------------------------------- +# Swift uninstall branch (macOS, default). +# ---------------------------------------------------------------------- USER_BIN_LINK="$HOME/.local/bin/cua-driver" SYSTEM_BIN_LINK="/usr/local/bin/cua-driver" APP_BUNDLE="/Applications/CuaDriver.app" @@ -141,8 +432,6 @@ CACHE_DIR="$HOME/Library/Caches/cua-driver" LEGACY_UPDATE_SCRIPT="/usr/local/bin/cua-driver-update" LEGACY_UPDATER_PLIST="$HOME/Library/LaunchAgents/com.trycua.cua_driver_updater.plist" -log() { printf '==> %s\n' "$*"; } - # CLI symlinks. Try the user-bin first (no sudo), then the legacy # /usr/local/bin path (needs sudo on default macOS). for BIN_LINK in "$USER_BIN_LINK" "$SYSTEM_BIN_LINK"; do @@ -203,8 +492,8 @@ else fi # Agent skill symlinks (Claude Code + Codex). Only remove when the link -# is ours — a dev user pointing the symlink at a working copy of the repo -# keeps theirs untouched. +# is ours — a dev user pointing the symlink at a working copy of the +# repo keeps theirs untouched. SKILL_TARGET_EXPECTED="$APP_BUNDLE/Contents/Resources/Skills/cua-driver" for SKILL_LINK in \ "$HOME/.claude/skills/cua-driver" \ @@ -220,11 +509,11 @@ for SKILL_LINK in \ done # Claude Code MCP registrations. `claude mcp remove` only removes from -# the current project / user scopes, while ~/.claude.json can also contain -# stale project entries for other directories. Scrub only registrations -# that are explicitly named cua-driver or whose command points at a -# cua-driver binary, so unrelated servers named "computer-use" are left -# alone. +# the current project / user scopes, while ~/.claude.json can also +# contain stale project entries for other directories. Scrub only +# registrations explicitly named cua-driver or whose command points at +# a cua-driver binary, so unrelated servers named "computer-use" are +# left alone. CLAUDE_JSON="$HOME/.claude.json" if [[ -f "$CLAUDE_JSON" ]] && command -v python3 >/dev/null 2>&1; then PY_OUTPUT="$( @@ -323,8 +612,8 @@ else fi # Best-effort CLI cleanup for the active Claude project. This covers -# .mcp.json / current-working-directory scopes when present and is harmless -# when the entries were already removed above. +# .mcp.json / current-working-directory scopes when present and is +# harmless when the entries were already removed above. if command -v claude >/dev/null 2>&1; then for SERVER in cua-driver cua-computer-use; do for SCOPE in local project user; do From 7bc1cf0b1221ceff7f44aa466b09c5b4b072eb42 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 23 May 2026 17:26:05 +0000 Subject: [PATCH 2/5] fix(cua-driver-rs)(install-local.ps1): UTF-8 read for post-install-hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Next steps:` block rendered with mojibake (`•` for `•`, `â€"` for `—`) because PowerShell 5.1's `Get-Content -Raw` defaults to Windows-1252 when the source file has no BOM. The .txt is UTF-8. Replaced `Get-Content -Raw` with `[System.IO.File]::ReadAllText(..., UTF8)`. Surgical fix at the one Windows reader; the other 3 installers (install.ps1 via Invoke-WebRequest HTTP-charset, _install-rust.sh / install-local.sh via sed on bytes) were never affected, so the .txt stays BOM-free. Co-Authored-By: Claude Opus 4.7 --- libs/cua-driver-rs/scripts/install-local.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver-rs/scripts/install-local.ps1 b/libs/cua-driver-rs/scripts/install-local.ps1 index a7c7640b31..4c66486b4a 100644 --- a/libs/cua-driver-rs/scripts/install-local.ps1 +++ b/libs/cua-driver-rs/scripts/install-local.ps1 @@ -246,7 +246,13 @@ if ($AutoStart) { $installedBinary = Join-Path $VisibleBinDir $BinaryName $HintsTxt = Join-Path $RepoRoot "..\cua-driver\scripts\post-install-hints.txt" if (Test-Path -LiteralPath $HintsTxt) { - $hintsRaw = Get-Content -Raw -LiteralPath $HintsTxt + # Read explicitly as UTF-8. PowerShell 5.1's Get-Content -Raw falls + # back to Windows-1252 when the source file has no BOM, which turns + # the .txt's `•` / `—` into mojibake (`•` / `â€"`) in the rendered + # block. The other 3 installers don't hit this: install.ps1 reads + # the URL response via Invoke-WebRequest (HTTP charset decoding); + # _install-rust.sh / install-local.sh stream raw bytes through sed. + $hintsRaw = [System.IO.File]::ReadAllText($HintsTxt, [System.Text.Encoding]::UTF8) Write-Host ($hintsRaw -replace '\{\{BINARY\}\}', $installedBinary) } else { # Repo layout changed or .txt missing — fall back to one-line From 5986430bc23c475bdd3affdad5d8778bb5f5231d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 23 May 2026 17:27:19 +0000 Subject: [PATCH 3/5] chore(cua-driver-rs): clean up 30 build warnings in -p cua-driver path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo build --release -p cua-driver` previously emitted 28 warnings on platform-windows + 3 on cua-driver. After this commit: zero. Mechanical groups: 1. Dead code (delete): - overlay.rs: unused `Duration` import; 2 stray `use Win32::*` lines (PR #1662 fallout from rotate_toward extraction); 2 unused `mut`; uia/mod.rs lines 173-174 dead writes to `counter`/`total` that are never read after the fallback block. - cli.rs: deleted unused `run_dump_docs` wrapper — `main.rs` only calls `run_dump_docs_with_type`, the wrapper had zero callers. 2. #[allow(dead_code)] on bundle.rs non-macOS/non-unix stubs — they exist for cross-platform API symmetry per the module header. 3. `let _ = ` on 10 Win32 teardown calls (ShowWindow, TranslateMessage, DeleteDC, UpdateLayeredWindow, DeleteObject) that return BOOL / Result. These are fire-and-forget at end-of-scope; the lint wants explicit ignore. 4. Module-scope #![allow(non_upper_case_globals)] in uia/windows_enum.rs — pattern-matches against `UIA_*ControlTypeId` constants from the `windows` crate (we can't rename external symbols). Mirrors the existing `#![allow(...)]` at overlay.rs:12. Cargo.lock picks up the 0.2.7 → 0.2.18 workspace-version-bump that shipped earlier this session. The `TypeTextCharsTool { state: _state }` rename + per-tool description thickening on platform-windows/tools/impl_.rs land in a follow-up commit alongside the agent-guidance refactor. Co-Authored-By: Claude Opus 4.7 --- libs/cua-driver-rs/Cargo.lock | 16 +++++++------- .../crates/cua-driver/src/bundle.rs | 2 ++ .../crates/cua-driver/src/cli.rs | 5 ----- .../crates/platform-windows/src/capture.rs | 8 +++---- .../crates/platform-windows/src/overlay.rs | 21 ++++++++----------- .../crates/platform-windows/src/uia/mod.rs | 5 +++-- .../platform-windows/src/uia/windows_enum.rs | 6 ++++++ 7 files changed, 32 insertions(+), 31 deletions(-) diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock index d50f03a564..bd21ee23c8 100644 --- a/libs/cua-driver-rs/Cargo.lock +++ b/libs/cua-driver-rs/Cargo.lock @@ -242,7 +242,7 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -270,7 +270,7 @@ dependencies = [ [[package]] name = "cua-driver-uia" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "embed-manifest", @@ -285,7 +285,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "image", @@ -428,7 +428,7 @@ checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "focus-monitor-win" -version = "0.2.7" +version = "0.2.18" dependencies = [ "windows", ] @@ -865,7 +865,7 @@ dependencies = [ [[package]] name = "mcp-server" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1151,7 +1151,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-linux" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1171,7 +1171,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", @@ -1201,7 +1201,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.2.7" +version = "0.2.18" dependencies = [ "anyhow", "async-trait", diff --git a/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs index bea611bd63..cf642fe786 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/bundle.rs @@ -47,6 +47,7 @@ pub fn is_executable_inside_cuadriver_app() -> bool { } #[cfg(not(target_os = "macos"))] +#[allow(dead_code)] // Non-macOS stub kept for API symmetry — see module header. pub fn is_executable_inside_cuadriver_app() -> bool { false } @@ -74,6 +75,7 @@ pub fn parent_is_not_launchd() -> bool { } #[cfg(not(unix))] +#[allow(dead_code)] // Non-unix stub kept for API symmetry — see module header. pub fn parent_is_not_launchd() -> bool { // No launchd on non-Unix; the heuristic is macOS-only anyway. // Returning false keeps the caller in-process on unsupported diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index faca5a7183..265a88c818 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -1111,11 +1111,6 @@ pub fn run_update_cmd(apply: bool) { } } -/// `cua-driver dump-docs [--pretty]` — output all MCP tool schemas as JSON. -pub fn run_dump_docs(registry: &ToolRegistry, pretty: bool) { - run_dump_docs_with_type(registry, pretty, "all") -} - /// Output documentation as JSON. `doc_type` is one of: /// - `"mcp"` — only MCP tool docs (`{version, tools: [...]}`) /// - `"cli"` — CLI docs (stub on Rust — Swift extracts via swift-argument-parser diff --git a/libs/cua-driver-rs/crates/platform-windows/src/capture.rs b/libs/cua-driver-rs/crates/platform-windows/src/capture.rs index 2615ad863f..dcd8de39f3 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/capture.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/capture.rs @@ -211,8 +211,8 @@ unsafe fn screenshot_window_bytes_unsafe(hwnd: u64) -> Result> { let ok = GetDIBits(mem_dc, bitmap, 0, h as u32, Some(pixels.as_mut_ptr() as *mut _), &mut bmi, DIB_RGB_COLORS); SelectObject(mem_dc, old_bitmap); - DeleteObject(bitmap); - DeleteDC(mem_dc); + let _ = DeleteObject(bitmap); + let _ = DeleteDC(mem_dc); ReleaseDC(hwnd, screen_dc); if ok == 0 { bail!("GetDIBits returned 0"); } @@ -372,8 +372,8 @@ pub fn screenshot_display_bytes() -> Result> { let mut pixels = vec![0u8; (w * h * 4) as usize]; let ok = GetDIBits(mem_dc, bitmap, 0, h as u32, Some(pixels.as_mut_ptr() as *mut _), &mut bmi, DIB_RGB_COLORS); SelectObject(mem_dc, old_bitmap); - DeleteObject(bitmap); - DeleteDC(mem_dc); + let _ = DeleteObject(bitmap); + let _ = DeleteDC(mem_dc); ReleaseDC(HWND::default(), screen_dc); if ok == 0 { bail!("GetDIBits returned 0"); } encode_bgra_to_png(&pixels, w as u32, h as u32) diff --git a/libs/cua-driver-rs/crates/platform-windows/src/overlay.rs b/libs/cua-driver-rs/crates/platform-windows/src/overlay.rs index 648b81b3a0..4b9b515dcf 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/overlay.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/overlay.rs @@ -12,7 +12,7 @@ #![allow(non_snake_case, non_upper_case_globals)] use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; +use std::time::Instant; use cursor_overlay::{ CursorConfig, CursorShape, MotionConfig, OverlayCommand, Palette, PathPlanner, PathState, @@ -468,9 +468,7 @@ fn draw_default_arrow( #[cfg(target_os = "windows")] fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver) { - use windows::Win32::Foundation::*; use windows::Win32::UI::WindowsAndMessaging::*; - use windows::Win32::Graphics::Gdi::*; use windows::Win32::Media::timeBeginPeriod; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::core::PCWSTR; @@ -544,7 +542,7 @@ fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver windows::Win32::Foundation::LRESULT { use windows::Win32::Foundation::*; use windows::Win32::UI::WindowsAndMessaging::*; - use windows::Win32::Graphics::Gdi::*; match msg { WM_TIMER => { @@ -604,7 +601,7 @@ unsafe extern "system" fn wnd_proc( let mut guard = RENDER.lock().unwrap(); if let Some(rs) = guard.as_mut() { // Drain the channel. - if let Ok(mut rx_guard) = CMD_RX_WIN.try_lock() { + if let Ok(rx_guard) = CMD_RX_WIN.try_lock() { if let Some(ref rx) = *rx_guard { while let Ok(cmd) = rx.try_recv() { rs.apply_command(cmd); @@ -661,7 +658,7 @@ unsafe fn update_layered_window( let hdc_mem = CreateCompatibleDC(hdc_screen); // Create a 32-bit top-down DIB section (BGRA). - let mut bmi = BITMAPINFO { + let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { biSize: std::mem::size_of::() as u32, biWidth: w, @@ -684,7 +681,7 @@ unsafe fn update_layered_window( 0, ); if hbmp.is_err() || bits_ptr.is_null() { - DeleteDC(hdc_mem); + let _ = DeleteDC(hdc_mem); ReleaseDC(None, hdc_screen); return; } @@ -729,11 +726,11 @@ unsafe fn update_layered_window( SourceConstantAlpha: 255, AlphaFormat: 1, // AC_SRC_ALPHA }; - UpdateLayeredWindow(hwnd, hdc_screen, Some(&pt_dst), Some(&sz), + let _ = UpdateLayeredWindow(hwnd, hdc_screen, Some(&pt_dst), Some(&sz), hdc_mem, Some(&pt_src), COLORREF(0), Some(&blend), ULW_ALPHA); - DeleteObject(hbmp); - DeleteDC(hdc_mem); + let _ = DeleteObject(hbmp); + let _ = DeleteDC(hdc_mem); ReleaseDC(None, hdc_screen); } diff --git a/libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs b/libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs index 9aab153e28..d82bc4fb2d 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/uia/mod.rs @@ -170,8 +170,9 @@ unsafe fn walk_tree_unsafe(hwnd: u64, query: Option<&str>) -> UiaTreeResult { if fallback_nodes.iter().any(|n| n.element_index.is_some()) { nodes = fallback_nodes; lines = fallback_lines; - counter = fallback_counter; - total = fallback_total; + // counter/total aren't read after this point — they're + // only used by walk_cached's &mut params for element + // indexing inside that call. } } } diff --git a/libs/cua-driver-rs/crates/platform-windows/src/uia/windows_enum.rs b/libs/cua-driver-rs/crates/platform-windows/src/uia/windows_enum.rs index fb316a279c..12cb2603dd 100644 --- a/libs/cua-driver-rs/crates/platform-windows/src/uia/windows_enum.rs +++ b/libs/cua-driver-rs/crates/platform-windows/src/uia/windows_enum.rs @@ -12,6 +12,12 @@ //! UIA element's `NativeWindowHandle` — i.e. an honest Win32 HWND that downstream //! code can pass to `GetWindowRect`, `PostMessage`, etc. +// We pattern-match against `UIA_*ControlTypeId` constants from the `windows` +// crate, which use mixed case we can't rename. The lint's suggested rewrite +// (UIA_BUTTON_CONTROL_TYPE_ID) would silently shadow the external constant +// with a fresh local binding and break the match. Mirrors overlay.rs:12. +#![allow(non_upper_case_globals)] + use std::cell::RefCell; use anyhow::{bail, Context}; From 7ab537c1eb4646e95fdd2634843aa71c0cca2c71 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sat, 23 May 2026 17:27:50 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(cua-driver-rs):=20agent-guidance=20ref?= =?UTF-8?q?actor=20=E2=80=94=20Skills/MCP-instructions/per-tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes informed by a survey of how OSS computer-use tools push usage guidance to LLM agents (Playwright MCP, browser-use, Goose, Open Interpreter, Anthropic computer-use cookbook) cross-checked against the MCP spec + Claude Skills convention. Findings driving the changes: - Anthropic's documented Skills tip is "Keep SKILL.md under 500 lines; move detailed reference material to separate files" (code.claude.com/docs/en/skills). Our SKILL.md was 911 lines. - The MCP `instructions` field is spec'd as a hint that "MAY be added to the system prompt" — eager, every-turn cost. Community ceiling ~200 words. Goose templates its `instructions` per-OS (AX vs UIA vs AT-SPI) so the agent only sees the path that applies. - AGENT_INSTRUCTIONS reaches every MCP client uniformly (Hermes, Cursor, Copilot CLI, plus Claude-Skills-aware agents over MCP). SKILL.md reaches only the 4 agents with a Skills loader. So both surfaces should cross-reference each other rather than duplicate. Changes: 1. **SKILL.md split**: 911 lines → 493-line cross-platform entrypoint (snapshot invariant, CLI/MCP defaults, behavior matrix, canonical loop, pixel-click contract, common errors) + new MACOS.md (475 lines) carrying the no-foreground contract, AppleScript / `open` prohibitions, AXMenuBar navigation, SkyLight click dispatch, and the Apple-Events JS bridge. Existing WINDOWS.md (686), LINUX.md (87), WEB_APPS.md (477), RECORDING.md (120) untouched — they were already separated. (Sibling Swift driver skill at libs/cua-driver/Skills/cua-driver/SKILL.md is 887 lines — also over budget but out of scope for this Rust-port PR.) 2. **AGENT_INSTRUCTIONS in crates/mcp-server/src/protocol.rs**: - Converted from `const &str` to `fn() -> String` so it can be templated at compile time per host (`cfg!(target_os = ...)`). macOS clients see "AX (Accessibility)", Windows see "UIA (UI Automation)", Linux see "AT-SPI" — same pattern as Goose's ComputerController + Open Interpreter's `platform.system()`. - Added a closing line pointing skill-aware harnesses at SKILL.md + the relevant per-OS companion (e.g. MACOS.md on macOS). MCP clients without a skills loader (Hermes, Cursor) ignore the pointer; clients with one get a free description-match hint. - Tightened the workflow: 5 steps including re-snapshot, dropped the redundant list_apps step in favor of launch_app's `windows` array. Still under 200 words. 3. **Thicker per-tool descriptions** on click + screenshot (all 3 platforms): pushed "prefer element_index over pixel coords" guidance onto click (per Playwright's "this is better than screenshot" editorial pattern), and a pointer to get_window_state + max_image_dimension=1568 default onto screenshot. Per-call gating rather than per-session — Anthropic computer-use cookbook shape. Also includes the `TypeTextCharsTool { state: _state }` rename on platform-windows/tools/impl_.rs (the warnings-cleanup change that overlapped this file — the only field rename, struct still intentionally unregistered per the comment at impl_.rs:3991). Co-Authored-By: Claude Opus 4.7 --- .../Skills/cua-driver-rs/MACOS.md | 475 ++++++++++ .../Skills/cua-driver-rs/SKILL.md | 852 +++++------------- .../crates/mcp-server/src/protocol.rs | 56 +- .../crates/platform-linux/src/tools/impl_.rs | 28 +- .../crates/platform-macos/src/tools/click.rs | 17 +- .../platform-macos/src/tools/screenshot.rs | 10 +- .../platform-windows/src/tools/impl_.rs | 28 +- 7 files changed, 804 insertions(+), 662 deletions(-) create mode 100644 libs/cua-driver-rs/Skills/cua-driver-rs/MACOS.md diff --git a/libs/cua-driver-rs/Skills/cua-driver-rs/MACOS.md b/libs/cua-driver-rs/Skills/cua-driver-rs/MACOS.md new file mode 100644 index 0000000000..6a55426747 --- /dev/null +++ b/libs/cua-driver-rs/Skills/cua-driver-rs/MACOS.md @@ -0,0 +1,475 @@ +# cua-driver-rs — macOS specifics + +This file holds macOS-only material that used to live in `SKILL.md`. +The cross-platform core (snapshot invariant, CLI/MCP defaults, +behavior matrix, canonical loop, pixel-click contract, common error +patterns) is in `SKILL.md`. Read this in addition to `SKILL.md` when +you're driving an app on macOS. + +## The no-foreground contract + +**The user's frontmost app MUST NOT change.** This is the whole +reason cua-driver exists. Users pay for the right to keep typing in +their editor while an agent drives another app in the background. +Violate this rule and every other nice property the driver gives +you (no cursor warp, no Space switch, no window raise) stops +mattering — you just shipped the Accessibility Inspector with extra +steps. + +Before running any shell command, ask: **"does this raise, +activate, foreground, or make-key any app?"** If yes, don't run it. +Every one of the commands below activates the target on macOS and +is therefore forbidden unless the user **explicitly** asked for +frontmost state: + +- **Every form of the `open` CLI — `open -a `, `open -b + `, `open `, `open `, `open + ` — always activates.** macOS routes all forms through + LaunchServices, which unhides and foregrounds the target + regardless of whether you passed an app name, a bundle id, a + document, a URL, or the bundle path itself. The activation + happens even when the only intent was "start the process." + **Never use `open` for any app launch.** This includes launching + a just-built .app from a local build dir (e.g. `open + build/Build/Products/Debug/MyApp.app`) — resolve the + `CFBundleIdentifier` from `Info.plist` and use `launch_app` + with that id. See "The narrow carve-out" below for why + `launch_app` is safe even when the app internally calls + `NSApp.activate`. +- `osascript -e 'tell application "X" to activate'` — + activates by design. Same for `... to open `, + `... to launch`, and anything with `activate` in the tell block. +- `osascript -e 'tell application "System Events" to ... frontmost'` + in a mutating form (setting `frontmost` rather than reading it). +- AppleScript files that invoke `activate`, `launch`, or `open` + against the target app. +- `cliclick` (moves the user's real cursor to the target coords + before clicking — a focus-steal-equivalent even if the app's + window state is unchanged). +- `CGEventPost` with `cghidEventTap` targeting a coordinate over + a different app's window (warps the cursor, possibly activates + on hit). +- `AppleScriptTask`, `NSAppleScript`, `Process` wrapping `osascript` + that contains any of the above. +- `NSRunningApplication.activate(options:)` called from your own + helper binary — same class. +- Dock clicks and any `open` invocation (see the first bullet — + every form of `open` goes through LaunchServices which + activates, full stop). +- **Keyboard shortcuts that semantically mean "focus here" — + most notably Chrome / Safari / Arc's `⌘L` (focus omnibox) and + Finder's `⌘⇧G` (Go to Folder).** These aren't pure key events — + the receiving app interprets "user wants to type here" as + activation intent and raises its window to be key. Even when + delivered to a backgrounded pid via `hotkey`, the downstream app + pulls focus. **For omnibox navigation specifically**, the correct + path is `launch_app({bundle_id: "com.google.Chrome", urls: + ["https://…"]})` — no omnibox dance, no `⌘L`, no focus-steal. Do + NOT try `set_value` on the omnibox: Chrome's commit logic requires + a "user-typed" signal that neither an AX value write nor + `CGEvent.postToPid` keystrokes supply from a backgrounded pid — + the URL lands in the field but Return fires as a no-op. See + `WEB_APPS.md` → "Navigate to a URL" for the full pattern. The + general principle: a shortcut that says "put my cursor inside this + app" is a focus-steal; a shortcut that says "do this thing" (copy, + save, quit) is fine. +- **Tab-switching shortcuts in browsers (`⌘1..⌘9`, `⌘]`, `⌘[`, + `⌘⇧[`, `⌘⇧]`) are visibly disruptive even when delivered to a + backgrounded pid.** The app's key handler processes the shortcut, + the window re-renders the new tab's content, the user sees their + tabs flipping. There is no AX-only workaround: page content (HTML, + form state, `AXWebArea`) populates only for the focused tab; + inspecting a background tab requires activating it, which is the + visible flip. Observed with Dia; the same mechanic applies to every + Chromium-family browser (Chrome, Arc, Brave, Edge). + + **Prefer the windows-over-tabs pattern**: for each URL you need to + drive backgrounded, use `launch_app({bundle_id, urls: [url]})` — + browsers open each URL in a new **window**. Each window has its own + `window_id`, its own AX tree, and can be inspected / interacted with + via `element_index` without activating or switching anything. Tabs + are a UX grouping for humans; cua-driver workflows should default to + windows. See `WEB_APPS.md` → "Tabs vs windows" for the full pattern. + + Tab-title enumeration (read-only) IS safe — walk a window's toolbar + AX tree for `AXTab` / `AXRadioButton` children and read their + `AXTitle`s. Tab switching (activating one) is not. + +Reading frontmost state is fine (`osascript -e 'tell application +"System Events" to get name of first application process whose +frontmost is true'`). Mutating it is not. + +**Corollary — the AXMenuBar rule.** `AXMenuBarItem` + AXPick +dispatches at the AX layer regardless of which app is frontmost, +but macOS's on-screen menu bar always belongs to the frontmost +app. If you drive a *backgrounded* app's menu bar, the AX call +succeeds but the viewer sees the dispatch rendered over the +*frontmost* app's menu bar — confusing in any observed session and +routinely a silent no-op too, because action menu items go +`DISABLED` when their owning app isn't the key window. **So: only +use menu-bar navigation when the target is already frontmost.** For +backgrounded targets, read state via in-window AX (window title, +toolbar `AXStaticText`) and dispatch via in-window `element_index` +or pixel clicks — both paths are frontmost-insensitive. Full +rationale in "Navigating native menu bars" below. + +**"Open \" in user speech means launch, not activate.** +`cua-driver launch_app` is the one correct path for process +startup — it's idempotent (no-op on a running app), returns the +pid, and has an internal `FocusRestoreGuard` that catches +`NSApp.activate(ignoringOtherApps:)` calls the target makes during +`application(_:open:)` and clobbers the frontmost back to what it +was before the launch. That guard is why `launch_app` with `urls` +(e.g. `{"bundle_id": "com.colliderli.iina", "urls": ["~/video.mp4"]}`) +is safe even for apps that normally foreground on media-load +(Chrome, Electron, media players). + +## Intent → tool mapping (macOS-specific) + +| Intent | Use | Don't use | +|---|---|---| +| Open / launch an app | `launch_app({bundle_id})` or `launch_app({bundle_id, urls:[...]})` | `open -a`, `osascript 'tell app … to launch/activate/open'` | +| Find a pid | `list_apps` or `launch_app`'s return | `pgrep`, `ps`, `osascript frontmost` | +| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `osascript 'every window of app …'` | +| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `osascript`, `cliclick`, raw `CGEvent`, `open ` | +| Drag / drag-and-drop / marquee select | `drag({pid, from_x, from_y, to_x, to_y})` (pixel-only — macOS AX has no semantic drag) | `cliclick dd:`, `osascript drag` | +| Screenshot | `screenshot` or the PNG in `get_window_state` | `screencapture` | +| Quit an app | ask the user first, then `hotkey({pid, keys:["cmd","q"]})` | `kill`, `killall`, `pkill` | +| Hand a file/URL to an app | `launch_app({bundle_id, urls:[]})` | `open -a `, `open ` | + +### The narrow carve-out + +The **only** legitimate use of `osascript -e 'tell app X to +activate'` is when the user **explicitly** asked for frontmost +state ("bring Chrome to the front", "make it frontmost", "I want +to see X"). Reaching for it because a tool call returned something +confusing is wrong — that's the skill's classic foot-in-the-door +failure mode and it steals focus every time. + +When a cua-driver call surprises you, diagnose cua-driver first: + +- **Tiny screenshot / empty `tree_markdown`?** Check + `cua-driver get_config` → `capture_mode`. Default `"som"` returns + both the AX tree and screenshot. `"vision"` omits the AX tree + (PNG only), `"ax"` omits the PNG. If a snapshot lacks a tree, + `capture_mode` is almost certainly `"vision"` — either reason + purely from the PNG or flip to `"som"` / `"ax"` via `set_config`. +- **`has_screenshot: false`?** The window capture failed (transient + race against a close, or the window has no backing store yet). + Re-snapshot; if persistent, pick a different `window_id` via + `list_windows`. +- **`Invalid element_index` / `No cached AX state`?** You either + skipped `get_window_state` this turn or passed a different + `window_id` than the one the snapshot cached against. The cache + is keyed on `(pid, window_id)` — indices don't carry across + windows of the same app. Re-snapshot with the same window_id + you're about to click in. +- **Sparse Chromium AX tree?** Retry `get_window_state` once — the + tree populates on second call. + +Only after those are ruled out, and only if the user's action +genuinely needs frontmost state, fall through to the activate +fallback. Always name the focus steal in your response ("I'll +briefly bring Chrome to the front because …"). + +### Self-check pattern + +Before every `Bash` call whose command line touches any macOS app +(launching, opening, clicking, typing, scripting, screenshotting), +run the self-check: + +1. **Does this command foreground the target?** If yes — stop and + translate to the cua-driver equivalent from the mapping table. +2. **Does this command move the user's real cursor?** (`cliclick`, + any `CGEventPost` at `cghidEventTap` over another app's window). + If yes — stop; use `click({pid, x, y})` which routes per-pid + via SkyLight and never warps the cursor. +3. **Does this command bypass cua-driver entirely?** (`osascript` + mutating GUI state, AppleScript files, external helpers.) If + yes — stop; find the cua-driver tool that does the intent. + +If all three are "no," the command is safe. If you can't answer, +default to stop and ask rather than proceed. A single `open -a` +run by accident kills the demo, the trust, and the user's in-flight +editor state. + +## Prerequisites — macOS + +1. `cua-driver` is on `$PATH` (`which cua-driver`). If not, point the + user at `scripts/install-local.sh` and stop. +2. Run `cua-driver check_permissions` (with the daemon up — see step 3). + The default behavior also raises the system permission dialogs for + any missing grants, so the user can grant on the spot. If either + grant still reads `false` after that (user dismissed the dialog), + tell them to open System Settings → Privacy & Security and grant + Accessibility and Screen Recording to `CuaDriver.app`, then stop. + Pass `'{"prompt":false}'` for a purely read-only status check that + won't steal focus. +3. Start the daemon with `open -n -g -a CuaDriver --args serve` (the + recommended form — goes through LaunchServices so TCC attributes + the process to CuaDriver.app). `cua-driver serve &` also works; + the CLI auto-relaunches through `open -n -g -a CuaDriver` when it + detects a wrong-TCC context (any IDE-spawned shell: Claude Code, + Cursor, VS Code, Conductor). Verify with `cua-driver status`. + +## Resolve target pid — always via `launch_app` + +**Always start with `launch_app`**, whether or not the target is already +running. It's idempotent (relaunching returns the existing pid with no +side effects) and gives you the pid in one call — no `list_apps` hop. + +- `launch_app({bundle_id: "com.apple.finder"})` — preferred, unambiguous. +- `launch_app({name: "Calculator"})` — when bundle_id isn't known. + +`launch_app` is a **hidden-launch primitive by design** — that's the +entire point of cua-driver: agents drive apps in the background while +the user keeps typing in their real foreground app. The target's +window is initialized (AX tree fully populated, clickable via +`element_index`, the pid appears in `list_apps`) but not drawn on +screen. The driver never activates or unhides apps on its own; that +would violate the no-foreground contract the whole driver exists to +protect. + +If the user explicitly wants the window visible (usually for a demo +or recording), they unhide it themselves — Dock click, Cmd-Tab, or +Spotlight. Do not reach for `open` / `osascript activate` as a +shortcut to make the window visible; those paths break the backgrounded +invariant on every call, not just the call that "needed" the +foreground. Say out loud what the user needs to do ("click the +Todo app in your Dock to bring it forward") and let them do it. + +Never shell out to **any** form of `open` (including `open +` for a just-built binary — resolve the bundle id +from `Info.plist` and use `launch_app` with that), `osascript 'tell +app … to launch/open'`, or similar. Those paths activate the target, +bypass the driver's focus-restore guard, and require a Bash +permission prompt the agent loop shouldn't be burning on app launch. + +## Pixel-click dispatch (macOS) + +The pixel click is routed through SkyLight's per-pid event path +(`SLEventPostToPid`), not the system HID stream. The dispatch recipe +is the backgrounded "noraise" sequence: yabai's focus-without-raise +SLPS event records followed by an off-screen user-activation primer +and the real click. The target app becomes AppKit-active for event +routing but its window does **not** rise to the front of the +z-stack, and macOS's "switch to Space with windows for app" follow +is suppressed. Full mechanics in +`Sources/CuaDriverCore/Input/MouseInput.swift` (`clickViaAuthSignedPost`) +and the companion `FocusWithoutRaise.swift`. + +### Canvases, viewports, games (Blender, Unity, GHOST, Qt, wxWidgets) + +Apps whose main surface is an OpenGL / Metal / Qt / wxWidgets +viewport expose **no useful AX tree** — the whole surface is one +opaque `AXGroup` or `AXWindow` from AX's perspective. Per-pid event +paths (`SLEventPostToPid`, `CGEvent.postToPid`) are filtered by the +viewport's own event-source check and silently dropped — the event +loop wants "real HID origin". + +The working pattern: + +1. Bring the target frontmost (a brief `osascript activate` is + acceptable here — this is the carve-out the skill's osascript + gate allows). +2. `CGEvent.post(tap: .cghidEventTap)` with a leading `mouseMoved` + event (~30 ms before the click). `cua-driver click` when the + target is frontmost automatically takes this path. +3. Accept that the real cursor visibly moves — `cghidEventTap` is + the system HID stream, the cursor warps to the click point. + +There is no backgrounded path that reaches these apps today. + +### Known pixel-click limits + +- **Chromium `