From 8691ac9568e6d04b49fee781f24c4c8073b4c110 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 2 Mar 2026 11:38:34 +0000 Subject: [PATCH 1/2] docs(sdk): document local plugin install example Co-authored-by: openhands --- sdk/guides/plugins.mdx | 181 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index 2e7b3fe5..c4e5125a 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -283,6 +283,187 @@ with tempfile.TemporaryDirectory() as tmpdir: +## Install + Manage Plugins Locally + +The SDK also includes utilities for managing *installed plugins*. + +Installed plugins are copied into an installation root (defaults to `~/.openhands/plugins/installed/`) and tracked in an `.installed.json` metadata file. + +This is intended as a building block for higher-level workflows (for example, a future `/plugin install ...` CLI command). + + +This example is available on GitHub: [examples/05_skills_and_plugins/03_local_plugin_install/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_local_plugin_install/main.py) + + +```python icon="python" expandable examples/05_skills_and_plugins/03_local_plugin_install/main.py +"""Example: Install + manage a local plugin (no external LLM required). + +This example demonstrates the *installed plugins* utilities introduced in this PR. + +Key ideas: +- Installed plugin packages live under `~/.openhands/plugins/installed/` by default. +- Each plugin is a self-contained directory that can include `skills/`, `agents/`, + `hooks/`, `.mcp.json`, etc. (Claude Code style). + +By default this example uses a temporary directory and leaves no artifacts. + +To write artifacts to disk (useful for PR review), set: + + export OPENHANDS_EXAMPLE_ARTIFACT_DIR=.pr/local_plugin_install_test + +Then run: + + uv run python examples/05_skills_and_plugins/03_local_plugin_install/main.py + +This will create (and overwrite) `plugin_src/`, `installed_root/`, and +`persistence/` under the artifact directory. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import uuid +from contextlib import ExitStack +from pathlib import Path + +from openhands.sdk import Agent, Conversation +from openhands.sdk.llm import Message, TextContent +from openhands.sdk.plugin import ( + get_installed_plugin, + install_plugin, + list_installed_plugins, + load_installed_plugins, + uninstall_plugin, + update_plugin, +) +from openhands.sdk.testing import TestLLM + + +def _write_example_plugin(plugin_dir: Path, *, version: str) -> None: + (plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True) + (plugin_dir / ".plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "local-plugin", + "version": version, + "description": "Example local plugin", + } + ) + ) + + skill_dir = plugin_dir / "skills" / "hello" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: hello +description: Say hello +triggers: + - hello +--- + +Reply with a short greeting. +""" + ) + + +artifact_dir = os.getenv("OPENHANDS_EXAMPLE_ARTIFACT_DIR") + +with ExitStack() as stack: + if artifact_dir: + root = Path(artifact_dir).resolve() + root.mkdir(parents=True, exist_ok=True) + + for subdir in ("plugin_src", "installed_root", "persistence"): + shutil.rmtree(root / subdir, ignore_errors=True) + else: + tmp_dir = stack.enter_context(tempfile.TemporaryDirectory()) + root = Path(tmp_dir) + + # Create a local plugin directory (this simulates a repo checkout). + plugin_source_dir = root / "plugin_src" / "local-plugin" + _write_example_plugin(plugin_source_dir, version="1.0.0") + + # Install into a dedicated root (avoids touching real ~/.openhands/). + installed_dir = root / "installed_root" / "plugins" / "installed" + + info = install_plugin(source=str(plugin_source_dir), installed_dir=installed_dir) + print(f"Installed: {info.name} v{info.version} from {info.source}") + + print("\nList installed plugins:") + for item in list_installed_plugins(installed_dir=installed_dir): + print(f"- {item.name} v{item.version} ({item.source})") + + print("\nLoad installed plugins:") + plugins = load_installed_plugins(installed_dir=installed_dir) + for plugin in plugins: + print(f"- {plugin.name}: {len(plugin.get_all_skills())} skill(s)") + + print("\nGet installed plugin:") + print(get_installed_plugin("local-plugin", installed_dir=installed_dir)) + + # Smoke test: run a minimal Conversation with persistence enabled. + # + # We use TestLLM so this runs without external services. The plugin skill + # is triggered by the user message "hello". + agent = Agent( + llm=TestLLM.from_messages( + [Message(role="assistant", content=[TextContent(text="Done")])] + ), + tools=[], + ) + + merged_context = agent.agent_context + for plugin in plugins: + merged_context = plugin.add_skills_to(merged_context) + agent = agent.model_copy(update={"agent_context": merged_context}) + + persistence_dir = root / "persistence" + conversation_id = ( + uuid.UUID("00000000-0000-0000-0000-000000000203") + if artifact_dir + else uuid.uuid4() + ) + conversation = Conversation( + agent=agent, + workspace=str(root), + persistence_dir=persistence_dir, + conversation_id=conversation_id, + visualizer=None, + ) + conversation.send_message("hello") + conversation.run() + + print(f"\nActivated skills: {conversation.state.activated_knowledge_skills}") + print(f"Wrote persistence to: {conversation.state.persistence_dir}") + + # Don't leave transient lock files in persisted artifacts. + if conversation.state.persistence_dir: + lockfile = ( + Path(conversation.state.persistence_dir) / "events" / ".eventlog.lock" + ) + lockfile.unlink(missing_ok=True) + + # Update: mutate the local plugin source and call update_plugin(), which + # reinstalls from the original source with ref=None (latest). + _write_example_plugin(plugin_source_dir, version="1.0.1") + updated = update_plugin("local-plugin", installed_dir=installed_dir) + assert updated is not None + print(f"\nUpdated: {updated.name} v{updated.version}") + + if artifact_dir: + print("\nSkipping uninstall (artifact mode)") + else: + uninstall_plugin("local-plugin", installed_dir=installed_dir) + print("\nAfter uninstall:") + print(list_installed_plugins(installed_dir=installed_dir)) + +print("EXAMPLE_COST: 0") +``` + + ## Next Steps From 2a5b8a07631fd0f1a15077ea421f03ccae79333c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 3 Mar 2026 10:29:41 +0000 Subject: [PATCH 2/2] docs(sdk): update plugin docs for existing example - Update Ready-to-run Example with current 02_loading_plugins/main.py code - Add succinct 'Installing Plugins to Persistent Storage' section - Remove reference to non-existent 03_local_plugin_install example Co-authored-by: openhands --- sdk/guides/plugins.mdx | 371 +++++++++++++++++++---------------------- 1 file changed, 170 insertions(+), 201 deletions(-) diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index c4e5125a..e2c7d7f7 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -196,28 +196,48 @@ Brief explanation on how to use a plugin with an agent. ## Ready-to-run Example +The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, update, uninstall). + This example is available on GitHub: [examples/05_skills_and_plugins/02_loading_plugins/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/02_loading_plugins/main.py) ```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py -"""Example: Loading Plugins via Conversation +"""Example: Loading and Managing Plugins + +This example demonstrates plugin loading and management in the SDK: + +1. Loading plugins via Conversation (PluginSource) +2. Installing plugins to persistent storage +3. Listing, updating, and uninstalling plugins + +Plugins bundle skills, hooks, and MCP config together. -Demonstrates the recommended way to load plugins using the `plugins` parameter -on Conversation. 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 @@ -225,246 +245,195 @@ 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.") - # Verify skills were loaded from the plugin (after lazy loading) - skills = ( - conversation.agent.agent_context.skills - if conversation.agent.agent_context - else [] - ) - print(f"Loaded {len(skills)} skill(s) from plugins") +def demo_conversation_with_plugins(llm: LLM) -> None: + """Demo 1: Load plugins via Conversation's plugins parameter. - conversation.run() + This is the recommended way to use plugins - they are loaded lazily + when the conversation starts. + """ + print("\n" + "=" * 60) + print("DEMO 1: Loading plugins via Conversation") + print("=" * 60) - print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") -``` + # Define plugins to load + plugins = [ + PluginSource(source=str(local_plugin_path)), + # Examples of other sources: + # PluginSource(source="github:owner/repo", ref="v1.0.0"), + # PluginSource(source="github:owner/monorepo", repo_path="plugins/my-plugin"), + ] - + agent = Agent( + llm=llm, + tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)], + ) -## Install + Manage Plugins Locally + with tempfile.TemporaryDirectory() as tmpdir: + conversation = Conversation( + agent=agent, + workspace=tmpdir, + plugins=plugins, + ) -The SDK also includes utilities for managing *installed plugins*. + # The "lint" keyword triggers the python-linting skill + conversation.send_message("How do I lint Python code? Brief answer please.") -Installed plugins are copied into an installation root (defaults to `~/.openhands/plugins/installed/`) and tracked in an `.installed.json` metadata file. + # Verify skills were loaded + skills = ( + conversation.agent.agent_context.skills + if conversation.agent.agent_context + else [] + ) + print(f"✓ Loaded {len(skills)} skill(s) from plugins") -This is intended as a building block for higher-level workflows (for example, a future `/plugin install ...` CLI command). + conversation.run() - -This example is available on GitHub: [examples/05_skills_and_plugins/03_local_plugin_install/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/03_local_plugin_install/main.py) - -```python icon="python" expandable examples/05_skills_and_plugins/03_local_plugin_install/main.py -"""Example: Install + manage a local plugin (no external LLM required). +def demo_install_local_plugin(installed_dir: Path) -> None: + """Demo 2: Install a plugin from a local path. -This example demonstrates the *installed plugins* utilities introduced in this PR. + Useful for development or local-only plugins. + """ + print("\n" + "=" * 60) + print("DEMO 2: Installing plugin from local path") + print("=" * 60) -Key ideas: -- Installed plugin packages live under `~/.openhands/plugins/installed/` by default. -- Each plugin is a self-contained directory that can include `skills/`, `agents/`, - `hooks/`, `.mcp.json`, etc. (Claude Code style). + 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}") -By default this example uses a temporary directory and leaves no artifacts. -To write artifacts to disk (useful for PR review), set: +def demo_install_github_plugin(installed_dir: Path) -> None: + """Demo 3: Install a plugin from GitHub. - export OPENHANDS_EXAMPLE_ARTIFACT_DIR=.pr/local_plugin_install_test + Demonstrates the github:owner/repo shorthand with repo_path for monorepos. + """ + print("\n" + "=" * 60) + print("DEMO 3: Installing plugin from GitHub") + print("=" * 60) -Then run: + try: + # Install from anthropics/skills repository + info = install_plugin( + source="github:anthropics/skills", + repo_path="skills/pptx", + 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}") - uv run python examples/05_skills_and_plugins/03_local_plugin_install/main.py + except PluginFetchError as e: + print(f"⚠ Could not fetch from GitHub: {e}") + print(" (Network or rate limiting issue)") -This will create (and overwrite) `plugin_src/`, `installed_root/`, and -`persistence/` under the artifact directory. -""" -from __future__ import annotations +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) -import json -import os -import shutil -import tempfile -import uuid -from contextlib import ExitStack -from pathlib import Path + # 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})") -from openhands.sdk import Agent, Conversation -from openhands.sdk.llm import Message, TextContent -from openhands.sdk.plugin import ( - get_installed_plugin, - install_plugin, - list_installed_plugins, - load_installed_plugins, - uninstall_plugin, - update_plugin, -) -from openhands.sdk.testing import TestLLM - - -def _write_example_plugin(plugin_dir: Path, *, version: str) -> None: - (plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True) - (plugin_dir / ".plugin" / "plugin.json").write_text( - json.dumps( - { - "name": "local-plugin", - "version": version, - "description": "Example local plugin", - } - ) - ) + # 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)") - skill_dir = plugin_dir / "skills" / "hello" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text( - """--- -name: hello -description: Say hello -triggers: - - hello ---- -Reply with a short greeting. -""" - ) +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}") -artifact_dir = os.getenv("OPENHANDS_EXAMPLE_ARTIFACT_DIR") + remaining = list_installed_plugins(installed_dir=installed_dir) + print(f"\nRemaining plugins: {len(remaining)}") -with ExitStack() as stack: - if artifact_dir: - root = Path(artifact_dir).resolve() - root.mkdir(parents=True, exist_ok=True) - for subdir in ("plugin_src", "installed_root", "persistence"): - shutil.rmtree(root / subdir, ignore_errors=True) +# 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: - tmp_dir = stack.enter_context(tempfile.TemporaryDirectory()) - root = Path(tmp_dir) + 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"), + ) - # Create a local plugin directory (this simulates a repo checkout). - plugin_source_dir = root / "plugin_src" / "local-plugin" - _write_example_plugin(plugin_source_dir, version="1.0.0") + with tempfile.TemporaryDirectory() as tmpdir: + installed_dir = Path(tmpdir) / "installed" + installed_dir.mkdir() - # Install into a dedicated root (avoids touching real ~/.openhands/). - installed_dir = root / "installed_root" / "plugins" / "installed" + # Demo 1: Conversation with plugins (requires LLM) + if llm: + demo_conversation_with_plugins(llm) - info = install_plugin(source=str(plugin_source_dir), installed_dir=installed_dir) - print(f"Installed: {info.name} v{info.version} from {info.source}") + # 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("\nList installed plugins:") - for item in list_installed_plugins(installed_dir=installed_dir): - print(f"- {item.name} v{item.version} ({item.source})") + print("\n" + "=" * 60) + print("EXAMPLE COMPLETED SUCCESSFULLY") + print("=" * 60) - print("\nLoad installed plugins:") - plugins = load_installed_plugins(installed_dir=installed_dir) - for plugin in plugins: - print(f"- {plugin.name}: {len(plugin.get_all_skills())} skill(s)") + if llm: + print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + else: + print("EXAMPLE_COST: 0") +``` - print("\nGet installed plugin:") - print(get_installed_plugin("local-plugin", installed_dir=installed_dir)) + - # Smoke test: run a minimal Conversation with persistence enabled. - # - # We use TestLLM so this runs without external services. The plugin skill - # is triggered by the user message "hello". - agent = Agent( - llm=TestLLM.from_messages( - [Message(role="assistant", content=[TextContent(text="Done")])] - ), - tools=[], - ) +## Installing Plugins to Persistent Storage - merged_context = agent.agent_context - for plugin in plugins: - merged_context = plugin.add_skills_to(merged_context) - agent = agent.model_copy(update={"agent_context": merged_context}) - - persistence_dir = root / "persistence" - conversation_id = ( - uuid.UUID("00000000-0000-0000-0000-000000000203") - if artifact_dir - else uuid.uuid4() - ) - conversation = Conversation( - agent=agent, - workspace=str(root), - persistence_dir=persistence_dir, - conversation_id=conversation_id, - visualizer=None, - ) - conversation.send_message("hello") - conversation.run() +The SDK provides utilities to install plugins to a local directory (`~/.openhands/plugins/installed/` by default). Installed plugins are tracked in `.installed.json`. - print(f"\nActivated skills: {conversation.state.activated_knowledge_skills}") - print(f"Wrote persistence to: {conversation.state.persistence_dir}") +```python icon="python" +from openhands.sdk.plugin import ( + install_plugin, + list_installed_plugins, + load_installed_plugins, + uninstall_plugin, +) - # Don't leave transient lock files in persisted artifacts. - if conversation.state.persistence_dir: - lockfile = ( - Path(conversation.state.persistence_dir) / "events" / ".eventlog.lock" - ) - lockfile.unlink(missing_ok=True) +# Install from local path or GitHub +install_plugin(source="/path/to/plugin") +install_plugin(source="github:owner/repo", ref="v1.0.0") - # Update: mutate the local plugin source and call update_plugin(), which - # reinstalls from the original source with ref=None (latest). - _write_example_plugin(plugin_source_dir, version="1.0.1") - updated = update_plugin("local-plugin", installed_dir=installed_dir) - assert updated is not None - print(f"\nUpdated: {updated.name} v{updated.version}") +# List and load installed plugins +for info in list_installed_plugins(): + print(f"{info.name} v{info.version}") - if artifact_dir: - print("\nSkipping uninstall (artifact mode)") - else: - uninstall_plugin("local-plugin", installed_dir=installed_dir) - print("\nAfter uninstall:") - print(list_installed_plugins(installed_dir=installed_dir)) +plugins = load_installed_plugins() -print("EXAMPLE_COST: 0") +# Uninstall +uninstall_plugin("plugin-name") ``` - - ## Next Steps - **[Skills](/sdk/guides/skill)** - Learn more about skills and triggers