jupyter-hermes-proxy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jupyter_hermes_proxy-0.1.0/.gitignore +6 -0
- jupyter_hermes_proxy-0.1.0/PKG-INFO +78 -0
- jupyter_hermes_proxy-0.1.0/README.md +66 -0
- jupyter_hermes_proxy-0.1.0/jupyter_hermes_proxy/__init__.py +75 -0
- jupyter_hermes_proxy-0.1.0/jupyter_hermes_proxy/icons/hermes.svg +4 -0
- jupyter_hermes_proxy-0.1.0/pyproject.toml +28 -0
- jupyter_hermes_proxy-0.1.0/tests/test_hermes_lookup.py +141 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jupyter-hermes-proxy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Jupyter server proxy for Hermes Agent dashboard
|
|
5
|
+
Project-URL: Homepage, https://github.com/dive4dec/jupyter-hermes-proxy
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/dive4dec/jupyter-hermes-proxy/issues
|
|
7
|
+
Author-email: Chung Chan <chung.chan@cityu.edu.hk>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: jupyter-server-proxy>=4.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# jupyter-hermes-proxy
|
|
14
|
+
|
|
15
|
+
Launch the [Hermes Agent](https://github.com/nousresearch/hermes-agent) dashboard from JupyterLab as a native launcher entry.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- One-click launch from the JupyterLab launcher panel with a custom Hermes icon
|
|
20
|
+
- Automatic port management via [jupyter-server-proxy](https://github.com/jupyter-server/jupyter-server-proxy)
|
|
21
|
+
- Works behind reverse proxies (no manual port forwarding needed)
|
|
22
|
+
- **Two modes**: spawn a new dashboard, or connect to an existing one
|
|
23
|
+
- Configurable Hermes executable path via `HERMES_BIN_PATH`
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install jupyter-hermes-proxy
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then restart JupyterLab. The "Hermes Dashboard" launcher entry will appear automatically.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
### Spawn mode (default)
|
|
36
|
+
|
|
37
|
+
By default the proxy searches for `hermes` in `PATH` and spawns a new dashboard process when you click the launcher.
|
|
38
|
+
|
|
39
|
+
Override the binary path:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export HERMES_BIN_PATH=/path/to/hermes
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### URL mode (connect to existing dashboard)
|
|
46
|
+
|
|
47
|
+
When Hermes dashboard is already running (e.g. supervised by s6 in Docker), point the proxy at it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export HERMES_DASHBOARD_URL=http://127.0.0.1:9119
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In this mode the proxy **does not spawn a new process** — it simply proxies traffic to the existing dashboard. No `hermes` binary on PATH is required.
|
|
54
|
+
|
|
55
|
+
## Related Packages
|
|
56
|
+
|
|
57
|
+
- **[jupyter-ai-hermes](https://github.com/dive4dec/jupyter-ai-hermes)** — Hermes Agent as an ACP persona for Jupyter AI chat, with live notebook context injection and MCP tools for cell management.
|
|
58
|
+
|
|
59
|
+
Both packages share the same `HERMES_BIN_PATH` environment variable convention.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone git@github.com:dive4dec/jupyter-hermes-proxy.git
|
|
65
|
+
cd jupyter-hermes-proxy
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## How It Works
|
|
70
|
+
|
|
71
|
+
This package registers a server spec under the `jupyter_serverproxy_servers` entry point.
|
|
72
|
+
|
|
73
|
+
- **Spawn mode**: `jupyter-server-proxy` calls `setup_hermes()` which returns a command that spawns `hermes dashboard` on an ephemeral port and proxies it through the Jupyter server.
|
|
74
|
+
- **URL mode**: `setup_hermes()` returns the pre-existing dashboard URL directly, so jupyter-server-proxy connects to it without spawning anything.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# jupyter-hermes-proxy
|
|
2
|
+
|
|
3
|
+
Launch the [Hermes Agent](https://github.com/nousresearch/hermes-agent) dashboard from JupyterLab as a native launcher entry.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- One-click launch from the JupyterLab launcher panel with a custom Hermes icon
|
|
8
|
+
- Automatic port management via [jupyter-server-proxy](https://github.com/jupyter-server/jupyter-server-proxy)
|
|
9
|
+
- Works behind reverse proxies (no manual port forwarding needed)
|
|
10
|
+
- **Two modes**: spawn a new dashboard, or connect to an existing one
|
|
11
|
+
- Configurable Hermes executable path via `HERMES_BIN_PATH`
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install jupyter-hermes-proxy
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then restart JupyterLab. The "Hermes Dashboard" launcher entry will appear automatically.
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
### Spawn mode (default)
|
|
24
|
+
|
|
25
|
+
By default the proxy searches for `hermes` in `PATH` and spawns a new dashboard process when you click the launcher.
|
|
26
|
+
|
|
27
|
+
Override the binary path:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export HERMES_BIN_PATH=/path/to/hermes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### URL mode (connect to existing dashboard)
|
|
34
|
+
|
|
35
|
+
When Hermes dashboard is already running (e.g. supervised by s6 in Docker), point the proxy at it:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export HERMES_DASHBOARD_URL=http://127.0.0.1:9119
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In this mode the proxy **does not spawn a new process** — it simply proxies traffic to the existing dashboard. No `hermes` binary on PATH is required.
|
|
42
|
+
|
|
43
|
+
## Related Packages
|
|
44
|
+
|
|
45
|
+
- **[jupyter-ai-hermes](https://github.com/dive4dec/jupyter-ai-hermes)** — Hermes Agent as an ACP persona for Jupyter AI chat, with live notebook context injection and MCP tools for cell management.
|
|
46
|
+
|
|
47
|
+
Both packages share the same `HERMES_BIN_PATH` environment variable convention.
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone git@github.com:dive4dec/jupyter-hermes-proxy.git
|
|
53
|
+
cd jupyter-hermes-proxy
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How It Works
|
|
58
|
+
|
|
59
|
+
This package registers a server spec under the `jupyter_serverproxy_servers` entry point.
|
|
60
|
+
|
|
61
|
+
- **Spawn mode**: `jupyter-server-proxy` calls `setup_hermes()` which returns a command that spawns `hermes dashboard` on an ephemeral port and proxies it through the Jupyter server.
|
|
62
|
+
- **URL mode**: `setup_hermes()` returns the pre-existing dashboard URL directly, so jupyter-server-proxy connects to it without spawning anything.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Jupyter server proxy for Hermes Agent dashboard."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _find_hermes() -> str:
|
|
11
|
+
"""Locate the ``hermes`` executable, respecting HERMES_BIN_PATH override."""
|
|
12
|
+
path = os.environ.get("HERMES_BIN_PATH") or shutil.which("hermes")
|
|
13
|
+
if not path:
|
|
14
|
+
raise FileNotFoundError(
|
|
15
|
+
"Cannot find 'hermes' in PATH. "
|
|
16
|
+
"Install Hermes Agent or set HERMES_BIN_PATH to its full path."
|
|
17
|
+
)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _hermes_cmd(port: int, unix_socket: str) -> List[str]:
|
|
22
|
+
"""Build the command to launch the Hermes dashboard.
|
|
23
|
+
|
|
24
|
+
``jupyter-server-proxy`` substitutes ``{port}`` with an ephemeral port
|
|
25
|
+
before calling this function, so we use that to tell the dashboard which
|
|
26
|
+
port to listen on.
|
|
27
|
+
"""
|
|
28
|
+
hermes_bin = _find_hermes()
|
|
29
|
+
return [
|
|
30
|
+
hermes_bin,
|
|
31
|
+
"dashboard",
|
|
32
|
+
"--port={port}",
|
|
33
|
+
"--host=127.0.0.1",
|
|
34
|
+
"--no-open",
|
|
35
|
+
"--skip-build",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _hermes_url() -> Optional[str]:
|
|
40
|
+
"""Return the pre-existing dashboard URL if HERMES_DASHBOARD_URL is set."""
|
|
41
|
+
return os.environ.get("HERMES_DASHBOARD_URL")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_hermes() -> Dict[str, Any]:
|
|
45
|
+
"""Return a jupyter-server-proxy server spec for the Hermes dashboard.
|
|
46
|
+
|
|
47
|
+
When ``HERMES_DASHBOARD_URL`` is set (e.g. ``http://127.0.0.1:9119``),
|
|
48
|
+
the proxy connects to an existing Hermes dashboard instead of spawning
|
|
49
|
+
a new process. This is the recommended mode in Docker/s6 deployments
|
|
50
|
+
where Hermes is already supervised.
|
|
51
|
+
|
|
52
|
+
See: https://jupyter-server-proxy.readthedocs.io/
|
|
53
|
+
"""
|
|
54
|
+
existing_url = _hermes_url()
|
|
55
|
+
|
|
56
|
+
if existing_url:
|
|
57
|
+
return {
|
|
58
|
+
"url": existing_url,
|
|
59
|
+
"timeout": 90,
|
|
60
|
+
"new_browser_tab": True,
|
|
61
|
+
"launcher_entry": {
|
|
62
|
+
"title": "Hermes Dashboard",
|
|
63
|
+
"icon_path": os.path.join(HERE, "icons", "hermes.svg"),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"command": _hermes_cmd,
|
|
69
|
+
"timeout": 90,
|
|
70
|
+
"new_browser_tab": True,
|
|
71
|
+
"launcher_entry": {
|
|
72
|
+
"title": "Hermes Dashboard",
|
|
73
|
+
"icon_path": os.path.join(HERE, "icons", "hermes.svg"),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jupyter-hermes-proxy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Jupyter server proxy for Hermes Agent dashboard"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Chung Chan", email = "chung.chan@cityu.edu.hk"},
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"jupyter-server-proxy>=4.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."jupyter_serverproxy_servers"]
|
|
20
|
+
hermes = "jupyter_hermes_proxy:setup_hermes"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/dive4dec/jupyter-hermes-proxy"
|
|
24
|
+
"Bug Tracker" = "https://github.com/dive4dec/jupyter-hermes-proxy/issues"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["jupyter_hermes_proxy"]
|
|
28
|
+
include = ["jupyter_hermes_proxy/icons"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Tests for jupyter-hermes-proxy hermes executable lookup and proxy modes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestFindHermes:
|
|
11
|
+
"""Test _find_hermes() resolves the hermes binary correctly."""
|
|
12
|
+
|
|
13
|
+
def test_env_var_overrides_path(self, monkeypatch):
|
|
14
|
+
"""HERMES_BIN_PATH takes precedence over PATH lookup."""
|
|
15
|
+
import jupyter_hermes_proxy
|
|
16
|
+
|
|
17
|
+
path_bin = "/usr/local/bin/hermes"
|
|
18
|
+
env_bin = "/opt/data/mamba/envs/main/bin/hermes"
|
|
19
|
+
|
|
20
|
+
monkeypatch.setenv("HERMES_BIN_PATH", env_bin)
|
|
21
|
+
with patch("shutil.which", return_value=path_bin):
|
|
22
|
+
result = jupyter_hermes_proxy._find_hermes()
|
|
23
|
+
|
|
24
|
+
assert result == env_bin
|
|
25
|
+
assert result != path_bin
|
|
26
|
+
|
|
27
|
+
def test_falls_back_to_shutil_which(self, monkeypatch):
|
|
28
|
+
"""shutil.which is used when HERMES_BIN_PATH is not set."""
|
|
29
|
+
import jupyter_hermes_proxy
|
|
30
|
+
|
|
31
|
+
path_bin = "/usr/local/bin/hermes"
|
|
32
|
+
|
|
33
|
+
monkeypatch.delenv("HERMES_BIN_PATH", raising=False)
|
|
34
|
+
with patch("shutil.which", return_value=path_bin):
|
|
35
|
+
result = jupyter_hermes_proxy._find_hermes()
|
|
36
|
+
|
|
37
|
+
assert result == path_bin
|
|
38
|
+
|
|
39
|
+
def test_raises_file_not_found_when_missing(self, monkeypatch):
|
|
40
|
+
"""FileNotFoundError is raised when neither env var nor PATH works."""
|
|
41
|
+
import jupyter_hermes_proxy
|
|
42
|
+
|
|
43
|
+
monkeypatch.delenv("HERMES_BIN_PATH", raising=False)
|
|
44
|
+
with patch("shutil.which", return_value=None):
|
|
45
|
+
with pytest.raises(FileNotFoundError, match="hermes"):
|
|
46
|
+
jupyter_hermes_proxy._find_hermes()
|
|
47
|
+
|
|
48
|
+
def test_env_var_used_even_when_path_exists(self, monkeypatch):
|
|
49
|
+
"""HERMES_BIN_PATH must override a binary found in PATH."""
|
|
50
|
+
import jupyter_hermes_proxy
|
|
51
|
+
|
|
52
|
+
path_bin = "/usr/local/bin/hermes"
|
|
53
|
+
env_bin = "/opt/custom/bin/hermes"
|
|
54
|
+
|
|
55
|
+
monkeypatch.setenv("HERMES_BIN_PATH", env_bin)
|
|
56
|
+
with patch("shutil.which", return_value=path_bin):
|
|
57
|
+
result = jupyter_hermes_proxy._find_hermes()
|
|
58
|
+
|
|
59
|
+
assert result == env_bin
|
|
60
|
+
assert result != path_bin
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestHermesCmd:
|
|
64
|
+
"""Test _hermes_cmd() builds the correct command list."""
|
|
65
|
+
|
|
66
|
+
def test_basic_command(self, monkeypatch):
|
|
67
|
+
"""Command includes dashboard flags."""
|
|
68
|
+
import jupyter_hermes_proxy
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(jupyter_hermes_proxy, "shutil", __import__("shutil"))
|
|
71
|
+
with patch("shutil.which", return_value="/usr/bin/hermes"):
|
|
72
|
+
cmd = jupyter_hermes_proxy._hermes_cmd(8765, "")
|
|
73
|
+
|
|
74
|
+
assert cmd[0] == "/usr/bin/hermes"
|
|
75
|
+
assert "dashboard" in cmd
|
|
76
|
+
assert "--port={port}" in cmd
|
|
77
|
+
assert "--host=127.0.0.1" in cmd
|
|
78
|
+
assert "--no-open" in cmd
|
|
79
|
+
assert "--skip-build" in cmd
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestSetupHermes:
|
|
83
|
+
"""Test setup_hermes() returns a valid server spec in both modes."""
|
|
84
|
+
|
|
85
|
+
def _get_module(self):
|
|
86
|
+
import jupyter_hermes_proxy
|
|
87
|
+
return jupyter_hermes_proxy
|
|
88
|
+
|
|
89
|
+
# -- Spawn mode (no HERMES_DASHBOARD_URL) --
|
|
90
|
+
|
|
91
|
+
def test_spawn_mode_has_command(self, monkeypatch):
|
|
92
|
+
"""Spawn mode contains 'command', not 'url'."""
|
|
93
|
+
monkeypatch.delenv("HERMES_DASHBOARD_URL", raising=False)
|
|
94
|
+
with patch("shutil.which", return_value="/usr/bin/hermes"):
|
|
95
|
+
spec = self._get_module().setup_hermes()
|
|
96
|
+
|
|
97
|
+
assert "command" in spec
|
|
98
|
+
assert "url" not in spec
|
|
99
|
+
|
|
100
|
+
def test_spawn_mode_has_timeout_and_launcher(self, monkeypatch):
|
|
101
|
+
"""Spawn spec contains all required keys."""
|
|
102
|
+
monkeypatch.delenv("HERMES_DASHBOARD_URL", raising=False)
|
|
103
|
+
with patch("shutil.which", return_value="/usr/bin/hermes"):
|
|
104
|
+
spec = self._get_module().setup_hermes()
|
|
105
|
+
|
|
106
|
+
assert spec["timeout"] == 90
|
|
107
|
+
assert spec["launcher_entry"]["title"] == "Hermes Dashboard"
|
|
108
|
+
assert "hermes.svg" in spec["launcher_entry"]["icon_path"]
|
|
109
|
+
|
|
110
|
+
# -- URL mode (HERMES_DASHBOARD_URL set) --
|
|
111
|
+
|
|
112
|
+
def test_url_mode_has_url_not_command(self, monkeypatch):
|
|
113
|
+
"""URL mode contains 'url', not 'command'."""
|
|
114
|
+
monkeypatch.setenv("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
|
|
115
|
+
spec = self._get_module().setup_hermes()
|
|
116
|
+
|
|
117
|
+
assert "url" in spec
|
|
118
|
+
assert "command" not in spec
|
|
119
|
+
|
|
120
|
+
def test_url_mode_preserves_url_value(self, monkeypatch):
|
|
121
|
+
"""URL mode propagates the env var value directly."""
|
|
122
|
+
monkeypatch.setenv("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
|
|
123
|
+
spec = self._get_module().setup_hermes()
|
|
124
|
+
|
|
125
|
+
assert spec["url"] == "http://127.0.0.1:9119"
|
|
126
|
+
|
|
127
|
+
def test_url_mode_has_timeout_and_launcher(self, monkeypatch):
|
|
128
|
+
"""URL spec also contains timeout and launcher entry."""
|
|
129
|
+
monkeypatch.setenv("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
|
|
130
|
+
spec = self._get_module().setup_hermes()
|
|
131
|
+
|
|
132
|
+
assert spec["timeout"] == 90
|
|
133
|
+
assert spec["launcher_entry"]["title"] == "Hermes Dashboard"
|
|
134
|
+
assert "hermes.svg" in spec["launcher_entry"]["icon_path"]
|
|
135
|
+
|
|
136
|
+
def test_url_mode_ignores_hermes_bin(self, monkeypatch):
|
|
137
|
+
"""URL mode does not need hermes binary on PATH."""
|
|
138
|
+
monkeypatch.setenv("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
|
|
139
|
+
# No shutil.which mock — should not be called
|
|
140
|
+
spec = self._get_module().setup_hermes()
|
|
141
|
+
assert spec["url"] == "http://127.0.0.1:9119"
|