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 "$@"