feat(cli): preserve --tui and other flags across internal relaunches
Extract all os.execvp('hermes', ...) calls into a utility so flags like
--tui, --dev, --profile, --model, --provider, et al. survive session
resume and post-setup relaunch.
- resolve_hermes_bin: prefers sys.argv[0] when callable, then PATH,
then falls back to '${sys.executable} -m hermes_cli.main' (fixes nix
run relaunches)
- build_relaunch_argv: allowlists critical flags so they carry over
- cmd_sessions browse now calls relaunch(['--resume', <id>])
- _apply_profile_override skips redundant work when HERMES_HOME is
already set (child inherits parent profile)
- setup.py replaces _resolve_hermes_chat_argv with relaunch_chat()
- added comprehensive tests for flag extraction and binary resolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
140
tests/hermes_cli/test_relaunch.py
Normal file
140
tests/hermes_cli/test_relaunch.py
Normal file
@@ -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"])]
|
||||
@@ -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"])]
|
||||
|
||||
Reference in New Issue
Block a user