diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index d90066ca0e..5316111afa 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -1383,6 +1383,40 @@ def _packaged_desktop_launch_command(packaged_executable: Path) -> list[str]: return launch_command +def _site_packages_install_kind(project_root: Path) -> Optional[str]: + """The package manager owning a non-editable install at *project_root*, or None. + + A package-manager install (Homebrew, pip, distro packaging) places this + code in a ``site-packages``/``dist-packages`` tree. Such a tree ships no + ``apps/desktop`` source, so the build ladder below can never run — the + caller must not treat it like a broken checkout. A Homebrew formula lives + under a ``Cellar`` directory; any other site-packages owner is reported + generically as pip. + """ + parts = Path(project_root).parts + if "site-packages" in parts or "dist-packages" in parts: + return "homebrew" if "Cellar" in parts else "pip" + return None + + +def _launch_installed_macos_desktop_app() -> bool: + """Launch a separately installed ``/Applications/Hermes.app``, if present. + + Returns True only when the app bundle exists and a detached launch was + started — the caller then exits without touching the build ladder. + """ + if sys.platform != "darwin": + return False + executable = Path("/Applications/Hermes.app/Contents/MacOS/Hermes") + if not executable.is_file(): + return False + from hermes_cli.bundled_app import launch_detached + + pid = launch_detached([str(executable)], cwd=executable.parent) + print(f"→ Launched the installed Hermes Desktop app: {executable} (pid {pid})") + return True + + def cmd_gui(args: argparse.Namespace): """Build and launch the native Electron desktop GUI.""" from hermes_cli.main import PROJECT_ROOT @@ -1396,7 +1430,21 @@ def cmd_gui(args: argparse.Namespace): bundled = is_bundled_payload(PROJECT_ROOT) if not bundled and not (desktop_dir / "package.json").exists(): + # A package-manager install (Homebrew, pip, ...) ships no desktop + # source tree, so building here is impossible by construction (#61056). + # Prefer the separately installed desktop app; otherwise explain the + # packaging shape instead of the generic missing-source error. + install_kind = _site_packages_install_kind(PROJECT_ROOT) + if install_kind is not None and _launch_installed_macos_desktop_app(): + sys.exit(0) print(f"Desktop GUI source not found at: {desktop_dir}") + if install_kind == "homebrew": + print( + " This Hermes came from Homebrew, which does not ship the desktop app's\n" + " source tree, so it cannot be built from this install.\n" + " Install the desktop app from https://hermes-agent.nousresearch.com,\n" + " or run `hermes desktop` from a source checkout." + ) sys.exit(1) with contextlib.suppress(Exception): diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 776f23c4a9..2c3c2681e4 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -209,6 +209,68 @@ def test_packaged_renderer_bom_does_not_bypass_entry_validation(tmp_path): # Dependency admission and staging are exercised by test_desktop_source_build.py. +# --- package-manager (Homebrew/pip) installs: no desktop source tree ------- +# (#61056: `hermes desktop` under Homebrew looked for apps/desktop inside the +# Cellar site-packages, which the formula does not ship.) + + +def test_site_packages_install_kind_detects_brew_and_pip(): + brew_root = Path("/opt/homebrew/Cellar/hermes-agent/2026.7.7.2/libexec/site-packages") + pip_root = Path("/usr/lib/python3/dist-packages") + venv_root = Path("/home/u/proj/venv/lib/python3.12/site-packages") + assert main_desktop._site_packages_install_kind(brew_root) == "homebrew" + assert main_desktop._site_packages_install_kind(pip_root) == "pip" + assert main_desktop._site_packages_install_kind(venv_root) == "pip" + assert main_desktop._site_packages_install_kind(Path("/home/u/hermes-agent")) is None + + +def test_gui_brew_install_prints_brew_guidance_not_venv_hint(tmp_path, monkeypatch, capsys): + """A Homebrew install has no apps/desktop tree and can never build one — the + error must say so instead of implying a broken checkout.""" + root = tmp_path / "Cellar" / "hermes-agent" / "2026.7.7.2" / "libexec" / "site-packages" + root.mkdir(parents=True) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(main_desktop, "_launch_installed_macos_desktop_app", lambda: False) + + with pytest.raises(SystemExit) as exc: + main_desktop.cmd_gui(_ns()) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "Desktop GUI source not found" in out + assert "Homebrew" in out + + +def test_gui_brew_install_launches_installed_app_when_present(tmp_path, monkeypatch): + """Prefer the separately installed /Applications/Hermes.app over failing.""" + root = tmp_path / "Cellar" / "hermes-agent" / "2026.7.7.2" / "libexec" / "site-packages" + root.mkdir(parents=True) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + launched = [] + monkeypatch.setattr(main_desktop, "_launch_installed_macos_desktop_app", lambda: launched.append(1) or True) + + with pytest.raises(SystemExit) as exc: + main_desktop.cmd_gui(_ns()) + + assert exc.value.code == 0 + assert launched == [1] + + +@pytest.mark.parametrize("exists,platform", [(True, "darwin"), (False, "darwin"), (True, "linux")]) +def test_launch_installed_macos_desktop_app_gates_on_bundle_and_platform(tmp_path, monkeypatch, exists, platform): + monkeypatch.setattr(main_desktop.sys, "platform", platform) + exe = Path("/Applications/Hermes.app/Contents/MacOS/Hermes") + monkeypatch.setattr(main_desktop.Path, "is_file", lambda self: exists if self == exe else Path.is_file(self)) + calls = [] + if exists and platform == "darwin": + import hermes_cli.bundled_app as bundled_app + monkeypatch.setattr(bundled_app, "launch_detached", lambda argv, **kw: calls.append(argv) or 4321) + + assert main_desktop._launch_installed_macos_desktop_app() is (exists and platform == "darwin") + if exists and platform == "darwin": + assert calls == [[str(exe)]] + + # ── Content-hash stamp tests ──────────────────────────────────────────