Skip to content

Commit 0c4630d

Browse files
refactor: extract common extension patterns into openhands.sdk.extensions module
Create a new `openhands.sdk.extensions` module to deduplicate code between plugin, marketplace, and skills modules. This extracts: - ExtensionSource, ResolvedExtensionSource, ExtensionAuthor (source.py) - ExtensionCatalogEntry, ExtensionCatalog (catalog.py) - fetch_extension_with_resolution (fetch.py) - InstalledExtensionInfo, InstalledExtensionMetadata[T], InstalledExtensionManager[ItemT, InfoT] (installed.py) Key changes: - PluginSource now extends ExtensionSource - PluginAuthor now extends ExtensionAuthor - InstalledPluginInfo now extends InstalledExtensionInfo - InstalledSkillInfo now extends InstalledExtensionInfo - Both plugin/installed.py and skills/installed.py now use InstalledExtensionManager - Backward-compatible wrappers preserve legacy API (InstalledPluginsMetadata, InstalledSkillsMetadata) - Legacy metadata formats ("plugins", "skills" keys) are still supported when loading This reduces code duplication by ~400+ lines while maintaining full backward compatibility. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 53b8038 commit 0c4630d

9 files changed

Lines changed: 1304 additions & 696 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Extensions module for OpenHands SDK.
2+
3+
This module provides shared infrastructure for installable extensions
4+
(plugins, skills, etc.), including:
5+
6+
- Source specification types for describing where to fetch extensions
7+
- Catalog entry types for extension marketplaces
8+
- Generic installation management infrastructure
9+
- Fetching utilities for remote sources
10+
11+
The types and utilities here are used by the plugin, skills, and marketplace
12+
modules to provide consistent behavior for extension management.
13+
14+
Example:
15+
>>> from openhands.sdk.extensions import ExtensionSource, InstalledExtensionManager
16+
>>> source = ExtensionSource(source="github:owner/repo", ref="v1.0.0")
17+
"""
18+
19+
from openhands.sdk.extensions.catalog import (
20+
ExtensionAuthor,
21+
ExtensionCatalogEntry,
22+
)
23+
from openhands.sdk.extensions.fetch import (
24+
DEFAULT_CACHE_DIR,
25+
ExtensionFetchError,
26+
fetch_extension,
27+
fetch_extension_with_resolution,
28+
get_cache_path,
29+
parse_extension_source,
30+
)
31+
from openhands.sdk.extensions.installed import (
32+
InstalledExtensionInfo,
33+
InstalledExtensionManager,
34+
InstalledExtensionMetadata,
35+
)
36+
from openhands.sdk.extensions.source import (
37+
ExtensionSource,
38+
ResolvedExtensionSource,
39+
)
40+
41+
42+
__all__ = [
43+
# Source types
44+
"ExtensionSource",
45+
"ResolvedExtensionSource",
46+
# Catalog types
47+
"ExtensionAuthor",
48+
"ExtensionCatalogEntry",
49+
# Installed extension management
50+
"InstalledExtensionInfo",
51+
"InstalledExtensionMetadata",
52+
"InstalledExtensionManager",
53+
# Fetching utilities
54+
"ExtensionFetchError",
55+
"fetch_extension",
56+
"fetch_extension_with_resolution",
57+
"parse_extension_source",
58+
"get_cache_path",
59+
"DEFAULT_CACHE_DIR",
60+
]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Extension catalog entry types.
2+
3+
These types define entries in extension catalogs (marketplaces) that list
4+
available extensions with their metadata and source locations.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, Self
10+
11+
from pydantic import BaseModel, Field, field_validator
12+
13+
14+
class ExtensionAuthor(BaseModel):
15+
"""Author information for an extension."""
16+
17+
name: str = Field(description="Author's name")
18+
email: str | None = Field(default=None, description="Author's email address")
19+
url: str | None = Field(
20+
default=None, description="Author's URL (e.g., GitHub profile)"
21+
)
22+
23+
@classmethod
24+
def from_string(cls, author_str: str) -> Self:
25+
"""Parse author from string format 'Name <email>'.
26+
27+
Examples:
28+
>>> ExtensionAuthor.from_string("John Doe <john@example.com>")
29+
ExtensionAuthor(name='John Doe', email='john@example.com', url=None)
30+
31+
>>> ExtensionAuthor.from_string("Jane Doe")
32+
ExtensionAuthor(name='Jane Doe', email=None, url=None)
33+
"""
34+
if "<" in author_str and ">" in author_str:
35+
name = author_str.split("<")[0].strip()
36+
email = author_str.split("<")[1].split(">")[0].strip()
37+
return cls(name=name, email=email)
38+
return cls(name=author_str.strip())
39+
40+
41+
class ExtensionCatalogEntry(BaseModel):
42+
"""Entry in an extension catalog (marketplace).
43+
44+
This is the base type for catalog entries that point to extensions
45+
(plugins, skills, etc.) with their metadata and source locations.
46+
47+
Source is a string path that can be:
48+
- Local path: "./path/to/extension", "/absolute/path"
49+
- GitHub URL: "https://github.com/owner/repo/tree/branch/path"
50+
"""
51+
52+
name: str = Field(description="Identifier (kebab-case, no spaces)")
53+
source: str = Field(description="Path to extension directory (local or GitHub URL)")
54+
description: str | None = Field(default=None, description="Brief description")
55+
version: str | None = Field(default=None, description="Version")
56+
author: ExtensionAuthor | None = Field(default=None, description="Author info")
57+
category: str | None = Field(default=None, description="Category for organization")
58+
homepage: str | None = Field(
59+
default=None, description="Homepage or documentation URL"
60+
)
61+
62+
model_config = {"extra": "allow", "populate_by_name": True}
63+
64+
@field_validator("author", mode="before")
65+
@classmethod
66+
def _parse_author(cls, v: Any) -> Any:
67+
"""Parse author from string if needed."""
68+
if isinstance(v, str):
69+
return ExtensionAuthor.from_string(v)
70+
return v

0 commit comments

Comments
 (0)