Skip to content
9 changes: 5 additions & 4 deletions openhands-sdk/openhands/sdk/plugin/format/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
- ``base`` — the abstract :class:`PluginFormat` contract plus the shared
discovery logic (skills discovery, final assembly).
- ``claude_code`` — the concrete :class:`ClaudeCodePluginFormat` strategy.
- ``agent_plugins`` — the :class:`AgentPluginsFormat` strategy, not registered.
- ``agent_plugins`` — the :class:`AgentPluginsFormat` strategy, not registered;
it also owns our ``dev.openhands`` client-extension namespace.
- this package ``__init__`` — the format registry (``_FORMATS``) and the
:func:`detect_format` dispatcher.

Expand All @@ -22,8 +23,8 @@
fallback.

:class:`AgentPluginsFormat` is held out of ``_FORMATS`` until its ``mcp.json``
and client-extension loaders land (#4405): registering it earlier would claim
any directory with a root ``plugin.json`` and load it with zero MCP servers.
loader lands (#4405): registering it earlier would claim any directory with a
root ``plugin.json`` and load it with zero MCP servers.

How to add a new plugin format
------------------------------
Expand Down Expand Up @@ -61,7 +62,7 @@

# Registered formats, in detection-precedence order. Higher-precedence formats
# come first; the Claude Code format is last because it accepts any directory.
# AgentPluginsFormat is intentionally absent until its component loaders land
# AgentPluginsFormat is intentionally absent until its mcp.json loader lands
# (see the module docstring); it belongs ahead of ClaudeCodePluginFormat.
_FORMATS: list[type[PluginFormat]] = [ClaudeCodePluginFormat]

Expand Down
94 changes: 71 additions & 23 deletions openhands-sdk/openhands/sdk/plugin/format/agent_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@
from openhands.sdk.hooks import HookConfig
from openhands.sdk.logger import get_logger
from openhands.sdk.mcp.config import MCPServer
from openhands.sdk.plugin.format.base import PluginFormat
from openhands.sdk.plugin.format.base import (
PluginFormat,
_read_command_definitions,
_read_hooks_config,
)
from openhands.sdk.plugin.types import CommandDefinition, PluginManifest
from openhands.sdk.subagent.load import load_agents_from_dir
from openhands.sdk.subagent.schema import AgentDefinition


Expand All @@ -28,6 +33,12 @@
# At the plugin root, not nested as in the Claude Code layout.
MANIFEST_FILE: Final[str] = "plugin.json"

#: OpenHands' client-extension namespace (spec §8). Reverse-domain form of
#: openhands.dev, which we control. It names both the ``extensions`` member in
#: the manifest and the top-level extension *directory* holding our file-based
#: components (§8.2).
EXTENSION_NAMESPACE: Final[str] = "dev.openhands"

_SCHEMAS_DIR: Final[Path] = Path(__file__).parent / "schemas"

#: The only manifest ``$schema`` we support, and its vendored file. Agent
Expand All @@ -54,20 +65,21 @@ class AgentPluginsFormat(PluginFormat):
├── plugin.json # required manifest (root-level, closed schema)
├── skills/ # Agent Skills (optional)
├── mcp.json # MCP servers, no leading dot (optional)
└── com.example.client/ # reverse-domain client extensions (optional)
└── dev.openhands/ # our client extension directory (optional)
├── commands/
├── agents/
└── hooks/hooks.json
```

Only the manifest and skills are read today (skills via the shared
:meth:`PluginFormat.load_skills`). ``mcp.json`` and OpenHands' client
extension are follow-ups under #4405, so their loaders return empty --
spec-correct, since a client must ignore extensions it does not implement.
Hooks, commands, agents and ``entry_command`` are not portable core, so they
live in our client extension (§8): file-based components under the top-level
``dev.openhands/`` directory, and ``entry_command`` under
``extensions["dev.openhands"]`` in the manifest. Their on-disk layout inside
that directory is unchanged from the Claude Code format, so a plugin ports by
moving those directories rather than rewriting them. Namespaces other than
ours are ignored without validating their contents.

Hooks, commands, agents and ``entry_command`` are not portable core. The
standard's place for them is a client extension: a reverse-domain namespace
carrying manifest data under ``extensions.<ns>``, a top-level ``<ns>/``
directory, or both, whose contents the namespace owner defines. Which
namespace we claim and what goes where are still open in #4405, and nothing
here reads one yet.
``mcp.json`` is a follow-up under #4405, so its loader still returns empty.

Not registered in ``_FORMATS`` yet; see the package docstring.
"""
Expand All @@ -91,7 +103,9 @@ def load_manifest(self, plugin_dir: Path) -> PluginManifest:

Fields with no ``PluginManifest`` column (``$schema``, ``homepage``,
``repository``, ``license``, ``keywords``, ``extensions``) survive via
``extra="allow"``.
``extra="allow"``. Our own extension data is additionally projected onto
the matching ``PluginManifest`` fields, so downstream code reads
``manifest.entry_command`` regardless of the format it came from.

Raises:
ValueError: On any fatal manifest violation.
Expand Down Expand Up @@ -143,20 +157,54 @@ def load_manifest(self, plugin_dir: Path) -> PluginManifest:
f"Invalid Agent Plugins manifest {manifest_path}: {e.message}"
) from e

return PluginManifest.model_validate(data)
return PluginManifest.model_validate(
data | _extension_manifest_fields(data, manifest_path)
)

def load_mcp_config(self, plugin_dir: Path) -> dict[str, MCPServer]: # noqa: ARG002
"""Not read yet: root ``mcp.json`` is a follow-up."""
return {}

def load_hooks(self, plugin_dir: Path) -> HookConfig | None: # noqa: ARG002
"""Always None: hooks are not part of the Agent Plugins portable core."""
return None
def load_hooks(self, plugin_dir: Path) -> HookConfig | None:
"""Load hooks from ``dev.openhands/hooks/hooks.json``."""
return _read_hooks_config(plugin_dir / EXTENSION_NAMESPACE)

def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]:
"""Load agent definitions from ``dev.openhands/agents/``."""
return load_agents_from_dir(plugin_dir / EXTENSION_NAMESPACE / "agents")

def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]:
"""Load command definitions from ``dev.openhands/commands/``."""
return _read_command_definitions(plugin_dir / EXTENSION_NAMESPACE)


def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]: # noqa: ARG002
"""Always empty: agents are not part of the Agent Plugins portable core."""
return []
def _extension_manifest_fields(
data: dict[str, Any], manifest_path: Path
) -> dict[str, Any]:
"""Map our namespace's manifest data onto ``PluginManifest`` fields.

Only ``extensions["dev.openhands"]`` is read. Other namespaces are ignored
without inspecting their contents, as §8.1 requires -- a foreign namespace
carrying an ``entry_command`` of the wrong type is none of our business.

Within our own namespace we own the validation, and apply the same narrow
failure boundary as elsewhere: an unusable value is reported and ignored
rather than rejecting the plugin.
"""
# The manifest has passed validation, so ``extensions`` -- if still present
# -- is an object whose values are objects.
extension = data.get("extensions", {}).get(EXTENSION_NAMESPACE)
if not extension:
return {}

def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]: # noqa: ARG002
"""Always empty: commands are not part of the Agent Plugins portable core."""
return []
entry_command = extension.get("entry_command")
if entry_command is not None and not isinstance(entry_command, str):
logger.warning(
"Ignoring non-string '%s' entry_command in %s: %r",
EXTENSION_NAMESPACE,
manifest_path,
entry_command,
)
entry_command = None

return {"entry_command": entry_command} if entry_command else {}
59 changes: 59 additions & 0 deletions openhands-sdk/openhands/sdk/plugin/format/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,65 @@ def load(self, plugin_dir: Path) -> Plugin:
)


def _read_hooks_config(root: Path) -> HookConfig | None:
"""Read ``hooks/hooks.json`` under ``root``, or None if it is absent.

Shared by the concrete strategies: hooks, agents and commands use the same
on-disk layout in both formats — only the directory they are rooted at
differs (the plugin root for Claude Code, the client-extension directory for
Agent Plugins).
"""
hooks_json = root / "hooks" / "hooks.json"
if not hooks_json.exists():
return None

try:
hook_config = HookConfig.load(path=hooks_json)
# A hooks.json that parses but declares no hooks yields an empty config.
# Keep that distinct from "file not present" (None). An unparseable or
# schema-invalid file raises out of load() and is caught below.
if hook_config.is_empty():
logger.info(f"No hooks configured in {hooks_json}")
return HookConfig()
logger.info(f"Loaded hooks from {hooks_json}")
return hook_config
except Exception as e:
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
return None


def _read_command_definitions(root: Path) -> list[CommandDefinition]:
"""Read command definitions from the ``commands/`` directory under ``root``.

Commands have no counterpart to :func:`load_agents_from_dir`, so this is the
one loader of the three the plugin format still owns. It applies the same
file predicate, so ``commands/`` and ``agents/`` stay symmetric.
"""
commands_dir = root / "commands"
if not commands_dir.is_dir():
return []

commands: list[CommandDefinition] = []
for item in sorted(commands_dir.iterdir()):
if (
not item.is_dir()
and item.suffix.lower() == ".md"
and item.name
not in (
"README.md",
"readme.md",
)
):
try:
command = CommandDefinition.load(item)
commands.append(command)
logger.debug(f"Loaded command: {command.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load command from {item}: {e}")

return commands


def _load_skills_from_skills_dir(skills_dir: Path) -> list[Skill]:
"""Load every skill under a plugin's ``skills/`` directory."""
skills: list[Skill] = []
Expand Down
57 changes: 9 additions & 48 deletions openhands-sdk/openhands/sdk/plugin/format/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@
from openhands.sdk.hooks import HookConfig
from openhands.sdk.logger import get_logger
from openhands.sdk.mcp.config import MCPServer, coerce_mcp_config
from openhands.sdk.plugin.format.base import PluginFormat
from openhands.sdk.plugin.format.base import (
PluginFormat,
_read_command_definitions,
_read_hooks_config,
)
from openhands.sdk.plugin.types import (
CommandDefinition,
PluginAuthor,
PluginManifest,
)
from openhands.sdk.skills.utils import load_mcp_config
from openhands.sdk.subagent.load import load_agents_from_dir
from openhands.sdk.subagent.schema import AgentDefinition


Expand Down Expand Up @@ -140,56 +145,12 @@ def load_mcp_config(self, plugin_dir: Path) -> dict[str, MCPServer]:

def load_hooks(self, plugin_dir: Path) -> HookConfig | None:
"""Load hooks configuration from ``hooks/hooks.json``."""
hooks_json = plugin_dir / "hooks" / "hooks.json"
if not hooks_json.exists():
return None

try:
hook_config = HookConfig.load(path=hooks_json)
# If hooks.json exists but is invalid, HookConfig.load() returns an
# empty config and logs the validation error. Keep that distinct from
# "file not present" (None).
if hook_config.is_empty():
logger.info(f"No hooks configured in {hooks_json}")
return HookConfig()
logger.info(f"Loaded hooks from {hooks_json}")
return hook_config
except Exception as e:
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
return None
return _read_hooks_config(plugin_dir)

def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]:
"""Load agent definitions from the ``agents/`` directory."""
agents_dir = plugin_dir / "agents"
if not agents_dir.is_dir():
return []

agents: list[AgentDefinition] = []
for item in sorted(agents_dir.iterdir()):
if item.suffix == ".md" and item.name.lower() != "readme.md":
try:
agent = AgentDefinition.load(item)
agents.append(agent)
logger.debug(f"Loaded agent: {agent.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load agent from {item}: {e}")

return agents
return load_agents_from_dir(plugin_dir / "agents")

def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]:
"""Load command definitions from the ``commands/`` directory."""
commands_dir = plugin_dir / "commands"
if not commands_dir.is_dir():
return []

commands: list[CommandDefinition] = []
for item in sorted(commands_dir.iterdir()):
if item.suffix == ".md" and item.name.lower() != "readme.md":
try:
command = CommandDefinition.load(item)
commands.append(command)
logger.debug(f"Loaded command: {command.name} from {item}")
except Exception as e:
logger.warning(f"Failed to load command from {item}: {e}")

return commands
return _read_command_definitions(plugin_dir)
Loading
Loading