diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index fb6068a81..0f792592f 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -238,6 +238,52 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} +_RELEASE_URL_BASE = "https://github.com/NousResearch/hermes-agent/releases/tag" +_latest_release_cache: Optional[tuple] = None # (tag, url) once resolved + + +def get_latest_release_tag(repo_dir: Optional[Path] = None) -> Optional[tuple]: + """Return ``(tag, release_url)`` for the latest git tag, or None. + + Local-only — runs ``git describe --tags --abbrev=0`` against the + Hermes checkout. Cached per-process. Release URL always points at the + canonical NousResearch/hermes-agent repo (forks don't get a link). + """ + global _latest_release_cache + if _latest_release_cache is not None: + return _latest_release_cache or None + + repo_dir = repo_dir or _resolve_repo_dir() + if repo_dir is None: + _latest_release_cache = () # falsy sentinel — skip future lookups + return None + + try: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + timeout=3, + cwd=str(repo_dir), + ) + except Exception: + _latest_release_cache = () + return None + + if result.returncode != 0: + _latest_release_cache = () + return None + + tag = (result.stdout or "").strip() + if not tag: + _latest_release_cache = () + return None + + url = f"{_RELEASE_URL_BASE}/{tag}" + _latest_release_cache = (tag, url) + return _latest_release_cache + + def format_banner_version_label() -> str: """Return the version label shown in the startup banner title.""" base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})" @@ -519,9 +565,16 @@ def build_welcome_banner(console: Console, model: str, cwd: str, agent_name = _skin_branding("agent_name", "Hermes Agent") title_color = _skin_color("banner_title", "#FFD700") border_color = _skin_color("banner_border", "#CD7F32") + version_label = format_banner_version_label() + release_info = get_latest_release_tag() + if release_info: + _tag, _url = release_info + title_markup = f"[bold {title_color}][link={_url}]{version_label}[/link][/]" + else: + title_markup = f"[bold {title_color}]{version_label}[/]" outer_panel = Panel( layout_table, - title=f"[bold {title_color}]{format_banner_version_label()}[/]", + title=title_markup, border_style=border_color, padding=(0, 2), ) diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py index 4ea089fd0..9945c78c4 100644 --- a/tests/hermes_cli/test_banner.py +++ b/tests/hermes_cli/test_banner.py @@ -68,3 +68,68 @@ def test_build_welcome_banner_uses_normalized_toolset_names(): assert "homeassistant_tools:" not in output assert "honcho_tools:" not in output assert "web_tools:" not in output + + +def test_build_welcome_banner_title_is_hyperlinked_to_release(): + """Panel title (version label) is wrapped in an OSC-8 hyperlink to the GitHub release.""" + import io + from unittest.mock import patch as _patch + import hermes_cli.banner as _banner + import model_tools as _mt + import tools.mcp_tool as _mcp + + _banner._latest_release_cache = None + tag_url = ("v2026.4.23", "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.23") + + buf = io.StringIO() + with ( + _patch.object(_mt, "check_tool_availability", return_value=(["web"], [])), + _patch.object(_banner, "get_available_skills", return_value={}), + _patch.object(_banner, "get_update_result", return_value=None), + _patch.object(_mcp, "get_mcp_status", return_value=[]), + _patch.object(_banner, "get_latest_release_tag", return_value=tag_url), + ): + console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160) + _banner.build_welcome_banner( + console=console, model="x", cwd="/tmp", + session_id="abc123", + tools=[{"function": {"name": "read_file"}}], + get_toolset_for_tool=lambda n: "file", + ) + + raw = buf.getvalue() + # The existing version label must still be present in the title + assert "Hermes Agent v" in raw, "Version label missing from title" + # OSC-8 hyperlink escape sequence present with the release URL + assert "\x1b]8;" in raw, "OSC-8 hyperlink not emitted" + assert "releases/tag/v2026.4.23" in raw, "Release URL missing from banner output" + + +def test_build_welcome_banner_title_falls_back_when_no_tag(): + """Without a resolvable tag, the panel title renders as plain text (no hyperlink escape).""" + import io + from unittest.mock import patch as _patch + import hermes_cli.banner as _banner + import model_tools as _mt + import tools.mcp_tool as _mcp + + _banner._latest_release_cache = None + buf = io.StringIO() + with ( + _patch.object(_mt, "check_tool_availability", return_value=(["web"], [])), + _patch.object(_banner, "get_available_skills", return_value={}), + _patch.object(_banner, "get_update_result", return_value=None), + _patch.object(_mcp, "get_mcp_status", return_value=[]), + _patch.object(_banner, "get_latest_release_tag", return_value=None), + ): + console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160) + _banner.build_welcome_banner( + console=console, model="x", cwd="/tmp", + session_id="abc123", + tools=[{"function": {"name": "read_file"}}], + get_toolset_for_tool=lambda n: "file", + ) + + raw = buf.getvalue() + assert "Hermes Agent v" in raw, "Version label missing from title" + assert "\x1b]8;" not in raw, "OSC-8 hyperlink should not be emitted without a tag"