feat: add hermes backend support

- Add myhermes entrypoint and myagents hermes subcommand
- Hermes sessions read from global SQLite store (~/.hermes/state.db)
- Default args: --yolo
- Update README, AGENTS.md, Makefile, pyproject.toml
- Add tests for hermes subcommand, passthrough, and session listing
This commit is contained in:
Zhengshou Lai
2026-07-15 10:21:38 +08:00
parent 2169a0a303
commit 78ca74152f
11 changed files with 284 additions and 17 deletions
+135 -1
View File
@@ -1,6 +1,7 @@
"""Tests for myagents CLI."""
import json
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -14,13 +15,14 @@ class TestMyagentsHelp:
"""Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None:
"""--help should list claude, kimi and codex subcommands."""
"""--help should list claude, kimi, codex and hermes subcommands."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "claude" in result.output
assert "kimi" in result.output
assert "codex" in result.output
assert "hermes" in result.output
assert "update" in result.output
assert "upgrade" in result.output
@@ -400,3 +402,135 @@ class TestCodexListSessions:
assert result.exit_code == 0, result.output
assert "019aeb67-0620-71c1-8cbb-756bc8845c6e" in result.output
assert "hello codex" in result.output
class TestHermesSubcommand:
"""Tests for ``myagents hermes``."""
def test_help_shows_options(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["hermes", "--help"])
assert result.exit_code == 0
assert "--cwd" in result.output
assert "--list" in result.output
def test_runs_hermes(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["hermes"])
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[0][0] == ["/usr/bin/hermes", "--yolo"]
def test_missing_binary_error(self) -> None:
runner = CliRunner()
with patch("myagents.launcher.shutil.which", return_value=None):
result = runner.invoke(cli, ["hermes"])
assert result.exit_code == 127
assert "not found" in result.output.lower()
def test_cwd_option_passed(self, tmp_path: Path) -> None:
runner = CliRunner()
test_dir = tmp_path / "test_cwd"
test_dir.mkdir()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["hermes", "--cwd", str(test_dir)])
assert result.exit_code == 0
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
class TestHermesPassthrough:
"""Unknown leading flags forward to hermes instead of erroring."""
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
cli, ["hermes", "--cwd", str(tmp_path), *args]
)
assert result.exit_code == 0, result.output
mock_run.assert_called_once()
return mock_run.call_args[0][0]
def test_resume_with_session_id(self, tmp_path: Path) -> None:
cmd = self._invoke(["--resume", "abc123"], tmp_path)
assert cmd == ["/usr/bin/hermes", "--yolo", "--resume", "abc123"]
def test_continue_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--continue"], tmp_path)
assert cmd[-1] == "--continue"
class TestHermesListSessions:
"""``myagents hermes --list`` reads the global SQLite session store."""
def _patch_state_db(self, db_path: Path):
return patch(
"myagents.launcher._BACKENDS",
{
"hermes": {
**myagents.launcher._BACKENDS["hermes"],
"state_db": lambda: db_path,
}
},
)
def test_list_missing_database(self, tmp_path: Path) -> None:
runner = CliRunner()
with self._patch_state_db(tmp_path / "missing.db"):
result = runner.invoke(
cli, ["hermes", "--cwd", str(tmp_path), "--list"]
)
assert result.exit_code == 0
assert "no sessions" in result.output.lower()
def test_list_shows_session_id_and_snippet(self, tmp_path: Path) -> None:
db_path = tmp_path / "state.db"
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL,"
" started_at REAL NOT NULL, title TEXT)"
)
conn.execute(
"CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT,"
" timestamp REAL NOT NULL)"
)
conn.execute(
"INSERT INTO sessions (id, source, started_at)"
" VALUES ('sess-abc123', 'cli', 1767220800)"
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp)"
" VALUES ('sess-abc123', 'user', 'hello hermes', 1767220801)"
)
conn.commit()
conn.close()
runner = CliRunner()
with self._patch_state_db(db_path):
result = runner.invoke(
cli, ["hermes", "--cwd", str(tmp_path), "--list"]
)
assert result.exit_code == 0, result.output
assert "sess-abc123" in result.output
assert "hello hermes" in result.output
+36 -2
View File
@@ -1,11 +1,11 @@
"""Tests for standalone myclaude / mykimi / mycodex entrypoints."""
"""Tests for standalone myclaude / mykimi / mycodex / myhermes entrypoints."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from myagents.entrypoints import claude_cli, codex_cli, kimi_cli
from myagents.entrypoints import claude_cli, codex_cli, hermes_cli, kimi_cli
class TestMyclaudeEntrypoint:
@@ -105,3 +105,37 @@ class TestMycodexEntrypoint:
result = runner.invoke(codex_cli, ["--cwd", str(tmp_path), "-r", "abc123"])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == ["/usr/bin/codex", "--dangerously-bypass-approvals-and-sandbox", "resume", "abc123"]
class TestMyhermesEntrypoint:
"""``myhermes`` standalone entrypoint."""
def test_runs_hermes(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/hermes"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(hermes_cli, [])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == ["/usr/bin/hermes", "--yolo"]
def test_version_shows_myhermes(self) -> None:
runner = CliRunner()
result = runner.invoke(hermes_cli, ["--version"])
assert result.exit_code == 0
assert "myhermes" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/hermes"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
hermes_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
)
assert result.exit_code == 0
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]