diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 40573b62bd..10c655b787 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -244,7 +244,7 @@ try: mode=( "gui" if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "") - == "dashboard" + in {"dashboard", "gui"} else "cli" ) ) @@ -6366,7 +6366,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if r2.returncode != 0: stderr_preview = (r2.stderr or "").strip() stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" - dist_dir = web_dir.parent / "hermes_cli" / "web_dist" + project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent + dist_dir = project_root / "hermes_cli" / "web_dist" dist_index = dist_dir / "index.html" # If a stale dist exists, serve it as a fallback instead of failing. @@ -6390,6 +6391,123 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: return True +def _desktop_dist_exists(desktop_dir: Path) -> bool: + """Return True when a local desktop renderer build is present.""" + return (desktop_dir / "dist" / "index.html").exists() + + +def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: + """Return the current platform's unpacked Electron app executable.""" + release_dir = desktop_dir / "release" + if sys.platform == "darwin": + candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes")) + elif sys.platform == "win32": + candidates = [ + release_dir / "win-unpacked" / "Hermes.exe", + release_dir / "win-ia32-unpacked" / "Hermes.exe", + release_dir / "win-arm64-unpacked" / "Hermes.exe", + ] + else: + candidates = [ + release_dir / "linux-unpacked" / "hermes", + release_dir / "linux-unpacked" / "Hermes", + ] + + existing = [p for p in candidates if p.exists()] + if not existing: + return None + return max(existing, key=lambda p: p.stat().st_mtime) + + +def cmd_gui(args): + """Build and launch the native Electron desktop GUI.""" + desktop_dir = PROJECT_ROOT / "apps" / "desktop" + if not (desktop_dir / "package.json").exists(): + print(f"Desktop GUI source not found at: {desktop_dir}") + sys.exit(1) + + try: + from hermes_logging import setup_logging as _setup_logging_gui + _setup_logging_gui(mode="gui") + except Exception: + pass + + env = os.environ.copy() + if getattr(args, "fake_boot", False): + env["HERMES_DESKTOP_BOOT_FAKE"] = "1" + if getattr(args, "ignore_existing", False): + env["HERMES_DESKTOP_IGNORE_EXISTING"] = "1" + if getattr(args, "hermes_root", None): + env["HERMES_DESKTOP_HERMES_ROOT"] = str(Path(args.hermes_root).expanduser().resolve()) + if getattr(args, "cwd", None): + env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve()) + + source_mode = getattr(args, "source", False) + skip_build = getattr(args, "skip_build", False) + packaged_executable = _desktop_packaged_executable(desktop_dir) + + if source_mode or not skip_build: + npm = shutil.which("npm") + if not npm: + print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.") + print("Install Node.js, then run: hermes gui") + sys.exit(1) + else: + npm = None + + if getattr(args, "skip_build", False): + if source_mode: + if not _desktop_dist_exists(desktop_dir): + print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}") + print(" Pre-build first: cd apps/desktop && npm run build") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists(): + print("✗ --skip-build --source requires existing workspace dependencies.") + print(f" Install first: cd {PROJECT_ROOT} && npm ci") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + print(f"→ Skipping desktop source build (--skip-build --source); using dist at {desktop_dir / 'dist'}") + elif packaged_executable is None: + print(f"✗ --skip-build was passed but no packaged desktop app was found at: {desktop_dir / 'release'}") + print(" Pre-build first: cd apps/desktop && npm run pack") + print(" Or drop --skip-build to package automatically.") + sys.exit(1) + else: + print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}") + else: + print("→ Installing desktop workspace dependencies...") + install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False) + if install_result.returncode != 0: + print("✗ Desktop dependency install failed") + print(f" Run manually: cd {PROJECT_ROOT} && npm ci") + sys.exit(install_result.returncode or 1) + + build_label = "source build" if source_mode else "packaged app" + print(f"→ Building desktop {build_label}...") + build_script = "build" if source_mode else "pack" + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) + if build_result.returncode != 0: + print("✗ Desktop GUI build failed") + print(f" Run manually: cd apps/desktop && npm run {build_script}") + sys.exit(build_result.returncode or 1) + packaged_executable = _desktop_packaged_executable(desktop_dir) + + if source_mode: + print("→ Launching Hermes Desktop from source build...") + launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False) + sys.exit(launch_result.returncode) + + if packaged_executable is None: + print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}") + print(" Expected an unpacked Electron app for the current OS.") + sys.exit(1) + + print(f"→ Launching packaged Hermes Desktop: {packaged_executable}") + launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False) + sys.exit(launch_result.returncode) + + def _find_stale_dashboard_pids() -> list[int]: """Return PIDs of ``hermes dashboard`` processes other than ourselves. @@ -9621,6 +9739,7 @@ def _coalesce_session_name_args(argv: list) -> list: "uninstall", "profile", "dashboard", + "gui", "honcho", "claw", "plugins", @@ -10474,7 +10593,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "computer-use", "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", - "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", + "gui", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "model", "pairing", "plugins", "postinstall", "profile", "proxy", "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", @@ -13254,6 +13373,48 @@ Examples: ) dashboard_parser.set_defaults(func=cmd_dashboard) + # ========================================================================= + # gui command + # ========================================================================= + gui_parser = subparsers.add_parser( + "gui", + help="Build and launch the native desktop GUI", + description=( + "Launch the Hermes Electron desktop app. By default this installs " + "workspace Node dependencies, builds the current OS's unpacked " + "Electron app, then launches that packaged artifact." + ), + ) + gui_parser.add_argument( + "--skip-build", + action="store_true", + help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release", + ) + gui_parser.add_argument( + "--source", + action="store_true", + help="Launch via `electron .` against apps/desktop/dist instead of the packaged app", + ) + gui_parser.add_argument( + "--fake-boot", + action="store_true", + help="Enable deterministic desktop boot delays for validating startup UI", + ) + gui_parser.add_argument( + "--ignore-existing", + action="store_true", + help="Force Desktop to ignore any hermes CLI already on PATH during backend resolution", + ) + gui_parser.add_argument( + "--hermes-root", + help="Override the Hermes source root used by Desktop (sets HERMES_DESKTOP_HERMES_ROOT)", + ) + gui_parser.add_argument( + "--cwd", + help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)", + ) + gui_parser.set_defaults(func=cmd_gui) + # ========================================================================= # logs command # ========================================================================= diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py new file mode 100644 index 0000000000..963e4563b2 --- /dev/null +++ b/tests/hermes_cli/test_gui_command.py @@ -0,0 +1,179 @@ +"""Tests for ``hermes gui`` desktop launcher wiring.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import main as cli_main + + +def _ns(**kw): + defaults = dict( + skip_build=False, + source=False, + fake_boot=False, + ignore_existing=False, + hermes_root=None, + cwd=None, + ) + defaults.update(kw) + return argparse.Namespace(**defaults) + + +def _make_desktop_tree(tmp_path: Path) -> Path: + root = tmp_path / "hermes-agent" + desktop_dir = root / "apps" / "desktop" + desktop_dir.mkdir(parents=True) + (desktop_dir / "package.json").write_text("{}", encoding="utf-8") + return root + + +def _make_packaged_executable(root: Path, monkeypatch, platform: str = "darwin") -> Path: + monkeypatch.setattr(cli_main.sys, "platform", platform) + desktop_dir = root / "apps" / "desktop" + if platform == "darwin": + exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes" + elif platform == "win32": + exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" + else: + exe = desktop_dir / "release" / "linux-unpacked" / "hermes" + exe.parent.mkdir(parents=True) + exe.write_text("", encoding="utf-8") + return exe + + +def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 0 + mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False) + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] + assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir + assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)] + assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir + + +def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + hermes_root = tmp_path / "custom-hermes" + cwd = tmp_path / "project" + hermes_root.mkdir() + cwd.mkdir() + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + + ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \ + pytest.raises(SystemExit): + cli_main.cmd_gui(_ns( + fake_boot=True, + ignore_existing=True, + hermes_root=str(hermes_root), + cwd=str(cwd), + )) + + launch_env = mock_run.call_args_list[1].kwargs["env"] + assert launch_env["HERMES_DESKTOP_BOOT_FAKE"] == "1" + assert launch_env["HERMES_DESKTOP_IGNORE_EXISTING"] == "1" + assert launch_env["HERMES_DESKTOP_HERMES_ROOT"] == str(hermes_root) + assert launch_env["HERMES_DESKTOP_CWD"] == str(cwd) + + +def test_gui_exits_when_npm_missing(tmp_path, monkeypatch, capsys): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + with patch("hermes_cli.main.shutil.which", return_value=None), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + assert "npm was not found" in capsys.readouterr().out + + +def test_gui_skip_build_requires_existing_packaged_app(tmp_path, monkeypatch, capsys): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(cli_main.sys, "platform", "darwin") + + with pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 1 + assert "no packaged desktop app" in capsys.readouterr().out + + +def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) + + with patch("hermes_cli.main.shutil.which", return_value=None), \ + patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \ + patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 0 + mock_install.assert_not_called() + mock_run.assert_called_once() + assert mock_run.call_args.args[0] == [str(packaged_exe)] + + +def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0) + launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(source=True)) + + assert exc.value.code == 0 + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"] + assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir + assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir + + +@pytest.mark.parametrize( + "argv", + [ + ["hermes", "gui"], + ["hermes", "-m", "gpt5", "gui"], + ], +) +def test_gui_is_known_builtin_for_plugin_gating(argv): + with patch.object(sys, "argv", argv): + assert cli_main._plugin_cli_discovery_needed() is False