diff --git a/openhands_cli/argparsers/main_parser.py b/openhands_cli/argparsers/main_parser.py index 29ebd0f5d..7f52cfe26 100644 --- a/openhands_cli/argparsers/main_parser.py +++ b/openhands_cli/argparsers/main_parser.py @@ -7,6 +7,7 @@ from openhands_cli.argparsers.auth_parser import add_login_parser, add_logout_parser from openhands_cli.argparsers.cloud_parser import add_cloud_parser from openhands_cli.argparsers.mcp_parser import add_mcp_parser +from openhands_cli.argparsers.plugin_parser import add_plugin_parser from openhands_cli.argparsers.serve_parser import add_serve_parser from openhands_cli.argparsers.util import ( add_confirmation_mode_args, @@ -122,6 +123,9 @@ def create_main_parser() -> argparse.ArgumentParser: # Add MCP subcommand add_mcp_parser(subparsers) + # Add plugin subcommand + add_plugin_parser(subparsers) + # Add cloud subcommand add_cloud_parser(subparsers) diff --git a/openhands_cli/argparsers/plugin_parser.py b/openhands_cli/argparsers/plugin_parser.py new file mode 100644 index 000000000..05bc808c7 --- /dev/null +++ b/openhands_cli/argparsers/plugin_parser.py @@ -0,0 +1,239 @@ +"""Argument parser for plugin subcommand.""" + +import argparse +import sys +from typing import NoReturn + + +class PluginArgumentParser(argparse.ArgumentParser): + """Custom ArgumentParser for plugin commands that shows full help on errors.""" + + def error(self, message: str) -> NoReturn: + """Override error method to show full help instead of just usage.""" + self.print_help(sys.stderr) + print(f"\nError: {message}", file=sys.stderr) + sys.exit(2) + + +def add_plugin_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """Add plugin subcommand parser. + + Args: + subparsers: The subparsers object to add the plugin parser to + + Returns: + The plugin argument parser + """ + description = """ +Manage OpenHands plugins and plugin marketplaces. + +Plugins extend OpenHands with additional skills, tools, and capabilities. +You can manage plugin marketplaces (registries) to discover and install plugins. + +Examples: + + # Add a plugin marketplace using GitHub shorthand + openhands plugin marketplace add company/plugins + + # Add with explicit GitHub prefix + openhands plugin marketplace add github:openhands/community-plugins + + # Add a Git repository + openhands plugin marketplace add https://gitlab.com/org/plugins.git + + # Add a direct URL to marketplace index + openhands plugin marketplace add https://plugins.example.com/marketplace.json + + # Add with a custom name + openhands plugin marketplace add company/plugins --name my-plugins + + # List configured marketplaces + openhands plugin marketplace list + + # Remove a marketplace by name + openhands plugin marketplace remove company-plugins + + # Update all marketplace indexes + openhands plugin marketplace update + + # Update a specific marketplace + openhands plugin marketplace update company-plugins +""" + plugin_parser = subparsers.add_parser( + "plugin", + help="Manage plugins and plugin marketplaces", + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + plugin_subparsers = plugin_parser.add_subparsers( + dest="plugin_command", + help="Plugin commands", + required=True, + parser_class=PluginArgumentParser, + ) + + # Add marketplace subcommand + _add_marketplace_parser(plugin_subparsers) + + return plugin_parser + + +def _add_marketplace_parser( + plugin_subparsers: argparse._SubParsersAction, +) -> argparse.ArgumentParser: + """Add marketplace subcommand parser. + + Args: + plugin_subparsers: The plugin subparsers object + + Returns: + The marketplace argument parser + """ + marketplace_description = """ +Manage plugin marketplaces (registries). + +Marketplaces provide an index of available plugins. Supported source formats: + - GitHub shorthand: owner/repo + - Explicit GitHub: github:owner/repo + - Git URL: https://gitlab.com/org/plugins.git + - Direct URL: https://example.com/marketplace.json + +Examples: + + # Add a marketplace using GitHub shorthand + openhands plugin marketplace add company/plugins + + # Add with a custom name + openhands plugin marketplace add company/plugins --name my-plugins + + # List all marketplaces + openhands plugin marketplace list + + # Remove a marketplace by name + openhands plugin marketplace remove company-plugins + + # Update all marketplace indexes + openhands plugin marketplace update + + # Update a specific marketplace + openhands plugin marketplace update company-plugins +""" + marketplace_parser = plugin_subparsers.add_parser( + "marketplace", + help="Manage plugin marketplaces", + description=marketplace_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + marketplace_subparsers = marketplace_parser.add_subparsers( + dest="marketplace_command", + help="Marketplace commands", + required=True, + parser_class=PluginArgumentParser, + ) + + # marketplace add command + add_description = """ +Add a new plugin marketplace. + +Supported source formats: + - GitHub shorthand: owner/repo + - Explicit GitHub: github:owner/repo + - Git URL: https://gitlab.com/org/plugins.git + - Direct URL: https://example.com/marketplace.json + +Examples: + + # Add using GitHub shorthand + openhands plugin marketplace add company/plugins + + # Add with explicit GitHub prefix + openhands plugin marketplace add github:openhands/community-plugins + + # Add a Git repository + openhands plugin marketplace add https://gitlab.com/org/plugins.git + + # Add with a custom name + openhands plugin marketplace add company/plugins --name my-plugins +""" + add_parser = marketplace_subparsers.add_parser( + "add", + help="Add a plugin marketplace", + description=add_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + add_parser.add_argument( + "source", + help="Marketplace source (GitHub: owner/repo, Git URL, or direct URL)", + ) + add_parser.add_argument( + "--name", + "-n", + help="Custom name for the marketplace (auto-generated if not provided)", + ) + + # marketplace remove command + remove_description = """ +Remove a plugin marketplace. + +Examples: + + # Remove a marketplace by name + openhands plugin marketplace remove company-plugins +""" + remove_parser = marketplace_subparsers.add_parser( + "remove", + help="Remove a plugin marketplace", + description=remove_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + remove_parser.add_argument( + "name", + help="Name of the marketplace to remove", + ) + + # marketplace list command + list_description = """ +List all configured plugin marketplaces. + +Displays configured marketplaces with their source, auto-update setting, +and timestamps. + +Examples: + + # List all marketplaces + openhands plugin marketplace list +""" + marketplace_subparsers.add_parser( + "list", + help="List all configured marketplaces", + description=list_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # marketplace update command + update_description = """ +Update marketplace indexes. + +This refreshes the local cache of available plugins from configured marketplaces. + +Examples: + + # Update all marketplace indexes + openhands plugin marketplace update + + # Update a specific marketplace + openhands plugin marketplace update company-plugins +""" + update_parser = marketplace_subparsers.add_parser( + "update", + help="Update marketplace indexes", + description=update_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + update_parser.add_argument( + "name", + nargs="?", + help="Name of specific marketplace to update (optional, updates all if not specified)", + ) + + return marketplace_parser diff --git a/openhands_cli/entrypoint.py b/openhands_cli/entrypoint.py index 1f7c52fe1..74e526cee 100644 --- a/openhands_cli/entrypoint.py +++ b/openhands_cli/entrypoint.py @@ -168,6 +168,11 @@ def main() -> None: from openhands_cli.mcp.mcp_commands import handle_mcp_command handle_mcp_command(args) + elif args.command == "plugin": + # Import plugin command handler only when needed + from openhands_cli.plugins.marketplace_commands import handle_plugin_command + + handle_plugin_command(args) elif args.command == "cloud": # Validate cloud mode requirements if not args.task and not args.file: diff --git a/openhands_cli/locations.py b/openhands_cli/locations.py index 7e76d28aa..0fe1651c1 100644 --- a/openhands_cli/locations.py +++ b/openhands_cli/locations.py @@ -12,3 +12,6 @@ # MCP configuration file (relative to PERSISTENCE_DIR) MCP_CONFIG_FILE = "mcp.json" + +# Plugin marketplaces configuration file +MARKETPLACES_FILE = os.path.join(PERSISTENCE_DIR, "marketplaces.json") diff --git a/openhands_cli/plugins/__init__.py b/openhands_cli/plugins/__init__.py new file mode 100644 index 000000000..abd332cd6 --- /dev/null +++ b/openhands_cli/plugins/__init__.py @@ -0,0 +1 @@ +"""Plugin management module for OpenHands CLI.""" diff --git a/openhands_cli/plugins/marketplace_commands.py b/openhands_cli/plugins/marketplace_commands.py new file mode 100644 index 000000000..7c2538607 --- /dev/null +++ b/openhands_cli/plugins/marketplace_commands.py @@ -0,0 +1,236 @@ +"""Marketplace command handlers for the CLI interface. + +This module provides command handlers for managing plugin marketplace configurations +through the command line interface. +""" + +import argparse + +from rich.console import Console +from rich.table import Table + +from openhands_cli.plugins.marketplace_storage import ( + MarketplaceError, + MarketplaceStorage, +) +from openhands_cli.theme import OPENHANDS_THEME + + +console = Console() + + +def handle_marketplace_add(args: argparse.Namespace) -> None: + """Handle the 'plugin marketplace add' command. + + Args: + args: Parsed command line arguments + """ + storage = MarketplaceStorage() + try: + name = getattr(args, "name", None) + marketplace = storage.add_marketplace(source_str=args.source, name=name) + console.print( + f"Added marketplace '{marketplace.name}'", + style=OPENHANDS_THEME.success, + ) + console.print(f" Source: {marketplace.source}", style=OPENHANDS_THEME.secondary) + except MarketplaceError as e: + console.print(f"Error: {e}", style=OPENHANDS_THEME.error) + raise SystemExit(1) + + +def handle_marketplace_remove(args: argparse.Namespace) -> None: + """Handle the 'plugin marketplace remove' command. + + Args: + args: Parsed command line arguments + """ + storage = MarketplaceStorage() + try: + storage.remove_marketplace(name=args.name) + console.print( + f"Removed marketplace '{args.name}'", + style=OPENHANDS_THEME.success, + ) + except MarketplaceError as e: + console.print(f"Error: {e}", style=OPENHANDS_THEME.error) + raise SystemExit(1) + + +def handle_marketplace_list(_args: argparse.Namespace) -> None: + """Handle the 'plugin marketplace list' command. + + Args: + _args: Parsed command line arguments (unused) + """ + storage = MarketplaceStorage() + try: + marketplaces = storage.list_marketplaces() + + if not marketplaces: + console.print( + "No plugin marketplaces configured.", style=OPENHANDS_THEME.warning + ) + console.print( + "Use [bold]openhands plugin marketplace add [/bold] " + "to add a marketplace.", + style=OPENHANDS_THEME.accent, + ) + return + + # Create a table for display + table = Table(title="Configured Plugin Marketplaces") + table.add_column("Name", style="green", no_wrap=True) + table.add_column("Source", style="cyan") + table.add_column("Plugins", style="magenta", justify="right") + table.add_column("Auto Update", style="dim") + table.add_column("Added", style="dim") + table.add_column("Last Updated", style="dim") + + total_plugins = 0 + + for m in marketplaces: + # Format timestamps for display + added = m.added_at[:10] if m.added_at else "-" + updated = m.last_updated[:10] if m.last_updated else "-" + auto_update = "Yes" if m.auto_update else "No" + + # Get plugin count from cached index + cached_index = storage.get_cached_index(m.name) + if cached_index: + plugin_count = len(cached_index.get("plugins", [])) + total_plugins += plugin_count + plugins_str = str(plugin_count) + else: + plugins_str = "-" + + table.add_row( + m.name, + str(m.source), + plugins_str, + auto_update, + added, + updated, + ) + + console.print(table) + console.print( + f"\n{len(marketplaces)} marketplace(s) configured. " + f"Plugins available: {total_plugins}", + style=OPENHANDS_THEME.secondary, + ) + + except MarketplaceError as e: + console.print(f"Error: {e}", style=OPENHANDS_THEME.error) + raise SystemExit(1) + + +def handle_marketplace_update(args: argparse.Namespace) -> None: + """Handle the 'plugin marketplace update' command. + + Args: + args: Parsed command line arguments + """ + storage = MarketplaceStorage() + try: + marketplaces = storage.list_marketplaces() + + if not marketplaces: + console.print( + "No plugin marketplaces configured.", style=OPENHANDS_THEME.warning + ) + return + + # Filter by name if specified + name_filter = getattr(args, "name", None) + if name_filter: + marketplaces = [m for m in marketplaces if m.name == name_filter] + if not marketplaces: + console.print( + f"Marketplace not found: {name_filter}", + style=OPENHANDS_THEME.error, + ) + raise SystemExit(1) + + # Update each marketplace + updated_count = 0 + failed_count = 0 + for marketplace in marketplaces: + console.print( + f"Updating marketplace '{marketplace.name}'...", + style=OPENHANDS_THEME.foreground, + ) + try: + # Fetch and cache the marketplace index + index_data = storage.update_marketplace(marketplace.name) + plugin_count = len(index_data.get("plugins", [])) + console.print( + f" Updated: {marketplace.name} ({plugin_count} plugins)", + style=OPENHANDS_THEME.success, + ) + updated_count += 1 + except MarketplaceError as e: + console.print( + f" Failed: {e}", + style=OPENHANDS_THEME.error, + ) + failed_count += 1 + + if updated_count > 0: + console.print( + f"Successfully updated {updated_count} marketplace(s).", + style=OPENHANDS_THEME.success, + ) + if failed_count > 0: + console.print( + f"Failed to update {failed_count} marketplace(s).", + style=OPENHANDS_THEME.warning, + ) + if updated_count == 0: + raise SystemExit(1) + + except MarketplaceError as e: + console.print(f"Error: {e}", style=OPENHANDS_THEME.error) + raise SystemExit(1) + + +def handle_marketplace_command(args: argparse.Namespace) -> None: + """Main handler for marketplace subcommands. + + Args: + args: Parsed command line arguments + """ + marketplace_cmd = getattr(args, "marketplace_command", None) + + if marketplace_cmd == "add": + handle_marketplace_add(args) + elif marketplace_cmd == "remove": + handle_marketplace_remove(args) + elif marketplace_cmd == "list": + handle_marketplace_list(args) + elif marketplace_cmd == "update": + handle_marketplace_update(args) + else: + console.print( + "Unknown marketplace command. Use --help for usage.", + style=OPENHANDS_THEME.error, + ) + raise SystemExit(1) + + +def handle_plugin_command(args: argparse.Namespace) -> None: + """Main handler for plugin commands. + + Args: + args: Parsed command line arguments + """ + plugin_cmd = getattr(args, "plugin_command", None) + + if plugin_cmd == "marketplace": + handle_marketplace_command(args) + else: + console.print( + "Unknown plugin command. Use --help for usage.", + style=OPENHANDS_THEME.error, + ) + raise SystemExit(1) diff --git a/openhands_cli/plugins/marketplace_storage.py b/openhands_cli/plugins/marketplace_storage.py new file mode 100644 index 000000000..3de1a1faa --- /dev/null +++ b/openhands_cli/plugins/marketplace_storage.py @@ -0,0 +1,398 @@ +"""Storage module for plugin marketplace configurations. + +This module handles persistence of marketplaces to ~/.openhands/marketplaces.json +""" + +import json +import os +import re +import urllib.request +import urllib.error +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from openhands_cli.locations import MARKETPLACES_FILE, PERSISTENCE_DIR + +# Directory for caching marketplace indexes +MARKETPLACE_CACHE_DIR = os.path.join(PERSISTENCE_DIR, "marketplace_cache") + + +class MarketplaceError(Exception): + """Exception raised for marketplace-related errors.""" + + pass + + +@dataclass +class MarketplaceSource: + """Represents a marketplace source configuration.""" + + source_type: str # "github", "git", "url" + repo: str | None = None # For github: "owner/repo" + url: str | None = None # For git/url types + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result: dict[str, Any] = {"source": self.source_type} + if self.repo: + result["repo"] = self.repo + if self.url: + result["url"] = self.url + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "MarketplaceSource": + """Create from dictionary.""" + return cls( + source_type=data.get("source", "url"), + repo=data.get("repo"), + url=data.get("url"), + ) + + def __str__(self) -> str: + """Return string representation.""" + if self.source_type == "github" and self.repo: + return f"github:{self.repo}" + elif self.url: + return self.url + return f"{self.source_type}:{self.repo or self.url}" + + def get_fetch_url(self) -> str: + """Get the URL to fetch the marketplace index from. + + Returns: + URL string for fetching the marketplace index. + + Raises: + MarketplaceError: If the source type doesn't support fetching. + """ + if self.source_type == "url" and self.url: + return self.url + elif self.source_type == "github" and self.repo: + # Fetch from GitHub raw content (assumes marketplace.json at repo root) + return f"https://raw.githubusercontent.com/{self.repo}/main/marketplace.json" + elif self.source_type == "git" and self.url: + raise MarketplaceError( + f"Git repositories require cloning. Use GitHub or direct URL instead." + ) + else: + raise MarketplaceError(f"Cannot fetch from source: {self}") + + +@dataclass +class Marketplace: + """Represents a plugin marketplace configuration.""" + + name: str + source: MarketplaceSource + added_at: str = field(default_factory=lambda: datetime.now().isoformat()) + last_updated: str | None = None + auto_update: bool = True + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "source": self.source.to_dict(), + "added_at": self.added_at, + "last_updated": self.last_updated, + "auto_update": self.auto_update, + } + + @classmethod + def from_dict(cls, name: str, data: dict[str, Any]) -> "Marketplace": + """Create from dictionary.""" + return cls( + name=name, + source=MarketplaceSource.from_dict(data.get("source", {})), + added_at=data.get("added_at", datetime.now().isoformat()), + last_updated=data.get("last_updated"), + auto_update=data.get("auto_update", True), + ) + + +def parse_source(source_str: str) -> tuple[str, MarketplaceSource]: + """Parse a source string into a name and MarketplaceSource. + + Supported formats: + - owner/repo -> github shorthand + - github:owner/repo -> explicit github + - https://gitlab.com/org/plugins.git -> git URL + - https://example.com/marketplace.json -> direct URL + + Args: + source_str: The source string to parse. + + Returns: + Tuple of (generated_name, MarketplaceSource) + """ + # GitHub explicit: github:owner/repo + if source_str.startswith("github:"): + repo = source_str[7:] # Remove "github:" prefix + name = repo.replace("/", "-") + return name, MarketplaceSource(source_type="github", repo=repo) + + # Git URL: ends with .git + if source_str.endswith(".git"): + # Extract name from URL + match = re.search(r"/([^/]+)\.git$", source_str) + name = match.group(1) if match else "unknown" + return name, MarketplaceSource(source_type="git", url=source_str) + + # Direct URL: starts with http:// or https:// + if source_str.startswith("http://") or source_str.startswith("https://"): + # Extract name from URL path + match = re.search(r"/([^/]+?)(?:\.json)?$", source_str) + name = match.group(1) if match else "unknown" + return name, MarketplaceSource(source_type="url", url=source_str) + + # GitHub shorthand: owner/repo (no protocol, contains single /) + if "/" in source_str and not source_str.startswith("/"): + parts = source_str.split("/") + if len(parts) == 2: + name = source_str.replace("/", "-") + return name, MarketplaceSource(source_type="github", repo=source_str) + + # Fallback: treat as URL + return source_str, MarketplaceSource(source_type="url", url=source_str) + + +class MarketplaceStorage: + """Handles storage and retrieval of marketplace configurations.""" + + def __init__(self, config_path: str | None = None): + """Initialize marketplace storage. + + Args: + config_path: Optional path to config file. Defaults to MARKETPLACES_FILE. + """ + self.config_path = config_path or MARKETPLACES_FILE + + def _ensure_config_dir(self) -> None: + """Ensure the configuration directory exists.""" + os.makedirs(PERSISTENCE_DIR, exist_ok=True) + + def _load_config(self) -> dict[str, Any]: + """Load the marketplace configuration from file. + + Returns: + Dictionary containing marketplace configurations. + """ + if not os.path.exists(self.config_path): + return {"marketplaces": {}} + + try: + with open(self.config_path, encoding="utf-8") as f: + content = f.read().strip() + if not content: + return {"marketplaces": {}} + data = json.loads(content) + # Ensure marketplaces key exists + if "marketplaces" not in data: + data["marketplaces"] = {} + return data + except json.JSONDecodeError as e: + raise MarketplaceError(f"Invalid JSON in config file: {e}") + except OSError as e: + raise MarketplaceError(f"Failed to read config file: {e}") + + def _save_config(self, config: dict[str, Any]) -> None: + """Save the marketplace configuration to file. + + Args: + config: Configuration dictionary to save. + """ + self._ensure_config_dir() + try: + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except OSError as e: + raise MarketplaceError(f"Failed to save config file: {e}") + + def list_marketplaces(self) -> list[Marketplace]: + """List all configured marketplaces. + + Returns: + List of Marketplace objects. + """ + config = self._load_config() + marketplaces_dict = config.get("marketplaces", {}) + return [ + Marketplace.from_dict(name, data) + for name, data in marketplaces_dict.items() + ] + + def add_marketplace( + self, source_str: str, name: str | None = None + ) -> Marketplace: + """Add a new marketplace. + + Args: + source_str: Source string (GitHub shorthand, Git URL, or direct URL). + name: Optional custom name for the marketplace. + + Returns: + The created Marketplace object. + + Raises: + MarketplaceError: If the marketplace already exists. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", {}) + + # Parse source string + generated_name, source = parse_source(source_str) + marketplace_name = name or generated_name + + # Check if marketplace already exists + if marketplace_name in marketplaces: + raise MarketplaceError(f"Marketplace already exists: {marketplace_name}") + + # Create new marketplace + marketplace = Marketplace(name=marketplace_name, source=source) + marketplaces[marketplace_name] = marketplace.to_dict() + config["marketplaces"] = marketplaces + + self._save_config(config) + return marketplace + + def remove_marketplace(self, name: str) -> None: + """Remove a marketplace by name. + + Args: + name: Name of the marketplace to remove. + + Raises: + MarketplaceError: If the marketplace is not found. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", {}) + + if name not in marketplaces: + raise MarketplaceError(f"Marketplace not found: {name}") + + del marketplaces[name] + config["marketplaces"] = marketplaces + self._save_config(config) + + def get_marketplace(self, name: str) -> Marketplace | None: + """Get a marketplace by name. + + Args: + name: Name of the marketplace. + + Returns: + Marketplace object if found, None otherwise. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", {}) + + if name in marketplaces: + return Marketplace.from_dict(name, marketplaces[name]) + return None + + def update_marketplace(self, name: str) -> dict[str, Any]: + """Fetch and update the marketplace index. + + Re-fetches the marketplace index from the source and updates + the cached copy and metadata. + + Args: + name: Name of the marketplace to update. + + Returns: + The fetched marketplace index data. + + Raises: + MarketplaceError: If the marketplace is not found or fetch fails. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", {}) + + if name not in marketplaces: + raise MarketplaceError(f"Marketplace not found: {name}") + + marketplace = Marketplace.from_dict(name, marketplaces[name]) + + # Fetch the marketplace index + index_data = self._fetch_marketplace_index(marketplace) + + # Cache the fetched index + self._cache_marketplace_index(name, index_data) + + # Update the last_updated timestamp + marketplaces[name]["last_updated"] = datetime.now().isoformat() + config["marketplaces"] = marketplaces + self._save_config(config) + + return index_data + + def _fetch_marketplace_index(self, marketplace: Marketplace) -> dict[str, Any]: + """Fetch the marketplace index from the source. + + Args: + marketplace: The marketplace to fetch the index for. + + Returns: + The parsed marketplace index data. + + Raises: + MarketplaceError: If the fetch fails. + """ + try: + fetch_url = marketplace.source.get_fetch_url() + except MarketplaceError: + raise + + try: + request = urllib.request.Request( + fetch_url, + headers={"User-Agent": "OpenHands-CLI/1.0"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + content = response.read().decode("utf-8") + return json.loads(content) + except urllib.error.HTTPError as e: + raise MarketplaceError( + f"Failed to fetch marketplace index: HTTP {e.code} - {e.reason}" + ) + except urllib.error.URLError as e: + raise MarketplaceError(f"Failed to connect to marketplace: {e.reason}") + except json.JSONDecodeError as e: + raise MarketplaceError(f"Invalid JSON in marketplace index: {e}") + except TimeoutError: + raise MarketplaceError("Timeout while fetching marketplace index") + + def _cache_marketplace_index(self, name: str, index_data: dict[str, Any]) -> None: + """Cache the marketplace index to disk. + + Args: + name: Name of the marketplace. + index_data: The marketplace index data to cache. + """ + os.makedirs(MARKETPLACE_CACHE_DIR, exist_ok=True) + cache_path = os.path.join(MARKETPLACE_CACHE_DIR, f"{name}.json") + try: + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(index_data, f, indent=2) + except OSError as e: + raise MarketplaceError(f"Failed to cache marketplace index: {e}") + + def get_cached_index(self, name: str) -> dict[str, Any] | None: + """Get the cached marketplace index. + + Args: + name: Name of the marketplace. + + Returns: + The cached index data, or None if not cached. + """ + cache_path = os.path.join(MARKETPLACE_CACHE_DIR, f"{name}.json") + if not os.path.exists(cache_path): + return None + + try: + with open(cache_path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None