From 5441aea5250c268f152d6ed5fc6a0504861dac96 Mon Sep 17 00:00:00 2001 From: Marco Barisione Date: Mon, 19 Jan 2026 18:31:56 +0000 Subject: [PATCH] Explain: use `uv` for the Claude Code plugin's dependencies The previous approach would fail in these two cases: 1. No Python installed: `run.sh` exited immediately with "python3 not found". 2. Debian/Ubuntu system Python: `ensurepip` module doesn't work as the maintainers want users to use system packages. To make things easier, we can instead use `uv` (downloading it if necessary) to manage the Python interpreter and the dependencies. `uv` is also much faster, which should decrease the chance of timeouts when the plugin is run for the first time. The only problem we should have now is the lack or wget or curl, but that seems less likely. --- explain/claude_code_plugin/deps.py | 55 +++++++++++++----------------- explain/claude_code_plugin/run.sh | 54 ++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 44 deletions(-) diff --git a/explain/claude_code_plugin/deps.py b/explain/claude_code_plugin/deps.py index 6856f8f..2e09418 100644 --- a/explain/claude_code_plugin/deps.py +++ b/explain/claude_code_plugin/deps.py @@ -9,16 +9,13 @@ from . import xdg_dirs -# IMPORTANT: only import standard library modules or modules with a similar warning here as this -# code must run before dependencies are installed. - - repo_root = Path(__file__).resolve().parent.parent.parent def ensure_sys_paths() -> None: - # Use per-Python-version package directories so switching Python versions - # doesn't require reinstalling (each version keeps its own cached packages). + """ + Add the dependencies directory and repo root to `sys.path`, installing dependencies if needed. + """ py_version = f"{sys.version_info[0]}.{sys.version_info[1]}" deps_dir = xdg_dirs.get_plugin_data_dir() / f"packages-{py_version}" sys.path.insert(0, str(deps_dir)) @@ -28,12 +25,14 @@ def ensure_sys_paths() -> None: def _install_deps(deps_dir: Path) -> None: - # First get `explain`'s dependencies. + """ + Install dependencies from `manifest.json` to the given directory using `uv`. + """ + # First, find out `explain`'s dependencies. manifest = json.loads( (repo_root / "private/manifest.json").read_text(encoding="utf-8"), ) - deps = manifest["udb_addons"]["explain"]["python_package_deps"] - assert isinstance(deps, list) + deps: list[str] = manifest["udb_addons"]["explain"]["python_package_deps"] # Then add the ones for the Claude plugin itself. deps.extend( @@ -43,6 +42,7 @@ def _install_deps(deps_dir: Path) -> None: ] ) + # Skip installation if dependencies haven't changed. checksum_path = deps_dir / "checksum.txt" checksum_current = hashlib.sha224(json.dumps(deps).encode("utf-8")).hexdigest() try: @@ -55,45 +55,36 @@ def _install_deps(deps_dir: Path) -> None: deps_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - _run_install_command( - [ - sys.executable, - "-m", - "ensurepip", - ] - ) + try: + uv = os.environ["_UNDO_uv_path"] # Set by `run.sh`. + except KeyError as exc: + raise RuntimeError( + "_UNDO_uv_path not set. This module must be invoked via run.sh." + ) from exc deps_cmd = [ - sys.executable, - "-s", # Don't use user site packages. - "-m", + uv, "pip", - "-q", "install", - "--ignore-installed", # Ignore what may be installed in the system. + "--quiet", "--upgrade", "--target", - ".", + str(deps_dir), ] + deps - _run_install_command(deps_cmd, cwd=deps_dir) - - checksum_path.write_text(checksum_current) - -def _run_install_command(cmd: list[str], *, cwd: Path | None = None) -> None: try: subprocess.check_output( - cmd, + deps_cmd, stderr=subprocess.STDOUT, text=True, - cwd=cwd, env={ **os.environ, - "PIP_NO_WARN_SCRIPT_LOCATION": "1", - "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "UV_NO_PROGRESS": "1", }, ) except subprocess.CalledProcessError as exc: raise RuntimeError( - f"Failed to install dependencies with command {shlex.join(cmd)}:\n{exc.output}" + f"Failed to install dependencies with command {shlex.join(deps_cmd)}:\n{exc.output}" ) from exc + + checksum_path.write_text(checksum_current) diff --git a/explain/claude_code_plugin/run.sh b/explain/claude_code_plugin/run.sh index 05f37f6..fa59d93 100755 --- a/explain/claude_code_plugin/run.sh +++ b/explain/claude_code_plugin/run.sh @@ -2,18 +2,48 @@ set -euo pipefail -readonly me=$(realpath "${BASH_SOURCE[0]:-$0}") -readonly root=$(dirname "$me")/../.. - -export PYTHONPATH -PYTHONPATH="$root":${PYTHONPATH:-} -readonly python=$(which python3 2> /dev/null) -if [[ -z "$python" ]]; then - echo "python3 not found" >&2 - exit 1 -fi +me=$(realpath "${BASH_SOURCE[0]:-$0}") +readonly me +root=$(dirname "$me")/../.. +readonly root + +readonly plugin_data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/undo/udb_claude_code_plugin" +readonly uv_bin_dir="$plugin_data_dir/bin" +export PYTHONPATH="$root":${PYTHONPATH:-} export UNDO_telemetry_ui=ai -# -S prevents site packages from being loaded. -exec "$python" -S -m explain.claude_code_plugin "$@" +# Return the path to the uv binary, checking PATH first, then local installation. +find_uv() { + if command -v uv &>/dev/null; then + command -v uv + elif [[ -x "$uv_bin_dir/uv" ]]; then + echo "$uv_bin_dir/uv" + else + return 1 + fi +} + +# Download and install the uv binary to the plugin data directory. +install_uv() { + echo "Downloading uv..." >&2 + if command -v curl &>/dev/null; then + curl -fsSL https://astral.sh/uv/install.sh + elif command -v wget &>/dev/null; then + wget -qO- https://astral.sh/uv/install.sh + else + echo "Neither curl nor wget found. Please install one of them." >&2 + return 1 + fi | UV_INSTALL_DIR="$plugin_data_dir" sh >&2 +} + +if ! uv_path=$(find_uv); then + install_uv + if ! uv_path=$(find_uv); then + echo "Failed to install uv" >&2 + exit 1 + fi +fi +export _UNDO_uv_path="$uv_path" # Used bt `deps.py`. + +exec "$uv_path" run --no-project --python 3.10 -m explain.claude_code_plugin "$@"