diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 2cba9372333..079033b772a 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -348,17 +348,15 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]: pip_target.mkdir(parents=True, exist_ok=True) try: logger.info("[install] pip install --target %s %s", pip_target, pkg) - proc = subprocess.run( - [sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg], - check=False, - capture_output=True, - text=True, + from hermes_cli.tools_config import _pip_install + + proc = _pip_install( + ["--target", str(pip_target), "--quiet", pkg], timeout=300, - stdin=subprocess.DEVNULL, ) if proc.returncode != 0: logger.warning( - "[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500] + "[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500] ) return None except (subprocess.TimeoutExpired, OSError) as e: diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 710be775496..3c397759d51 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -163,17 +163,15 @@ def _ensure_qrcode_installed() -> bool: import subprocess - # Try uv first (Hermes convention), then pip - for cmd in ( - [sys.executable, "-m", "uv", "pip", "install", "qrcode"], - [sys.executable, "-m", "pip", "install", "-q", "qrcode"], - ): - try: - subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + from hermes_cli.tools_config import _pip_install + + try: + result = _pip_install(["-q", "qrcode"], timeout=120) + if result.returncode == 0: import qrcode # noqa: F401,F811 return True - except (subprocess.CalledProcessError, ImportError, FileNotFoundError): - continue + except (subprocess.SubprocessError, ImportError, OSError): + pass return False diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index c4574affe39..2f6801de399 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -122,36 +122,19 @@ def _install_dependencies(provider_name: str) -> None: print(f"\n Installing dependencies: {', '.join(missing)}") - import shutil - - uv_path = shutil.which("uv") - if uv_path: - install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing - manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}" - else: - pip_cmd = shutil.which("pip3") or shutil.which("pip") - if not pip_cmd: - print(" ⚠ uv not found — cannot install dependencies") - print(" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") - print(" Then re-run: hermes memory setup") - return - print(" ⚠ uv not found. Falling back to standard pip...") - install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing - manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}" + from hermes_cli.tools_config import _pip_install + manual_cmd = f"uv pip install {' '.join(missing)}" try: - subprocess.run( - install_cmd, - check=True, timeout=120, - capture_output=True, - ) - print(f" ✓ Installed {', '.join(missing)}") - except subprocess.CalledProcessError as e: - print(f" ⚠ Failed to install {', '.join(missing)}") - stderr = (e.stderr or b"").decode()[:200] - if stderr: - print(f" {stderr}") - print(f" Run manually: {manual_cmd}") + result = _pip_install(["--quiet"] + missing, timeout=120) + if result.returncode == 0: + print(f" ✓ Installed {', '.join(missing)}") + else: + print(f" ⚠ Failed to install {', '.join(missing)}") + stderr = (result.stderr or "")[:200] + if stderr: + print(f" {stderr}") + print(f" Run manually: {manual_cmd}") except Exception as e: print(f" ⚠ Install failed: {e}") print(f" Run manually: {manual_cmd}") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 63a9372adf9..fcdba27165f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -829,29 +829,23 @@ def _install_neutts_deps() -> bool: print_info("This will also download the TTS model (~300MB) on first use.") print() - # Ensure pip is available — some venvs are created without pip (e.g. Ubuntu 25.10) - try: - import importlib - if importlib.util.find_spec("pip") is None: - print_info("pip not found in venv — bootstrapping with ensurepip...") - subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], check=True, timeout=30) - print_success("pip bootstrapped successfully") - except Exception as e: - print_warning(f"Could not bootstrap pip: {e}") - print_info("Try: python -m ensurepip --upgrade") - return False + # Route through the canonical uv → pip → ensurepip ladder so pip-less + # venvs (Ubuntu 25.10 `python -m venv`, `uv venv`) work out of the box. + from hermes_cli.tools_config import _pip_install try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"], - check=True, timeout=300, - ) + result = _pip_install(["-U", "neutts[all]", "--quiet"], timeout=300) + except Exception as e: + print_error(f"Failed to install neutts: {e}") + print_info("Try manually: uv pip install -U 'neutts[all]'") + return False + if result.returncode == 0: print_success("neutts installed successfully") return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - print_error(f"Failed to install neutts: {e}") - print_info("Try manually: python -m pip install -U neutts[all]") - return False + err = (result.stderr or "").strip() + print_error(f"Failed to install neutts: {err[:300] if err else 'install failed'}") + print_info("Try manually: uv pip install -U 'neutts[all]'") + return False def _install_kittentts_deps() -> bool: @@ -866,17 +860,22 @@ def _install_kittentts_deps() -> bool: print() print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...") print() + + from hermes_cli.tools_config import _pip_install + try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"], - check=True, timeout=300, - ) + result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300) + except Exception as e: + print_error(f"Failed to install kittentts: {e}") + print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile") + return False + if result.returncode == 0: print_success("kittentts installed successfully") return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - print_error(f"Failed to install kittentts: {e}") - print_info(f"Try manually: python -m pip install -U '{wheel_url}' soundfile") - return False + err = (result.stderr or "").strip() + print_error(f"Failed to install kittentts: {err[:300] if err else 'install failed'}") + print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile") + return False def _xai_oauth_logged_in_for_setup() -> bool: @@ -1315,32 +1314,13 @@ def setup_terminal_backend(config: dict): __import__("modal") except ImportError: print_info("Installing modal SDK...") - import subprocess + from hermes_cli.tools_config import _pip_install - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [ - uv_bin, - "pip", - "install", - "--python", - sys.executable, - "modal", - ], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "modal"], - capture_output=True, - text=True, - ) + result = _pip_install(["modal"]) if result.returncode == 0: print_success("modal SDK installed") else: - print_warning("Install failed — run manually: pip install modal") + print_warning("Install failed — run manually: uv pip install modal") # Modal token print() @@ -1375,25 +1355,13 @@ def setup_terminal_backend(config: dict): __import__("daytona") except ImportError: print_info("Installing daytona SDK...") - import subprocess + from hermes_cli.tools_config import _pip_install - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "daytona"], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "daytona"], - capture_output=True, - text=True, - ) + result = _pip_install(["daytona"]) if result.returncode == 0: print_success("daytona SDK installed") else: - print_warning("Install failed — run manually: pip install daytona") + print_warning("Install failed — run manually: uv pip install daytona") if result.stderr: print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index 1e5f91d0177..1ab0c0fd73d 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -250,10 +250,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: pip_pkgs = ["playwright", "websockets"] print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}") try: - res = _sp.run( - [sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs], - check=False, - ) + from hermes_cli.tools_config import _pip_install + + res = _pip_install(["--upgrade", *pip_pkgs], capture_output=False) if res.returncode != 0: print(" pip install failed") return 1 diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index d70a13e146b..3db0cc3fd68 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -515,20 +515,16 @@ def _ensure_sdk_installed() -> bool: print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n") return False - import subprocess print(" Installing honcho-ai...", flush=True) - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install(["honcho-ai>=2.0.1"]) if result.returncode == 0: print(" Installed.\n") return True else: - print(f" Install failed:\n{result.stderr.strip()}") - print(" Run manually: pip install 'honcho-ai>=2.0.1'\n") + print(f" Install failed:\n{(result.stderr or '').strip()}") + print(" Run manually: uv pip install 'honcho-ai>=2.0.1'\n") return False diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 3d481b3ead7..277b2396f0c 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -379,13 +379,14 @@ def install_deps() -> bool: print("Installing Google Chat OAuth dependencies...") try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES, - stdout=subprocess.DEVNULL, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install(["--quiet"] + _REQUIRED_PACKAGES) + if result.returncode != 0: + raise RuntimeError((result.stderr or "install failed").strip()[:300]) print("Dependencies installed.") return True - except subprocess.CalledProcessError as exc: + except Exception as exc: print(f"ERROR: Failed to install dependencies: {exc}") print("Or install via the optional extra:") print(" pip install 'hermes-agent[google_chat]'") diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index dac4dbd1665..d66a1d87b76 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -4497,23 +4497,14 @@ def interactive_setup() -> None: __import__("mautrix") except ImportError: print_info(f"Installing {matrix_pkg}...") - import subprocess - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg], - capture_output=True, text=True, - ) - else: - result = subprocess.run( - [_sys.executable, "-m", "pip", "install", matrix_pkg], - capture_output=True, text=True, - ) + from hermes_cli.tools_config import _pip_install + + result = _pip_install([matrix_pkg]) if result.returncode == 0: print_success(f"{matrix_pkg} installed") else: print_warning( - f"Install failed — run manually: pip install " + f"Install failed — run manually: uv pip install " f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks" ) diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 7e7ff6ba328..afeade9ddbd 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -51,47 +51,55 @@ class TestMemorySetupProviderRouting: class TestInstallDependenciesRunner: - """`_install_dependencies` must install via `uv` when present and fall back - to standard `pip` when `uv` is unavailable (e.g. slim containers / CI images - that don't ship uv) instead of dead-ending with "cannot install".""" + """`_install_dependencies` must route through the canonical + ``_pip_install`` ladder (uv → pip → ensurepip): uv when present, standard + pip when uv is unavailable, and an ensurepip bootstrap for pip-less venvs + instead of dead-ending with "cannot install".""" - def _run_with_missing_dep(self, tmp_path, which_side_effect): + def _run_with_missing_dep(self, tmp_path, which_side_effect, run_behavior=None): """Drive _install_dependencies for a plugin that declares one missing - pip dep, capturing the subprocess.run argv (or None if never called).""" + pip dep, capturing every subprocess.run argv issued by the ladder.""" import sys (tmp_path / "plugin.yaml").write_text( "pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8" ) - captured = {} + calls = [] def fake_run(cmd, **kw): - captured["cmd"] = cmd - return SimpleNamespace() + calls.append(cmd) + if run_behavior: + return run_behavior(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ - patch("shutil.which", side_effect=which_side_effect), \ - patch("subprocess.run", fake_run): + patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \ + patch("hermes_cli.tools_config.subprocess.run", fake_run): memory_setup._install_dependencies("x") - return captured.get("cmd"), sys.executable + return calls, sys.executable def test_uses_uv_when_available(self, tmp_path): - cmd, _ = self._run_with_missing_dep( + calls, _ = self._run_with_missing_dep( tmp_path, lambda b: "/usr/bin/uv" if b == "uv" else None ) - assert cmd is not None - assert cmd[:3] == ["/usr/bin/uv", "pip", "install"] + assert calls + assert calls[0][:3] == ["/usr/bin/uv", "pip", "install"] - def test_falls_back_to_pip_when_uv_missing(self, tmp_path, capsys): - """The salvaged behavior (#5954): no uv but pip present -> python -m pip.""" - cmd, py = self._run_with_missing_dep( - tmp_path, lambda b: "/usr/bin/pip3" if b == "pip3" else None - ) - assert cmd is not None - assert cmd[:4] == [py, "-m", "pip", "install"] - assert "Falling back to standard pip" in capsys.readouterr().out + def test_falls_back_to_pip_when_uv_missing(self, tmp_path): + """No uv but pip importable -> python -m pip install.""" + calls, py = self._run_with_missing_dep(tmp_path, lambda b: None) + assert calls + # Ladder probes pip first, then installs with it. + assert calls[0][:3] == [py, "-m", "pip"] + assert calls[-1][:4] == [py, "-m", "pip", "install"] - def test_aborts_when_neither_uv_nor_pip(self, tmp_path, capsys): - cmd, _ = self._run_with_missing_dep(tmp_path, lambda b: None) - assert cmd is None # no install attempted - assert "cannot install dependencies" in capsys.readouterr().out + def test_bootstraps_pip_via_ensurepip_when_missing(self, tmp_path): + """Neither uv nor pip -> ensurepip bootstrap, then pip install.""" + def behavior(cmd): + if cmd[-1] == "--version": + return SimpleNamespace(returncode=1, stdout="", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + calls, py = self._run_with_missing_dep(tmp_path, lambda b: None, behavior) + assert any("ensurepip" in c for c in calls) + assert calls[-1][:4] == [py, "-m", "pip", "install"]