feat(plugins): load portable agent components

This commit is contained in:
GodsBoy
2026-08-06 22:05:45 +02:00
committed by Teknium
parent c5117655b6
commit ca78c6d7a6
14 changed files with 576 additions and 32 deletions
+12 -1
View File
@@ -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)
+6 -1
View File
@@ -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
View File
@@ -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)
+56 -9
View File
@@ -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:
+4 -1
View File
@@ -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")
+19 -1
View File
@@ -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):
+88
View File
@@ -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"
+27
View File
@@ -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",
)
+37
View File
@@ -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,
+28 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+56 -2
View File
@@ -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
+19
View File
@@ -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." />