Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions openhands-sdk/openhands/sdk/plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
)
from openhands.sdk.skills.skill import Skill
from openhands.sdk.skills.utils import (
discover_skill_resources,
find_skill_md,
load_mcp_config,
)
Expand Down Expand Up @@ -374,24 +373,41 @@ def _load_manifest(plugin_dir: Path) -> PluginManifest:


def _load_skills(plugin_dir: Path) -> list[Skill]:
"""Load skills from the skills/ directory.
"""Load a plugin's skills.

Supports both Claude Code plugin skill layouts:

- Multi-skill: a ``skills/`` directory containing one ``<name>/SKILL.md``
per skill (or single ``.md`` files).
- Single-skill: a ``SKILL.md`` at the plugin root when there is no
``skills/`` directory. Claude Code loads such a plugin as a single-skill
plugin (v2.1.142+); this mirrors that behavior so standalone Agent Skills
Comment thread
enyst marked this conversation as resolved.
Resolved
published as plugins load without an extra nesting level.

Note: Plugin skills are loaded with relaxed validation (strict=False)
to support Claude Code plugins which may use different naming conventions.
"""
skills_dir = plugin_dir / "skills"
if not skills_dir.is_dir():
return []
if skills_dir.is_dir():
return _load_skills_from_skills_dir(skills_dir)

root_skill_md = find_skill_md(plugin_dir)
if root_skill_md is not None:
return _load_root_skill(plugin_dir, root_skill_md)

return []


def _load_skills_from_skills_dir(skills_dir: Path) -> list[Skill]:
"""Load every skill under a plugin's ``skills/`` directory."""
skills: list[Skill] = []
for item in sorted(skills_dir.iterdir()):
if item.is_dir():
skill_md = find_skill_md(item)
if skill_md:
try:
# Skill.load() discovers resources, no need to do it again
skill = Skill.load(skill_md, skills_dir, strict=False)
# Discover and attach resources
skill.resources = discover_skill_resources(item)
skills.append(skill)
logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
except Exception as e:
Expand All @@ -408,6 +424,26 @@ def _load_skills(plugin_dir: Path) -> list[Skill]:
return skills


def _load_root_skill(plugin_dir: Path, skill_md: Path) -> list[Skill]:
"""Load a single-skill plugin whose ``SKILL.md`` lives at the plugin root.

For root skills, the plugin directory is the skill root, so .mcp.json at the
plugin level is the same file that Skill.load() would try to load. We pass
skip_mcp=True to avoid double-loading with different semantics (plugin-level
uses expand_defaults=False for deferred secret expansion; skill-level would
use expand_defaults=True and raise on validation errors).
"""
try:
# skip_mcp=True: Plugin-level MCP already loaded
# Skill.load() discovers resources, no need to do it again
skill = Skill.load(skill_md, plugin_dir, strict=False, skip_mcp=True)
logger.debug(f"Loaded single-skill plugin: {skill.name} from {skill_md}")
return [skill]
except Exception as e:
logger.warning(f"Failed to load root skill from {plugin_dir}: {e}")
return []


def _load_hooks(plugin_dir: Path) -> HookConfig | None:
"""Load hooks configuration from hooks/hooks.json."""
hooks_json = plugin_dir / "hooks" / "hooks.json"
Expand Down
20 changes: 14 additions & 6 deletions openhands-sdk/openhands/sdk/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ def load(
path: str | Path,
skill_base_dir: Path | None = None,
strict: bool = True,
skip_mcp: bool = False,
) -> "Skill":
"""Load a skill from a markdown file with frontmatter.

Expand All @@ -313,27 +314,33 @@ def load(
skill_base_dir: Base directory for skills (used to derive relative names).
strict: If True, enforce strict AgentSkills name validation.
If False, allow relaxed naming (e.g., for plugin compatibility).
skip_mcp: If True, skip loading MCP configuration from .mcp.json.
Used when loading root plugin skills where MCP config is already
loaded at the plugin level to avoid double-loading.
"""
path = Path(path) if isinstance(path, str) else path

with open(path, encoding="utf-8") as f:
file_content = f.read()

if path.name.lower() == "skill.md":
return cls._load_agentskills_skill(path, file_content, strict=strict)
return cls._load_agentskills_skill(
path, file_content, strict=strict, skip_mcp=skip_mcp
)
else:
return cls._load_legacy_openhands_skill(path, file_content, skill_base_dir)

@classmethod
def _load_agentskills_skill(
cls, path: Path, file_content: str, strict: bool = True
cls, path: Path, file_content: str, strict: bool = True, skip_mcp: bool = False
) -> "Skill":
"""Load a skill from an AgentSkills-format SKILL.md file.

Args:
path: Path to the SKILL.md file.
file_content: Content of the file.
strict: If True, enforce strict AgentSkills name validation.
skip_mcp: If True, skip loading MCP configuration from .mcp.json.
"""
# For SKILL.md files, use parent directory name as the skill name
directory_name = path.parent.name
Expand All @@ -357,10 +364,11 @@ def _load_agentskills_skill(

# Load MCP configuration from .mcp.json (agent_skills ONLY use .mcp.json)
mcp_tools: dict[str, MCPServer] | None = None
mcp_json_path = find_mcp_config(skill_root)
if mcp_json_path:
mcp_config = load_mcp_config(mcp_json_path, skill_root)
mcp_tools = coerce_mcp_config(mcp_config.get("mcpServers", {}))
if not skip_mcp:
mcp_json_path = find_mcp_config(skill_root)
if mcp_json_path:
mcp_config = load_mcp_config(mcp_json_path, skill_root)
mcp_tools = coerce_mcp_config(mcp_config.get("mcpServers", {}))

# Discover resource directories
resources: SkillResources | None = None
Expand Down
Loading
Loading