Skip to content

feat(plugin): Add installed plugins management utilities#2031

Merged
enyst merged 27 commits intomainfrom
openhands/plugin-install-utils
Mar 4, 2026
Merged

feat(plugin): Add installed plugins management utilities#2031
enyst merged 27 commits intomainfrom
openhands/plugin-install-utils

Conversation

@xingyaoww
Copy link
Collaborator

@xingyaoww xingyaoww commented Feb 13, 2026

Summary

This PR adds installed plugin management utilities to openhands-sdk, providing the SDK building blocks needed for a future CLI /plugin install ... workflow.

Installed plugins are treated as self-contained plugin directories (Claude Code style) that may include skills/, agents/, hooks/, .mcp.json, etc., and are copied into an installation root under the user’s ~/.openhands/ directory.

Intentions / design notes

  • Match Claude Code’s plugin directory shape so existing Claude-style plugins can be reused with minimal changes.
  • Keep installed plugins separate from standalone skills:
    • Standalone user skills live under ~/.openhands/skills/.
    • Installed plugin packages live under ~/.openhands/plugins/installed/.
    • This avoids the confusing skills/.../skills/... nesting and leaves room for future plugin plumbing (e.g. ~/.openhands/plugins/cache/).
  • Be safe by default for filesystem operations:
    • plugin names used for install/uninstall are restricted to Claude-like kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$
    • uninstall refuses to delete directories unless the plugin is tracked in metadata.
  • Keep listing practical and self-healing:
    • listing may update metadata to remove stale entries and discover manually-copied plugins
    • but it won’t rewrite .installed.json unless something actually changed.

Installed plugins location + metadata

  • Default install root: ~/.openhands/plugins/installed/
  • Each installed plugin lives at: ~/.openhands/plugins/installed/<plugin-name>/...
  • Installations are tracked in: ~/.openhands/plugins/installed/.installed.json

Directory shape:

~/.openhands/plugins/installed/
├── <plugin-name-1>/
│   ├── .plugin/ OR .claude-plugin/
│   │   └── plugin.json
│   ├── skills/
│   ├── agents/            (optional)
│   ├── hooks/             (optional)
│   ├── .mcp.json           (optional)
│   └── ...
├── <plugin-name-2>/
│   └── ...
└── .installed.json

Public API (exported via openhands.sdk.plugin)

Models

  • InstalledPluginInfo
  • InstalledPluginsMetadata

Functions

  • install_plugin(source, ref=None, repo_path=None, installed_dir=None, force=False)
  • uninstall_plugin(name, installed_dir=None)
  • list_installed_plugins(installed_dir=None)
  • load_installed_plugins(installed_dir=None)
  • get_installed_plugin(name, installed_dir=None)
  • update_plugin(name, installed_dir=None)

Behavioral summary

  • install_plugin(...)

    • fetches via fetch_plugin_with_resolution(...)
    • validates plugin.name (kebab-case)
    • copies plugin into installed_dir / plugin.name
    • updates .installed.json
  • uninstall_plugin(...)

    • validates name (kebab-case)
    • only uninstalls plugins tracked in .installed.json
  • list_installed_plugins(...)

    • returns InstalledPluginInfo entries
    • self-heals metadata (cleanup + discovery)
    • won’t rewrite metadata unless it changed
  • update_plugin(...)

    • reinstalls from the original source with ref=None and force=True
    • i.e., updates to latest from source (does not preserve a pinned ref)

Usage examples

from openhands.sdk.plugin import (
    install_plugin,
    list_installed_plugins,
    uninstall_plugin,
    load_installed_plugins,
    get_installed_plugin,
    update_plugin,
)

# Install a plugin from GitHub
info = install_plugin("github:owner/my-plugin", ref="v1.0.0")
print(f"Installed {info.name} v{info.version}")

# List all installed plugins
for plugin_info in list_installed_plugins():
    print(f"  - {plugin_info.name}: {plugin_info.description}")

# Load plugins for use with an agent
plugins = load_installed_plugins()

# Get info about a specific installed plugin
maybe_info = get_installed_plugin("my-plugin")
print(maybe_info)

# Update an installed plugin to the latest from its source
updated_info = update_plugin("my-plugin")
print(updated_info)

# Uninstall a plugin
uninstall_plugin("my-plugin")

See also: examples/05_skills_and_plugins/03_local_plugin_install/main.py (no LLM required).

Non-goals (for this PR) / future work

  • No enable/disable semantics, install scopes, or marketplace management yet (those belong in CLI/UI layers).
  • Intentionally leaves room for future directories like ~/.openhands/plugins/cache/.

Checklist


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.12-nodejs22 Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:65501fd-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-65501fd-python \
  ghcr.io/openhands/agent-server:65501fd-python

All tags pushed for this build

ghcr.io/openhands/agent-server:65501fd-golang-amd64
ghcr.io/openhands/agent-server:65501fd-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:65501fd-golang-arm64
ghcr.io/openhands/agent-server:65501fd-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:65501fd-java-amd64
ghcr.io/openhands/agent-server:65501fd-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:65501fd-java-arm64
ghcr.io/openhands/agent-server:65501fd-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:65501fd-python-amd64
ghcr.io/openhands/agent-server:65501fd-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-amd64
ghcr.io/openhands/agent-server:65501fd-python-arm64
ghcr.io/openhands/agent-server:65501fd-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-arm64
ghcr.io/openhands/agent-server:65501fd-golang
ghcr.io/openhands/agent-server:65501fd-java
ghcr.io/openhands/agent-server:65501fd-python

About Multi-Architecture Support

  • Each variant tag (e.g., 65501fd-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 65501fd-python-amd64) are also available if needed

Add support for managing plugins installed in ~/.openhands/skills/installed/:

- install_plugin(): Install plugins from GitHub repos, git URLs, or local paths
- uninstall_plugin(): Remove installed plugins by name
- list_installed_plugins(): List all installed plugins with metadata
- load_installed_plugins(): Load all installed plugins as Plugin objects
- get_installed_plugin(): Get info about a specific installed plugin
- update_plugin(): Update an installed plugin to the latest version

This follows the Claude Code plugin pattern where plugins can be installed
from various sources and managed in a user-specific directory.

Includes comprehensive tests for all functionality.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/plugin
   installed.py1721690%279, 304–308, 340–342, 346–348, 351, 355, 396, 435
TOTAL19282571170% 

xingyaoww pushed a commit to OpenHands/OpenHands-CLI that referenced this pull request Feb 13, 2026
Add support for managing plugins installed in ~/.openhands/skills/installed/:

TUI Command:
- /plugin - Shows installed plugins and usage instructions

CLI Subcommand:
- openhands plugin list [--json] - List installed plugins
- openhands plugin install <source> [--ref] [--repo-path] [--force] - Install plugin
- openhands plugin uninstall <name> - Uninstall plugin
- openhands plugin update <name> - Update plugin to latest version

Supported sources:
- github:owner/repo - GitHub repository shorthand
- https://github.com/owner/repo - Full git URL
- /local/path - Local directory path

This uses the new installed plugins management utilities from SDK PR #2031:
OpenHands/software-agent-sdk#2031

Closes #405

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Feb 13, 2026

Looks like there are a few issues preventing this PR from being merged!

  • GitHub Actions are failing:
    • Pre-commit checks

If you'd like me to help, just leave a comment, like

@OpenHands please fix the failing actions on PR #2031 at branch `openhands/plugin-install-utils`

Feel free to include any additional details that might help me get this PR into a better state.

You can manage your notification settings

@enyst enyst added behavior-initiative This is related to the system prompt sections and LLM steering. and removed behavior-initiative This is related to the system prompt sections and LLM steering. labels Feb 14, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 1, 2026

API breakage checks (Griffe)

Result: Failed

Log excerpt (first 1000 characters)

============================================================
Checking openhands-sdk (openhands.sdk)
============================================================
Comparing openhands-sdk 1.11.5 against 1.11.4
::notice title=openhands-sdk API::Ignoring Field metadata-only change (non-breaking): load_public_skills
::notice title=openhands-sdk API::Ignoring Field metadata-only change (non-breaking): temperature
::warning file=openhands-sdk/openhands/sdk/llm/llm.py,line=196,title=LLM.top_p::Attribute value was changed: `Field(default=1.0, ge=0, le=1)` -> `Field(default=None, ge=0, le=1, description='Nucleus sampling parameter. Defaults to None (uses provider default). Set to a value between 0 and 1 to control diversity of outputs.')`
::error title=SemVer::Breaking changes detected (1); require at least minor version bump from 1.11.x, but new is 1.11.5

============================================================
Checking openhands-workspace (openhands.workspace)
============================

Action log

@github-actions
Copy link
Contributor

github-actions bot commented Mar 1, 2026

Agent server REST API breakage checks (OpenAPI)

Result: Passed

Action log

@enyst
Copy link
Collaborator

enyst commented Mar 1, 2026

@OpenHands Understand this PR very well. Fetch plugins specification from claude code too, to see the goal.

Then:

  • fix pre-commit as appropriate, note that we use uv; commit that fix
  • then do a /codereview and lets figure what is missing, if anything

@openhands-ai
Copy link

openhands-ai bot commented Mar 1, 2026

I'm on it! enyst can track my progress at all-hands.dev

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Mar 1, 2026

Pre-commit failures are fixed and pushed.

  • Ran uv run pre-commit run --files ... on the PR-touched files.
  • Applied the Ruff auto-fixes (format + lint) and committed/pushed: 074ecf8f (chore: fix pre-commit issues in installed plugin utils)
  • Verified: uv run pytest -q tests/sdk/plugin/test_installed_plugins.py (22 passed)

Claude Code plugins spec (what we’re trying to mirror)

From the official Claude Code docs:

OpenHands’ implementation in this PR is aiming for the same directory shape and “installed plugins” concept, but (currently) only implements a “user-scope-ish” installed directory at ~/.openhands/skills/installed.


/codereview (important issues only)

1) Style and Formatting (significant only)

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 412–421] Readability: duplicated Returns: line in the docstring.

  • Not functionally wrong, but it’s confusing and looks like an editing artifact.

[tests/sdk/plugin/test_installed_plugins.py, Lines 118–122] Readability: duplicated “Load and verify” comment line.

2) Clarity and Readability

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 443–485] Doc/behavior mismatch: docstring says “The original source and ref are preserved from the installation metadata”, but:

  • InstalledPluginInfo does not store ref / original_ref (only resolved_ref).
  • update_plugin() explicitly calls install_plugin(..., ref=None) (i.e., do not preserve a pinned tag/branch).

Concrete fix options:

  • Either change the docstring to match behavior (“updates to latest”), or
  • Persist original_ref in InstalledPluginInfo and use it in update_plugin() when appropriate.

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 213–232] Efficiency: install_plugin() fetches from remote before checking whether it’s already installed.

  • If a plugin is already installed and force=False, you still pay the network/cache update cost before raising FileExistsError.
  • Suggest checking installed state earlier (if you can determine the intended plugin name deterministically).

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 347–376] Side effects in “list”: list_installed_plugins() always writes .installed.json (line 375), even when “listing” only.

  • This makes listing non-idempotent w.r.t. filesystem timestamps and creates the metadata file even for manual installs.
  • It may be intended, but if callers expect list_* to be read-only, this will surprise them.

3) Security and Common Bug Patterns

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 222–242] Security: path traversal / arbitrary write risk via plugin.name.

  • plugin_name = plugin.name comes from plugin manifest (or inferred), and is used directly as a filesystem path component: install_path = installed_dir / plugin_name.
  • If a manifest sets name to something like ../../somewhere, shutil.copytree(..., install_path) could write outside installed_dir.

[openhands-sdk/openhands/sdk/plugin/installed.py, Lines 262–306] Security: path traversal / arbitrary delete risk via uninstall_plugin(name, ...).

  • plugin_path = installed_dir / name then shutil.rmtree(plugin_path).
  • A caller passing name="../.." could delete outside the installed plugins directory.

Concrete improvement:

  • Introduce a strict validation function for any “plugin name” used in paths (both manifest-derived and user-provided), e.g.:
    • must be a single path segment (no / or \)
    • must not contain ..
    • ideally enforce Claude-like kebab-case (or at least “safe filename”)
  • After joining paths, also consider verifying install_path.resolve().is_relative_to(installed_dir.resolve()) (py3.9+: manual check) before any filesystem mutation.

This is the biggest “must fix” gap before wiring this into a CLI command that accepts untrusted plugin sources.

4) Testing and Behavior Verification

[tests/sdk/plugin/test_installed_plugins.py] Tests cover the happy paths and basic metadata behavior, but no tests cover the path traversal / unsafe name cases above.

  • Suggest adding at least 1–2 unit tests that verify unsafe plugin names (from manifest and from uninstall args) are rejected.

What’s “missing” vs Claude Code’s end-goal (non-blocking, but good to track)

Compared to Claude Code’s plugin manager, this utility layer doesn’t yet cover:

  • enable/disable state (vs uninstall)
  • install scopes (user/project/local) + writing to settings files
  • marketplace metadata / catalog integration (though that may be intentionally out-of-scope for this PR)

If you want, I can dig further into Claude’s marketplace JSON format and caching behavior too (I used the official docs pages; there isn’t an “MCP fetch” tool available in this runner, but I can continue pulling primary-source details from the Claude docs in the same way).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator

enyst commented Mar 1, 2026

Current behavior (PR #2031)

This PR adds installed plugin management utilities to the SDK, mirroring the Claude Code plugin directory shape (plugins are self-contained directories that can include skills/, agents/, hooks/, .mcp.json, etc.).

Installed plugins location + metadata

  • Default install root: ~/.openhands/plugins/installed/
  • Each installed plugin lives at: ~/.openhands/plugins/installed/<plugin-name>/...
  • Installations are tracked in: ~/.openhands/plugins/installed/.installed.json
    • stored as InstalledPluginsMetadataplugins: dict[name, InstalledPluginInfo]

Name safety / constraints

  • Plugin names used for install/uninstall are required to be Claude-like kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$
    • This prevents path traversal / accidental deletes outside the install root.

New public APIs (exported via openhands.sdk.plugin)

  • Models:
    • InstalledPluginInfo (name/version/description/source/resolved_ref/repo_path/installed_at/install_path)
    • InstalledPluginsMetadata
  • Functions:
    • install_plugin(source, ref=None, repo_path=None, installed_dir=None, force=False)
    • uninstall_plugin(name, installed_dir=None)
    • list_installed_plugins(installed_dir=None)
    • load_installed_plugins(installed_dir=None)
    • get_installed_plugin(name, installed_dir=None)
    • update_plugin(name, installed_dir=None)

Behavior details

install_plugin(...)

  • Fetches the plugin via fetch_plugin_with_resolution(...) (supports github:owner/repo, arbitrary git URLs, or local paths; optional ref and repo_path).
  • Loads the plugin to read plugin.name and validates the name (kebab-case).
  • Installs by copying the fetched plugin directory into installed_dir / plugin.name.
    • If already installed:
      • force=False → raises FileExistsError
      • force=True → overwrites existing installation
  • Updates .installed.json with installation metadata (including resolved_ref when available).

uninstall_plugin(name, ...)

  • Validates name (kebab-case).
  • Only uninstalls plugins tracked in .installed.json (won’t delete arbitrary directories just because they exist).
  • Removes the plugin directory (if present) and removes the metadata entry.

list_installed_plugins(...)

  • Returns InstalledPluginInfo entries.
  • Self-healing behavior:
    • Removes metadata entries whose directories are missing.
    • Removes metadata entries with invalid tracked names.
    • Discovers untracked plugin directories (kebab-case dir name + manifest name must match dir name) and adds them to metadata with source="local".
  • Does not rewrite .installed.json unless something actually changed.

load_installed_plugins(...)

  • Loads Plugin objects from the installed plugins directory (Plugin.load_all(...)).

get_installed_plugin(name, ...)

  • Validates name (kebab-case).
  • Returns metadata if tracked and directory still exists, else None.

update_plugin(name, ...)

  • Validates name (kebab-case).
  • Re-installs from the original source (stored in metadata) with ref=None and force=True.
    • i.e., updates to “latest from source” (does not preserve a pinned ref).

Tests

  • Adds unit tests covering install/uninstall/list/load/update flows, including:
    • invalid plugin name rejection
    • uninstall refusing to delete untracked plugin directories
    • list self-healing (cleanup + discovery)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 1, 2026

PR Artifacts Cleaned Up

The .pr/ directory has been automatically removed.

enyst and others added 3 commits March 2, 2026 01:07
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator

enyst commented Mar 2, 2026

With TestLLM, the “live test” worked like this:

What we were trying to prove

We wanted an end-to-end-ish smoke test that:

  1. installs a local plugin directory into an installed_dir (no real ~/.openhands/ writes)
  2. loads that installed plugin as a Plugin object
  3. merges the plugin’s skills/ into an Agent’s agent_context
  4. runs a real Conversation loop and confirms the skill actually triggers
  5. persists the conversation to disk (so reviewers can inspect base_state.json + events/*.json)

Why TestLLM is enough here

TestLLM is a real LLM subclass in the SDK (openhands.sdk.testing.TestLLM). Instead of calling an external provider, it returns scripted assistant messages.

In the example we used:

TestLLM.from_messages([
  Message(role="assistant", content=[TextContent(text="Done")])
])

So when the conversation calls the LLM once, it deterministically returns "Done" (and cost stays 0). No network calls, no API keys.

The important “real behavior” that still happens

Even though the LLM response is scripted, the SDK still does the real steps around it:

  • The installed plugin’s skills/hello/SKILL.md is loaded as a Skill object (via Plugin.load_all()Plugin.load()_load_skills()).
  • We merge it into the agent via plugin.add_skills_to(...).
  • When we do:
conversation.send_message("hello")
conversation.run()

the AgentContext keyword trigger logic runs, and we actually see:

  • the skill gets activated (conversation.state.activated_knowledge_skills becomes ['hello'])
  • logs show: Skill 'hello' triggered by keyword 'hello'

That’s the key proof that “installed plugin → loaded plugin → skill in agent context → skill triggers in a conversation” works.

What got persisted

Because Conversation(..., persistence_dir=...) was set, it wrote:

  • base_state.json
  • events/event-*.json

Those events include (at minimum) the user message event and the agent message event (from TestLLM). In artifact mode, we also delete events/.eventlog.lock so the persisted folder is clean for review.

What this does not test

It doesn’t test:

  • external LLM behavior (reasoning quality/tool calls/etc.)
  • git fetching from GitHub (we used a local path source)
  • plugin hooks/MCP/etc

But it does test the installed-plugin utilities + the skill loading/activation path in a “real Conversation”.

If you still want a “real-world-like” run with a cheap real model (e.g. gpt-5-nano) we can swap TestLLM for LLM(...) in the example and keep the same artifact directory behavior—just note the example would then require OPENAI_API_KEY (or LLM_API_KEY + base_url) and would incur small cost.

openhands-agent and others added 3 commits March 2, 2026 00:37
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator

@enyst enyst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xingyaoww In my understanding, and the agent found references, we're blurring the lines a bit here:

  • a plugin has a defined format as manifest + skills/ directory + optional stuff
  • an Agentskill has a defined format as a SKILL.md + optional stuff

In the latest change in this PR, we're reusing the plugin utilities to get/install/load a single Agentskill.

I think this is rather a good thing: it's convenient, it reuses the machinery but the support for the special ("flat") case is in one place; there is no "flat" case in definitions, but... maybe it should be.

Just would probably be good to be aware of it, and 🤔 maybe we could document accurately somewhere, so that the agent doesn't get fooled going forward. It's a trade-off: we would need similar implementation to just fetch a single Agentskill (which is incredibly useful), so we reuse the utilities named "plugin" to do it.

@xingyaoww
Copy link
Collaborator Author

@OpenHands please review this PR and the full discussion in #2272 - help me revert related changes (eg 55746ba) and modify the example script so we directly load the plugins from https://github.com/anthropics/skills as described here: #2272 (comment)

The expected outcome would be:

  1. we installed two plugins from that repo
  2. we keep the prompt the same, the agent is still able to read pptx skill and respond

Please update the code, run the example script and make sure it works as expected before you declare victory. When you are done, attach evidence that the code actually work

@openhands-ai
Copy link

openhands-ai bot commented Mar 3, 2026

I'm on it! xingyaoww can track my progress at all-hands.dev

…s/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>
@openhands-ai
Copy link

openhands-ai bot commented Mar 3, 2026

Final Summary

I've completed the requested changes to PR #2031. The changes have already been committed and pushed.

What was done:

✅ Reverted flat SKILL.md support (as requested from commit 55746bab)

  • Removed the fallback in _load_skills() that checked for root-level SKILL.md
  • Plugins now only load skills from the skills/ subdirectory (proper plugin format)

✅ Updated example script to load proper plugins from anthropics/skills

  • Changed from loading repo_path="skills/pptx" (single AgentSkill) to loading the full repo
  • The full repo contains the document-skills plugin which properly bundles pptx, xlsx, docx, pdf skills in skills/ subdirectories

✅ Updated tests accordingly

  • Removed the two flat SKILL.md tests
  • Updated test_install_from_anthropic_skillstest_install_document_skills_plugin to test proper plugin loading

✅ Verified the code works

  • Pre-commit checks pass
  • All 26 unit tests pass
  • Example script successfully loads 16 skills from the anthropics/skills plugin
  • The agent correctly uses the pptx skill to answer PowerPoint-related questions

Commit pushed:

The changes are concise and directly address the user's request to revert the flat SKILL.md support and update the example to load proper plugins from the anthropics/skills repository.

@enyst
Copy link
Collaborator

enyst commented Mar 3, 2026

@OpenHands Fetch agentskills.io and the claude code plugin definition, to understand Agentskill format and Plugin format.

Read all top-level comments in this PR, with accent on the latest discussion and also read issue 2272 to see what we decided. You can see that the agent reverted single-Agentskill support.

Then review the PR. Tell me what it does on plugin operations, like install/uninstall, enable/disable, list, etc. Tell me what it does (or the codebase does) on Agentskill operations, of the same kinds.

Post your response as a comment to this PR. Note that it will rendered as markdown.

@openhands-ai
Copy link

openhands-ai bot commented Mar 3, 2026

I'm on it! enyst can track my progress at all-hands.dev

@openhands-ai
Copy link

openhands-ai bot commented Mar 3, 2026

I can’t directly post GitHub PR comments from this request (per the agent instructions for “question” requests), but below is a ready-to-paste PR comment in Markdown.


PR review: Plugin format vs AgentSkills format + what this PR adds

External specs (ground truth)

AgentSkills (agentskills.io) skill format

  • Spec: https://agentskills.io/specification
  • A skill is a directory whose minimum required file is SKILL.md at the root of that skill directory:
    skill-name/
    └── SKILL.md
    
  • SKILL.md must have YAML frontmatter with required fields:
    • name (lowercase alnum + hyphens; must match directory name)
    • description
  • Optional directories: scripts/, references/, assets/, etc.

Claude Code plugin format

  • “Create plugins”: https://code.claude.com/docs/en/plugins
  • “Plugins reference” (full spec + CLI commands): https://code.claude.com/docs/en/plugins-reference
  • A plugin is a container directory (optionally with .claude-plugin/plugin.json) that can include:
    • skills/<skill-name>/SKILL.md (AgentSkills-format skills inside a plugin container)
    • agents/, hooks/, .mcp.json, .lsp.json, settings.json, etc.
  • Claude Code also has explicit enable/disable semantics in its CLI (claude plugin enable|disable) and scope-based install (user|project|local|managed), defined in the reference.

Latest PR discussion + Issue #2272 decision (what we decided)

Issue: #2272 (now closed)

The key clarification from the latest discussion:

  • The earlier “flat plugin” / “flat skill” framing was a conflation.
  • anthropics/skills mostly contains AgentSkills skill directories under skills/* (e.g. skills/pptx/SKILL.md), not standalone Claude “plugins” in the marketplace sense.
  • We concluded we should not treat “single AgentSkill directory” as a plugin container by adding a plugin loader fallback that loads <plugin_root>/SKILL.md.

Concretely in this PR:

  • Commit 55746bab added “root-level SKILL.md” fallback support (a single AgentSkill behaving like a “degenerate plugin”).
  • Latest head commit af4e8d2b reverts that: plugin skill discovery is again only via plugin_root/skills/... (plus .md directly under skills/).

This aligns with the decision that future CLI UX should likely separate:

  • /plugin install (plugin container format)
  • /skill install (AgentSkills format)

What this PR does for plugin operations (SDK)

This PR adds installed plugin management utilities under:

  • Install root (default): ~/.openhands/plugins/installed/
  • Metadata file: ~/.openhands/plugins/installed/.installed.json

Install

API: install_plugin(source, ref=None, repo_path=None, installed_dir=None, force=False)

Behavior:

  • Fetches source via fetch_plugin_with_resolution(...) (git cached clone or local path).
  • Loads a Plugin via Plugin.load(fetched_path) to determine metadata (name/version/description).
  • Validates plugin name is safe kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$ (path traversal defense).
  • Copies the directory into:
    • installed_dir / plugin.name
  • Writes/updates .installed.json with:
    • source, resolved_ref (commit SHA for git sources), optional repo_path, timestamps, install_path, etc.
  • force=True overwrites an existing install directory.

Uninstall

API: uninstall_plugin(name, installed_dir=None) -> bool

Behavior:

  • Validates name with the same kebab-case regex.
  • Refuses to delete unless the plugin is present in .installed.json (safety guard).
  • Deletes installed_dir/name and removes the entry from .installed.json.
  • Returns False if not installed/tracked.

List

API: list_installed_plugins(installed_dir=None) -> list[InstalledPluginInfo]

Behavior (important):

  • “Self-healing” metadata:
    • Removes tracked entries whose directories disappeared.
    • Can “discover” untracked plugin directories that were manually copied in (skips dot-directories; validates name; loads plugin; requires manifest name == directory name).
  • Only writes .installed.json if something changed.

Load

API: load_installed_plugins(installed_dir=None) -> list[Plugin]

Behavior:

  • Loads every subdirectory via Plugin.load_all(installed_dir).
  • Note: Plugin.load_all currently does not skip hidden directories, so if someone adds dot-directories under installed root, it may emit warnings while trying to load them.

Get

API: get_installed_plugin(name, installed_dir=None) -> InstalledPluginInfo | None

Behavior:

  • Returns metadata entry if present and directory exists.

Update

API: update_plugin(name, installed_dir=None) -> InstalledPluginInfo | None

Behavior:

  • Re-installs from the original recorded source, using:
    • ref=None (i.e. “latest”), force=True
  • It does not preserve a pinned ref (even though resolved_ref is stored).

Enable / Disable

  • Not implemented in this PR (and explicitly called out as a non-goal).
  • In OpenHands SDK today, “enabled” effectively means “passed into the runtime”:
    • via Conversation(..., plugins=[PluginSource(...)]) or similar higher-layer config
    • or by manually calling load_installed_plugins() and merging into an agent

What the codebase does for AgentSkills operations (install/uninstall/list/enable/update equivalents)

OpenHands supports AgentSkills as a skill format, not as an “installed plugin container”.

“Install”

There is no dedicated install_skill(...) API in the SDK today.

What exists instead:

  • “Install by convention”: user puts skills in directories that loaders scan, e.g.
    • ~/.agents/skills/
    • ~/.openhands/skills/
    • ~/.openhands/microagents/ (legacy)
  • For “public skills”, there is something install-like:
    • load_public_skills() maintains a local git clone of https://github.com/OpenHands/extensions under ~/.openhands/skills-cache/ and pulls updates.

“Uninstall”

  • No uninstall API; users delete directories/files from those skill locations.

“List”

  • Core loader: load_skills_from_dir(dir):
    • supports AgentSkills format (<skill>/SKILL.md) and legacy .md skills
    • categorizes into:
      • repo_skills (always-on, trigger=None)
      • knowledge_skills (trigger-based)
      • agent_skills (AgentSkills SKILL.md format; separate bucket)
  • Higher-level “listing” is typically: call load_user_skills(), load_project_skills(...), and/or load_public_skills() and inspect the returned Skill objects.
  • to_prompt(skills) formats a <available_skills> XML block for prompts.

“Enable / Disable”

  • There’s no per-skill enable/disable state tracked in metadata.
  • The main “enable” switches are at AgentContext level:
    • AgentContext(load_user_skills=True/False)
    • AgentContext(load_public_skills=True/False)
  • Trigger semantics (“progressive disclosure”) act like a runtime enable:
    • skills can be injected only when triggered, vs always-on repo skills.

“Update”

  • For user/project skills: “update” is manual (edit files).
  • For public skills: load_public_skills() effectively does a git pull-style update of the cached repo before loading, and it loads only what’s in the default marketplace.

Review notes / potential follow-ups (optional)

  • Prevent accidental “non-plugin” installs: install_plugin() will happily install any directory that Plugin.load() can parse (including inferred manifests). Given issue Plugin loader doesn't detect root-level SKILL.md (anthropics/skills format) #2272’s confusion, consider an optional “strict plugin shape” validation (manifest present, or at least one plugin component dir present) before allowing install—or leave it permissive but document it clearly.
  • .git copying: when installing from a repo root (repo_path=None), shutil.copytree() can copy the repo’s .git directory into the installed plugin. If that’s undesirable, consider excluding it (or encourage repo_path pointing at the plugin subdir).

If you want, I can turn the above into a shorter version (more “executive summary”) for the PR thread, but I kept it explicit since the plugin-vs-skill boundary was the core confusion in #2272.

@enyst
Copy link
Collaborator

enyst commented Mar 4, 2026

Xingyao, I'll merge this, it's great to have it and there's no point in... loading it in context again to check what we already checked multiple times. And, it's basically a single file with install utilities.

Thank you! ❤️

@OpenHands Read the entire discussion here on Plugins and Agentskills; get yourself information from agentskills.io and from claude code plugin docs about the specs of these two; then

  • understand where we land once we merge this PR, on support for install/uninstall/enable/disable/list both
  • find open issues about support for any of them
  • add to those issues the current status after merge
  • I think maybe we don't have an issue on Agentskills the whole way? If so, make a new one
  • clone yourself the CLI repo from same org; check it for issues and PRs related to this too, and add there status too

@enyst enyst enabled auto-merge (squash) March 4, 2026 20:00
@openhands-ai
Copy link

openhands-ai bot commented Mar 4, 2026

I'm on it! enyst can track my progress at all-hands.dev

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Mar 4, 2026

Summary of new work since the last update:

  • Adjusted test_install_document_skills_plugin to derive the expected plugin name from the cache path (get_cache_path(parse_plugin_source(...))) because anthropics/skills lacks a manifest; assertions now use the computed name consistently.
  • Added the necessary imports for the cache-path helper in tests/sdk/plugin/test_installed_plugins.py.
  • Ran uv run pre-commit run --files tests/sdk/plugin/test_installed_plugins.py successfully.
  • Committed and pushed the fix (c4180d84).

Checklist:

  • Addressed the failing test by aligning expectations with current plugin name inference.
  • Changes are minimal and confined to the failing test.
  • Pre-commit hook executed on the modified file.
  • Changes pushed to the PR branch.

No further tool actions needed.

@xingyaoww
Copy link
Collaborator Author

xingyaoww commented Mar 4, 2026

@enyst thanks a ton for helping babysit this PR 🙏 This LGTM!

Sorry i haven't had enough time lately to pay attention to github notification lately :(
I should be back soon!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants