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:
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user