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:
parent
22ff6ca32b
commit
95f2802f84
@ -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 <id> 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":
|
||||
|
||||
147
hermes_cli/relaunch.py
Normal file
147
hermes_cli/relaunch.py
Normal file
@ -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)
|
||||
@ -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):
|
||||
|
||||
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"])]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user