feat(plugins): load portable agent components
This commit is contained in:
@@ -9,6 +9,7 @@ Python code.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -316,7 +317,8 @@ def _translate_stdio(
|
||||
for key, value in env.items()
|
||||
):
|
||||
raise ValueError("env must map string keys to string values")
|
||||
if "PLUGIN_ROOT" in env or "PLUGIN_DATA" in env:
|
||||
env_keys = {key.upper() if os.name == "nt" else key for key in env}
|
||||
if "PLUGIN_ROOT" in env_keys or "PLUGIN_DATA" in env_keys:
|
||||
raise ValueError("PLUGIN_ROOT and PLUGIN_DATA are reserved")
|
||||
|
||||
cwd = config.get("cwd")
|
||||
@@ -436,3 +438,12 @@ def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage:
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]:
|
||||
"""Validate only root ``plugin.json`` without discovering components."""
|
||||
|
||||
root = Path(plugin_root).resolve(strict=True)
|
||||
if not root.is_dir():
|
||||
raise AgentPluginError("plugin root must be a directory")
|
||||
manifest, diagnostics = _validate_manifest(root)
|
||||
return manifest, tuple(diagnostics)
|
||||
|
||||
@@ -17,7 +17,12 @@ def _has_configured_mcp_servers() -> bool:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
|
||||
if isinstance(mcp_servers, dict) and len(mcp_servers) > 0:
|
||||
return True
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
|
||||
discover_plugins()
|
||||
return get_plugin_manager().has_portable_mcp_servers()
|
||||
except Exception:
|
||||
# Be conservative: if config probing fails, try discovery in the
|
||||
# background so startup still can't block.
|
||||
|
||||
+124
-3
@@ -34,6 +34,7 @@ so plugin-defined tools appear alongside the built-in tools.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
@@ -280,6 +281,15 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
|
||||
|
||||
|
||||
def _portable_skill_namespace(key: str) -> str:
|
||||
"""Return a readable, collision-resistant namespace for a portable plugin."""
|
||||
|
||||
slug = "".join(ch if ch.isalnum() or ch in "_-" else "-" for ch in key.lower())
|
||||
slug = slug.strip("-_") or "plugin"
|
||||
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
||||
return f"agent-plugin-{slug}-{digest}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginManifest:
|
||||
"""Parsed representation of a plugin.yaml manifest."""
|
||||
@@ -287,7 +297,7 @@ class PluginManifest:
|
||||
name: str
|
||||
version: str = ""
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
author: Any = ""
|
||||
requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list)
|
||||
provides_tools: List[str] = field(default_factory=list)
|
||||
provides_hooks: List[str] = field(default_factory=list)
|
||||
@@ -315,6 +325,8 @@ class PluginManifest:
|
||||
# category plugin at ``plugins/image_gen/openai/`` the key is
|
||||
# ``image_gen/openai``. When empty, falls back to ``name``.
|
||||
key: str = ""
|
||||
portable: bool = False
|
||||
skill_namespace: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1258,10 +1270,13 @@ class PluginContext:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
||||
|
||||
qualified = f"{self.manifest.name}:{name}"
|
||||
namespace = self.manifest.skill_namespace or self.manifest.name
|
||||
qualified = f"{namespace}:{name}"
|
||||
if qualified in self._manager._plugin_skills:
|
||||
raise ValueError(f"Plugin skill '{qualified}' is already registered")
|
||||
self._manager._plugin_skills[qualified] = {
|
||||
"path": path,
|
||||
"plugin": self.manifest.name,
|
||||
"plugin": namespace,
|
||||
"bare_name": name,
|
||||
"description": description,
|
||||
}
|
||||
@@ -1291,6 +1306,7 @@ class PluginManager:
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
self._plugin_skills: Dict[str, Dict[str, Any]] = {}
|
||||
self._portable_mcp_servers: Dict[str, Dict[str, Any]] = {}
|
||||
# Plugin-registered auxiliary tasks: key → {key, display_name,
|
||||
# description, defaults, plugin}. See PluginContext.register_auxiliary_task.
|
||||
self._aux_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -1328,6 +1344,7 @@ class PluginManager:
|
||||
self._cli_commands.clear()
|
||||
self._plugin_commands.clear()
|
||||
self._plugin_skills.clear()
|
||||
self._portable_mcp_servers.clear()
|
||||
self._aux_tasks.clear()
|
||||
self._slack_action_handlers.clear()
|
||||
self._context_engine = None
|
||||
@@ -1570,6 +1587,36 @@ class PluginManager:
|
||||
manifests.append(manifest)
|
||||
continue
|
||||
|
||||
portable_file = child / "plugin.json"
|
||||
if portable_file.exists() or portable_file.is_symlink():
|
||||
try:
|
||||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||||
|
||||
data, diagnostics = read_agent_plugin_manifest(child)
|
||||
for diagnostic in diagnostics:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s': %s",
|
||||
child,
|
||||
diagnostic.message,
|
||||
)
|
||||
key = f"{prefix}/{child.name}" if prefix else data["name"]
|
||||
manifests.append(
|
||||
PluginManifest(
|
||||
name=data["name"],
|
||||
version=data.get("version", ""),
|
||||
description=data.get("description", ""),
|
||||
author=data.get("author", ""),
|
||||
source=source,
|
||||
path=str(child),
|
||||
key=key,
|
||||
portable=True,
|
||||
skill_namespace=_portable_skill_namespace(key),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse %s: %s", portable_file, exc)
|
||||
continue
|
||||
|
||||
# No manifest at this level. If we're still within the depth
|
||||
# cap, treat this directory as a category namespace and recurse
|
||||
# one level in looking for children with manifests.
|
||||
@@ -1783,6 +1830,10 @@ class PluginManager:
|
||||
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
|
||||
)
|
||||
|
||||
if manifest.portable:
|
||||
self._load_portable_plugin(manifest, loaded)
|
||||
return
|
||||
|
||||
from tools.registry import registry as _registry
|
||||
_plugin_id = manifest.key or manifest.name
|
||||
_slug = _plugin_id.replace("/", "__").replace("-", "_")
|
||||
@@ -1859,6 +1910,53 @@ class PluginManager:
|
||||
)
|
||||
self._plugins[manifest.key or manifest.name] = loaded
|
||||
|
||||
def _load_portable_plugin(
|
||||
self, manifest: PluginManifest, loaded: LoadedPlugin
|
||||
) -> None:
|
||||
"""Load validated portable components without importing Python code."""
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
try:
|
||||
from hermes_cli.agent_plugins import load_agent_plugin
|
||||
|
||||
package = load_agent_plugin(
|
||||
Path(manifest.path),
|
||||
get_hermes_home() / "plugin-data" / manifest.skill_namespace,
|
||||
)
|
||||
ctx = PluginContext(manifest, self)
|
||||
for diagnostic in package.diagnostics:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' [%s]: %s",
|
||||
lookup_key,
|
||||
diagnostic.scope,
|
||||
diagnostic.message,
|
||||
)
|
||||
for skill in package.skills:
|
||||
try:
|
||||
ctx.register_skill(skill.name, skill.skill_md, skill.description)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' skill '%s' skipped: %s",
|
||||
lookup_key,
|
||||
skill.name,
|
||||
exc,
|
||||
)
|
||||
for server_name, config in package.mcp_servers.items():
|
||||
internal_name = f"{manifest.skill_namespace}__{server_name}"
|
||||
if internal_name in self._portable_mcp_servers:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' MCP server collision: %s",
|
||||
lookup_key,
|
||||
internal_name,
|
||||
)
|
||||
continue
|
||||
self._portable_mcp_servers[internal_name] = dict(config)
|
||||
loaded.enabled = True
|
||||
except Exception as exc:
|
||||
loaded.error = str(exc)
|
||||
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc)
|
||||
self._plugins[lookup_key] = loaded
|
||||
|
||||
def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
|
||||
"""Import a directory-based plugin as ``hermes_plugins.<slug>``.
|
||||
|
||||
@@ -2047,6 +2145,29 @@ class PluginManager:
|
||||
if qn.startswith(prefix)
|
||||
)
|
||||
|
||||
def list_plugin_skill_metadata(self) -> List[Dict[str, str]]:
|
||||
"""Return progressive-disclosure metadata for registered plugin skills."""
|
||||
|
||||
return [
|
||||
{
|
||||
"name": qualified,
|
||||
"description": str(entry.get("description", "")),
|
||||
"category": "plugin",
|
||||
}
|
||||
for qualified, entry in sorted(self._plugin_skills.items())
|
||||
]
|
||||
|
||||
def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a defensive copy of enabled portable MCP server configs."""
|
||||
|
||||
return {
|
||||
name: dict(config)
|
||||
for name, config in self._portable_mcp_servers.items()
|
||||
}
|
||||
|
||||
def has_portable_mcp_servers(self) -> bool:
|
||||
return bool(self._portable_mcp_servers)
|
||||
|
||||
def remove_plugin_skill(self, qualified_name: str) -> None:
|
||||
"""Remove a stale registry entry (silently ignores missing keys)."""
|
||||
self._plugin_skills.pop(qualified_name, None)
|
||||
|
||||
@@ -262,10 +262,22 @@ def _repo_name_from_url(url: str) -> str:
|
||||
|
||||
|
||||
def _read_manifest(plugin_dir: Path) -> dict:
|
||||
"""Read plugin.yaml and return the parsed dict, or empty dict."""
|
||||
"""Read a native or portable manifest, preferring native YAML."""
|
||||
manifest_file = plugin_dir / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
return {}
|
||||
manifest_file = plugin_dir / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
portable_file = plugin_dir / "plugin.json"
|
||||
if not portable_file.exists() and not portable_file.is_symlink():
|
||||
return {}
|
||||
try:
|
||||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||||
|
||||
manifest, _ = read_agent_plugin_manifest(plugin_dir)
|
||||
return manifest
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e)
|
||||
return {}
|
||||
try:
|
||||
import yaml
|
||||
|
||||
@@ -497,7 +509,25 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
||||
else:
|
||||
tmp_target = tmp_clone
|
||||
|
||||
manifest = _read_manifest(tmp_target)
|
||||
has_native_manifest = (tmp_target / "plugin.yaml").exists() or (
|
||||
tmp_target / "plugin.yml"
|
||||
).exists()
|
||||
has_portable_manifest = (tmp_target / "plugin.json").exists() or (
|
||||
tmp_target / "plugin.json"
|
||||
).is_symlink()
|
||||
if not has_native_manifest and has_portable_manifest:
|
||||
try:
|
||||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||||
|
||||
manifest, diagnostics = read_agent_plugin_manifest(tmp_target)
|
||||
for diagnostic in diagnostics:
|
||||
logger.warning("Agent Plugin install: %s", diagnostic.message)
|
||||
except Exception as exc:
|
||||
raise PluginOperationError(
|
||||
f"Portable plugin manifest validation failed: {exc}"
|
||||
) from exc
|
||||
else:
|
||||
manifest = _read_manifest(tmp_target)
|
||||
plugin_name = manifest.get("name") or (
|
||||
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
|
||||
)
|
||||
@@ -536,7 +566,8 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
||||
shutil.move(str(tmp_target), str(target))
|
||||
|
||||
has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
|
||||
if not has_yaml and not (target / "__init__.py").exists():
|
||||
has_portable = (target / "plugin.json").exists()
|
||||
if not has_yaml and not has_portable and not (target / "__init__.py").exists():
|
||||
logger.warning(
|
||||
"%s has no plugin.yaml / __init__.py; may not be a valid plugin",
|
||||
plugin_name,
|
||||
@@ -590,12 +621,12 @@ def cmd_install(
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
|
||||
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (target / "plugin.json").exists() and not (
|
||||
target / "__init__.py"
|
||||
).exists():
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml "
|
||||
f"or __init__.py. It may not be a valid Hermes plugin.",
|
||||
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, "
|
||||
f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.",
|
||||
)
|
||||
|
||||
_prompt_plugin_env_vars(installed_manifest, console)
|
||||
@@ -985,7 +1016,7 @@ def _plugin_exists(name: str) -> bool:
|
||||
|
||||
|
||||
def _read_manifest_info(d: Path, prefix: str):
|
||||
"""Read a plugin.yaml manifest and return (name, version, description, key).
|
||||
"""Read a native or portable manifest and return display metadata.
|
||||
|
||||
Returns None if no manifest file exists.
|
||||
"""
|
||||
@@ -993,7 +1024,23 @@ def _read_manifest_info(d: Path, prefix: str):
|
||||
if not manifest_file.exists():
|
||||
manifest_file = d / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
return None
|
||||
portable_file = d / "plugin.json"
|
||||
if not portable_file.exists() and not portable_file.is_symlink():
|
||||
return None
|
||||
try:
|
||||
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
||||
|
||||
manifest, _ = read_agent_plugin_manifest(d)
|
||||
name = manifest["name"]
|
||||
key = f"{prefix}/{d.name}" if prefix else name
|
||||
return (
|
||||
name,
|
||||
manifest.get("version", ""),
|
||||
manifest.get("description", ""),
|
||||
key,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
|
||||
@@ -14,7 +14,10 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
plugins_parser = subparsers.add_parser(
|
||||
"plugins",
|
||||
help="Manage plugins — install, update, remove, list",
|
||||
description="Install plugins from Git repositories, update, remove, or list them.",
|
||||
description=(
|
||||
"Install, update, remove, or list native Hermes plugins and "
|
||||
"portable Agent Plugins v1 packages. Portable packages install disabled."
|
||||
),
|
||||
)
|
||||
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
|
||||
|
||||
|
||||
@@ -146,6 +146,25 @@ def test_background_mcp_discovery_suppresses_interactive_oauth(monkeypatch):
|
||||
assert state["active"] is False
|
||||
|
||||
|
||||
def test_portable_only_mcp_configuration_opens_startup_gate(monkeypatch):
|
||||
manager = types.SimpleNamespace(has_portable_mcp_servers=lambda: True)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.config",
|
||||
types.SimpleNamespace(read_raw_config=lambda: {}),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(
|
||||
discover_plugins=lambda: None,
|
||||
get_plugin_manager=lambda: manager,
|
||||
),
|
||||
)
|
||||
|
||||
assert mcp_startup._has_configured_mcp_servers() is True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -183,4 +202,3 @@ def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for the Hermes plugin system (hermes_cli.plugins)."""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
@@ -92,6 +93,93 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
||||
class TestPluginDiscovery:
|
||||
"""Tests for plugin discovery from directories and entry points."""
|
||||
|
||||
def test_enabled_portable_plugin_registers_components(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1
|
||||
from hermes_cli import plugins as plugins_mod
|
||||
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
skill = plugin / "skills" / "summarize"
|
||||
skill.mkdir(parents=True)
|
||||
(plugin / "plugin.json").write_text(
|
||||
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
|
||||
)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: summarize\ndescription: Summarize reports.\n---\nBody.\n"
|
||||
)
|
||||
(plugin / "mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"worker": {"type": "stdio", "command": "python"}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
native = home / "plugins" / "native"
|
||||
native.mkdir()
|
||||
(native / "plugin.yaml").write_text(
|
||||
yaml.safe_dump({"name": "native", "version": "1.0.0"})
|
||||
)
|
||||
(native / "__init__.py").write_text("def register(ctx):\n pass\n")
|
||||
home.mkdir(exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["portable.test", "native"]}})
|
||||
)
|
||||
empty_bundled = tmp_path / "bundled"
|
||||
empty_bundled.mkdir()
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "os-home"))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled)
|
||||
|
||||
manager = PluginManager()
|
||||
manager.discover_and_load()
|
||||
|
||||
[qualified] = manager.list_plugin_skill_metadata()
|
||||
assert qualified["name"].endswith(":summarize")
|
||||
assert set(manager.get_portable_mcp_servers()) == {
|
||||
qualified["name"].split(":", 1)[0] + "__worker"
|
||||
}
|
||||
assert manager._plugins["portable.test"].enabled is True
|
||||
assert manager._plugins["native"].enabled is True
|
||||
assert manager._plugins["native"].module is not None
|
||||
|
||||
def test_disabled_portable_plugin_registers_nothing(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
||||
from hermes_cli import plugins as plugins_mod
|
||||
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
plugin.mkdir(parents=True)
|
||||
(plugin / "plugin.json").write_text(
|
||||
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
|
||||
)
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"plugins": {
|
||||
"enabled": ["portable.test"],
|
||||
"disabled": ["portable.test"],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
empty_bundled = tmp_path / "bundled"
|
||||
empty_bundled.mkdir()
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "os-home"))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled)
|
||||
|
||||
manager = PluginManager()
|
||||
manager.discover_and_load()
|
||||
|
||||
assert manager._plugins["portable.test"].enabled is False
|
||||
assert manager.list_plugin_skill_metadata() == []
|
||||
assert manager.get_portable_mcp_servers() == {}
|
||||
|
||||
|
||||
def test_plugin_can_register_and_invoke_middleware(self, tmp_path, monkeypatch):
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
|
||||
@@ -616,3 +616,30 @@ class TestSubdirInstallE2E:
|
||||
identifier = f"file://{repo_root}#does-not-exist"
|
||||
with pytest.raises(PluginOperationError, match="does not exist"):
|
||||
pc._install_plugin_core(identifier, force=False)
|
||||
|
||||
|
||||
def test_portable_manifest_is_visible_to_plugin_cli(tmp_path):
|
||||
import json
|
||||
|
||||
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
||||
from hermes_cli.plugins_cmd import _read_manifest_info
|
||||
|
||||
plugin = tmp_path / "portable"
|
||||
plugin.mkdir()
|
||||
(plugin / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"$schema": PLUGIN_SCHEMA_V1,
|
||||
"name": "portable.test",
|
||||
"version": "1.0.0",
|
||||
"description": "Portable test plugin",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert _read_manifest_info(plugin, "") == (
|
||||
"portable.test",
|
||||
"1.0.0",
|
||||
"Portable test plugin",
|
||||
"portable.test",
|
||||
)
|
||||
|
||||
@@ -139,6 +139,17 @@ class TestPluginContextRegisterSkill:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
ctx.register_skill("foo", tmp_path / "nonexistent.md")
|
||||
|
||||
def test_duplicate_qualified_name_is_rejected(self, ctx, tmp_path):
|
||||
first = tmp_path / "first" / "SKILL.md"
|
||||
second = tmp_path / "second" / "SKILL.md"
|
||||
first.parent.mkdir()
|
||||
second.parent.mkdir()
|
||||
first.write_text("test")
|
||||
second.write_text("test")
|
||||
ctx.register_skill("foo", first)
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
ctx.register_skill("foo", second)
|
||||
|
||||
|
||||
# ── skill_view qualified name dispatch ────────────────────────────────────
|
||||
|
||||
@@ -178,6 +189,32 @@ class TestSkillViewQualifiedName:
|
||||
assert result["name"] == "superpowers:writing-plans"
|
||||
assert "writing-plans body." in result["content"]
|
||||
|
||||
def test_reads_supporting_file_with_containment(self, tmp_path):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
md = self._register_skill(tmp_path)
|
||||
reference = md.parent / "references" / "api.md"
|
||||
reference.parent.mkdir()
|
||||
reference.write_text("API details.")
|
||||
|
||||
main = json.loads(skill_view("superpowers:writing-plans"))
|
||||
assert main["linked_files"] == {"references": ["references/api.md"]}
|
||||
result = json.loads(
|
||||
skill_view("superpowers:writing-plans", file_path="references/api.md")
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["content"] == "API details."
|
||||
|
||||
def test_rejects_supporting_file_escape(self, tmp_path):
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
self._register_skill(tmp_path)
|
||||
result = json.loads(
|
||||
skill_view("superpowers:writing-plans", file_path="../outside.md")
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "traversal" in result["error"].lower()
|
||||
|
||||
def test_plugin_skill_usage_reports_installed_provenance(
|
||||
self,
|
||||
tmp_path,
|
||||
|
||||
@@ -118,6 +118,29 @@ class TestLoadMCPConfig:
|
||||
result = _load_mcp_config()
|
||||
assert result == {}
|
||||
|
||||
def test_portable_servers_merge_after_native_interpolation(self):
|
||||
native = {"native": {"command": "node", "args": ["${PORT}"]}}
|
||||
portable = {
|
||||
"agent-plugin-demo__worker": {
|
||||
"command": "python",
|
||||
"args": ["${UNKNOWN}"],
|
||||
"cwd": "/plugin",
|
||||
}
|
||||
}
|
||||
manager = SimpleNamespace(get_portable_mcp_servers=lambda: portable)
|
||||
with (
|
||||
patch("hermes_cli.config.load_config", return_value={"mcp_servers": native}),
|
||||
patch("hermes_cli.plugins.discover_plugins"),
|
||||
patch("hermes_cli.plugins.get_plugin_manager", return_value=manager),
|
||||
patch.dict(os.environ, {"PORT": "3000"}),
|
||||
):
|
||||
from tools.mcp_tool import _load_mcp_config
|
||||
|
||||
result = _load_mcp_config()
|
||||
|
||||
assert result["native"]["args"] == ["3000"]
|
||||
assert result["agent-plugin-demo__worker"]["args"] == ["${UNKNOWN}"]
|
||||
|
||||
|
||||
class TestMCPParallelSafetyProvenance:
|
||||
def test_parallel_safe_servers_keep_exact_raw_names(self, monkeypatch):
|
||||
@@ -739,14 +762,17 @@ class TestMCPServerTask:
|
||||
p_stdio, p_cs, _, _ = self._mock_stdio_and_session(mock_session)
|
||||
|
||||
async def _test():
|
||||
with patch("tools.mcp_tool.StdioServerParameters"), p_stdio, p_cs:
|
||||
with patch("tools.mcp_tool.StdioServerParameters") as params, p_stdio, p_cs:
|
||||
server = MCPServerTask("test_srv")
|
||||
await server.start({"command": "npx", "args": ["-y", "test"]})
|
||||
await server.start(
|
||||
{"command": "npx", "args": ["-y", "test"], "cwd": "/plugin"}
|
||||
)
|
||||
|
||||
assert server.session is mock_session
|
||||
assert len(server._tools) == 1
|
||||
assert server._tools[0].name == "echo"
|
||||
mock_session.initialize.assert_called_once()
|
||||
assert params.call_args.kwargs["cwd"] == "/plugin"
|
||||
|
||||
await server.shutdown()
|
||||
assert server.session is None
|
||||
|
||||
+18
-2
@@ -2524,6 +2524,7 @@ class MCPServerTask:
|
||||
command=command,
|
||||
args=args,
|
||||
env=safe_env if safe_env else None,
|
||||
cwd=config.get("cwd"),
|
||||
# On Windows, pipe I/O can deliver non-UTF-8 bytes at chunk
|
||||
# boundaries. Use "replace" to substitute undecodable bytes
|
||||
# with U+FFFD instead of crashing with UnicodeDecodeError.
|
||||
@@ -4890,8 +4891,8 @@ def _load_mcp_config() -> Dict[str, dict]:
|
||||
return {}
|
||||
config = load_config()
|
||||
servers = config.get("mcp_servers")
|
||||
if not servers or not isinstance(servers, dict):
|
||||
return {}
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
# Ensure .env vars are available for interpolation
|
||||
try:
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
@@ -4904,6 +4905,21 @@ def _load_mcp_config() -> Dict[str, dict]:
|
||||
if isinstance(interpolated, dict):
|
||||
_warn_hidden_whitespace(name, interpolated)
|
||||
safe_servers[name] = interpolated
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
|
||||
discover_plugins()
|
||||
portable = get_plugin_manager().get_portable_mcp_servers()
|
||||
for name, cfg in _filter_suspicious_mcp_servers(portable).items():
|
||||
if name in safe_servers:
|
||||
logger.warning(
|
||||
"Portable MCP server '%s' conflicts with native config; skipping",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
safe_servers[name] = dict(cfg)
|
||||
except Exception:
|
||||
logger.debug("Failed to load portable MCP servers", exc_info=True)
|
||||
return safe_servers
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to load MCP config: %s", exc)
|
||||
|
||||
+82
-10
@@ -801,18 +801,16 @@ def skills_list(category: str = None, task_id: str = None) -> str:
|
||||
active_skills_dir = _skills_dir()
|
||||
if not active_skills_dir.exists():
|
||||
active_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"skills": [],
|
||||
"categories": [],
|
||||
"message": f"No skills found. Skills directory created at {display_hermes_home()}/skills/",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# Find all skills
|
||||
all_skills = _find_all_skills()
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
|
||||
discover_plugins()
|
||||
all_skills.extend(get_plugin_manager().list_plugin_skill_metadata())
|
||||
except Exception:
|
||||
logger.debug("Plugin skill listing failed", exc_info=True)
|
||||
|
||||
if not all_skills:
|
||||
return json.dumps(
|
||||
@@ -859,6 +857,7 @@ def _serve_plugin_skill(
|
||||
skill_md: Path,
|
||||
namespace: str,
|
||||
bare: str,
|
||||
file_path: str | None = None,
|
||||
*,
|
||||
preprocess: bool = True,
|
||||
session_id: str | None = None,
|
||||
@@ -878,6 +877,59 @@ def _serve_plugin_skill(
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if file_path:
|
||||
from tools.path_security import has_traversal_component, validate_within_dir
|
||||
|
||||
skill_root = skill_md.parent
|
||||
if has_traversal_component(file_path):
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Path traversal ('..') is not allowed."},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
target = skill_root / file_path
|
||||
path_error = validate_within_dir(target, skill_root)
|
||||
if path_error:
|
||||
return json.dumps(
|
||||
{"success": False, "error": path_error}, ensure_ascii=False
|
||||
)
|
||||
if not target.is_file():
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"File '{file_path}' not found in skill '{namespace}:{bare}'.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"name": f"{namespace}:{bare}",
|
||||
"file": file_path,
|
||||
"content": f"[Binary file: {target.name}, size: {target.stat().st_size} bytes]",
|
||||
"is_binary": True,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Failed to read '{file_path}': {exc}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"name": f"{namespace}:{bare}",
|
||||
"file": file_path,
|
||||
"content": content,
|
||||
"file_type": target.suffix,
|
||||
"_source_path": str(target),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
@@ -952,13 +1004,32 @@ def _serve_plugin_skill(
|
||||
"name": f"{namespace}:{bare}",
|
||||
"content": f"{banner}{rendered_content}" if banner else rendered_content,
|
||||
"description": description,
|
||||
"linked_files": None,
|
||||
"linked_files": _plugin_skill_linked_files(skill_md.parent),
|
||||
"readiness_status": SkillReadinessStatus.AVAILABLE.value,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _plugin_skill_linked_files(skill_root: Path) -> Dict[str, List[str]] | None:
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
linked: Dict[str, List[str]] = {}
|
||||
for category in ("references", "templates", "assets", "scripts"):
|
||||
base = skill_root / category
|
||||
if not base.is_dir():
|
||||
continue
|
||||
files = [
|
||||
str(path.relative_to(skill_root))
|
||||
for path in sorted(base.rglob("*"))
|
||||
if path.is_file()
|
||||
and validate_within_dir(path, skill_root) is None
|
||||
]
|
||||
if files:
|
||||
linked[category] = files
|
||||
return linked or None
|
||||
|
||||
|
||||
def skill_view(
|
||||
name: str,
|
||||
file_path: str = None,
|
||||
@@ -1041,6 +1112,7 @@ def skill_view(
|
||||
plugin_skill_md,
|
||||
namespace,
|
||||
bare,
|
||||
file_path=file_path,
|
||||
preprocess=preprocess,
|
||||
session_id=task_id,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,60 @@ See the full [Pluggable interfaces table](/user-guide/features/plugins#pluggable
|
||||
Plugins that integrate **someone else's product or project** — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins — are built and distributed as **standalone plugin repos**, not merged into `NousResearch/hermes-agent`. Users install them into `~/.hermes/plugins/` or via a pip entry point; everything in this guide works the same way from a standalone repo. This is a coupling-and-maintenance decision (the core moves fast and we don't own your backend), not a quality bar — a plugin can be excellent and still belong in its own repo. Promote it in the Nous Research Discord `#plugins-skills-and-skins` channel. See [CONTRIBUTING.md](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) for the policy.
|
||||
:::
|
||||
|
||||
## Portable Agent Plugins v1 packages
|
||||
|
||||
Hermes can also install and load directory packages that target the Agent
|
||||
Plugins v1.0.0 format. This is a compatibility adapter for the portable
|
||||
components Hermes already owns. It does not replace native `plugin.yaml` plus
|
||||
`register(ctx)` plugins.
|
||||
|
||||
```text
|
||||
my-portable-plugin/
|
||||
├── plugin.json
|
||||
├── skills/
|
||||
│ └── summarize/
|
||||
│ ├── SKILL.md
|
||||
│ └── references/
|
||||
└── mcp.json
|
||||
```
|
||||
|
||||
Install and activate a portable package through the normal workflow:
|
||||
|
||||
```bash
|
||||
hermes plugins install owner/repository --no-enable
|
||||
hermes plugins list
|
||||
hermes plugins enable <plugin-name>
|
||||
```
|
||||
|
||||
Portable packages are disabled after installation unless you explicitly enable
|
||||
them. An enabled package may provide immediate `skills/*/SKILL.md` directories
|
||||
and stdio MCP servers from root `mcp.json`. Skills are read-only, namespaced,
|
||||
and loaded through `skills_list` plus `skill_view`. MCP commands are passed as
|
||||
one executable token with a separate argument list, never through a shell.
|
||||
|
||||
Hermes validates `plugin.json`, Agent Skills frontmatter, fixed component
|
||||
locations, `mcp.json`, resolved paths, and symlink containment locally. It does
|
||||
not fetch JSON schemas while loading a package. A bad skill or MCP entry is
|
||||
skipped at its own boundary when valid sibling components can still load.
|
||||
`PLUGIN_ROOT` points to the resolved package root. `PLUGIN_DATA` points to a
|
||||
profile-scoped writable directory managed by Hermes.
|
||||
Values declared in portable MCP `env` are visible package data, not a secret
|
||||
storage mechanism. Do not place credentials in `mcp.json`.
|
||||
|
||||
The current portable subset supports stdio MCP only. Portable Streamable HTTP
|
||||
and legacy SSE entries are reported and skipped because the native remote
|
||||
client does not yet prove the v1 configured-header redirect boundary end to
|
||||
end. Agent Plugins v1 does not define trust, permissions, provenance, or a
|
||||
sandbox. Enabling a package grants its instructions and local executable the
|
||||
same full-trust posture as other installed Hermes plugins.
|
||||
|
||||
The [rendered specification](https://agent-plugins.org/specification) currently
|
||||
labels v1.0.0 a Working Draft, while the
|
||||
[versioned specification repository](https://github.com/agentplugins/agent-plugins-spec/blob/main/spec/1.0.0.md)
|
||||
records it as Published. Hermes keys behavior on the canonical v1.0.0 schema
|
||||
identifiers and normative text, not either mutable status label. This is an
|
||||
explicit supported subset, not a claim of full Agent Plugins conformance.
|
||||
|
||||
## What you're building
|
||||
|
||||
A **calculator** plugin with two tools:
|
||||
@@ -353,8 +407,8 @@ hermes logs --level WARNING | grep -i plugin
|
||||
Common reasons a plugin doesn't appear:
|
||||
|
||||
- **Not enabled in config** — plugins are opt-in. Run `hermes plugins enable <name>` (the name comes from the `plugins list` output, which can be `<category>/<plugin>` for nested layouts).
|
||||
- **Wrong directory layout** — must be `~/.hermes/plugins/<plugin-name>/plugin.yaml` (flat) or `~/.hermes/plugins/<category>/<plugin-name>/plugin.yaml` (one level of category nesting, max). Anything deeper is ignored.
|
||||
- **Missing `__init__.py`** — the plugin directory needs both `plugin.yaml` and `__init__.py` with a `register(ctx)` function.
|
||||
- **Wrong directory layout:** Native packages use `~/.hermes/plugins/<plugin-name>/plugin.yaml` (flat) or one category level. Portable packages use root `plugin.json` in the same locations. Anything deeper is ignored.
|
||||
- **Missing `__init__.py`:** Native packages need both `plugin.yaml` and `__init__.py` with a `register(ctx)` function. Portable packages do not import Python and do not require `__init__.py`.
|
||||
- **Wrong `kind`** — gateway adapters need `kind: platform` in their manifest. Memory providers are auto-detected as `kind: exclusive` and routed through the `memory.provider` config instead of `plugins.enabled`.
|
||||
|
||||
## Your plugin's final structure
|
||||
|
||||
@@ -51,6 +51,25 @@ hermes -w # Interactive mode in worktree
|
||||
hermes -w -z "Fix issue #123" # Single query in worktree
|
||||
```
|
||||
|
||||
### Plugin management
|
||||
|
||||
The `hermes plugins` commands manage native Hermes plugins and portable Agent
|
||||
Plugins v1 packages through the same opt-in workflow:
|
||||
|
||||
```bash
|
||||
hermes plugins install owner/repository --no-enable
|
||||
hermes plugins list
|
||||
hermes plugins enable <plugin-name>
|
||||
hermes plugins disable <plugin-name>
|
||||
hermes plugins update <plugin-name>
|
||||
hermes plugins remove <plugin-name>
|
||||
```
|
||||
|
||||
Portable packages remain disabled until explicitly enabled. Hermes currently
|
||||
loads portable Agent Skills and stdio MCP entries. See the
|
||||
[plugin developer guide](/developer-guide/plugins#portable-agent-plugins-v1-packages)
|
||||
for the exact supported subset and trust boundary.
|
||||
|
||||
## Interface Layout
|
||||
|
||||
<img className="docs-terminal-figure" src="/docs/img/docs/cli-layout.svg" alt="Stylized preview of the Hermes CLI layout showing the banner, conversation area, and fixed input prompt." />
|
||||
|
||||
Reference in New Issue
Block a user