diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py index a33b5a93938..7b2556fd062 100644 --- a/acp_adapter/auth.py +++ b/acp_adapter/auth.py @@ -1,8 +1,11 @@ -"""ACP auth helpers — detect the currently configured Hermes provider.""" +"""ACP auth helpers — detect and advertise Hermes authentication methods.""" from __future__ import annotations -from typing import Optional +from typing import Any, Optional + + +TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup" def detect_provider() -> Optional[str]: @@ -22,3 +25,44 @@ def detect_provider() -> Optional[str]: def has_provider() -> bool: """Return True if Hermes can resolve any runtime provider credentials.""" return detect_provider() is not None + + +def build_auth_methods() -> list[Any]: + """Return registry-compatible ACP auth methods for Hermes. + + The official ACP registry validates that agents advertise at least one + usable auth method during the initial handshake. A fresh Zed install may + not have Hermes provider credentials configured yet, so Hermes always + advertises a terminal setup method. When credentials are already present, + it also advertises the resolved provider as the default agent-managed + runtime credential method. + """ + from acp.schema import AuthMethodAgent, TerminalAuthMethod + + methods: list[Any] = [] + provider = detect_provider() + if provider: + methods.append( + AuthMethodAgent( + id=provider, + name=f"{provider} runtime credentials", + description=( + "Authenticate Hermes using the currently configured " + f"{provider} runtime credentials." + ), + ) + ) + + methods.append( + TerminalAuthMethod( + id=TERMINAL_SETUP_AUTH_METHOD_ID, + name="Configure Hermes provider", + description=( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + type="terminal", + args=["--setup"], + ) + ) + return methods diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index cc7f835f7e0..48e677a6522 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -24,6 +24,7 @@ except ModuleNotFoundError: # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. pass +import argparse import asyncio import logging import sys @@ -107,8 +108,62 @@ def _load_env() -> None: ) -def main() -> None: +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="hermes-acp", + description="Run Hermes Agent as an ACP stdio server.", + ) + parser.add_argument("--version", action="store_true", help="Print Hermes version and exit") + parser.add_argument( + "--check", + action="store_true", + help="Verify ACP dependencies and adapter imports, then exit", + ) + parser.add_argument( + "--setup", + action="store_true", + help="Run interactive Hermes provider/model setup for ACP terminal auth", + ) + return parser.parse_args(argv) + + +def _print_version() -> None: + from hermes_cli import __version__ as hermes_version + + print(hermes_version) + + +def _run_check() -> None: + import acp # noqa: F401 + from acp_adapter.server import HermesACPAgent # noqa: F401 + + print("Hermes ACP check OK") + + +def _run_setup() -> None: + from hermes_cli.main import main as hermes_main + + old_argv = sys.argv[:] + try: + sys.argv = [old_argv[0] if old_argv else "hermes", "model"] + hermes_main() + finally: + sys.argv = old_argv + + +def main(argv: list[str] | None = None) -> None: """Entry point: load env, configure logging, run the ACP agent.""" + args = _parse_args(argv) + if args.version: + _print_version() + return + if args.check: + _run_check() + return + if args.setup: + _run_setup() + return + _setup_logging() _load_env() diff --git a/acp_adapter/server.py b/acp_adapter/server.py index c61bb80e471..20c4d7cdb4f 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -57,13 +57,7 @@ from acp.schema import ( UserMessageChunk, ) -# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0 -try: - from acp.schema import AuthMethodAgent -except ImportError: - from acp.schema import AuthMethod as AuthMethodAgent # type: ignore[attr-defined] - -from acp_adapter.auth import detect_provider +from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, detect_provider from acp_adapter.events import ( make_message_cb, make_step_cb, @@ -744,16 +738,7 @@ class HermesACPAgent(acp.Agent): resolved_protocol_version = ( protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION ) - provider = detect_provider() - auth_methods = None - if provider: - auth_methods = [ - AuthMethodAgent( - id=provider, - name=f"{provider} runtime credentials", - description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.", - ) - ] + auth_methods = build_auth_methods() client_name = client_info.name if client_info else "unknown" logger.info( @@ -784,10 +769,18 @@ class HermesACPAgent(acp.Agent): # server has provider credentials configured — harmless under # Hermes' threat model (ACP is stdio-only, local-trust), but poor # API hygiene and confusing if ACP ever grows multi-method auth. - provider = detect_provider() - if not provider: + if not isinstance(method_id, str): return None - if not isinstance(method_id, str) or method_id.strip().lower() != provider: + normalized_method = method_id.strip().lower() + provider = detect_provider() + + if normalized_method == TERMINAL_SETUP_AUTH_METHOD_ID: + # Terminal auth launches Hermes setup/model selection out-of-band. + # Only report success once that flow has produced usable runtime + # credentials for the normal ACP session. + return AuthenticateResponse() if provider else None + + if not provider or normalized_method != provider: return None return AuthenticateResponse() diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 492a84445d4..f6d9d7a574e 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,12 +1,15 @@ { - "schema_version": 1, - "name": "hermes-agent", - "display_name": "Hermes Agent", - "description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support", - "icon": "icon.svg", + "id": "hermes-agent", + "name": "Hermes Agent", + "version": "0.13.0", + "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", + "repository": "https://github.com/NousResearch/hermes-agent", + "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", + "authors": ["Nous Research"], + "license": "MIT", "distribution": { - "type": "command", - "command": "hermes", - "args": ["acp"] + "npx": { + "package": "@nousresearch/hermes-agent-acp@0.13.0" + } } } diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg index fc08ec05190..f42c0daea45 100644 --- a/acp_registry/icon.svg +++ b/acp_registry/icon.svg @@ -1,25 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/docs/plans/acp-registry-zed-integration.md b/docs/plans/acp-registry-zed-integration.md new file mode 100644 index 00000000000..05358f7afed --- /dev/null +++ b/docs/plans/acp-registry-zed-integration.md @@ -0,0 +1,97 @@ +# Hermes Agent ACP Registry + Zed Integration Implementation Plan + +> For Hermes: Use subagent-driven-development skill to implement this plan task-by-task. + +Goal: Make Hermes Agent installable from Zed's official ACP Registry, so users can add Hermes from Zed's agent panel without manual custom `agent_servers` settings. + +Architecture: Use the official `agentclientprotocol/registry` flow instead of the deprecated Zed Agent Server Extension path. Ship a registry-compatible launcher distribution, advertise valid ACP auth methods during every handshake, validate against official registry schema and auth CI, then submit a registry PR for `hermes-agent`. + +Tech Stack: Hermes Agent Python package, ACP adapter (`hermes acp` / `hermes-acp`), npm launcher package, official ACP Registry JSON schema, Zed external agent UI. + +--- + +## Compliance constraints + +- Zed v0.221.x+ prefers the ACP Registry for external agents; do not use Zed Agent Server Extensions for distribution. +- Registry repo layout is top-level `hermes-agent/agent.json` and `hermes-agent/icon.svg`, not `agents/hermes-agent/`. +- Registry metadata must use the official schema: `id`, `name`, `version`, `description`, `distribution`, optional `repository`, `website`, `authors`, `license`. +- Distribution must be exactly one supported type unless intentionally adding another: `binary`, `npx`, or `uvx`. +- Hermes must advertise at least one valid `authMethods` entry on a clean first-run handshake. No-provider/no-auth is not compliant. +- Terminal Auth must be explicit and deterministic: `id: hermes-setup`, `type: terminal`, `args: ["--setup"]`. +- `icon.svg` must be 16x16, square, monochrome, and use only `currentColor` / `none` for fill/stroke; no gradients, hardcoded colors, or `url(#...)` paints. +- ACP server mode must reserve stdout for JSON-RPC only. Diagnostics/logs go to stderr. `--version`, `--check`, and `--setup` are not server mode and may print normally. +- Published npm package must exist and be runnable before the upstream registry PR references it. + +--- + +## Tasks + +1. Verify/implement ACP auth methods. + - Always return terminal setup auth from `initialize()`. + - Return configured provider auth in addition when provider credentials are resolvable. + - Add tests for provider auth, terminal fallback auth, and authenticate behavior before/after provider setup. + +2. Add non-interactive ACP commands. + - `hermes acp --version` + - `hermes acp --check` + - `hermes acp --setup` + - Same behavior through `hermes-acp`. + +3. Build npm launcher package. + - Package: `@nousresearch/hermes-agent-acp@`. + - Command: `uvx --from 'hermes-agent[acp]==' hermes-acp ...args`. + - Fallback: `uv tool run --from ...` when only `uv` exists. + - Forward all args, including `--setup`, `--version`, and `--check`. + - Preserve stdio in server mode. + - Print actionable stderr error when `uv`/`uvx` is missing. + +4. Replace local registry metadata. + - Convert `acp_registry/agent.json` from old command-style local format to official registry schema. + - Replace `acp_registry/icon.svg` with compliant 16x16 currentColor icon. + - Add tests rejecting old fields (`schema_version`, `display_name`, `distribution.type`, `distribution.command`) and unknown distribution keys. + +5. Update docs. + - Zed docs show official ACP Registry install first: Add Agent / `zed: acp registry` -> search Hermes Agent -> install. + - Manual `agent_servers` JSON remains only as local-development fallback. + - Docs include `uv` prerequisite and `hermes acp --check` troubleshooting. + - Developer internals mention npm launcher and terminal setup auth. + +6. Validate locally. + - `python -m pytest tests/acp/test_auth.py tests/acp/test_server.py tests/acp/test_entry.py tests/acp/test_registry_manifest.py -q` + - `(cd packages/hermes-agent-acp && npm test)` + - `(cd packages/hermes-agent-acp && npm pack --dry-run)` + - `hermes acp --version` + - `hermes acp --check` + +7. Validate against official registry tooling before PR. + - In a clone/fork of `agentclientprotocol/registry`, copy files into top-level `hermes-agent/`. + - Run official dry-run build, e.g. `uv run --with jsonschema .github/workflows/build_registry.py --dry-run`. + - Run official auth check if available, e.g. `.github/workflows/scripts/run-registry-docker.sh python3 .github/workflows/verify_agents.py --auth-check`. + - Fix any schema/auth issues before submitting. + +8. Publish and submit. + - Publish `@nousresearch/hermes-agent-acp@`. + - Verify published package: + - `npx @nousresearch/hermes-agent-acp@ --version` + - `npx @nousresearch/hermes-agent-acp@ --check` + - ACP initialize/authMethods smoke test through the published package. + - Open PR to `agentclientprotocol/registry` adding `hermes-agent/agent.json` and `hermes-agent/icon.svg`. + +9. End-to-end Zed verification. + - Install Hermes Agent through Zed's ACP Registry. + - Start a Hermes thread. + - Verify workspace cwd, file tools, terminal tools, tool rendering, and approval prompts. + +--- + +## Acceptance criteria + +- Hermes appears in Zed's official ACP Registry UI. +- Install starts Hermes without custom Zed settings. +- Registry CI passes schema and auth validation. +- ACP stdout remains JSON-RPC only; all logs go to stderr. +- `authMethods` are present and valid on clean first run. +- Terminal Auth can launch Hermes provider/model setup with `--setup`. +- Zed workspace cwd is honored by Hermes file and terminal tools. +- Docs describe registry install first and manual custom config second. +- Package/release automation prevents registry entries from pointing at unpublished versions. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e448e2b18ee..6b770edaf28 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11699,16 +11699,39 @@ Examples: description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)", ) _add_accept_hooks_flag(acp_parser) + acp_parser.add_argument( + "--version", + action="store_true", + dest="acp_version", + help="Print Hermes ACP version and exit", + ) + acp_parser.add_argument( + "--check", + action="store_true", + help="Verify ACP dependencies and adapter imports, then exit", + ) + acp_parser.add_argument( + "--setup", + action="store_true", + help="Run interactive Hermes provider/model setup for ACP terminal auth", + ) def cmd_acp(args): """Launch Hermes Agent as an ACP server.""" try: from acp_adapter.entry import main as acp_main - acp_main() + acp_argv = [] + if getattr(args, "acp_version", False): + acp_argv.append("--version") + if getattr(args, "check", False): + acp_argv.append("--check") + if getattr(args, "setup", False): + acp_argv.append("--setup") + acp_main(acp_argv) except ImportError: - print("ACP dependencies not installed.") - print("Install them with: pip install -e '.[acp]'") + print("ACP dependencies not installed.", file=sys.stderr) + print("Install them with: pip install -e '.[acp]'", file=sys.stderr) sys.exit(1) acp_parser.set_defaults(func=cmd_acp) diff --git a/packages/hermes-agent-acp/README.md b/packages/hermes-agent-acp/README.md new file mode 100644 index 00000000000..b3e9eea0afa --- /dev/null +++ b/packages/hermes-agent-acp/README.md @@ -0,0 +1,26 @@ +# @nousresearch/hermes-agent-acp + +ACP launcher for Hermes Agent. + +This package is intended for clients such as Zed that install agents through the official ACP Registry. It launches the Python Hermes ACP server with: + +```bash +uvx --from 'hermes-agent[acp]==0.13.0' hermes-acp +``` + +## Requirements + +- Node.js 18+ +- `uv` or `uvx` on PATH +- Hermes provider credentials configured with `hermes model`, or through Hermes' normal `~/.hermes/.env` / `~/.hermes/config.yaml` setup + +## Commands + +```bash +npx @nousresearch/hermes-agent-acp@0.13.0 --version +npx @nousresearch/hermes-agent-acp@0.13.0 --check +npx @nousresearch/hermes-agent-acp@0.13.0 --setup +npx @nousresearch/hermes-agent-acp@0.13.0 +``` + +Normal no-argument mode reserves stdout for ACP JSON-RPC traffic. Diagnostics are emitted on stderr by Hermes. diff --git a/packages/hermes-agent-acp/bin/hermes-agent-acp.js b/packages/hermes-agent-acp/bin/hermes-agent-acp.js new file mode 100755 index 00000000000..b9d571d3550 --- /dev/null +++ b/packages/hermes-agent-acp/bin/hermes-agent-acp.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +'use strict'; + +const { spawn, spawnSync } = require('node:child_process'); + +const HERMES_AGENT_VERSION = '0.13.0'; +const HERMES_SPEC = `hermes-agent[acp]==${HERMES_AGENT_VERSION}`; + +function commandExists(command) { + const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); + return !result.error && result.status === 0; +} + +function buildCommand(argv, exists = commandExists) { + if (exists('uvx')) { + return { + command: 'uvx', + args: ['--from', HERMES_SPEC, 'hermes-acp', ...argv], + }; + } + + if (exists('uv')) { + return { + command: 'uv', + args: ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', ...argv], + }; + } + + return null; +} + +function main() { + const argv = process.argv.slice(2); + const command = buildCommand(argv); + + if (!command) { + console.error('Hermes Agent ACP requires uv or uvx to launch the Python package.'); + console.error('Install uv from https://docs.astral.sh/uv/getting-started/installation/'); + console.error('Then retry this agent from Zed.'); + process.exit(127); + } + + const child = spawn(command.command, command.args, { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', (error) => { + console.error(`Failed to start Hermes Agent ACP: ${error.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { buildCommand, HERMES_AGENT_VERSION, HERMES_SPEC }; diff --git a/packages/hermes-agent-acp/package.json b/packages/hermes-agent-acp/package.json new file mode 100644 index 00000000000..224bb275b77 --- /dev/null +++ b/packages/hermes-agent-acp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nousresearch/hermes-agent-acp", + "version": "0.13.0", + "description": "ACP launcher for Hermes Agent", + "bin": { + "hermes-agent-acp": "bin/hermes-agent-acp.js" + }, + "files": [ + "bin/", + "README.md" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/NousResearch/hermes-agent.git", + "directory": "packages/hermes-agent-acp" + }, + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/packages/hermes-agent-acp/test/launcher.test.js b/packages/hermes-agent-acp/test/launcher.test.js new file mode 100644 index 00000000000..7a338305e56 --- /dev/null +++ b/packages/hermes-agent-acp/test/launcher.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildCommand, HERMES_SPEC } = require('../bin/hermes-agent-acp.js'); + +test('uses uvx when available and forwards args', () => { + const command = buildCommand(['--version'], (name) => name === 'uvx'); + + assert.equal(command.command, 'uvx'); + assert.deepEqual(command.args, ['--from', HERMES_SPEC, 'hermes-acp', '--version']); +}); + +test('falls back to uv tool run and forwards setup args', () => { + const command = buildCommand(['--setup'], (name) => name === 'uv'); + + assert.equal(command.command, 'uv'); + assert.deepEqual(command.args, ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', '--setup']); +}); + +test('returns null when neither uvx nor uv is available', () => { + assert.equal(buildCommand([], () => false), null); +}); diff --git a/tests/acp/test_auth.py b/tests/acp/test_auth.py index ffb07463f8d..0610d3e3350 100644 --- a/tests/acp/test_auth.py +++ b/tests/acp/test_auth.py @@ -1,6 +1,11 @@ """Tests for acp_adapter.auth — provider detection.""" -from acp_adapter.auth import has_provider, detect_provider +from acp_adapter.auth import ( + TERMINAL_SETUP_AUTH_METHOD_ID, + build_auth_methods, + has_provider, + detect_provider, +) class TestHasProvider: @@ -54,3 +59,44 @@ class TestDetectProvider: monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom) assert detect_provider() is None + + def test_detect_provider_strips_and_lowercases_provider(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda: {"provider": " OpenRouter ", "api_key": " sk-or-test "}, + ) + assert detect_provider() == "openrouter" + + +class TestBuildAuthMethods: + def test_build_auth_methods_returns_provider_and_terminal_when_configured(self, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter") + + methods = build_auth_methods() + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods] + + assert payloads[0]["id"] == "openrouter" + assert payloads[0]["name"] == "openrouter runtime credentials" + assert any(payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID for payload in payloads) + terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID) + assert terminal["type"] == "terminal" + assert terminal["args"] == ["--setup"] + + def test_build_auth_methods_returns_terminal_setup_when_unconfigured(self, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None) + + methods = build_auth_methods() + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods] + + assert payloads == [ + { + "args": ["--setup"], + "description": ( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + "id": TERMINAL_SETUP_AUTH_METHOD_ID, + "name": "Configure Hermes provider", + "type": "terminal", + } + ] diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 760522c312a..4c7e55f1d4b 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -15,6 +15,39 @@ def test_main_enables_unstable_protocol(monkeypatch): monkeypatch.setattr(entry, "_load_env", lambda: None) monkeypatch.setattr(acp, "run_agent", fake_run_agent) - entry.main() + entry.main([]) assert calls["kwargs"]["use_unstable_protocol"] is True + + +def test_main_version_prints_without_starting_server(monkeypatch, capsys): + monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server"))) + + entry.main(["--version"]) + + output = capsys.readouterr().out.strip() + assert output + assert "Starting hermes-agent ACP adapter" not in output + + +def test_main_check_prints_ok_without_starting_server(monkeypatch, capsys): + monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server"))) + + entry.main(["--check"]) + + assert capsys.readouterr().out.strip() == "Hermes ACP check OK" + + +def test_main_setup_runs_model_configuration(monkeypatch): + calls = {} + + def fake_hermes_main(): + import sys + + calls["argv"] = sys.argv[:] + + monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main) + + entry.main(["--setup"]) + + assert calls["argv"][1:] == ["model"] diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py new file mode 100644 index 00000000000..134cb5415ae --- /dev/null +++ b/tests/acp/test_registry_manifest.py @@ -0,0 +1,96 @@ +"""Tests for ACP Registry metadata shipped with Hermes.""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "acp_registry" / "agent.json" +ICON = ROOT / "acp_registry" / "icon.svg" +FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"} +ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"} + + +def _manifest() -> dict: + return json.loads(MANIFEST.read_text(encoding="utf-8")) + + +def _pyproject_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return data["project"]["version"] + + +def test_agent_json_matches_official_registry_required_fields(): + data = _manifest() + + assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data) + assert data["id"] == "hermes-agent" + assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"]) + assert data["name"] == "Hermes Agent" + assert data["description"] + assert data["repository"] == "https://github.com/NousResearch/hermes-agent" + assert data["website"].startswith("https://hermes-agent.nousresearch.com/") + assert data["authors"] == ["Nous Research"] + assert data["license"] == "MIT" + assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS + + +def test_agent_json_uses_npx_distribution_without_local_command_fields(): + data = _manifest() + + assert set(data["distribution"]) == {"npx"} + assert set(data["distribution"]["npx"]) == {"package"} + assert data["distribution"]["npx"]["package"] == ( + f"@nousresearch/hermes-agent-acp@{data['version']}" + ) + assert "type" not in data["distribution"] + assert "command" not in data["distribution"] + assert "args" not in data["distribution"] + + +def test_agent_json_version_matches_pyproject(): + assert _manifest()["version"] == _pyproject_version() + + +def test_npm_launcher_versions_match_pyproject_and_manifest(): + version = _pyproject_version() + package = json.loads( + (ROOT / "packages" / "hermes-agent-acp" / "package.json").read_text(encoding="utf-8") + ) + launcher = (ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js").read_text( + encoding="utf-8" + ) + + assert package["version"] == version + assert f"const HERMES_AGENT_VERSION = '{version}';" in launcher + assert _manifest()["distribution"]["npx"]["package"] == ( + f"@nousresearch/hermes-agent-acp@{version}" + ) + + +def test_icon_svg_is_16x16_current_color(): + root = ET.fromstring(ICON.read_text(encoding="utf-8")) + + assert root.attrib["viewBox"] == "0 0 16 16" + assert root.attrib["width"] == "16" + assert root.attrib["height"] == "16" + + +def test_icon_svg_has_no_hardcoded_colors_or_gradients(): + text = ICON.read_text(encoding="utf-8") + + assert "linearGradient" not in text + assert "radialGradient" not in text + assert "url(#" not in text + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text) + + root = ET.fromstring(text) + for element in root.iter(): + for attr in ("fill", "stroke"): + value = element.attrib.get(attr) + if value is not None: + assert value in {"currentColor", "none"} diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index a4dad4aefa8..6e2039d2b24 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -33,6 +33,7 @@ from acp.schema import ( UsageUpdate, UserMessageChunk, ) +from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID from acp_adapter.server import HermesACPAgent, HERMES_VERSION from acp_adapter.session import SessionManager from hermes_state import SessionDB @@ -92,6 +93,41 @@ class TestInitialize: assert "list" in session_caps assert "resume" in session_caps + @pytest.mark.asyncio + async def test_initialize_advertises_provider_and_terminal_auth_methods(self, agent, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter") + monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: "openrouter") + + resp = await agent.initialize(protocol_version=1) + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods] + + assert payloads[0]["id"] == "openrouter" + assert payloads[0]["name"] == "openrouter runtime credentials" + terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID) + assert terminal["type"] == "terminal" + assert terminal["args"] == ["--setup"] + + @pytest.mark.asyncio + async def test_initialize_advertises_terminal_setup_auth_when_no_provider(self, agent, monkeypatch): + monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None) + monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: None) + + resp = await agent.initialize(protocol_version=1) + payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods] + + assert payloads == [ + { + "args": ["--setup"], + "description": ( + "Open Hermes' interactive model/provider setup in a terminal. " + "Use this when Hermes has not been configured on this machine yet." + ), + "id": TERMINAL_SETUP_AUTH_METHOD_ID, + "name": "Configure Hermes provider", + "type": "terminal", + } + ] + # --------------------------------------------------------------------------- # authenticate @@ -135,6 +171,24 @@ class TestAuthenticate: resp = await agent.authenticate(method_id="openrouter") assert resp is None + @pytest.mark.asyncio + async def test_authenticate_accepts_terminal_setup_after_provider_configured(self, agent, monkeypatch): + monkeypatch.setattr( + "acp_adapter.server.detect_provider", + lambda: "openrouter", + ) + resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID) + assert isinstance(resp, AuthenticateResponse) + + @pytest.mark.asyncio + async def test_authenticate_rejects_terminal_setup_without_provider(self, agent, monkeypatch): + monkeypatch.setattr( + "acp_adapter.server.detect_provider", + lambda: None, + ) + resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID) + assert resp is None + # --------------------------------------------------------------------------- # new_session / cancel / load / resume diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md index 2ef552e266c..f688869033d 100644 --- a/website/docs/developer-guide/acp-internals.md +++ b/website/docs/developer-guide/acp-internals.md @@ -24,12 +24,15 @@ Key implementation files: ```text hermes acp / hermes-acp / python -m acp_adapter -> acp_adapter.entry.main() + -> parse --version / --check / --setup before server startup -> load ~/.hermes/.env -> configure stderr logging -> construct HermesACPAgent -> acp.run_agent(agent, use_unstable_protocol=True) ``` +The Zed ACP Registry path launches the same adapter through `npx @nousresearch/hermes-agent-acp@`, which delegates to `uvx --from 'hermes-agent[acp]==' hermes-acp`. + Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr. ## Major components @@ -146,7 +149,7 @@ Instead it reuses Hermes' runtime resolver: - `acp_adapter/auth.py` - `hermes_cli/runtime_provider.py` -So ACP advertises and uses the currently configured Hermes provider/credentials. +So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session. ## Working directory binding diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 1822f7adfad..b55664191c3 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -45,6 +45,14 @@ This installs the `agent-client-protocol` dependency and enables: - `hermes-acp` - `python -m acp_adapter` +For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses the npm launcher package `@nousresearch/hermes-agent-acp`, which runs: + +```bash +uvx --from 'hermes-agent[acp]==' hermes-acp +``` + +Make sure `uv` or `uvx` is available on `PATH` before using the registry install path. + ## Launching the ACP server Any of the following starts Hermes in ACP mode: @@ -63,6 +71,13 @@ python -m acp_adapter Hermes logs to stderr so stdout remains reserved for ACP JSON-RPC traffic. +For non-interactive checks: + +```bash +hermes acp --version +hermes acp --check +``` + ## Editor setup ### VS Code @@ -90,7 +105,19 @@ If you want to define Hermes manually, add it through VS Code settings under `ac ### Zed -Example settings snippet: +Zed v0.221.x and newer installs external agents through the official ACP Registry. + +1. Open the Agent Panel. +2. Click **Add Agent**, or run the `zed: acp registry` command. +3. Search for **Hermes Agent**. +4. Install it and start a new Hermes external-agent thread. + +Prerequisites: + +- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`. +- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==' hermes-acp`. + +For local development before the registry entry is available, use a custom agent server in Zed settings: ```json { @@ -98,9 +125,9 @@ Example settings snippet: "hermes-agent": { "type": "custom", "command": "hermes", - "args": ["acp"], - }, - }, + "args": ["acp"] + } + } } ``` @@ -114,18 +141,23 @@ Use an ACP-compatible plugin and point it at: ## Registry manifest -The ACP registry manifest lives at: +The source copy of Hermes' official ACP Registry metadata lives at: ```text acp_registry/agent.json +acp_registry/icon.svg ``` -It advertises a command-based agent whose launch command is: +The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`. + +The registry entry uses an `npx` distribution: ```text -hermes acp +npx @nousresearch/hermes-agent-acp@ ``` +The launcher then runs `hermes-acp` from the matching Python package version. + ## Configuration and credentials ACP mode uses the same Hermes configuration as the CLI: @@ -135,7 +167,7 @@ ACP mode uses the same Hermes configuration as the CLI: - `~/.hermes/skills/` - `~/.hermes/state.db` -Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. +Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup. ## Session behavior @@ -171,29 +203,36 @@ On timeout or error, the approval bridge denies the request. Check: -- the editor is pointed at the correct `acp_registry/` path -- Hermes is installed and on your PATH -- the ACP extra is installed (`pip install -e '.[acp]'`) +- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**. +- For manual/local development, verify the custom `agent_servers` command points to `hermes acp`. +- Hermes is installed and on your PATH. +- The ACP extra is installed (`pip install -e '.[acp]'`). +- `uv` or `uvx` is installed if launching from the official Zed registry entry. ### ACP starts but immediately errors Try these checks: ```bash +hermes acp --version +hermes acp --check hermes doctor hermes status -hermes acp ``` ### Missing credentials -ACP mode does not have its own login flow. It uses Hermes' existing provider setup. Configure credentials with: +ACP mode uses Hermes' existing provider setup. Configure credentials with: ```bash hermes model ``` -or by editing `~/.hermes/.env`. +or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup. + +### Zed registry launcher cannot find uv + +Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed. ## See also