Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-15 10:21:00 -05:00
35 changed files with 2388 additions and 122 deletions

View File

@@ -0,0 +1,275 @@
"""Tests for the Command Installation check in hermes doctor."""
import os
import sys
import types
from argparse import Namespace
from pathlib import Path
import pytest
import hermes_cli.doctor as doctor_mod
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"):
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
# Create a fake venv entry point
venv_bin_dir = project / venv_name / "bin"
venv_bin_dir.mkdir(parents=True, exist_ok=True)
hermes_bin = venv_bin_dir / "hermes"
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n")
hermes_bin.chmod(0o755)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
# Stub model_tools so doctor doesn't fail on import
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
# Stub auth checks
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
# Stub httpx.get to avoid network calls
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
return home, project, hermes_bin
def _run_doctor(fix=False):
"""Run doctor and capture stdout."""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=fix))
return buf.getvalue()
class TestDoctorCommandInstallation:
"""Tests for the ◆ Command Installation section."""
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_correct_symlink_shows_ok(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create the command link dir with correct symlink
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.symlink_to(hermes_bin)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point exists" in out
assert "correct target" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_missing_symlink_shows_fail(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Don't create the symlink — it should be missing
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point exists" in out
assert "not found" in out
assert "hermes doctor --fix" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_fix_creates_missing_symlink(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=True)
assert "Command Installation" in out
assert "Created symlink" in out
# Verify the symlink was actually created
cmd_link = tmp_path / ".local" / "bin" / "hermes"
assert cmd_link.is_symlink()
assert cmd_link.resolve() == hermes_bin.resolve()
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_wrong_target_symlink_shows_warn(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create a symlink pointing to the wrong target
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
wrong_target = tmp_path / "wrong_hermes"
wrong_target.write_text("#!/usr/bin/env python\n")
cmd_link.symlink_to(wrong_target)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "wrong target" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create a symlink pointing to wrong target
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
wrong_target = tmp_path / "wrong_hermes"
wrong_target.write_text("#!/usr/bin/env python\n")
cmd_link.symlink_to(wrong_target)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=True)
assert "Fixed symlink" in out
# Verify the symlink now points to the correct target
assert cmd_link.is_symlink()
assert cmd_link.resolve() == hermes_bin.resolve()
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
# Do NOT create any venv entry point
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point not found" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path):
"""The check finds entry points in .venv/ as well as venv/."""
home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv")
# Create the command link with correct symlink
hermes_bin = project / ".venv" / "bin" / "hermes"
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.symlink_to(hermes_bin)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Venv entry point exists" in out
assert ".venv/bin/hermes" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_non_symlink_regular_file_shows_ok(self, monkeypatch, tmp_path):
"""If ~/.local/bin/hermes is a regular file (not symlink), accept it."""
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.write_text("#!/bin/sh\nexec python -m hermes_cli.main \"$@\"\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "non-symlink" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
"""On Termux, the command link dir is $PREFIX/bin."""
prefix_dir = tmp_path / "termux_prefix"
prefix_bin = prefix_dir / "bin"
prefix_bin.mkdir(parents=True)
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", str(prefix_dir))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "$PREFIX/bin" in out
def test_windows_skips_check(self, monkeypatch, tmp_path):
"""On Windows, the Command Installation section is skipped."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(sys, "platform", "win32")
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
out = _run_doctor(fix=False)
assert "Command Installation" not in out

View File

@@ -0,0 +1,148 @@
"""Tests for the defensive subparser routing workaround (bpo-9338).
The main() function in hermes_cli/main.py sets subparsers.required=True
when argv contains a known subcommand name. This forces deterministic
routing on Python versions where argparse fails to match subcommand tokens
when the parent parser has nargs='?' optional arguments (--continue).
If the subcommand token is consumed as a flag value (e.g. `hermes -c model`
to resume a session named 'model'), the required=True parse raises
SystemExit and the code falls back to the default required=False behaviour.
"""
import argparse
import io
import sys
import pytest
def _build_parser():
"""Build a minimal replica of the hermes top-level parser."""
parser = argparse.ArgumentParser(prog="hermes")
parser.add_argument("--version", "-V", action="store_true")
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
parser.add_argument(
"--continue", "-c",
dest="continue_last",
nargs="?",
const=True,
default=None,
metavar="SESSION_NAME",
)
parser.add_argument("--worktree", "-w", action="store_true", default=False)
parser.add_argument("--skills", "-s", action="append", default=None)
parser.add_argument("--yolo", action="store_true", default=False)
parser.add_argument("--pass-session-id", action="store_true", default=False)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
chat_p = subparsers.add_parser("chat")
chat_p.add_argument("-q", "--query", default=None)
subparsers.add_parser("model")
subparsers.add_parser("gateway")
subparsers.add_parser("setup")
return parser, subparsers
def _safe_parse(parser, subparsers, argv):
"""Replica of the defensive parsing logic from main()."""
known_cmds = set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set()
has_cmd_token = any(t in known_cmds for t in argv if not t.startswith("-"))
if has_cmd_token:
subparsers.required = True
saved_stderr = sys.stderr
try:
sys.stderr = io.StringIO()
args = parser.parse_args(argv)
sys.stderr = saved_stderr
return args
except SystemExit:
sys.stderr = saved_stderr
subparsers.required = False
return parser.parse_args(argv)
else:
subparsers.required = False
return parser.parse_args(argv)
class TestSubparserRoutingFallback:
"""Verify the bpo-9338 defensive routing works for all key cases."""
def test_direct_subcommand(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["model"])
assert args.command == "model"
def test_subcommand_with_flags(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["--yolo", "model"])
assert args.command == "model"
assert args.yolo is True
def test_bare_hermes_defaults_to_none(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, [])
assert args.command is None
def test_flags_only_defaults_to_none(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["--yolo"])
assert args.command is None
assert args.yolo is True
def test_continue_flag_alone(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-c"])
assert args.command is None
assert args.continue_last is True
def test_continue_with_session_name(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-c", "myproject"])
assert args.command is None
assert args.continue_last == "myproject"
def test_continue_with_subcommand_name_as_session(self):
"""Edge case: session named 'model' — should be treated as session name, not subcommand."""
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-c", "model"])
assert args.command is None
assert args.continue_last == "model"
def test_continue_with_session_then_subcommand(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-c", "myproject", "model"])
assert args.command == "model"
assert args.continue_last == "myproject"
def test_chat_with_query(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["chat", "-q", "hello"])
assert args.command == "chat"
assert args.query == "hello"
def test_resume_flag(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-r", "abc123"])
assert args.command is None
assert args.resume == "abc123"
def test_resume_with_subcommand(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-r", "abc123", "chat"])
assert args.command == "chat"
assert args.resume == "abc123"
def test_skills_flag_with_subcommand(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["-s", "myskill", "chat"])
assert args.command == "chat"
assert args.skills == ["myskill"]
def test_all_flags_with_subcommand(self):
parser, sub = _build_parser()
args = _safe_parse(parser, sub, ["--yolo", "-w", "-s", "myskill", "model"])
assert args.command == "model"
assert args.yolo is True
assert args.worktree is True
assert args.skills == ["myskill"]

View File

@@ -8,6 +8,7 @@ from hermes_cli.tools_config import (
_platform_toolset_summary,
_save_platform_tools,
_toolset_has_keys,
CONFIGURABLE_TOOLSETS,
TOOL_CATEGORIES,
_visible_providers,
tools_command,
@@ -22,6 +23,15 @@ def test_get_platform_tools_uses_default_when_platform_not_configured():
assert enabled
def test_configurable_toolsets_include_messaging():
assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
def test_get_platform_tools_default_telegram_includes_messaging():
enabled = _get_platform_tools({}, "telegram")
assert "messaging" in enabled
def test_get_platform_tools_preserves_explicit_empty_selection():
config = {"platform_toolsets": {"cli": []}}