diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ce6ca6f2bbf8..35880ace5035 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/tests/hermes_cli/test_ensure_acp_launcher.py b/tests/hermes_cli/test_ensure_acp_launcher.py new file mode 100644 index 000000000000..44aa36cf803c --- /dev/null +++ b/tests/hermes_cli/test_ensure_acp_launcher.py @@ -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) diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 4983465d2fd0..89c7afdcebe3 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -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: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md index 717381203e77..0a53a44be46c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md @@ -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 配置: