Skip to content

Commit fc83d5c

Browse files
committed
clients/hermes: ship the plugin as an installable entry-point package
Hermes discovers pip/Nix-installed plugins by scanning the `hermes_agent.plugins` entry-point group (importlib.metadata -> ep.load() -> register(ctx)) — the documented recommended distribution path. Declare that entry point so commy installs that way instead of a per-repo git clone or an init-container extract. - pyproject: a real installable package (build-system + commy package + package-data) declaring `commy-platform = "commy"`. - flake: build the package straight from clients/hermes as a package output + overlay, consumed via services.hermes-agent.extraPythonPackages or a package .override -- no second repo, no deploy key, no mirror CI. - imports: intra-package `from commy.X` -> relative `from .X` so the package loads under any module name, not only a top-level `commy`. - test: prove the plugin advertises + loads via the entry-point path. - README: document the entry-point mechanism + pip/Nix consumption. Retires homelab's agent-comms-extract init-container + PYTHONPATH hack at consume time (hl-ynhi). comms-i8qn
1 parent 999b57f commit fc83d5c

11 files changed

Lines changed: 204 additions & 46 deletions

File tree

‎clients/hermes/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ __pycache__/
33
*.pyc
44
.pytest_cache/
55
.ruff_cache/
6+
dist/
7+
build/
8+
*.egg-info/

‎clients/hermes/README.md‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ tests/
6060
test_adapter_connection.py
6161
_stub_mcp_server.py # real stub MCP server for the transport tests
6262
scripts/test.sh # build isolated env + lint + test
63+
pyproject.toml # installable package + hermes_agent.plugins entry point
6364
```
6465

65-
The plugin payload is the `commy/` directory. Hermes loads it by
66-
directory scan (like the bundled platform plugins), not as a pip wheel — so
67-
this is a non-package uv project (tooling + tests only).
66+
The plugin payload is the `commy/` package. It ships as an installable Python
67+
package whose `pyproject.toml` declares the `hermes_agent.plugins` entry point
68+
(`commy-platform = "commy"`) — the mechanism Hermes uses to discover
69+
pip/Nix-installed plugins. See [Install / distribution](#install--distribution).
6870

6971
## Per-topic connection lifecycle (`comms-a7j.5`)
7072

@@ -115,8 +117,40 @@ Tested against the latest `hermes-agent` on PyPI (range `>=0.12,<1`); the pin
115117
tightens to the confirmed pod Hermes version via the homelab image lane
116118
(`comms-v9nws`).
117119

118-
## Pod install
120+
## Install / distribution
119121

120-
Wiring the install into `~/.hermes/plugins/` is tracked separately
121-
(`comms-a7j.7`).
122+
Hermes discovers plugins by scanning the `hermes_agent.plugins` entry-point
123+
group (`importlib.metadata.entry_points` → `ep.load()` → `register(ctx)`), the
124+
[documented recommended distribution path](https://github.com/NousResearch/hermes-agent)
125+
for pip/Nix-installed plugins. This package declares that entry point, so it
126+
installs without a per-repo git clone or an init-container extract. After
127+
install, activate it once:
128+
129+
```bash
130+
hermes plugins enable commy-platform
131+
```
132+
133+
**NixOS (the supported pod path).** The repo's flake builds this package as an
134+
output and exposes an overlay, so a NixOS host consumes it via
135+
`services.hermes-agent.extraPythonPackages` — built straight from this monorepo
136+
subdir, no separate plugin repo:
137+
138+
```nix
139+
{
140+
inputs.commy.url = "github:CodeForBreakfast/commy";
141+
142+
# In the host config, with commy's overlay applied so `commy-hermes` lands in
143+
# the same Python set as hermes-agent (version-matched):
144+
nixpkgs.overlays = [ inputs.commy.overlays.default ];
145+
services.hermes-agent.extraPythonPackages = ps: [ ps.commy-hermes ];
146+
}
122147
```
148+
149+
`nix build github:CodeForBreakfast/commy#commy-hermes` builds the wheel
150+
directly for inspection.
151+
152+
**pip.** `pip install` of the built wheel into Hermes' environment works the
153+
same way (auto-discovered on next startup, then `hermes plugins enable
154+
commy-platform`). Publishing the wheel to PyPI for public `pip install
155+
commy-hermes` is a future option (would use PyPI Trusted Publishing / OIDC); it
156+
is intentionally not wired until a public consumer needs it.

‎clients/hermes/commy/adapter.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
)
4141
from gateway.session import SessionSource
4242

43-
from commy.connection import ConnectionSpec, SpawnConfig, TopicConnectionManager
44-
from commy.listener import ChannelListener
45-
from commy.receive import ChannelFrame, frame_from_params
43+
from .connection import ConnectionSpec, SpawnConfig, TopicConnectionManager
44+
from .listener import ChannelListener
45+
from .receive import ChannelFrame, frame_from_params
4646

4747
PLATFORM_NAME = "commy"
4848
PLATFORM_LABEL = "Commy"
@@ -161,7 +161,7 @@ async def connect(self) -> bool:
161161
brings the listener up.
162162
"""
163163
if self._connection_manager is None:
164-
from commy.transport import make_listener, make_manager
164+
from .transport import make_listener, make_manager
165165

166166
config = SpawnConfig.from_env()
167167
self._connection_manager = make_manager(config, self.receive_channel_notification)

‎clients/hermes/commy/connection.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from dataclasses import dataclass, field
2828
from typing import Awaitable, Callable, Mapping, Optional, Protocol
2929

30-
from commy.naming import deterministic_bot_name
30+
from .naming import deterministic_bot_name
3131

3232
# A `{content, meta}` inbound frame — the same shape the receive path
3333
# (`CommyAdapter.receive_channel_notification`) consumes. It rides under

‎clients/hermes/commy/listener.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222

2323
from typing import Awaitable, Callable, Set, Tuple
2424

25-
from commy.connection import (
25+
from .connection import (
2626
ConnectionSpec,
2727
Frame,
2828
SpawnConfig,
2929
TransportFactory,
3030
)
31-
from commy.naming import deterministic_listener_name
32-
from commy.receive import frame_from_params
31+
from .naming import deterministic_listener_name
32+
from .receive import frame_from_params
3333

3434
# The boot listener owns no single topic, so its ConnectionSpec.topic is empty.
3535
_LISTENER_TOPIC = ""

‎clients/hermes/commy/session.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from mcp import ClientSession
3030
from mcp.types import Notification, ServerNotificationType
3131

32-
from commy.receive import NOTIFICATION_METHOD
32+
from .receive import NOTIFICATION_METHOD
3333

3434

3535
class ChannelFrameParams(pydantic.BaseModel):

‎clients/hermes/commy/transport.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from collections.abc import Mapping
3636
from typing import IO, Optional
3737

38-
from commy.connection import (
38+
from .connection import (
3939
Clock,
4040
ConnectionSpec,
4141
Frame,
@@ -44,7 +44,7 @@
4444
TopicConnectionManager,
4545
TransportFactory,
4646
)
47-
from commy.listener import ChannelListener, OwnedKeys, SpawnTrigger, build_listener_spec
47+
from .listener import ChannelListener, OwnedKeys, SpawnTrigger, build_listener_spec
4848

4949
_STOP_GRACE_SECONDS = 5.0
5050

@@ -115,7 +115,7 @@ async def _run(self) -> None:
115115
from mcp import StdioServerParameters
116116
from mcp.client.stdio import stdio_client
117117

118-
from commy.session import ChannelAwareClientSession
118+
from .session import ChannelAwareClientSession
119119

120120
server = StdioServerParameters(
121121
command=self._spec.command,

‎clients/hermes/pyproject.toml‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ requires-python = ">=3.11"
1010
# bundle or pin `hermes-agent` (or its tree). See README for the test recipe.
1111
dependencies = []
1212

13+
# Hermes discovers plugins by scanning this entry-point group
14+
# (importlib.metadata.entry_points → ep.load() → register(ctx); see
15+
# hermes_cli/plugins.py). The value is the importable module that exposes
16+
# `register`. This is the recommended distribution mechanism (comms-i8qn):
17+
# installed via pip or — on the pod — via `services.hermes-agent.extraPythonPackages`,
18+
# then activated with `hermes plugins enable commy-platform`.
19+
[project.entry-points."hermes_agent.plugins"]
20+
commy-platform = "commy"
21+
22+
[build-system]
23+
requires = ["setuptools>=61"]
24+
build-backend = "setuptools.build_meta"
25+
26+
[tool.setuptools]
27+
packages = ["commy"]
28+
29+
[tool.setuptools.package-data]
30+
# Ship the manifest with the wheel for introspection and the directory-install
31+
# fallback; entry-point discovery itself does not read it.
32+
commy = ["plugin.yaml"]
33+
1334
[dependency-groups]
1435
# `mcp` is the MCP Python SDK the per-topic transport (connection lifecycle,
1536
# comms-a7j.5) drives. It is NOT a runtime dependency of the plugin: the pod's
@@ -18,11 +39,6 @@ dependencies = []
1839
# tests can import the real SDK and run a real stub-server subprocess.
1940
dev = ["pytest>=8", "pyyaml>=6", "ruff>=0.6", "mcp>=1.2"]
2041

21-
[tool.uv]
22-
# Plugin payload is loaded by Hermes via directory scan, not pip-installed as a
23-
# wheel — so this is a non-package (virtual) uv project: deps + tooling only.
24-
package = false
25-
2642
[tool.pytest.ini_options]
2743
pythonpath = ["."]
2844
testpaths = ["tests"]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""The plugin is discoverable + loadable via Hermes's entry-point mechanism.
2+
3+
Hermes finds pip/Nix-installed plugins by scanning the ``hermes_agent.plugins``
4+
entry-point group with ``importlib.metadata.entry_points`` and loading each via
5+
``ep.load()`` → ``register(ctx)`` (see hermes_cli/plugins.py
6+
``_scan_entry_points`` / ``_load_entrypoint_module``). This is the documented
7+
*recommended* distribution path (comms-i8qn): the package declares the entry
8+
point in ``pyproject.toml`` and is consumed on the pod via
9+
``services.hermes-agent.extraPythonPackages``. ``hermes plugins enable
10+
commy-platform`` then activates it.
11+
12+
Requires the package installed in the environment (``scripts/test.sh``'s
13+
``uv sync`` builds it) so the entry point is present in ``importlib.metadata``.
14+
"""
15+
16+
import importlib.metadata
17+
18+
ENTRY_POINT_GROUP = "hermes_agent.plugins"
19+
ENTRY_POINT_NAME = "commy-platform"
20+
21+
22+
def _commy_entry_point() -> importlib.metadata.EntryPoint | None:
23+
eps = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
24+
return next((ep for ep in eps if ep.name == ENTRY_POINT_NAME), None)
25+
26+
27+
def test_plugin_advertises_hermes_entry_point():
28+
ep = _commy_entry_point()
29+
assert ep is not None, (
30+
f"no '{ENTRY_POINT_NAME}' entry point in group '{ENTRY_POINT_GROUP}'; "
31+
"Hermes discovers entry-point plugins via importlib.metadata, so the "
32+
"package must declare it in pyproject.toml"
33+
)
34+
35+
36+
def test_entry_point_loads_and_exposes_register():
37+
ep = _commy_entry_point()
38+
assert ep is not None
39+
loaded = ep.load()
40+
register = getattr(loaded, "register", None)
41+
assert callable(register), (
42+
"the entry-point target must expose a callable register(ctx) — "
43+
"Hermes loads the plugin with ep.load() then calls "
44+
"getattr(module, 'register')(ctx)"
45+
)

‎clients/hermes/uv.lock‎

Lines changed: 23 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)