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
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:
@@ -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.
|
||||
Executable
+27
@@ -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}"
|
||||
Executable
+69
@@ -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" "$@"
|
||||
Executable
+7
@@ -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" "$@"
|
||||
Executable
+219
@@ -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}"
|
||||
Executable
+231
@@ -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}"
|
||||
Executable
+241
@@ -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}"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '{')
|
||||
.replace(/\}/g, '}')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
})
|
||||
.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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, '<')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
# Python dependencies for documentation generation
|
||||
griffe>=0.40.0
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, '<');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("{", "{")
|
||||
.replace("}", "}")
|
||||
)
|
||||
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()
|
||||
@@ -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
|
||||
}
|
||||
Executable
+263
@@ -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
|
||||
Executable
+124
@@ -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())
|
||||
Reference in New Issue
Block a user