From 95994bbc568a61297e10bbea0d0c96f3d2148b07 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 26 Jun 2026 14:37:02 +0700 Subject: [PATCH 1/3] fix(windows): repair missing hermes.exe after pip install (#52931) On Windows, uv pip install -e . can register hermes.exe in package metadata while the launcher never lands on disk. Detect missing [project.scripts] shims and reinstall entry points under the existing quarantine path in hermes update and install.ps1. --- hermes_cli/main.py | 91 +++++++++++++++++++++++++++++++++++++++++++++ scripts/install.ps1 | 42 +++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e6445afd766..d67c85718a6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7666,6 +7666,97 @@ def _install_python_dependencies_with_optional_fallback( # missing, then re-verify so the failure surfaces here instead of # downstream. _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group) + _verify_console_scripts_installed(install_cmd_prefix, env=env) + + +def _load_console_script_names() -> list[str]: + """Return ``[project.scripts]`` entry-point names from pyproject.toml.""" + try: + import tomllib # Python 3.11+ + except ImportError: # pragma: no cover + return [] + + pyproject = PROJECT_ROOT / "pyproject.toml" + if not pyproject.is_file(): + return [] + + try: + with open(pyproject, "rb") as f: + data = tomllib.load(f) + scripts = data.get("project", {}).get("scripts", {}) or {} + return [str(name) for name in scripts if name] + except Exception as e: + logger.debug("console script verification: failed to read pyproject.toml: %s", e) + return [] + + +def _verify_console_scripts_installed( + install_cmd_prefix: list[str], + *, + env: dict[str, str] | None = None, +) -> None: + """Ensure every declared console_script shim exists on disk after install. + + On Windows, ``uv pip install -e .`` can register ``hermes.exe`` in the + wheel RECORD while the file never lands on disk — typically when the live + ``hermes.exe`` shim is locked during ``hermes update``, or when uv/distlib + skips a launcher write. The symptom is ``hermes-agent.exe`` and + ``hermes-acp.exe`` present but ``hermes.exe`` missing, so ``hermes`` drops + off PATH even though the install reported success (issue #52931). + + If any shim is missing we reinstall with ``--reinstall -e .`` under the + same quarantine dance as the primary install path, then re-check. + """ + if not _is_windows(): + return + + scripts_dir = _venv_scripts_dir() + if scripts_dir is None: + return + + names = _load_console_script_names() + if not names: + return + + def _missing() -> list[str]: + return [ + name + for name in names + if not (scripts_dir / f"{name}.exe").is_file() + ] + + missing = _missing() + if not missing: + return + + print( + f" ⚠ Verification: {len(missing)} console script(s) missing on disk: " + f"{', '.join(missing)}" + ) + print(" → Reinstalling entry points with --reinstall...") + + try: + _run_quarantined_install( + install_cmd_prefix + ["install", "--reinstall", "-e", "."], + env=env, + scripts_dir=scripts_dir, + ) + except subprocess.CalledProcessError as e: + logger.warning("console script verification: repair install failed: %s", e) + print( + " ⚠ Entry point repair failed; try `hermes update --force` after " + "closing other hermes processes." + ) + return + + still_missing = _missing() + if still_missing: + print( + f" ⚠ Still missing after repair: {', '.join(still_missing)}. " + "Workaround: python -m hermes_cli.main " + ) + else: + print(" ✓ All console entry points restored") def _verify_core_dependencies_installed( diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 86bf987ef37..fde9d4363d4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1854,6 +1854,48 @@ except Exception: Write-Success "Baseline imports verified in venv" } + if (-not $NoVenv) { + # uv on Windows can register hermes.exe in dist-info/RECORD but fail to + # materialise the .exe (file lock during self-update, distlib edge case). + # Catch it here so a fresh install/update does not finish with a broken + # `hermes` command while hermes-agent.exe / hermes-acp.exe exist + $scriptsDir = Join-Path $InstallDir "venv\Scripts" + $pythonExe = Join-Path $scriptsDir "python.exe" + if ((Test-Path $scriptsDir) -and (Test-Path $pythonExe)) { + $scriptNames = & $pythonExe -c @" +import tomllib +with open('pyproject.toml', 'rb') as fh: + scripts = tomllib.load(fh).get('project', {}).get('scripts', {}) or {} +print(','.join(scripts)) +"@ 2>$null + if ($LASTEXITCODE -eq 0 -and $scriptNames) { + $expected = @($scriptNames.Trim().Split(',') | Where-Object { $_ }) + $missing = @() + foreach ($name in $expected) { + $exe = Join-Path $scriptsDir "$name.exe" + if (-not (Test-Path $exe)) { $missing += "$name.exe" } + } + if ($missing.Count -gt 0) { + Write-Warn "Console entry point(s) missing: $($missing -join ', ')" + Write-Info "Reinstalling entry points..." + $env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv" + Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install --reinstall -e . } + $stillMissing = @() + foreach ($name in $expected) { + $exe = Join-Path $scriptsDir "$name.exe" + if (-not (Test-Path $exe)) { $stillMissing += "$name.exe" } + } + if ($stillMissing.Count -gt 0) { + Write-Warn "Entry points still missing after repair: $($stillMissing -join ', ')" + Write-Info "Workaround: `"$pythonExe`" -m hermes_cli.main " + } else { + Write-Success "Console entry points restored" + } + } + } + } + } + # Verify the dashboard deps specifically -- they're the most common thing # users hit and lazy-import errors from `hermes dashboard` are confusing. # If tier 1 failed (the common case), [web] was still picked up by tiers From 76bb8f46a0db2b9d7893dc0d54e2a87f79365fb5 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 26 Jun 2026 14:37:02 +0700 Subject: [PATCH 2/3] test(cli): cover Windows console script repair (#52931) Add unit tests for missing-shim detection and repair trigger in _verify_console_scripts_installed. --- .../hermes_cli/test_verify_console_scripts.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/hermes_cli/test_verify_console_scripts.py diff --git a/tests/hermes_cli/test_verify_console_scripts.py b/tests/hermes_cli/test_verify_console_scripts.py new file mode 100644 index 00000000000..a09b1915ecb --- /dev/null +++ b/tests/hermes_cli/test_verify_console_scripts.py @@ -0,0 +1,88 @@ +"""Tests for _verify_console_scripts_installed (issue #52931).""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def temp_pyproject(tmp_path, monkeypatch): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + textwrap.dedent( + """\ + [project] + name = "fake" + version = "0.0.0" + + [project.scripts] + hermes = "hermes_cli.main:main" + hermes-agent = "run_agent:main" + hermes-acp = "acp_adapter.entry:main" + """ + ) + ) + import hermes_cli.main as main_mod + + monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path) + return tmp_path + + +@pytest.fixture +def fake_scripts_dir(tmp_path): + scripts = tmp_path / "venv" / "Scripts" + scripts.mkdir(parents=True) + return scripts + + +class TestVerifyConsoleScriptsInstalled: + def test_no_action_when_all_shims_present(self, temp_pyproject, fake_scripts_dir): + for name in ("hermes", "hermes-agent", "hermes-acp"): + (fake_scripts_dir / f"{name}.exe").write_bytes(b"fake") + + with patch("hermes_cli.main._is_windows", return_value=True), \ + patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts_dir), \ + patch("hermes_cli.main._run_quarantined_install") as mock_install: + from hermes_cli.main import _verify_console_scripts_installed + + _verify_console_scripts_installed(["uv", "pip"], env={}) + + mock_install.assert_not_called() + + def test_triggers_reinstall_when_hermes_exe_missing( + self, temp_pyproject, fake_scripts_dir + ): + (fake_scripts_dir / "hermes-agent.exe").write_bytes(b"fake") + (fake_scripts_dir / "hermes-acp.exe").write_bytes(b"fake") + + with patch("hermes_cli.main._is_windows", return_value=True), \ + patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts_dir), \ + patch("hermes_cli.main._run_quarantined_install") as mock_install: + from hermes_cli.main import _verify_console_scripts_installed + + _verify_console_scripts_installed(["uv", "pip"], env={}) + + mock_install.assert_called_once() + args = mock_install.call_args[0][0] + assert "--reinstall" in args + assert "-e" in args and "." in args + assert mock_install.call_args[1]["scripts_dir"] == fake_scripts_dir + + def test_skips_off_windows(self, temp_pyproject, fake_scripts_dir): + with patch("hermes_cli.main._is_windows", return_value=False), \ + patch("hermes_cli.main._run_quarantined_install") as mock_install: + from hermes_cli.main import _verify_console_scripts_installed + + _verify_console_scripts_installed(["uv", "pip"], env={}) + + mock_install.assert_not_called() + + def test_load_console_script_names_reads_pyproject(self, temp_pyproject): + from hermes_cli.main import _load_console_script_names + + names = _load_console_script_names() + assert names == ["hermes", "hermes-agent", "hermes-acp"] From df8e2523faa36e0c138065e6648fa203a7b0bfff Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:02:05 -0500 Subject: [PATCH 3/3] fix(windows): verify launchers after primary install --- hermes_cli/main.py | 11 +++++--- .../hermes_cli/test_verify_console_scripts.py | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d67c85718a6..511c435f131 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7182,10 +7182,12 @@ def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: """ if not _is_windows(): return [] - return [ - scripts_dir / "hermes.exe", - scripts_dir / "hermes-gateway.exe", - ] + + names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"} + # The gateway shim is not a [project.scripts] entry point, but older + # update/install paths still rewrite and quarantine it. + names.add("hermes-gateway") + return [scripts_dir / f"{name}.exe" for name in sorted(names)] def _detect_concurrent_hermes_instances( @@ -7629,6 +7631,7 @@ def _install_python_dependencies_with_optional_fallback( try: _install(["install", "-e", f".[{group}]"]) + _verify_console_scripts_installed(install_cmd_prefix, env=env) return except subprocess.CalledProcessError: print( diff --git a/tests/hermes_cli/test_verify_console_scripts.py b/tests/hermes_cli/test_verify_console_scripts.py index a09b1915ecb..f0bd3078393 100644 --- a/tests/hermes_cli/test_verify_console_scripts.py +++ b/tests/hermes_cli/test_verify_console_scripts.py @@ -86,3 +86,31 @@ class TestVerifyConsoleScriptsInstalled: names = _load_console_script_names() assert names == ["hermes", "hermes-agent", "hermes-acp"] + + def test_primary_install_success_still_verifies_scripts(self): + import hermes_cli.main as main_mod + + with patch("hermes_cli.main._is_windows", return_value=False), \ + patch("hermes_cli.main._run_quarantined_install") as mock_install, \ + patch("hermes_cli.main._verify_console_scripts_installed") as mock_verify: + main_mod._install_python_dependencies_with_optional_fallback( + ["uv", "pip"], env={"VIRTUAL_ENV": "x"} + ) + + mock_install.assert_called_once_with( + ["uv", "pip", "install", "-e", ".[all]"], + env={"VIRTUAL_ENV": "x"}, + scripts_dir=None, + ) + mock_verify.assert_called_once_with(["uv", "pip"], env={"VIRTUAL_ENV": "x"}) + + def test_quarantine_shims_include_declared_console_scripts( + self, temp_pyproject, fake_scripts_dir + ): + import hermes_cli.main as main_mod + + with patch("hermes_cli.main._is_windows", return_value=True): + names = {path.name for path in main_mod._hermes_exe_shims(fake_scripts_dir)} + + assert {"hermes.exe", "hermes-agent.exe", "hermes-acp.exe"} <= names + assert "hermes-gateway.exe" in names