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 源码 ├── myagents/ # Python 源码
│ ├── cli.py # CLI entrypoint │ ├── cli.py # CLI entrypoint
│ ├── launcher.py # Agent 启动器 │ ├── launcher.py # Agent 启动器
│ ├── entrypoints.py # myclaude / mykimi 独立入口 │ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
│ ├── commands/ # 子命令 │ ├── commands/ # 子命令
│ └── ... │ └── ...
├── templates/ # 模板目录 ├── templates/ # 模板目录
+1 -1
View File
@@ -1,7 +1,7 @@
ROOT_DIR := $(shell pwd) ROOT_DIR := $(shell pwd)
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_BIN_DIR := $(HOME)/.local/bin USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi mycodex COMMANDS := myagents myclaude mykimi mycodex myhermes
.PHONY: help install uninstall _symlink-commands .PHONY: help install uninstall _symlink-commands
+11 -6
View File
@@ -1,11 +1,11 @@
# MyAgents # 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` - 🤖 **多 Agent 统一入口**`myagents claude` / `myagents kimi` / `myagents codex` / `myagents hermes`
- 🚀 **独立快捷命令**`myclaude``mykimi``mycodex` - 🚀 **独立快捷命令**`myclaude``mykimi``mycodex``myhermes`
- 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖 - 📂 **自动 Workspace 解析**:默认 `~/workspace`,可通过环境变量覆盖
- 📝 **Session 管理**`--list` 列出可恢复会话 - 📝 **Session 管理**`--list` 列出可恢复会话
- ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传 - ➡️ **原生参数透传**:任意底层 agent 支持的参数直接透传
@@ -16,7 +16,7 @@
myagents/ myagents/
├── myagents/ ├── myagents/
│ ├── cli.py # myagents 统一入口 │ ├── cli.py # myagents 统一入口
│ ├── entrypoints.py # myclaude / mykimi 独立入口 │ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
│ ├── launcher.py # Agent 启动逻辑 │ ├── launcher.py # Agent 启动逻辑
│ ├── project_root.py # 项目/工作区根目录解析 │ ├── project_root.py # 项目/工作区根目录解析
│ └── commands/ # update / upgrade 子命令 │ └── commands/ # update / upgrade 子命令
@@ -48,7 +48,7 @@ make install
`make install` 会: `make install` 会:
1. 同步/创建虚拟环境并做可编辑安装 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` 3. 自动安装 shell 补全到 `~/.local/share/zsh/site-functions``~/.local/share/bash-completion/completions`
请确保 `~/.local/bin``PATH` 中,并在 `~/.zshrc` 里加上: 请确保 `~/.local/bin``PATH` 中,并在 `~/.zshrc` 里加上:
@@ -84,6 +84,9 @@ myagents kimi -S <session-id> # 恢复 Kimi session
myagents codex # 启动 OpenAI Codex CLI myagents codex # 启动 OpenAI Codex CLI
myagents codex -r <session-id> # 恢复 Codex session(映射为 resume myagents codex -r <session-id> # 恢复 Codex session(映射为 resume
myagents codex resume <id> # 原生 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 update # 重装 myagents 并同步 workspace 链接
myagents upgrade # update 别名 myagents upgrade # update 别名
myagents completion # 安装/查看/卸载 shell 补全 myagents completion # 安装/查看/卸载 shell 补全
@@ -96,9 +99,11 @@ mykimi -S <session-id>
mycodex mycodex
mycodex -r <session-id> mycodex -r <session-id>
mycodex resume <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(): def _progs():
"""Return (prog_name, cli_factory) pairs that need shell completions.""" """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 [ return [
("myagents", lambda: cli), ("myagents", lambda: cli),
("myclaude", lambda: claude_cli), ("myclaude", lambda: claude_cli),
("mykimi", lambda: kimi_cli), ("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
] ]
@@ -34,7 +40,8 @@ def _package_version() -> str:
def cli(ctx: click.Context) -> None: def cli(ctx: click.Context) -> None:
"""Myagents: unified launcher for AI coding agents. """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: if ctx.invoked_subcommand is None:
click.echo(ctx.get_help()) 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("claude"), name="claude")
cli.add_command(build_cli("kimi"), name="kimi") cli.add_command(build_cli("kimi"), name="kimi")
cli.add_command(build_cli("codex"), name="codex") cli.add_command(build_cli("codex"), name="codex")
cli.add_command(build_cli("hermes"), name="hermes")
cli.add_command(update_cmd) cli.add_command(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade") cli.add_command(upgrade_cmd, name="upgrade")
cli.add_command(build_completion_group(_progs)) 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 from myagents.entrypoints import codex_cli
cli_obj = codex_cli cli_obj = codex_cli
elif prog_name == "myhermes":
from myagents.entrypoints import hermes_cli
cli_obj = hermes_cli
else: else:
raise click.ClickException(f"Unknown command: {prog_name}") raise click.ClickException(f"Unknown command: {prog_name}")
@@ -60,7 +64,7 @@ def build_completion_group(progs_fn: ProgListFn) -> click.Group:
@completion.command("install") @completion.command("install")
def install_cmd() -> None: 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()) installed = install_completions(progs_fn())
if not installed: if not installed:
raise click.ClickException( 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 from myagents.launcher import build_cli
claude_cli = build_cli("claude", prog_name="myclaude") claude_cli = build_cli("claude", prog_name="myclaude")
kimi_cli = build_cli("kimi", prog_name="mykimi") kimi_cli = build_cli("kimi", prog_name="mykimi")
codex_cli = build_cli("codex", prog_name="mycodex") codex_cli = build_cli("codex", prog_name="mycodex")
hermes_cli = build_cli("hermes", prog_name="myhermes")
def _progs(): def _progs():
@@ -15,6 +16,7 @@ def _progs():
("myclaude", lambda: claude_cli), ("myclaude", lambda: claude_cli),
("mykimi", lambda: kimi_cli), ("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
] ]
@@ -46,3 +48,13 @@ def codex_main() -> None:
ensure_completions_installed(_progs()) ensure_completions_installed(_progs())
codex_cli() 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 os
import re import re
import shutil import shutil
import sqlite3
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from importlib.metadata import PackageNotFoundError, version 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]." "[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>" 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: def _list_sessions(backend: str, chat_cwd: Path) -> None:
"""Print resumable sessions for chat_cwd, newest first.""" """Print resumable sessions for chat_cwd, newest first."""
if backend == "hermes":
_list_sessions_hermes(chat_cwd)
return
files = _session_files(backend, chat_cwd) files = _session_files(backend, chat_cwd)
if not files: if not files:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}") 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" myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main" mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main" mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0"]
+2 -2
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import os import os
import sys import sys
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex") _COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
def _remove_link(link: str, want: str) -> bool: def _remove_link(link: str, want: str) -> bool:
+135 -1
View File
@@ -1,6 +1,7 @@
"""Tests for myagents CLI.""" """Tests for myagents CLI."""
import json import json
import sqlite3
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -14,13 +15,14 @@ class TestMyagentsHelp:
"""Tests for top-level myagents command.""" """Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None: 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() runner = CliRunner()
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "claude" in result.output assert "claude" in result.output
assert "kimi" in result.output assert "kimi" in result.output
assert "codex" in result.output assert "codex" in result.output
assert "hermes" in result.output
assert "update" in result.output assert "update" in result.output
assert "upgrade" in result.output assert "upgrade" in result.output
@@ -400,3 +402,135 @@ class TestCodexListSessions:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "019aeb67-0620-71c1-8cbb-756bc8845c6e" in result.output assert "019aeb67-0620-71c1-8cbb-756bc8845c6e" in result.output
assert "hello codex" 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 pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from click.testing import CliRunner 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: class TestMyclaudeEntrypoint:
@@ -105,3 +105,37 @@ class TestMycodexEntrypoint:
result = runner.invoke(codex_cli, ["--cwd", str(tmp_path), "-r", "abc123"]) result = runner.invoke(codex_cli, ["--cwd", str(tmp_path), "-r", "abc123"])
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_run.call_args[0][0] == ["/usr/bin/codex", "--dangerously-bypass-approvals-and-sandbox", "resume", "abc123"] 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"]