Merge pull request #30165 from NousResearch/bb/gui-inline-build
feat(desktop): add hermes gui launcher
This commit is contained in:
+164
-3
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user