diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c10043f825e..74b74f2725b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6603,6 +6603,103 @@ def _run_install_with_heartbeat( t.join(timeout=0.2) +def _is_windows() -> bool: + return sys.platform == "win32" + + +def _venv_scripts_dir() -> Path | None: + """Return the venv Scripts directory if we're running inside the project venv.""" + venv_dir = PROJECT_ROOT / "venv" + if not venv_dir.is_dir(): + return None + scripts = venv_dir / ("Scripts" if _is_windows() else "bin") + return scripts if scripts.is_dir() else None + + +def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: + """Entry-point shims that uv may try to rewrite during ``pip install -e .``. + + On Windows these are .exe launchers generated by setuptools/uv. On POSIX + they're regular Python scripts which can be replaced atomically — no + self-replacement hazard exists outside Windows. + """ + if not _is_windows(): + return [] + return [ + scripts_dir / "hermes.exe", + scripts_dir / "hermes-gateway.exe", + ] + + +def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]: + """Pre-empt Windows file lock on the running ``hermes.exe``. + + Windows allows RENAMING a mapped/running executable (the kernel tracks the + file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv + needs to overwrite the entry-point shims during ``pip install -e .``; + when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv + fails with ``Access is denied. (os error 5)``. + + We rename live shims to ``hermes.exe.old.`` first. uv then writes + fresh shims at the original paths. The ``.old`` files are cleaned up on + the next hermes invocation by ``_cleanup_quarantined_exes``. + + Returns the list of (original, quarantined) pairs so the caller can roll + back if the install itself fails before uv writes a replacement. + """ + moved: list[tuple[Path, Path]] = [] + if not _is_windows(): + return moved + + import time + stamp = int(time.time() * 1000) + for shim in _hermes_exe_shims(scripts_dir): + if not shim.exists(): + continue + target = shim.with_suffix(shim.suffix + f".old.{stamp}") + try: + shim.rename(target) + moved.append((shim, target)) + except OSError as e: + # Best-effort: keep going. uv's failure later will surface the + # real error; this is a heuristic, not a hard guarantee. + print(f" ⚠ Could not quarantine {shim.name}: {e}") + return moved + + +def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: + """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements.""" + for original, quarantined in moved: + try: + if not original.exists() and quarantined.exists(): + quarantined.rename(original) + except OSError: + pass + + +def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: + """Sweep ``hermes.exe.old.*`` left by prior updates. + + Called early on every hermes invocation. The .old files are unlocked once + their owning process exited, so deletion succeeds the next run. Silent + no-op when nothing's there or on file-locked / permission errors. + """ + if not _is_windows(): + return + if scripts_dir is None: + scripts_dir = _venv_scripts_dir() + if scripts_dir is None: + return + try: + for stale in scripts_dir.glob("*.exe.old.*"): + try: + stale.unlink() + except OSError: + pass # still locked or in use — try again next run + except OSError: + pass + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -6613,31 +6710,42 @@ def _install_python_dependencies_with_optional_fallback( By default this targets ``.[all]``; Termux callers can pass ``group='termux-all'`` to use the curated Android-compatible profile. + + On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims + in the venv Scripts dir before each install attempt so uv can write fresh + copies (Windows blocks REPLACE on a running .exe but allows RENAME). See + ``_quarantine_running_hermes_exe`` for the rationale. """ + scripts_dir = _venv_scripts_dir() if _is_windows() else None + + def _install(args: list[str]) -> None: + moved: list[tuple[Path, Path]] = [] + if scripts_dir is not None: + moved = _quarantine_running_hermes_exe(scripts_dir) + try: + _run_install_with_heartbeat(install_cmd_prefix + args, env=env) + except BaseException: + # Restore shims if uv didn't write replacements (e.g. install + # failed before the entry-points step). Don't swallow the error. + if scripts_dir is not None: + _restore_quarantined_exes(moved) + raise + try: - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", f".[{group}]"], - env=env, - ) + _install(["install", "-e", f".[{group}]"]) return except subprocess.CalledProcessError: print( " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." ) - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", "."], - env=env, - ) + _install(["install", "-e", "."]) failed_extras: list[str] = [] installed_extras: list[str] = [] for extra in _load_installable_optional_extras(group=group): try: - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", f".[{extra}]"], - env=env, - ) + _install(["install", "-e", f".[{extra}]"]) installed_extras.append(extra) except subprocess.CalledProcessError: failed_extras.append(extra) @@ -9150,6 +9258,14 @@ def main(): except Exception: pass + # Sweep stale ``hermes.exe.old.*`` quarantine files left by previous + # ``hermes update`` runs on Windows. Silent no-op on non-Windows or when + # there's nothing to clean. See ``_quarantine_running_hermes_exe``. + try: + _cleanup_quarantined_exes() + except Exception: + pass + from hermes_cli._parser import build_top_level_parser parser, subparsers, chat_parser = build_top_level_parser() diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 74fc29247d2..96b3d4e3be5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -13,6 +13,7 @@ import json as _json import logging import os import shutil +import subprocess import sys from pathlib import Path from typing import Dict, List, Optional, Set @@ -521,6 +522,75 @@ TOOLSET_ENV_REQUIREMENTS = { # ─── Post-Setup Hooks ───────────────────────────────────────────────────────── + +def _pip_install( + args: List[str], + *, + timeout: int = 300, + capture_output: bool = True, +): + """Install Python packages from a post-setup hook. + + Strategy (in order): + 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the venv. + 2. ``python -m pip install`` — works on stdlib venvs. + 3. ``python -m ensurepip --upgrade`` then retry pip — covers ``uv venv`` + which creates a venv WITHOUT pip. + + Why this exists: the Windows installer creates the venv via ``uv venv``, + which doesn't seed pip. Post-setup hooks that shelled out to + ``[sys.executable, '-m', 'pip', 'install', ...]`` failed with + ``No module named pip`` on every fresh install. uv-first sidesteps that. + + Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded + (or the last failure for the caller to inspect). + """ + venv_root = Path(sys.executable).parent.parent + uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + + uv_bin = shutil.which("uv") + if uv_bin: + try: + result = subprocess.run( + [uv_bin, "pip", "install", *args], + capture_output=capture_output, text=True, timeout=timeout, + env=uv_env, + ) + if result.returncode == 0: + return result + # Fall through to pip — uv may have failed for an unrelated reason + # (resolution conflict, network), and pip might handle it. + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + pip_cmd = [sys.executable, "-m", "pip"] + try: + # Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it). + probe = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, timeout=15, + ) + if probe.returncode != 0: + raise FileNotFoundError("pip not in venv") + except (subprocess.TimeoutExpired, FileNotFoundError): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + capture_output=True, text=True, timeout=120, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + # Synthesize a result so callers see a clean failure path. + return subprocess.CompletedProcess( + pip_cmd, returncode=1, stdout="", + stderr=f"pip not available and ensurepip failed: {e}", + ) + + return subprocess.run( + pip_cmd + ["install", *args], + capture_output=capture_output, text=True, timeout=timeout, + ) + + def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil @@ -712,51 +782,43 @@ def _run_post_setup(post_setup_key: str): return except ImportError: pass - import subprocess _print_info(" Installing kittentts (~25-80MB model, CPU-only)...") wheel_url = ( "https://github.com/KittenML/KittenTTS/releases/download/" "0.8.1/kittentts-0.8.1-py3-none-any.whl" ) try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" kittentts installed") _print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo") _print_info(" Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)") else: _print_warning(" kittentts install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile") except subprocess.TimeoutExpired: _print_warning(" kittentts install timed out (>5min)") - _print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") + _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile") elif post_setup_key == "piper": try: __import__("piper") _print_success(" piper-tts is already installed") except ImportError: - import subprocess _print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...") try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" piper-tts installed") else: _print_warning(" piper-tts install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(" Run manually: python -m pip install -U piper-tts") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(" Run manually: uv pip install -U piper-tts") return except subprocess.TimeoutExpired: _print_warning(" piper-tts install timed out (>5min)") - _print_info(" Run manually: python -m pip install -U piper-tts") + _print_info(" Run manually: uv pip install -U piper-tts") return _print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)") _print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md") @@ -767,23 +829,19 @@ def _run_post_setup(post_setup_key: str): __import__("ddgs") _print_success(" ddgs is already installed") except ImportError: - import subprocess _print_info(" Installing ddgs (DuckDuckGo search package)...") try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" ddgs installed") else: _print_warning(" ddgs install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(" Run manually: python -m pip install -U ddgs") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(" Run manually: uv pip install -U ddgs") return except subprocess.TimeoutExpired: _print_warning(" ddgs install timed out (>5min)") - _print_info(" Run manually: python -m pip install -U ddgs") + _print_info(" Run manually: uv pip install -U ddgs") return _print_info(" No API key required. DuckDuckGo enforces server-side rate limits.") _print_info(" Pair with an extract provider if you also need web_extract.") @@ -824,18 +882,7 @@ def _run_post_setup(post_setup_key: str): tinker_dir = PROJECT_ROOT / "tinker-atropos" if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): _print_info(" Installing tinker-atropos submodule...") - import subprocess - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "-e", str(tinker_dir)], - capture_output=True, text=True - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)], - capture_output=True, text=True - ) + result = _pip_install(["-e", str(tinker_dir)]) if result.returncode == 0: _print_success(" tinker-atropos installed") else: @@ -852,16 +899,12 @@ def _run_post_setup(post_setup_key: str): __import__("langfuse") _print_success(" langfuse SDK already installed") except ImportError: - import subprocess _print_info(" Installing langfuse SDK...") - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "langfuse", "--quiet"], - capture_output=True, text=True, timeout=120, - ) + result = _pip_install(["langfuse", "--quiet"], timeout=120) if result.returncode == 0: _print_success(" langfuse SDK installed") else: - _print_warning(" langfuse SDK install failed — run manually: pip install langfuse") + _print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse") # Opt the bundled observability/langfuse plugin into plugins.enabled. # The plugin ships in the repo but doesn't load until the user enables # it (standalone plugins are opt-in). diff --git a/pyproject.toml b/pyproject.toml index 15362c2df42..0e3a7901eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,6 +227,3 @@ select = ["PLW1514"] "skills/**" = ["PLW1514"] "optional-skills/**" = ["PLW1514"] "plugins/**" = ["PLW1514"] - -[tool.uv] -exclude-newer = "7 days"