Skip to content

Commit 53b8038

Browse files
Move marketplace definitions to openhands.sdk.marketplace module (#2786)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 541e888 commit 53b8038

File tree

10 files changed

+585
-297
lines changed

10 files changed

+585
-297
lines changed

examples/01_standalone_sdk/43_mixed_marketplace_skills/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
import sys
3838
from pathlib import Path
3939

40-
from openhands.sdk.plugin import Marketplace
40+
from openhands.sdk.marketplace import Marketplace
4141
from openhands.sdk.skills import (
4242
install_skills_from_marketplace,
4343
list_installed_skills,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Marketplace module for OpenHands SDK.
2+
3+
This module provides support for plugin and skill marketplaces - directories
4+
that list available plugins and skills with their metadata and source locations.
5+
6+
A marketplace is defined by a `marketplace.json` file in a `.plugin/` or
7+
`.claude-plugin/` directory at the root of a repository. It lists plugins and
8+
skills available for installation, along with metadata like descriptions,
9+
versions, and authors.
10+
11+
Example marketplace.json:
12+
```json
13+
{
14+
"name": "company-tools",
15+
"owner": {"name": "DevTools Team"},
16+
"plugins": [
17+
{"name": "formatter", "source": "./plugins/formatter"}
18+
],
19+
"skills": [
20+
{"name": "github", "source": "./skills/github"}
21+
]
22+
}
23+
```
24+
"""
25+
26+
from openhands.sdk.marketplace.types import (
27+
MARKETPLACE_MANIFEST_DIRS,
28+
MARKETPLACE_MANIFEST_FILE,
29+
Marketplace,
30+
MarketplaceEntry,
31+
MarketplaceMetadata,
32+
MarketplaceOwner,
33+
MarketplacePluginEntry,
34+
MarketplacePluginSource,
35+
)
36+
37+
38+
__all__ = [
39+
# Constants
40+
"MARKETPLACE_MANIFEST_DIRS",
41+
"MARKETPLACE_MANIFEST_FILE",
42+
# Marketplace classes
43+
"Marketplace",
44+
"MarketplaceEntry",
45+
"MarketplaceOwner",
46+
"MarketplacePluginEntry",
47+
"MarketplacePluginSource",
48+
"MarketplaceMetadata",
49+
]
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"""Type definitions for Marketplace module."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from pydantic import BaseModel, Field, field_validator, model_validator
10+
11+
from openhands.sdk.plugin.types import (
12+
HooksConfigDict,
13+
LspServersDict,
14+
McpServersDict,
15+
PluginAuthor,
16+
PluginManifest,
17+
)
18+
19+
20+
# Directories to check for marketplace manifest
21+
MARKETPLACE_MANIFEST_DIRS = [".plugin", ".claude-plugin"]
22+
MARKETPLACE_MANIFEST_FILE = "marketplace.json"
23+
24+
25+
class MarketplaceOwner(BaseModel):
26+
"""Owner information for a marketplace.
27+
28+
The owner represents the maintainer or team responsible for the marketplace.
29+
"""
30+
31+
name: str = Field(description="Name of the maintainer or team")
32+
email: str | None = Field(
33+
default=None, description="Contact email for the maintainer"
34+
)
35+
36+
37+
class MarketplacePluginSource(BaseModel):
38+
"""Plugin source specification for non-local sources.
39+
40+
Supports GitHub repositories and generic git URLs.
41+
"""
42+
43+
source: str = Field(description="Source type: 'github' or 'url'")
44+
repo: str | None = Field(
45+
default=None, description="GitHub repository in 'owner/repo' format"
46+
)
47+
url: str | None = Field(default=None, description="Git URL for 'url' source type")
48+
ref: str | None = Field(
49+
default=None, description="Branch, tag, or commit reference"
50+
)
51+
path: str | None = Field(
52+
default=None, description="Subdirectory path within the repository"
53+
)
54+
55+
model_config = {"extra": "allow"}
56+
57+
@model_validator(mode="after")
58+
def validate_source_fields(self) -> MarketplacePluginSource:
59+
"""Validate that required fields are present based on source type."""
60+
if self.source == "github" and not self.repo:
61+
raise ValueError("GitHub source requires 'repo' field")
62+
if self.source == "url" and not self.url:
63+
raise ValueError("URL source requires 'url' field")
64+
return self
65+
66+
67+
class MarketplaceEntry(BaseModel):
68+
"""Base class for marketplace entries (plugins and skills).
69+
70+
Both plugins and skills are pointers to directories:
71+
- Plugin directories contain: plugin.json, skills/, commands/, agents/, etc.
72+
- Skill directories contain: SKILL.md and optionally scripts/, references/, assets/
73+
74+
Source is a string path (local path or GitHub URL).
75+
"""
76+
77+
name: str = Field(description="Identifier (kebab-case, no spaces)")
78+
source: str = Field(description="Path to directory (local path or GitHub URL)")
79+
description: str | None = Field(default=None, description="Brief description")
80+
version: str | None = Field(default=None, description="Version")
81+
author: PluginAuthor | None = Field(default=None, description="Author information")
82+
category: str | None = Field(default=None, description="Category for organization")
83+
homepage: str | None = Field(
84+
default=None, description="Homepage or documentation URL"
85+
)
86+
87+
model_config = {"extra": "allow", "populate_by_name": True}
88+
89+
@field_validator("author", mode="before")
90+
@classmethod
91+
def _parse_author(cls, v: Any) -> Any:
92+
if isinstance(v, str):
93+
return PluginAuthor.from_string(v)
94+
return v
95+
96+
97+
class MarketplacePluginEntry(MarketplaceEntry):
98+
"""Plugin entry in a marketplace.
99+
100+
Extends MarketplaceEntry with Claude Code compatibility fields for
101+
inline plugin definitions (when strict=False).
102+
103+
Plugins support both string sources and complex source objects
104+
(MarketplacePluginSource) for GitHub/git URLs with ref and path.
105+
"""
106+
107+
# Override source to allow complex source objects for plugins
108+
source: str | MarketplacePluginSource = Field( # type: ignore[assignment]
109+
description="Path to plugin directory or source object for GitHub/git"
110+
)
111+
112+
# Plugin-specific fields
113+
entry_command: str | None = Field(
114+
default=None,
115+
description=(
116+
"Default command to invoke when launching this plugin. "
117+
"Should match a command name from the commands/ directory."
118+
),
119+
)
120+
121+
# Claude Code compatibility fields
122+
strict: bool = Field(
123+
default=True,
124+
description="If True, plugin source must contain plugin.json. "
125+
"If False, marketplace entry defines the plugin inline.",
126+
)
127+
commands: str | list[str] | None = Field(default=None)
128+
agents: str | list[str] | None = Field(default=None)
129+
hooks: str | HooksConfigDict | None = Field(default=None)
130+
mcp_servers: McpServersDict | None = Field(default=None, alias="mcpServers")
131+
lsp_servers: LspServersDict | None = Field(default=None, alias="lspServers")
132+
133+
# Additional metadata fields
134+
license: str | None = Field(default=None, description="SPDX license identifier")
135+
keywords: list[str] = Field(default_factory=list)
136+
tags: list[str] = Field(default_factory=list)
137+
repository: str | None = Field(
138+
default=None, description="Source code repository URL"
139+
)
140+
141+
@field_validator("source", mode="before")
142+
@classmethod
143+
def _parse_source(cls, v: Any) -> Any:
144+
if isinstance(v, dict):
145+
return MarketplacePluginSource.model_validate(v)
146+
return v
147+
148+
def to_plugin_manifest(self) -> PluginManifest:
149+
"""Convert to PluginManifest (for strict=False entries)."""
150+
return PluginManifest(
151+
name=self.name,
152+
version=self.version or "1.0.0",
153+
description=self.description or "",
154+
author=self.author,
155+
entry_command=self.entry_command,
156+
)
157+
158+
159+
class MarketplaceMetadata(BaseModel):
160+
"""Optional metadata for a marketplace."""
161+
162+
description: str | None = Field(default=None)
163+
version: str | None = Field(default=None)
164+
165+
model_config = {"extra": "allow", "populate_by_name": True}
166+
167+
168+
class Marketplace(BaseModel):
169+
"""A plugin marketplace that lists available plugins and skills.
170+
171+
Follows the Claude Code marketplace structure for compatibility,
172+
with an additional `skills` field for standalone skill references.
173+
174+
The marketplace.json file is located in `.plugin/` or `.claude-plugin/`
175+
directory at the root of the marketplace repository.
176+
177+
Example:
178+
```json
179+
{
180+
"name": "company-tools",
181+
"owner": {"name": "DevTools Team"},
182+
"plugins": [
183+
{"name": "formatter", "source": "./plugins/formatter"}
184+
],
185+
"skills": [
186+
{"name": "github", "source": "./skills/github"}
187+
]
188+
}
189+
```
190+
"""
191+
192+
name: str = Field(
193+
description="Marketplace identifier (kebab-case, no spaces). "
194+
"Users see this when installing plugins: /plugin install tool@<marketplace>"
195+
)
196+
owner: MarketplaceOwner = Field(description="Marketplace maintainer information")
197+
description: str | None = Field(
198+
default=None,
199+
description="Brief marketplace description. Can also be in metadata.",
200+
)
201+
plugins: list[MarketplacePluginEntry] = Field(
202+
default_factory=list, description="List of available plugins"
203+
)
204+
skills: list[MarketplaceEntry] = Field(
205+
default_factory=list, description="List of standalone skills"
206+
)
207+
metadata: MarketplaceMetadata | None = Field(
208+
default=None, description="Optional marketplace metadata"
209+
)
210+
path: str | None = Field(
211+
default=None,
212+
description="Path to the marketplace directory (set after loading)",
213+
)
214+
215+
model_config = {"extra": "allow"}
216+
217+
@classmethod
218+
def load(cls, marketplace_path: str | Path) -> Marketplace:
219+
"""Load a marketplace from a directory.
220+
221+
Looks for marketplace.json in .plugin/ or .claude-plugin/ directories.
222+
223+
Args:
224+
marketplace_path: Path to the marketplace directory.
225+
226+
Returns:
227+
Loaded Marketplace instance.
228+
229+
Raises:
230+
FileNotFoundError: If the marketplace directory or manifest doesn't exist.
231+
ValueError: If the marketplace manifest is invalid.
232+
"""
233+
marketplace_dir = Path(marketplace_path).resolve()
234+
if not marketplace_dir.is_dir():
235+
raise FileNotFoundError(
236+
f"Marketplace directory not found: {marketplace_dir}"
237+
)
238+
239+
# Find manifest file
240+
manifest_path = None
241+
for manifest_dir in MARKETPLACE_MANIFEST_DIRS:
242+
candidate = marketplace_dir / manifest_dir / MARKETPLACE_MANIFEST_FILE
243+
if candidate.exists():
244+
manifest_path = candidate
245+
break
246+
247+
if manifest_path is None:
248+
dirs = " or ".join(MARKETPLACE_MANIFEST_DIRS)
249+
raise FileNotFoundError(
250+
f"Marketplace manifest not found. "
251+
f"Expected {MARKETPLACE_MANIFEST_FILE} in {dirs} "
252+
f"directory under {marketplace_dir}"
253+
)
254+
255+
try:
256+
with open(manifest_path) as f:
257+
data = json.load(f)
258+
except json.JSONDecodeError as e:
259+
raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e
260+
261+
return cls.model_validate({**data, "path": str(marketplace_dir)})
262+
263+
def get_plugin(self, name: str) -> MarketplacePluginEntry | None:
264+
"""Get a plugin entry by name.
265+
266+
Args:
267+
name: Plugin name to look up.
268+
269+
Returns:
270+
MarketplacePluginEntry if found, None otherwise.
271+
"""
272+
for plugin in self.plugins:
273+
if plugin.name == name:
274+
return plugin
275+
return None
276+
277+
def resolve_plugin_source(
278+
self, plugin: MarketplacePluginEntry
279+
) -> tuple[str, str | None, str | None]:
280+
"""Resolve a plugin's source to a full path or URL.
281+
282+
Returns:
283+
Tuple of (source, ref, subpath) where:
284+
- source: Resolved source string (path or URL)
285+
- ref: Branch, tag, or commit reference (None for local paths)
286+
- subpath: Subdirectory path within the repo (None if not specified)
287+
"""
288+
source = plugin.source
289+
290+
# Handle complex source objects (GitHub, git URLs)
291+
if isinstance(source, MarketplacePluginSource):
292+
if source.source == "github" and source.repo:
293+
return (f"github:{source.repo}", source.ref, source.path)
294+
if source.source == "url" and source.url:
295+
return (source.url, source.ref, source.path)
296+
raise ValueError(
297+
f"Invalid plugin source for '{plugin.name}': "
298+
f"source type '{source.source}' is missing required field"
299+
)
300+
301+
# Absolute paths or URLs - return as-is
302+
if source.startswith(("/", "~")) or "://" in source:
303+
return (source, None, None)
304+
305+
# Relative path - resolve against marketplace path if known
306+
if self.path:
307+
source = str(Path(self.path) / source.lstrip("./"))
308+
309+
return (source, None, None)

0 commit comments

Comments
 (0)