diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b337ae1676..b4900517e5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -114,6 +114,12 @@ def _apply_profile_override() -> None: consume = 1 break + # 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it. + # This lets child processes (relaunch, subprocess) inherit the parent's + # profile choice without having to pass --profile again. + if profile_name is None and os.environ.get("HERMES_HOME"): + return + # 2. If no flag, check active_profile in the hermes root if profile_name is None: try: @@ -9807,15 +9813,8 @@ Examples: # Launch hermes --resume by replacing the current process print(f"Resuming session: {selected_id}") - hermes_bin = shutil.which("hermes") - if hermes_bin: - os.execvp(hermes_bin, ["hermes", "--resume", selected_id]) - else: - # Fallback: re-invoke via python -m - os.execvp( - sys.executable, - [sys.executable, "-m", "hermes_cli.main", "--resume", selected_id], - ) + from hermes_cli.relaunch import relaunch + relaunch(["--resume", selected_id]) return # won't reach here after execvp elif action == "stats": diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py new file mode 100644 index 0000000000..66f6d79718 --- /dev/null +++ b/hermes_cli/relaunch.py @@ -0,0 +1,147 @@ +""" +Unified self-relaunch for Hermes CLI. + +Preserves critical flags (--tui, --dev, --profile, --model, etc.) across +process replacement so that ``hermes sessions browse`` or post-setup relaunch +doesn't silently drop the user's UI mode or other preferences. + +Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``). +""" + +import os +import shutil +import sys +from typing import Optional, Sequence + + +# (option_string, takes_value) — flags whose presence (and value, where +# applicable) on the original argv must survive a self-relaunch. +_CRITICAL_FLAGS: list[tuple[str, bool]] = [ + ("--tui", False), + ("--dev", False), + ("--profile", True), + ("-p", True), + ("--model", True), + ("-m", True), + ("--provider", True), + ("--yolo", False), + ("--ignore-user-config", False), + ("--ignore-rules", False), + ("--pass-session-id", False), + ("--accept-hooks", False), + ("--worktree", False), + ("-w", False), + ("--skills", True), + ("-s", True), + ("--quiet", False), + ("-Q", False), + ("--verbose", False), + ("-v", False), + ("--source", True), +] + + +def _extract_critical_flags(argv: Sequence[str]) -> list[str]: + """Pull out flags that affect session behaviour / UI mode.""" + flags: list[str] = [] + i = 0 + while i < len(argv): + arg = argv[i] + if "=" in arg: + key = arg.split("=", 1)[0] + for flag, _ in _CRITICAL_FLAGS: + if key == flag: + flags.append(arg) + break + i += 1 + continue + + for flag, takes_value in _CRITICAL_FLAGS: + if arg == flag: + flags.append(arg) + if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"): + flags.append(argv[i + 1]) + i += 1 + break + i += 1 + return flags + + +def resolve_hermes_bin() -> Optional[str]: + """Find the hermes entry point. + + Priority: + 1. ``sys.argv[0]`` if it resolves to a real executable. + 2. ``shutil.which("hermes")`` on PATH. + 3. ``None`` → caller should fall back to ``python -m hermes_cli.main``. + """ + argv0 = sys.argv[0] + + # Absolute path to an executable (covers nix store, venv wrappers, etc.) + if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK): + return argv0 + + # Relative path — resolve against CWD + if not argv0.startswith("-") and os.path.isfile(argv0): + abs_path = os.path.abspath(argv0) + if os.access(abs_path, os.X_OK): + return abs_path + + # PATH lookup + path_bin = shutil.which("hermes") + if path_bin: + return path_bin + + return None + + +def build_relaunch_argv( + extra_args: Sequence[str], + *, + preserve_critical: bool = True, + original_argv: Optional[Sequence[str]] = None, +) -> list[str]: + """Construct an argv list for replacing the current process with hermes. + + Args: + extra_args: Arguments to append (e.g. ``["--resume", id]``). + preserve_critical: Whether to carry over UI / behaviour flags. + original_argv: The original argv to scan for flags (defaults to + ``sys.argv[1:]``). + """ + bin_path = resolve_hermes_bin() + + if bin_path: + argv = [bin_path] + else: + argv = [sys.executable, "-m", "hermes_cli.main"] + + src = list(original_argv) if original_argv is not None else list(sys.argv[1:]) + + if preserve_critical: + argv.extend(_extract_critical_flags(src)) + + argv.extend(extra_args) + return argv + + +def relaunch( + extra_args: Sequence[str], + *, + preserve_critical: bool = True, + original_argv: Optional[Sequence[str]] = None, +) -> None: + """Replace the current process with a fresh hermes invocation.""" + new_argv = build_relaunch_argv( + extra_args, preserve_critical=preserve_critical, original_argv=original_argv + ) + os.execvp(new_argv[0], new_argv) + + +def relaunch_chat( + *, + preserve_critical: bool = True, + original_argv: Optional[Sequence[str]] = None, +) -> None: + """Convenience wrapper: relaunch into ``hermes chat``.""" + relaunch(["chat"], preserve_critical=preserve_critical, original_argv=original_argv) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 07cc92f4ab..aea8960e0e 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -3249,33 +3249,14 @@ def run_setup_wizard(args): _offer_launch_chat() -def _resolve_hermes_chat_argv() -> Optional[list[str]]: - """Resolve argv for launching ``hermes chat`` in a fresh process.""" - hermes_bin = shutil.which("hermes") - if hermes_bin: - return [hermes_bin, "chat"] - - try: - if importlib.util.find_spec("hermes_cli") is not None: - return [sys.executable, "-m", "hermes_cli.main", "chat"] - except Exception: - pass - - return None - - def _offer_launch_chat(): """Prompt the user to jump straight into chat after setup.""" print() if not prompt_yes_no("Launch hermes chat now?", True): return - chat_argv = _resolve_hermes_chat_argv() - if not chat_argv: - print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.") - return - - os.execvp(chat_argv[0], chat_argv) + from hermes_cli.relaunch import relaunch_chat + relaunch_chat() def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): diff --git a/tests/hermes_cli/test_relaunch.py b/tests/hermes_cli/test_relaunch.py new file mode 100644 index 0000000000..0052c7269c --- /dev/null +++ b/tests/hermes_cli/test_relaunch.py @@ -0,0 +1,140 @@ +"""Tests for hermes_cli.relaunch — unified self-relaunch utility.""" + +import sys + +import pytest + +from hermes_cli import relaunch as relaunch_mod + + +class TestResolveHermesBin: + def test_prefers_absolute_argv0_when_executable(self, monkeypatch): + fake = "/nix/store/abc/bin/hermes" + monkeypatch.setattr(sys, "argv", [fake]) + monkeypatch.setattr(relaunch_mod.os.path, "isfile", lambda p: p == fake) + monkeypatch.setattr(relaunch_mod.os, "access", lambda p, mode: p == fake) + assert relaunch_mod.resolve_hermes_bin() == fake + + def test_resolves_relative_argv0(self, monkeypatch, tmp_path): + fake = tmp_path / "hermes" + fake.write_text("#!/bin/sh\n") + fake.chmod(0o755) + monkeypatch.setattr(sys, "argv", [str(fake.name)]) + monkeypatch.chdir(tmp_path) + # Ensure we don't accidentally match a real 'hermes' on PATH + monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None) + assert relaunch_mod.resolve_hermes_bin() == str(fake) + + def test_falls_back_to_path_which(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["-c"]) # not a real path + monkeypatch.setattr( + relaunch_mod.shutil, "which", lambda name: "/usr/bin/hermes" if name == "hermes" else None + ) + assert relaunch_mod.resolve_hermes_bin() == "/usr/bin/hermes" + + def test_returns_none_when_unresolvable(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["-c"]) + monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None) + assert relaunch_mod.resolve_hermes_bin() is None + + +class TestExtractCriticalFlags: + def test_extracts_tui_and_dev(self): + argv = ["--tui", "--dev", "chat"] + assert relaunch_mod._extract_critical_flags(argv) == ["--tui", "--dev"] + + def test_extracts_profile_with_value(self): + argv = ["--profile", "work", "chat"] + assert relaunch_mod._extract_critical_flags(argv) == ["--profile", "work"] + + def test_extracts_short_p_with_value(self): + argv = ["-p", "work"] + assert relaunch_mod._extract_critical_flags(argv) == ["-p", "work"] + + def test_extracts_equals_form(self): + argv = ["--profile=work", "--model=anthropic/claude-sonnet-4"] + assert relaunch_mod._extract_critical_flags(argv) == [ + "--profile=work", + "--model=anthropic/claude-sonnet-4", + ] + + def test_skips_unknown_flags(self): + argv = ["--foo", "bar", "--tui"] + assert relaunch_mod._extract_critical_flags(argv) == ["--tui"] + + def test_does_not_consume_flag_like_value(self): + argv = ["--tui", "--resume", "abc123"] + assert relaunch_mod._extract_critical_flags(argv) == ["--tui"] + + def test_preserves_multiple_skills(self): + argv = ["-s", "foo", "-s", "bar", "--tui"] + assert relaunch_mod._extract_critical_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"] + + +class TestBuildRelaunchArgv: + def test_uses_bin_when_available(self, monkeypatch): + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") + argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"]) + assert argv[0] == "/usr/bin/hermes" + + def test_falls_back_to_python_module(self, monkeypatch): + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None) + argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"]) + assert argv == [sys.executable, "-m", "hermes_cli.main", "--resume", "abc"] + + def test_preserves_critical_flags(self, monkeypatch): + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") + original = ["--tui", "--dev", "--profile", "work", "sessions", "browse"] + argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"], original_argv=original) + assert "--tui" in argv + assert "--dev" in argv + assert "--profile" in argv + assert "work" in argv + assert "--resume" in argv + assert "abc" in argv + # The original subcommand should not survive + assert "sessions" not in argv + assert "browse" not in argv + + def test_can_disable_preserve(self, monkeypatch): + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") + original = ["--tui", "chat"] + argv = relaunch_mod.build_relaunch_argv( + ["--resume", "abc"], preserve_critical=False, original_argv=original + ) + assert "--tui" not in argv + assert argv == ["/usr/bin/hermes", "--resume", "abc"] + + +class TestRelaunch: + def test_calls_execvp(self, monkeypatch): + calls = [] + + def fake_execvp(path, argv): + calls.append((path, argv)) + raise SystemExit(0) + + monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") + + with pytest.raises(SystemExit): + relaunch_mod.relaunch(["--resume", "abc"]) + + assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])] + + +class TestRelaunchChat: + def test_appends_chat(self, monkeypatch): + calls = [] + + def fake_execvp(path, argv): + calls.append((path, argv)) + raise SystemExit(0) + + monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") + + with pytest.raises(SystemExit): + relaunch_mod.relaunch_chat() + + assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "chat"])] diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index c934305bf7..6ea2c80ab2 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -565,28 +565,12 @@ def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeyp assert defaults[" Vercel team ID"] == "linked-team" -def test_resolve_hermes_chat_argv_prefers_which(monkeypatch): - from hermes_cli import setup as setup_mod - - monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None) - - assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"] - - -def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch): - from hermes_cli import setup as setup_mod - - monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None) - monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None) - - assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"] - - -def test_offer_launch_chat_execs_fresh_process(monkeypatch): +def test_offer_launch_chat_relaunches_via_bin(monkeypatch): from hermes_cli import setup as setup_mod + from hermes_cli import relaunch as relaunch_mod monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) - monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"]) + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes") exec_calls = [] @@ -594,7 +578,7 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch): exec_calls.append((path, argv)) raise SystemExit(0) - monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp) + monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) with pytest.raises(SystemExit): setup_mod._offer_launch_chat() @@ -602,13 +586,22 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch): assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])] -def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys): +def test_offer_launch_chat_falls_back_to_module(monkeypatch): from hermes_cli import setup as setup_mod + from hermes_cli import relaunch as relaunch_mod monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True) - monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None) + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None) - setup_mod._offer_launch_chat() + exec_calls = [] - captured = capsys.readouterr() - assert "Run 'hermes chat' manually" in captured.out + def fake_execvp(path, argv): + exec_calls.append((path, argv)) + raise SystemExit(0) + + monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) + + with pytest.raises(SystemExit): + setup_mod._offer_launch_chat() + + assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]