Skip to content

Commit af4e8d2

Browse files
fix: revert flat SKILL.md support, load proper plugins from anthropics/skills
The previous changes added support for 'flat' SKILL.md format where a single AgentSkill directory was treated as a plugin. Per discussion in #2272, this conflates AgentSkills with Plugins and creates confusion. Changes: - Revert _load_skills() to only check skills/ subdirectory (no root fallback) - Remove flat SKILL.md tests from test_installed_plugins.py - Update example script and tests to load full anthropics/skills repository which contains proper plugins with skills/ subdirectories (document-skills plugin with pptx, xlsx, docx, pdf skills) The anthropics/skills repo marketplace.json lists 'document-skills' and 'example-skills' as the actual plugins, not individual AgentSkill folders. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent d3f6e5c commit af4e8d2

File tree

3 files changed

+62
-159
lines changed

3 files changed

+62
-159
lines changed

examples/05_skills_and_plugins/02_loading_plugins/main.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,19 @@ def demo_conversation_with_github_plugin(llm: LLM) -> None:
4848
4949
This demonstrates loading a plugin directly from GitHub using PluginSource.
5050
The plugin is fetched and loaded lazily when the conversation starts.
51+
52+
We load the anthropics/skills repository which contains the "document-skills"
53+
plugin with skills for pptx, xlsx, docx, and pdf document processing.
5154
"""
5255
print("\n" + "=" * 60)
5356
print("DEMO 1: Loading plugin from GitHub via Conversation")
5457
print("=" * 60)
5558

56-
# Load the pptx skill from anthropics/skills repository
57-
# This skill helps create PowerPoint presentations
59+
# Load the anthropics/skills repository which contains the document-skills plugin
60+
# This plugin bundles multiple document processing skills including pptx
5861
plugins = [
5962
PluginSource(
6063
source="github:anthropics/skills",
61-
repo_path="skills/pptx",
6264
ref="main",
6365
),
6466
]
@@ -76,12 +78,6 @@ def demo_conversation_with_github_plugin(llm: LLM) -> None:
7678
plugins=plugins,
7779
)
7880

79-
# Ask a question that uses the pptx skill
80-
conversation.send_message(
81-
"What's the best way to create a PowerPoint presentation "
82-
"programmatically? Check the skill before you answer."
83-
)
84-
8581
# Verify skills were loaded
8682
skills = (
8783
conversation.agent.agent_context.skills
@@ -92,6 +88,12 @@ def demo_conversation_with_github_plugin(llm: LLM) -> None:
9288
for skill in skills:
9389
print(f" - {skill.name}")
9490

91+
# Ask a question that uses the pptx skill
92+
conversation.send_message(
93+
"What's the best way to create a PowerPoint presentation "
94+
"programmatically? Check the skill before you answer."
95+
)
96+
9597
conversation.run()
9698

9799
except PluginFetchError as e:
@@ -117,17 +119,17 @@ def demo_install_local_plugin(installed_dir: Path) -> None:
117119
def demo_install_github_plugin(installed_dir: Path) -> None:
118120
"""Demo 3: Install a plugin from GitHub to persistent storage.
119121
120-
Demonstrates the github:owner/repo shorthand with repo_path for monorepos.
122+
Demonstrates loading the anthropics/skills repository which contains
123+
multiple document processing skills (pptx, xlsx, docx, pdf).
121124
"""
122125
print("\n" + "=" * 60)
123126
print("DEMO 3: Installing plugin from GitHub")
124127
print("=" * 60)
125128

126129
try:
127-
# Install from anthropics/skills repository
130+
# Install the anthropics/skills repository (contains document-skills plugin)
128131
info = install_plugin(
129132
source="github:anthropics/skills",
130-
repo_path="skills/pptx",
131133
ref="main",
132134
installed_dir=installed_dir,
133135
)
@@ -141,9 +143,11 @@ def demo_install_github_plugin(installed_dir: Path) -> None:
141143
if plugin.name == info.name:
142144
skills = plugin.get_all_skills()
143145
print(f" Skills: {len(skills)}")
144-
for skill in skills:
146+
for skill in skills[:5]: # Show first 5 skills
145147
desc = skill.description or "(no description)"
146148
print(f" - {skill.name}: {desc[:50]}...")
149+
if len(skills) > 5:
150+
print(f" ... and {len(skills) - 5} more skills")
147151

148152
except PluginFetchError as e:
149153
print(f"⚠ Could not fetch from GitHub: {e}")

openhands-sdk/openhands/sdk/plugin/plugin.py

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -385,52 +385,36 @@ def _load_manifest(plugin_dir: Path) -> PluginManifest:
385385

386386

387387
def _load_skills(plugin_dir: Path) -> list[Skill]:
388-
"""Load skills from the plugin directory.
389-
390-
Supports two formats:
391-
1. SDK format: skills in skills/<name>/SKILL.md subdirectories
392-
2. Flat format: single SKILL.md at plugin root (e.g., anthropics/skills)
388+
"""Load skills from the skills/ directory.
393389
394390
Note: Plugin skills are loaded with relaxed validation (strict=False)
395391
to support Claude Code plugins which may use different naming conventions.
396392
"""
397-
skills: list[Skill] = []
398-
399-
# Check for skills/ subdirectory (SDK format)
400393
skills_dir = plugin_dir / "skills"
401-
if skills_dir.is_dir():
402-
for item in skills_dir.iterdir():
403-
if item.is_dir():
404-
skill_md = find_skill_md(item)
405-
if skill_md:
406-
try:
407-
skill = Skill.load(skill_md, skills_dir, strict=False)
408-
# Discover and attach resources
409-
skill.resources = discover_skill_resources(item)
410-
skills.append(skill)
411-
logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
412-
except Exception as e:
413-
logger.warning(f"Failed to load skill from {item}: {e}")
414-
elif item.suffix == ".md" and item.name.lower() != "readme.md":
415-
# Also support single .md files in skills/ directory
394+
if not skills_dir.is_dir():
395+
return []
396+
397+
skills: list[Skill] = []
398+
for item in skills_dir.iterdir():
399+
if item.is_dir():
400+
skill_md = find_skill_md(item)
401+
if skill_md:
416402
try:
417-
skill = Skill.load(item, skills_dir, strict=False)
403+
skill = Skill.load(skill_md, skills_dir, strict=False)
404+
# Discover and attach resources
405+
skill.resources = discover_skill_resources(item)
418406
skills.append(skill)
419-
logger.debug(f"Loaded skill: {skill.name} from {item}")
407+
logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
420408
except Exception as e:
421409
logger.warning(f"Failed to load skill from {item}: {e}")
422-
423-
# Fallback: check for root-level SKILL.md (flat format, e.g., anthropics/skills)
424-
if not skills:
425-
root_skill_md = find_skill_md(plugin_dir)
426-
if root_skill_md:
410+
elif item.suffix == ".md" and item.name.lower() != "readme.md":
411+
# Also support single .md files in skills/ directory
427412
try:
428-
skill = Skill.load(root_skill_md, plugin_dir, strict=False)
429-
skill.resources = discover_skill_resources(plugin_dir)
413+
skill = Skill.load(item, skills_dir, strict=False)
430414
skills.append(skill)
431-
logger.debug(f"Loaded root skill: {skill.name} from {root_skill_md}")
415+
logger.debug(f"Loaded skill: {skill.name} from {item}")
432416
except Exception as e:
433-
logger.warning(f"Failed to load root skill from {root_skill_md}: {e}")
417+
logger.warning(f"Failed to load skill from {item}: {e}")
434418

435419
return skills
436420

tests/sdk/plugin/test_installed_plugins.py

Lines changed: 27 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -470,100 +470,6 @@ def test_update_nonexistent_plugin(installed_dir: Path) -> None:
470470
assert info is None
471471

472472

473-
# ============================================================================
474-
# Root-Level SKILL.md Tests (flat format support)
475-
# ============================================================================
476-
477-
478-
def test_load_plugin_with_root_skill_md(tmp_path: Path) -> None:
479-
"""Test loading a plugin with SKILL.md at the root (flat format).
480-
481-
This tests support for the anthropics/skills format where SKILL.md
482-
is at the plugin root instead of in a skills/ subdirectory.
483-
"""
484-
plugin_dir = tmp_path / "flat-plugin"
485-
plugin_dir.mkdir(parents=True)
486-
487-
# Create plugin manifest
488-
(plugin_dir / ".plugin").mkdir()
489-
(plugin_dir / ".plugin" / "plugin.json").write_text(
490-
json.dumps(
491-
{
492-
"name": "flat-plugin",
493-
"version": "1.0.0",
494-
"description": "A flat-format plugin",
495-
}
496-
)
497-
)
498-
499-
# Create SKILL.md at root (not in skills/ subdirectory)
500-
(plugin_dir / "SKILL.md").write_text(
501-
"""---
502-
name: flat-skill
503-
description: A skill at the root level
504-
---
505-
# Flat Skill
506-
507-
This skill is at the plugin root, not in skills/ subdirectory.
508-
"""
509-
)
510-
511-
# Load the plugin
512-
plugin = Plugin.load(plugin_dir)
513-
514-
assert plugin.name == "flat-plugin"
515-
skills = plugin.get_all_skills()
516-
assert len(skills) == 1
517-
assert skills[0].name == "flat-skill"
518-
519-
520-
def test_load_plugin_prefers_skills_dir_over_root(tmp_path: Path) -> None:
521-
"""Test that skills/ directory takes precedence over root SKILL.md."""
522-
plugin_dir = tmp_path / "mixed-plugin"
523-
plugin_dir.mkdir(parents=True)
524-
525-
# Create plugin manifest
526-
(plugin_dir / ".plugin").mkdir()
527-
(plugin_dir / ".plugin" / "plugin.json").write_text(
528-
json.dumps(
529-
{
530-
"name": "mixed-plugin",
531-
"version": "1.0.0",
532-
"description": "Plugin with both formats",
533-
}
534-
)
535-
)
536-
537-
# Create SKILL.md at root
538-
(plugin_dir / "SKILL.md").write_text(
539-
"""---
540-
name: root-skill
541-
description: Skill at root
542-
---
543-
Root skill content.
544-
"""
545-
)
546-
547-
# Create skill in skills/ subdirectory
548-
skills_dir = plugin_dir / "skills" / "nested-skill"
549-
skills_dir.mkdir(parents=True)
550-
(skills_dir / "SKILL.md").write_text(
551-
"""---
552-
name: nested-skill
553-
description: Skill in skills/ directory
554-
---
555-
Nested skill content.
556-
"""
557-
)
558-
559-
# Load the plugin - should prefer skills/ directory
560-
plugin = Plugin.load(plugin_dir)
561-
562-
skills = plugin.get_all_skills()
563-
assert len(skills) == 1
564-
assert skills[0].name == "nested-skill" # Not root-skill
565-
566-
567473
# ============================================================================
568474
# Integration Tests (Real GitHub)
569475
# ============================================================================
@@ -619,39 +525,48 @@ def test_install_from_github_with_ref(installed_dir: Path) -> None:
619525

620526

621527
@pytest.mark.network
622-
def test_install_from_anthropic_skills(installed_dir: Path) -> None:
623-
"""Test installing a skill from anthropics/skills repository.
528+
def test_install_document_skills_plugin(installed_dir: Path) -> None:
529+
"""Test installing the document-skills plugin from anthropics/skills repository.
624530
625-
This tests the Claude Code skill format where SKILL.md is at the root.
626-
The SDK should detect and load the root-level SKILL.md as the plugin's skill.
531+
This tests loading a proper Claude Code plugin which bundles multiple skills
532+
(xlsx, docx, pptx, pdf) in the skills/ subdirectory.
627533
"""
628534
try:
629535
info = install_plugin(
630536
source="github:anthropics/skills",
631-
repo_path="skills/pptx",
632537
ref="main",
633538
installed_dir=installed_dir,
634539
)
635540

636-
assert info.name == "pptx"
541+
assert info.name == "anthropic-agent-skills"
637542
assert info.source == "github:anthropics/skills"
638-
assert info.repo_path == "skills/pptx"
639543

544+
# Verify the plugin directory has the expected structure
640545
install_path = Path(info.install_path)
641-
skill_md = install_path / "SKILL.md"
642-
assert skill_md.exists()
546+
skills_dir = install_path / "skills"
547+
assert skills_dir.is_dir()
643548

644-
content = skill_md.read_text()
645-
assert "name: pptx" in content
646-
assert "description:" in content
549+
# Check that the expected skill directories exist
550+
for skill_name in ["pptx", "xlsx", "docx", "pdf"]:
551+
skill_dir = skills_dir / skill_name
552+
assert skill_dir.is_dir(), f"Expected skill directory: {skill_name}"
553+
skill_md = skill_dir / "SKILL.md"
554+
assert skill_md.exists(), f"Expected SKILL.md in {skill_name}"
647555

648-
# Verify the skill is loaded (tests root-level SKILL.md support)
556+
# Verify skills are loaded from the plugin
649557
plugins = load_installed_plugins(installed_dir=installed_dir)
650-
pptx_plugin = next((p for p in plugins if p.name == "pptx"), None)
651-
assert pptx_plugin is not None
652-
skills = pptx_plugin.get_all_skills()
653-
assert len(skills) == 1
654-
assert skills[0].name == "pptx"
558+
doc_plugin = next(
559+
(p for p in plugins if p.name == "anthropic-agent-skills"), None
560+
)
561+
assert doc_plugin is not None
562+
skills = doc_plugin.get_all_skills()
563+
# Should have at least the 4 document skills
564+
assert len(skills) >= 4
565+
skill_names = {s.name for s in skills}
566+
assert "pptx" in skill_names
567+
assert "xlsx" in skill_names
568+
assert "docx" in skill_names
569+
assert "pdf" in skill_names
655570

656571
except PluginFetchError:
657572
pytest.skip("GitHub not accessible (network issue)")

0 commit comments

Comments
 (0)