diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 40573b62bd..1575b949f1 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,72 @@ 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 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 + + 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) + + 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()) + + if getattr(args, "skip_build", False): + if not _desktop_dist_exists(desktop_dir): + print(f"✗ --skip-build 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 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 build (--skip-build); using dist at {desktop_dir / 'dist'}") + 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) + + print("→ Building desktop GUI...") + build_result = subprocess.run([npm, "run", "build"], cwd=desktop_dir, env=env, check=False) + if build_result.returncode != 0: + print("✗ Desktop GUI build failed") + print(" Run manually: cd apps/desktop && npm run build") + sys.exit(build_result.returncode or 1) + + print("→ Launching Hermes Desktop...") + launch_result = subprocess.run([npm, "exec", "--", "electron", "."], 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 +9688,7 @@ def _coalesce_session_name_args(argv: list) -> list: "uninstall", "profile", "dashboard", + "gui", "honcho", "claw", "plugins", @@ -10474,7 +10542,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 +13322,42 @@ 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 apps/desktop inline, then starts Electron." + ), + ) + gui_parser.add_argument( + "--skip-build", + action="store_true", + help="Skip npm install/build and launch the existing apps/desktop/dist build", + ) + 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..842f2468f5 --- /dev/null +++ b/tests/hermes_cli/test_gui_command.py @@ -0,0 +1,144 @@ +"""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, + 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 test_gui_installs_builds_and_launches_desktop(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) as mock_install, \ + 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()) + + 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", "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 + + +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) + + 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_dist(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="/usr/bin/npm"), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 1 + assert "no desktop dist found" in capsys.readouterr().out + + +def test_gui_skip_build_launches_existing_dist_without_install_or_build(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + (desktop_dir / "dist").mkdir() + (desktop_dir / "dist" / "index.html").write_text("
", encoding="utf-8") + electron_pkg = root / "node_modules" / "electron" + electron_pkg.mkdir(parents=True) + (electron_pkg / "package.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + 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") 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] == ["/usr/bin/npm", "exec", "--", "electron", "."] + + +@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