mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(update): self-heal the hermes-acp launcher + document Buzz Desktop as an ACP host
Existing installs predate the install.sh hermes-acp launcher, and hermes update never re-runs setup_path, so ACP hosts (Zed, JetBrains, Buzz) still resolve Hermes as unavailable until a reinstall. _ensure_acp_launcher() writes the launcher next to an existing hermes command in ~/.local/bin or /usr/local/bin during hermes update — delegating to the sibling launcher so it is correct for every install layout. Never follows symlinks (#21454), skips unwritable dirs, no-op on Windows (venv Scripts is already on PATH). Docs: add a Buzz Desktop section to the ACP page (en + zh-Hans).
This commit is contained in:
parent
a7d5147cf1
commit
38cd253abd
4 changed files with 211 additions and 0 deletions
|
|
@ -10986,6 +10986,54 @@ def _ensure_fhs_path_guard() -> None:
|
|||
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
|
||||
|
||||
|
||||
def _ensure_acp_launcher() -> None:
|
||||
"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one.
|
||||
|
||||
Mirrors the launcher block in ``scripts/install.sh`` so existing installs
|
||||
gain the ACP command on ``hermes update`` without a reinstall. ACP hosts
|
||||
(Zed, JetBrains, Buzz Desktop) spawn the agent by resolving the
|
||||
``hermes-acp`` command name against the login-shell PATH; the console
|
||||
script of that name lives inside the install's venv, which is not on that
|
||||
PATH, so those hosts report Hermes as not installed even when it is.
|
||||
|
||||
The shim simply delegates to the sibling ``hermes`` launcher with the
|
||||
``acp`` subcommand, which makes it correct for every install layout
|
||||
(venv wrapper, FHS symlink, pipx/pip console script) without having to
|
||||
reconstruct interpreter/entrypoint paths.
|
||||
|
||||
No-op on Windows (install.ps1 puts ``venv\\Scripts`` on the user PATH, so
|
||||
``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is
|
||||
already present next to the ``hermes`` command. Unwritable directories
|
||||
(e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return
|
||||
for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")):
|
||||
hermes_cmd = bin_dir / "hermes"
|
||||
acp_cmd = bin_dir / "hermes-acp"
|
||||
try:
|
||||
if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()):
|
||||
continue
|
||||
# Already present — a console script (pip/pipx install), an
|
||||
# earlier shim, or a symlink. is_symlink() catches broken
|
||||
# symlinks that exists() would miss; never follow-and-overwrite
|
||||
# (the #21454 failure mode).
|
||||
if acp_cmd.exists() or acp_cmd.is_symlink():
|
||||
continue
|
||||
shim = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"# Hermes Agent — ACP launcher (written by `hermes update`).\n"
|
||||
"# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n"
|
||||
"# command name on the login-shell PATH.\n"
|
||||
f'exec "{hermes_cmd}" acp "$@"\n'
|
||||
)
|
||||
acp_cmd.write_text(shim, encoding="utf-8")
|
||||
acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755)
|
||||
except OSError:
|
||||
continue
|
||||
print(f" ✓ Installed hermes-acp launcher → {acp_cmd}")
|
||||
|
||||
|
||||
def _size_delta_label(saved_mb: float) -> str:
|
||||
"""Human label for a before/after database size delta, in MB.
|
||||
|
||||
|
|
@ -12916,6 +12964,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
except Exception as e:
|
||||
logger.debug("FHS PATH guard check failed: %s", e)
|
||||
|
||||
# Self-heal the hermes-acp launcher for installs that predate it, so
|
||||
# ACP hosts (Zed, JetBrains, Buzz) can resolve Hermes on PATH without
|
||||
# a reinstall. No-op on Windows and when already present.
|
||||
try:
|
||||
_ensure_acp_launcher()
|
||||
except Exception as e:
|
||||
logger.debug("hermes-acp launcher self-heal failed: %s", e)
|
||||
|
||||
# Refresh the cua-driver binary used by the Computer Use toolset.
|
||||
# The upstream installer is gated on supported platforms and on the
|
||||
# binary already being on PATH, so this is a no-op for users who
|
||||
|
|
|
|||
121
tests/hermes_cli/test_ensure_acp_launcher.py
Normal file
121
tests/hermes_cli/test_ensure_acp_launcher.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""`hermes update` must self-heal the ``hermes-acp`` launcher.
|
||||
|
||||
ACP hosts (Zed, JetBrains, Buzz Desktop) resolve the agent by the
|
||||
``hermes-acp`` command name on the login-shell PATH. Fresh installs get the
|
||||
launcher from ``scripts/install.sh``; existing installs get it from
|
||||
``_ensure_acp_launcher()`` during ``hermes update``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _ensure_acp_launcher
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
bin_dir = tmp_path / ".local" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
return bin_dir
|
||||
|
||||
|
||||
def test_writes_launcher_next_to_hermes(fake_home):
|
||||
hermes = fake_home / "hermes"
|
||||
hermes.write_text("#!/usr/bin/env bash\nexec true\n", encoding="utf-8")
|
||||
hermes.chmod(0o755)
|
||||
|
||||
_ensure_acp_launcher()
|
||||
|
||||
acp = fake_home / "hermes-acp"
|
||||
assert acp.is_file()
|
||||
assert acp.stat().st_mode & stat.S_IXUSR
|
||||
text = acp.read_text(encoding="utf-8")
|
||||
# Delegates to the sibling `hermes` launcher with the acp subcommand.
|
||||
assert f'exec "{hermes}" acp "$@"' in text
|
||||
|
||||
|
||||
def test_noop_without_hermes_launcher(fake_home):
|
||||
_ensure_acp_launcher()
|
||||
assert not (fake_home / "hermes-acp").exists()
|
||||
|
||||
|
||||
def test_does_not_overwrite_existing_command(fake_home):
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
existing = fake_home / "hermes-acp"
|
||||
marker = "#!/usr/bin/env python\n# real console script\n"
|
||||
existing.write_text(marker, encoding="utf-8")
|
||||
|
||||
_ensure_acp_launcher()
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == marker
|
||||
|
||||
|
||||
def test_does_not_follow_symlink_into_venv(fake_home, tmp_path):
|
||||
"""#21454 failure mode: never write through a symlinked hermes-acp."""
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
console_script = tmp_path / "venv" / "bin" / "hermes-acp"
|
||||
console_script.parent.mkdir(parents=True)
|
||||
marker = "#!/usr/bin/env python\n# real console script\n"
|
||||
console_script.write_text(marker, encoding="utf-8")
|
||||
(fake_home / "hermes-acp").symlink_to(console_script)
|
||||
|
||||
_ensure_acp_launcher()
|
||||
|
||||
assert console_script.read_text(encoding="utf-8") == marker
|
||||
assert (fake_home / "hermes-acp").is_symlink()
|
||||
|
||||
|
||||
def test_skips_broken_symlink(fake_home, tmp_path):
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
dangling = fake_home / "hermes-acp"
|
||||
dangling.symlink_to(tmp_path / "gone")
|
||||
|
||||
_ensure_acp_launcher()
|
||||
|
||||
assert dangling.is_symlink()
|
||||
assert not dangling.exists()
|
||||
|
||||
|
||||
def test_symlinked_hermes_counts_as_present(fake_home, tmp_path):
|
||||
"""FHS-style installs symlink ~/.local/bin/hermes — still eligible."""
|
||||
real = tmp_path / "real-hermes"
|
||||
real.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
(fake_home / "hermes").symlink_to(real)
|
||||
|
||||
_ensure_acp_launcher()
|
||||
|
||||
assert (fake_home / "hermes-acp").is_file()
|
||||
|
||||
|
||||
def test_idempotent(fake_home):
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
_ensure_acp_launcher()
|
||||
first = (fake_home / "hermes-acp").read_text(encoding="utf-8")
|
||||
_ensure_acp_launcher()
|
||||
assert (fake_home / "hermes-acp").read_text(encoding="utf-8") == first
|
||||
|
||||
|
||||
def test_noop_on_windows(fake_home):
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
with patch("hermes_cli.main.sys") as fake_sys:
|
||||
fake_sys.platform = "win32"
|
||||
_ensure_acp_launcher()
|
||||
assert not (fake_home / "hermes-acp").exists()
|
||||
|
||||
|
||||
def test_unwritable_bin_dir_is_skipped(fake_home):
|
||||
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("root ignores directory write permissions")
|
||||
fake_home.chmod(0o555)
|
||||
try:
|
||||
_ensure_acp_launcher() # must not raise
|
||||
assert not (fake_home / "hermes-acp").exists()
|
||||
finally:
|
||||
fake_home.chmod(0o755)
|
||||
|
|
@ -145,6 +145,24 @@ Prerequisites:
|
|||
|
||||
Use an ACP-compatible plugin and point it at `hermes acp` or `hermes-acp`.
|
||||
|
||||
### Buzz Desktop
|
||||
|
||||
[Buzz](https://github.com/block/buzz) ships Hermes Agent as a preset runtime.
|
||||
With Hermes installed the normal way, Buzz discovers it automatically —
|
||||
open **Settings → Runtimes** and Hermes appears under your runtimes.
|
||||
|
||||
If discovery fails (older installs), make sure the ACP launcher resolves on a
|
||||
login-shell PATH:
|
||||
|
||||
```bash
|
||||
command -v hermes-acp || command -v hermes
|
||||
```
|
||||
|
||||
Recent installs write both `hermes` and `hermes-acp` launchers into
|
||||
`~/.local/bin`; running `hermes update` adds the `hermes-acp` launcher to
|
||||
older installs. As a manual fallback, configure Buzz's agent command as
|
||||
`hermes` with args `["acp"]`.
|
||||
|
||||
## Configuration and credentials
|
||||
|
||||
ACP mode uses the same Hermes configuration as the CLI:
|
||||
|
|
|
|||
|
|
@ -143,6 +143,22 @@ hermes acp --setup-browser --yes # 非交互式接受下载
|
|||
|
||||
使用兼容 ACP 的插件并将其指向 `hermes acp` 或 `hermes-acp`。
|
||||
|
||||
### Buzz Desktop
|
||||
|
||||
[Buzz](https://github.com/block/buzz) 将 Hermes Agent 作为预设运行时提供。
|
||||
按常规方式安装 Hermes 后,Buzz 会自动发现它 —— 打开 **Settings → Runtimes**,
|
||||
Hermes 就会出现在你的运行时列表中。
|
||||
|
||||
如果发现失败(较旧的安装),请确认 ACP 启动器可以在登录 shell 的 PATH 上解析:
|
||||
|
||||
```bash
|
||||
command -v hermes-acp || command -v hermes
|
||||
```
|
||||
|
||||
较新的安装会将 `hermes` 和 `hermes-acp` 两个启动器写入 `~/.local/bin`;
|
||||
运行 `hermes update` 会为较旧的安装补上 `hermes-acp` 启动器。作为手动兜底方案,
|
||||
可以将 Buzz 的 agent 命令配置为 `hermes`,参数为 `["acp"]`。
|
||||
|
||||
## 配置与凭据
|
||||
|
||||
ACP 模式使用与 CLI 相同的 Hermes 配置:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue