diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index a75c10b02..2707c77f4 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -97,16 +97,25 @@ def _install_dependencies(provider_name: str) -> None: print(f"\n Installing dependencies: {', '.join(missing)}") import shutil + uv_path = shutil.which("uv") - if not uv_path: - print(f" ⚠ uv not found — cannot install dependencies") - print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") - print(f" Then re-run: hermes memory setup") - return + 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(f" ⚠ uv not found — cannot install dependencies") + print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") + print(f" Then re-run: hermes memory setup") + return + print(f" ⚠ 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)}" try: subprocess.run( - [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing, + install_cmd, check=True, timeout=120, capture_output=True, ) @@ -116,10 +125,10 @@ def _install_dependencies(provider_name: str) -> None: stderr = (e.stderr or b"").decode()[:200] if stderr: print(f" {stderr}") - print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") + print(f" Run manually: {manual_cmd}") except Exception as e: print(f" ⚠ Install failed: {e}") - print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") + print(f" Run manually: {manual_cmd}") # Also show external dependencies (non-pip) if any ext_deps = meta.get("external_dependencies", []) diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 6dd310094..7e7ff6ba3 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -48,3 +48,50 @@ class TestMemorySetupProviderRouting: out = capsys.readouterr().out assert "not found" in out assert "hermes memory setup" in out + + +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".""" + + def _run_with_missing_dep(self, tmp_path, which_side_effect): + """Drive _install_dependencies for a plugin that declares one missing + pip dep, capturing the subprocess.run argv (or None if never called).""" + import sys + + (tmp_path / "plugin.yaml").write_text( + "pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8" + ) + captured = {} + + def fake_run(cmd, **kw): + captured["cmd"] = cmd + return SimpleNamespace() + + with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ + patch("shutil.which", side_effect=which_side_effect), \ + patch("subprocess.run", fake_run): + memory_setup._install_dependencies("x") + return captured.get("cmd"), sys.executable + + def test_uses_uv_when_available(self, tmp_path): + cmd, _ = 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"] + + 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_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