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
+1 -1
View File
@@ -45,7 +45,7 @@ myagents/
├── myagents/ # Python 源码
│ ├── cli.py # CLI entrypoint
│ ├── launcher.py # Agent 启动器
│ ├── entrypoints.py # myclaude / mykimi 独立入口
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
│ ├── commands/ # 子命令
│ └── ...
├── templates/ # 模板目录
+1 -1
View File
@@ -1,7 +1,7 @@
ROOT_DIR := $(shell pwd)
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi mycodex
COMMANDS := myagents myclaude mykimi mycodex myhermes
.PHONY: help install uninstall _symlink-commands
+11 -6
View File
@@ -1,11 +1,11 @@
# MyAgents
一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI、OpenAI Codex 等工具。
一个统一的 AI coding agent 启动器,封装 Claude Code、Kimi Code CLI、OpenAI Codex、Hermes Agent 等工具。
## 功能
- 🤖 **多 Agent 统一入口**`myagents claude` / `myagents kimi` / `myagents codex`
- 🚀 **独立快捷命令**`myclaude``mykimi``mycodex`
- 🤖 **多 Agent 统一入口**`myagents claude` / `myagents kimi` / `myagents codex` / `myagents hermes`
- 🚀 **独立快捷命令**`myclaude``mykimi``mycodex``myhermes`
- 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖
- 📝 **Session 管理**`--list` 列出可恢复会话
- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传
@@ -16,7 +16,7 @@
myagents/
├── myagents/
│ ├── cli.py # myagents 统一入口
│ ├── entrypoints.py # myclaude / mykimi 独立入口
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
│ ├── launcher.py # Agent 启动逻辑
│ ├── project_root.py # 项目/工作区根目录解析
│ └── commands/ # update / upgrade 子命令
@@ -48,7 +48,7 @@ make install
`make install` 会:
1. 同步/创建虚拟环境并做可编辑安装
2.`~/.local/bin/` 创建 `myagents``myclaude``mykimi``mycodex` 符号链接
2.`~/.local/bin/` 创建 `myagents``myclaude``mykimi``mycodex``myhermes` 符号链接
3. 自动安装 shell 补全到 `~/.local/share/zsh/site-functions``~/.local/share/bash-completion/completions`
请确保 `~/.local/bin``PATH` 中,并在 `~/.zshrc` 里加上:
@@ -84,6 +84,9 @@ myagents kimi -S <session-id> # 恢复 Kimi session
myagents codex # 启动 OpenAI Codex CLI
myagents codex -r <session-id> # 恢复 Codex session(映射为 resume
myagents codex resume <id> # 原生 resume 子命令
myagents hermes # 启动 Hermes Agent(默认 --yolo
myagents hermes -l # 列出最近的 Hermes sessions(全局 SQLite 存储)
myagents hermes --resume <id> # 恢复 Hermes session
myagents update # 重装 myagents 并同步 workspace 链接
myagents upgrade # update 别名
myagents completion # 安装/查看/卸载 shell 补全
@@ -96,9 +99,11 @@ mykimi -S <session-id>
mycodex
mycodex -r <session-id>
mycodex resume <session-id>
myhermes
myhermes --resume <session-id>
```
各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session``-c`continue)、`-y`yolo)等,Codex 支持 `resume``continue``exec` 等子命令;`mycodex` 还会将 `-r` / `--resume` 映射为 `codex resume`,与 `myclaude` 体验保持一致。
各后端原生参数会原样透传。例如 Kimi npm 版支持 `--session``-c`continue)、`-y`yolo)等,Codex 支持 `resume``continue``exec` 等子命令;`mycodex` 还会将 `-r` / `--resume` 映射为 `codex resume`,与 `myclaude` 体验保持一致。Hermes 默认带 `--yolo` 启动,支持 `--resume <id>` / `--continue` 透传;其 sessions 存在全局 SQLite`~/.hermes/state.db`),不区分工作目录,`-l` 列出最近 20 条。
## 配置
+10 -2
View File
@@ -11,13 +11,19 @@ from myagents.launcher import build_cli
def _progs():
"""Return (prog_name, cli_factory) pairs that need shell completions."""
from myagents.entrypoints import claude_cli, codex_cli, kimi_cli
from myagents.entrypoints import (
claude_cli,
codex_cli,
hermes_cli,
kimi_cli,
)
return [
("myagents", lambda: cli),
("myclaude", lambda: claude_cli),
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
]
@@ -34,7 +40,8 @@ def _package_version() -> str:
def cli(ctx: click.Context) -> None:
"""Myagents: unified launcher for AI coding agents.
Use ``myagents claude``, ``myagents kimi`` or ``myagents codex`` to start an agent in workspace/.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex`` or
``myagents hermes`` to start an agent in workspace/.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@@ -44,6 +51,7 @@ def cli(ctx: click.Context) -> None:
cli.add_command(build_cli("claude"), name="claude")
cli.add_command(build_cli("kimi"), name="kimi")
cli.add_command(build_cli("codex"), name="codex")
cli.add_command(build_cli("hermes"), name="hermes")
cli.add_command(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade")
cli.add_command(build_completion_group(_progs))
+5 -1
View File
@@ -39,6 +39,10 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import codex_cli
cli_obj = codex_cli
elif prog_name == "myhermes":
from myagents.entrypoints import hermes_cli
cli_obj = hermes_cli
else:
raise click.ClickException(f"Unknown command: {prog_name}")
@@ -60,7 +64,7 @@ def build_completion_group(progs_fn: ProgListFn) -> click.Group:
@completion.command("install")
def install_cmd() -> None:
"""Write completion scripts for myagents, myclaude, mykimi and mycodex."""
"""Write completion scripts for all myagents entrypoints."""
installed = install_completions(progs_fn())
if not installed:
raise click.ClickException(
+13 -1
View File
@@ -1,10 +1,11 @@
"""Standalone entrypoints for myclaude, mykimi, and mycodex command names."""
"""Standalone entrypoints for myclaude, mykimi, mycodex, and myhermes."""
from myagents.launcher import build_cli
claude_cli = build_cli("claude", prog_name="myclaude")
kimi_cli = build_cli("kimi", prog_name="mykimi")
codex_cli = build_cli("codex", prog_name="mycodex")
hermes_cli = build_cli("hermes", prog_name="myhermes")
def _progs():
@@ -15,6 +16,7 @@ def _progs():
("myclaude", lambda: claude_cli),
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
]
@@ -46,3 +48,13 @@ def codex_main() -> None:
ensure_completions_installed(_progs())
codex_cli()
def hermes_main() -> None:
"""Run ``myhermes``."""
from myagents.commands.completion_install import (
ensure_completions_installed,
)
ensure_completions_installed(_progs())
hermes_cli()
+69
View File
@@ -5,6 +5,7 @@ import json
import os
import re
import shutil
import sqlite3
import subprocess
from datetime import datetime
from importlib.metadata import PackageNotFoundError, version
@@ -53,6 +54,18 @@ _BACKENDS: dict[str, dict] = {
"[cyan]npm install -g @openai/codex[/cyan] or set [cyan]CODEX_BIN[/cyan]."
),
},
"hermes": {
"binary": "hermes",
"env_bin": "HERMES_BIN",
# Hermes keeps sessions in a global SQLite store, not per-cwd files.
"state_db": lambda: Path.home() / ".hermes" / "state.db",
"default_args": ["--yolo"],
"not_found_msg": (
"[red]hermes CLI not found in PATH.[/red] Install via the official "
"Hermes app or [cyan]hermes-agent.nousresearch.com/install.sh[/cyan], "
"or set [cyan]HERMES_BIN[/cyan]."
),
},
}
@@ -273,8 +286,64 @@ def _resume_syntax(backend: str) -> str:
return "--resume, -r <id>"
_HERMES_SESSIONS_SQL = """
SELECT s.id, s.started_at, s.title,
(SELECT m.content FROM messages m
WHERE m.session_id = s.id AND m.role = 'user'
ORDER BY m.timestamp ASC LIMIT 1) AS first_message
FROM sessions s
ORDER BY s.started_at DESC
LIMIT 20
"""
def _list_sessions_hermes(chat_cwd: Path) -> None:
"""List recent hermes sessions from the global SQLite store.
Hermes sessions are not tied to a cwd, so the newest sessions are shown
regardless of chat_cwd. Snippet is the session title, falling back to the
first user message.
"""
db_path = _BACKENDS["hermes"]["state_db"]()
rows: list[tuple] = []
if db_path.is_file():
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
rows = conn.execute(_HERMES_SESSIONS_SQL).fetchall()
finally:
conn.close()
except sqlite3.Error as exc:
stderr_console.print(
f"[red]Failed to read hermes session store:[/red] {exc}"
)
return
if not rows:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}")
return
console.print(
f"[bold]Sessions in[/bold] {chat_cwd} "
"[dim](hermes sessions are global)[/dim]"
)
for session_id, started_at, title, first_message in rows:
mtime = datetime.fromtimestamp(started_at)
snippet = " ".join(str(title or first_message or "").split())[:80]
console.print(
f" [cyan]{session_id}[/cyan] "
f"[dim]{mtime:%Y-%m-%d %H:%M}[/dim] {snippet or '[dim](empty)[/dim]'}"
)
console.print(
"\n[dim]Resume with[/dim] [green]hermes --resume <id>[/green]"
)
def _list_sessions(backend: str, chat_cwd: Path) -> None:
"""Print resumable sessions for chat_cwd, newest first."""
if backend == "hermes":
_list_sessions_hermes(chat_cwd)
return
files = _session_files(backend, chat_cwd)
if not files:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}")
+1
View File
@@ -13,6 +13,7 @@ myagents = "myagents.cli:cli"
myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main"
[dependency-groups]
dev = ["pytest>=8.0"]
+2 -2
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Remove ~/.local/bin/{myagents,myclaude,mykimi,mycodex} symlinks to this repo's venv."""
"""Remove ~/.local/bin/{myagents,myclaude,mykimi,mycodex,myhermes} symlinks to this repo's venv."""
from __future__ import annotations
import os
import sys
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex")
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
def _remove_link(link: str, want: str) -> bool:
+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"]