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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# Cua-driver CI runners
These scripts are thin entrypoints around the Rust integration tests. They
build the repo-local fixture applications, run one strict Rust environment
preflight, set testkit paths, execute Rust targets, invoke the Rust report
validator, and collect artifacts. They do not define behavioral rows, push
code, or alter branches.
For the test layout and the distinction between unit tests, shared harnesses,
and native harnesses, see
`libs/cua-driver/docs/test-harnesses-guide.md`.
| Runner | Session | Canonical command |
| --- | --- | --- |
| `linux/run-rust-e2e.sh` | Existing Linux X11 or Wayland desktop | no selector |
| `linux/run-rust-e2e-wayland.sh` | Headless native Sway session | no selector |
| `linux/run-rust-e2e-inject.sh` | Nested `cua-compositor` session | no selector |
| `linux/run-rust-e2e-desktop.sh` | Existing representative Linux desktop | no selector |
| `windows/run-rust-e2e.ps1` | Windows console/RDP user session | `-RequireGui` |
| `macos/run-rust-e2e.sh` | Logged-in macOS session with TCC | no selector |
Use the command without a selector for the canonical complete run. CI sets the
private `CUA_E2E_INTERNAL_LANE` partition to `shared`, `native`, or `capture`
when it fans the same matrix into independent jobs. Those values are not public
alternate suites.
Run the Wayland wrapper through `nix develop .#cua-driver-wayland-e2e`. It
creates a pure Wayland session with Xwayland disabled and delegates every
scenario to `run-rust-e2e.sh`.
Run the nested compositor wrapper through
`nix develop .#cua-driver-inject-e2e`. This environment is experimental and
proves only the private compositor-owned route. Use `run-rust-e2e-desktop.sh`
for maintainer checks on representative GNOME, KDE, or real-Xorg sessions.
The GitHub-hosted Windows workflow is canonical when its strict preflight proves
an interactive desktop. The workflow also accepts a runner label so maintainers
can replay the same command on an Azure VM with an active RDP session for
environment parity; that replay is not a separate test definition or source of
behavioral truth.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if (($# != 2)); then
echo "usage: link-e2e-evidence.sh <summary.md> <artifact-url>" >&2
exit 2
fi
summary_path="$1"
artifact_url="$2"
awk -F '|' -v artifact_url="${artifact_url}" '
BEGIN { OFS = "|" }
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
/^\|/ && NF >= 16 {
cell = trim($2)
evidence = trim($15)
if (cell != "Cell" && cell !~ /^-+$/ && evidence != "" && evidence != "-" && evidence !~ /^\[/) {
$15 = " [" evidence "](" artifact_url ") "
}
}
{ print }
' "${summary_path}"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Validate the canonical Rust matrix in a representative maintainer desktop.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENVIRONMENT="${1:-}"
if [[ $# -gt 0 ]]; then
shift
fi
case "${ENVIRONMENT}" in
gnome)
[[ "${XDG_SESSION_TYPE:-}" == wayland ]] || {
echo "GNOME validation requires an active Wayland user session" >&2
exit 2
}
[[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* ]] || {
echo "XDG_CURRENT_DESKTOP does not identify GNOME: ${XDG_CURRENT_DESKTOP:-<unset>}" >&2
exit 2
}
gdbus call --session \
--dest org.cua.WinRects \
--object-path /org/cua/WinRects \
--method org.cua.WinRects.GetRects >/dev/null || {
echo "The GNOME WinRects helper is not available in this user session" >&2
exit 2
}
export CUA_E2E_COMPOSITOR=gnome-mutter
export CUA_E2E_INPUT_BACKENDS=atspi,libei-portal
export CUA_DRIVER_RS_ENABLE_WAYLAND=1
;;
kde)
[[ "${XDG_SESSION_TYPE:-}" == wayland ]] || {
echo "KDE validation requires an active Wayland user session" >&2
exit 2
}
[[ "${XDG_CURRENT_DESKTOP:-}" == *KDE* ]] || {
echo "XDG_CURRENT_DESKTOP does not identify KDE: ${XDG_CURRENT_DESKTOP:-<unset>}" >&2
exit 2
}
kwin_version="$(kwin_wayland --version 2>/dev/null | sed -n 's/^kwin \([0-9][0-9]*\).*/\1/p')"
[[ "${kwin_version:-0}" -ge 6 ]] || {
echo "Representative KDE validation requires Plasma/KWin 6; found ${kwin_version:-unknown}" >&2
exit 2
}
export CUA_E2E_COMPOSITOR=kwin
export CUA_E2E_INPUT_BACKENDS=atspi,libei-portal
export CUA_DRIVER_RS_ENABLE_WAYLAND=1
;;
xorg)
[[ -n "${DISPLAY:-}" && "${XDG_SESSION_TYPE:-x11}" != wayland ]] || {
echo "Real-Xorg validation requires DISPLAY in a non-Wayland session" >&2
exit 2
}
export CUA_E2E_COMPOSITOR=real-xorg
export CUA_E2E_INPUT_BACKENDS=atspi,xsend-event,xtest,mpx-uinput
;;
*)
echo "Usage: run-rust-e2e-desktop.sh {gnome|kde|xorg} [--no-build]" >&2
exit 2
;;
esac
if [[ "${CUA_E2E_HARNESS_FILTER:-}" == *tauri* && ! -e /dev/dri/renderD128 ]]; then
echo "Tauri/WebKitGTK validation requires a representative DRM render node" >&2
exit 2
fi
exec "${SCRIPT_DIR}/run-rust-e2e.sh" "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Run the canonical Rust matrix inside the nested cua-compositor environment.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CUA_E2E_WAYLAND_SESSION=cua-compositor
exec "${SCRIPT_DIR}/run-rust-e2e-wayland.sh" "$@"
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
# Start one native, headless Sway session and run the complete Rust E2E matrix.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
RUNTIME_DIR="$(mktemp -d)"
SWAY_CONFIG="$(mktemp)"
SESSION_KIND="${CUA_E2E_WAYLAND_SESSION:-sway}"
COMPOSITOR_LOG="${REPO_ROOT}/artifacts/cua-driver/linux/${SESSION_KIND}.log"
ATSPI_LOG="${REPO_ROOT}/artifacts/cua-driver/linux/at-spi-bus.log"
COMPOSITOR_PID=""
DBUS_PID=""
ATSPI_PID=""
cleanup() {
if [[ -n "${COMPOSITOR_PID}" ]]; then
kill "${COMPOSITOR_PID}" 2>/dev/null || true
wait "${COMPOSITOR_PID}" 2>/dev/null || true
fi
if [[ -n "${DBUS_PID}" ]]; then
kill "${DBUS_PID}" 2>/dev/null || true
fi
if [[ -n "${ATSPI_PID}" ]]; then
kill "${ATSPI_PID}" 2>/dev/null || true
wait "${ATSPI_PID}" 2>/dev/null || true
fi
rm -f "${SWAY_CONFIG}"
}
trap cleanup EXIT
mkdir -p "$(dirname "${COMPOSITOR_LOG}")"
chmod 700 "${RUNTIME_DIR}"
cat > "${SWAY_CONFIG}" <<'EOF'
xwayland disable
output HEADLESS-1 mode 1920x1080
seat seat0 fallback true
focus_follows_mouse no
default_border none
default_floating_border none
for_window [title="^CuaTestHarness"] floating enable, resize set 940 780, move position 0 0
for_window [title="CuaTestHarness Sentinel"] fullscreen enable
EOF
unset DISPLAY
unset WAYLAND_DISPLAY
export XDG_RUNTIME_DIR="${RUNTIME_DIR}"
export XDG_SESSION_TYPE=wayland
export XDG_CURRENT_DESKTOP=sway
export XDG_SESSION_DESKTOP=sway
export WLR_BACKENDS=headless
export WLR_RENDERER=pixman
export WLR_RENDERER_ALLOW_SOFTWARE=1
export WLR_LIBINPUT_NO_DEVICES=1
export WLR_HEADLESS_OUTPUTS=1
export CUA_DRIVER_RS_ENABLE_WAYLAND=1
if [[ "${SESSION_KIND}" == cua-compositor ]]; then
export CUA_E2E_COMPOSITOR=cua-compositor-nested
export CUA_E2E_INPUT_BACKENDS=atspi,cua-compositor-inject
export CUA_E2E_HARNESS_FILTER=electron
export CUA_INJECT_SOCKET="${XDG_RUNTIME_DIR}/cua-inject.sock"
else
export CUA_E2E_COMPOSITOR=sway
export CUA_E2E_INPUT_BACKENDS=atspi,wlr-virtual-pointer
fi
export CUA_WAYLAND_RECORDING_OUTPUT=HEADLESS-1
export ELECTRON_OZONE_PLATFORM_HINT=wayland
export GDK_BACKEND=wayland
export QT_QPA_PLATFORM=wayland
# WebKitGTK's accelerated compositor requires a DRM render node. The canonical
# hosted lane has none, so keep its best-effort software settings explicit;
# native-Wayland WebKit coverage runs on the representative GNOME/KDE VMs.
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
# This isolated CI session uses a private runtime directory and no user home
# namespaces. Modern WebKitGTK ignores WEBKIT_FORCE_SANDBOX; use its explicit
# test-only escape hatch so the WebProcess can publish its AT-SPI subtree.
export WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1
export NO_AT_BRIDGE=0
export ACCESSIBILITY_ENABLED=1
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
dbus_daemon="$(command -v dbus-daemon)"
dbus_prefix="$(dirname "$(dirname "$(readlink -f "${dbus_daemon}")")")"
dbus_config="${dbus_prefix}/share/dbus-1/session.conf"
if [[ ! -f "${dbus_config}" ]]; then
echo "DBus session config is missing: ${dbus_config}" >&2
exit 1
fi
dbus_info="$(dbus-daemon --config-file="${dbus_config}" --fork --print-address=1 --print-pid=1)"
export DBUS_SESSION_BUS_ADDRESS="$(sed -n '1p' <<< "${dbus_info}")"
DBUS_PID="$(sed -n '2p' <<< "${dbus_info}")"
fi
# A private session bus does not activate the desktop accessibility stack by
# itself. Start the repo-pinned AT-SPI launcher, then require org.a11y.Bus to
# answer before any fixture or driver process inherits this session.
ATSPI_LAUNCHER="${CUA_AT_SPI_BUS_LAUNCHER:-$(command -v at-spi-bus-launcher || true)}"
if [[ ! -x "${ATSPI_LAUNCHER}" ]]; then
echo "AT-SPI bus launcher is unavailable: ${ATSPI_LAUNCHER:-<not found>}" >&2
exit 1
fi
"${ATSPI_LAUNCHER}" --launch-immediately > "${ATSPI_LOG}" 2>&1 &
ATSPI_PID=$!
deadline=$((SECONDS + 15))
while ((SECONDS < deadline)); do
if ! kill -0 "${ATSPI_PID}" 2>/dev/null; then
echo "AT-SPI bus launcher exited before org.a11y.Bus became ready" >&2
cat "${ATSPI_LOG}" >&2
exit 1
fi
if gdbus call --session \
--dest org.a11y.Bus \
--object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress >/dev/null 2>&1; then
break
fi
sleep 0.2
done
if ! gdbus call --session \
--dest org.a11y.Bus \
--object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress >/dev/null 2>&1; then
echo "org.a11y.Bus did not become ready within 15 seconds" >&2
cat "${ATSPI_LOG}" >&2
exit 1
fi
# The bus launcher and the registry daemon are separate services. Resolve the
# private accessibility bus and require the registry to activate on it before
# any toolkit inherits this session.
a11y_address="$(
gdbus call --session \
--dest org.a11y.Bus \
--object-path /org/a11y/bus \
--method org.a11y.Bus.GetAddress \
| sed -e "s/^('//" -e "s/',)$//"
)"
export AT_SPI_BUS_ADDRESS="${a11y_address}"
if [[ -z "${a11y_address}" ]] || ! gdbus call \
--address "${a11y_address}" \
--dest org.a11y.atspi.Registry \
--object-path /org/a11y/atspi/accessible/root \
--method org.freedesktop.DBus.Properties.Get \
org.a11y.atspi.Accessible ChildCount >/dev/null 2>&1; then
echo "AT-SPI registry did not activate on the accessibility bus" >&2
cat "${ATSPI_LOG}" >&2
exit 1
fi
if ! gdbus call --session \
--dest org.a11y.Bus \
--object-path /org/a11y/bus \
--method org.freedesktop.DBus.Properties.Set \
org.a11y.Status IsEnabled '<true>' >/dev/null 2>&1; then
echo "Could not enable accessibility on org.a11y.Bus" >&2
cat "${ATSPI_LOG}" >&2
exit 1
fi
if [[ "${SESSION_KIND}" == cua-compositor ]]; then
command -v cua-compositor >/dev/null || {
echo "cua-compositor is required for the nested injection lane" >&2
exit 1
}
cua-compositor > "${COMPOSITOR_LOG}" 2>&1 &
else
sway --unsupported-gpu --config "${SWAY_CONFIG}" > "${COMPOSITOR_LOG}" 2>&1 &
fi
COMPOSITOR_PID=$!
deadline=$((SECONDS + 20))
while ((SECONDS < deadline)); do
if ! kill -0 "${COMPOSITOR_PID}" 2>/dev/null; then
echo "${SESSION_KIND} exited before its Wayland socket became ready" >&2
cat "${COMPOSITOR_LOG}" >&2
exit 1
fi
socket="$(find "${XDG_RUNTIME_DIR}" -maxdepth 1 -type s -name 'wayland-*' -print -quit)"
if [[ -n "${socket}" ]]; then
export WAYLAND_DISPLAY="$(basename "${socket}")"
break
fi
sleep 0.2
done
if [[ -z "${WAYLAND_DISPLAY:-}" ]]; then
echo "${SESSION_KIND} did not create a Wayland socket within 20 seconds" >&2
cat "${COMPOSITOR_LOG}" >&2
exit 1
fi
if [[ "${SESSION_KIND}" == cua-compositor ]]; then
deadline=$((SECONDS + 10))
while [[ ! -S "${CUA_INJECT_SOCKET}" ]] && ((SECONDS < deadline)); do
sleep 0.2
done
if [[ ! -S "${CUA_INJECT_SOCKET}" ]]; then
echo "cua-compositor did not expose its injection socket" >&2
cat "${COMPOSITOR_LOG}" >&2
exit 1
fi
else
export SWAYSOCK="$(find "${XDG_RUNTIME_DIR}" -maxdepth 1 -type s -name 'sway-ipc.*.sock' -print -quit)"
if [[ -z "${SWAYSOCK}" ]]; then
echo "Sway did not expose its IPC socket" >&2
cat "${COMPOSITOR_LOG}" >&2
exit 1
fi
fi
echo "Native Wayland E2E session: ${SESSION_KIND} on ${WAYLAND_DISPLAY}"
set +e
"${SCRIPT_DIR}/run-rust-e2e.sh" "$@"
status=$?
set -e
if [[ "${status}" != 0 && "${SESSION_KIND}" == sway ]]; then
swaymsg -t get_tree > "${REPO_ROOT}/artifacts/cua-driver/linux/sway-tree.json" 2>/dev/null || true
fi
exit "${status}"
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# Run the canonical Rust desktop matrix on a Linux user session.
# Scenario definitions and assertions live in the Rust integration test.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
DRIVER_ROOT="${REPO_ROOT}/libs/cua-driver"
RUST_ROOT="${DRIVER_ROOT}/rust"
BUILD_FIXTURES=1
SUITE="${CUA_E2E_INTERNAL_LANE:-all}"
usage() {
cat <<'EOF'
Usage: run-rust-e2e.sh [--no-build]
The caller must provide a real or virtual Linux desktop session. For a
headless session, wrap this command in xvfb-run and dbus-run-session.
The contributor-facing command always runs the complete matrix.
EOF
}
while (($#)); do
case "$1" in
--no-build) BUILD_FIXTURES=0 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
case "$SUITE" in
shared|native|capture|all) ;;
*) echo "unsupported internal lane: $SUITE" >&2; exit 2 ;;
esac
ARTIFACT_DIR="${REPO_ROOT}/artifacts/cua-driver/linux"
mkdir -p "${ARTIFACT_DIR}"
RECORDING_ROOT="${ARTIFACT_DIR}/recordings"
rm -rf "${RECORDING_ROOT}"
mkdir -p "${RECORDING_ROOT}"
DECLARATIONS_FILE="${ARTIFACT_DIR}/cases.jsonl"
ENVIRONMENT_FILE="${ARTIFACT_DIR}/environment.jsonl"
RESULTS_FILE="${ARTIFACT_DIR}/results.jsonl"
SUMMARY_FILE="${ARTIFACT_DIR}/summary.md"
: > "${DECLARATIONS_FILE}"
: > "${ENVIRONMENT_FILE}"
: > "${RESULTS_FILE}"
rm -f "${SUMMARY_FILE}"
export CUA_E2E_DECLARATIONS_FILE="${DECLARATIONS_FILE}"
export CUA_E2E_ENVIRONMENT_FILE="${ENVIRONMENT_FILE}"
export CUA_E2E_RESULTS_FILE="${RESULTS_FILE}"
export CUA_E2E_RECORDINGS_ROOT="${RECORDING_ROOT}"
export CUA_TEST_WORKSPACE_ROOT="${RUST_ROOT}"
export CUA_TEST_DRIVER_BIN="${RUST_ROOT}/target/release/cua-driver"
export CUA_TEST_APPS_ROOT="${RUST_ROOT}/test-apps"
export CUA_TEST_REQUIRE_FIXTURES=1
export CUA_TEST_DRIVER_STDERR=1
export RUST_BACKTRACE="${RUST_BACKTRACE:-1}"
if [[ -z "${CUA_E2E_SOURCE_SHA:-}" && -f "${REPO_ROOT}/.cua-e2e-source-sha" ]]; then
export CUA_E2E_SOURCE_SHA="$(tr -d '[:space:]' < "${REPO_ROOT}/.cua-e2e-source-sha")"
export CUA_E2E_SOURCE_MARKER="${REPO_ROOT}/.cua-e2e-source-sha"
fi
if [[ -n "${WAYLAND_DISPLAY:-}" && -z "${DISPLAY:-}" ]]; then
export GDK_BACKEND="${GDK_BACKEND:-wayland}"
export CUA_E2E_COMPOSITOR="${CUA_E2E_COMPOSITOR:-wayland-unknown}"
export CUA_E2E_INPUT_BACKENDS="${CUA_E2E_INPUT_BACKENDS:-atspi}"
else
export CUA_E2E_COMPOSITOR="${CUA_E2E_COMPOSITOR:-openbox-x11}"
export CUA_E2E_INPUT_BACKENDS="${CUA_E2E_INPUT_BACKENDS:-atspi,xsend-event,xtest}"
fi
if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then
export CUA_ATSPI_DEBUG=1
fi
if [[ -n "${WAYLAND_DISPLAY:-}" && -z "${DISPLAY:-}" ]]; then
command -v wf-recorder >/dev/null || { echo "wf-recorder is required for native Wayland E2E videos" >&2; exit 1; }
command -v grim >/dev/null || { echo "grim is required for native Wayland capture fallback" >&2; exit 1; }
command -v wtype >/dev/null || { echo "wtype is required for native Wayland keyboard input" >&2; exit 1; }
else
command -v ffmpeg >/dev/null || { echo "ffmpeg is required for X11 E2E trajectory videos" >&2; exit 1; }
fi
command -v ffprobe >/dev/null || { echo "ffprobe is required for E2E trajectory validation" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq is required for E2E ownership validation" >&2; exit 1; }
if [[ "${BUILD_FIXTURES}" == 1 ]]; then
cargo build --release -p cua-driver --manifest-path "${RUST_ROOT}/Cargo.toml"
case "${SUITE}" in
shared) FIXTURE_TARGETS="${CUA_E2E_HARNESS_FILTER:-electron,tauri}" ;;
native|capture) FIXTURE_TARGETS="electron,gtk3" ;;
*) FIXTURE_TARGETS="${CUA_E2E_HARNESS_FILTER:-electron,tauri},gtk3" ;;
esac
bash "${DRIVER_ROOT}/tests/fixtures/build/linux.sh" --only "${FIXTURE_TARGETS}"
fi
if [[ ! -x "${CUA_TEST_DRIVER_BIN}" ]]; then
echo "driver binary not found: ${CUA_TEST_DRIVER_BIN}" >&2
exit 1
fi
required_fixtures=()
required_fixtures+=("${CUA_TEST_APPS_ROOT}/harness-electron/CuaTestHarness.Electron")
if [[ ("${SUITE}" == shared || "${SUITE}" == all) \
&& ",${CUA_E2E_HARNESS_FILTER:-electron,tauri}," == *,tauri,* ]]; then
required_fixtures+=(
"${CUA_TEST_APPS_ROOT}/harness-tauri/CuaTestHarness.Tauri"
)
fi
if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then
required_fixtures+=("${CUA_TEST_APPS_ROOT}/harness-gtk3/CuaTestHarness.Gtk3")
fi
for fixture in "${required_fixtures[@]}"; do
if [[ ! -x "${fixture}" ]]; then
echo "Required fixture was not built: ${fixture}" >&2
exit 1
fi
done
FAILURE_COUNT=0
run_report() {
(cd "${RUST_ROOT}" && cargo run -p cua-driver-testkit --bin cua-e2e-report -- \
--declarations "${DECLARATIONS_FILE}" \
--environment "${ENVIRONMENT_FILE}" \
--results "${RESULTS_FILE}" \
--artifact-root "${ARTIFACT_DIR}" \
--require-video \
--output "${SUMMARY_FILE}")
}
echo "[PREFLIGHT] Linux desktop, fixture, AX, capture, and video"
set +e
(cd "${RUST_ROOT}" && cargo test -p cua-driver --test e2e_environment_preflight_test -- \
--ignored --exact canonical_e2e_environment_is_ready --nocapture --test-threads=1) \
2>&1 | tee "${ARTIFACT_DIR}/environment-preflight.log"
PREFLIGHT_EXIT=${PIPESTATUS[0]}
set -e
if [[ "${PREFLIGHT_EXIT}" != 0 ]]; then
set +e
run_report
set -e
echo "Linux E2E environment preflight failed" >&2
exit 1
fi
run_test() {
local name="$1"
shift
echo "[RUN] ${name}"
set +e
(cd "${RUST_ROOT}" && "$@") 2>&1 | tee "${ARTIFACT_DIR}/${name}.log"
local exit_code=${PIPESTATUS[0]}
set -e
if [[ "${exit_code}" != 0 ]]; then
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
}
if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then
run_test shared-behavior-matrix \
cargo test -p cua-driver --test cross_platform_behavior_test -- \
--ignored --exact shared_web_action_matrix_is_state_verified \
--nocapture --test-threads=1
fi
if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then
run_test gtk3-native-harness \
cargo test -p cua-driver --test harness_gtk3_test -- \
--ignored --nocapture --test-threads=1
fi
if [[ "${SUITE}" == capture || "${SUITE}" == all ]]; then
run_test capture-contract \
cargo test -p cua-driver --test capture_contract_test -- \
--ignored --nocapture --test-threads=1
run_test desktop-scope \
cargo test -p cua-driver --test desktop_scope_linux_test -- \
--ignored --nocapture --test-threads=1
fi
video_count=0
while IFS= read -r -d '' video; do
video_count=$((video_count + 1))
if ! ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "${video}" >/dev/null; then
echo "[VIDEO FAIL] Unplayable trajectory: ${video}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
else
echo "[VIDEO PASS] ${video}"
fi
done < <(find "${RECORDING_ROOT}" -type f -name recording.mp4 -print0)
OWNED_VIDEOS="$(mktemp)"
jq -r 'select(.evidence.video != null) | .evidence.video' "${RESULTS_FILE}" > "${OWNED_VIDEOS}"
while IFS= read -r -d '' video; do
relative="${video#${ARTIFACT_DIR}/}"
if [[ "${relative}" == recordings/environment-preflight-*/recording.mp4 ]]; then
continue
fi
if ! grep -Fxq -- "${relative}" "${OWNED_VIDEOS}"; then
echo "[VIDEO FAIL] Orphan trajectory has no typed result row: ${relative}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
done < <(find "${RECORDING_ROOT}" -type f -name recording.mp4 -print0)
rm -f "${OWNED_VIDEOS}"
while IFS= read -r -d '' recording_error; do
echo "[VIDEO FAIL] ${recording_error}" >&2
cat "${recording_error}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
done < <(find "${RECORDING_ROOT}" -type f -name recording-error.txt -print0)
if [[ "${video_count}" == 0 ]]; then
echo "[VIDEO FAIL] No E2E trajectory videos were produced" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
set +e
run_report
REPORT_EXIT=$?
set -e
if [[ "${REPORT_EXIT}" != 0 ]]; then
echo "Linux E2E result validation failed" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
if [[ "${FAILURE_COUNT}" != 0 ]]; then
echo "Linux Rust e2e suite had ${FAILURE_COUNT} failing lane(s)" >&2
exit 1
fi
echo "Linux Rust e2e suite completed: ${SUITE}"
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# Run the canonical Rust desktop matrix in a logged-in macOS user session.
# macOS harness tests use the installed, TCC-authorized cua-driver daemon path.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
DRIVER_ROOT="${REPO_ROOT}/libs/cua-driver"
RUST_ROOT="${DRIVER_ROOT}/rust"
SUITE="${CUA_E2E_INTERNAL_LANE:-all}"
BUILD_FIXTURES=1
usage() {
cat <<'EOF'
Usage: run-rust-e2e.sh [--no-build]
Run from a logged-in macOS desktop after install-local and TCC authorization.
The testkit proxies MCP calls through the installed CuaDriver daemon.
The contributor-facing command always runs the complete matrix.
EOF
}
while (($#)); do
case "$1" in
--no-build) BUILD_FIXTURES=0 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
case "$SUITE" in
shared|native|capture|all) ;;
*) echo "unsupported internal lane: $SUITE" >&2; exit 2 ;;
esac
if ! git -C "${REPO_ROOT}" diff --quiet || ! git -C "${REPO_ROOT}" diff --cached --quiet; then
echo "macOS canonical E2E requires a clean tracked working tree" >&2
exit 2
fi
if [[ -z "${CUA_E2E_SOURCE_SHA:-}" ]]; then
CUA_E2E_SOURCE_SHA="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
fi
if [[ ! "${CUA_E2E_SOURCE_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "CUA_E2E_SOURCE_SHA must be a full 40-character commit SHA" >&2
exit 2
fi
export CUA_E2E_SOURCE_SHA
ARTIFACT_DIR="${REPO_ROOT}/artifacts/cua-driver/macos"
RECORDING_ROOT="${ARTIFACT_DIR}/recordings"
if [[ -e "${RECORDING_ROOT}" ]]; then
RECORDING_ARCHIVE="$(mktemp -d "${TMPDIR:-/tmp}/cua-macos-e2e-recordings.XXXXXX")"
mv "${RECORDING_ROOT}" "${RECORDING_ARCHIVE}/recordings"
echo "Previous recordings preserved at ${RECORDING_ARCHIVE}/recordings"
fi
mkdir -p "${RECORDING_ROOT}"
RESULTS_FILE="${ARTIFACT_DIR}/results.jsonl"
DECLARATIONS_FILE="${ARTIFACT_DIR}/cases.jsonl"
ENVIRONMENT_FILE="${ARTIFACT_DIR}/environment.jsonl"
SUMMARY_FILE="${ARTIFACT_DIR}/summary.md"
mkdir -p "${ARTIFACT_DIR}"
: > "${DECLARATIONS_FILE}"
: > "${ENVIRONMENT_FILE}"
: > "${RESULTS_FILE}"
rm -f "${SUMMARY_FILE}"
export CUA_E2E_DECLARATIONS_FILE="${DECLARATIONS_FILE}"
export CUA_E2E_ENVIRONMENT_FILE="${ENVIRONMENT_FILE}"
export CUA_E2E_RESULTS_FILE="${RESULTS_FILE}"
export CUA_E2E_RECORDINGS_ROOT="${RECORDING_ROOT}"
export CUA_TEST_WORKSPACE_ROOT="${RUST_ROOT}"
export CUA_TEST_DRIVER_BIN="${RUST_ROOT}/target/release/cua-driver"
export CUA_TEST_APPS_ROOT="${RUST_ROOT}/test-apps"
export CUA_TEST_REQUIRE_FIXTURES=1
export CUA_TEST_DRIVER_STDERR=1
command -v ffmpeg >/dev/null || { echo "ffmpeg is required for E2E trajectory videos" >&2; exit 1; }
command -v ffprobe >/dev/null || { echo "ffprobe is required for E2E trajectory validation" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq is required for E2E ownership validation" >&2; exit 1; }
if [[ "${BUILD_FIXTURES}" == 1 ]]; then
cargo build --release -p cua-driver --manifest-path "${RUST_ROOT}/Cargo.toml"
bash "${DRIVER_ROOT}/tests/fixtures/build/macos.sh"
fi
if [[ ! -x "${CUA_TEST_DRIVER_BIN}" ]]; then
echo "Required driver binary was not built: ${CUA_TEST_DRIVER_BIN}" >&2
exit 1
fi
required_fixtures=()
required_fixtures+=("${CUA_TEST_APPS_ROOT}/harness-electron/CuaTestHarness.Electron.app")
if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then
required_fixtures+=(
"${CUA_TEST_APPS_ROOT}/harness-tauri/CuaTestHarness.Tauri.app"
"${CUA_TEST_APPS_ROOT}/harness-wkwebview/CuaTestHarness.WKWebView.app"
)
fi
if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then
required_fixtures+=(
"${CUA_TEST_APPS_ROOT}/harness-appkit/CuaTestHarness.AppKit.app"
"${CUA_TEST_APPS_ROOT}/harness-swiftui/CuaTestHarness.SwiftUI.app"
)
fi
for fixture in "${required_fixtures[@]}"; do
[[ -d "${fixture}" ]] || { echo "Required fixture missing: ${fixture}" >&2; exit 1; }
done
FAILURE_COUNT=0
run_report() {
(cd "${RUST_ROOT}" && cargo run -p cua-driver-testkit --bin cua-e2e-report -- \
--declarations "${DECLARATIONS_FILE}" \
--environment "${ENVIRONMENT_FILE}" \
--results "${RESULTS_FILE}" \
--artifact-root "${ARTIFACT_DIR}" \
--require-video \
--output "${SUMMARY_FILE}")
}
echo "[PREFLIGHT] macOS daemon identity, fixture, AX, capture, and video"
set +e
(cd "${RUST_ROOT}" && cargo test -p cua-driver --test e2e_environment_preflight_test -- \
--ignored --exact canonical_e2e_environment_is_ready --nocapture --test-threads=1) \
2>&1 | tee "${ARTIFACT_DIR}/environment-preflight.log"
PREFLIGHT_EXIT=${PIPESTATUS[0]}
set -e
if [[ "${PREFLIGHT_EXIT}" != 0 ]]; then
set +e
run_report
set -e
echo "macOS E2E environment preflight failed" >&2
exit 1
fi
run_test() {
local name="$1"; shift
echo "[RUN] ${name}"
set +e
(cd "${RUST_ROOT}" && "$@") 2>&1 | tee "${ARTIFACT_DIR}/${name}.log"
local exit_code=${PIPESTATUS[0]}
set -e
if [[ "${exit_code}" != 0 ]]; then
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
}
if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then
run_test shared-app-matrix cargo test -p cua-driver --test cross_platform_behavior_test -- \
--ignored --exact shared_web_action_matrix_is_state_verified \
--nocapture --test-threads=1
fi
if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then
for appkit_test in \
harness_appkit_smoke \
harness_appkit_text_input \
harness_appkit_type_text_background \
harness_appkit_scroll_foreground \
harness_appkit_scroll_background \
harness_appkit_counter \
harness_appkit_counter_px_background \
harness_appkit_right_click_px_foreground \
harness_appkit_right_click_px_background \
harness_appkit_double_click_px_foreground \
harness_appkit_double_click_px_background \
harness_appkit_slider_drag_px_foreground \
harness_appkit_slider_drag_px_background; do
run_test "appkit-${appkit_test}" cargo test -p cua-driver --test harness_appkit_test -- \
--ignored --exact "${appkit_test}" --nocapture --test-threads=1
done
for swiftui_test in \
harness_swiftui_smoke \
harness_swiftui_counter_background \
harness_swiftui_set_value_background \
harness_swiftui_popover_foreground; do
run_test "swiftui-${swiftui_test}" cargo test -p cua-driver --test harness_swiftui_test -- \
--ignored --exact "${swiftui_test}" --nocapture --test-threads=1
done
run_test installed-app-launch cargo test -p cua-driver --test installed_app_launch_macos_test -- \
--ignored --nocapture --test-threads=1
run_test installed-app-textedit cargo test -p cua-driver --test installed_app_textedit_macos_test -- \
--ignored --exact background_type_on_native_cocoa_is_ax_verified \
--nocapture --test-threads=1
fi
if [[ "${SUITE}" == capture || "${SUITE}" == all ]]; then
run_test capture-contract cargo test -p cua-driver --test capture_contract_test -- \
--ignored --nocapture --test-threads=1
run_test desktop-scope cargo test -p cua-driver --test desktop_scope_macos_test -- \
--ignored --nocapture --test-threads=1
fi
video_count=0
while IFS= read -r -d '' video; do
video_count=$((video_count + 1))
if ! ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "${video}" >/dev/null; then
echo "[VIDEO FAIL] Unplayable trajectory: ${video}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
done < <(find "${RECORDING_ROOT}" -type f -name recording.mp4 -print0)
OWNED_VIDEOS="$(mktemp)"
jq -r 'select(.evidence.video != null) | .evidence.video' "${RESULTS_FILE}" > "${OWNED_VIDEOS}"
while IFS= read -r -d '' video; do
relative="${video#${ARTIFACT_DIR}/}"
if [[ "${relative}" == recordings/environment-preflight-*/recording.mp4 ]]; then
continue
fi
if ! grep -Fxq -- "${relative}" "${OWNED_VIDEOS}"; then
echo "[VIDEO FAIL] Orphan trajectory has no typed result row: ${relative}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
done < <(find "${RECORDING_ROOT}" -type f -name recording.mp4 -print0)
rm -f "${OWNED_VIDEOS}"
while IFS= read -r -d '' error_file; do
echo "[VIDEO FAIL] ${error_file}" >&2
cat "${error_file}" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
done < <(find "${RECORDING_ROOT}" -type f -name recording-error.txt -print0)
if [[ "${video_count}" == 0 ]]; then
echo "[VIDEO FAIL] No E2E trajectory videos were produced" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
set +e
run_report
REPORT_EXIT=$?
set -e
if [[ "${REPORT_EXIT}" != 0 ]]; then
echo "macOS E2E result validation failed" >&2
FAILURE_COUNT=$((FAILURE_COUNT + 1))
fi
if [[ "${FAILURE_COUNT}" != 0 ]]; then
echo "macOS Rust E2E suite had ${FAILURE_COUNT} failing lane(s)" >&2
exit 1
fi
echo "macOS Rust E2E suite completed: ${SUITE}"
+18
View File
@@ -0,0 +1,18 @@
# Build all repo-local Windows harness apps from source.
param(
[ValidateSet("none", "wpf", "winui3", "webview", "electron", "tauri")]
[string]$Skip = "none",
[ValidateSet("wpf", "winui3", "webview", "electron", "tauri")]
[string[]]$Targets = @("wpf", "winui3", "webview", "electron", "tauri")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..\..")).Path
$fixtureBuild = Join-Path $repoRoot "libs\cua-driver\tests\fixtures\build\windows.ps1"
& $fixtureBuild -Skip $Skip -Targets $Targets
if ($LASTEXITCODE -ne 0) {
throw "Windows harness build failed with exit code $LASTEXITCODE"
}
+19
View File
@@ -0,0 +1,19 @@
param(
[string]$OutputDirectory = "artifacts\cua-driver\windows"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..\..")).Path
$destination = Join-Path $repoRoot $OutputDirectory
New-Item -ItemType Directory -Force $destination | Out-Null
$rustRoot = Join-Path $repoRoot "libs\cua-driver\rust"
$target = Join-Path $rustRoot "target"
if (Test-Path $target) {
Get-ChildItem -Path $target -Filter "*.log" -Recurse -ErrorAction SilentlyContinue |
Copy-Item -Destination $destination -Force
}
Write-Host "Collected Windows driver artifacts in $destination" -ForegroundColor Green
+236
View File
@@ -0,0 +1,236 @@
# Run the canonical Rust desktop matrix in an active Windows user session.
# Scenario definitions and assertions stay in the Rust integration test.
param(
[switch]$NoBuild,
[switch]$RequireGui
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..\..")).Path
$driverRoot = Join-Path $repoRoot "libs\cua-driver"
$rustRoot = Join-Path $driverRoot "rust"
$suite = if ([string]::IsNullOrWhiteSpace($env:CUA_E2E_INTERNAL_LANE)) { "all" } else { $env:CUA_E2E_INTERNAL_LANE }
if ($suite -notin @("shared", "native", "capture", "all")) {
throw "Unsupported internal lane: $suite"
}
$artifactDir = Join-Path $repoRoot "artifacts\cua-driver\windows"
New-Item -ItemType Directory -Force $artifactDir | Out-Null
$recordingRoot = Join-Path $artifactDir "recordings"
Remove-Item -Path $recordingRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $recordingRoot | Out-Null
$resultsPath = Join-Path $artifactDir "results.jsonl"
$casesPath = Join-Path $artifactDir "cases.jsonl"
$environmentPath = Join-Path $artifactDir "environment.jsonl"
$summaryPath = Join-Path $artifactDir "summary.md"
foreach ($path in @($casesPath, $environmentPath, $resultsPath)) {
New-Item -ItemType File -Force $path | Out-Null
Clear-Content $path
}
Remove-Item -Force -ErrorAction SilentlyContinue $summaryPath
$env:CUA_E2E_DECLARATIONS_FILE = $casesPath
$env:CUA_E2E_ENVIRONMENT_FILE = $environmentPath
$env:CUA_E2E_RESULTS_FILE = $resultsPath
$env:CUA_E2E_RECORDINGS_ROOT = $recordingRoot
$ffmpeg = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
$ffprobe = Get-Command ffprobe.exe -ErrorAction SilentlyContinue
if ($null -eq $ffmpeg -or $null -eq $ffprobe) {
throw "FFmpeg and ffprobe are required for E2E trajectory videos"
}
& (Join-Path $scriptDir "verify-user-session.ps1")
if ($RequireGui) { $env:CUA_REQUIRE_GUI = "1" }
$env:CUA_TEST_WORKSPACE_ROOT = $rustRoot
$env:CUA_TEST_DRIVER_BIN = Join-Path $rustRoot "target\release\cua-driver.exe"
$env:CUA_TEST_APPS_ROOT = Join-Path $rustRoot "test-apps"
$env:CUA_TEST_REQUIRE_FIXTURES = "1"
$env:CUA_TEST_DRIVER_STDERR = "1"
if (-not $NoBuild) {
& cargo build --release -p cua-driver --manifest-path (Join-Path $rustRoot "Cargo.toml")
if ($LASTEXITCODE -ne 0) { throw "Rust driver build failed" }
$fixtureTargets = switch ($suite) {
"shared" { @("electron", "tauri") }
"native" { @("wpf", "winui3", "webview", "electron") }
"capture" { @("wpf", "electron") }
default { @("wpf", "winui3", "webview", "electron", "tauri") }
}
& (Join-Path $scriptDir "build-harnesses.ps1") -Targets $fixtureTargets
}
if (-not (Test-Path $env:CUA_TEST_DRIVER_BIN)) {
throw "Driver binary not found: $($env:CUA_TEST_DRIVER_BIN)"
}
$requiredFixtures = @()
$requiredFixtures += Join-Path $env:CUA_TEST_APPS_ROOT "harness-electron\CuaTestHarness.Electron.exe"
if ($suite -in @("shared", "all")) {
$requiredFixtures += Join-Path $env:CUA_TEST_APPS_ROOT "harness-tauri\CuaTestHarness.Tauri.exe"
}
if ($suite -in @("native", "capture", "all")) {
$requiredFixtures += Join-Path $env:CUA_TEST_APPS_ROOT "harness-wpf\CuaTestHarness.Wpf.exe"
}
if ($suite -in @("native", "all")) {
$requiredFixtures += @(
(Join-Path $env:CUA_TEST_APPS_ROOT "harness-winui3\CuaTestHarness.WinUI3.exe"),
(Join-Path $env:CUA_TEST_APPS_ROOT "harness-webview\CuaTestHarness.WebView.exe")
)
}
foreach ($fixture in $requiredFixtures) {
if (-not (Test-Path $fixture)) { throw "Required fixture was not built: $fixture" }
}
function Invoke-E2eReport {
Push-Location $rustRoot
try {
& cargo run -p cua-driver-testkit --bin cua-e2e-report -- `
--declarations $casesPath `
--environment $environmentPath `
--results $resultsPath `
--artifact-root $artifactDir `
--require-video `
--output $summaryPath | Out-Host
$exitCode = $LASTEXITCODE
return $exitCode
} finally {
Pop-Location
}
}
Write-Host "[PREFLIGHT] Windows desktop, fixture, UIA, capture, and video" -ForegroundColor Yellow
Push-Location $rustRoot
try {
$preflightLog = Join-Path $artifactDir "environment-preflight.log"
$preflightOutput = & cargo test -p cua-driver --test e2e_environment_preflight_test -- `
--ignored --exact canonical_e2e_environment_is_ready --nocapture --test-threads=1 2>&1
$preflightExit = $LASTEXITCODE
$preflightOutput | Tee-Object -FilePath $preflightLog
} finally {
Pop-Location
}
if ($preflightExit -ne 0) {
Invoke-E2eReport | Out-Null
throw "Windows E2E environment preflight failed"
}
function Invoke-CargoTest {
param([string]$Name, [string[]]$Arguments)
Write-Host "[RUN] $Name" -ForegroundColor Yellow
Push-Location $rustRoot
try {
$logPath = Join-Path $artifactDir ("$($Name -replace '[^A-Za-z0-9_.-]', '-').log")
$output = & cargo @Arguments 2>&1
$exitCode = $LASTEXITCODE
$output | Tee-Object -FilePath $logPath
if ($exitCode -ne 0) {
$script:FailureCount++
}
} finally {
Pop-Location
}
}
function Test-E2eRecordings {
$failureCount = 0
$errors = @(Get-ChildItem -Path $recordingRoot -Filter "recording-error.txt" -Recurse -ErrorAction SilentlyContinue)
foreach ($errorFile in $errors) {
Write-Host "[VIDEO FAIL] $($errorFile.FullName)" -ForegroundColor Red
Get-Content $errorFile.FullName | Write-Host
$failureCount++
}
$videos = @(Get-ChildItem -Path $recordingRoot -Filter "recording.mp4" -Recurse -ErrorAction SilentlyContinue)
if ($videos.Count -eq 0) {
Write-Host "[VIDEO FAIL] No E2E trajectory videos were produced" -ForegroundColor Red
return ($failureCount + 1)
}
foreach ($video in $videos) {
& $ffprobe.Source -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 $video.FullName | Out-Null
if ($LASTEXITCODE -ne 0 -or $video.Length -eq 0) {
Write-Host "[VIDEO FAIL] Unplayable trajectory: $($video.FullName)" -ForegroundColor Red
$failureCount++
} else {
Write-Host "[VIDEO PASS] $($video.FullName)" -ForegroundColor Green
}
}
$ownedVideos = @{}
Get-Content $resultsPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object {
$result = $_ | ConvertFrom-Json
if ($null -ne $result.evidence.video) {
$ownedVideos[$result.evidence.video.Replace("\", "/")] = $true
}
}
foreach ($video in $videos) {
$relative = [System.IO.Path]::GetRelativePath($artifactDir, $video.FullName).Replace("\", "/")
if ($relative -like "recordings/environment-preflight-*/recording.mp4") {
continue
}
if (-not $ownedVideos.ContainsKey($relative)) {
Write-Host "[VIDEO FAIL] Orphan trajectory has no typed result row: $relative" -ForegroundColor Red
$failureCount++
}
}
return $failureCount
}
$script:FailureCount = 0
if ($suite -in @("shared", "all")) {
Invoke-CargoTest "shared behavior matrix" @(
"test", "-p", "cua-driver", "--test", "cross_platform_behavior_test", "--",
"--ignored", "--exact", "shared_web_action_matrix_is_state_verified",
"--nocapture", "--test-threads=1"
)
}
if ($suite -in @("native", "all")) {
Invoke-CargoTest "Windows native harnesses" @(
"test", "-p", "cua-driver", "--test", "harness_wpf_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
Invoke-CargoTest "WinUI3 harnesses" @(
"test", "-p", "cua-driver", "--test", "harness_winui3_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
Invoke-CargoTest "Windows web harnesses" @(
"test", "-p", "cua-driver", "--test", "harness_web_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
Invoke-CargoTest "Windows minimized launch" @(
"test", "-p", "cua-driver", "--test", "launch_windows_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
Invoke-CargoTest "Windows agent cursor" @(
"test", "-p", "cua-driver", "--test", "agent_cursor_windows_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
}
if ($suite -in @("capture", "all")) {
Invoke-CargoTest "capture contract" @(
"test", "-p", "cua-driver", "--test", "capture_contract_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
Invoke-CargoTest "Windows desktop scope" @(
"test", "-p", "cua-driver", "--test", "desktop_scope_windows_test", "--",
"--ignored", "--nocapture", "--test-threads=1"
)
}
$script:FailureCount += (Test-E2eRecordings)
$reportExit = Invoke-E2eReport
if ($reportExit -ne 0) {
Write-Host "Windows E2E result validation failed" -ForegroundColor Red
$script:FailureCount++
}
if ($script:FailureCount -ne 0) {
throw "Windows Rust e2e suite had $($script:FailureCount) failing lane(s)"
}
Write-Host "Windows Rust e2e matrix completed: $suite" -ForegroundColor Green
@@ -0,0 +1,11 @@
# Verify that the runner is inside an interactive user session, not Session 0.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$sessionId = (Get-Process -Id $PID).SessionId
if ($sessionId -eq 0) {
throw "The test runner is in Session 0. Start it from the active console/RDP user session."
}
$sessionName = if ($env:SESSIONNAME) { $env:SESSIONNAME } else { "unknown" }
Write-Host "Interactive Windows session verified: id=$sessionId name=$sessionName" -ForegroundColor Green
+132
View File
@@ -0,0 +1,132 @@
# Documentation Generators
This directory contains auto-documentation generators for the Cua libraries. These generators ensure that documentation stays synchronized with source code.
## Architecture
```
scripts/docs-generators/
├── config.json # Central configuration for all generators
├── runner.ts # Main orchestrator that runs generators
├── cua-driver.ts # cua-driver (Rust) generator
├── lume.ts # Lume (Swift) generator
├── cua-cli.ts # Cua CLI (TypeScript) generator (planned)
├── mcp-server.ts # MCP Server (Python) generator (planned)
├── python-sdk.ts # Python SDK generator (planned)
├── typescript-sdk.ts # TypeScript SDK generator (planned)
└── README.md # This file
```
## Quick Start
```bash
# Generate all documentation
npm run docs:generate
# Check for drift (CI mode)
npm run docs:check
# List all configured generators
npm run docs:list
# Generate specific library docs
npx tsx scripts/docs-generators/runner.ts --library lume
```
## How It Works
1. **config.json** defines all libraries that need documentation generation:
- Source paths to watch
- Output paths for generated docs
- Generator scripts to run
- Build commands (if needed)
2. **runner.ts** orchestrates generation:
- Reads config.json
- Detects changed files (in CI)
- Runs appropriate generators
- Reports success/failure
3. **Library-specific generators** (e.g., `lume.ts`):
- Extract metadata from source code
- Generate MDX documentation
- Handle language-specific concerns
## Adding a New Library
1. **Add configuration** to `config.json`:
```json
{
"generators": {
"my-library": {
"name": "My Library",
"language": "python",
"sourcePath": "libs/my-library/src",
"docsOutputPath": "docs/content/docs/cua/reference/my-library",
"generatorScript": "scripts/docs-generators/my-library.ts",
"watchPaths": ["libs/my-library/src/**/*.py"],
"enabled": true
}
}
}
```
2. **Create generator script** (e.g., `my-library.ts`):
```typescript
#!/usr/bin/env npx tsx
// Follow the pattern in lume.ts
```
3. **Update CI workflow** if needed (paths are usually auto-detected from config)
## Extraction Methods
Different libraries use different extraction methods:
| Language | Method | Description |
| ---------- | ------------------------ | ---------------------------------- |
| Rust | `dump-docs` | Built-in command that outputs JSON |
| Swift | `dump-docs` | Built-in command that outputs JSON |
| TypeScript | `yargs-parse` | Parse yargs CLI definitions |
| Python | `argparse-introspection` | Introspect argparse commands |
| Python | `sphinx-autodoc` | Generate from docstrings |
| TypeScript | `typedoc` | Generate from TSDoc comments |
## CI Integration
The `.github/workflows/docs-sync-check.yml` workflow:
1. Detects which libraries have changes
2. Only runs generators for changed libraries
3. Fails if documentation is out of sync
4. Provides helpful fix instructions
## Generator Status
| Generator | Status | Notes |
| ----------------------- | -------------- | ------------------------- |
| cua-driver | ✅ Implemented | CLI + MCP tools |
| lume | ✅ Implemented | CLI + HTTP API |
| cua-cli | ⏸️ Planned | Needs yargs introspection |
| mcp-server | ⏸️ Planned | Needs MCP tool extraction |
| computer-sdk-python | ⏸️ Planned | Needs Sphinx/pydoc |
| computer-sdk-typescript | ⏸️ Planned | Needs TypeDoc |
| agent-sdk-python | ⏸️ Planned | Needs Sphinx/pydoc |
| agent-sdk-typescript | ⏸️ Planned | Needs TypeDoc |
## Development
To test generators locally:
```bash
# Run with verbose output
DEBUG=1 npx tsx scripts/docs-generators/runner.ts
# Generate only one library
npx tsx scripts/docs-generators/runner.ts --library lume
# Check without modifying files
npx tsx scripts/docs-generators/runner.ts --check
```
+186
View File
@@ -0,0 +1,186 @@
{
"$schema": "./config.schema.json",
"description": "Configuration for auto-generating documentation from source code",
"generators": {
"cua-driver": {
"name": "Cua Driver",
"language": "rust",
"sourcePath": "libs/cua-driver/rust/crates",
"docsOutputPath": "docs/content/docs/reference/cua-driver",
"generatorScript": "scripts/docs-generators/cua-driver.ts",
"watchPaths": [
"libs/cua-driver/rust/crates/**/*.rs",
"libs/cua-driver/rust/Cargo.toml",
"libs/cua-driver/rust/Cargo.lock"
],
"buildCommand": "cargo build -p cua-driver --release",
"buildDirectory": "libs/cua-driver/rust",
"extractionMethod": "dump-docs",
"outputs": [
{
"type": "mcp",
"outputFile": "mcp-tools.mdx",
"extractCommand": "target/release/cua-driver dump-docs --type mcp"
},
{
"type": "cli",
"outputFile": "cli-reference.mdx",
"extractCommand": "target/release/cua-driver dump-docs --type cli"
}
],
"enabled": true,
"notes": "Generates cli-reference.mdx + mcp-tools.mdx from the Rust binary's `dump-docs`. MCP tool docs come straight from the live tool registry; CLI docs come from a hand-maintained `cli_docs_json()` literal in crates/cua-driver/src/cli.rs (the CLI is hand-parsed, not clap, so there is nothing to introspect yet — introspecting it is a tracked follow-up). Curated cross-cutting content lives in the sibling non-generated files mcp-tool-notes.mdx and macos-permissions.mdx."
},
"lume": {
"name": "Lume VM Manager",
"language": "swift",
"sourcePath": "libs/lume/src",
"docsOutputPath": "docs/content/docs/reference/lume",
"generatorScript": "scripts/docs-generators/lume.ts",
"watchPaths": [
"libs/lume/src/Commands/**/*.swift",
"libs/lume/src/Server/**/*.swift",
"libs/lume/src/Utils/CommandDocExtractor.swift",
"libs/lume/src/Server/APIDocExtractor.swift"
],
"buildCommand": "swift build -c release",
"buildDirectory": "libs/lume",
"extractionMethod": "dump-docs",
"outputs": [
{
"type": "cli",
"outputFile": "cli-reference.mdx",
"extractCommand": ".build/release/lume dump-docs --type cli"
},
{
"type": "api",
"outputFile": "http-api.mdx",
"extractCommand": ".build/release/lume dump-docs --type api"
}
],
"enabled": true,
"notes": "Generates cli-reference.mdx + http-api.mdx from the Swift binary's `dump-docs` output. These files live under the Diátaxis Reference tree; task-oriented Lume docs belong under how-to-guides/lume."
},
"cua-cli": {
"name": "Cua CLI",
"language": "typescript",
"sourcePath": "libs/typescript/cua-cli/src",
"docsOutputPath": "docs/content/docs/cua/reference/cli-playbook",
"generatorScript": "scripts/docs-generators/cua-cli.ts",
"watchPaths": ["libs/typescript/cua-cli/src/**/*.ts"],
"buildCommand": null,
"buildDirectory": "libs/typescript/cua-cli",
"extractionMethod": "yargs-parse",
"outputs": [
{
"type": "cli",
"outputFile": "commands.mdx",
"extractCommand": null
}
],
"enabled": false,
"notes": "Requires yargs introspection or AST parsing - to be implemented"
},
"mcp-server": {
"name": "MCP Server",
"language": "python",
"sourcePath": "libs/python/mcp-server/src",
"docsOutputPath": "docs/content/docs/cua/reference/mcp-server",
"generatorScript": "scripts/docs-generators/mcp-server.ts",
"watchPaths": ["libs/python/mcp-server/src/**/*.py"],
"buildCommand": null,
"buildDirectory": "libs/python/mcp-server",
"extractionMethod": "mcp-introspection",
"outputs": [
{
"type": "tools",
"outputFile": "tools.mdx",
"extractCommand": null
}
],
"enabled": false,
"notes": "Requires MCP tool introspection - to be implemented"
},
"python-sdk": {
"name": "Python SDKs (Computer + Agent)",
"language": "python",
"sourcePath": "libs/python",
"docsOutputPath": "docs/content/docs/cua/reference",
"generatorScript": "scripts/docs-generators/python-sdk.ts",
"watchPaths": ["libs/python/computer/computer/**/*.py", "libs/python/agent/agent/**/*.py"],
"buildCommand": null,
"buildDirectory": "libs/python",
"extractionMethod": "griffe",
"outputs": [
{
"type": "api",
"outputFile": "computer-sdk/api.mdx",
"extractCommand": "python3 scripts/docs-generators/extract_python_docs.py"
},
{
"type": "api",
"outputFile": "agent-sdk/api.mdx",
"extractCommand": "python3 scripts/docs-generators/extract_python_docs.py"
}
],
"enabled": false,
"notes": "Disabled while these SDK docs are not part of the public docs nav; prevents docs:generate from resurrecting the old docs/content/docs/cua/reference tree."
},
"computer-sdk-typescript": {
"name": "Computer SDK (TypeScript)",
"language": "typescript",
"sourcePath": "libs/typescript/computer/src",
"docsOutputPath": "docs/content/docs/cua/reference/computer-sdk",
"generatorScript": "scripts/docs-generators/typescript-sdk.ts",
"watchPaths": ["libs/typescript/computer/src/**/*.ts"],
"extractionMethod": "typedoc",
"outputs": [
{
"type": "api",
"outputFile": "typescript-api.mdx",
"extractCommand": null
}
],
"enabled": false,
"notes": "Requires TypeDoc or ts-morph introspection - to be implemented"
},
"agent-sdk-typescript": {
"name": "Agent SDK (TypeScript)",
"language": "typescript",
"sourcePath": "libs/typescript/agent/src",
"docsOutputPath": "docs/content/docs/cua/reference/agent-sdk",
"generatorScript": "scripts/docs-generators/typescript-sdk.ts",
"watchPaths": ["libs/typescript/agent/src/**/*.ts"],
"extractionMethod": "typedoc",
"outputs": [
{
"type": "api",
"outputFile": "typescript-api.mdx",
"extractCommand": null
}
],
"enabled": false,
"notes": "Requires TypeDoc or ts-morph introspection - to be implemented"
},
"cuabot": {
"name": "Cua-Bot",
"language": "typescript",
"sourcePath": "libs/cuabot/src",
"docsOutputPath": "docs/content/docs/cuabot/reference",
"generatorScript": "scripts/docs-generators/typescript-sdk.ts",
"watchPaths": ["libs/cuabot/src/**/*.ts"],
"buildCommand": null,
"buildDirectory": "libs/cuabot",
"extractionMethod": "regex-parse",
"outputs": [
{
"type": "api",
"outputFile": "index.mdx",
"extractCommand": null
}
],
"enabled": false,
"notes": "Disabled while cuabot docs are not part of the public docs nav; prevents docs:generate from resurrecting docs/content/docs/cuabot/reference."
}
}
}
+820
View File
@@ -0,0 +1,820 @@
#!/usr/bin/env npx tsx
/**
* Cua Driver Documentation Generator
*
* Generates MDX documentation files from the cua-driver `dump-docs` command output.
* This ensures documentation stays synchronized with the source code.
*
* Usage:
* npx tsx scripts/docs-generators/cua-driver.ts # Generate docs
* npx tsx scripts/docs-generators/cua-driver.ts --check # Check for drift (CI mode)
*/
import { execFileSync, execSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
export interface CLIDocumentation {
name: string;
version: string;
abstract: string;
commands: CommandDoc[];
}
export interface CommandDoc {
name: string;
abstract: string;
discussion?: string;
arguments: ArgumentDoc[];
options: OptionDoc[];
flags: FlagDoc[];
subcommands: CommandDoc[];
}
export interface ArgumentDoc {
name: string;
help: string;
type: string;
is_optional: boolean;
}
export interface OptionDoc {
name: string;
short_name?: string | null;
help: string;
type: string;
default_value?: string | null;
is_optional: boolean;
}
export interface FlagDoc {
name: string;
short_name?: string | null;
help: string;
default_value: boolean;
}
export interface MCPToolDoc {
name: string;
description: string;
input_schema: MCPInputSchema;
}
export interface MCPInputSchema {
type: string;
required?: string[];
properties?: Record<string, MCPPropertyDoc>;
}
export interface MCPPropertyDoc {
type: string;
description: string;
items?: { type: string };
}
export interface MCPDocumentation {
version: string;
tools: MCPToolDoc[];
}
export interface DumpDocsOutput {
cli: CLIDocumentation;
mcp: MCPDocumentation;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const CUA_DRIVER_DIR = path.join(ROOT_DIR, 'libs', 'cua-driver', 'rust');
const CUA_DRIVER_BIN = path.join(
CUA_DRIVER_DIR,
'target',
'release',
process.platform === 'win32' ? 'cua-driver.exe' : 'cua-driver'
);
const DOCS_OUTPUT_DIR = path.join(ROOT_DIR, 'docs', 'content', 'docs', 'reference', 'cua-driver');
const TAG_PREFIX = 'cua-driver-rs-v';
// ============================================================================
// Version Discovery
// ============================================================================
function resolveCargoCommand(): string {
if (process.env.CARGO) {
return process.env.CARGO;
}
const candidates = [
'cargo',
path.join(os.homedir() || '', '.cargo', 'bin', 'cargo'),
'/opt/homebrew/bin/cargo',
'/usr/local/bin/cargo',
];
for (const candidate of candidates) {
try {
execFileSync(candidate, ['--version'], { stdio: 'ignore' });
return candidate;
} catch {
// Try the next candidate.
}
}
throw new Error('cargo not found on PATH; install Rust or set CARGO=/path/to/cargo');
}
/**
* Get the latest released version from git tags.
*/
export function getLatestReleasedVersion(): string {
try {
const output = execSync(`git tag | grep "^${TAG_PREFIX}" | sort -V | tail -1`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
}).trim();
if (output) {
return output.replace(TAG_PREFIX, '');
}
} catch {
// Fall through
}
return '0.0.0';
}
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('--check-only');
console.log('Cua Driver Documentation Generator');
console.log('===================================\n');
// Step 1: Build cua-driver
console.log('Building cua-driver...');
try {
const cargo = resolveCargoCommand();
execFileSync(cargo, ['build', '-p', 'cua-driver', '--release'], {
cwd: CUA_DRIVER_DIR,
stdio: 'inherit',
});
} catch (error) {
console.error('Failed to build cua-driver');
process.exit(1);
}
// Step 2: Extract all docs in a single invocation
console.log('\nExtracting documentation...');
const binary = process.env.CUA_DRIVER_BINARY || CUA_DRIVER_BIN;
const dumpDocsJson = execFileSync(binary, ['dump-docs', '--type', 'all', '--pretty'], {
cwd: CUA_DRIVER_DIR,
encoding: 'utf-8',
});
const dumpDocs: DumpDocsOutput = JSON.parse(dumpDocsJson);
console.log(` Found ${dumpDocs.cli.commands.length} CLI commands`);
console.log(` Found ${dumpDocs.mcp.tools.length} MCP tools`);
// Step 3: Generate MDX files
console.log('\nGenerating documentation files...');
const releasedVersion = getLatestReleasedVersion();
// Use the version embedded in the binary itself as the canonical current
// version — it's always correct even on unreleased branches where no git
// tag exists yet. Fall back to the latest released tag only when the
// binary reports an empty/missing version.
const currentVersion = dumpDocs.cli.version || dumpDocs.mcp.version || releasedVersion;
const cliMdx = generateCLIReferenceMDX(dumpDocs.cli, currentVersion);
const mcpMdx = generateMCPToolsMDX(dumpDocs.mcp, currentVersion);
const cliPath = path.join(DOCS_OUTPUT_DIR, 'cli-reference.mdx');
const mcpPath = path.join(DOCS_OUTPUT_DIR, 'mcp-tools.mdx');
if (checkOnly) {
// Check mode: compare with existing files
console.log('\nChecking for documentation drift...');
let hasDrift = false;
if (fs.existsSync(cliPath)) {
const existingCli = fs.readFileSync(cliPath, 'utf-8');
if (existingCli !== cliMdx) {
console.error('cli-reference.mdx is out of sync with source code');
hasDrift = true;
} else {
console.log('cli-reference.mdx is up to date');
}
} else {
console.error('cli-reference.mdx does not exist');
hasDrift = true;
}
if (fs.existsSync(mcpPath)) {
const existingMcp = fs.readFileSync(mcpPath, 'utf-8');
if (existingMcp !== mcpMdx) {
console.error('mcp-tools.mdx is out of sync with source code');
hasDrift = true;
} else {
console.log('mcp-tools.mdx is up to date');
}
} else {
console.error('mcp-tools.mdx does not exist');
hasDrift = true;
}
if (hasDrift) {
console.error(
"\nRun 'npx tsx scripts/docs-generators/cua-driver.ts' to update documentation"
);
process.exit(1);
}
console.log('\nAll cua-driver documentation is up to date!');
} else {
// Generate mode: write files
fs.mkdirSync(DOCS_OUTPUT_DIR, { recursive: true });
fs.writeFileSync(cliPath, cliMdx);
console.log(` Generated ${path.relative(ROOT_DIR, cliPath)}`);
fs.writeFileSync(mcpPath, mcpMdx);
console.log(` Generated ${path.relative(ROOT_DIR, mcpPath)}`);
console.log('\ncua-driver documentation generated successfully!');
}
}
// ============================================================================
// CLI Reference Generator
// ============================================================================
export function generateCLIReferenceMDX(docs: CLIDocumentation, releasedVersion: string): string {
const lines: string[] = [];
// Frontmatter — must be at the very beginning of the file
lines.push('---');
lines.push('title: CLI Reference');
lines.push('description: Command-line interface specification for Cua Driver');
lines.push('---');
lines.push('');
lines.push(`{/*
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/cua-driver.ts
Source: cua-driver dump-docs
Version: ${releasedVersion}
*/}`);
lines.push('');
// Introduction
lines.push(escapeMdxText(docs.abstract) + ' Install via the official script:');
lines.push('');
lines.push('```sh');
lines.push(
'curl -fsSL https://cua.ai/driver/install.sh | bash'
);
lines.push('```');
lines.push('');
lines.push(
`Documented against Cua Driver **${releasedVersion}**. Run \`cua-driver --version\` for your installed version.`
);
lines.push('');
lines.push(
'The macOS-only `cua-driver permissions` command is documented separately in [macOS permissions](/reference/cua-driver/macos-permissions).'
);
lines.push('');
lines.push('---');
lines.push('');
// Group commands by category
const toolDispatch = ['call', 'list-tools', 'describe'];
const daemonManagement = ['serve', 'stop', 'status', 'mcp', 'mcp-config'];
const trajectoryRecording = ['recording'];
const configuration = ['config'];
const diagnostics = ['diagnose', 'update', 'check-update', 'doctor'];
lines.push('## Tool dispatch');
lines.push('');
for (const cmd of docs.commands.filter((c) => toolDispatch.includes(c.name))) {
lines.push(...generateCommandDoc(cmd));
}
lines.push('## Daemon management');
lines.push('');
for (const cmd of docs.commands.filter((c) => daemonManagement.includes(c.name))) {
lines.push(...generateCommandDoc(cmd));
}
lines.push('## Trajectory recording');
lines.push('');
for (const cmd of docs.commands.filter((c) => trajectoryRecording.includes(c.name))) {
lines.push(...generateCommandDoc(cmd));
}
lines.push('## Configuration');
lines.push('');
for (const cmd of docs.commands.filter((c) => configuration.includes(c.name))) {
lines.push(...generateCommandDoc(cmd));
}
lines.push('## Diagnostics');
lines.push('');
for (const cmd of docs.commands.filter((c) => diagnostics.includes(c.name))) {
lines.push(...generateCommandDoc(cmd));
}
// Any commands not yet categorised above
const allCategorised = [
...toolDispatch,
...daemonManagement,
...trajectoryRecording,
...configuration,
...diagnostics,
];
// Documented on their own pages, so keep them out of this reference.
const excludedFromReference = ['permissions']; // -> reference/cua-driver/macos-permissions
const uncategorised = docs.commands.filter(
(c) => !allCategorised.includes(c.name) && !excludedFromReference.includes(c.name)
);
if (uncategorised.length > 0) {
lines.push('## Other commands');
lines.push('');
for (const cmd of uncategorised) {
lines.push(...generateCommandDoc(cmd));
}
}
// Global options footer
lines.push('## Global options');
lines.push('');
lines.push('Available on all commands:');
lines.push('');
lines.push('- `--help` — Show help information.');
lines.push('- `--version` — Show version number.');
lines.push('');
return lines.join('\n');
}
export function generateCommandDoc(cmd: CommandDoc): string[] {
const lines: string[] = [];
lines.push(`### \`cua-driver ${cmd.name}\``);
lines.push('');
lines.push(escapeMdxText(cmd.abstract));
lines.push('');
if (cmd.discussion) {
lines.push(escapeMdxText(cmd.discussion));
lines.push('');
}
// Arguments table
if (cmd.arguments.length > 0) {
lines.push('**Arguments:**');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const arg of cmd.arguments) {
const required = arg.is_optional ? 'No' : 'Yes';
lines.push(
`| \`<${arg.name}>\` | ${escapeTableCell(arg.type)} | ${required} | ${escapeTableCell(arg.help)} |`
);
}
lines.push('');
}
// Options table
if (cmd.options.length > 0) {
lines.push('**Options:**');
lines.push('');
lines.push('| Name | Type | Default | Description |');
lines.push('| ---- | ---- | ------- | ----------- |');
for (const opt of cmd.options) {
const nameCell = opt.short_name
? `\`-${opt.short_name}\`, \`--${opt.name}\``
: `\`--${opt.name}\``;
const defaultVal = opt.default_value != null ? opt.default_value : '—';
lines.push(
`| ${nameCell} | ${escapeTableCell(opt.type)} | ${escapeTableCell(defaultVal)} | ${escapeTableCell(opt.help)} |`
);
}
lines.push('');
}
// Flags table
if (cmd.flags.length > 0) {
lines.push('**Flags:**');
lines.push('');
lines.push('| Name | Description |');
lines.push('| ---- | ----------- |');
for (const flag of cmd.flags) {
const nameCell = flag.short_name
? `\`-${flag.short_name}\`, \`--${flag.name}\``
: `\`--${flag.name}\``;
lines.push(`| ${nameCell} | ${escapeTableCell(flag.help)} |`);
}
lines.push('');
}
// Subcommands
if (cmd.subcommands.length > 0) {
for (const sub of cmd.subcommands) {
lines.push(`#### \`cua-driver ${cmd.name} ${sub.name}\``);
lines.push('');
lines.push(escapeMdxText(sub.abstract));
lines.push('');
if (sub.discussion) {
lines.push(escapeMdxText(sub.discussion));
lines.push('');
}
if (sub.arguments.length > 0) {
lines.push('**Arguments:**');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const arg of sub.arguments) {
const required = arg.is_optional ? 'No' : 'Yes';
lines.push(
`| \`<${arg.name}>\` | ${escapeTableCell(arg.type)} | ${required} | ${escapeTableCell(arg.help)} |`
);
}
lines.push('');
}
if (sub.options.length > 0) {
lines.push('**Options:**');
lines.push('');
lines.push('| Name | Type | Default | Description |');
lines.push('| ---- | ---- | ------- | ----------- |');
for (const opt of sub.options) {
const nameCell = opt.short_name
? `\`-${opt.short_name}\`, \`--${opt.name}\``
: `\`--${opt.name}\``;
const defaultVal = opt.default_value != null ? opt.default_value : '—';
lines.push(
`| ${nameCell} | ${escapeTableCell(opt.type)} | ${escapeTableCell(defaultVal)} | ${escapeTableCell(opt.help)} |`
);
}
lines.push('');
}
if (sub.flags.length > 0) {
lines.push('**Flags:**');
lines.push('');
lines.push('| Name | Description |');
lines.push('| ---- | ----------- |');
for (const flag of sub.flags) {
const nameCell = flag.short_name
? `\`-${flag.short_name}\`, \`--${flag.name}\``
: `\`--${flag.name}\``;
lines.push(`| ${nameCell} | ${escapeTableCell(flag.help)} |`);
}
lines.push('');
}
// Nested subcommands
if (sub.subcommands.length > 0) {
for (const nested of sub.subcommands) {
lines.push(`##### \`cua-driver ${cmd.name} ${sub.name} ${nested.name}\``);
lines.push('');
lines.push(escapeMdxText(nested.abstract));
lines.push('');
if (nested.discussion) {
lines.push(escapeMdxText(nested.discussion));
lines.push('');
}
if (nested.arguments.length > 0) {
lines.push('**Arguments:**');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const arg of nested.arguments) {
const required = arg.is_optional ? 'No' : 'Yes';
lines.push(
`| \`<${arg.name}>\` | ${escapeTableCell(arg.type)} | ${required} | ${escapeTableCell(arg.help)} |`
);
}
lines.push('');
}
if (nested.options.length > 0) {
lines.push('**Options:**');
lines.push('');
lines.push('| Name | Type | Default | Description |');
lines.push('| ---- | ---- | ------- | ----------- |');
for (const opt of nested.options) {
const nameCell = opt.short_name
? `\`-${opt.short_name}\`, \`--${opt.name}\``
: `\`--${opt.name}\``;
const defaultVal = opt.default_value != null ? opt.default_value : '—';
lines.push(
`| ${nameCell} | ${escapeTableCell(opt.type)} | ${escapeTableCell(defaultVal)} | ${escapeTableCell(opt.help)} |`
);
}
lines.push('');
}
if (nested.flags.length > 0) {
lines.push('**Flags:**');
lines.push('');
lines.push('| Name | Description |');
lines.push('| ---- | ----------- |');
for (const flag of nested.flags) {
const nameCell = flag.short_name
? `\`-${flag.short_name}\`, \`--${flag.name}\``
: `\`--${flag.name}\``;
lines.push(`| ${nameCell} | ${escapeTableCell(flag.help)} |`);
}
lines.push('');
}
}
}
}
}
return lines;
}
// ============================================================================
// MCP Tools Generator
// ============================================================================
export function generateMCPToolsMDX(docs: MCPDocumentation, releasedVersion: string): string {
const lines: string[] = [];
// Frontmatter — must be at the very beginning of the file
lines.push('---');
lines.push('title: MCP Tools');
lines.push('description: Reference for every MCP tool Cua Driver exposes');
lines.push('---');
lines.push('');
lines.push(`{/*
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/cua-driver.ts
Source: cua-driver dump-docs
Version: ${releasedVersion}
*/}`);
lines.push('');
lines.push("import { Callout } from 'fumadocs-ui/components/callout';");
lines.push('');
// Introduction — mirror the existing hand-written header prose
lines.push(
`\`cua-driver\` exposes ${docs.tools.length} MCP tools through a single stdio server (\`cua-driver mcp\`). Every tool is also callable from the shell as \`cua-driver <name> '<JSON-args>'\`.`
);
lines.push('');
lines.push(
'Tool names are `snake_case`. Responses are MCP `CallTool.Result` envelopes: a text content block prefixed with a `✅` summary (or the error reason on failure), plus optional image or structured-content blocks on tools that produce them. See the [CLI reference](/reference/cua-driver/cli-reference) for CLI-specific options like `--socket` and `--screenshot-out-file`.'
);
lines.push('');
lines.push(
'For the cross-cutting parameter contract (shared parameters, required-parameter rules, platform-specific parameters) and the action response shape, see [MCP tool notes](/reference/cua-driver/mcp-tool-notes).'
);
lines.push('');
lines.push('<Callout type="info">');
lines.push(
' Tool names here match the CLI form exactly. `cua-driver list_apps` and the MCP `list_apps` tool run the same code path.'
);
lines.push('</Callout>');
lines.push('');
lines.push('<Callout type="info">');
lines.push(
" **TCC auto-delegation.** When an MCP client spawns `cua-driver mcp` from an IDE terminal (Claude Code, Cursor, VS Code, Warp), macOS attributes the subprocess to the parent terminal — not `CuaDriver.app` — so AX probes fail against the wrong bundle id. `mcp` detects this and auto-launches a `cua-driver serve` daemon via `open -n -g -a CuaDriver --args serve`, then proxies every tool call through the daemon's Unix socket. Tool semantics are identical to the in-process path; no Python bridge is needed. Pass `--no-daemon-relaunch` (or set `CUA_DRIVER_MCP_NO_RELAUNCH=1`) to force in-process execution. See the [process model](/reference/cua-driver/process-model) for the full lifecycle, failure modes, and wrapper-author guidance."
);
lines.push('</Callout>');
lines.push('');
const categories: Array<{ title: string; tools: string[] }> = [
{
title: 'Inspection tools',
tools: [
'list_apps',
'list_windows',
'get_window_state',
'get_accessibility_tree',
'get_desktop_state',
'get_screen_size',
'get_cursor_position',
'get_config',
'get_recording_state',
'get_agent_cursor_state',
],
},
{
title: 'Action tools',
tools: [
'launch_app',
'kill_app',
'bring_to_front',
'click',
'double_click',
'right_click',
'drag',
'type_text',
'press_key',
'hotkey',
'set_value',
'scroll',
'move_cursor',
'zoom',
],
},
{
title: 'Browser tools',
tools: ['page'],
},
{
title: 'Recording tools',
tools: ['start_recording', 'stop_recording', 'replay_trajectory'],
},
{
title: 'Configuration tools',
tools: [
'set_config',
'start_session',
'end_session',
'set_agent_cursor_enabled',
'set_agent_cursor_motion',
'set_agent_cursor_style',
],
},
{
title: 'Maintenance tools',
tools: [
'check_permissions',
'health_report',
'check_for_update',
'install_ffmpeg',
],
},
];
const byName = new Map(docs.tools.map((tool) => [tool.name, tool]));
const emitted = new Set<string>();
for (const category of categories) {
const tools = category.tools
.map((name) => byName.get(name))
.filter((tool): tool is MCPToolDoc => Boolean(tool));
if (tools.length === 0) continue;
lines.push(`## ${category.title}`);
lines.push('');
for (const tool of tools) {
emitted.add(tool.name);
lines.push(...generateMCPToolDoc(tool));
}
}
const uncategorised = docs.tools.filter((tool) => !emitted.has(tool.name));
if (uncategorised.length > 0) {
lines.push('## Other tools');
lines.push('');
for (const tool of uncategorised) {
lines.push(...generateMCPToolDoc(tool));
}
}
return lines.join('\n');
}
export function generateMCPToolDoc(tool: MCPToolDoc): string[] {
const lines: string[] = [];
lines.push(`### \`${tool.name}\``);
lines.push('');
lines.push(escapeMdxText(tool.description));
lines.push('');
const properties = tool.input_schema.properties ?? {};
const required = new Set(tool.input_schema.required ?? []);
const propertyNames = Object.keys(properties);
if (propertyNames.length === 0) {
lines.push('**Arguments:** none.');
lines.push('');
} else {
lines.push('**Arguments:**');
lines.push('');
for (const propName of propertyNames) {
const prop = properties[propName];
const isRequired = required.has(propName);
const requiredLabel = isRequired ? 'required' : 'optional';
const typeLabel = formatPropertyType(prop);
const description = escapeMdxText(prop.description ?? '');
const suffix = description ? `: ${description}` : '';
lines.push(`- \`${propName}\` (${typeLabel}, ${requiredLabel})${suffix}`);
}
lines.push('');
}
// Synthesize a minimal JSON example from required params only.
// Skip the example entirely when there are no required params (tools
// like check_permissions where everything is optional) or when the
// required set is empty after filtering — avoid emitting {} for tools
// that are only valid with at least one of several mutually-optional groups.
const exampleObj: Record<string, unknown> = {};
for (const propName of propertyNames) {
if (required.has(propName)) {
exampleObj[propName] = syntheticExampleValue(propName, properties[propName]);
}
}
if (Object.keys(exampleObj).length > 0) {
lines.push('```json');
lines.push(JSON.stringify(exampleObj));
lines.push('```');
lines.push('');
}
return lines;
}
function escapeTableCell(value: string): string {
return escapeMdxText(value.replace(/\n/g, ' ')).replace(/\|/g, '\\|');
}
function escapeMdxText(value: string): string {
return value
.split(/(`[^`]*`)/g)
.map((segment) => {
if (segment.startsWith('`') && segment.endsWith('`')) {
return segment;
}
return segment
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
})
.join('');
}
function formatPropertyType(prop: MCPPropertyDoc): string {
if (prop.type === 'array') {
const itemType = prop.items?.type ?? 'unknown';
return `array of ${itemType}`;
}
return prop.type;
}
function syntheticExampleValue(name: string, prop: MCPPropertyDoc): unknown {
switch (prop.type) {
case 'integer':
if (name === 'pid') return 844;
if (name === 'window_id') return 10725;
if (name === 'element_index') return 14;
if (name === 'x' || name === 'x1' || name === 'x2' || name === 'from_x' || name === 'to_x')
return 100;
if (name === 'y' || name === 'y1' || name === 'y2' || name === 'from_y' || name === 'to_y')
return 200;
return 1;
case 'number':
if (name === 'x' || name === 'x1' || name === 'x2' || name === 'from_x' || name === 'to_x')
return 100;
if (name === 'y' || name === 'y1' || name === 'y2' || name === 'from_y' || name === 'to_y')
return 200;
return 0.5;
case 'boolean':
return false;
case 'array':
if (name === 'keys') return ['cmd', 'c'];
if (name === 'modifiers') return ['cmd'];
return [];
case 'string':
if (name === 'text') return 'hello';
if (name === 'key') return 'return';
if (name === 'direction') return 'down';
if (name === 'action') return 'get_text';
if (name === 'bundle_id') return 'com.apple.finder';
if (name === 'value') return '42';
if (name === 'key_path' || name === 'key') return 'capture_mode';
if (name === 'dir' || name === 'output_dir') return '~/cua-trajectories/demo1';
return 'example';
default:
return 'value';
}
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
Extract Python API documentation using griffe.
This script extracts structured documentation from Python packages
without requiring them to be installed or imported.
Usage:
python extract_python_docs.py <package_path> <package_name>
Example:
python extract_python_docs.py libs/python/computer/computer computer
"""
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from griffe import (
Alias,
Attribute,
Class,
DocstringSectionKind,
Function,
Module,
Object,
load,
)
def extract_docstring_sections(obj: Object) -> Dict[str, Any]:
"""Extract parsed docstring sections from an object."""
result = {
"description": "",
"params": [],
"returns": None,
"raises": [],
"examples": [],
}
if not obj.docstring:
return result
# Get parsed sections (griffe parses automatically based on detected style)
try:
sections = obj.docstring.parsed
except Exception:
# Fallback to raw docstring
result["description"] = str(obj.docstring.value) if obj.docstring else ""
return result
for section in sections:
if section.kind == DocstringSectionKind.text:
result["description"] = str(section.value).strip()
elif section.kind == DocstringSectionKind.parameters:
for param in section.value:
result["params"].append(
{
"name": param.name,
"type": str(param.annotation) if param.annotation else "",
"description": param.description or "",
"default": str(param.default) if param.default else None,
}
)
elif section.kind == DocstringSectionKind.returns:
ret = section.value
if ret:
result["returns"] = {
"type": (
str(ret.annotation) if hasattr(ret, "annotation") and ret.annotation else ""
),
"description": ret.description if hasattr(ret, "description") else str(ret),
}
elif section.kind == DocstringSectionKind.raises:
for exc in section.value:
result["raises"].append(
{
"type": str(exc.annotation) if exc.annotation else "",
"description": exc.description or "",
}
)
elif section.kind == DocstringSectionKind.examples:
result["examples"].append(str(section.value))
return result
def extract_function(fn: Function, is_method: bool = False) -> Dict[str, Any]:
"""Extract function/method documentation."""
# Check if async (stored in labels)
is_async = "async" in getattr(fn, "labels", set())
# Build signature
params = []
for param in fn.parameters:
param_str = param.name
if param.annotation:
param_str += f": {param.annotation}"
if param.default is not None:
param_str += f" = {param.default}"
params.append(param_str)
signature = f"{'async ' if is_async else ''}def {fn.name}({', '.join(params)})"
if fn.returns:
signature += f" -> {fn.returns}"
docstring_data = extract_docstring_sections(fn)
return {
"name": fn.name,
"signature": signature,
"is_async": is_async,
"is_method": is_method,
"description": docstring_data["description"],
"parameters": docstring_data["params"],
"returns": docstring_data["returns"],
"raises": docstring_data["raises"],
"examples": docstring_data["examples"],
"is_private": fn.name.startswith("_") and not fn.name.startswith("__"),
"is_dunder": fn.name.startswith("__") and fn.name.endswith("__"),
}
def extract_attribute(attr: Attribute) -> Dict[str, Any]:
"""Extract attribute/property documentation."""
return {
"name": attr.name,
"type": str(attr.annotation) if attr.annotation else "",
"description": str(attr.docstring.value) if attr.docstring else "",
"default": str(attr.value) if attr.value else None,
"is_private": attr.name.startswith("_"),
}
def resolve_member(member: Any) -> Any:
"""Resolve an alias to its target if needed."""
if isinstance(member, Alias):
try:
return member.target
except Exception:
return member
return member
def extract_class(cls: Class) -> Dict[str, Any]:
"""Extract class documentation."""
docstring_data = extract_docstring_sections(cls)
# Extract methods
methods = []
for name, member in cls.members.items():
member = resolve_member(member)
if isinstance(member, Function):
method_doc = extract_function(member, is_method=True)
# Skip private methods except __init__
if not method_doc["is_private"] or name == "__init__":
methods.append(method_doc)
# Extract attributes/properties
attributes = []
for name, member in cls.members.items():
member = resolve_member(member)
if isinstance(member, Attribute) and not name.startswith("_"):
attributes.append(extract_attribute(member))
# Get base classes
bases = []
for base in cls.bases:
bases.append(str(base))
return {
"name": cls.name,
"description": docstring_data["description"],
"bases": bases,
"methods": methods,
"attributes": attributes,
"is_private": cls.name.startswith("_"),
}
def extract_module(module: Module, include_private: bool = False) -> Dict[str, Any]:
"""Extract module documentation."""
# Get version from module if available
version = "unknown"
if "__version__" in module.members:
version_attr = module.members["__version__"]
if hasattr(version_attr, "value") and version_attr.value:
version = str(version_attr.value).strip("'\"")
# Get __all__ exports if defined
exports = None
if "__all__" in module.members:
all_attr = module.members["__all__"]
if hasattr(all_attr, "value") and all_attr.value:
try:
# Try to parse __all__ as a list
exports = eval(str(all_attr.value))
except Exception:
exports = None
# Extract classes
classes = []
for name, member in module.members.items():
member = resolve_member(member)
if isinstance(member, Class):
# Include if in __all__ or if public
if exports is None:
if not name.startswith("_") or include_private:
classes.append(extract_class(member))
elif name in exports:
classes.append(extract_class(member))
# Extract module-level functions
functions = []
for name, member in module.members.items():
member = resolve_member(member)
if isinstance(member, Function):
if exports is None:
if not name.startswith("_") or include_private:
functions.append(extract_function(member))
elif name in exports:
functions.append(extract_function(member))
return {
"name": module.name,
"version": version,
"docstring": str(module.docstring.value) if module.docstring else "",
"exports": exports,
"classes": classes,
"functions": functions,
}
def extract_package_docs(package_path: str, package_name: str) -> Dict[str, Any]:
"""
Extract documentation from a Python package.
Args:
package_path: Path to the package directory (e.g., 'libs/python/computer/computer')
package_name: Name of the package to load (e.g., 'computer')
Returns:
Dictionary containing structured documentation
"""
# Resolve paths
package_dir = Path(package_path).resolve()
search_path = package_dir.parent
# Load the package using griffe
try:
package = load(
package_name,
search_paths=[str(search_path)],
)
except Exception as e:
return {
"error": f"Failed to load package: {e}",
"name": package_name,
"version": "unknown",
"modules": [],
}
# Extract main module
main_module = extract_module(package)
# Extract submodules
submodules = []
for name, member in package.members.items():
member = resolve_member(member)
if isinstance(member, Module) and not name.startswith("_"):
submodule_doc = extract_module(member)
if submodule_doc["classes"] or submodule_doc["functions"]:
submodules.append(submodule_doc)
return {
"name": package_name,
"version": main_module["version"],
"docstring": main_module["docstring"],
"exports": main_module["exports"],
"classes": main_module["classes"],
"functions": main_module["functions"],
"submodules": submodules,
}
def main():
"""Main entry point."""
if len(sys.argv) < 3:
print("Usage: python extract_python_docs.py <package_path> <package_name>", file=sys.stderr)
print(
"Example: python extract_python_docs.py libs/python/computer/computer computer",
file=sys.stderr,
)
sys.exit(1)
package_path = sys.argv[1]
package_name = sys.argv[2]
docs = extract_package_docs(package_path, package_name)
print(json.dumps(docs, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,546 @@
#!/usr/bin/env npx tsx
/**
* Changelog Generator
*
* Generates changelog MDX files for each SDK by parsing GitHub releases.
* Fetches all releases, cleans up noisy auto-generated body content, and
* produces clean per-SDK changelogs grouped by major.minor version.
*
* Usage:
* npx tsx scripts/docs-generators/generate-changelog.ts # Generate all
* npx tsx scripts/docs-generators/generate-changelog.ts --check # Check for drift
* npx tsx scripts/docs-generators/generate-changelog.ts --sdk=computer # Specific SDK
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
interface GitHubRelease {
tagName: string;
name: string;
body: string;
publishedAt: string;
isDraft: boolean;
isPrerelease: boolean;
}
interface SDKConfig {
name: string;
displayName: string;
tagPrefix: string;
outputPath: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const DOCS_BASE = path.join(ROOT_DIR, 'docs/content/docs');
const GITHUB_REPO = 'trycua/cua';
const SDK_CONFIGS: SDKConfig[] = [
{
name: 'computer',
displayName: 'Computer SDK',
tagPrefix: 'computer-v',
outputPath: 'cua/reference/computer-sdk/changelog.mdx',
},
{
name: 'agent',
displayName: 'Agent SDK',
tagPrefix: 'agent-v',
outputPath: 'cua/reference/agent-sdk/changelog.mdx',
},
{
name: 'computer-server',
displayName: 'Desktop Sandbox',
tagPrefix: 'computer-server-v',
outputPath: 'cua/reference/desktop-sandbox/changelog.mdx',
},
{
name: 'lume',
displayName: 'Lume',
tagPrefix: 'lume-v',
outputPath: 'reference/lume/changelog.mdx',
},
{
name: 'cuabot',
displayName: 'Cua-Bot',
tagPrefix: 'cuabot-v',
outputPath: 'cuabot/reference/changelog.mdx',
},
];
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check');
const sdkArg = args.find((a) => a.startsWith('--sdk='));
const targetSdk = sdkArg?.split('=')[1];
console.log('Changelog Generator');
console.log('======================\n');
// Fetch ALL releases once (across all SDKs)
console.log('Fetching all GitHub releases...');
const allReleases = fetchAllReleases();
console.log(`Found ${allReleases.length} total releases\n`);
let hasErrors = false;
for (const config of SDK_CONFIGS) {
if (targetSdk && targetSdk !== config.name) {
continue;
}
console.log(`Processing ${config.displayName}...`);
// Filter releases for this SDK
const sdkReleases = allReleases.filter(
(r) => r.tagName.startsWith(config.tagPrefix) && !r.isDraft
);
// Sort by version descending
sdkReleases.sort((a, b) => {
const versionA = a.tagName.replace(config.tagPrefix, '');
const versionB = b.tagName.replace(config.tagPrefix, '');
return compareVersions(versionB, versionA);
});
console.log(` Found ${sdkReleases.length} releases`);
if (sdkReleases.length === 0) {
console.log(` No releases found for ${config.displayName}`);
continue;
}
// Fetch body for each release
const releasesWithBody = fetchBodies(sdkReleases);
// Generate MDX
const mdx = generateChangelogMDX(config, releasesWithBody);
// Output path
const outputPath = path.join(DOCS_BASE, config.outputPath);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (checkOnly) {
if (fs.existsSync(outputPath)) {
const existing = fs.readFileSync(outputPath, 'utf-8');
if (existing !== mdx) {
console.error(` ${config.name} changelog is out of sync`);
hasErrors = true;
} else {
console.log(` ${config.name} changelog is up to date`);
}
} else {
console.error(` ${config.name} changelog does not exist`);
hasErrors = true;
}
} else {
fs.writeFileSync(outputPath, mdx);
console.log(` Generated ${path.relative(ROOT_DIR, outputPath)}`);
}
}
if (hasErrors) {
if (checkOnly) {
console.error("\nRun 'npx tsx scripts/docs-generators/generate-changelog.ts' to update");
}
process.exit(1);
}
console.log('\nChangelog generation complete!');
}
// ============================================================================
// GitHub Release Fetching
// ============================================================================
/**
* Fetch all releases at once (without body) to avoid per-SDK limit issues.
* Uses --limit 500 to get all releases across the entire repo.
*/
function fetchAllReleases(): GitHubRelease[] {
try {
const output = execSync(
'gh release list --limit 500 --json tagName,name,publishedAt,isDraft,isPrerelease',
{ encoding: 'utf-8', cwd: ROOT_DIR, maxBuffer: 10 * 1024 * 1024 }
);
return JSON.parse(output);
} catch (error) {
console.error(`Failed to fetch releases: ${error}`);
return [];
}
}
/**
* Fetch release bodies individually for a filtered set of releases.
*/
function fetchBodies(releases: GitHubRelease[]): GitHubRelease[] {
const results: GitHubRelease[] = [];
for (const release of releases) {
try {
const output = execSync(`gh release view "${release.tagName}" --json body`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
maxBuffer: 1024 * 1024,
});
const data = JSON.parse(output);
results.push({
...release,
body: data.body || '',
});
} catch {
results.push({ ...release, body: '' });
}
}
return results;
}
function compareVersions(a: string, b: string): number {
const partsA = a.split('.').map((x) => parseInt(x, 10) || 0);
const partsB = b.split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) {
return partA - partB;
}
}
return 0;
}
// ============================================================================
// MDX Generation
// ============================================================================
function generateChangelogMDX(config: SDKConfig, releases: GitHubRelease[]): string {
const lines: string[] = [];
// Frontmatter
lines.push('---');
lines.push(`title: Changelog`);
lines.push(`description: Release history for ${config.displayName}`);
lines.push('---');
lines.push('');
// Auto-generated notice
lines.push(`{/*`);
lines.push(` AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY`);
lines.push(` Generated by: npx tsx scripts/docs-generators/generate-changelog.ts`);
lines.push(` Last updated: ${new Date().toISOString().split('T')[0]}`);
lines.push(`*/}`);
lines.push('');
// Introduction
lines.push(`# ${config.displayName} Changelog`);
lines.push('');
lines.push(`All notable changes to the ${config.displayName} are documented here.`);
lines.push('');
// Group releases by major.minor version
const grouped = groupByMajorMinor(releases, config.tagPrefix);
for (const [majorMinor, versionReleases] of Object.entries(grouped)) {
lines.push(`## ${majorMinor}.x`);
lines.push('');
for (const release of versionReleases) {
const version = release.tagName.replace(config.tagPrefix, '');
const date = formatDate(release.publishedAt);
lines.push(`### v${version} (${date})`);
lines.push('');
// Process release body
const body = processReleaseBody(release.body);
if (body) {
lines.push(body);
lines.push('');
} else {
lines.push('Maintenance release.');
lines.push('');
}
}
}
return lines.join('\n');
}
function groupByMajorMinor(
releases: GitHubRelease[],
tagPrefix: string
): Record<string, GitHubRelease[]> {
const grouped: Record<string, GitHubRelease[]> = {};
for (const release of releases) {
const version = release.tagName.replace(tagPrefix, '');
const parts = version.split('.');
const majorMinor = `${parts[0]}.${parts[1] || '0'}`;
if (!grouped[majorMinor]) {
grouped[majorMinor] = [];
}
grouped[majorMinor].push(release);
}
// Sort keys by version descending
const sortedKeys = Object.keys(grouped).sort((a, b) => compareVersions(b, a));
const sortedGrouped: Record<string, GitHubRelease[]> = {};
for (const key of sortedKeys) {
sortedGrouped[key] = grouped[key];
}
return sortedGrouped;
}
function formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toISOString().split('T')[0];
}
// ============================================================================
// Release Body Processing
// ============================================================================
/**
* Clean up a GitHub release body to produce focused changelog content.
*
* Strips:
* - "## What's Changed" header
* - "## Installation" / "## Installation Options" sections
* - "## Usage" / "## Quick Start" / "## Claude Desktop Integration" sections
* - "## Dependencies" section (condensed to one line if present)
* - Package description boilerplate
* - "**Full Changelog**" links
* - Heading markers ## / ### (to avoid hierarchy conflicts)
* - Commit SHAs like (abc1234)
*
* Adds:
* - Linked PR numbers: (#789) -> ([#789](https://github.com/trycua/cua/pull/789))
*
* Detects:
* - Bot-only releases (all entries are "Bump cua-X") -> "Maintenance release."
*/
function processReleaseBody(body: string): string {
if (!body || body.trim() === '') {
return '';
}
let processed = body;
// Remove "**Full Changelog**" links
processed = processed.replace(/\*\*Full Changelog\*\*:.*$/gm, '');
// Remove package description boilerplate headers
processed = processed.replace(
/^##\s*(Computer control library|Computer Server|Base package|MCP Server|Computer Vision|Toolkit for computer-use|Lightweight webUI|Unified CLI).*$/gm,
''
);
// Remove standalone description lines that match boilerplate patterns
processed = processed.replace(
/^(A FastAPI-based server implementation|This package provides (MCP|enhanced UI)|Manage cloud sandboxes).*$/gm,
''
);
// Remove raw checksum lines (sha256 hex + filename)
processed = processed.replace(/^[a-f0-9]{64}\s+\S+.*$/gm, '');
// Remove markdown table rows that look like checksum tables
processed = processed.replace(/^\|?\s*`?[a-f0-9]{64}`?\s*\|.*$/gm, '');
// Remove checksum table headers
processed = processed.replace(/^\|?\s*SHA256\s*\|.*$/gim, '');
processed = processed.replace(/^\|?\s*-+\s*\|.*$/gm, '');
// Extract dependencies from "## Dependencies" section before removing sections
let depsLine = '';
const depsContent = extractSection(processed, 'Dependencies');
if (depsContent !== null) {
const items = depsContent
.split('\n')
.filter((l) => l.trim().startsWith('*'))
.map((l) => l.replace(/^\*\s*/, '').trim());
if (items.length > 0) {
depsLine = `**Dependencies:** ${items.join(', ')}`;
}
processed = removeSection(processed, 'Dependencies');
}
// Remove boilerplate sections (BEFORE removing "What's Changed" heading,
// so section boundaries are intact for removeSection to work correctly)
const sectionsToRemove = [
'Installation Options',
'Installation with script',
'Installation',
'Usage',
'Claude Desktop Integration',
'Quick Start',
];
for (const section of sectionsToRemove) {
processed = removeSection(processed, section);
}
// NOW remove "What's Changed" / "Whats Changed" header line (after sections are removed)
processed = processed.replace(/^#{1,3}\s*What'?s Changed\s*$/gm, '');
// Handle single-line "**Dependencies:**" already in the body (from new workflow format)
const inlineDeps = processed.match(/^\*\*Dependencies:\*\*.*$/m);
if (inlineDeps) {
depsLine = inlineDeps[0].trim();
processed = processed.replace(/^\*\*Dependencies:\*\*.*$/m, '');
}
// Remove release title headings like "# cua-computer v0.4.11"
processed = processed.replace(/^#{1,3}\s*cua-\S+\s+v[\d.]+\s*$/gm, '');
// Strip heading markers (## / ###) from remaining content to avoid hierarchy issues
processed = processed.replace(/^#{1,3}\s+/gm, '');
// Link PR numbers: (#123) -> ([#123](https://github.com/trycua/cua/pull/123))
processed = processed.replace(
/\(#(\d+)\)/g,
`([#$1](https://github.com/${GITHUB_REPO}/pull/$1))`
);
// Strip commit SHAs: (7-8 char hex in parens) from entries
processed = processed.replace(/\s*\([a-f0-9]{7,8}\)/g, '');
// Clean up excessive blank lines
processed = processed.replace(/\n{3,}/g, '\n\n').trim();
// Check if all remaining bullet lines are bot bumps
const contentLines = processed
.split('\n')
.filter((l) => l.trim().startsWith('*') || l.trim().startsWith('-'));
const allBotBumps =
contentLines.length > 0 && contentLines.every((l) => /Bump (cua-|lume)/i.test(l));
if (allBotBumps && !depsLine) {
return 'Maintenance release.';
}
// Re-add dependencies line at the top if present
if (depsLine) {
processed = depsLine + (processed ? '\n\n' + processed : '');
}
// Final check: if nothing meaningful remains
if (!processed.trim()) {
return '';
}
// Escape curly braces outside of code blocks for MDX compatibility
processed = escapeMDXOutsideCode(processed);
return processed;
}
/**
* Extract the content of a markdown section (any heading level) up to the next
* same-or-higher-level heading or end of text.
* Returns null if the section is not found.
*/
function extractSection(text: string, sectionTitle: string): string | null {
const lines = text.split('\n');
let inSection = false;
let sectionLevel = 0;
let content: string[] = [];
for (const line of lines) {
const headerMatch = line.match(
new RegExp(`^(#{1,6})\\s*${escapeRegex(sectionTitle)}\\s*$`, 'i')
);
if (!inSection && headerMatch) {
inSection = true;
sectionLevel = headerMatch[1].length;
continue;
}
if (inSection) {
// Stop at next heading of same or higher level
const headingMatch = line.match(/^(#{1,6})\s/);
if (headingMatch && headingMatch[1].length <= sectionLevel) {
break;
}
content.push(line);
}
}
return inSection ? content.join('\n') : null;
}
/**
* Remove a markdown section (any heading level) and everything until the next
* same-or-higher-level heading or end of text.
*/
function removeSection(text: string, sectionTitle: string): string {
const lines = text.split('\n');
const result: string[] = [];
let inSection = false;
let sectionLevel = 0;
for (const line of lines) {
const headerMatch =
!inSection && line.match(new RegExp(`^(#{1,6})\\s*${escapeRegex(sectionTitle)}\\s*$`, 'i'));
if (headerMatch) {
inSection = true;
sectionLevel = headerMatch[1].length;
continue;
}
if (inSection) {
const headingMatch = line.match(/^(#{1,6})\s/);
if (headingMatch && headingMatch[1].length <= sectionLevel) {
inSection = false;
result.push(line);
}
continue;
}
result.push(line);
}
return result.join('\n');
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeMDXOutsideCode(text: string): string {
const codeBlockRegex = /(```[\s\S]*?```|`[^`]+`)/g;
const parts = text.split(codeBlockRegex);
return parts
.map((part) => {
if (part.startsWith('```') || part.startsWith('`')) {
return part;
}
return part.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
})
.join('');
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
@@ -0,0 +1,681 @@
#!/usr/bin/env npx tsx
/**
* Versioned Documentation Generator
*
* Generates API documentation for historical versions by checking out
* tagged versions from git and running the extraction.
*
* Usage:
* npx tsx scripts/docs-generators/generate-versioned-docs.ts # Generate all
* npx tsx scripts/docs-generators/generate-versioned-docs.ts --sdk=computer # Specific SDK
* npx tsx scripts/docs-generators/generate-versioned-docs.ts --list # List available versions
*/
import { execSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
type ExtractionType = 'python-griffe' | 'swift-dump-docs' | 'typescript';
interface SDKConfig {
name: string;
displayName: string;
tagPrefix: string;
packageDir: string;
packageName: string;
outputDir: string;
extractionType: ExtractionType;
/** Skip versions below this major.minor (e.g. '0.2' skips 0.1.x) */
minVersion?: string;
/** Base docs directory (defaults to cua/reference under DOCS_BASE) */
docsBasePath?: string;
}
interface VersionInfo {
tag: string;
version: string;
majorMinor: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const DOCS_BASE = path.join(ROOT_DIR, 'docs/content/docs');
const DOCS_DIR = path.join(DOCS_BASE, 'cua/reference');
const PYTHON_SCRIPT = path.join(__dirname, 'extract_python_docs.py');
const SDK_CONFIGS: SDKConfig[] = [
{
name: 'computer',
displayName: 'Computer SDK',
tagPrefix: 'computer-v',
packageDir: 'libs/python/computer/computer',
packageName: 'computer',
outputDir: 'computer-sdk',
extractionType: 'python-griffe',
},
{
name: 'agent',
displayName: 'Agent SDK',
tagPrefix: 'agent-v',
packageDir: 'libs/python/agent/agent',
packageName: 'agent',
outputDir: 'agent-sdk',
extractionType: 'python-griffe',
},
{
name: 'bench',
displayName: 'Cua Bench',
tagPrefix: 'bench-v',
packageDir: 'libs/cua-bench/cua_bench',
packageName: 'cua_bench',
outputDir: 'reference',
extractionType: 'python-griffe',
docsBasePath: 'cuabench/reference',
},
{
name: 'lume',
displayName: 'Lume',
tagPrefix: 'lume-v',
packageDir: 'libs/lume',
packageName: 'lume',
outputDir: 'reference/lume',
extractionType: 'swift-dump-docs',
minVersion: '0.2',
docsBasePath: 'reference/lume',
},
{
name: 'cuabot',
displayName: 'Cua-Bot',
tagPrefix: 'cuabot-v',
packageDir: 'libs/cuabot/src',
packageName: 'cuabot',
outputDir: 'reference',
extractionType: 'typescript',
minVersion: '1.0',
docsBasePath: 'cuabot/reference',
},
];
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const listOnly = args.includes('--list');
const sdkArg = args.find((a) => a.startsWith('--sdk='));
const targetSdk = sdkArg?.split('=')[1];
console.log('📚 Versioned Documentation Generator');
console.log('====================================\n');
// Check for uncommitted changes
const status = execSync('git status --porcelain', { encoding: 'utf-8', cwd: ROOT_DIR });
if (status.trim()) {
console.warn('⚠️ Warning: You have uncommitted changes. They will be preserved.\n');
}
for (const config of SDK_CONFIGS) {
if (targetSdk && targetSdk !== config.name) {
continue;
}
console.log(`\n📦 ${config.displayName}`);
console.log('─'.repeat(40));
// Get all version tags
const versions = getVersions(config.tagPrefix);
console.log(` Found ${versions.length} version tags`);
if (listOnly) {
// Just list versions
const grouped = groupByMajorMinor(versions);
for (const [majorMinor, versionList] of Object.entries(grouped)) {
console.log(` v${majorMinor}.x: ${versionList.map((v) => v.version).join(', ')}`);
}
continue;
}
// Group by major.minor (we only generate one doc per major.minor)
const grouped = groupByMajorMinor(versions);
for (const [majorMinor, versionList] of Object.entries(grouped)) {
// Skip versions below minVersion
if (config.minVersion && compareVersions(majorMinor, config.minVersion) < 0) {
console.log(` Skipping v${majorMinor} (below minVersion ${config.minVersion})`);
continue;
}
// Use the latest patch version for this major.minor
const latestVersion = versionList[0];
console.log(`\n Generating v${majorMinor} (from ${latestVersion.tag})...`);
try {
// Generate docs for this version
await generateVersionDocs(config, latestVersion, majorMinor);
const files =
config.extractionType === 'swift-dump-docs'
? 'cli-reference.mdx + http-api.mdx'
: 'api.mdx';
console.log(` ✅ Generated v${majorMinor}/${files}`);
} catch (error) {
console.error(` ❌ Failed: ${error}`);
}
}
}
console.log('\n✅ Versioned documentation generation complete!');
}
// ============================================================================
// Version Discovery
// ============================================================================
function getVersions(tagPrefix: string): VersionInfo[] {
try {
const output = execSync(`git tag | grep "^${tagPrefix}"`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
});
return output
.trim()
.split('\n')
.filter(Boolean)
.map((tag) => {
const version = tag.replace(tagPrefix, '');
const parts = version.split('.');
const majorMinor = `${parts[0]}.${parts[1] || '0'}`;
return { tag, version, majorMinor };
})
.sort((a, b) => compareVersions(b.version, a.version)); // Descending
} catch {
return [];
}
}
function groupByMajorMinor(versions: VersionInfo[]): Record<string, VersionInfo[]> {
const grouped: Record<string, VersionInfo[]> = {};
for (const v of versions) {
if (!grouped[v.majorMinor]) {
grouped[v.majorMinor] = [];
}
grouped[v.majorMinor].push(v);
}
// Sort keys descending
const sortedKeys = Object.keys(grouped).sort((a, b) => compareVersions(b, a));
const sorted: Record<string, VersionInfo[]> = {};
for (const key of sortedKeys) {
sorted[key] = grouped[key];
}
return sorted;
}
function compareVersions(a: string, b: string): number {
const partsA = a.split('.').map((x) => parseInt(x, 10) || 0);
const partsB = b.split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) {
return partA - partB;
}
}
return 0;
}
// ============================================================================
// Documentation Generation
// ============================================================================
async function generateVersionDocs(
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string
): Promise<void> {
// Resolve output directory based on config
const baseDir = config.docsBasePath
? path.join(DOCS_BASE, config.docsBasePath)
: path.join(DOCS_DIR, config.outputDir);
const outputDir = path.join(baseDir, `v${majorMinor}`);
// Create output directory
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Checkout the package directory at the tagged version
const packagePath = path.join(ROOT_DIR, config.packageDir);
try {
// Save current state
execSync(`git stash push -m "versioned-docs-temp" -- ${config.packageDir}`, {
cwd: ROOT_DIR,
stdio: 'pipe',
});
} catch {
// No changes to stash, continue
}
try {
// Checkout tagged version of the package
execSync(`git checkout ${versionInfo.tag} -- ${config.packageDir}`, {
cwd: ROOT_DIR,
stdio: 'pipe',
});
if (config.extractionType === 'swift-dump-docs') {
await generateSwiftVersionDocs(config, versionInfo, majorMinor, outputDir);
} else if (config.extractionType === 'typescript') {
await generateTypeScriptVersionDocs(config, versionInfo, majorMinor, outputDir);
} else {
await generatePythonVersionDocs(config, versionInfo, majorMinor, outputDir);
}
} finally {
// Restore HEAD version
execSync(`git checkout HEAD -- ${config.packageDir}`, {
cwd: ROOT_DIR,
stdio: 'pipe',
});
// Restore stashed changes if any
try {
execSync('git stash pop', { cwd: ROOT_DIR, stdio: 'pipe' });
} catch {
// No stash to pop
}
}
}
async function generatePythonVersionDocs(
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string,
outputDir: string
): Promise<void> {
const packagePath = path.join(ROOT_DIR, config.packageDir);
const outputPath = path.join(outputDir, 'api.mdx');
// Run extraction
const extractOutput = execSync(
`python3 "${PYTHON_SCRIPT}" "${packagePath}" "${config.packageName}"`,
{ encoding: 'utf-8', cwd: ROOT_DIR, maxBuffer: 10 * 1024 * 1024 }
);
const docs = JSON.parse(extractOutput);
// Generate MDX
const mdx = generateMDX(docs, config, versionInfo, majorMinor);
// Write output
fs.writeFileSync(outputPath, mdx);
// Create meta.json for the version folder
const metaPath = path.join(outputDir, 'meta.json');
fs.writeFileSync(
metaPath,
JSON.stringify(
{
title: `v${majorMinor}`,
description: `${config.displayName} v${majorMinor} API Reference`,
pages: ['api'],
},
null,
2
)
);
}
async function generateSwiftVersionDocs(
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string,
outputDir: string
): Promise<void> {
const lumeDir = path.join(ROOT_DIR, config.packageDir);
// Build Lume at this version
try {
execSync('swift build -c release', {
cwd: lumeDir,
stdio: 'pipe',
timeout: 300000, // 5 min build timeout
});
} catch (error) {
throw new Error(`Swift build failed for ${versionInfo.tag}: ${error}`);
}
// Import Lume MDX generators
const { generateCLIReferenceMDX, generateHTTPAPIMDX } = await import('./lume');
// Extract CLI docs
let cliMdx: string;
try {
const cliDocsJson = execSync('.build/release/lume dump-docs --type cli', {
cwd: lumeDir,
encoding: 'utf-8',
});
const cliDocs = JSON.parse(cliDocsJson);
cliMdx = generateVersionedLumeMDX(
generateCLIReferenceMDX(cliDocs),
config,
versionInfo,
majorMinor,
'CLI Reference'
);
} catch (error) {
throw new Error(`CLI docs extraction failed for ${versionInfo.tag}: ${error}`);
}
// Extract API docs
let apiMdx: string;
try {
const apiDocsJson = execSync('.build/release/lume dump-docs --type api', {
cwd: lumeDir,
encoding: 'utf-8',
});
const apiDocs = JSON.parse(apiDocsJson);
apiMdx = generateVersionedLumeMDX(
generateHTTPAPIMDX(apiDocs),
config,
versionInfo,
majorMinor,
'HTTP API Reference'
);
} catch (error) {
throw new Error(`API docs extraction failed for ${versionInfo.tag}: ${error}`);
}
// Write outputs
fs.writeFileSync(path.join(outputDir, 'cli-reference.mdx'), cliMdx);
fs.writeFileSync(path.join(outputDir, 'http-api.mdx'), apiMdx);
// Create meta.json
fs.writeFileSync(
path.join(outputDir, 'meta.json'),
JSON.stringify(
{
title: `v${majorMinor}`,
description: `${config.displayName} v${majorMinor} Reference`,
pages: ['cli-reference', 'http-api'],
},
null,
2
)
);
}
async function generateTypeScriptVersionDocs(
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string,
outputDir: string
): Promise<void> {
// Run the typescript-sdk generator to produce MDX, then wrap it with a version callout
const tsGenScript = path.join(__dirname, 'typescript-sdk.ts');
const outputPath = path.join(outputDir, 'api.mdx');
// Run the TS generator targeting cuabot — it will write to the configured output path
// Instead, we run it inline and capture the current version's output, then modify it
try {
// Generate using the typescript-sdk generator
spawnSync('npx', ['tsx', tsGenScript, `--sdk=${config.name}`], {
cwd: ROOT_DIR,
stdio: 'pipe',
});
// Read the generated file and wrap it with version callout
const generatedPath = path.join(ROOT_DIR, 'docs/content/docs/cuabot/reference/index.mdx');
if (fs.existsSync(generatedPath)) {
let content = fs.readFileSync(generatedPath, 'utf-8');
// Replace VersionHeader with old-version callout
const lines = content.split('\n');
const result: string[] = [];
let skipVersionHeader = false;
for (const line of lines) {
if (line.startsWith('<VersionHeader')) {
skipVersionHeader = true;
result.push('<Callout type="warn">');
result.push(
` This is documentation for **v${majorMinor}**. [View latest version](/cuabot/reference).`
);
result.push('</Callout>');
result.push('');
result.push('<div className="flex items-center gap-2 mb-6">');
result.push(
` <span className="px-2 py-1 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 rounded text-sm font-mono">v${versionInfo.version}</span>`
);
result.push(
` <span className="text-sm text-fd-muted-foreground">npm install -g cuabot@${versionInfo.version}</span>`
);
result.push('</div>');
result.push('');
continue;
}
if (skipVersionHeader) {
if (line.startsWith('/>')) {
skipVersionHeader = false;
continue;
}
continue;
}
result.push(line);
}
// Update title
content = result
.join('\n')
.replace(/^title: .*$/m, `title: ${config.displayName} v${majorMinor} API Reference`);
fs.writeFileSync(outputPath, content);
}
} catch (error) {
throw new Error(`TypeScript docs generation failed for ${versionInfo.tag}: ${error}`);
}
// Create meta.json
fs.writeFileSync(
path.join(outputDir, 'meta.json'),
JSON.stringify(
{
title: `v${majorMinor}`,
description: `${config.displayName} v${majorMinor} API Reference`,
pages: ['api'],
},
null,
2
)
);
}
/**
* Wrap generated Lume MDX with a version warning callout for historical versions.
* Replaces the VersionHeader (which shows current version) with an old-version notice.
*/
function generateVersionedLumeMDX(
currentMdx: string,
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string,
title: string
): string {
// Replace the VersionHeader block with an old-version callout
const lines = currentMdx.split('\n');
const result: string[] = [];
let skipVersionHeader = false;
for (const line of lines) {
// Skip the VersionHeader block
if (line.startsWith('<VersionHeader')) {
skipVersionHeader = true;
// Insert old-version callout instead
result.push('<Callout type="warn">');
result.push(
` This is documentation for **v${majorMinor}**. [View latest version](/reference/lume/cli-reference).`
);
result.push('</Callout>');
result.push('');
result.push('<div className="flex items-center gap-2 mb-6">');
result.push(
` <span className="px-2 py-1 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 rounded text-sm font-mono">v${versionInfo.version}</span>`
);
result.push(
` <span className="text-sm text-fd-muted-foreground">curl -fsSL .../install.sh | bash</span>`
);
result.push('</div>');
result.push('');
continue;
}
if (skipVersionHeader) {
if (line.startsWith('/>')) {
skipVersionHeader = false;
continue;
}
continue;
}
result.push(line);
}
return result.join('\n');
}
// ============================================================================
// MDX Generation (simplified version for historical docs)
// ============================================================================
function generateMDX(
docs: any,
config: SDKConfig,
versionInfo: VersionInfo,
majorMinor: string
): string {
const lines: string[] = [];
// Frontmatter
lines.push('---');
lines.push(`title: ${config.displayName} v${majorMinor} API Reference`);
lines.push(`description: API reference for ${config.displayName} version ${majorMinor}`);
lines.push('---');
lines.push('');
// Auto-generated notice
lines.push(`{/*`);
lines.push(` AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY`);
lines.push(` Generated by: npx tsx scripts/docs-generators/generate-versioned-docs.ts`);
lines.push(` Source tag: ${versionInfo.tag}`);
lines.push(` Version: ${versionInfo.version}`);
lines.push(`*/}`);
lines.push('');
// Imports
lines.push("import { Callout } from 'fumadocs-ui/components/callout';");
lines.push('');
// Version notice — link to the folder root (index.mdx is the landing page)
const latestHref = config.docsBasePath
? `/${config.docsBasePath.replace(/\/$/, '')}/${config.outputDir}`
: `/cua/reference/${config.outputDir}`;
lines.push('<Callout type="warn">');
lines.push(
` This is documentation for **v${majorMinor}**. [View latest version](${latestHref}).`
);
lines.push('</Callout>');
lines.push('');
// Version badge
const pipName = config.packageName.replace(/_/g, '-');
const fullPipName = pipName.startsWith('cua') ? pipName : `cua-${pipName}`;
lines.push('<div className="flex items-center gap-2 mb-6">');
lines.push(
` <span className="px-2 py-1 bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 rounded text-sm font-mono">v${versionInfo.version}</span>`
);
lines.push(
` <span className="text-sm text-fd-muted-foreground">pip install ${fullPipName}==${versionInfo.version}</span>`
);
lines.push('</div>');
lines.push('');
// Package description
if (docs.docstring) {
lines.push(escapeMDX(docs.docstring));
lines.push('');
}
// Classes
if (docs.classes && docs.classes.length > 0) {
lines.push('## Classes');
lines.push('');
lines.push('| Class | Description |');
lines.push('|-------|-------------|');
for (const cls of docs.classes) {
if (!cls.is_private) {
const desc = escapeMDX(cls.description?.split('\n')[0]) || 'No description';
lines.push(`| \`${cls.name}\` | ${desc} |`);
}
}
lines.push('');
// Class details
for (const cls of docs.classes) {
if (!cls.is_private) {
lines.push(`## ${cls.name}`);
lines.push('');
if (cls.description) {
lines.push(escapeMDX(cls.description));
lines.push('');
}
// Methods
const publicMethods = (cls.methods || []).filter(
(m: any) => !m.is_private && !m.is_dunder && m.name !== '__init__'
);
if (publicMethods.length > 0) {
lines.push('### Methods');
lines.push('');
for (const method of publicMethods) {
lines.push(`#### ${cls.name}.${method.name}`);
lines.push('');
lines.push('```python');
lines.push(method.signature || `def ${method.name}(...)`);
lines.push('```');
lines.push('');
if (method.description) {
lines.push(escapeMDX(method.description));
lines.push('');
}
}
}
}
}
}
return lines.join('\n');
}
function escapeMDX(text: string): string {
if (!text) return text;
return text.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+751
View File
@@ -0,0 +1,751 @@
#!/usr/bin/env npx tsx
/**
* Lume Documentation Generator
*
* Generates MDX documentation files from Lume's dump-docs command output.
* This ensures documentation stays synchronized with the source code.
*
* Usage:
* npx tsx scripts/docs-generators/lume.ts # Generate docs
* npx tsx scripts/docs-generators/lume.ts --check # Check for drift (CI mode)
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
export interface CLIDocumentation {
name: string;
version: string;
abstract: string;
commands: CommandDoc[];
}
export interface CommandDoc {
name: string;
abstract: string;
discussion?: string;
arguments: ArgumentDoc[];
options: OptionDoc[];
flags: FlagDoc[];
subcommands: CommandDoc[];
}
export interface ArgumentDoc {
name: string;
help: string;
type: string;
is_optional: boolean;
}
export interface OptionDoc {
name: string;
short_name?: string;
help: string;
type: string;
default_value?: string;
is_optional: boolean;
}
export interface FlagDoc {
name: string;
short_name?: string;
help: string;
default_value: boolean;
}
export interface HTTPAPIDocumentation {
base_path: string;
version: string;
description: string;
endpoints: APIEndpointDoc[];
}
export interface APIEndpointDoc {
method: string;
path: string;
description: string;
category: string;
path_parameters: APIParameterDoc[];
query_parameters: APIParameterDoc[];
request_body?: APIRequestBodyDoc;
response_body: APIResponseDoc;
status_codes: APIStatusCodeDoc[];
}
export interface APIParameterDoc {
name: string;
type: string;
required: boolean;
description: string;
}
export interface APIRequestBodyDoc {
content_type: string;
description: string;
fields: APIFieldDoc[];
}
export interface APIResponseDoc {
content_type: string;
description: string;
fields?: APIFieldDoc[];
}
export interface APIFieldDoc {
name: string;
type: string;
required: boolean;
description: string;
default_value?: string;
}
export interface APIStatusCodeDoc {
code: number;
description: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const LUME_DIR = path.join(ROOT_DIR, 'libs', 'lume');
const DOCS_OUTPUT_DIR = path.join(ROOT_DIR, 'docs', 'content', 'docs', 'reference', 'lume');
const TAG_PREFIX = 'lume-v';
// ============================================================================
// Version Discovery
// ============================================================================
interface VersionInfo {
version: string;
href: string;
isCurrent: boolean;
}
/**
* Get the latest released version from git tags.
*/
export function getLatestReleasedVersion(): string {
try {
const output = execSync(`git tag | grep "^${TAG_PREFIX}" | sort -V | tail -1`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
}).trim();
if (output) {
return output.replace(TAG_PREFIX, '');
}
} catch {
// Fall through
}
return '0.0.0';
}
/**
* Discover available versioned doc folders and build version list.
*/
export function discoverVersions(currentVersion: string): VersionInfo[] {
const versions: VersionInfo[] = [];
const currentMajorMinor = currentVersion.split('.').slice(0, 2).join('.');
// Add current version (latest)
versions.push({
version: currentMajorMinor,
href: '/reference/lume/cli-reference',
isCurrent: true,
});
// Discover versioned folders (v0.2, v0.1, etc.)
if (fs.existsSync(DOCS_OUTPUT_DIR)) {
const entries = fs.readdirSync(DOCS_OUTPUT_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('v')) {
const version = entry.name.substring(1);
if (version === currentMajorMinor) continue;
versions.push({
version,
href: `/reference/lume/${entry.name}/cli-reference`,
isCurrent: false,
});
}
}
}
// Sort descending
versions.sort((a, b) => {
const partsA = a.version.split('.').map((x) => parseInt(x, 10) || 0);
const partsB = b.version.split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) return partB - partA;
}
return 0;
});
return versions;
}
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('--check-only');
console.log('🔧 Lume Documentation Generator');
console.log('================================\n');
// Step 1: Build Lume if needed
console.log('📦 Building Lume...');
try {
execSync('swift build -c release', {
cwd: LUME_DIR,
stdio: 'inherit',
});
} catch (error) {
console.error('❌ Failed to build Lume');
process.exit(1);
}
// Step 2: Get CLI documentation
console.log('\n📖 Extracting CLI documentation...');
const cliDocsJson = execSync('.build/release/lume dump-docs --type cli', {
cwd: LUME_DIR,
encoding: 'utf-8',
});
const cliDocs: CLIDocumentation = JSON.parse(cliDocsJson);
console.log(` Found ${cliDocs.commands.length} commands`);
// Step 3: Get API documentation
console.log('📖 Extracting API documentation...');
const apiDocsJson = execSync('.build/release/lume dump-docs --type api', {
cwd: LUME_DIR,
encoding: 'utf-8',
});
const apiDocs: HTTPAPIDocumentation = JSON.parse(apiDocsJson);
console.log(` Found ${apiDocs.endpoints.length} endpoints`);
// Step 4: Generate MDX files
console.log('\n📝 Generating documentation files...');
const cliMdx = generateCLIReferenceMDX(cliDocs);
const apiMdx = generateHTTPAPIMDX(apiDocs);
const cliPath = path.join(DOCS_OUTPUT_DIR, 'cli-reference.mdx');
const apiPath = path.join(DOCS_OUTPUT_DIR, 'http-api.mdx');
if (checkOnly) {
// Check mode: compare with existing files
console.log('\n🔍 Checking for documentation drift...');
let hasDrift = false;
if (fs.existsSync(cliPath)) {
const existingCli = fs.readFileSync(cliPath, 'utf-8');
if (existingCli !== cliMdx) {
console.error('❌ cli-reference.mdx is out of sync with source code');
hasDrift = true;
} else {
console.log('✅ cli-reference.mdx is up to date');
}
} else {
console.error('❌ cli-reference.mdx does not exist');
hasDrift = true;
}
if (fs.existsSync(apiPath)) {
const existingApi = fs.readFileSync(apiPath, 'utf-8');
if (existingApi !== apiMdx) {
console.error('❌ http-api.mdx is out of sync with source code');
hasDrift = true;
} else {
console.log('✅ http-api.mdx is up to date');
}
} else {
console.error('❌ http-api.mdx does not exist');
hasDrift = true;
}
if (hasDrift) {
console.error("\n💡 Run 'npx tsx scripts/docs-generators/lume.ts' to update documentation");
process.exit(1);
}
console.log('\n✅ All Lume documentation is up to date!');
} else {
// Generate mode: write files
fs.writeFileSync(cliPath, cliMdx);
console.log(` ✅ Generated ${path.relative(ROOT_DIR, cliPath)}`);
fs.writeFileSync(apiPath, apiMdx);
console.log(` ✅ Generated ${path.relative(ROOT_DIR, apiPath)}`);
console.log('\n✅ Lume documentation generated successfully!');
}
}
// ============================================================================
// CLI Reference Generator
// ============================================================================
export function generateCLIReferenceMDX(docs: CLIDocumentation): string {
const lines: string[] = [];
const documentedVersion = docs.version || getLatestReleasedVersion();
// Header - frontmatter MUST be at the very beginning of the file
lines.push('---');
lines.push('title: CLI Reference');
lines.push('description: Command Line Interface reference for Lume');
lines.push('---');
lines.push('');
lines.push(`{/*
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/lume.ts
Source: lume dump-docs --type cli
Version: ${documentedVersion}
*/}`);
lines.push('');
lines.push(`${docs.abstract}`);
lines.push('');
lines.push(
`Documented against Lume **${documentedVersion}**. Run \`lume --version\` for your installed version.`
);
lines.push('');
lines.push('For installation steps, see [Install Lume](/how-to-guides/lume/install-lume).');
lines.push('');
const groups = [
{ title: 'VM Management', commands: ['create', 'run', 'stop', 'delete', 'clone'] },
{ title: 'VM Information and Configuration', commands: ['ls', 'get', 'set'] },
{
title: 'Image Management',
commands: ['images', 'pull', 'push', 'convert', 'ipsw', 'prune'],
},
{ title: 'Guest Access and Security', commands: ['ssh', 'setup', 'sip'] },
{
title: 'Configuration and Server',
commands: ['config', 'serve', 'logs', 'check-update', 'update'],
},
{ title: 'Developer Tools', commands: ['dump-docs'] },
];
const assignedCommands = groups.flatMap((group) => group.commands);
const duplicateAssignments = assignedCommands.filter(
(name, index) => assignedCommands.indexOf(name) !== index
);
const ungroupedCommands = docs.commands
.map((command) => command.name)
.filter((name) => !assignedCommands.includes(name));
if (duplicateAssignments.length > 0 || ungroupedCommands.length > 0) {
throw new Error(
`CLI command grouping mismatch. Ungrouped: ${[...new Set(ungroupedCommands)].sort().join(', ') || 'none'}; duplicated: ${[...new Set(duplicateAssignments)].sort().join(', ') || 'none'}`
);
}
for (const group of groups) {
lines.push(`## ${group.title}`);
lines.push('');
for (const commandName of group.commands) {
const command = docs.commands.find((candidate) => candidate.name === commandName);
if (command) {
lines.push(...generateCommandDoc(command, '###'));
}
}
}
// Global options
lines.push('## Global Options');
lines.push('');
lines.push('These options are available for all commands:');
lines.push('');
lines.push('- `--help` - Show help information');
lines.push('- `--version` - Show version number');
lines.push('');
return lines.join('\n');
}
export function generateCommandDoc(cmd: CommandDoc, heading: string): string[] {
const lines: string[] = [];
lines.push(`${heading} lume ${cmd.name}`);
lines.push('');
lines.push(cmd.abstract);
lines.push('');
// Arguments
if (cmd.arguments.length > 0) {
lines.push('**Arguments:**');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const arg of cmd.arguments) {
const required = arg.is_optional ? 'No' : 'Yes';
lines.push(`| \`<${arg.name}>\` | ${arg.type} | ${required} | ${arg.help} |`);
}
lines.push('');
}
// Options
if (cmd.options.length > 0) {
lines.push('**Options:**');
lines.push('');
lines.push('| Name | Type | Default | Description |');
lines.push('| ---- | ---- | ------- | ----------- |');
for (const opt of cmd.options) {
const shortFlag = opt.short_name ? `-${opt.short_name}, ` : '';
const defaultVal = opt.default_value || '-';
lines.push(`| \`${shortFlag}--${opt.name}\` | ${opt.type} | ${defaultVal} | ${opt.help} |`);
}
lines.push('');
}
// Flags
if (cmd.flags.length > 0) {
lines.push('**Flags:**');
lines.push('');
lines.push('| Name | Default | Description |');
lines.push('| ---- | ------- | ----------- |');
for (const flag of cmd.flags) {
const shortFlag = flag.short_name ? `-${flag.short_name}, ` : '';
lines.push(`| \`${shortFlag}--${flag.name}\` | ${flag.default_value} | ${flag.help} |`);
}
lines.push('');
}
// Subcommands
if (cmd.subcommands.length > 0) {
lines.push('**Subcommands:**');
lines.push('');
for (const sub of cmd.subcommands) {
lines.push(`- \`lume ${cmd.name} ${sub.name}\` - ${sub.abstract}`);
// Show subcommand details
if (sub.arguments.length > 0 || sub.options.length > 0) {
for (const arg of sub.arguments) {
lines.push(` - \`<${arg.name}>\` - ${arg.help}`);
}
for (const opt of sub.options) {
const shortFlag = opt.short_name ? `-${opt.short_name}, ` : '';
lines.push(` - \`${shortFlag}--${opt.name}\` - ${opt.help}`);
}
}
// Nested subcommands
if (sub.subcommands.length > 0) {
for (const nested of sub.subcommands) {
lines.push(` - \`lume ${cmd.name} ${sub.name} ${nested.name}\` - ${nested.abstract}`);
}
}
}
lines.push('');
}
return lines;
}
// ============================================================================
// HTTP API Reference Generator
// ============================================================================
export function generateHTTPAPIMDX(docs: HTTPAPIDocumentation): string {
const lines: string[] = [];
const documentedVersion = docs.version || getLatestReleasedVersion();
// Header - frontmatter MUST be at the very beginning of the file
lines.push('---');
lines.push('title: API Reference');
lines.push('description: HTTP API reference for Lume server');
lines.push('---');
lines.push('');
lines.push(`{/*
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/lume.ts
Source: lume dump-docs --type api
Version: ${documentedVersion}
*/}`);
lines.push('');
lines.push("import { Tabs, Tab } from 'fumadocs-ui/components/tabs';");
lines.push('');
// Introduction
lines.push(docs.description);
lines.push('');
lines.push(
`Documented against Lume **${documentedVersion}**. Run \`lume --version\` for your installed version.`
);
lines.push('');
lines.push('## Default URL');
lines.push('');
lines.push('```');
lines.push('http://localhost:7777');
lines.push('```');
lines.push('');
lines.push(
'Start the server with `lume serve` or specify a custom port with `lume serve --port <port>`.'
);
lines.push('');
// Group endpoints by category
const categories = [...new Set(docs.endpoints.map((e) => e.category))];
for (const category of categories) {
lines.push(`## ${category}`);
lines.push('');
const categoryEndpoints = docs.endpoints.filter((e) => e.category === category);
for (const endpoint of categoryEndpoints) {
lines.push(...generateEndpointDoc(endpoint));
}
}
return lines.join('\n');
}
export function generateEndpointDoc(endpoint: APIEndpointDoc): string[] {
const lines: string[] = [];
lines.push(`### ${endpoint.description}`);
lines.push('');
lines.push(endpoint.description);
lines.push('');
lines.push(`\`${endpoint.method}: ${endpoint.path}\``);
lines.push('');
// Parameters table (path + query)
const allParams = [
...endpoint.path_parameters.map((p) => ({ ...p, location: 'path' })),
...endpoint.query_parameters.map((p) => ({ ...p, location: 'query' })),
];
if (allParams.length > 0) {
lines.push('#### Parameters');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const param of allParams) {
const required = param.required ? 'Yes' : 'No';
lines.push(`| ${param.name} | ${param.type} | ${required} | ${param.description} |`);
}
lines.push('');
}
// Request body
if (endpoint.request_body) {
lines.push('#### Request Body');
lines.push('');
lines.push('| Name | Type | Required | Description |');
lines.push('| ---- | ---- | -------- | ----------- |');
for (const field of endpoint.request_body.fields) {
const required = field.required ? 'Yes' : 'No';
const defaultStr = field.default_value ? ` (default: ${field.default_value})` : '';
lines.push(
`| ${field.name} | ${field.type} | ${required} | ${field.description}${defaultStr} |`
);
}
lines.push('');
}
// Example request
lines.push('#### Example Request');
lines.push('');
lines.push("<Tabs groupId=\"language\" persist items={['Curl', 'Python', 'TypeScript']}>");
// Generate curl example
lines.push(' <Tab value="Curl">');
lines.push('```bash');
lines.push(generateCurlExample(endpoint));
lines.push('```');
lines.push(' </Tab>');
// Generate Python example
lines.push(' <Tab value="Python">');
lines.push('```python');
lines.push(generatePythonExample(endpoint));
lines.push('```');
lines.push(' </Tab>');
// Generate TypeScript example
lines.push(' <Tab value="TypeScript">');
lines.push('```typescript');
lines.push(generateTypeScriptExample(endpoint));
lines.push('```');
lines.push(' </Tab>');
lines.push('</Tabs>');
lines.push('');
// Status codes
lines.push('#### Response');
lines.push('');
for (const status of endpoint.status_codes) {
lines.push(`- **${status.code}**: ${status.description}`);
}
lines.push('');
lines.push('---');
lines.push('');
return lines;
}
function generateCurlExample(endpoint: APIEndpointDoc): string {
const path = endpoint.path.replace(/:(\w+)/g, (_, name) => getExamplePathValue(name));
const url = `http://localhost:7777${path}`;
if (endpoint.method === 'GET' || endpoint.method === 'DELETE') {
if (endpoint.method === 'DELETE') {
return `curl -X DELETE "${url}"`;
}
return `curl "${url}"`;
}
// POST/PATCH with body
if (endpoint.request_body && endpoint.request_body.fields.length > 0) {
const bodyObj: Record<string, unknown> = {};
for (const field of endpoint.request_body.fields) {
if (field.required) {
bodyObj[field.name] = getExampleValue(field);
}
}
const bodyJson = JSON.stringify(bodyObj, null, 2);
return `curl -X ${endpoint.method} "http://localhost:7777${path}" \\
-H "Content-Type: application/json" \\
-d '${bodyJson}'`;
}
return `curl -X ${endpoint.method} "${url}"`;
}
function generatePythonExample(endpoint: APIEndpointDoc): string {
const path = endpoint.path.replace(/:(\w+)/g, (_, name) => getExamplePathValue(name));
const method = endpoint.method.toLowerCase();
const lines: string[] = [];
lines.push('import requests');
lines.push('');
if (
endpoint.request_body &&
endpoint.request_body.fields.length > 0 &&
(endpoint.method === 'POST' || endpoint.method === 'PATCH')
) {
lines.push('data = {');
for (const field of endpoint.request_body.fields) {
if (field.required) {
const value = getExampleValuePython(field);
lines.push(` "${field.name}": ${value},`);
}
}
lines.push('}');
lines.push('');
lines.push(`response = requests.${method}("http://localhost:7777${path}", json=data)`);
} else {
lines.push(`response = requests.${method}("http://localhost:7777${path}")`);
}
lines.push('print(response.json())');
return lines.join('\n');
}
function generateTypeScriptExample(endpoint: APIEndpointDoc): string {
const path = endpoint.path.replace(/:(\w+)/g, (_, name) => getExamplePathValue(name));
const lines: string[] = [];
if (
endpoint.request_body &&
endpoint.request_body.fields.length > 0 &&
(endpoint.method === 'POST' || endpoint.method === 'PATCH')
) {
lines.push(`const response = await fetch(\`http://localhost:7777${path}\`, {`);
lines.push(` method: "${endpoint.method}",`);
lines.push(' headers: { "Content-Type": "application/json" },');
lines.push(' body: JSON.stringify({');
for (const field of endpoint.request_body.fields) {
if (field.required) {
const value = getExampleValueTS(field);
lines.push(` ${field.name}: ${value},`);
}
}
lines.push(' }),');
lines.push('});');
} else if (endpoint.method === 'DELETE') {
lines.push(`const response = await fetch(\`http://localhost:7777${path}\`, {`);
lines.push(` method: "DELETE",`);
lines.push('});');
} else {
lines.push(`const response = await fetch(\`http://localhost:7777${path}\`);`);
}
lines.push('const data = await response.json();');
return lines.join('\n');
}
function getExampleValue(field: APIFieldDoc): unknown {
switch (field.type) {
case 'string':
if (field.name === 'name') return 'my-vm';
if (field.name === 'os') return 'macOS';
if (field.name === 'memory') return '8GB';
if (field.name === 'diskSize') return '50GB';
if (field.name === 'display') return '1024x768';
if (field.name === 'image') return 'macos-tahoe-vanilla:latest';
if (field.name === 'path') return '/path/to/storage';
return 'example';
case 'integer':
if (field.name === 'cpu') return 4;
return 1;
case 'boolean':
return false;
case 'array':
if (field.name === 'tags') return ['latest'];
return [];
default:
return 'value';
}
}
function getExampleValuePython(field: APIFieldDoc): string {
const val = getExampleValue(field);
if (typeof val === 'string') return `"${val}"`;
if (typeof val === 'boolean') return val ? 'True' : 'False';
if (Array.isArray(val)) return JSON.stringify(val);
return String(val);
}
function getExampleValueTS(field: APIFieldDoc): string {
const val = getExampleValue(field);
if (typeof val === 'string') return `"${val}"`;
if (Array.isArray(val)) return JSON.stringify(val);
return String(val);
}
function getExamplePathValue(name: string): string {
if (name === 'name') return 'my-vm';
if (name === 'id') return 'example-id';
return `example-${name}`;
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+932
View File
@@ -0,0 +1,932 @@
#!/usr/bin/env npx tsx
/**
* Python SDK Documentation Generator
*
* Generates MDX API reference documentation from Python source code docstrings.
* Uses griffe to extract documentation without importing packages.
*
* Usage:
* npx tsx scripts/docs-generators/python-sdk.ts # Generate all
* npx tsx scripts/docs-generators/python-sdk.ts --sdk=computer # Generate specific SDK
* npx tsx scripts/docs-generators/python-sdk.ts --check # Check for drift (CI mode)
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
interface PythonPackage {
name: string;
version: string;
docstring: string;
exports: string[] | null;
classes: ClassDoc[];
functions: FunctionDoc[];
submodules: ModuleDoc[];
error?: string;
}
interface ModuleDoc {
name: string;
version: string;
docstring: string;
exports: string[] | null;
classes: ClassDoc[];
functions: FunctionDoc[];
}
interface ClassDoc {
name: string;
description: string;
bases: string[];
methods: FunctionDoc[];
attributes: AttributeDoc[];
is_private: boolean;
}
interface FunctionDoc {
name: string;
signature: string;
is_async: boolean;
is_method: boolean;
description: string;
parameters: ParameterDoc[];
returns: ReturnDoc | null;
raises: RaiseDoc[];
examples: string[];
is_private: boolean;
is_dunder: boolean;
}
interface ParameterDoc {
name: string;
type: string;
description: string;
default: string | null;
}
interface ReturnDoc {
type: string;
description: string;
}
interface RaiseDoc {
type: string;
description: string;
}
interface AttributeDoc {
name: string;
type: string;
description: string;
default: string | null;
is_private: boolean;
}
interface SDKConfig {
packageDir: string;
packageName: string;
outputPath: string;
displayName: string;
description: string;
outputDir: string; // For version discovery (relative to docsBaseDir)
tagPrefix: string; // Git tag prefix for version discovery
/** Absolute docs dir for this SDK (defaults to docs/content/docs/cua/reference) */
docsBaseDir?: string;
/** URL base path for hrefs (defaults to /cua/reference) */
hrefBase?: string;
/** Submodules to include in docs (if set, only these are included; if unset, all are included) */
includeSubmodules?: string[];
/** Override the page title (defaults to "${displayName} API Reference") */
pageTitle?: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const PYTHON_SCRIPT = path.join(__dirname, 'extract_python_docs.py');
const SDK_CONFIGS: Record<string, SDKConfig> = {
computer: {
packageDir: 'libs/python/computer/computer',
packageName: 'computer',
outputPath: 'docs/content/docs/cua/reference/computer-sdk/index.mdx',
displayName: 'Computer SDK',
description: 'Python API reference for controlling virtual machines and computer interfaces',
outputDir: 'computer-sdk',
tagPrefix: 'computer-v',
includeSubmodules: ['interface', 'models', 'tracing', 'helpers', 'diorama_computer'],
},
agent: {
packageDir: 'libs/python/agent/agent',
packageName: 'agent',
outputPath: 'docs/content/docs/cua/reference/agent-sdk/index.mdx',
displayName: 'Agent SDK',
description: 'Python API reference for building computer-use agents',
outputDir: 'agent-sdk',
tagPrefix: 'agent-v',
includeSubmodules: ['callbacks', 'tools', 'types'],
},
cli: {
packageDir: 'libs/python/cua-cli/cua_cli',
packageName: 'cua_cli',
outputPath: 'docs/content/docs/cua/reference/cli/index.mdx',
displayName: 'Cua CLI',
description: 'Python API reference for the Cua command-line interface',
outputDir: 'cli',
tagPrefix: 'cli-v',
},
sandbox: {
packageDir: 'libs/python/cua-sandbox/cua_sandbox',
packageName: 'cua_sandbox',
outputPath: 'docs/content/docs/cua/reference/sandbox-sdk/index.mdx',
displayName: 'Sandbox SDK',
description: 'Python API reference for cua-sandbox — creating and controlling sandboxes',
outputDir: 'sandbox-sdk',
tagPrefix: 'sandbox-v',
includeSubmodules: ['sandbox', 'image', 'localhost', 'interfaces', 'builder'],
},
bench: {
packageDir: 'libs/cua-bench/cua_bench',
packageName: 'cua_bench',
outputPath: 'docs/content/docs/cuabench/reference/api.mdx',
displayName: 'Cua Bench',
description: 'Python API reference for the desktop automation benchmarking framework',
outputDir: 'reference',
tagPrefix: 'bench-v',
docsBaseDir: 'docs/content/docs/cuabench',
hrefBase: '/cuabench',
pageTitle: 'API Reference',
},
};
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('--check-only');
const sdkArg = args.find((a) => a.startsWith('--sdk='));
const targetSdk = sdkArg?.split('=')[1];
console.log('🐍 Python SDK Documentation Generator');
console.log('=====================================\n');
// Check if Python script exists
if (!fs.existsSync(PYTHON_SCRIPT)) {
console.error(`❌ Python extraction script not found: ${PYTHON_SCRIPT}`);
process.exit(1);
}
let hasErrors = false;
for (const [sdkName, config] of Object.entries(SDK_CONFIGS)) {
// Skip if targeting specific SDK
if (targetSdk && targetSdk !== sdkName) {
continue;
}
console.log(`📖 Processing ${config.displayName}...`);
// Check if package exists
const packagePath = path.join(ROOT_DIR, config.packageDir);
if (!fs.existsSync(packagePath)) {
console.error(` ❌ Package not found: ${config.packageDir}`);
hasErrors = true;
continue;
}
// Extract documentation using Python script
console.log(` Extracting documentation from ${config.packageDir}...`);
let docs: PythonPackage;
try {
// Prefer uv run --with griffe python (works cross-platform), fall back to python3
const pythonCmd = process.platform === 'win32' ? `uv run --with griffe python` : `python3`;
const output = execSync(
`${pythonCmd} "${PYTHON_SCRIPT}" "${packagePath}" "${config.packageName}"`,
{
encoding: 'utf-8',
cwd: ROOT_DIR,
timeout: 60000,
}
);
docs = JSON.parse(output);
} catch (error) {
console.error(` ❌ Failed to extract documentation: ${error}`);
hasErrors = true;
continue;
}
if (docs.error) {
console.error(` ❌ Extraction error: ${docs.error}`);
hasErrors = true;
continue;
}
console.log(` Found ${docs.classes.length} classes, ${docs.functions.length} functions`);
// Generate MDX
console.log(` Generating MDX...`);
const mdx = generateMDX(docs, config);
// Ensure output directory exists
const outputPath = path.join(ROOT_DIR, config.outputPath);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (checkOnly) {
// Check mode: compare with existing file
if (fs.existsSync(outputPath)) {
const existing = fs.readFileSync(outputPath, 'utf-8');
if (existing !== mdx) {
console.error(`${path.basename(outputPath)} is out of sync with source code`);
hasErrors = true;
} else {
console.log(`${path.basename(outputPath)} is up to date`);
}
} else {
console.error(`${path.basename(outputPath)} does not exist`);
hasErrors = true;
}
} else {
// Generate mode: write file
fs.writeFileSync(outputPath, mdx);
console.log(` ✅ Generated ${path.relative(ROOT_DIR, outputPath)}`);
}
}
if (hasErrors) {
if (checkOnly) {
console.error(
"\n💡 Run 'npx tsx scripts/docs-generators/python-sdk.ts' to update documentation"
);
}
process.exit(1);
}
console.log('\n✅ Python SDK documentation generation complete!');
}
// ============================================================================
// Version Discovery
// ============================================================================
interface VersionInfo {
version: string;
href: string;
isCurrent: boolean;
}
/**
* Get the latest released version from git tags.
* Falls back to the version from source if no tags found.
*/
function getLatestReleasedVersion(config: SDKConfig, fallbackVersion: string): string {
try {
const output = execSync(`git tag | grep "^${config.tagPrefix}" | sort -V | tail -1`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
}).trim();
if (output) {
return output.replace(config.tagPrefix, '');
}
} catch {
// Fall through to fallback
}
return fallbackVersion;
}
function discoverVersions(config: SDKConfig, currentVersion: string): VersionInfo[] {
const baseDir = config.docsBaseDir
? path.join(ROOT_DIR, config.docsBaseDir)
: path.join(ROOT_DIR, 'docs/content/docs/cua/reference');
const docsDir = path.join(baseDir, config.outputDir);
const hrefBase = config.hrefBase ?? '/cua/reference';
const versions: VersionInfo[] = [];
// Add current version (latest) — points to the index page (folder root)
const currentMajorMinor = currentVersion.split('.').slice(0, 2).join('.');
versions.push({
version: currentMajorMinor,
href: `${hrefBase}/${config.outputDir}`,
isCurrent: true,
});
// Discover versioned folders (v0.5, v0.4, etc.)
if (fs.existsSync(docsDir)) {
const entries = fs.readdirSync(docsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('v')) {
const version = entry.name.substring(1); // Remove 'v' prefix
// Skip if this is the current version
if (version === currentMajorMinor) continue;
versions.push({
version,
href: `${hrefBase}/${config.outputDir}/${entry.name}/api`,
isCurrent: false,
});
}
}
}
// Sort versions descending
versions.sort((a, b) => {
const partsA = a.version.split('.').map((x) => parseInt(x, 10) || 0);
const partsB = b.version.split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) return partB - partA;
}
return 0;
});
return versions;
}
// ============================================================================
// MDX Generation
// ============================================================================
function generateMDX(docs: PythonPackage, config: SDKConfig): string {
const lines: string[] = [];
// Get the actual latest released version from git tags
const releasedVersion = getLatestReleasedVersion(config, docs.version);
// Frontmatter
const pageTitle = config.pageTitle ?? `${config.displayName} API Reference`;
lines.push('---');
lines.push(`title: ${pageTitle}`);
lines.push(`description: ${config.description}`);
lines.push('---');
lines.push('');
// Auto-generated notice
lines.push(`{/*`);
lines.push(` AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY`);
lines.push(` Generated by: npx tsx scripts/docs-generators/python-sdk.ts`);
lines.push(` Source: ${config.packageDir}`);
lines.push(` Version: ${releasedVersion}`);
lines.push(`*/}`);
lines.push('');
// Imports
lines.push("import { Callout } from 'fumadocs-ui/components/callout';");
lines.push("import { Tabs, Tab } from 'fumadocs-ui/components/tabs';");
lines.push("import { VersionHeader } from '@/components/version-selector';");
lines.push('');
// Discover available versions using the released version
const versions = discoverVersions(config, releasedVersion);
const currentMajorMinor = releasedVersion.split('.').slice(0, 2).join('.');
// Version selector and badge
lines.push('<VersionHeader');
lines.push(` versions={${JSON.stringify(versions)}}`);
lines.push(` currentVersion="${currentMajorMinor}"`);
lines.push(` fullVersion="${releasedVersion}"`);
// Use pip-style package name (underscores → hyphens)
// If it already starts with 'cua', don't add prefix
const pipName = config.packageName.replace(/_/g, '-');
const fullPipName = pipName.startsWith('cua') ? pipName : `cua-${pipName}`;
lines.push(` packageName="${fullPipName}"`);
lines.push('/>');
lines.push('');
// Package description
if (docs.docstring) {
lines.push(docs.docstring);
lines.push('');
}
// Table of contents for classes
if (docs.classes.length > 0) {
lines.push('## Classes');
lines.push('');
lines.push('| Class | Description |');
lines.push('|-------|-------------|');
for (const cls of docs.classes) {
if (!cls.is_private) {
const desc = escapeMDX(cls.description.split('\n')[0]) || 'No description';
lines.push(`| [\`${cls.name}\`](#${cls.name.toLowerCase()}) | ${desc} |`);
}
}
lines.push('');
}
// Table of contents for functions
if (docs.functions.length > 0) {
lines.push('## Functions');
lines.push('');
lines.push('| Function | Description |');
lines.push('|----------|-------------|');
for (const fn of docs.functions) {
if (!fn.is_private) {
const desc = escapeMDX(fn.description.split('\n')[0]) || 'No description';
lines.push(`| [\`${fn.name}\`](#${fn.name.toLowerCase()}) | ${desc} |`);
}
}
lines.push('');
}
// Detailed class documentation
for (const cls of docs.classes) {
if (!cls.is_private) {
lines.push(...generateClassDoc(cls));
}
}
// Detailed function documentation
for (const fn of docs.functions) {
if (!fn.is_private) {
lines.push(...generateFunctionDoc(fn, '##'));
}
}
// Submodules (for packages that expose API through submodules)
if (docs.submodules && docs.submodules.length > 0) {
let publicSubmodules = docs.submodules.filter(
(m) => !m.name.startsWith('_') && (m.classes.length > 0 || m.functions.length > 0)
);
// Filter to only included submodules if configured
if (config.includeSubmodules) {
publicSubmodules = publicSubmodules.filter((m) => config.includeSubmodules!.includes(m.name));
}
for (const mod of publicSubmodules) {
const publicClasses = mod.classes.filter((c) => !c.is_private);
const publicFunctions = mod.functions.filter((f) => !f.is_private && !f.is_dunder);
if (publicClasses.length === 0 && publicFunctions.length === 0) continue;
lines.push('---');
lines.push('');
lines.push(`## ${mod.name}`);
lines.push('');
if (mod.docstring) {
lines.push(escapeMDX(mod.docstring));
lines.push('');
}
for (const cls of publicClasses) {
lines.push(...generateClassDoc(cls));
}
for (const fn of publicFunctions) {
lines.push(...generateFunctionDoc(fn, '###'));
}
}
}
return lines.join('\n');
}
function generateClassDoc(cls: ClassDoc): string[] {
const lines: string[] = [];
lines.push('---');
lines.push('');
lines.push(`## ${cls.name}`);
lines.push('');
// Base classes
if (cls.bases.length > 0) {
const bases = cls.bases.filter((b) => b !== 'object').join(', ');
if (bases) {
lines.push(`*Inherits from: ${bases}*`);
lines.push('');
}
}
// Description
if (cls.description) {
lines.push(escapeMDX(cls.description));
lines.push('');
}
// Constructor (__init__)
const initMethod = cls.methods.find((m) => m.name === '__init__');
if (initMethod) {
lines.push('### Constructor');
lines.push('');
lines.push('```python');
lines.push(formatSignature(initMethod.signature, cls.name));
lines.push('```');
lines.push('');
if (initMethod.parameters.length > 0) {
lines.push(...generateParametersTable(initMethod.parameters));
}
}
// Attributes
if (cls.attributes.length > 0) {
lines.push('### Attributes');
lines.push('');
lines.push('| Name | Type | Description |');
lines.push('|------|------|-------------|');
for (const attr of cls.attributes) {
const type = attr.type || 'Any';
// Strip docstring sections and collapse to single line for table cells
const desc = escapeMDX(stripDocstringSections(attr.description)) || '';
lines.push(`| \`${attr.name}\` | \`${type}\` | ${desc} |`);
}
lines.push('');
}
// Methods (excluding __init__ and private)
const publicMethods = cls.methods.filter(
(m) => !m.is_private && !m.is_dunder && m.name !== '__init__'
);
if (publicMethods.length > 0) {
lines.push('### Methods');
lines.push('');
for (const method of publicMethods) {
lines.push(...generateMethodDoc(method, cls.name));
}
}
return lines;
}
function generateMethodDoc(method: FunctionDoc, className: string): string[] {
const lines: string[] = [];
lines.push(`#### ${className}.${method.name}`);
lines.push('');
lines.push('```python');
lines.push(formatSignature(method.signature));
lines.push('```');
lines.push('');
// Use structured data flags to avoid duplicating info from docstring
const hasStructuredParams = method.parameters.filter((p) => p.name !== 'self').length > 0;
const hasStructuredReturns = !!method.returns;
const hasStructuredRaises = method.raises.length > 0;
if (method.description) {
lines.push(
...formatDocstringLines(
method.description,
hasStructuredParams,
hasStructuredReturns,
hasStructuredRaises
)
);
}
// Structured parameters (from parsed signature)
if (hasStructuredParams) {
const params = method.parameters.filter((p) => p.name !== 'self');
lines.push(...generateParametersTable(params));
}
// Structured returns
if (method.returns) {
lines.push('**Returns:**');
lines.push('');
const returnType = method.returns.type || 'None';
const returnDesc = escapeMDX(method.returns.description) || '';
lines.push(`- \`${returnType}\` - ${returnDesc}`);
lines.push('');
}
// Structured raises
if (method.raises.length > 0) {
lines.push('**Raises:**');
lines.push('');
for (const exc of method.raises) {
lines.push(`- \`${exc.type}\` - ${escapeMDX(exc.description)}`);
}
lines.push('');
}
return lines;
}
function generateFunctionDoc(fn: FunctionDoc, heading: string): string[] {
const lines: string[] = [];
lines.push(`${heading} ${fn.name}`);
lines.push('');
lines.push('```python');
lines.push(formatSignature(fn.signature));
lines.push('```');
lines.push('');
const hasStructuredParams = fn.parameters.length > 0;
const hasStructuredReturns = !!fn.returns;
const hasStructuredRaises = fn.raises.length > 0;
if (fn.description) {
lines.push(
...formatDocstringLines(
fn.description,
hasStructuredParams,
hasStructuredReturns,
hasStructuredRaises
)
);
}
// Structured parameters
if (hasStructuredParams) {
lines.push(...generateParametersTable(fn.parameters));
}
// Structured returns
if (fn.returns) {
lines.push('**Returns:**');
lines.push('');
const returnType = fn.returns.type || 'None';
const returnDesc = escapeMDX(fn.returns.description) || '';
lines.push(`- \`${returnType}\` - ${returnDesc}`);
lines.push('');
}
// Structured raises
if (fn.raises.length > 0) {
lines.push('**Raises:**');
lines.push('');
for (const exc of fn.raises) {
lines.push(`- \`${exc.type}\` - ${escapeMDX(exc.description)}`);
}
lines.push('');
}
// Structured examples (from parsed data)
if (fn.examples.length > 0) {
lines.push('**Example:**');
lines.push('');
lines.push('```python');
for (const example of fn.examples) {
lines.push(example);
}
lines.push('```');
lines.push('');
}
return lines;
}
function generateParametersTable(params: ParameterDoc[]): string[] {
const lines: string[] = [];
lines.push('**Parameters:**');
lines.push('');
lines.push('| Name | Type | Description |');
lines.push('|------|------|-------------|');
for (const param of params) {
const type = param.type || 'Any';
const desc = escapeMDX(param.description) || '';
const defaultVal = param.default ? ` (default: \`${param.default}\`)` : '';
lines.push(`| \`${param.name}\` | \`${type}\` | ${desc}${defaultVal} |`);
}
lines.push('');
return lines;
}
function formatSignature(signature: string, className?: string): string {
// Replace __init__ with class name for constructors
if (className && signature.includes('__init__')) {
return signature.replace('def __init__', className);
}
return signature;
}
/**
* Escape special MDX characters in text content.
* Curly braces and HTML-like tags must be escaped outside of code blocks.
*/
function escapeMDX(text: string): string {
if (!text) return text;
return (
text
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
// Escape HTML-like tags that would be interpreted as JSX components
// but preserve markdown links []() and code backticks
.replace(/<(?!\/?(?:Callout|Tab|Tabs|VersionHeader|div|span|a|code|pre|br|hr)\b)/g, '&lt;')
);
}
/**
* Parse Google-style docstring sections (Args, Returns, Raises, Examples)
* and return the description text with those sections stripped,
* plus the parsed sections as structured data.
*/
interface ParsedDocstring {
description: string;
args: { name: string; type: string; description: string }[];
returns: string;
raises: { type: string; description: string }[];
examples: string[];
}
function parseDocstring(text: string): ParsedDocstring {
if (!text) return { description: '', args: [], returns: '', raises: [], examples: [] };
const lines = text.split('\n');
const result: ParsedDocstring = {
description: '',
args: [],
returns: '',
raises: [],
examples: [],
};
type Section = 'description' | 'args' | 'returns' | 'raises' | 'examples';
let currentSection: Section = 'description';
const descLines: string[] = [];
const returnsLines: string[] = [];
const exampleLines: string[] = [];
let currentArg: { name: string; type: string; description: string } | null = null;
let currentRaise: { type: string; description: string } | null = null;
for (const line of lines) {
const trimmed = line.trim();
// Detect section headers
if (/^Args?\s*:/.test(trimmed) || /^Parameters?\s*:/.test(trimmed)) {
currentSection = 'args';
continue;
}
if (/^Returns?\s*:/.test(trimmed)) {
// Check if it's a one-liner like "Returns: something"
const inlineReturn = trimmed.replace(/^Returns?\s*:\s*/, '');
if (inlineReturn) {
returnsLines.push(inlineReturn);
}
currentSection = 'returns';
continue;
}
if (/^Raises?\s*:/.test(trimmed)) {
currentSection = 'raises';
continue;
}
if (/^Examples?\s*:/.test(trimmed)) {
currentSection = 'examples';
continue;
}
switch (currentSection) {
case 'description':
descLines.push(line);
break;
case 'args': {
// Match "param_name (type): description" or "param_name: description"
const argMatch = trimmed.match(/^(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)$/);
if (argMatch && !line.startsWith(' ')) {
if (currentArg) result.args.push(currentArg);
currentArg = {
name: argMatch[1],
type: argMatch[2] || '',
description: argMatch[3],
};
} else if (currentArg && trimmed) {
// Continuation line for current arg
currentArg.description += ' ' + trimmed;
}
break;
}
case 'returns':
if (trimmed) returnsLines.push(trimmed);
break;
case 'raises': {
const raiseMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
if (raiseMatch && !line.startsWith(' ')) {
if (currentRaise) result.raises.push(currentRaise);
currentRaise = { type: raiseMatch[1], description: raiseMatch[2] };
} else if (currentRaise && trimmed) {
currentRaise.description += ' ' + trimmed;
}
break;
}
case 'examples':
exampleLines.push(line);
break;
}
}
// Flush remaining items
if (currentArg) result.args.push(currentArg);
if (currentRaise) result.raises.push(currentRaise);
result.description = descLines.join('\n').trim();
result.returns = returnsLines.join(' ').trim();
result.examples = exampleLines;
return result;
}
/**
* Format a parsed docstring into markdown lines.
* Only outputs sections not already covered by structured data.
*/
function formatDocstringLines(
text: string,
hasStructuredParams: boolean,
hasStructuredReturns: boolean,
hasStructuredRaises: boolean
): string[] {
const parsed = parseDocstring(text);
const lines: string[] = [];
// Description (always output)
if (parsed.description) {
lines.push(escapeMDX(parsed.description));
lines.push('');
}
// Args (only if no structured params)
if (!hasStructuredParams && parsed.args.length > 0) {
lines.push('**Parameters:**');
lines.push('');
lines.push('| Name | Type | Description |');
lines.push('|------|------|-------------|');
for (const arg of parsed.args) {
const type = arg.type || 'Any';
lines.push(`| \`${arg.name}\` | \`${escapeMDX(type)}\` | ${escapeMDX(arg.description)} |`);
}
lines.push('');
}
// Returns (only if no structured returns)
if (!hasStructuredReturns && parsed.returns) {
lines.push(`**Returns:** ${escapeMDX(parsed.returns)}`);
lines.push('');
}
// Raises (only if no structured raises)
if (!hasStructuredRaises && parsed.raises.length > 0) {
lines.push('**Raises:**');
lines.push('');
for (const r of parsed.raises) {
lines.push(`- \`${r.type}\` - ${escapeMDX(r.description)}`);
}
lines.push('');
}
// Examples
if (parsed.examples.length > 0) {
// Dedent example lines by removing common leading whitespace
const nonEmptyLines = parsed.examples.filter((l) => l.trim().length > 0);
if (nonEmptyLines.length > 0) {
const minIndent = Math.min(...nonEmptyLines.map((l) => l.match(/^(\s*)/)?.[1].length ?? 0));
const dedented = parsed.examples
.map((l) => (l.trim().length > 0 ? l.substring(minIndent) : ''))
.join('\n')
.trim();
if (dedented) {
lines.push('**Example:**');
lines.push('');
lines.push('```python');
lines.push(dedented);
lines.push('```');
lines.push('');
}
}
}
return lines;
}
/**
* Strip docstring sections from text for use in single-line contexts (e.g. table cells).
*/
function stripDocstringSections(text: string): string {
if (!text) return text;
const parsed = parseDocstring(text);
// Return only the first line of the description
return parsed.description.split('\n')[0].trim();
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+2
View File
@@ -0,0 +1,2 @@
# Python dependencies for documentation generation
griffe>=0.40.0
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env npx tsx
/**
* Documentation Generator Runner
*
* Orchestrates documentation generation across all configured libraries.
* Reads config.json and runs appropriate generators based on what changed.
*
* Usage:
* npx tsx scripts/docs-generators/runner.ts # Generate all enabled docs
* npx tsx scripts/docs-generators/runner.ts --check # Check for drift (CI mode)
* npx tsx scripts/docs-generators/runner.ts --library lume # Generate specific library
* npx tsx scripts/docs-generators/runner.ts --list # List all configured generators
* npx tsx scripts/docs-generators/runner.ts --changed # Only run for changed files (CI)
*/
import { execSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
interface GeneratorOutput {
type: string;
outputFile: string;
extractCommand: string | null;
}
interface GeneratorConfig {
name: string;
language: string;
sourcePath: string;
docsOutputPath: string;
generatorScript: string;
watchPaths: string[];
buildCommand: string | null;
buildDirectory: string;
extractionMethod: string;
outputs: GeneratorOutput[];
enabled: boolean;
notes?: string;
}
interface Config {
description: string;
generators: Record<string, GeneratorConfig>;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const CONFIG_PATH = path.join(__dirname, 'config.json');
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
// Parse arguments
const checkOnly = args.includes('--check') || args.includes('--check-only');
const listOnly = args.includes('--list');
const changedOnly = args.includes('--changed');
const libraryIndex = args.indexOf('--library');
const specificLibrary = libraryIndex !== -1 ? args[libraryIndex + 1] : null;
console.log('📚 Documentation Generator Runner');
console.log('==================================\n');
// Load config
if (!fs.existsSync(CONFIG_PATH)) {
console.error(`❌ Config file not found: ${CONFIG_PATH}`);
process.exit(1);
}
const config: Config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
// List mode
if (listOnly) {
listGenerators(config);
return;
}
// Determine which generators to run
let generatorsToRun: string[] = [];
if (specificLibrary) {
if (!config.generators[specificLibrary]) {
console.error(`❌ Unknown library: ${specificLibrary}`);
console.log('\nAvailable libraries:', Object.keys(config.generators).join(', '));
process.exit(1);
}
generatorsToRun = [specificLibrary];
} else if (changedOnly) {
generatorsToRun = getChangedGenerators(config);
if (generatorsToRun.length === 0) {
console.log('✅ No documentation-related changes detected.');
return;
}
} else {
// Run all enabled generators
generatorsToRun = Object.entries(config.generators)
.filter(([_, cfg]) => cfg.enabled)
.map(([key, _]) => key);
}
if (generatorsToRun.length === 0) {
console.log('⚠️ No generators to run. Enable generators in config.json.');
return;
}
console.log(`📋 Generators to run: ${generatorsToRun.join(', ')}\n`);
// Run generators
let hasErrors = false;
for (const generatorKey of generatorsToRun) {
const generator = config.generators[generatorKey];
console.log(`\n${'='.repeat(50)}`);
console.log(`📦 ${generator.name} (${generatorKey})`);
console.log(`${'='.repeat(50)}\n`);
if (!generator.enabled) {
console.log(`⏭️ Skipped (disabled)`);
if (generator.notes) {
console.log(` Note: ${generator.notes}`);
}
continue;
}
const success = await runGenerator(generator, generatorKey, checkOnly);
if (!success) {
hasErrors = true;
}
}
// Summary
console.log(`\n${'='.repeat(50)}`);
if (hasErrors) {
console.error('❌ Some generators failed or detected drift.');
if (checkOnly) {
console.log("\n💡 Run 'npx tsx scripts/docs-generators/runner.ts' to update documentation");
}
process.exit(1);
} else {
console.log('✅ All documentation is up to date!');
}
}
// ============================================================================
// Generator Execution
// ============================================================================
async function runGenerator(
generator: GeneratorConfig,
key: string,
checkOnly: boolean
): Promise<boolean> {
const generatorPath = path.join(ROOT_DIR, generator.generatorScript);
// Check if generator script exists
if (!fs.existsSync(generatorPath)) {
console.log(`⚠️ Generator script not found: ${generator.generatorScript}`);
console.log(` Create this file to enable documentation generation.`);
return true; // Not an error, just not implemented
}
try {
// Run the generator
const args = checkOnly ? ['--check'] : [];
const result = spawnSync('npx', ['tsx', generatorPath, ...args], {
cwd: ROOT_DIR,
stdio: 'inherit',
encoding: 'utf-8',
});
return result.status === 0;
} catch (error) {
console.error(`❌ Error running generator for ${key}:`, error);
return false;
}
}
// ============================================================================
// Changed Files Detection (for CI)
// ============================================================================
function getChangedGenerators(config: Config): string[] {
const changedGenerators: string[] = [];
try {
// Get changed files from git
// This works for both PRs (comparing to base) and pushes
const gitDiff = execSync(
'git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD',
{ cwd: ROOT_DIR, encoding: 'utf-8' }
);
const changedFiles = gitDiff.split('\n').filter(Boolean);
console.log(`📝 Changed files: ${changedFiles.length}`);
for (const [key, generator] of Object.entries(config.generators)) {
if (!generator.enabled) continue;
// Check if any watch path matches changed files
const watchPatterns = generator.watchPaths.map(
(p) => new RegExp(p.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\//g, '\\/'))
);
const hasChanges = changedFiles.some((file) =>
watchPatterns.some((pattern) => pattern.test(file))
);
if (hasChanges) {
changedGenerators.push(key);
console.log(` 📌 ${key}: changes detected`);
}
}
} catch (error) {
console.warn('⚠️ Could not detect changed files, running all generators');
return Object.entries(config.generators)
.filter(([_, cfg]) => cfg.enabled)
.map(([key, _]) => key);
}
return changedGenerators;
}
// ============================================================================
// List Generators
// ============================================================================
function listGenerators(config: Config): void {
console.log('Configured Documentation Generators:\n');
const maxKeyLen = Math.max(...Object.keys(config.generators).map((k) => k.length));
for (const [key, generator] of Object.entries(config.generators)) {
const status = generator.enabled ? '✅' : '⏸️ ';
const paddedKey = key.padEnd(maxKeyLen);
console.log(`${status} ${paddedKey} ${generator.name} (${generator.language})`);
if (!generator.enabled && generator.notes) {
console.log(` ${''.padEnd(maxKeyLen)} └─ ${generator.notes}`);
}
}
console.log('\n');
console.log('Legend:');
console.log(' ✅ = Enabled (will run)');
console.log(' ⏸️ = Disabled (not yet implemented)');
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+772
View File
@@ -0,0 +1,772 @@
#!/usr/bin/env npx tsx
/**
* TypeScript SDK Documentation Generator
*
* Generates MDX API reference documentation from TypeScript source code.
* Uses regex-based parsing to extract exports and JSDoc comments (no TS compiler dependency).
*
* Usage:
* npx tsx scripts/docs-generators/typescript-sdk.ts # Generate all
* npx tsx scripts/docs-generators/typescript-sdk.ts --sdk=cuabot # Generate specific
* npx tsx scripts/docs-generators/typescript-sdk.ts --check # Check for drift (CI mode)
*/
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
// ============================================================================
// Types
// ============================================================================
interface ExtractedClass {
name: string;
description: string;
constructorSig: string | null;
constructorParams: ParamInfo[];
methods: MethodInfo[];
properties: PropInfo[];
}
interface ExtractedInterface {
name: string;
description: string;
properties: PropInfo[];
}
interface ExtractedFunction {
name: string;
description: string;
signature: string;
params: ParamInfo[];
returnType: string;
isAsync: boolean;
}
interface ExtractedConst {
name: string;
type: string;
description: string;
}
interface MethodInfo {
name: string;
description: string;
signature: string;
params: ParamInfo[];
returnType: string;
isAsync: boolean;
}
interface ParamInfo {
name: string;
type: string;
description: string;
defaultValue: string | null;
isOptional: boolean;
}
interface PropInfo {
name: string;
type: string;
description: string;
isOptional: boolean;
}
interface ModuleDoc {
name: string;
description: string;
classes: ExtractedClass[];
interfaces: ExtractedInterface[];
functions: ExtractedFunction[];
constants: ExtractedConst[];
}
interface SDKConfig {
packageDir: string;
packageName: string;
outputPath: string;
displayName: string;
description: string;
outputDir: string;
tagPrefix: string;
docsBaseDir?: string;
hrefBase?: string;
pageTitle?: string;
includeFiles?: string[];
installCommand?: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const SDK_CONFIGS: Record<string, SDKConfig> = {
cuabot: {
packageDir: 'libs/cuabot/src',
packageName: 'cuabot',
outputPath: 'docs/content/docs/cuabot/reference/index.mdx',
displayName: 'Cua-Bot',
description: 'TypeScript API reference for the Cua-Bot sandboxed agent framework',
outputDir: 'reference',
tagPrefix: 'cuabot-v',
docsBaseDir: 'docs/content/docs/cuabot',
hrefBase: '/cuabot',
pageTitle: 'API Reference',
includeFiles: ['client.ts', 'settings.ts'],
installCommand: 'npm install -g cuabot',
},
};
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('--check-only');
const sdkArg = args.find((a) => a.startsWith('--sdk='));
const targetSdk = sdkArg?.split('=')[1];
console.log('📦 TypeScript SDK Documentation Generator');
console.log('==========================================\n');
let hasErrors = false;
for (const [sdkName, config] of Object.entries(SDK_CONFIGS)) {
if (targetSdk && targetSdk !== sdkName) continue;
console.log(`📖 Processing ${config.displayName}...`);
const packagePath = path.join(ROOT_DIR, config.packageDir);
if (!fs.existsSync(packagePath)) {
console.error(` ❌ Package not found: ${config.packageDir}`);
hasErrors = true;
continue;
}
const modules = extractDocs(packagePath, config);
console.log(
` Found ${modules.reduce((n, m) => n + m.classes.length, 0)} classes, ` +
`${modules.reduce((n, m) => n + m.functions.length, 0)} functions, ` +
`${modules.reduce((n, m) => n + m.interfaces.length, 0)} interfaces`
);
const mdx = generateMDX(modules, config);
const outputPath = path.join(ROOT_DIR, config.outputPath);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (checkOnly) {
// Check mode: compare with existing file
if (fs.existsSync(outputPath)) {
const existing = fs.readFileSync(outputPath, 'utf-8');
if (existing !== mdx) {
console.error(`${path.basename(outputPath)} is out of sync with source code`);
hasErrors = true;
} else {
console.log(`${path.basename(outputPath)} is up to date`);
}
} else {
console.error(`${path.basename(outputPath)} does not exist (needs generation)`);
hasErrors = true;
}
} else {
// Generate mode: write file
fs.writeFileSync(outputPath, mdx);
console.log(` ✅ Generated ${path.relative(ROOT_DIR, outputPath)}`);
}
}
if (hasErrors) {
if (checkOnly) {
console.error(
"\n💡 Run 'npx tsx scripts/docs-generators/typescript-sdk.ts' to update documentation"
);
}
process.exit(1);
}
console.log('\n✅ TypeScript SDK documentation generation complete!');
}
// ============================================================================
// Regex-based Extraction
// ============================================================================
function extractDocs(packagePath: string, config: SDKConfig): ModuleDoc[] {
const fileNames = config.includeFiles
? config.includeFiles
: fs.readdirSync(packagePath).filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts'));
const modules: ModuleDoc[] = [];
for (const fileName of fileNames) {
const filePath = path.join(packagePath, fileName);
if (!fs.existsSync(filePath)) continue;
const source = fs.readFileSync(filePath, 'utf-8');
const moduleName = path.basename(fileName, '.ts');
const mod: ModuleDoc = {
name: moduleName,
description: extractFileDescription(source),
classes: extractClasses(source),
interfaces: extractInterfaces(source),
functions: extractFunctions(source),
constants: extractConstants(source),
};
if (
mod.classes.length > 0 ||
mod.interfaces.length > 0 ||
mod.functions.length > 0 ||
mod.constants.length > 0
) {
modules.push(mod);
}
}
return modules;
}
function extractFileDescription(source: string): string {
const match = source.match(/^\/\*\*\s*\n([\s\S]*?)\*\//);
if (!match) return '';
return match[1]
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
.filter(Boolean)
.join('\n');
}
/**
* Get the JSDoc comment immediately preceding a position in source.
*/
function getJSDocBefore(source: string, pos: number): string {
const before = source.substring(0, pos);
const match = before.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
if (!match) return '';
return match[1]
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, ''))
.filter((l) => !l.startsWith('@'))
.join('\n')
.trim();
}
function extractClasses(source: string): ExtractedClass[] {
const classes: ExtractedClass[] = [];
const classRegex = /export\s+class\s+(\w+)(?:\s+extends\s+[\w.]+)?\s*\{/g;
let match;
while ((match = classRegex.exec(source)) !== null) {
const name = match[1];
const description = getJSDocBefore(source, match.index);
const classBodyStart = match.index + match[0].length;
const classBody = extractBraceBlock(source, classBodyStart - 1);
const cls: ExtractedClass = {
name,
description,
constructorSig: null,
constructorParams: [],
methods: [],
properties: [],
};
// Extract constructor
const ctorMatch = classBody.match(/constructor\s*\(([\s\S]*?)\)\s*\{/);
if (ctorMatch) {
cls.constructorParams = parseParams(ctorMatch[1]);
cls.constructorSig = `constructor(${ctorMatch[1].trim()})`;
}
// Extract methods (async or not, excluding private)
const methodRegex =
/(\/\*\*[\s\S]*?\*\/\s*)?(async\s+)?(\w+)\s*\(([\s\S]*?)\)\s*:\s*([\w<>\[\]|, ]+)\s*\{/g;
let mMatch;
while ((mMatch = methodRegex.exec(classBody)) !== null) {
const methodName = mMatch[3];
if (methodName === 'constructor' || methodName.startsWith('_') || methodName === 'private')
continue;
const isAsync = !!mMatch[2];
const params = parseParams(mMatch[4]);
const returnType = mMatch[5].trim();
const jsdoc = mMatch[1] ? parseJSDocBlock(mMatch[1]) : '';
// Get param descriptions from JSDoc
if (mMatch[1]) {
const paramDescs = parseJSDocParams(mMatch[1]);
for (const p of params) {
if (paramDescs[p.name]) p.description = paramDescs[p.name];
}
}
cls.methods.push({
name: methodName,
description: jsdoc,
signature: `${isAsync ? 'async ' : ''}${methodName}(${params.map((p) => formatParam(p)).join(', ')}): ${returnType}`,
params: params.filter((p) => p.name !== 'this'),
returnType,
isAsync,
});
}
classes.push(cls);
}
return classes;
}
function extractInterfaces(source: string): ExtractedInterface[] {
const interfaces: ExtractedInterface[] = [];
const ifaceRegex = /export\s+interface\s+(\w+)\s*\{/g;
let match;
while ((match = ifaceRegex.exec(source)) !== null) {
const name = match[1];
const description = getJSDocBefore(source, match.index);
const bodyStart = match.index + match[0].length;
const body = extractBraceBlock(source, bodyStart - 1);
const properties: PropInfo[] = [];
const propRegex = /(\w+)(\?)?\s*:\s*([^;\n]+)/g;
let pMatch;
while ((pMatch = propRegex.exec(body)) !== null) {
properties.push({
name: pMatch[1],
type: pMatch[3].trim().replace(/;$/, ''),
description: '',
isOptional: !!pMatch[2],
});
}
interfaces.push({ name, description, properties });
}
return interfaces;
}
function extractFunctions(source: string): ExtractedFunction[] {
const functions: ExtractedFunction[] = [];
const fnRegex =
/export\s+(async\s+)?function\s+(\w+)\s*\(([\s\S]*?)\)\s*:\s*([\w<>\[\]|, {}:]+)\s*\{/g;
let match;
while ((match = fnRegex.exec(source)) !== null) {
const isAsync = !!match[1];
const name = match[2];
if (name.startsWith('_')) continue;
const params = parseParams(match[3]);
const returnType = match[4].trim();
const description = getJSDocBefore(source, match.index);
// Get param descriptions from JSDoc
const jsdocBlock = source.substring(Math.max(0, match.index - 500), match.index);
const jsdocMatch = jsdocBlock.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
if (jsdocMatch) {
const paramDescs = parseJSDocParams(jsdocMatch[0]);
for (const p of params) {
if (paramDescs[p.name]) p.description = paramDescs[p.name];
}
}
functions.push({
name,
description,
signature: `${isAsync ? 'async ' : ''}function ${name}(${params.map((p) => formatParam(p)).join(', ')}): ${returnType}`,
params,
returnType,
isAsync,
});
}
return functions;
}
function extractConstants(source: string): ExtractedConst[] {
const constants: ExtractedConst[] = [];
const constRegex = /export\s+const\s+(\w+)(?:\s*:\s*([^=]+))?\s*=/g;
let match;
while ((match = constRegex.exec(source)) !== null) {
const name = match[1];
if (name.startsWith('_')) continue;
const type = match[2]?.trim() || 'const';
const description = getJSDocBefore(source, match.index);
constants.push({ name, type, description });
}
return constants;
}
// ============================================================================
// Helpers
// ============================================================================
function extractBraceBlock(source: string, openBracePos: number): string {
let depth = 0;
let start = openBracePos;
for (let i = openBracePos; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) return source.substring(start + 1, i);
}
}
return source.substring(start + 1);
}
function parseParams(paramStr: string): ParamInfo[] {
if (!paramStr.trim()) return [];
const params: ParamInfo[] = [];
let depth = 0;
let current = '';
for (const char of paramStr) {
if (char === '(' || char === '<' || char === '{' || char === '[') depth++;
else if (char === ')' || char === '>' || char === '}' || char === ']') depth--;
if (char === ',' && depth === 0) {
const p = parseSingleParam(current.trim());
if (p) params.push(p);
current = '';
} else {
current += char;
}
}
if (current.trim()) {
const p = parseSingleParam(current.trim());
if (p) params.push(p);
}
return params;
}
function parseSingleParam(param: string): ParamInfo | null {
if (!param) return null;
// Match: name?: type = default
const match = param.match(/^(\w+)(\?)?\s*(?::\s*([\s\S]+?))?(?:\s*=\s*([\s\S]+))?$/);
if (!match) return null;
return {
name: match[1],
type: match[3]?.trim().replace(/\s*=\s*[\s\S]*$/, '') || 'any',
description: '',
defaultValue: match[4]?.trim() || null,
isOptional: !!match[2] || !!match[4],
};
}
function formatParam(p: ParamInfo): string {
const opt = p.isOptional && !p.defaultValue ? '?' : '';
const def = p.defaultValue ? ` = ${p.defaultValue}` : '';
return `${p.name}${opt}: ${p.type}${def}`;
}
function parseJSDocBlock(block: string): string {
return block
.replace(/^\/\*\*\s*/, '')
.replace(/\s*\*\/\s*$/, '')
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, ''))
.filter((l) => !l.startsWith('@'))
.join('\n')
.trim();
}
function parseJSDocParams(block: string): Record<string, string> {
const params: Record<string, string> = {};
const lines = block.split('\n');
for (const line of lines) {
const match = line.match(/@param\s+(\w+)\s+(.*)/);
if (match) params[match[1]] = match[2].trim();
}
return params;
}
// ============================================================================
// Version Discovery
// ============================================================================
interface VersionInfo {
version: string;
href: string;
isCurrent: boolean;
}
function getLatestReleasedVersion(config: SDKConfig, fallback: string): string {
try {
const output = execSync(`git tag | grep "^${config.tagPrefix}" | sort -V | tail -1`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
}).trim();
if (output) return output.replace(config.tagPrefix, '');
} catch {
// fall through
}
return fallback;
}
function discoverVersions(config: SDKConfig, currentVersion: string): VersionInfo[] {
const baseDir = config.docsBaseDir
? path.join(ROOT_DIR, config.docsBaseDir)
: path.join(ROOT_DIR, 'docs/content/docs/cuabot');
const docsDir = path.join(baseDir, config.outputDir);
const hrefBase = config.hrefBase ?? '/cuabot';
const versions: VersionInfo[] = [];
const currentMM = currentVersion.split('.').slice(0, 2).join('.');
versions.push({ version: currentMM, href: `${hrefBase}/${config.outputDir}`, isCurrent: true });
if (fs.existsSync(docsDir)) {
for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('v')) {
const v = entry.name.substring(1);
if (v === currentMM) continue;
versions.push({
version: v,
href: `${hrefBase}/${config.outputDir}/${entry.name}/api`,
isCurrent: false,
});
}
}
}
versions.sort((a, b) => {
const pa = a.version.split('.').map(Number);
const pb = b.version.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
}
return 0;
});
return versions;
}
function getPackageVersion(config: SDKConfig): string {
const pkgJsonPath = path.join(ROOT_DIR, path.dirname(config.packageDir), 'package.json');
try {
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version || '0.0.0';
} catch {
return '0.0.0';
}
}
// ============================================================================
// MDX Generation
// ============================================================================
function escapeMDX(text: string): string {
if (!text) return text;
return text
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/<(?!\/?(?:Callout|Tab|Tabs|VersionHeader|div|span|a|code|pre|br|hr)\b)/g, '&lt;');
}
function generateMDX(modules: ModuleDoc[], config: SDKConfig): string {
const lines: string[] = [];
const pkgVersion = getPackageVersion(config);
const releasedVersion = getLatestReleasedVersion(config, pkgVersion);
const pageTitle = config.pageTitle ?? `${config.displayName} API Reference`;
// Frontmatter
lines.push('---');
lines.push(`title: ${pageTitle}`);
lines.push(`description: ${config.description}`);
lines.push('---');
lines.push('');
lines.push(`{/*`);
lines.push(` AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY`);
lines.push(` Generated by: npx tsx scripts/docs-generators/typescript-sdk.ts`);
lines.push(` Source: ${config.packageDir}`);
lines.push(` Version: ${releasedVersion}`);
lines.push(`*/}`);
lines.push('');
// Imports
lines.push("import { Callout } from 'fumadocs-ui/components/callout';");
lines.push("import { VersionHeader } from '@/components/version-selector';");
lines.push('');
// Version header
const versions = discoverVersions(config, releasedVersion);
const currentMM = releasedVersion.split('.').slice(0, 2).join('.');
lines.push('<VersionHeader');
lines.push(` versions={${JSON.stringify(versions)}}`);
lines.push(` currentVersion="${currentMM}"`);
lines.push(` fullVersion="${releasedVersion}"`);
lines.push(` packageName="${config.packageName}"`);
if (config.installCommand) {
lines.push(` installCommand="${config.installCommand}"`);
}
lines.push('/>');
lines.push('');
for (const mod of modules) {
lines.push('---');
lines.push('');
lines.push(`## ${mod.name}`);
lines.push('');
if (mod.description) {
lines.push(escapeMDX(mod.description));
lines.push('');
}
// Interfaces
for (const iface of mod.interfaces) {
lines.push(`### ${iface.name}`);
lines.push('');
if (iface.description) {
lines.push(escapeMDX(iface.description));
lines.push('');
}
lines.push('```typescript');
lines.push(`interface ${iface.name} {`);
for (const prop of iface.properties) {
const opt = prop.isOptional ? '?' : '';
lines.push(` ${prop.name}${opt}: ${prop.type};`);
}
lines.push('}');
lines.push('```');
lines.push('');
if (iface.properties.length > 0) {
lines.push('| Property | Type | Description |');
lines.push('|----------|------|-------------|');
for (const prop of iface.properties) {
const opt = prop.isOptional ? ' *(optional)*' : '';
lines.push(
`| \`${prop.name}\` | \`${escapeMDX(prop.type)}\` | ${opt}${escapeMDX(prop.description)} |`
);
}
lines.push('');
}
}
// Constants
for (const c of mod.constants) {
lines.push(`### ${c.name}`);
lines.push('');
lines.push('```typescript');
lines.push(`const ${c.name}: ${escapeMDX(c.type)}`);
lines.push('```');
lines.push('');
if (c.description) {
lines.push(escapeMDX(c.description));
lines.push('');
}
}
// Classes
for (const cls of mod.classes) {
lines.push(`### ${cls.name}`);
lines.push('');
if (cls.description) {
lines.push(escapeMDX(cls.description));
lines.push('');
}
if (cls.constructorSig) {
lines.push('#### Constructor');
lines.push('');
lines.push('```typescript');
lines.push(
`new ${cls.name}(${cls.constructorParams.map((p) => formatParam(p)).join(', ')})`
);
lines.push('```');
lines.push('');
if (cls.constructorParams.length > 0) {
lines.push(...generateParamsTable(cls.constructorParams));
}
}
if (cls.methods.length > 0) {
lines.push('#### Methods');
lines.push('');
for (const method of cls.methods) {
lines.push(`##### ${cls.name}.${method.name}`);
lines.push('');
lines.push('```typescript');
lines.push(method.signature);
lines.push('```');
lines.push('');
if (method.description) {
lines.push(escapeMDX(method.description));
lines.push('');
}
if (method.params.length > 0) {
lines.push(...generateParamsTable(method.params));
}
if (
method.returnType &&
method.returnType !== 'void' &&
method.returnType !== 'Promise<void>'
) {
lines.push(`**Returns:** \`${escapeMDX(method.returnType)}\``);
lines.push('');
}
}
}
}
// Functions
for (const fn of mod.functions) {
lines.push(`### ${fn.name}`);
lines.push('');
lines.push('```typescript');
lines.push(fn.signature);
lines.push('```');
lines.push('');
if (fn.description) {
lines.push(escapeMDX(fn.description));
lines.push('');
}
if (fn.params.length > 0) {
lines.push(...generateParamsTable(fn.params));
}
if (fn.returnType && fn.returnType !== 'void') {
lines.push(`**Returns:** \`${escapeMDX(fn.returnType)}\``);
lines.push('');
}
}
}
return lines.join('\n');
}
function generateParamsTable(params: ParamInfo[]): string[] {
const lines: string[] = [];
lines.push('**Parameters:**');
lines.push('');
lines.push('| Name | Type | Description |');
lines.push('|------|------|-------------|');
for (const p of params) {
const def = p.defaultValue ? ` (default: \`${p.defaultValue}\`)` : '';
const opt = p.isOptional ? ' *(optional)*' : '';
lines.push(
`| \`${p.name}\` | \`${escapeMDX(p.type)}\` | ${escapeMDX(p.description)}${opt}${def} |`
);
}
lines.push('');
return lines;
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""Generate docs/content/docs/cua/reference/sandbox-sdk/interfaces.mdx
from the pydocstrings in cua_sandbox/interfaces/*.py.
Usage:
python scripts/gen_interface_docs.py
python scripts/gen_interface_docs.py --dry-run # print to stdout only
The script introspects each interface module, collects class/method
docstrings + signatures, and emits structured MDX.
"""
from __future__ import annotations
import argparse
import ast
import inspect
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INTERFACES_DIR = REPO_ROOT / "libs/python/cua-sandbox/cua_sandbox/interfaces"
OUTPUT_FILE = REPO_ROOT / "docs/content/docs/cua/reference/sandbox-sdk/interfaces.mdx"
# Order in which classes appear in the output
CLASS_ORDER = [
"Shell",
"CommandResult",
"Mouse",
"Keyboard",
"Screen",
"Clipboard",
"Tunnel",
"TunnelInfo",
"Terminal",
"Window",
"Mobile",
]
# Module file for each class
CLASS_MODULE = {
"Shell": "shell",
"CommandResult": "shell",
"Mouse": "mouse",
"Keyboard": "keyboard",
"Screen": "screen",
"Clipboard": "clipboard",
"Tunnel": "tunnel",
"TunnelInfo": "tunnel",
"Terminal": "terminal",
"Window": "window",
"Mobile": "mobile",
}
# ── AST helpers ──────────────────────────────────────────────────────────────
def _parse_module(path: Path) -> ast.Module:
return ast.parse(path.read_text())
def _get_docstring(node) -> str:
ds = ast.get_docstring(node) or ""
return textwrap.dedent(ds).strip()
def _format_arg(arg: ast.arg, defaults: dict[str, ast.expr]) -> str:
name = arg.arg
annotation = ""
if arg.annotation:
annotation = f": {ast.unparse(arg.annotation)}"
default = ""
if name in defaults:
default = f" = {ast.unparse(defaults[name])}"
return f"{name}{annotation}{default}"
def _method_signature(func: ast.FunctionDef) -> str:
"""Return a human-readable signature string (without 'self')."""
args = func.args
# Build default map: last N positional args get the last N defaults
all_args = args.args
defaults = {}
n_defaults = len(args.defaults)
if n_defaults:
for arg, default in zip(all_args[-n_defaults:], args.defaults):
defaults[arg.arg] = default
# kwonly defaults
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
if default is not None:
defaults[arg.arg] = default
parts = []
for arg in all_args:
if arg.arg == "self":
continue
parts.append(_format_arg(arg, defaults))
for arg in args.kwonlyargs:
parts.append(_format_arg(arg, defaults))
if args.vararg:
parts.append(f"*{args.vararg.arg}")
if args.kwarg:
parts.append(f"**{args.kwarg.arg}")
ret = ""
if func.returns:
ret = f" -> {ast.unparse(func.returns)}"
return f"({', '.join(parts)}){ret}"
def _is_public(name: str) -> bool:
return not name.startswith("_")
# ── Collect info ─────────────────────────────────────────────────────────────
class FieldInfo:
def __init__(self, name: str, annotation: str):
self.name = name
self.annotation = annotation
class MethodInfo:
def __init__(self, name: str, sig: str, doc: str, is_async: bool, is_property: bool):
self.name = name
self.sig = sig
self.doc = doc
self.is_async = is_async
self.is_property = is_property
class ClassInfo:
def __init__(self, name: str, doc: str, fields: list[FieldInfo], methods: list[MethodInfo]):
self.name = name
self.doc = doc
self.fields = fields
self.methods = methods
def _collect_class(tree: ast.Module, class_name: str) -> ClassInfo | None:
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef) or node.name != class_name:
continue
doc = _get_docstring(node)
fields: list[FieldInfo] = []
methods: list[MethodInfo] = []
for item in node.body:
# Dataclass-style annotated field: `name: type` or `name: type = value`
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
fname = item.target.id
if _is_public(fname):
ann = ast.unparse(item.annotation) if item.annotation else ""
fields.append(FieldInfo(name=fname, annotation=ann))
elif isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
if not _is_public(item.name):
continue
is_property = any(
(isinstance(d, ast.Name) and d.id == "property")
or (isinstance(d, ast.Attribute) and d.attr == "property")
for d in item.decorator_list
)
methods.append(
MethodInfo(
name=item.name,
sig=_method_signature(item),
doc=_get_docstring(item),
is_async=isinstance(item, ast.AsyncFunctionDef),
is_property=is_property,
)
)
return ClassInfo(name=class_name, doc=doc, fields=fields, methods=methods)
return None
def collect_all() -> list[ClassInfo]:
infos = []
for class_name in CLASS_ORDER:
mod_name = CLASS_MODULE[class_name]
path = INTERFACES_DIR / f"{mod_name}.py"
if not path.exists():
print(f" [skip] {path} not found")
continue
tree = _parse_module(path)
info = _collect_class(tree, class_name)
if info:
infos.append(info)
else:
print(f" [warn] class {class_name} not found in {path}")
return infos
# ── MDX rendering ─────────────────────────────────────────────────────────────
def _escape_mdx(text: str) -> str:
"""Escape <> in doc text so MDX doesn't treat them as JSX tags.
Preserves content inside backtick code spans and fenced code blocks.
"""
import re
# Split on backtick-delimited segments (`` ` `` or ``` `` ```)
# Odd-indexed segments are inside backticks — leave them alone
parts = re.split(r"(```.+?```|``.+?``|`.+?`)", text, flags=re.DOTALL)
for i, part in enumerate(parts):
if i % 2 == 0: # outside code
parts[i] = (
part.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("{", "&#123;")
.replace("}", "&#125;")
)
return "".join(parts)
def _render_method(m: MethodInfo) -> str:
lines = []
prefix = "async " if m.is_async else ""
prop = "@property\n" if m.is_property else ""
lines.append(f"#### `{prop}{prefix}{m.name}{m.sig}`\n")
if m.doc:
lines.append(_escape_mdx(m.doc))
lines.append("")
return "\n".join(lines)
def _render_class(c: ClassInfo) -> str:
lines = []
lines.append(f"## `{c.name}`\n")
if c.doc:
lines.append(_escape_mdx(c.doc))
lines.append("")
if c.fields:
lines.append("### Fields\n")
lines.append("| Name | Type |")
lines.append("|------|------|")
for f in c.fields:
lines.append(f"| `{f.name}` | `{_escape_mdx(f.annotation)}` |")
lines.append("")
if c.methods:
lines.append("### Methods\n")
for m in c.methods:
lines.append(_render_method(m))
return "\n".join(lines)
HEADER = """\
---
title: Interfaces Reference
description: Auto-generated reference for all cua-sandbox interface classes. Do not edit manually — run scripts/gen_interface_docs.py to regenerate.
---
import { Callout } from "fumadocs-ui/components/callout";
<Callout type="info">
This page is auto-generated from the Python source docstrings.
Run `python scripts/gen_interface_docs.py` to regenerate after editing interface code.
</Callout>
The sandbox exposes the following interface objects on every `Sandbox` instance:
| Attribute | Class | Purpose |
|-----------|-------|---------|
| `sb.shell` | `Shell` | Run shell commands |
| `sb.mouse` | `Mouse` | Mouse control |
| `sb.keyboard` | `Keyboard` | Keyboard control |
| `sb.screen` | `Screen` | Screenshots and screen info |
| `sb.clipboard` | `Clipboard` | Clipboard read/write |
| `sb.tunnel` | `Tunnel` | Port forwarding |
| `sb.terminal` | `Terminal` | PTY terminal sessions |
| `sb.window` | `Window` | Window management |
| `sb.mobile` | `Mobile` | Mobile-specific actions |
---
"""
def render(infos: list[ClassInfo]) -> str:
parts = [HEADER]
for info in infos:
parts.append(_render_class(info))
parts.append("\n---\n\n")
return "\n".join(parts)
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print output to stdout instead of writing to file",
)
args = parser.parse_args()
print(f"Collecting interfaces from {INTERFACES_DIR} ...")
infos = collect_all()
print(f" Found {len(infos)} classes: {[c.name for c in infos]}")
content = render(infos)
if args.dry_run:
print("\n" + "=" * 60 + "\n")
print(content)
else:
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_FILE.write_text(content)
print(f"Written to {OUTPUT_FILE}")
if __name__ == "__main__":
main()
+245
View File
@@ -0,0 +1,245 @@
# Cua CLI Installation Script for Windows
$ErrorActionPreference = "Stop"
function Get-LatestCuaTag {
$page = 1
$perPage = 100
$maxPages = 10
while ($page -le $maxPages) {
try {
$response = Invoke-RestMethod -Uri "https://api.github.com/repos/trycua/cua/tags?per_page=$perPage&page=$page" -ErrorAction Stop
if (-not $response -or $response.Count -eq 0) {
return $null
}
$cuaTag = $response | Where-Object { $_.name -like "cua-*" } | Select-Object -First 1
if ($cuaTag) {
return $cuaTag.name
}
$page++
} catch {
return $null
}
}
return $null
}
# Install shell completions (idempotent)
function Install-Completions {
param([string]$CuaBinaryPath)
$completionsDir = "$env:USERPROFILE\.cua\completions"
if (-not (Test-Path $completionsDir)) {
New-Item -ItemType Directory -Path $completionsDir -Force | Out-Null
}
$completionScript = Join-Path $completionsDir "cua.ps1"
# Generate completion script
try {
$completionContent = & $CuaBinaryPath completion 2>$null
if ($completionContent) {
Set-Content -Path $completionScript -Value $completionContent -Force
}
} catch {
return
}
if (-not (Test-Path $completionScript)) {
return
}
# Add to PowerShell profile (idempotent)
$profilePath = $PROFILE.CurrentUserAllHosts
if (-not $profilePath) {
$profilePath = $PROFILE
}
$sourceLine = ". `"$completionScript`""
# Create profile if it doesn't exist
if (-not (Test-Path $profilePath)) {
$profileDir = Split-Path $profilePath -Parent
if (-not (Test-Path $profileDir)) {
New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
}
New-Item -ItemType File -Path $profilePath -Force | Out-Null
}
# Check if line already exists
$profileContent = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
if ($profileContent -notlike "*$completionScript*") {
Add-Content -Path $profilePath -Value "`n# Cua CLI completions`n$sourceLine"
Write-Host "Success: Added shell completions to PowerShell profile" -ForegroundColor Green
}
}
function Install-WithBun {
Write-Host "Installing Cua CLI using Bun..." -ForegroundColor Yellow
# Check if bun is already installed
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
Write-Host "Installing Bun..." -ForegroundColor Yellow
try {
powershell -c "irm bun.sh/install.ps1|iex"
# Refresh environment variables
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
# Add bun to PATH for this session if not already there
$bunPath = "$env:USERPROFILE\.bun\bin"
if ($env:Path -notlike "*$bunPath*") {
$env:Path = "$bunPath;$env:Path"
}
} catch {
Write-Host "Error: Failed to install Bun. Please install manually from https://bun.sh" -ForegroundColor Red
return $false
}
}
# Verify bun installation
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
Write-Host "Error: Bun installation failed. Please install manually from https://bun.sh" -ForegroundColor Red
return $false
}
try {
bun add -g @trycua/cli
# Determine installed version from npm registry
try {
$bunVersion = (npm view @trycua/cli version) 2>$null
if (-not $bunVersion) { $bunVersion = "unknown" }
} catch { $bunVersion = "unknown" }
# Ensure install dir and write version file
$installDir = "$env:USERPROFILE\.cua\bin"
if (-not (Test-Path $installDir)) { New-Item -ItemType Directory -Path $installDir -Force | Out-Null }
Set-Content -Path (Join-Path $installDir ".version") -Value $bunVersion -NoNewline
# Install completions
$cuaPath = Get-Command cua -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
if ($cuaPath) { Install-Completions -CuaBinaryPath $cuaPath }
return $true
} catch {
Write-Host "Warning: Failed to install with Bun, trying npm..." -ForegroundColor Yellow
try {
npm install -g @trycua/cli
# Determine installed version from npm registry
try {
$npmVersion = (npm view @trycua/cli version) 2>$null
if (-not $npmVersion) { $npmVersion = "unknown" }
} catch { $npmVersion = "unknown" }
# Ensure install dir and write version file
$installDir = "$env:USERPROFILE\.cua\bin"
if (-not (Test-Path $installDir)) { New-Item -ItemType Directory -Path $installDir -Force | Out-Null }
Set-Content -Path (Join-Path $installDir ".version") -Value $npmVersion -NoNewline
# Install completions
$cuaPath = Get-Command cua -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
if ($cuaPath) { Install-Completions -CuaBinaryPath $cuaPath }
return $true
} catch {
Write-Host "Error: Installation failed with npm as well." -ForegroundColor Red
return $false
}
}
}
Write-Host "Installing Cua CLI..." -ForegroundColor Green
# Determine if this is a 64-bit system
$is64Bit = [Environment]::Is64BitOperatingSystem
if (-not $is64Bit) {
Write-Host "Warning: 32-bit Windows is not supported. Falling back to Bun installation..." -ForegroundColor Yellow
if (Install-WithBun) {
exit 0
} else {
Write-Host "Error: Installation failed. Please try installing manually:" -ForegroundColor Red
Write-Host " irm https://cua.ai/install.ps1 | iex"
exit 1
}
}
# Get the latest cua release tag
$tagName = Get-LatestCuaTag
if (-not $tagName) {
Write-Host "Warning: Could not find latest cua release, falling back to Bun installation" -ForegroundColor Yellow
if (Install-WithBun) {
exit 0
} else {
Write-Host "Error: Installation failed. Please try installing manually:" -ForegroundColor Red
Write-Host " irm https://cua.ai/install.ps1 | iex"
exit 1
}
}
# Extract version number (remove 'cua-v' prefix)
$version = $tagName -replace '^cua-v', ''
# Construct download URL using the specific cua release tag
$binaryUrl = "https://github.com/trycua/cua/releases/download/$tagName/cua-windows-x64.exe"
# Create installation directory
$installDir = "$env:USERPROFILE\.cua\bin"
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
$binaryPath = Join-Path $installDir "cua.exe"
# Download the binary
Write-Host "Downloading Cua CLI $version for Windows x64..." -ForegroundColor Cyan
try {
Invoke-WebRequest -Uri $binaryUrl -OutFile $binaryPath -ErrorAction Stop
} catch {
Write-Host "Warning: Failed to download pre-built binary, falling back to Bun installation" -ForegroundColor Yellow
if (Install-WithBun) {
exit 0
} else {
Write-Host "Error: Installation failed. Please try installing manually:" -ForegroundColor Red
Write-Host " irm https://cua.ai/install.ps1 | iex"
exit 1
}
}
# Write version file for binary install
try {
Set-Content -Path (Join-Path $installDir ".version") -Value $version -NoNewline
} catch {
# Non-fatal
}
# Add to PATH if not already there
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$installDir*") {
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$installDir", "User")
$env:Path = "$env:Path;$installDir"
Write-Host "Success: Added $installDir to your PATH" -ForegroundColor Green
}
# Install shell completions
Install-Completions -CuaBinaryPath $binaryPath
# Verify installation
if (Test-Path $binaryPath) {
Write-Host "Success: Cua CLI $version installed successfully to $binaryPath" -ForegroundColor Green
Write-Host ""
Write-Host "Get started with:" -ForegroundColor Cyan
Write-Host " cua login"
Write-Host " cua create --os linux --size small --region north-america"
Write-Host ""
Write-Host "For more help, visit: https://docs.cua.ai/libraries/cua-cli" -ForegroundColor Cyan
# Offer to add to PATH if not already there
if (-not ($env:Path -like "*$installDir*")) {
Write-Host ""
Write-Host "Note: Please restart your terminal or run the following command to use Cua CLI:" -ForegroundColor Yellow
Write-Host " `$env:Path += ';$installDir'"
}
} else {
Write-Host "Error: Installation failed. Please try installing manually:" -ForegroundColor Red
Write-Host " irm https://cua.ai/install.ps1 | iex"
exit 1
}
+263
View File
@@ -0,0 +1,263 @@
#!/bin/bash
set -e
# Cua CLI Installation Script for macOS/Linux
echo "🚀 Installing Cua CLI..."
# Function to print success message
print_success() {
local bin_path="$1"
local version="$2"
local config_file="$3"
printf "\033[32m✅ Cua CLI %s was installed successfully to %s\033[0m\n" "$version" "$bin_path"
printf "\033[90mAdded \"%s\" to \$PATH in \"%s\"\033[0m\n" "$bin_path" "$config_file"
printf "\n\033[90mTo get started, run:\033[0m\n"
printf " source %s\n" "$config_file"
printf " cua --help\n"
printf "\033[90m📚 For more help, visit: https://docs.cua.ai/libraries/cua-cli\033[0m\n"
}
# Helper function to add line to profile if not present (idempotent)
add_to_profile_if_missing() {
local file="$1"
local line="$2"
local description="$3"
if [ -f "$file" ]; then
if ! grep -qF "$line" "$file" 2>/dev/null; then
echo "$line" >> "$file"
echo "$description"
fi
fi
}
# Install shell completions (idempotent)
install_completions_for_path() {
local cua_bin="$1"
local COMPLETIONS_DIR="$HOME/.cua/completions"
mkdir -p "$COMPLETIONS_DIR"
# Generate completions script
local COMPLETION_SCRIPT="$COMPLETIONS_DIR/cua.bash"
"$cua_bin" completion > "$COMPLETION_SCRIPT" 2>/dev/null || true
if [ -s "$COMPLETION_SCRIPT" ]; then
local SOURCE_LINE="[ -f \"$COMPLETION_SCRIPT\" ] && source \"$COMPLETION_SCRIPT\""
add_to_profile_if_missing "$HOME/.bashrc" "$SOURCE_LINE" "Added shell completions to ~/.bashrc"
# For zsh, also add to .zshrc
if [ -f "$HOME/.zshrc" ]; then
add_to_profile_if_missing "$HOME/.zshrc" "$SOURCE_LINE" "Added shell completions to ~/.zshrc"
fi
fi
}
# Function to install with bun as fallback
install_with_bun() {
echo "📦 Installing Cua CLI using Bun..."
# Check if bun is already installed
if ! command -v bun &> /dev/null; then
echo "📦 Installing Bun..."
curl -fsSL https://bun.sh/install | bash
# Source the shell profile to make bun available
if [ -f "$HOME/.bashrc" ]; then
source "$HOME/.bashrc"
elif [ -f "$HOME/.zshrc" ]; then
source "$HOME/.zshrc"
fi
# Add bun to PATH for this session
export PATH="$HOME/.bun/bin:$PATH"
fi
# Verify bun installation
if ! command -v bun &> /dev/null; then
echo "❌ Failed to install Bun. Please install manually from https://bun.sh"
exit 1
fi
echo "📦 Installing Cua CLI..."
if ! bun add -g @trycua/cli; then
echo "❌ Failed to install with Bun, trying npm..."
if ! npm install -g @trycua/cli; then
echo "❌ Installation failed. Please try installing manually:"
echo " npm install -g @trycua/cli"
exit 1
fi
fi
# Verify installation
if command -v cua &> /dev/null; then
# Determine which config file was updated
local config_file="$HOME/.bashrc"
if [ -f "$HOME/.zshrc" ]; then
config_file="$HOME/.zshrc"
elif [ -f "$HOME/.profile" ]; then
config_file="$HOME/.profile"
fi
# Determine installed version via npm registry (fallback to unknown)
local VERSION_BUN
VERSION_BUN=$(npm view @trycua/cli version 2>/dev/null || echo "unknown")
# Write version file to ~/.cua/bin/.version
local INSTALL_DIR="$HOME/.cua/bin"
mkdir -p "$INSTALL_DIR"
echo "$VERSION_BUN" > "$INSTALL_DIR/.version"
# Install completions
install_completions_for_path "$(command -v cua)"
# Print success and exit
print_success "$(command -v cua)" "$VERSION_BUN" "$config_file"
exit 0
else
echo "❌ Installation failed. Please try installing manually:"
echo " npm install -g @trycua/cli"
exit 1
fi
}
# Determine OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
# Map architecture to the format used in release assets
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
*) ARCH="$ARCH" ;;
esac
# Function to get the latest cua release tag
get_latest_cua_tag() {
local page=1
local per_page=100
local max_pages=10
local CUA_TAG=""
while [ $page -le $max_pages ]; do
local response=$(curl -s "https://api.github.com/repos/trycua/cua/tags?per_page=$per_page&page=$page")
if [ -z "$response" ] || [ "$(echo "$response" | grep -c '"name":')" -eq 0 ]; then
return 1
fi
CUA_TAG=$(echo "$response" | grep '"name": "cua-' | head -n 1 | cut -d '"' -f 4)
if [ -n "$CUA_TAG" ]; then
echo "$CUA_TAG"
return 0
fi
page=$((page + 1))
done
return 1
}
# Determine the binary name
BINARY_NAME="cua-${OS}-${ARCH}"
if [ "$OS" = "darwin" ] && [ "$ARCH" = "arm64" ]; then
BINARY_NAME="cua-darwin-arm64"
elif [ "$OS" = "darwin" ] && [ "$ARCH" = "x64" ]; then
BINARY_NAME="cua-darwin-x64"
elif [ "$OS" = "linux" ] && [ "$ARCH" = "x64" ]; then
BINARY_NAME="cua-linux-x64"
else
echo "⚠️ Pre-built binary not available for ${OS}-${ARCH}, falling back to Bun installation"
install_with_bun
exit 0
fi
# Get the latest cua release tag
TAG_NAME=$(get_latest_cua_tag)
if [ -z "$TAG_NAME" ]; then
echo "⚠️ Could not find latest cua release, falling back to Bun installation"
install_with_bun
exit 0
fi
# Extract version number (remove 'cua-v' prefix)
VERSION=${TAG_NAME#cua-v}
# Construct download URL using the specific cua release tag
BINARY_URL="https://github.com/trycua/cua/releases/download/${TAG_NAME}/${BINARY_NAME}"
printf "\033[90mBINARY_URL: %s\033[0m\n" "$BINARY_URL"
# Create ~/.cua/bin directory if it doesn't exist
INSTALL_DIR="$HOME/.cua/bin"
mkdir -p "$INSTALL_DIR"
# Download the binary
echo "📥 Downloading Cua CLI $VERSION for ${OS}-${ARCH}..."
echo "📍 Downloading from: $BINARY_URL"
# Download with progress bar and proper error handling
if ! curl -L --progress-bar --fail "$BINARY_URL" -o "$INSTALL_DIR/cua"; then
echo "❌ Failed to download pre-built binary from $BINARY_URL"
echo "⚠️ Falling back to Bun installation"
install_with_bun
exit 0
fi
# Verify the downloaded file exists and has content
if [ ! -f "$INSTALL_DIR/cua" ] || [ ! -s "$INSTALL_DIR/cua" ]; then
echo "❌ Downloaded file is missing or empty"
echo "⚠️ Falling back to Bun installation"
rm -f "$INSTALL_DIR/cua"
install_with_bun
exit 0
fi
# Check if the downloaded file looks like a binary (not HTML error page)
if file "$INSTALL_DIR/cua" | grep -q "HTML\|text"; then
echo "❌ Downloaded file appears to be corrupted (HTML/text instead of binary)"
echo "⚠️ Falling back to Bun installation"
rm -f "$INSTALL_DIR/cua"
install_with_bun
exit 0
fi
# Make the binary executable
chmod +x "$INSTALL_DIR/cua"
# Write version file
echo "$VERSION" > "$INSTALL_DIR/.version"
# Add ~/.cua/bin to PATH if not already in PATH (idempotent)
PATH_EXPORT="export PATH=\"$INSTALL_DIR:\$PATH\""
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
add_to_profile_if_missing "$HOME/.bashrc" "$PATH_EXPORT" "Added $INSTALL_DIR to PATH in ~/.bashrc"
add_to_profile_if_missing "$HOME/.zshrc" "$PATH_EXPORT" "Added $INSTALL_DIR to PATH in ~/.zshrc"
if [ ! -f "$HOME/.bashrc" ] && [ ! -f "$HOME/.zshrc" ]; then
add_to_profile_if_missing "$HOME/.profile" "$PATH_EXPORT" "Added $INSTALL_DIR to PATH in ~/.profile"
fi
# Add to current session
export PATH="$INSTALL_DIR:$PATH"
fi
# Install shell completions
install_completions_for_path "$INSTALL_DIR/cua"
# Verify installation
if command -v cua &> /dev/null; then
# Determine which config file was updated
config_file="$HOME/.bashrc"
if [ -f "$HOME/.zshrc" ]; then
config_file="$HOME/.zshrc"
elif [ -f "$HOME/.profile" ]; then
config_file="$HOME/.profile"
fi
print_success "$(which cua)" "$VERSION" "$config_file"
exit 0
else
echo "❌ Installation failed. Please try installing manually:"
echo " curl -fsSL https://cua.ai/install.sh | sh"
exit 1
fi
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Add or verify SPDX + copyright headers on cua-driver source files.
Two modes:
--apply (default): insert the header on files that lack it
--check: exit 1 if any tracked file is missing the header (use in CI)
Header format (matches Linux-kernel / Rust ecosystem convention):
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Cua AI, Inc.
//
// <blank line>
// <original file content>
The script is idempotent: it skips any file that already contains an
SPDX-License-Identifier marker in its first 2 KiB.
Typical use:
scripts/spdx-headers.py # apply to libs/cua-driver
scripts/spdx-headers.py libs/cua-driver-rs # apply to a different tree
scripts/spdx-headers.py --check # CI gate
scripts/spdx-headers.py --dry-run # preview only
"""
from __future__ import annotations
import argparse
import pathlib
import sys
from typing import Iterable
LICENSE_ID = "MIT"
COPYRIGHT_HOLDER = "Cua AI, Inc."
COPYRIGHT_YEAR = "2026"
HEADER_LINES = [
f"// SPDX-License-Identifier: {LICENSE_ID}",
f"// Copyright (c) {COPYRIGHT_YEAR} {COPYRIGHT_HOLDER}",
"",
]
MARKER = "SPDX-License-Identifier"
EXTENSIONS = {".swift", ".rs"}
SKIP_DIR_NAMES = {"target", ".build", "build", "node_modules", "DerivedData", ".git"}
DEFAULT_ROOT = "libs/cua-driver"
def file_has_header(path: pathlib.Path) -> bool:
try:
head = path.read_bytes()[:2048].decode("utf-8", errors="replace")
except OSError:
return True # don't try to rewrite unreadable files
return MARKER in head
def insert_header(path: pathlib.Path) -> None:
body = path.read_text(encoding="utf-8")
path.write_text("\n".join(HEADER_LINES) + "\n" + body, encoding="utf-8")
def iter_source_files(root: pathlib.Path) -> Iterable[pathlib.Path]:
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix not in EXTENSIONS:
continue
if any(part in SKIP_DIR_NAMES for part in path.parts):
continue
yield path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("root", nargs="?", default=DEFAULT_ROOT, help=f"directory to scan (default: {DEFAULT_ROOT})")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--apply", action="store_true", help="insert headers on files that lack them (default)")
mode.add_argument("--check", action="store_true", help="exit 1 if any file is missing a header")
mode.add_argument("--dry-run", action="store_true", help="report what would change without writing")
args = parser.parse_args()
root = pathlib.Path(args.root).resolve()
if not root.exists():
print(f"error: {root} does not exist", file=sys.stderr)
return 2
apply_mode = args.apply or not (args.check or args.dry_run)
missing: list[pathlib.Path] = []
touched = 0
skipped = 0
for path in iter_source_files(root):
if file_has_header(path):
skipped += 1
continue
missing.append(path)
rel = path.relative_to(root)
if apply_mode:
insert_header(path)
touched += 1
print(f"+ {rel}")
elif args.dry_run:
print(f"would add header: {rel}")
else: # --check
print(f"missing header: {rel}")
print()
if args.check:
if missing:
print(f"error: {len(missing)} file(s) missing SPDX header under {root}", file=sys.stderr)
return 1
print(f"ok: all {skipped} source files under {root} carry SPDX headers")
return 0
if args.dry_run:
print(f"would add headers to {len(missing)} file(s); {skipped} already have one")
return 0
print(f"added headers to {touched} file(s); {skipped} already had one")
return 0
if __name__ == "__main__":
sys.exit(main())