Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c4c54eb
feat(plugin): Add installed plugins management utilities
openhands-agent Feb 13, 2026
223cdc8
Merge branch 'main' into openhands/plugin-install-utils
enyst Mar 1, 2026
074ecf8
chore: fix pre-commit issues in installed plugin utils
openhands-agent Mar 1, 2026
ce64794
fix(plugin): validate installed plugin names and safe uninstall
openhands-agent Mar 1, 2026
3bd7632
chore(plugin): move installed plugins dir under ~/.openhands/plugins
openhands-agent Mar 1, 2026
077f6df
docs(examples): add local plugin install + update example
openhands-agent Mar 1, 2026
656b9e5
test: add PR-only local plugin install smoke test artifacts
openhands-agent Mar 1, 2026
29c7250
Apply suggestion from @enyst
enyst Mar 2, 2026
7408bc5
docs(examples): simplify local plugin install example script
openhands-agent Mar 2, 2026
1e4aec9
test(examples): consolidate local plugin install smoke test
openhands-agent Mar 2, 2026
5065d14
docs(.pr): document smoke test behavior and cleanup workflow
openhands-agent Mar 2, 2026
6c9803c
docs(.pr): clarify cleanup guarantee and smoke test summary
openhands-agent Mar 2, 2026
ab1857b
Apply suggestion from @enyst
enyst Mar 2, 2026
570d4a0
Apply suggestion from @enyst
enyst Mar 2, 2026
1ac3a72
chore: Remove PR-only artifacts [automated]
Mar 2, 2026
2076b67
Merge branch 'main' into openhands/plugin-install-utils
enyst Mar 2, 2026
76cbeba
chore(examples): avoid nested markdown fences in plugin install example
openhands-agent Mar 2, 2026
0fae727
refactor(plugin): Improve installed.py readability based on code review
openhands-agent Mar 2, 2026
a6521d6
Merge branch 'main' into openhands/plugin-install-utils
enyst Mar 3, 2026
001bbad
feat: add plugin install/uninstall functionality to example and tests
xingyaoww Mar 3, 2026
7725622
refactor: combine plugin tests and deduplicate
xingyaoww Mar 3, 2026
55746ba
fix: support root-level SKILL.md in plugins (anthropics/skills format)
xingyaoww Mar 3, 2026
5ca4880
tweak the prompt so it work as expected
xingyaoww Mar 3, 2026
d3f6e5c
Merge branch 'main' into openhands/plugin-install-utils
xingyaoww Mar 3, 2026
af4e8d2
fix: revert flat SKILL.md support, load proper plugins from anthropic…
openhands-agent Mar 3, 2026
b0603ac
Merge branch 'main' into openhands/plugin-install-utils
enyst Mar 4, 2026
c4180d8
test: derive skills plugin name
openhands-agent Mar 4, 2026
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
255 changes: 202 additions & 53 deletions examples/05_skills_and_plugins/02_loading_plugins/main.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,227 @@
"""Example: Loading Plugins via Conversation
"""Example: Loading and Managing Plugins

Demonstrates the recommended way to load plugins using the `plugins` parameter
on Conversation. Plugins bundle skills, hooks, and MCP config together.
This example demonstrates plugin loading and management in the SDK:

1. Loading plugins from GitHub via Conversation (PluginSource)
2. Installing plugins to persistent storage (local and GitHub)
3. Listing, loading, and uninstalling plugins

Plugins bundle skills, hooks, and MCP config together.

Supported plugin sources:
- Local path: /path/to/plugin
- GitHub shorthand: github:owner/repo
- Git URL: https://github.com/owner/repo.git
- With ref: branch, tag, or commit SHA
- With repo_path: subdirectory for monorepos

For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins
"""

import os
import sys
import tempfile
from pathlib import Path

from pydantic import SecretStr

from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.plugin import PluginSource
from openhands.sdk.plugin import (
PluginFetchError,
PluginSource,
install_plugin,
list_installed_plugins,
load_installed_plugins,
uninstall_plugin,
)
from openhands.sdk.tool import Tool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool


# Locate example plugin directory
script_dir = Path(__file__).parent
plugin_path = script_dir / "example_plugins" / "code-quality"

# Define plugins to load
# Supported sources: local path, "github:owner/repo", or git URL
# Optional: ref (branch/tag/commit), repo_path (for monorepos)
plugins = [
PluginSource(source=str(plugin_path)),
# PluginSource(source="github:org/security-plugin", ref="v2.0.0"),
# PluginSource(source="github:org/monorepo", repo_path="plugins/logging"),
]

# Check for API key
api_key = os.getenv("LLM_API_KEY")
if not api_key:
print("Set LLM_API_KEY to run this example")
print("EXAMPLE_COST: 0")
sys.exit(0)

# Configure LLM and Agent
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
llm = LLM(
usage_id="plugin-demo",
model=model,
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
)
agent = Agent(
llm=llm, tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)]
)
local_plugin_path = script_dir / "example_plugins" / "code-quality"

# Create conversation with plugins - skills, MCP config, and hooks are merged
# Note: Plugins are loaded lazily on first send_message() or run() call
with tempfile.TemporaryDirectory() as tmpdir:
conversation = Conversation(
agent=agent,
workspace=tmpdir,
plugins=plugins,
)

# Test: The "lint" keyword triggers the python-linting skill
# This first send_message() call triggers lazy plugin loading
conversation.send_message("How do I lint Python code? Brief answer please.")
def demo_conversation_with_github_plugin(llm: LLM) -> None:
"""Demo 1: Load plugin from GitHub via Conversation.

This demonstrates loading a plugin directly from GitHub using PluginSource.
The plugin is fetched and loaded lazily when the conversation starts.

We load the anthropics/skills repository which contains the "document-skills"
plugin with skills for pptx, xlsx, docx, and pdf document processing.
"""
print("\n" + "=" * 60)
print("DEMO 1: Loading plugin from GitHub via Conversation")
print("=" * 60)

# Load the anthropics/skills repository which contains the document-skills plugin
# This plugin bundles multiple document processing skills including pptx
plugins = [
PluginSource(
source="github:anthropics/skills",
ref="main",
),
]

# Verify skills were loaded from the plugin (after lazy loading)
skills = (
conversation.agent.agent_context.skills
if conversation.agent.agent_context
else []
agent = Agent(
llm=llm,
tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
)
print(f"Loaded {len(skills)} skill(s) from plugins")

conversation.run()
with tempfile.TemporaryDirectory() as tmpdir:
try:
conversation = Conversation(
agent=agent,
workspace=tmpdir,
plugins=plugins,
)

# Verify skills were loaded
skills = (
conversation.agent.agent_context.skills
if conversation.agent.agent_context
else []
)
print(f"✓ Loaded {len(skills)} skill(s) from GitHub plugin")
for skill in skills:
print(f" - {skill.name}")

# Ask a question that uses the pptx skill
conversation.send_message(
"What's the best way to create a PowerPoint presentation "
"programmatically? Check the skill before you answer."
)

conversation.run()

except PluginFetchError as e:
print(f"⚠ Could not fetch from GitHub: {e}")
print(" Skipping this demo (network or rate limiting issue)")


def demo_install_local_plugin(installed_dir: Path) -> None:
"""Demo 2: Install a plugin from a local path.

Useful for development or local-only plugins.
"""
print("\n" + "=" * 60)
print("DEMO 2: Installing plugin from local path")
print("=" * 60)

info = install_plugin(source=str(local_plugin_path), installed_dir=installed_dir)
print(f"✓ Installed: {info.name} v{info.version}")
print(f" Source: {info.source}")
print(f" Path: {info.install_path}")


def demo_install_github_plugin(installed_dir: Path) -> None:
"""Demo 3: Install a plugin from GitHub to persistent storage.

Demonstrates loading the anthropics/skills repository which contains
multiple document processing skills (pptx, xlsx, docx, pdf).
"""
print("\n" + "=" * 60)
print("DEMO 3: Installing plugin from GitHub")
print("=" * 60)

try:
# Install the anthropics/skills repository (contains document-skills plugin)
info = install_plugin(
source="github:anthropics/skills",
ref="main",
installed_dir=installed_dir,
)
print(f"✓ Installed: {info.name} v{info.version}")
print(f" Source: {info.source}")
print(f" Resolved ref: {info.resolved_ref}")

# Show the skills loaded from the plugin
plugins = load_installed_plugins(installed_dir=installed_dir)
for plugin in plugins:
if plugin.name == info.name:
skills = plugin.get_all_skills()
print(f" Skills: {len(skills)}")
for skill in skills[:5]: # Show first 5 skills
desc = skill.description or "(no description)"
print(f" - {skill.name}: {desc[:50]}...")
if len(skills) > 5:
print(f" ... and {len(skills) - 5} more skills")

except PluginFetchError as e:
print(f"⚠ Could not fetch from GitHub: {e}")
print(" (Network or rate limiting issue)")


def demo_list_and_load_plugins(installed_dir: Path) -> None:
"""Demo 4: List and load installed plugins."""
print("\n" + "=" * 60)
print("DEMO 4: List and load installed plugins")
print("=" * 60)

# List installed plugins
print("Installed plugins:")
for info in list_installed_plugins(installed_dir=installed_dir):
print(f" - {info.name} v{info.version} ({info.source})")

# Load plugins as Plugin objects
plugins = load_installed_plugins(installed_dir=installed_dir)
print(f"\nLoaded {len(plugins)} plugin(s):")
for plugin in plugins:
skills = plugin.get_all_skills()
print(f" - {plugin.name}: {len(skills)} skill(s)")


def demo_uninstall_plugins(installed_dir: Path) -> None:
"""Demo 5: Uninstall plugins."""
print("\n" + "=" * 60)
print("DEMO 5: Uninstalling plugins")
print("=" * 60)

for info in list_installed_plugins(installed_dir=installed_dir):
uninstall_plugin(info.name, installed_dir=installed_dir)
print(f"✓ Uninstalled: {info.name}")

remaining = list_installed_plugins(installed_dir=installed_dir)
print(f"\nRemaining plugins: {len(remaining)}")


# Main execution
if __name__ == "__main__":
api_key = os.getenv("LLM_API_KEY")
if not api_key:
print("Set LLM_API_KEY to run the full example")
print("Running install/uninstall demos only...")
llm = None
else:
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
llm = LLM(
usage_id="plugin-demo",
model=model,
api_key=SecretStr(api_key),
base_url=os.getenv("LLM_BASE_URL"),
)

with tempfile.TemporaryDirectory() as tmpdir:
installed_dir = Path(tmpdir) / "installed"
installed_dir.mkdir()

# Demo 1: Conversation with GitHub plugin (requires LLM)
if llm:
demo_conversation_with_github_plugin(llm)

# Demo 2-5: Plugin management (no LLM required)
demo_install_local_plugin(installed_dir)
demo_install_github_plugin(installed_dir)
demo_list_and_load_plugins(installed_dir)
demo_uninstall_plugins(installed_dir)

print("\n" + "=" * 60)
print("EXAMPLE COMPLETED SUCCESSFULLY")
print("=" * 60)

print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
if llm:
print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
else:
print("EXAMPLE_COST: 0")
24 changes: 24 additions & 0 deletions openhands-sdk/openhands/sdk/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,26 @@

It also provides support for plugin marketplaces - directories that list
available plugins with their metadata and source locations.

Additionally, it provides utilities for managing installed plugins in the
user's home directory (~/.openhands/plugins/installed/).
"""

from openhands.sdk.plugin.fetch import (
PluginFetchError,
fetch_plugin_with_resolution,
)
from openhands.sdk.plugin.installed import (
InstalledPluginInfo,
InstalledPluginsMetadata,
get_installed_plugin,
get_installed_plugins_dir,
install_plugin,
list_installed_plugins,
load_installed_plugins,
uninstall_plugin,
update_plugin,
)
from openhands.sdk.plugin.loader import load_plugins
from openhands.sdk.plugin.plugin import Plugin
from openhands.sdk.plugin.types import (
Expand Down Expand Up @@ -45,4 +59,14 @@
"MarketplacePluginEntry",
"MarketplacePluginSource",
"MarketplaceMetadata",
# Installed plugins management
"InstalledPluginInfo",
"InstalledPluginsMetadata",
"install_plugin",
"uninstall_plugin",
"list_installed_plugins",
"load_installed_plugins",
"get_installed_plugins_dir",
"get_installed_plugin",
"update_plugin",
]
Loading
Loading