diff --git a/AGENTS.md b/AGENTS.md index 4652da6..8443f49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ # 模板目录 diff --git a/Makefile b/Makefile index 0778b68..d6259e1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 693530c..ad8e5ef 100644 --- a/README.md +++ b/README.md @@ -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 # 恢复 Kimi session myagents codex # 启动 OpenAI Codex CLI myagents codex -r # 恢复 Codex session(映射为 resume) myagents codex resume # 原生 resume 子命令 +myagents hermes # 启动 Hermes Agent(默认 --yolo) +myagents hermes -l # 列出最近的 Hermes sessions(全局 SQLite 存储) +myagents hermes --resume # 恢复 Hermes session myagents update # 重装 myagents 并同步 workspace 链接 myagents upgrade # update 别名 myagents completion # 安装/查看/卸载 shell 补全 @@ -96,9 +99,11 @@ mykimi -S mycodex mycodex -r mycodex resume +myhermes +myhermes --resume ``` -各后端原生参数会原样透传。例如 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 ` / `--continue` 透传;其 sessions 存在全局 SQLite(`~/.hermes/state.db`),不区分工作目录,`-l` 列出最近 20 条。 ## 配置 diff --git a/myagents/cli.py b/myagents/cli.py index edc6553..b6fb813 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -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)) diff --git a/myagents/commands/completion.py b/myagents/commands/completion.py index 02ca9d3..7e100e3 100644 --- a/myagents/commands/completion.py +++ b/myagents/commands/completion.py @@ -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( diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index f636d4c..3f40fb5 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -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() diff --git a/myagents/launcher.py b/myagents/launcher.py index 624dd2a..6421558 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -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 " +_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 [/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}") diff --git a/pyproject.toml b/pyproject.toml index 23a6744..66cba9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/rm_user_local_myagents.py b/scripts/rm_user_local_myagents.py index 78096bb..4ba6ae2 100644 --- a/scripts/rm_user_local_myagents.py +++ b/scripts/rm_user_local_myagents.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index fe3d51e..60e3608 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 5f30de8..2e314cc 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -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"]